Repository Public
1"use client";2import { useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminConfig } from "@/i18n/admin-config";5import { api, ApiClientError } from "@/lib/client-api";6import { Badge, Button, cx, Empty, errorText, Field, Icon, Notice, Sheet, toast, type IconName } from "@/components/ui";7import { Loadable, PageHeader, useLoad } from "@/components/admin/shell";8import { Confirm, CopyButton, FormGroup, numOrEmpty, Toggle } from "./common";910type Kind = "video" | "phone" | "in_person";11interface Question { key: string; label: string; label_en: string; kind: "text" | "textarea"; required: boolean }12interface TypeRow {13 id: string; slug: string; title: string; title_en: string; description: string; description_en: string; duration_min: number; color: string;14 location_kind: Kind; location_detail: string; instructions: string; instructions_en: string; languages: ("fr" | "en")[]; approval: "manual" | "auto";15 hold_policy: "hold" | "none"; hold_ttl_hours: number; buffer_before_min: number; buffer_after_min: number; slot_step_min: number; min_notice_min: number;16 horizon_days: number; daily_limit: number | null; weekly_limit: number | null; questions: Partial<Question>[]; ask_organization: boolean; require_reason: boolean;17 allow_visitor_cancel: boolean; allow_visitor_reschedule: boolean; reminder_hours: number | null; custom_availability: boolean; active: boolean; position: number;18}19type Num = number | "";20interface Form {21 slug: string; title: string; title_en: string; description: string; description_en: string; duration_min: Num; color: string; location_kind: Kind; location_detail: string;22 instructions: string; instructions_en: string; languages: ("fr" | "en")[]; approval: "manual" | "auto"; hold_policy: "hold" | "none"; hold_ttl_hours: Num;23 buffer_before_min: Num; buffer_after_min: Num; slot_step_min: Num; notice_hours: Num; horizon_days: Num; daily_limit: Num; weekly_limit: Num; questions: Question[];24 ask_organization: boolean; require_reason: boolean; allow_visitor_cancel: boolean; allow_visitor_reschedule: boolean; reminder_hours: Num; custom_availability: boolean;25 active: boolean; position: number;26}2728const SWATCHES = ["#2257e0", "#087a55", "#9a5b06", "#c22a40", "#6d3fd1", "#0b7285", "#b0306f", "#3b4252"];29const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;30const KIND_ICON: Record<Kind, IconName> = { video: "video", phone: "phone", in_person: "pin" };31const plain = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();32const slugify = (s: string) => plain(s).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64).replace(/-+$/, "");33const keyify = (s: string) => plain(s).replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 32).replace(/_+$/, "") || "question";3435const blank = (position: number): Form => ({36 slug: "", title: "", title_en: "", description: "", description_en: "", duration_min: 30, color: SWATCHES[0], location_kind: "video", location_detail: "", instructions: "", instructions_en: "",37 languages: ["fr"], approval: "manual", hold_policy: "hold", hold_ttl_hours: 48, buffer_before_min: 0, buffer_after_min: 0, slot_step_min: 30, notice_hours: 4, horizon_days: 60,38 daily_limit: "", weekly_limit: "", questions: [], ask_organization: true, require_reason: true, allow_visitor_cancel: true, allow_visitor_reschedule: true, reminder_hours: "",39 custom_availability: false, active: true, position,40});41const fromRow = (r: TypeRow): Form => ({42 slug: r.slug, title: r.title, title_en: r.title_en, description: r.description, description_en: r.description_en, duration_min: r.duration_min, color: r.color, location_kind: r.location_kind,43 location_detail: r.location_detail, instructions: r.instructions, instructions_en: r.instructions_en, languages: r.languages?.length ? r.languages : ["fr"], approval: r.approval,44 hold_policy: r.hold_policy, hold_ttl_hours: r.hold_ttl_hours, buffer_before_min: r.buffer_before_min, buffer_after_min: r.buffer_after_min, slot_step_min: r.slot_step_min,45 notice_hours: Math.round((r.min_notice_min / 60) * 100) / 100, horizon_days: r.horizon_days, daily_limit: r.daily_limit ?? "", weekly_limit: r.weekly_limit ?? "",46 questions: (r.questions ?? []).map((q) => ({ key: q.key ?? "", label: q.label ?? "", label_en: q.label_en ?? "", kind: q.kind === "textarea" ? "textarea" : "text", required: !!q.required })),47 ask_organization: r.ask_organization, require_reason: r.require_reason, allow_visitor_cancel: r.allow_visitor_cancel, allow_visitor_reschedule: r.allow_visitor_reschedule,48 reminder_hours: r.reminder_hours ?? "", custom_availability: r.custom_availability, active: r.active, position: r.position,49});50/** API body (`eventTypeInput`). Question keys are generated from the label when missing, and kept unique. */51function toBody(f: Form) {52 const used = new Set<string>();53 const questions = f.questions.map((q) => {54 let key = q.key || keyify(q.label);55 for (let n = 2; used.has(key); n++) key = `${(q.key || keyify(q.label)).slice(0, 29)}_${n}`;56 used.add(key);57 return { key, label: q.label.trim(), label_en: q.label_en.trim(), kind: q.kind, required: q.required };58 });59 return {60 slug: f.slug, title: f.title.trim(), title_en: f.title_en.trim(), description: f.description, description_en: f.description_en, duration_min: Number(f.duration_min), color: f.color,61 location_kind: f.location_kind, location_detail: f.location_detail, instructions: f.instructions, instructions_en: f.instructions_en, languages: f.languages, approval: f.approval,62 hold_policy: f.hold_policy, hold_ttl_hours: Number(f.hold_ttl_hours), buffer_before_min: Number(f.buffer_before_min), buffer_after_min: Number(f.buffer_after_min),63 slot_step_min: Number(f.slot_step_min), min_notice_min: Math.round(Number(f.notice_hours) * 60), horizon_days: Number(f.horizon_days),64 daily_limit: f.daily_limit === "" ? null : f.daily_limit, weekly_limit: f.weekly_limit === "" ? null : f.weekly_limit, questions,65 ask_organization: f.ask_organization, require_reason: f.require_reason, allow_visitor_cancel: f.allow_visitor_cancel, allow_visitor_reschedule: f.allow_visitor_reschedule,66 reminder_hours: f.reminder_hours === "" ? null : f.reminder_hours, custom_availability: f.custom_availability, active: f.active, position: f.position,67 };68}6970export function TypesScreen() {71 const t = useT(adminConfig);72 const locale = useLocale();73 const state = useLoad<{ types: TypeRow[] }>("/api/v1/admin/event-types");74 const [editing, setEditing] = useState<{ id: string | null; form: Form } | null>(null);75 const [toDelete, setToDelete] = useState<TypeRow | null>(null);76 const [deleting, setDeleting] = useState(false);77 const [deleteError, setDeleteError] = useState<string | null>(null);78 const [toggling, setToggling] = useState<string | null>(null);79 const origin = typeof window === "undefined" ? "" : window.location.origin;80 const kindLabel = (k: Kind) => (k === "video" ? t("tyKindVideo") : k === "phone" ? t("tyKindPhone") : t("tyKindInPerson"));8182 const setActive = async (r: TypeRow, active: boolean) => {83 setToggling(r.id);84 try { await api(`/api/v1/admin/event-types/${r.id}`, { method: "PATCH", body: toBody({ ...fromRow(r), active }) }); toast.ok(active ? t("tyActivated") : t("tyDeactivated")); await state.reload(); }85 catch (e) { toast.error(errorText(e, locale)); } finally { setToggling(null); }86 };87 const remove = async () => {88 if (!toDelete) return;89 setDeleting(true); setDeleteError(null);90 try { await api(`/api/v1/admin/event-types/${toDelete.id}`, { method: "DELETE" }); toast.ok(t("tyDeleted")); setToDelete(null); await state.reload(); }91 catch (e) { setDeleteError(e instanceof ApiClientError && e.code === "in_use" ? t("tyInUse") : errorText(e, locale)); } finally { setDeleting(false); }92 };9394 const types = state.data?.types ?? [];95 return (96 <>97 <PageHeader title={t("tyTitle")} lead={t("tyLead")} actions={<Button variant="primary" icon="plus" onClick={() => setEditing({ id: null, form: blank(types.length) })}>{t("tyNew")}</Button>} />98 <Loadable state={state}>99 {types.length === 0 ? <div className="card"><Empty icon="tag" title={t("tyEmpty")}>{t("tyEmptyLead")}</Empty></div> : (100 <ul className="grid gap-3 md:grid-cols-2">101 {types.map((r) => (102 <li key={r.id} className={cx("card p-5 flex flex-col gap-3.5", !r.active && "opacity-80")}>103 <div className="flex items-start gap-3">104 <span className="w-11 h-11 rounded-[14px] flex items-center justify-center shrink-0 text-white shadow-e1" style={{ background: `linear-gradient(140deg, ${r.color}, color-mix(in srgb, ${r.color} 62%, #000))` }} aria-hidden><Icon name={KIND_ICON[r.location_kind]} size={21} /></span>105 <div className="min-w-0 flex-1">106 <h2 className="t-headline !text-[18px] break-words leading-snug">{r.title}</h2>107 <p className="t-mute text-sm flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5">108 <span>{t("tyMinutes", { n: r.duration_min })}</span><span aria-hidden>·</span>109 <span>{kindLabel(r.location_kind)}</span>110 </p>111 </div>112 </div>113 <div className="flex flex-wrap gap-2">114 <Badge tone={r.approval === "manual" ? "pending" : "ok"}>{r.approval === "manual" ? t("tyApprovalManual") : t("tyApprovalAuto")}</Badge>115 {r.active ? null : <Badge>{t("tyInactive")}</Badge>}116 {r.custom_availability ? <Badge tone="info">{t("tyCustomHours")}</Badge> : null}117 </div>118 <p className="text-[13px] font-mono t-mute break-all rounded-xl bg-elev px-3 py-2">{origin}/book/{r.slug}</p>119 <div className="flex flex-wrap items-center gap-2 hairline pt-3 mt-auto">120 <CopyButton text={`${origin}/book/${r.slug}`} label={t("cCopyLink")} copied={t("cCopied")} />121 <a className="btn btn-secondary btn-sm" href={`/book/${r.slug}`} target="_blank" rel="noreferrer">{t("cOpen")}</a>122 <Button small icon="settings" onClick={() => setEditing({ id: r.id, form: fromRow(r) })}>{t("cEdit")}</Button>123 <Button small variant="ghost" icon="trash" aria-label={`${t("cDelete")} — ${r.title}`} onClick={() => { setDeleteError(null); setToDelete(r); }} />124 </div>125 <Toggle checked={r.active} disabled={toggling === r.id} label={t("tyActive")} hint={r.active ? t("tyActiveHint") : t("tyInactiveHint")} onChange={(v) => setActive(r, v)} />126 </li>127 ))}128 </ul>129 )}130 </Loadable>131 {editing ? <TypeEditor key={editing.id ?? "new"} id={editing.id} initial={editing.form} origin={origin} onClose={() => setEditing(null)} onSaved={async () => { setEditing(null); await state.reload(); }} /> : null}132 <Confirm open={!!toDelete} onClose={() => setToDelete(null)} title={t("tyDeleteTitle")} confirmLabel={t("cDelete")} cancelLabel={t("cCancel")} onConfirm={remove} busy={deleting}>133 <p>{t("tyDeleteLead", { title: toDelete?.title ?? "" })}</p>134 {deleteError ? <Notice tone="danger">{deleteError}</Notice> : null}135 </Confirm>136 </>137 );138}139140function TypeEditor({ id, initial, origin, onClose, onSaved }: { id: string | null; initial: Form; origin: string; onClose: () => void; onSaved: () => void }) {141 const t = useT(adminConfig);142 const locale = useLocale();143 const [f, setF] = useState<Form>(initial);144 const [slugTouched, setSlugTouched] = useState(!!id);145 const [errors, setErrors] = useState<Record<string, string>>({});146 const [formError, setFormError] = useState<string | null>(null);147 const [busy, setBusy] = useState(false);148 const set = <K extends keyof Form>(k: K, v: Form[K]) => setF((cur) => ({ ...cur, [k]: v }));149150 const num = (k: keyof Form, label: string, min: number, max: number, opts: { hint?: string; empty?: boolean; step?: number } = {}) => (151 <Field label={label} hint={opts.hint} error={errors[k]} optional={opts.empty ? t("cOptional") : undefined}>152 {(p) => <input {...p} type="number" inputMode="decimal" className="input" min={min} max={max} step={opts.step ?? 1} value={f[k] as Num} onChange={(e) => set(k, numOrEmpty(e.target.value) as never)} />}153 </Field>154 );155 const validate = (): Record<string, string> => {156 const e: Record<string, string> = {};157 if (f.title.trim().length < 1) e.title = t("tyErrTitle");158 if (!SLUG.test(f.slug)) e.slug = t("tyErrSlug");159 const range = (k: keyof Form, min: number, max: number, empty = false) => {160 const v = f[k] as Num;161 if (v === "") { if (!empty) e[k] = t("cErrRange", { min, max }); return; }162 if (Number.isNaN(v) || v < min || v > max || (k !== "notice_hours" && !Number.isInteger(v))) e[k] = t("cErrRange", { min, max });163 };164 range("duration_min", 5, 720); range("hold_ttl_hours", 1, 720); range("buffer_before_min", 0, 240); range("buffer_after_min", 0, 240); range("slot_step_min", 5, 240);165 range("notice_hours", 0, 720); range("horizon_days", 1, 365); range("daily_limit", 1, 100, true); range("weekly_limit", 1, 500, true); range("reminder_hours", 1, 336, true);166 if (!f.languages.length) e.languages = t("tyErrLanguages");167 f.questions.forEach((q, i) => { if (!q.label.trim()) e[`q${i}`] = t("tyErrQuestion"); });168 return e;169 };170 const save = async (ev: React.FormEvent) => {171 ev.preventDefault();172 const e = validate(); setErrors(e); setFormError(null);173 if (Object.keys(e).length) { setFormError(t("cErrForm")); return; }174 setBusy(true);175 try {176 await api(id ? `/api/v1/admin/event-types/${id}` : "/api/v1/admin/event-types", { method: id ? "PATCH" : "POST", body: toBody(f) });177 toast.ok(t("tySaved")); onSaved();178 } catch (err) {179 if (err instanceof ApiClientError && err.code === "slug_taken") { setErrors({ slug: t("tyErrSlugTaken") }); setFormError(t("cErrForm")); }180 else setFormError(errorText(err, locale));181 setBusy(false);182 }183 };184 const radio = <K extends keyof Form>(k: K, value: Form[K], label: string, hint: string) => (185 <label className={cx("flex gap-3 items-start p-3 rounded-2xl border cursor-pointer", f[k] === value ? "border-blue bg-blue-wash" : "border-line-strong")}>186 <input type="radio" name={String(k)} className="mt-1" checked={f[k] === value} onChange={() => set(k, value)} />187 <span><span className="block font-medium">{label}</span><span className="block text-[13px] t-mute">{hint}</span></span>188 </label>189 );190 const setQ = (i: number, patch: Partial<Question>) => set("questions", f.questions.map((q, k) => (k === i ? { ...q, ...patch } : q)));191192 return (193 <Sheet open onClose={onClose} title={id ? t("tyEditTitle") : t("tyNewTitle")} wide194 footer={<><Button onClick={onClose}>{t("cCancel")}</Button><Button variant="primary" type="submit" form="type-form" busy={busy}>{t("cSave")}</Button></>}>195 <form id="type-form" onSubmit={save} noValidate>196 <FormGroup title={t("tyGroupGeneral")}>197 <div className="grid sm:grid-cols-2 gap-4">198 <Field label={t("tyFieldTitle")} error={errors.title}>{(p) => <input {...p} className="input" value={f.title} maxLength={120} onChange={(e) => { const v = e.target.value; setF((c) => ({ ...c, title: v, slug: slugTouched ? c.slug : slugify(v) })); }} />}</Field>199 <Field label={t("tyFieldTitleEn")} optional={t("cOptional")}>{(p) => <input {...p} className="input" lang="en" value={f.title_en} maxLength={120} onChange={(e) => set("title_en", e.target.value)} />}</Field>200 </div>201 <Field label={t("tyFieldSlug")} error={errors.slug} hint={`${origin}/book/${f.slug || "…"}`}>202 {(p) => <input {...p} className="input font-mono" autoCapitalize="none" spellCheck={false} value={f.slug} maxLength={64} onChange={(e) => { setSlugTouched(true); set("slug", e.target.value.toLowerCase()); }} />}203 </Field>204 <div className="grid sm:grid-cols-2 gap-4">205 <Field label={t("tyFieldDesc")} optional={t("cOptional")}>{(p) => <textarea {...p} className="input" value={f.description} maxLength={2000} onChange={(e) => set("description", e.target.value)} />}</Field>206 <Field label={t("tyFieldDescEn")} optional={t("cOptional")}>{(p) => <textarea {...p} className="input" lang="en" value={f.description_en} maxLength={2000} onChange={(e) => set("description_en", e.target.value)} />}</Field>207 </div>208 <fieldset>209 <legend className="label">{t("tyFieldColor")}</legend>210 <div className="flex flex-wrap items-center gap-2">211 {SWATCHES.map((c) => (212 <button key={c} type="button" aria-pressed={f.color.toLowerCase() === c} aria-label={`${t("tyFieldColor")} ${c}`} onClick={() => set("color", c)}213 className={cx("w-11 h-11 rounded-full flex items-center justify-center cursor-pointer border-2", f.color.toLowerCase() === c ? "border-ink" : "border-transparent")}>214 <span className="w-8 h-8 rounded-full" style={{ background: c }} />215 </button>216 ))}217 <label className="inline-flex items-center gap-2 text-sm t-mute ml-1 min-h-[44px]">{t("tyColorCustom")}<input type="color" className="w-11 h-11 rounded-xl border border-line-strong bg-surface cursor-pointer" value={f.color} onChange={(e) => set("color", e.target.value)} /></label>218 </div>219 </fieldset>220 <fieldset>221 <legend className="label">{t("tyFieldLanguages")}</legend>222 <div className="flex flex-wrap gap-4">223 {(["fr", "en"] as const).map((l) => (224 <label key={l} className="inline-flex items-center gap-2 min-h-[44px] cursor-pointer">225 <input type="checkbox" className="w-5 h-5" checked={f.languages.includes(l)} onChange={(e) => set("languages", e.target.checked ? [...f.languages, l] : f.languages.filter((x) => x !== l))} />226 {l === "fr" ? "Français" : "English"}227 </label>228 ))}229 </div>230 {errors.languages ? <p className="error-text" role="alert">{errors.languages}</p> : null}231 </fieldset>232 </FormGroup>233234 <FormGroup title={t("tyGroupFormat")}>235 <div className="grid sm:grid-cols-2 gap-4">236 {num("duration_min", t("tyFieldDuration"), 5, 720)}237 <Field label={t("tyFieldKind")}>238 {(p) => (239 <select {...p} className="input" value={f.location_kind} onChange={(e) => set("location_kind", e.target.value as Kind)}>240 <option value="video">{t("tyKindVideo")}</option><option value="phone">{t("tyKindPhone")}</option><option value="in_person">{t("tyKindInPerson")}</option>241 </select>242 )}243 </Field>244 </div>245 <Field label={f.location_kind === "video" ? t("tyFieldDetailVideo") : f.location_kind === "phone" ? t("tyFieldDetailPhone") : t("tyFieldDetailPlace")} optional={t("cOptional")}246 hint={f.location_kind === "video" ? t("tyDetailHintVideo") : t("tyDetailHint")}>247 {(p) => <input {...p} className="input" value={f.location_detail} maxLength={500} onChange={(e) => set("location_detail", e.target.value)} />}248 </Field>249 <div className="grid sm:grid-cols-2 gap-4">250 <Field label={t("tyFieldInstr")} optional={t("cOptional")} hint={t("tyInstrHint")}>{(p) => <textarea {...p} className="input" value={f.instructions} maxLength={2000} onChange={(e) => set("instructions", e.target.value)} />}</Field>251 <Field label={t("tyFieldInstrEn")} optional={t("cOptional")}>{(p) => <textarea {...p} className="input" lang="en" value={f.instructions_en} maxLength={2000} onChange={(e) => set("instructions_en", e.target.value)} />}</Field>252 </div>253 </FormGroup>254255 <FormGroup title={t("tyGroupBooking")}>256 <fieldset>257 <legend className="label">{t("tyFieldApproval")}</legend>258 <div className="grid sm:grid-cols-2 gap-2">259 {radio("approval", "manual", t("tyApprovalManual"), t("tyApprovalManualHint"))}260 {radio("approval", "auto", t("tyApprovalAuto"), t("tyApprovalAutoHint"))}261 </div>262 </fieldset>263 <fieldset>264 <legend className="label">{t("tyFieldHold")}</legend>265 <div className="grid sm:grid-cols-2 gap-2">266 {radio("hold_policy", "hold", t("tyHoldHold"), t("tyHoldHoldHint"))}267 {radio("hold_policy", "none", t("tyHoldNone"), t("tyHoldNoneHint"))}268 </div>269 </fieldset>270 <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">271 {f.hold_policy === "hold" ? num("hold_ttl_hours", t("tyFieldHoldTtl"), 1, 720) : null}272 {num("slot_step_min", t("tyFieldStep"), 5, 240)}273 {num("notice_hours", t("tyFieldNotice"), 0, 720, { step: 0.25 })}274 {num("horizon_days", t("tyFieldHorizon"), 1, 365)}275 {num("buffer_before_min", t("tyFieldBufferBefore"), 0, 240)}276 {num("buffer_after_min", t("tyFieldBufferAfter"), 0, 240)}277 {num("daily_limit", t("tyFieldDaily"), 1, 100, { empty: true })}278 {num("weekly_limit", t("tyFieldWeekly"), 1, 500, { empty: true })}279 </div>280 <p className="hint !mt-0">{t("tyLimitsHint")}</p>281 </FormGroup>282283 <FormGroup title={t("tyGroupForm")}>284 <p className="hint !mt-0">{t("tyFormHint")}</p>285 <Toggle checked={f.ask_organization} label={t("tyAskOrg")} onChange={(v) => set("ask_organization", v)} />286 <Toggle checked={f.require_reason} label={t("tyRequireReason")} hint={t("tyRequireReasonHint")} onChange={(v) => set("require_reason", v)} />287 {f.questions.map((q, i) => (288 <div key={i} className="rounded-2xl border border-line-strong p-3 grid gap-3">289 <div className="flex items-center justify-between gap-2">290 <span className="t-eyebrow">{t("tyQuestionN", { n: i + 1 })}</span>291 <Button small variant="ghost" icon="trash" aria-label={`${t("cDelete")} — ${t("tyQuestionN", { n: i + 1 })}`} onClick={() => set("questions", f.questions.filter((_, k) => k !== i))} />292 </div>293 <div className="grid sm:grid-cols-2 gap-3">294 <Field label={t("tyQLabel")} error={errors[`q${i}`]}>{(p) => <input {...p} className="input" value={q.label} maxLength={120} onChange={(e) => setQ(i, { label: e.target.value })} />}</Field>295 <Field label={t("tyQLabelEn")} optional={t("cOptional")}>{(p) => <input {...p} className="input" lang="en" value={q.label_en} maxLength={120} onChange={(e) => setQ(i, { label_en: e.target.value })} />}</Field>296 </div>297 <div className="flex flex-wrap items-center gap-x-6 gap-y-2">298 <Field label={t("tyQKind")}>{(p) => <select {...p} className="input !w-auto" value={q.kind} onChange={(e) => setQ(i, { kind: e.target.value as Question["kind"] })}><option value="text">{t("tyQKindText")}</option><option value="textarea">{t("tyQKindArea")}</option></select>}</Field>299 <label className="inline-flex items-center gap-2 min-h-[44px] cursor-pointer"><input type="checkbox" className="w-5 h-5" checked={q.required} onChange={(e) => setQ(i, { required: e.target.checked })} />{t("tyQRequired")}</label>300 </div>301 </div>302 ))}303 {f.questions.length < 10 ? <div><Button small icon="plus" onClick={() => set("questions", [...f.questions, { key: "", label: "", label_en: "", kind: "text", required: false }])}>{t("tyAddQuestion")}</Button></div> : null}304 </FormGroup>305306 <FormGroup title={t("tyGroupVisitor")}>307 <Toggle checked={f.allow_visitor_cancel} label={t("tyAllowCancel")} onChange={(v) => set("allow_visitor_cancel", v)} />308 <Toggle checked={f.allow_visitor_reschedule} label={t("tyAllowReschedule")} hint={t("tyAllowRescheduleHint")} onChange={(v) => set("allow_visitor_reschedule", v)} />309 <div className="max-w-xs">{num("reminder_hours", t("tyFieldReminder"), 1, 336, { empty: true, hint: t("tyReminderHint") })}</div>310 </FormGroup>311312 <FormGroup title={t("tyGroupAvailability")}>313 <Toggle checked={f.custom_availability} label={t("tyCustomAvail")} hint={t("tyCustomAvailHint")} onChange={(v) => set("custom_availability", v)} />314 {f.custom_availability ? <Notice tone="info">{t("tyCustomAvailNote")} <a className="underline font-medium" href="/admin/availability">{t("tyCustomAvailLink")}</a></Notice> : null}315 </FormGroup>316317 <FormGroup title={t("tyGroupStatus")}>318 <Toggle checked={f.active} label={t("tyActive")} hint={f.active ? t("tyActiveHint") : t("tyInactiveHint")} onChange={(v) => set("active", v)} />319 </FormGroup>320 {formError ? <div className="mt-4"><Notice tone="danger">{formError}</Notice></div> : null}321 </form>322 </Sheet>323 );324}325