SPB Git

Repository Public

manage-view.tsx 11.2 KB 145 lines tsx

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

Theme