Repository Public
1"use client";2import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import { useLocale, useT } from "@/i18n";4import { publicDict } from "@/i18n/public";5import { Button, cx, Icon, Notice, Spinner } from "@/components/ui";6import { addDays, addMonths, fmtTime, fmtYmd, minutesInTz, startOfMonth, tzAbbr, tzCity, weekdayOf, ymdInTz } from "@/lib/format";78export interface SlotData { timezone: string; durationMin: number; slots: string[] }910/** The visitor's zone as the browser reports it; the choice is kept for the session. */11export function useVisitorTz(): [string, (tz: string) => void] {12 const [tz, setTz] = useState("America/Toronto");13 useEffect(() => {14 let v: string | null = null;15 try { v = sessionStorage.getItem("spcal:tz"); } catch { /* private mode */ }16 setTz(v || Intl.DateTimeFormat().resolvedOptions().timeZone || "America/Toronto");17 }, []);18 const set = useCallback((next: string) => { setTz(next); try { sessionStorage.setItem("spcal:tz", next); } catch { /* ignore */ } }, []);19 return [tz, set];20}2122export function TimezoneNote({ tz, onChange, sample }: { tz: string; onChange: (tz: string) => void; sample?: string }) {23 const t = useT(publicDict);24 const locale = useLocale();25 const [open, setOpen] = useState(false);26 const [q, setQ] = useState("");27 const zones = useMemo(() => { try { return Intl.supportedValuesOf("timeZone"); } catch { return ["America/Toronto", "America/Vancouver", "Europe/Paris", "UTC"]; } }, []);28 const shown = useMemo(() => { const s = q.trim().toLowerCase().replaceAll(" ", "_"); return (s ? zones.filter((z) => z.toLowerCase().includes(s)) : zones).slice(0, 60); }, [q, zones]);29 return (30 <div className="text-[13.5px]">31 <div className="rounded-2xl bg-elev px-3.5 py-2 flex flex-wrap items-center gap-x-2 gap-y-0.5">32 <Icon name="globe" size={16} className="t-mute" />33 <p className="min-w-0 flex-1 basis-40 leading-snug"><span className="t-mute">{t("tzYours")} : </span><strong className="font-semibold whitespace-nowrap">{tzCity(tz)} ({tzAbbr(sample ?? new Date(), tz, locale)})</strong></p>34 <button type="button" className="text-blue font-semibold cursor-pointer min-h-[40px] inline-flex items-center" aria-expanded={open} onClick={() => setOpen((o) => !o)}>{t("tzChange")}</button>35 </div>36 {open ? (37 <div className="mt-2 card !rounded-[20px] p-3 rise shadow-e2">38 <label className="sr-only" htmlFor="tz-search">{t("tzSearch")}</label>39 <input id="tz-search" className="input" placeholder={t("tzSearch")} value={q} onChange={(e) => setQ(e.target.value)} autoComplete="off" />40 <ul className="mt-2 max-h-56 overflow-y-auto overscroll-contain" role="listbox" aria-label={t("tzChange")}>41 {shown.map((z) => (42 <li key={z} role="option" aria-selected={z === tz}>43 <button type="button" className={cx("w-full text-left px-3 min-h-[44px] rounded-xl cursor-pointer hover:bg-elev flex items-center justify-between gap-2", z === tz && "bg-blue-wash text-blue font-semibold")} onClick={() => { onChange(z); setOpen(false); setQ(""); }}>{z.replaceAll("_", " ")}{z === tz ? <Icon name="check" size={16} /> : null}</button>44 </li>45 ))}46 </ul>47 </div>48 ) : null}49 </div>50 );51}5253/**54 * Month calendar + times of the chosen day. Only what the server returned is selectable: the component never55 * invents a slot. Days and times are shown in the visitor's zone; the server works in instants.56 */57export function SlotPicker({ load, horizonDays, tz, onTz, ownerName, selected, onSelect }: {58 load: (from: string, to: string) => Promise<SlotData>; horizonDays: number; tz: string; onTz: (tz: string) => void; ownerName: string;59 selected: string | null; onSelect: (iso: string, data: SlotData) => void;60}) {61 const t = useT(publicDict);62 const locale = useLocale();63 const today = ymdInTz(new Date(), tz);64 const lastDay = addDays(today, horizonDays + 1);65 const [month, setMonth] = useState(() => startOfMonth(today));66 const [day, setDay] = useState<string | null>(null);67 const [data, setData] = useState<SlotData | null>(null);68 const [state, setState] = useState<"loading" | "ok" | "error">("loading");69 const [autoAdvanced, setAutoAdvanced] = useState(0);70 const seq = useRef(0);71 const timesRef = useRef<HTMLDivElement>(null);7273 useEffect(() => { setMonth(startOfMonth(ymdInTz(new Date(), tz))); setDay(null); setAutoAdvanced(0); }, [tz]);7475 const fetchMonth = useCallback(async () => {76 const mine = ++seq.current;77 setState("loading");78 // One day of margin on each side: the visitor's calendar day may overlap two days of the owner's zone.79 const from = addDays(month < today ? today : month, -1);80 const to = addDays(addMonths(month, 1), 1);81 try { const d = await load(from, to); if (mine === seq.current) { setData(d); setState("ok"); } }82 catch { if (mine === seq.current) setState("error"); }83 }, [load, month, today]);84 useEffect(() => { void fetchMonth(); }, [fetchMonth]);8586 const byDay = useMemo(() => {87 const m = new Map<string, string[]>();88 for (const s of data?.slots ?? []) { const d = ymdInTz(s, tz); if (!m.has(d)) m.set(d, []); m.get(d)!.push(s); }89 return m;90 }, [data, tz]);91 const monthHasSlots = useMemo(() => [...byDay.keys()].some((d) => d.slice(0, 7) === month.slice(0, 7)), [byDay, month]);92 const canPrev = month > startOfMonth(today);93 const canNext = addMonths(month, 1) <= lastDay;9495 // First visit: when the current month has nothing left, move once or twice to the first month that has.96 useEffect(() => {97 if (state === "ok" && !monthHasSlots && canNext && autoAdvanced < 2 && !day) { setAutoAdvanced((n) => n + 1); setMonth((m) => addMonths(m, 1)); }98 }, [state, monthHasSlots, canNext, autoAdvanced, day]);99 useEffect(() => { if (day && state === "ok" && !byDay.has(day)) setDay(null); }, [day, byDay, state]);100101 const first = month, offset = weekdayOf(first) - 1;102 const daysInMonth = Number(addDays(addMonths(month, 1), -1).slice(8));103 const cells: (string | null)[] = [...Array(offset).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => addDays(first, i))];104 const weekdays = Array.from({ length: 7 }, (_, i) => fmtYmd(addDays("2024-01-01", i), locale, { weekday: "short" }));105 const daySlots = day ? byDay.get(day) ?? [] : [];106 const ownerTz = data?.timezone ?? tz;107108 // Times of the chosen day, grouped by part of the day in the visitor's zone.109 const groups = (["morning", "afternoon", "evening"] as const).map((k) => ({110 k, slots: daySlots.filter((x) => { const m = minutesInTz(x, tz); return k === "morning" ? m < 720 : k === "afternoon" ? m >= 720 && m < 1020 : m >= 1020; }),111 })).filter((g) => g.slots.length);112113 return (114 <div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">115 <div>116 <div className="flex items-center justify-between mb-4">117 <h3 className="t-headline !text-[21px] first-letter:uppercase" aria-live="polite">{fmtYmd(month, locale, { month: "long", year: "numeric" })}</h3>118 <div className="flex gap-1.5">119 <button type="button" className="btn btn-icon bg-elev" disabled={!canPrev} onClick={() => { setMonth(addMonths(month, -1)); setDay(null); }} aria-label={t("prevMonth")}><Icon name="left" /></button>120 <button type="button" className="btn btn-icon bg-elev" disabled={!canNext} onClick={() => { setMonth(addMonths(month, 1)); setDay(null); setAutoAdvanced(9); }} aria-label={t("nextMonth")}><Icon name="right" /></button>121 </div>122 </div>123 <div className="grid grid-cols-7 gap-y-1.5 gap-x-1 text-center" role="group" data-calendar aria-label={t("pickDate")} aria-busy={state === "loading"}>124 {weekdays.map((w) => <div key={w} aria-hidden className="t-eyebrow pb-1.5 !tracking-normal !text-[11px]">{w.replace(".", "")}</div>)}125 {cells.map((c, i) => {126 if (!c) return <div key={`e${i}`} />;127 const n = byDay.get(c)?.length ?? 0;128 const sel = c === day;129 const label = fmtYmd(c, locale, { weekday: "long", day: "numeric", month: "long" });130 return (131 <div key={c} className="flex justify-center">132 <button type="button" disabled={n === 0 || state !== "ok"} aria-pressed={sel} aria-label={n ? t("dayHasSlots", { date: label, n }) : t("dayNoSlots", { date: label })}133 onClick={() => { setDay(c); requestAnimationFrame(() => timesRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" })); }}134 className={cx("day t-num", n > 0 && state === "ok" && "day-open", c === today && "day-today", state === "loading" && "animate-pulse")}>135 {Number(c.slice(8))}136 </button>137 </div>138 );139 })}140 </div>141 <div className="mt-4 min-h-[24px]">142 {state === "loading" ? <p className="t-mute text-sm flex items-center gap-2"><Spinner size={15} />{t("loadingSlots")}</p> : null}143 {state === "error" ? <Notice tone="danger"><p>{t("slotsError")}</p><Button small className="mt-2" icon="refresh" onClick={fetchMonth}>{t("retry")}</Button></Notice> : null}144 {state === "ok" && !monthHasSlots ? (145 canNext ? <p className="t-mute text-sm">{t("noSlotsMonth")} <button type="button" className="text-blue font-semibold underline underline-offset-2 cursor-pointer min-h-[40px]" onClick={() => { setMonth(addMonths(month, 1)); setAutoAdvanced(9); }}>{t("seeNextMonth")}</button></p>146 : <Notice tone="info"><p className="font-semibold">{t("noSlotsAtAll")}</p><p>{t("noSlotsAtAllBody")}</p></Notice>147 ) : null}148 </div>149 <div className="mt-3"><TimezoneNote tz={tz} onChange={onTz} sample={selected ?? undefined} /></div>150 </div>151 <div ref={timesRef} className="md:border-l md:border-line md:pl-6 scroll-mt-20">152 <h3 className="t-headline !text-[19px] mb-1">{day ? <span className="inline-block first-letter:uppercase">{fmtYmd(day, locale, { weekday: "long", day: "numeric", month: "long" })}</span> : t("pickTime")}</h3>153 {!day ? (154 <div className="mt-3 rounded-[20px] border border-dashed border-line-strong px-4 py-7 text-center t-mute text-[14.5px] flex flex-col items-center gap-2"><Icon name="clock" size={24} /><p>{t("pickDate")}.</p></div>155 ) : daySlots.length === 0 ? <p className="t-mute text-sm mt-2">{t("noSlotsDay")}</p> : (156 <div key={day} className="md:max-h-[380px] md:overflow-y-auto md:pr-1 md:-mr-1">157 <p className="t-mute text-[13.5px] mb-3">{daySlots.length === 1 ? t("oneSlot") : t("nSlots", { n: daySlots.length })}</p>158 {groups.map((g, gi) => (159 <div key={g.k} className="mb-4 last:mb-0">160 <p className="t-eyebrow mb-2" id={`slots-${g.k}`}>{t(g.k)}</p>161 <ul className="stagger grid grid-cols-3 md:grid-cols-2 gap-2" aria-label={`${t("pickTime")} — ${t(g.k)}`}>162 {g.slots.map((s, i) => (163 <li key={s} style={{ "--i": Math.min(i + gi * 3, 14) } as React.CSSProperties}>164 <button type="button" aria-pressed={s === selected} onClick={() => onSelect(s, data!)} className="slot t-num">{fmtTime(s, tz, locale)}</button>165 </li>166 ))}167 </ul>168 </div>169 ))}170 </div>171 )}172 {day && ownerTz !== tz && daySlots[0] ? <p className="hint mt-3">{fmtTime(daySlots[0], tz, locale)} — {t("tzOwner", { time: fmtTime(daySlots[0], ownerTz, locale), city: tzCity(ownerTz), name: ownerName })}</p> : null}173 </div>174 </div>175 );176}177