SPB Git

spb/phi-conseil

Public
8.2 KB · 276 lines tsx
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
Files · 986334f6f6688676835eaf88399856804855ca6d
Menu

Theme