Repository Public
1"use client";2import { useCallback, useEffect, useState } from "react";3import { useLocale, useT } from "@/i18n";4import { publicDict } from "@/i18n/public";5import { api, ApiClientError } from "@/lib/client-api";6import { Button, cx, Empty, errorText, Field, Icon, Notice, Sheet, toast } from "@/components/ui";7import { fmtDateTime } from "@/lib/format";8import { SlotPicker, useVisitorTz, type SlotData } from "./slot-picker";9import { DateBlock, LOCATION_ICON, WhenBlock } from "./booking-flow";10import type { PublicType } from "@/lib/api/public";1112interface View {13 state: "pending" | "reschedule_requested" | "confirmed" | "past" | "declined" | "expired" | "cancelled";14 ownerName: string; ownerTimezone: string; visitorName: string; visitorTz: string; locale: string; type: PublicType;15 startsAt: string; endsAt: string;16 proposal: { startsAt: string; endsAt: string; message: string; expiresAt: string | null } | null;17 pendingChange: { startsAt: string } | null; declineMessage: string; locationText: string;18 can: { cancel: boolean; reschedule: boolean; accept: boolean; ics: boolean };19 result?: "pending" | "approved";20}2122export function ManageView({ token }: { token: string }) {23 const t = useT(publicDict);24 const locale = useLocale();25 const [tz, setTz] = useVisitorTz();26 const [view, setView] = useState<View | null>(null);27 const [fatal, setFatal] = useState<ApiClientError | null>(null);28 const [busy, setBusy] = useState<string | null>(null);29 const [error, setError] = useState<string | null>(null);30 const [cancelOpen, setCancelOpen] = useState(false);31 const [reason, setReason] = useState("");32 const [moveOpen, setMoveOpen] = useState(false);33 const [pick, setPick] = useState<{ start: string; end: string } | null>(null);34 const base = `/api/v1/public/manage/${token}`;3536 const refresh = useCallback(async () => {37 try { setView(await api<View>(base)); setFatal(null); }38 catch (e) { setFatal(e instanceof ApiClientError ? e : new ApiClientError(0, "internal", "error")); }39 }, [base]);40 useEffect(() => { void refresh(); }, [refresh]);41 const loadSlots = useCallback((from: string, to: string) => api<SlotData>(`${base}/slots?from=${from}&to=${to}`), [base]);4243 const act = async (name: string, fn: () => Promise<View>, okText?: (v: View) => string) => {44 setBusy(name); setError(null);45 try { const v = await fn(); setView(v); if (okText) toast.ok(okText(v)); return true; }46 catch (e) { setError(errorText(e, locale, { proposal_expired: { fr: "Cette proposition a expiré.", en: "This proposal has expired." }, not_allowed: { fr: "Cette action n'est pas offerte en ligne pour ce rendez-vous.", en: "This action is not available online for this appointment." }, reschedule_pending: { fr: "Un changement attend déjà une réponse.", en: "A change is already awaiting an answer." } })); void refresh(); return false; }47 finally { setBusy(null); }48 };4950 if (fatal) {51 const k = fatal.code === "link_expired" ? ["linkExpiredTitle", "linkExpiredBody"] : fatal.code === "link_revoked" ? ["linkRevokedTitle", "linkRevokedBody"] : fatal.code === "network" ? ["networkTitle", "networkBody"] : fatal.code === "link_invalid" || fatal.status === 404 ? ["linkInvalidTitle", "linkInvalidBody"] : ["errorTitle", "errorBody"];52 return (53 <div className="card p-6" data-testid="manage-error" data-code={fatal.code}>54 <Empty icon={fatal.code === "network" ? "refresh" : "link"} title={t(k[0] as "linkInvalidTitle")}>55 <p>{t(k[1] as "linkInvalidBody", { name: "SP Calendar" })}</p>56 <div className="mt-5 flex justify-center gap-2">57 {fatal.code === "network" || fatal.status >= 500 ? <Button icon="refresh" onClick={refresh}>{t("retry")}</Button> : null}58 <a href="/" className="btn btn-primary">{t("home")}</a>59 </div>60 </Empty>61 </div>62 );63 }64 if (!view) return <div className="card p-6 grid gap-4" aria-busy="true"><div className="skeleton h-4 w-1/3" /><div className="skeleton h-9 w-3/4" /><div className="skeleton h-24" /><div className="skeleton h-[54px]" /></div>;6566 const name = view.ownerName;67 const badge: Record<View["state"], { tone: "pending" | "ok" | "danger" | "info" | "mute"; label: string; explain: string }> = {68 pending: { tone: "pending", label: t("statePending"), explain: t("pendingExplain", { name }) },69 reschedule_requested: { tone: "info", label: t("stateProposal"), explain: t("proposalExplain", { name }) },70 confirmed: { tone: "ok", label: t("stateConfirmed"), explain: t("confirmedExplain") },71 past: { tone: "mute", label: t("statePast"), explain: t("pastExplain") },72 declined: { tone: "danger", label: t("stateDeclined"), explain: t("declinedExplain", { name }) },73 expired: { tone: "mute", label: t("stateExpired"), explain: t("expiredExplain") },74 cancelled: { tone: "mute", label: t("stateCancelled"), explain: t("cancelledExplain") },75 };76 const b = badge[view.state];77 const en = locale === "en";78 const confirmed = view.state === "confirmed";7980 const closed = view.state === "cancelled" || view.state === "declined" || view.state === "expired";8182 return (83 <div className="card p-5 sm:p-8 rise relative overflow-hidden" data-testid="manage" data-state={view.state}>84 <div aria-hidden className="absolute inset-x-0 top-0 h-[5px]" style={{ background: confirmed ? "var(--emerald)" : view.state === "pending" ? "var(--warn)" : view.state === "reschedule_requested" ? "var(--accent)" : "var(--line-strong)" }} />85 <p className="t-eyebrow mb-2 mt-1">{t("manageTitle")}</p>86 <h1 className="t-title !text-[28px] sm:!text-[32px]">{(en && view.type.titleEn) || view.type.title}</h1>87 <div className="mt-3 flex flex-wrap items-center gap-2">88 <span className={cx("badge badge-dot !text-[13.5px] !px-3 !py-1", `badge-${b.tone}`)}>{b.label}</span>89 <span className="chip"><Icon name={LOCATION_ICON[view.type.locationKind]} size={15} className="t-mute" />{t(view.type.locationKind)} · {t("minutes", { n: view.type.durationMin })}</span>90 </div>91 <p className="mt-4 text-pretty">{b.explain}</p>9293 {view.state === "reschedule_requested" && view.proposal ? (94 <div className="mt-5 grid gap-3">95 <div className="rounded-[22px] p-4 border border-blue bg-blue-wash/40">96 <div className="flex items-center gap-3.5">97 <DateBlock iso={view.proposal.startsAt} tz={tz} />98 <div className="min-w-0"><p className="t-eyebrow mb-0.5">{t("proposedTime")}</p><WhenBlock start={view.proposal.startsAt} end={view.proposal.endsAt} tz={tz} ownerTz={view.ownerTimezone} ownerName={name} /></div>99 </div>100 {view.proposal.message ? <p className="mt-3 text-[14.5px]"><span className="t-mute">{t("proposalMessage", { name })} : </span>{view.proposal.message}</p> : null}101 {view.proposal.expiresAt ? <p className="hint">{t("proposalExpires", { date: fmtDateTime(view.proposal.expiresAt, tz, locale) })}</p> : null}102 </div>103 <div className="rounded-[22px] p-4 bg-elev"><p className="t-eyebrow mb-1">{t("requestedTime")}</p><WhenBlock start={view.startsAt} end={view.endsAt} tz={tz} ownerTz={view.ownerTimezone} ownerName={name} /></div>104 </div>105 ) : (106 <div className={cx("mt-5 rounded-[22px] p-4 border", confirmed ? "border-emerald bg-emerald-wash/40" : view.state === "pending" ? "border-dashed border-warn bg-warn-wash/40" : "border-line bg-elev")}>107 <div className="flex items-center gap-3.5">108 <DateBlock iso={view.startsAt} tz={tz} />109 <div className="min-w-0"><p className="t-eyebrow mb-0.5">{t("when")}</p><div className={cx(closed && "line-through opacity-70")}><WhenBlock start={view.startsAt} end={view.endsAt} tz={tz} ownerTz={view.ownerTimezone} ownerName={name} /></div></div>110 </div>111 {confirmed && view.locationText ? <p className="mt-3 pt-3 border-t border-emerald/30 text-[14.5px] break-words"><span className="t-mute">{t("location")} : </span>{view.locationText}</p> : null}112 </div>113 )}114115 {view.pendingChange ? <div className="mt-4"><Notice tone="warn">{t("pendingChange", { date: fmtDateTime(view.pendingChange.startsAt, tz, locale), name })}</Notice></div> : null}116 {view.state === "declined" && view.declineMessage ? <p className="mt-4 text-[14.5px]"><span className="t-mute">{t("declineMessage", { name })} : </span>{view.declineMessage}</p> : null}117 {error ? <div className="mt-4"><Notice tone="danger">{error}</Notice></div> : null}118119 <div className="mt-6 grid gap-2.5 sm:flex sm:flex-wrap">120 {view.can.accept ? <Button large variant="ok" icon="check" busy={busy === "accept"} onClick={() => act("accept", () => api<View>(`${base}/accept`, { method: "POST", body: {} }), () => t("confirmedExplain"))}>{t("acceptProposal")}</Button> : null}121 {view.can.ics ? <a className="btn btn-primary btn-lg" href={`${base}/ics`}><Icon name="calendar" size={20} />{t("addIcs")}</a> : null}122 {view.can.reschedule ? <Button large icon="calendar" onClick={() => { setPick(null); setMoveOpen(true); }}>{confirmed ? t("reschedule") : t("chooseOther")}</Button> : null}123 {view.can.cancel ? <Button large variant="ghost" className="!text-danger" onClick={() => setCancelOpen(true)}>{confirmed ? t("cancelBooking") : t("cancelRequest")}</Button> : null}124 {!view.can.cancel && !view.can.reschedule && !view.can.accept ? <a className="btn btn-primary btn-lg" href={`/book/${view.type.slug}`}>{t("bookAnother")}</a> : null}125 </div>126127 <Sheet open={cancelOpen} onClose={() => setCancelOpen(false)} title={t("cancelConfirmTitle")}128 footer={<><Button onClick={() => setCancelOpen(false)}>{t("keep")}</Button><Button variant="danger" busy={busy === "cancel"} onClick={async () => { if (await act("cancel", () => api<View>(`${base}/cancel`, { body: { reason } }), () => t("cancelledExplain"))) setCancelOpen(false); else setCancelOpen(false); }}>{t("confirmCancel")}</Button></>}>129 <p className="mb-4">{t("cancelConfirmBody")}</p>130 <Field label={t("cancelReason")} optional={t("optional")}>{(p) => <textarea {...p} className="input" maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} />}</Field>131 </Sheet>132133 <Sheet open={moveOpen} onClose={() => setMoveOpen(false)} title={t("rescheduleTitle")} wide134 footer={<><Button onClick={() => setMoveOpen(false)}>{t("back")}</Button><Button variant="primary" disabled={!pick} busy={busy === "move"} onClick={async () => {135 const ok = await act("move", () => api<View>(`${base}/reschedule`, { body: { start: pick!.start } }), (v) => (confirmed ? (v.result === "approved" ? t("rescheduledDone") : t("rescheduledPending")) : v.result === "approved" ? t("rescheduledDone") : t("movedPending")));136 setMoveOpen(false); if (!ok) setPick(null);137 }}>{t("rescheduleConfirm")}</Button></>}>138 <SlotPicker load={loadSlots} horizonDays={view.type.horizonDays} tz={tz} onTz={setTz} ownerName={name} selected={pick?.start ?? null}139 onSelect={(iso, d) => setPick({ start: iso, end: new Date(new Date(iso).getTime() + d.durationMin * 60_000).toISOString() })} />140 {pick ? <div className="mt-4 rounded-[20px] bg-elev p-4 rise"><p className="t-eyebrow mb-1">{t("when")}</p><WhenBlock start={pick.start} end={pick.end} tz={tz} ownerTz={view.ownerTimezone} ownerName={name} /></div> : null}141 </Sheet>142 </div>143 );144}145