spb/phi-conseil
Public
1"use client";23import { useEffect, useId, useRef, useState } from "react";4import { useForm } from "react-hook-form";5import { zodResolver } from "@hookform/resolvers/zod";6import { contactFormSchema, type ContactFormValues } from "@/lib/validation";7import { Button } from "@/components/ui/Button";89type Status =10 | { kind: "idle" }11 | { kind: "submitting" }12 | { kind: "success"; delivered: boolean }13 | { kind: "error"; message: string };1415export function ContactForm({16 subjects,17 consentLabel,18 successTitle,19 successMessage,20}: {21 subjects: string[];22 consentLabel: string;23 successTitle: string;24 successMessage: string;25}) {26 const id = useId();27 const startedAt = useRef<number>(0);28 useEffect(() => {29 startedAt.current = Date.now();30 }, []);31 const [status, setStatus] = useState<Status>({ kind: "idle" });32 const {33 register,34 handleSubmit,35 reset,36 formState: { errors, isSubmitting },37 } = useForm<ContactFormValues>({38 resolver: zodResolver(contactFormSchema),39 defaultValues: {40 name: "",41 email: "",42 organization: "",43 subject: "",44 message: "",45 consent: undefined,46 },47 });4849 const submit = async (values: ContactFormValues) => {50 setStatus({ kind: "submitting" });51 try {52 const form = document.getElementById(`${id}-form`) as HTMLFormElement | null;53 const honeypot =54 (form?.elements.namedItem("website") as HTMLInputElement | null)?.value ?? "";55 const res = await fetch("/api/contact", {56 method: "POST",57 headers: { "Content-Type": "application/json", Accept: "application/json" },58 body: JSON.stringify({ ...values, website: honeypot, startedAt: startedAt.current }),59 });60 const data = (await res.json().catch(() => ({}))) as {61 ok?: boolean;62 delivered?: boolean;63 error?: string;64 };65 if (!res.ok || !data.ok) {66 setStatus({67 kind: "error",68 message:69 res.status === 42970 ? "Trop de tentatives. Veuillez réessayer dans quelques minutes."71 : (data.error ?? "L’envoi a échoué. Veuillez réessayer ou nous écrire directement."),72 });73 return;74 }75 setStatus({ kind: "success", delivered: data.delivered !== false });76 reset();77 } catch {78 setStatus({79 kind: "error",80 message: "Connexion impossible. Vérifiez votre réseau puis réessayez.",81 });82 }83 };8485 if (status.kind === "success") {86 return (87 <div88 role="status"89 aria-live="polite"90 className="border-success/30 rounded-[var(--radius-lg)] border bg-white/70 p-8"91 >92 <h2 className="text-h4 text-success">{successTitle}</h2>93 <p className="text-ink-700 mt-3">{successMessage}</p>94 {!status.delivered ? (95 <p className="text-ink-500 mt-3 text-sm">96 Note : le service de messagerie n’est pas encore configuré sur cet environnement ; le97 message a été reçu mais pas relayé par courriel.98 </p>99 ) : null}100 <div className="mt-6">101 <Button variant="outline" arrow={false} onClick={() => setStatus({ kind: "idle" })}>102 Envoyer un autre message103 </Button>104 </div>105 </div>106 );107 }108109 const field = (name: keyof ContactFormValues) => ({110 "aria-invalid": errors[name] ? true : undefined,111 "aria-describedby": errors[name] ? `${id}-${name}-error` : undefined,112 });113 const err = (name: keyof ContactFormValues) =>114 errors[name] ? (115 <p id={`${id}-${name}-error`} role="alert" className="text-danger mt-1.5 text-sm">116 {errors[name]?.message as string}117 </p>118 ) : null;119120 const input =121 "mt-1.5 w-full rounded-md border border-pale-300 bg-white px-3.5 py-2.5 text-ink-900 placeholder:text-ink-300 focus:border-slate-500 aria-[invalid]:border-danger";122 const label = "block text-sm font-medium text-ink-900";123124 return (125 <form126 id={`${id}-form`}127 onSubmit={(e) => void handleSubmit(submit)(e)}128 noValidate129 className="flex flex-col gap-5"130 aria-describedby={status.kind === "error" ? `${id}-status` : undefined}131 >132 <div className="grid gap-5 sm:grid-cols-2">133 <div>134 <label htmlFor={`${id}-name`} className={label}>135 Nom <span aria-hidden="true">*</span>136 </label>137 <input138 id={`${id}-name`}139 type="text"140 autoComplete="name"141 required142 maxLength={120}143 className={input}144 {...register("name")}145 {...field("name")}146 />147 {err("name")}148 </div>149 <div>150 <label htmlFor={`${id}-email`} className={label}>151 Courriel <span aria-hidden="true">*</span>152 </label>153 <input154 id={`${id}-email`}155 type="email"156 autoComplete="email"157 required158 maxLength={254}159 className={input}160 {...register("email")}161 {...field("email")}162 />163 {err("email")}164 </div>165 </div>166 <div className="grid gap-5 sm:grid-cols-2">167 <div>168 <label htmlFor={`${id}-organization`} className={label}>169 Organisation <span className="text-ink-500 font-normal">(facultatif)</span>170 </label>171 <input172 id={`${id}-organization`}173 type="text"174 autoComplete="organization"175 maxLength={160}176 className={input}177 {...register("organization")}178 {...field("organization")}179 />180 {err("organization")}181 </div>182 <div>183 <label htmlFor={`${id}-subject`} className={label}>184 Sujet <span aria-hidden="true">*</span>185 </label>186 <select187 id={`${id}-subject`}188 required189 className={input}190 defaultValue=""191 {...register("subject")}192 {...field("subject")}193 >194 <option value="" disabled>195 Choisir un sujet…196 </option>197 {subjects.map((s) => (198 <option key={s} value={s}>199 {s}200 </option>201 ))}202 </select>203 {err("subject")}204 </div>205 </div>206 <div>207 <label htmlFor={`${id}-message`} className={label}>208 Message <span aria-hidden="true">*</span>209 </label>210 <textarea211 id={`${id}-message`}212 rows={6}213 required214 minLength={20}215 maxLength={4000}216 className={input}217 {...register("message")}218 {...field("message")}219 />220 {err("message")}221 </div>222223 {/* Honeypot : invisible et hors tabulation ; doit rester vide. */}224 <div className="absolute -left-[9999px] h-0 w-0 overflow-hidden" aria-hidden="true">225 <label htmlFor={`${id}-website`}>Site web</label>226 <input227 id={`${id}-website`}228 type="text"229 name="website"230 tabIndex={-1}231 autoComplete="off"232 defaultValue=""233 />234 </div>235236 <div className="flex items-start gap-3">237 <input238 id={`${id}-consent`}239 type="checkbox"240 className="mt-1 h-4 w-4 accent-slate-500"241 {...register("consent")}242 {...field("consent")}243 />244 <label htmlFor={`${id}-consent`} className="text-ink-700 text-sm">245 {consentLabel} <span aria-hidden="true">*</span>246 </label>247 </div>248 {err("consent")}249250 {status.kind === "error" ? (251 <p252 id={`${id}-status`}253 role="alert"254 className="border-danger/40 bg-danger/5 text-danger rounded-md border px-4 py-3 text-sm"255 >256 {status.message}257 </p>258 ) : null}259260 <div>261 <Button262 type="submit"263 size="lg"264 disabled={isSubmitting || status.kind === "submitting"}265 aria-busy={status.kind === "submitting"}266 >267 {status.kind === "submitting" ? "Envoi en cours…" : "Envoyer le message"}268 </Button>269 <p className="text-ink-500 mt-3 text-xs">270 <span aria-hidden="true">* </span>Champs obligatoires.271 </p>272 </div>273 </form>274 );275}276