Repository Public
1"use client";2import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";3import { setLocaleCookie, useLocale } from "@/i18n";4import { ApiClientError } from "@/lib/client-api";56export const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(" ");78// ── Icons (inline SVG, decorative unless labelled) ──────────────────────────────────────────────────9const P: Record<string, string> = {10 calendar: "M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z",11 clock: "M12 7v5l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",12 video: "M15 10l5-3v10l-5-3M4 6h10a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z",13 phone: "M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a1 1 0 0 1-1 1A16 16 0 0 1 4 5a1 1 0 0 1 1-1z",14 pin: "M12 21s7-6.2 7-11a7 7 0 1 0-14 0c0 4.800 7 11 7 11zM12 12.500a2.500 2.500 0 1 0 0-5 2.500 2.500 0 0 0 0 5z",15 check: "M5 12.500l4.500 4.500L19 7.500",16 x: "M6 6l12 12M18 6L6 18",17 left: "M15 5l-7 7 7 7", right: "M9 5l7 7-7 7", down: "M5 9l7 7 7-7",18 globe: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.500 2.700 3.800 5.700 3.800 9s-1.300 6.300-3.800 9c-2.500-2.700-3.800-5.700-3.800-9S9.500 5.700 12 3z",19 sun: "M12 4V2M12 22v-2M4 12H2M22 12h-2M5.600 5.600L4.200 4.200M19.800 19.800l-1.400-1.400M5.600 18.400l-1.400 1.400M19.800 4.200l-1.400 1.400M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z",20 moon: "M20 14.500A8.500 8.500 0 0 1 9.500 4a8.500 8.500 0 1 0 10.500 10.500z",21 alert: "M12 8v5M12 16.500v.500M10.300 3.900L2.400 17.500A2 2 0 0 0 4.100 20.500h15.800a2 2 0 0 0 1.700-3L13.700 3.900a2 2 0 0 0-3.400 0z",22 info: "M12 11v6M12 7.500v.500M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",23 inbox: "M3 13l3-8h12l3 8M3 13v5a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-5M3 13h5l1 2h6l1-2h5",24 home: "M4 11l8-7 8 7M6 10v9h12v-9",25 users: "M16 19v-1a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v1M9.500 11a3.500 3.500 0 1 0 0-7 3.500 3.500 0 0 0 0 7zM21 19v-1a4 4 0 0 0-3-3.900M16 4.100a3.500 3.500 0 0 1 0 6.800",26 settings: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.400 15a1.700 1.700 0 0 0 .300 1.800l.100.100a2 2 0 1 1-2.800 2.800l-.100-.100a1.700 1.700 0 0 0-2.800 1.200V21a2 2 0 1 1-4 0v-.100a1.700 1.700 0 0 0-2.800-1.200l-.100.100a2 2 0 1 1-2.800-2.800l.100-.100a1.700 1.700 0 0 0-1.200-2.800H3a2 2 0 1 1 0-4h.100a1.700 1.700 0 0 0 1.200-2.800l-.100-.100a2 2 0 1 1 2.800-2.800l.100.100a1.700 1.700 0 0 0 2.800-1.200V3a2 2 0 1 1 4 0v.100a1.700 1.700 0 0 0 2.800 1.200l.100-.100a2 2 0 1 1 2.800 2.800l-.100.100a1.700 1.700 0 0 0 1.200 2.800H21a2 2 0 1 1 0 4h-.100a1.700 1.700 0 0 0-1.500 1z",27 plus: "M12 5v14M5 12h14",28 block: "M5.600 5.600l12.800 12.800M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",29 key: "M15 7a4 4 0 1 1-3.900 5H8v3H5v3H2v-3l7.100-7.100A4 4 0 0 1 15 7zM16 8h.010",30 mail: "M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zM3.500 7l8.500 6 8.500-6",31 link: "M10 14a4 4 0 0 0 5.700 0l3-3a4 4 0 0 0-5.700-5.700l-1 1M14 10a4 4 0 0 0-5.700 0l-3 3a4 4 0 0 0 5.700 5.700l1-1",32 list: "M8 6h12M8 12h12M8 18h12M4 6h.010M4 12h.010M4 18h.010",33 trash: "M4 7h16M10 11v6M14 11v6M6 7l1 12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-12M9 7V4h6v3",34 refresh: "M4 12a8 8 0 0 1 13.700-5.700L20 8.500M20 4v4.500h-4.500M20 12a8 8 0 0 1-13.700 5.700L4 15.500M4 20v-4.500h4.500",35 shield: "M12 3l8 3v6c0 4.500-3.200 8-8 9-4.800-1-8-4.500-8-9V6l8-3z",36 logout: "M15 12H4M11 8l4 4-4 4M15 4h4a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-4",37 sliders: "M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0M16 4v4M10 10v4M18 16v4",38 tag: "M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9-9-9zM7.500 7.500h.010",39 send: "M21 3L10 14M21 3l-7 18-4-7-7-4 18-7z",40 spark: "M12 3l1.800 5.200L19 10l-5.200 1.800L12 17l-1.800-5.200L5 10l5.200-1.800L12 3zM19 16l.700 2.300L22 19l-2.300.700L19 22l-.700-2.300L16 19l2.300-.700L19 16z",41 more: "M5 12h.010M12 12h.010M19 12h.010",42 bolt: "M13 2L4 14h7l-1 8 9-12h-7l1-8z",43 copy: "M9 9h10a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1",44 external: "M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5",45};46export type IconName = keyof typeof P;47export function Icon({ name, size = 18, className, label }: { name: IconName; size?: number; className?: string; label?: string }) {48 return (49 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"50 className={cx("shrink-0", className)} aria-hidden={label ? undefined : true} role={label ? "img" : undefined} aria-label={label}>51 <path d={P[name]} />52 </svg>53 );54}5556export function Spinner({ size = 18 }: { size?: number }) {57 return <svg width={size} height={size} viewBox="0 0 24 24" className="animate-spin" aria-hidden><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeOpacity=".25" strokeWidth="2.5" /><path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>;58}5960type BtnProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "ghost" | "danger" | "ok"; small?: boolean; large?: boolean; busy?: boolean; icon?: IconName };61export function Button({ variant = "secondary", small, large, busy, icon, className, children, disabled, type = "button", ...rest }: BtnProps) {62 return (63 <button type={type} className={cx("btn", `btn-${variant}`, small && "btn-sm", large && "btn-lg", className)} disabled={disabled || busy} aria-busy={busy || undefined} {...rest}>64 {busy ? <Spinner size={16} /> : icon ? <Icon name={icon} size={small ? 16 : large ? 20 : 18} /> : null}65 {children}66 </button>67 );68}6970export function Field({ label, hint, error, children, optional }: { label: string; hint?: string; error?: string | null; optional?: string; children: (p: { id: string; "aria-describedby"?: string; "aria-invalid"?: boolean }) => ReactNode }) {71 const id = useId();72 const desc = error ? `${id}-e` : hint ? `${id}-h` : undefined;73 return (74 <div>75 <label htmlFor={id} className="label">{label}{optional ? <span className="t-mute font-normal"> — {optional}</span> : null}</label>76 {children({ id, "aria-describedby": desc, "aria-invalid": error ? true : undefined })}77 {error ? <p id={`${id}-e`} className="error-text" role="alert">{error}</p> : hint ? <p id={`${id}-h`} className="hint">{hint}</p> : null}78 </div>79 );80}8182export function Notice({ tone = "info", children, icon }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode; icon?: IconName }) {83 return <div className={cx("notice", `notice-${tone}`)} role={tone === "danger" ? "alert" : "status"}><Icon name={icon ?? (tone === "ok" ? "check" : tone === "info" ? "info" : "alert")} className="mt-0.5" /><div className="min-w-0 flex-1">{children}</div></div>;84}8586export function Badge({ tone = "mute", children }: { tone?: "pending" | "ok" | "danger" | "info" | "mute"; children: ReactNode }) {87 return <span className={cx("badge", `badge-${tone}`)}>{children}</span>;88}8990/** Initials on the accent gradient, or on a colour derived from the name when `seed` is set. */91export function Avatar({ name, size = 40, seed }: { name: string; size?: number; seed?: boolean }) {92 const initials = name.trim().split(/\s+/).slice(0, 2).map((w) => w.slice(0, 1).toUpperCase()).join("") || "·";93 let h = 0; for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 360;94 const style: React.CSSProperties = { width: size, height: size, fontSize: Math.round(size * 0.38) };95 // Fixed lightness/chroma keep white initials above 4.5:1 for every hue.96 if (seed) style.background = `linear-gradient(135deg, oklch(0.5 0.17 ${h}), oklch(0.42 0.19 ${(h + 40) % 360}))`;97 return <span className="avatar" style={style} aria-hidden>{initials}</span>;98}99100/** House illustrations for empty states: line art in the current colour with accent details. */101function Illustration({ kind }: { kind: "calendar" | "inbox" | "link" | "users" }) {102 const g = useId();103 const common = { fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };104 return (105 <svg width="132" height="104" viewBox="0 0 132 104" aria-hidden className="floaty">106 <defs><linearGradient id={g} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="var(--a1)" /><stop offset="1" stopColor="var(--a2)" /></linearGradient></defs>107 <ellipse cx="66" cy="94" rx="40" ry="5" fill="currentColor" opacity=".08" />108 <path d="M18 22l2.200 5.800L26 30l-5.800 2.200L18 38l-2.200-5.800L10 30l5.800-2.200L18 22z" fill={`url(#${g})`} opacity=".9" />109 <path d="M112 54l1.600 4.400L118 60l-4.400 1.600L112 66l-1.600-4.400L106 60l4.400-1.600L112 54z" fill={`url(#${g})`} opacity=".7" />110 <circle cx="104" cy="20" r="3.500" fill={`url(#${g})`} opacity=".55" />111 {kind === "calendar" ? (112 <g {...common} opacity=".9">113 <rect x="36" y="20" width="60" height="62" rx="12" fill="var(--surface)" />114 <path d="M36 38h60M52 12v14M80 12v14" />115 <rect x="46" y="48" width="12" height="10" rx="3.500" stroke="none" fill="currentColor" opacity=".14" />116 <rect x="62" y="48" width="12" height="10" rx="3.500" stroke="none" fill={`url(#${g})`} />117 <rect x="78" y="48" width="10" height="10" rx="3.500" stroke="none" fill="currentColor" opacity=".14" />118 <rect x="46" y="63" width="12" height="10" rx="3.500" stroke="none" fill="currentColor" opacity=".14" />119 <rect x="62" y="63" width="12" height="10" rx="3.500" stroke="none" fill="currentColor" opacity=".14" />120 </g>121 ) : kind === "inbox" ? (122 <g {...common} opacity=".9">123 <path d="M30 54l10-30h52l10 30v22a6 6 0 0 1-6 6H36a6 6 0 0 1-6-6V54z" fill="var(--surface)" />124 <path d="M30 54h22l4 9h20l4-9h22" />125 <path d="M56 38l7 7 13-14" stroke={`url(#${g})`} strokeWidth="3.200" />126 </g>127 ) : kind === "users" ? (128 <g {...common} opacity=".9">129 <circle cx="56" cy="38" r="13" fill="var(--surface)" />130 <path d="M32 82c0-13 10.700-22 24-22s24 9 24 22" fill="var(--surface)" />131 <circle cx="88" cy="42" r="9" stroke={`url(#${g})`} />132 <path d="M88 60c9 0 15 6 15 15" stroke={`url(#${g})`} />133 </g>134 ) : (135 <g {...common} opacity=".9">136 <path d="M58 60a14 14 0 0 0 20 0l10-10a14 14 0 0 0-20-20l-4 4" />137 <path d="M74 44a14 14 0 0 0-20 0L44 54a14 14 0 0 0 20 20l4-4" stroke={`url(#${g})`} />138 </g>139 )}140 </svg>141 );142}143144export function Empty({ icon = "calendar", title, children }: { icon?: IconName; title: string; children?: ReactNode }) {145 const kind = icon === "inbox" ? "inbox" : icon === "users" ? "users" : icon === "link" || icon === "refresh" ? "link" : "calendar";146 return (147 <div className="flex flex-col items-center text-center py-9 px-4 gap-2 t-mute">148 <Illustration kind={kind} />149 <p className="t-headline mt-2 text-ink">{title}</p>150 {children ? <div className="text-[14.5px] max-w-sm">{children}</div> : null}151 </div>152 );153}154155/** Modal on desktop, bottom sheet on phones. Focus is trapped and restored; Escape closes. */156export function Sheet({ open, onClose, title, children, footer, wide }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer?: ReactNode; wide?: boolean }) {157 const ref = useRef<HTMLDivElement>(null);158 const titleId = useId();159 useEffect(() => {160 if (!open) return;161 const prev = document.activeElement as HTMLElement | null;162 const el = ref.current!;163 const focusables = () => Array.from(el.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')).filter((x) => !x.hasAttribute("disabled"));164 (focusables()[0] ?? el).focus();165 const onKey = (e: KeyboardEvent) => {166 if (e.key === "Escape") { e.stopPropagation(); onClose(); }167 if (e.key === "Tab") {168 const f = focusables(); if (!f.length) return;169 const first = f[0], last = f[f.length - 1];170 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }171 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }172 }173 };174 document.addEventListener("keydown", onKey);175 const overflow = document.body.style.overflow; document.body.style.overflow = "hidden";176 return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = overflow; prev?.focus?.(); };177 }, [open, onClose]);178 if (!open) return null;179 return (180 <>181 <div className="scrim" onClick={onClose} aria-hidden />182 <div ref={ref} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}183 className={cx("sheet flex flex-col max-h-[92dvh] inset-x-0 bottom-0 rounded-t-[30px] border-b-0 sm:border-b sm:inset-auto sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-[30px] sm:w-[calc(100%-2rem)] outline-none", wide ? "sm:max-w-2xl" : "sm:max-w-lg")}>184 <div className="grabber sm:hidden" aria-hidden />185 <div className="flex items-center justify-between gap-3 px-5 pt-3 sm:pt-5 pb-3">186 <h2 id={titleId} className="t-headline !text-[21px]">{title}</h2>187 <button className="btn btn-icon !w-9 !min-h-9 bg-elev text-mute" onClick={onClose} aria-label="Fermer / Close"><Icon name="x" size={17} /></button>188 </div>189 <div className="px-5 pb-5 overflow-y-auto overscroll-contain flex-1">{children}</div>190 {footer ? <div className="px-5 pt-3.5 hairline flex max-sm:flex-col-reverse sm:flex-wrap gap-2.5 justify-end pb-[max(1rem,env(safe-area-inset-bottom))] max-sm:[&>.btn]:w-full max-sm:[&>.btn]:min-h-[52px] max-sm:[&>.btn]:whitespace-normal">{footer}</div> : null}191 </div>192 </>193 );194}195196// ── Toasts ──────────────────────────────────────────────────────────────────────────────────────────197type Toast = { id: number; tone: "ok" | "danger" | "info"; text: string };198let pushToast: ((t: Omit<Toast, "id">) => void) | null = null;199export const toast = { ok: (text: string) => pushToast?.({ tone: "ok", text }), error: (text: string) => pushToast?.({ tone: "danger", text }), info: (text: string) => pushToast?.({ tone: "info", text }) };200export function Toaster() {201 const [list, setList] = useState<Toast[]>([]);202 useEffect(() => {203 pushToast = (t) => { const id = Date.now() + Math.random(); setList((l) => [...l, { ...t, id }]); setTimeout(() => setList((l) => l.filter((x) => x.id !== id)), t.tone === "danger" ? 7000 : 4000); };204 return () => { pushToast = null; };205 }, []);206 return (207 <div className="fixed z-[60] top-[max(.75rem,env(safe-area-inset-top))] left-1/2 -translate-x-1/2 flex flex-col gap-2 w-[calc(100%-1.5rem)] max-w-md pointer-events-none" aria-live="polite">208 {list.map((t) => <div key={t.id} className={cx("notice pop shadow-e3 pointer-events-auto font-medium border border-line", `notice-${t.tone}`)} role={t.tone === "danger" ? "alert" : "status"}><Icon name={t.tone === "ok" ? "check" : t.tone === "danger" ? "alert" : "info"} className="mt-0.5" /><span>{t.text}</span></div>)}209 </div>210 );211}212213/** Human message for an API error; unknown codes fall back to the server's message. */214export function errorText(e: unknown, locale: "fr" | "en", map: Record<string, { fr: string; en: string }> = {}): string {215 const base: Record<string, { fr: string; en: string }> = {216 network: { fr: "Connexion impossible. Vérifiez votre réseau et réessayez.", en: "Cannot connect. Check your network and try again." },217 rate_limited: { fr: "Trop de tentatives. Réessayez dans quelques minutes.", en: "Too many attempts. Try again in a few minutes." },218 internal: { fr: "Une erreur est survenue de notre côté. Réessayez.", en: "Something went wrong on our side. Please try again." },219 unauthenticated: { fr: "Votre session a expiré. Reconnectez-vous.", en: "Your session has expired. Sign in again." },220 slot_taken: { fr: "Ce créneau n'est plus disponible.", en: "This time is no longer available." },221 slot_past: { fr: "Ce moment est déjà passé.", en: "This time is in the past." },222 slot_unavailable: { fr: "Ce créneau n'est pas disponible.", en: "This time is not available." },223 slot_limit: { fr: "La limite de rendez-vous pour ce jour ou cette semaine est atteinte.", en: "The appointment limit for this day or week is reached." },224 invalid_state: { fr: "Cette action n'est plus possible : l'état a changé. Actualisez la page.", en: "This action is no longer possible: the state changed. Refresh the page." },225 invalid_input: { fr: "Certaines informations sont invalides.", en: "Some information is invalid." },226 ...map,227 };228 if (e instanceof ApiClientError) return base[e.code]?.[locale] ?? e.message;229 return base.internal[locale];230}231232// ── Theme and language ──────────────────────────────────────────────────────────────────────────────233export const THEME_COLOR = { light: "#f4f4fa", dark: "#050508" };234export function ThemeToggle() {235 const [dark, setDark] = useState(false);236 useEffect(() => { setDark(document.documentElement.dataset.theme === "dark"); }, []);237 const flip = useCallback(() => {238 const next = !dark; setDark(next);239 document.documentElement.dataset.theme = next ? "dark" : "light";240 // The browser chrome (status bar, address bar) follows the chosen theme, not only the system one.241 document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]').forEach((m) => { m.content = next ? THEME_COLOR.dark : THEME_COLOR.light; });242 try { localStorage.setItem("spcal:theme", next ? "dark" : "light"); } catch { /* private mode */ }243 }, [dark]);244 return <button className="btn btn-ghost btn-icon !w-10 !min-h-10" onClick={flip} aria-label={dark ? "Thème clair / Light theme" : "Thème sombre / Dark theme"} aria-pressed={dark}><Icon name={dark ? "sun" : "moon"} size={19} /></button>;245}246export function LangToggle() {247 const locale = useLocale();248 const other = locale === "fr" ? "en" : "fr";249 return <button className="btn btn-ghost btn-icon !w-10 !min-h-10 font-bold text-[13.5px]" onClick={() => setLocaleCookie(other)} lang={other} aria-label={other === "en" ? "EN — Switch to English" : "FR — Passer en français"}>{other.toUpperCase()}</button>;250}251252/** The mark: a calendar leaf on the accent gradient with a lit "today" dot. */253export function Mark({ size = 34 }: { size?: number }) {254 return (255 <span className="inline-flex items-center justify-center accent-bg shadow-glow shrink-0" style={{ width: size, height: size, borderRadius: size * 0.3 }} aria-hidden>256 <svg width={size * 0.62} height={size * 0.62} viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round">257 <rect x="3.500" y="5" width="17" height="15.500" rx="3.500" /><path d="M3.500 10h17M8 2.800v4M16 2.800v4" /><circle cx="15.600" cy="15" r="1.900" fill="#fff" stroke="none" />258 </svg>259 </span>260 );261}262export function Wordmark({ href = "/", compact }: { href?: string; compact?: boolean }) {263 return (264 <a href={href} className="inline-flex items-center gap-2.5 font-bold tracking-tight text-[16.5px] no-underline" aria-label="SP Calendar">265 <Mark />266 {compact ? null : <span>SP <span className="t-mute font-semibold">Calendar</span></span>}267 </a>268 );269}270