Repository Public
1"use client";2import { startTransition, useCallback, useEffect, useRef, useState, ViewTransition } from "react";3import { useLocale, useT } from "@/i18n";4import { publicDict } from "@/i18n/public";5import { api, ApiClientError } from "@/lib/client-api";6import { Button, cx, errorText, Field, Icon, Notice, type IconName } from "@/components/ui";7import { fmtDay, fmtRange, tzAbbr, tzCity } from "@/lib/format";8import { SlotPicker, useVisitorTz, type SlotData } from "./slot-picker";9import type { PublicType } from "@/lib/api/public";1011export interface OwnerInfo { name: string; timezone: string; hasAvatar: boolean; emailEnabled: boolean }12type Result = { status: "pending" | "approved"; manageToken: string; startsAt: string; endsAt: string };1314export const LOCATION_ICON: Record<string, IconName> = { video: "video", phone: "phone", in_person: "pin" };1516export function TypeFacts({ type, owner, compact }: { type: PublicType; owner: OwnerInfo; compact?: boolean }) {17 const t = useT(publicDict);18 const locale = useLocale();19 const en = locale === "en";20 const description = (en && type.descriptionEn) || type.description;21 const instructions = (en && type.instructionsEn) || type.instructions;22 return (23 <div>24 <div className="flex items-center gap-3.5">25 <span className="w-[52px] h-[52px] rounded-[17px] flex items-center justify-center shrink-0 text-white shadow-e2" style={{ background: `linear-gradient(140deg, ${type.color}, color-mix(in srgb, ${type.color} 62%, #000))` }} aria-hidden><Icon name={LOCATION_ICON[type.locationKind]} size={24} /></span>26 <div className="min-w-0">27 <p className="text-[13.5px] t-mute font-medium truncate">{owner.name}</p>28 <h1 className="t-title !text-[23px] lg:!text-[26px] !leading-[1.12]">{(en && type.titleEn) || type.title}</h1>29 </div>30 </div>31 <div className={cx(compact && "max-lg:hidden")}>32 <ul className="mt-4 flex flex-wrap gap-2">33 <li className="chip"><Icon name="clock" size={15} className="t-mute" /><span><span className="sr-only">{t("duration")} : </span>{t("minutes", { n: type.durationMin })}</span></li>34 <li className="chip"><Icon name={LOCATION_ICON[type.locationKind]} size={15} className="t-mute" /><span><span className="sr-only">{t("whereLabel")} : </span>{t(type.locationKind)}</span></li>35 <li className="chip"><Icon name="globe" size={15} className="t-mute" /><span><span className="sr-only">{t("language")} : </span>{type.languages.map((l) => (l === "fr" ? "Français" : "English")).join(" · ")}</span></li>36 <li className="chip"><Icon name={type.approval === "auto" ? "bolt" : "inbox"} size={15} className="t-mute" /><span>{type.approval === "auto" ? t("approvalAuto") : t("approvalManual")}</span></li>37 </ul>38 {description ? <p className="mt-4 whitespace-pre-line text-[15px] text-pretty">{description}</p> : null}39 {instructions ? <div className="mt-4 rounded-2xl bg-elev p-3.5"><p className="t-eyebrow mb-1">{t("instructions")}</p><p className="whitespace-pre-line text-[14.5px]">{instructions}</p></div> : null}40 </div>41 </div>42 );43}4445/** Month over day number, in the zone the times are shown in. */46export function DateBlock({ iso, tz }: { iso: string; tz: string }) {47 const locale = useLocale();48 return <span className="dateblock" aria-hidden><span>{fmtDay(iso, tz, locale, { month: "short" }).replace(".", "")}</span><span>{fmtDay(iso, tz, locale, { day: "numeric" })}</span></span>;49}5051export function WhenBlock({ start, end, tz, ownerTz, ownerName }: { start: string; end: string; tz: string; ownerTz: string; ownerName: string }) {52 const t = useT(publicDict);53 const locale = useLocale();54 return (55 <div className="min-w-0">56 <p className="font-semibold first-letter:uppercase">{fmtDay(start, tz, locale)}</p>57 <p className="font-bold text-[20px] tracking-tight t-num leading-snug">{fmtRange(start, end, tz, locale)} <span className="t-mute font-normal text-[13.5px] tracking-normal whitespace-nowrap">({tzAbbr(start, tz, locale)} · {tzCity(tz)})</span></p>58 {ownerTz !== tz ? <p className="hint !mt-1">{t("tzOwner", { time: fmtRange(start, end, ownerTz, locale), city: tzCity(ownerTz), name: ownerName })}</p> : null}59 </div>60 );61}6263/** Animated mark of the last step: a drawn ring and glyph, one halo, a few sparks. Decorative. */64export function SuccessMark({ tone }: { tone: "ok" | "sent" }) {65 const c = tone === "ok" ? "var(--emerald)" : "var(--a1)";66 const sparks = [[-46, -34], [0, -56], [46, -34], [58, 6], [40, 46], [0, 58], [-40, 46], [-58, 6]];67 return (68 <span className="success shrink-0" style={{ "--halo": c } as React.CSSProperties} aria-hidden>69 {sparks.map(([dx, dy], i) => <i key={i} style={{ "--dx": `${dx}px`, "--dy": `${dy}px`, "--c": i % 2 ? "var(--a2)" : c, animationDelay: `${0.55 + i * 0.03}s` } as React.CSSProperties} />)}70 <svg viewBox="0 0 84 84" fill="none">71 <circle cx="42" cy="42" r="38" fill="var(--surface)" />72 <circle cx="42" cy="42" r="38" fill={c} opacity=".12" />73 <circle className="ring" cx="42" cy="42" r="38" stroke={c} strokeWidth="4" strokeLinecap="round" transform="rotate(-90 42 42)" />74 {tone === "ok" ? <path className="tick" pathLength={60} d="M27 43.500l10.500 10.500L58 31.500" stroke={c} strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />75 : <path className="tick" pathLength={60} d="M59 26L38 47M59 26L46 60l-8-13-13-8 34-13z" stroke={c} strokeWidth="3.600" strokeLinecap="round" strokeLinejoin="round" />}76 </svg>77 </span>78 );79}8081const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;8283export function BookingFlow({ type, owner }: { type: PublicType; owner: OwnerInfo }) {84 const t = useT(publicDict);85 const locale = useLocale();86 const [tz, setTz] = useVisitorTz();87 const [step, setStepNow] = useState<"time" | "details" | "done">("time");88 // Step changes are transitions, so <ViewTransition> can animate the old step out and the new one in.89 const setStep = (next: "time" | "details" | "done") => startTransition(() => setStepNow(next));90 const [slot, setSlot] = useState<{ start: string; end: string } | null>(null);91 const [form, setForm] = useState({ name: "", email: "", organization: "", reason: "", consent: false, website: "" });92 const [answers, setAnswers] = useState<Record<string, string>>({});93 const [errors, setErrors] = useState<Record<string, string>>({});94 const [submitError, setSubmitError] = useState<string | null>(null);95 const [busy, setBusy] = useState(false);96 const [result, setResult] = useState<Result | null>(null);97 const [copied, setCopied] = useState(false);98 const [pickerKey, setPickerKey] = useState(0);99 const formToken = useRef<string | null>(null);100 const idem = useRef<string>("");101 const headRef = useRef<HTMLHeadingElement>(null);102103 const load = useCallback((from: string, to: string) => api<SlotData>(`/api/v1/public/event-types/${type.slug}/slots?from=${from}&to=${to}`), [type.slug]);104 useEffect(() => { api<{ formToken: string }>("/api/v1/public/form-token").then((d) => { formToken.current = d.formToken; }).catch(() => {}); }, []);105 // Move focus to the step heading so keyboard and screen-reader users follow the change of step.106 useEffect(() => { if (step !== "time") headRef.current?.focus(); }, [step]);107108 const pick = (iso: string, data: SlotData) => {109 setSlot({ start: iso, end: new Date(new Date(iso).getTime() + data.durationMin * 60_000).toISOString() });110 idem.current = crypto.randomUUID(); // one key per chosen time: a double click or a retry cannot create two requests111 setSubmitError(null);112 setStep("details");113 };114115 const validate = () => {116 const e: Record<string, string> = {};117 if (form.name.trim().length < 2) e.name = t("nameShort");118 if (!EMAIL_RE.test(form.email.trim())) e.email = t("emailInvalid");119 if (type.requireReason && !form.reason.trim()) e.reason = t("required");120 for (const q of type.questions) if (q.required && !(answers[q.key] ?? "").trim()) e[`q_${q.key}`] = t("required");121 if (!form.consent) e.consent = t("consentRequired");122 setErrors(e);123 return Object.keys(e).length === 0;124 };125126 const submit = async (ev: React.FormEvent) => {127 ev.preventDefault();128 if (!slot || !validate()) { requestAnimationFrame(() => document.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()); return; }129 setBusy(true); setSubmitError(null);130 try {131 if (!formToken.current) formToken.current = (await api<{ formToken: string }>("/api/v1/public/form-token")).formToken;132 const res = await api<Result>("/api/v1/public/requests", { body: {133 slug: type.slug, start: slot.start, name: form.name, email: form.email, organization: form.organization, reason: form.reason, answers,134 visitorTz: tz, locale, idempotencyKey: idem.current, consent: true, formToken: formToken.current, website: form.website,135 } });136 setResult(res); setStep("done");137 } catch (e) {138 const code = e instanceof ApiClientError ? e.code : "";139 if (code === "slot_taken" || code === "slot_unavailable" || code === "slot_past" || code === "slot_limit") {140 setSlot(null); setPickerKey((k) => k + 1); setStep("time"); setSubmitError(t("slotGone"));141 } else {142 if (code === "form_invalid" || code === "form_expired") formToken.current = null;143 setSubmitError(errorText(e, locale, {144 too_many_pending: { fr: publicDict.fr.tooManyPending, en: publicDict.en.tooManyPending },145 email_domain_not_allowed: { fr: publicDict.fr.domainRefused, en: publicDict.en.domainRefused },146 form_invalid: { fr: publicDict.fr.formExpired, en: publicDict.en.formExpired }, form_expired: { fr: publicDict.fr.formExpired, en: publicDict.en.formExpired },147 form_too_fast: { fr: publicDict.fr.formTooFast, en: publicDict.en.formTooFast },148 }));149 }150 } finally { setBusy(false); }151 };152153 const manageUrl = result ? `${typeof window === "undefined" ? "" : window.location.origin}/manage/${result.manageToken}` : "";154 const steps = [["time", t("stepDate") + " · " + t("stepTime")], ["details", t("stepDetails")], ["done", t("stepDone")]] as const;155 const stepIndex = steps.findIndex(([k]) => k === step);156157 const whenShort = slot ? `${fmtDay(slot.start, tz, locale, { weekday: "short", day: "numeric", month: "short" })} · ${fmtRange(slot.start, slot.end, tz, locale)}` : "";158159 return (160 <div className="grid gap-4 lg:gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] items-start">161 <aside className={cx("card lg:p-6 lg:sticky lg:top-24 rise", step === "time" ? "p-5" : "p-4")}>162 <a href="/" className={cx("items-center gap-1 text-[14px] font-medium t-mute hover:text-ink no-underline mb-4 min-h-[36px] -ml-1", step === "time" ? "inline-flex" : "hidden lg:inline-flex")}><Icon name="left" size={16} />{t("allTypes")}</a>163 <TypeFacts type={type} owner={owner} compact={step !== "time"} />164 </aside>165 <section className="card p-5 sm:p-7 min-w-0 rise" style={{ animationDelay: "70ms" }}>166 <div className="flex items-center gap-3 mb-6">167 <ol className="flex items-center gap-1.5 flex-1" aria-label="Étapes / Steps">168 {steps.map(([k, label], i) => (169 <li key={k} className="flex-1" aria-current={i === stepIndex ? "step" : undefined}>170 <span className="block h-[5px] rounded-full bg-elev overflow-hidden"><span className={cx("block h-full rounded-full accent-bg transition-all duration-500 ease-out", i <= stepIndex ? "w-full" : "w-0")} /></span>171 <span className="sr-only">{label}</span>172 </li>173 ))}174 </ol>175 <span className="text-[12.5px] font-semibold t-mute whitespace-nowrap" aria-hidden>{t("stepOf", { n: stepIndex + 1, m: steps.length })}</span>176 </div>177178 {step === "time" ? (179 <ViewTransition key="time" enter="step-in" exit="step-out" default="none">180 <div>181 {submitError ? <div className="mb-4"><Notice tone="warn">{submitError}</Notice></div> : null}182 <SlotPicker key={pickerKey} load={load} horizonDays={type.horizonDays} tz={tz} onTz={setTz} ownerName={owner.name} selected={slot?.start ?? null} onSelect={pick} />183 </div>184 </ViewTransition>185 ) : null}186187 {step === "details" && slot ? (188 <ViewTransition key="details" enter="step-in" exit="step-out" default="none">189 <form onSubmit={submit} noValidate className="grid gap-5">190 <h2 ref={headRef} tabIndex={-1} className="t-title !text-[26px] outline-none">{t("stepDetails")}</h2>191 <div className="rounded-[20px] bg-elev p-3.5 flex flex-wrap items-center gap-x-3.5 gap-y-3">192 <DateBlock iso={slot.start} tz={tz} />193 <div className="min-w-0 flex-1 basis-40"><p className="sr-only">{t("when")}</p><WhenBlock start={slot.start} end={slot.end} tz={tz} ownerTz={owner.timezone} ownerName={owner.name} /></div>194 <Button small className="max-sm:w-full" icon="calendar" onClick={() => setStep("time")}>{t("changeTime")}</Button>195 </div>196 <div className="grid sm:grid-cols-2 gap-4">197 <Field label={t("name")} error={errors.name}>{(p) => <input {...p} className="input" autoComplete="name" enterKeyHint="next" required maxLength={120} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />}</Field>198 <Field label={t("email")} error={errors.email}>{(p) => <input {...p} className="input" type="email" inputMode="email" autoComplete="email" autoCapitalize="none" enterKeyHint="next" required maxLength={254} value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />}</Field>199 </div>200 {type.askOrganization ? <Field label={t("organization")} optional={t("optional")}>{(p) => <input {...p} className="input" autoComplete="organization" maxLength={160} value={form.organization} onChange={(e) => setForm({ ...form, organization: e.target.value })} />}</Field> : null}201 <Field label={t("reason")} optional={type.requireReason ? undefined : t("optional")} error={errors.reason} hint={t("reasonHint", { name: owner.name })}>{(p) => <textarea {...p} className="input" maxLength={2000} required={type.requireReason} value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} />}</Field>202 {type.questions.map((q) => (203 <Field key={q.key} label={(locale === "en" && q.label_en) || q.label} optional={q.required ? undefined : t("optional")} error={errors[`q_${q.key}`]}>204 {(p) => q.kind === "textarea"205 ? <textarea {...p} className="input" maxLength={2000} required={q.required} value={answers[q.key] ?? ""} onChange={(e) => setAnswers({ ...answers, [q.key]: e.target.value })} />206 : <input {...p} className="input" maxLength={500} required={q.required} value={answers[q.key] ?? ""} onChange={(e) => setAnswers({ ...answers, [q.key]: e.target.value })} />}207 </Field>208 ))}209 {/* Honeypot: hidden from people and assistive technology; a filled value is refused by the server. */}210 <div aria-hidden="true" className="absolute -left-[9999px] w-px h-px overflow-hidden"><label>Website<input tabIndex={-1} autoComplete="off" name="website" value={form.website} onChange={(e) => setForm({ ...form, website: e.target.value })} /></label></div>211 <div>212 <label className={cx("flex items-start gap-3.5 cursor-pointer rounded-[18px] border p-3.5 transition-colors", form.consent ? "border-[var(--a1)] bg-blue-wash/50" : "border-line-strong")}>213 <input type="checkbox" className="mt-0.5 w-6 h-6 shrink-0" checked={form.consent} aria-invalid={errors.consent ? true : undefined} aria-describedby={errors.consent ? "consent-error" : undefined} onChange={(e) => setForm({ ...form, consent: e.target.checked })} />214 <span className="text-[14.5px]">{t("consent")} <a href="/privacy" target="_blank" rel="noreferrer" className="text-blue font-medium underline underline-offset-2">{t("consentLink")}</a>.</span>215 </label>216 {errors.consent ? <p id="consent-error" className="error-text" role="alert">{errors.consent}</p> : null}217 </div>218 <Notice tone="info">{type.approval === "auto" ? t("autoNote") : t("manualNote", { name: owner.name })}</Notice>219 {submitError ? <Notice tone="danger">{submitError}</Notice> : null}220 <div className="actionbar glass">221 <p className="sm:hidden text-[13px] font-semibold t-mute text-center mb-2 first-letter:uppercase t-num" aria-hidden>{whenShort}</p>222 <div className="flex gap-2.5 sm:justify-end">223 <Button large onClick={() => setStep("time")} icon="left" className="!px-4">{t("back")}</Button>224 <Button large type="submit" variant="primary" busy={busy} className="flex-1 sm:flex-none">{type.approval === "auto" ? t("submitBook") : t("submitRequest")}</Button>225 </div>226 </div>227 </form>228 </ViewTransition>229 ) : null}230231 {step === "done" && result ? (232 <ViewTransition key="done" enter="step-in" exit="step-out" default="none">233 <div className="grid gap-5" data-testid="booking-result" data-status={result.status}>234 <div className="flex flex-col items-center text-center gap-3 pt-2">235 <SuccessMark tone={result.status === "approved" ? "ok" : "sent"} />236 <h2 ref={headRef} tabIndex={-1} className="t-title !text-[30px] outline-none mt-1">{result.status === "approved" ? t("confirmedTitle") : t("pendingTitle")}</h2>237 <span className={cx("badge badge-dot !text-[13.5px] !px-3 !py-1", result.status === "approved" ? "badge-ok" : "badge-pending")}>{result.status === "approved" ? t("confirmedBadge") : t("pendingBadge")}</span>238 <p className="max-w-md text-pretty">{result.status === "approved" ? t("confirmedBody", { name: owner.name }) : t("pendingBody", { name: owner.name })}</p>239 </div>240 <div className={cx("rounded-[22px] p-4 border flex items-center gap-3.5", result.status === "approved" ? "border-emerald bg-emerald-wash/40" : "border-dashed border-warn bg-warn-wash/40")}>241 <DateBlock iso={result.startsAt} tz={tz} />242 <div className="min-w-0"><p className="t-eyebrow mb-0.5">{t("when")}</p><WhenBlock start={result.startsAt} end={result.endsAt} tz={tz} ownerTz={owner.timezone} ownerName={owner.name} /></div>243 </div>244 <p className="t-mute text-[14.5px] text-center">{owner.emailEnabled ? t("emailFollow", { email: form.email.trim() }) : t("noEmailFollow")}</p>245 <div className="grid gap-2.5 sm:flex sm:flex-wrap sm:justify-center">246 <a className="btn btn-primary btn-lg" href={`/manage/${result.manageToken}`}>{t("manageLink")}</a>247 <Button large icon={copied ? "check" : "copy"} onClick={async () => { try { await navigator.clipboard.writeText(manageUrl); setCopied(true); } catch { /* clipboard refused */ } }}>{copied ? t("copied") : t("copyLink")}</Button>248 {result.status === "approved" ? <a className="btn btn-secondary btn-lg" href={`/api/v1/public/manage/${result.manageToken}/ics`}><Icon name="calendar" size={20} />{t("addIcs")}</a> : null}249 </div>250 <a href="/" className="text-blue font-semibold underline underline-offset-2 text-[14.5px] text-center min-h-[40px] inline-flex items-center justify-center">{t("bookAnother")}</a>251 </div>252 </ViewTransition>253 ) : null}254 </section>255 </div>256 );257}258