Contact
FormsReactTailwindMulti-field contact form with inline validation, topic select, accessible error messages, and success/error states. Mocked submit with 10% failure rate.
Contact Form
Try submitting with empty fields to see inline validation. Submission is mocked (1.2s delay, 10% failure rate).
1 "use client"; 2 3 import { useState } from "react"; 4 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 5 import { 6 faCheck, 7 faCircleExclamation, 8 faPaperPlane, 9 faSpinner, 10 } from "@fortawesome/free-solid-svg-icons"; 11 import { Input } from "@/shared/ui/input"; 12 import { Label } from "@/shared/ui/label"; 13 import { Button } from "@/shared/ui/button"; 14 15 type FormState = { name: string; email: string; subject: string; message: string }; 16 type Errors = Partial<Record<keyof FormState, string>>; 17 type Status = "idle" | "submitting" | "success" | "error"; 18 19 const subjects = [ 20 { value: "", label: "Select a topic" }, 21 { value: "sales", label: "Sales inquiry" }, 22 { value: "support", label: "Support request" }, 23 { value: "partnership", label: "Partnership" }, 24 { value: "feedback", label: "Product feedback" }, 25 { value: "other", label: "Other" }, 26 ]; 27 28 function validate(form: FormState): Errors { 29 const errors: Errors = {}; 30 if (!form.name.trim()) errors.name = "Name is required"; 31 if (!form.email.trim()) errors.email = "Email is required"; 32 else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = "Invalid email"; 33 if (!form.subject) errors.subject = "Pick a topic"; 34 if (!form.message.trim()) errors.message = "Message is required"; 35 else if (form.message.trim().length < 10) errors.message = "Message too short"; 36 return errors; 37 } 38 39 export function ContactForm() { 40 const [form, setForm] = useState<FormState>({ name: "", email: "", subject: "", message: "" }); 41 const [errors, setErrors] = useState<Errors>({}); 42 const [status, setStatus] = useState<Status>("idle"); 43 44 async function handleSubmit(e: React.FormEvent) { 45 e.preventDefault(); 46 const errs = validate(form); 47 if (Object.keys(errs).length > 0) { setErrors(errs); return; } 48 setStatus("submitting"); 49 const res = await fetch("/api/contact", { 50 method: "POST", 51 headers: { "Content-Type": "application/json" }, 52 body: JSON.stringify(form), 53 }); 54 setStatus(res.ok ? "success" : "error"); 55 if (res.ok) setForm({ name: "", email: "", subject: "", message: "" }); 56 } 57 58 return ( 59 <form onSubmit={handleSubmit} className="bg-card space-y-4 rounded-lg border p-5" noValidate> 60 <div className="grid gap-4 md:grid-cols-2"> 61 <div className="space-y-1.5"> 62 <Label htmlFor="name">Name</Label> 63 <Input 64 id="name" 65 value={form.name} 66 onChange={(e) => setForm({ ...form, name: e.target.value })} 67 aria-invalid={!!errors.name} 68 /> 69 {errors.name && <p className="text-destructive text-xs">{errors.name}</p>} 70 </div> 71 <div className="space-y-1.5"> 72 <Label htmlFor="email">Email</Label> 73 <Input 74 id="email" 75 type="email" 76 value={form.email} 77 onChange={(e) => setForm({ ...form, email: e.target.value })} 78 aria-invalid={!!errors.email} 79 /> 80 {errors.email && <p className="text-destructive text-xs">{errors.email}</p>} 81 </div> 82 </div> 83 84 <div className="space-y-1.5"> 85 <Label htmlFor="subject">Topic</Label> 86 <select 87 id="subject" 88 value={form.subject} 89 onChange={(e) => setForm({ ...form, subject: e.target.value })} 90 aria-invalid={!!errors.subject} 91 className="border-input bg-background h-9 w-full rounded-md border px-3 text-sm" 92 > 93 {subjects.map((s) => ( 94 <option key={s.value} value={s.value}>{s.label}</option> 95 ))} 96 </select> 97 {errors.subject && <p className="text-destructive text-xs">{errors.subject}</p>} 98 </div> 99 100 <div className="space-y-1.5"> 101 <Label htmlFor="message">Message</Label> 102 <textarea 103 id="message" 104 value={form.message} 105 onChange={(e) => setForm({ ...form, message: e.target.value })} 106 rows={5} 107 aria-invalid={!!errors.message} 108 className="border-input bg-background min-h-[100px] w-full rounded-md border px-3 py-2 text-sm" 109 /> 110 {errors.message && <p className="text-destructive text-xs">{errors.message}</p>} 111 </div> 112 113 {status === "success" && ( 114 <div className="text-primary flex items-center gap-2 rounded-md border border-current/30 bg-current/5 px-3 py-2 text-sm"> 115 <FontAwesomeIcon icon={faCheck} className="h-3.5 w-3.5" /> 116 <span className="text-foreground">Message sent. We'll reply within 24 hours.</span> 117 </div> 118 )} 119 {status === "error" && ( 120 <div className="text-destructive border-destructive/30 bg-destructive/5 flex items-center gap-2 rounded-md border px-3 py-2 text-sm"> 121 <FontAwesomeIcon icon={faCircleExclamation} className="h-3.5 w-3.5" /> 122 <span>Failed to send. Try again or email us directly.</span> 123 </div> 124 )} 125 126 <div className="flex justify-end gap-3"> 127 <Button 128 type="reset" 129 variant="ghost" 130 size="sm" 131 onClick={() => { 132 setForm({ name: "", email: "", subject: "", message: "" }); 133 setErrors({}); 134 setStatus("idle"); 135 }} 136 > 137 Clear 138 </Button> 139 <Button type="submit" size="sm" disabled={status === "submitting"}> 140 {status === "submitting" ? ( 141 <> 142 <FontAwesomeIcon icon={faSpinner} className="h-3.5 w-3.5 animate-spin" /> 143 Sending… 144 </> 145 ) : ( 146 <> 147 <FontAwesomeIcon icon={faPaperPlane} className="h-3.5 w-3.5" /> 148 Send message 149 </> 150 )} 151 </Button> 152 </div> 153 </form> 154 ); 155 } 156 157 // Server-side API route (recommend pairing with rate-limit + spam protection): 158 // app/api/contact/route.ts 159 export async function POST(req: Request) { 160 const data = await req.json(); 161 const errors = validate(data); 162 if (Object.keys(errors).length) return Response.json({ errors }, { status: 400 }); 163 // send email via Resend / SES / SMTP, or queue to backend 164 return Response.json({ ok: true }); 165 }