SPB Git

Repository Public

booking-flow.tsx 19.5 KB 258 lines tsx

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
Files main
Menu

Theme