"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import type { SupabaseClient } from "@supabase/supabase-js";
import { CalendarDays, CalendarRange, Database, LogOut, RefreshCw, Scissors, Settings2, Store, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { AppointmentPanel } from "./appointment-panel";
import { toBookingError, type BookingBackend } from "./booking-backend";
import { BookingFlow } from "./booking-flow";
import { addDays, fmtShort, fmtTime, startOfWeek } from "./booking-utils";
import { Avatar, Button, IconButton, Spinner, Toasts, useQuery, useToasts } from "./booking-ui";
import { DEMO_ACCOUNTS } from "./data";
import { createDemoBackend, type DemoBackendOptions } from "./demo-backend";
import { ManageBooking } from "./manage-booking";
import { ScheduleView, type ScheduleMode } from "./schedule-view";
import { SetupView } from "./setup-view";
import { SignInDialog, SignInForm } from "./sign-in";
import { createSupabaseBackend } from "./supabase-backend";
import type { Appointment, AppointmentStatus, Catalog, Clock, Session } from "./types";
export type { BookingBackend } from "./booking-backend";
export { createDemoBackend } from "./demo-backend";
export { createSupabaseBackend } from "./supabase-backend";
export type * from "./types";
export interface BookingKitProps {
/** A Supabase client → live mode (run backend/migrations first). */
supabase?: SupabaseClient;
/** Or any custom implementation of BookingBackend. Takes precedence over `supabase`. */
backend?: BookingBackend;
/** Options for the in-memory demo backend (seed data, fixed clock) when neither prop is given. */
demo?: DemoBackendOptions;
/** Which side opens first. */
defaultMode?: "book" | "admin";
/** Open the manage view for this secret token (otherwise read from `?manage=` in the URL). */
manageToken?: string;
/** Builds the private link emailed to customers. Default: current page + `?manage=<token>`. */
manageUrl?: (token: string) => string;
/** Called after a customer completes a booking. */
onBooked?: (booking: Appointment, token: string) => void;
/** Show the dismissible "Demo mode" badge in demo mode (default true). */
showDemoBadge?: boolean;
className?: string;
}
type Mode = "book" | "manage" | "admin";
const noopSubscribe = () => () => {};
const readManageParam = () => new URLSearchParams(window.location.search).get("manage");
const defaultManageUrl = (token: string) => {
if (typeof window === "undefined") return `?manage=${token}`;
const u = new URL(window.location.href);
u.search = "";
u.hash = "";
u.searchParams.set("manage", token);
return u.toString();
};
export function BookingKit({ supabase, backend: backendProp, demo, defaultMode = "book", manageToken, manageUrl = defaultManageUrl, onBooked, showDemoBadge = true, className }: BookingKitProps) {
const [backend] = React.useState<BookingBackend>(() => backendProp ?? (supabase ? createSupabaseBackend(supabase) : createDemoBackend(demo)));
const [catalog, setCatalog] = React.useState<Catalog | null>(() => backend.snapshot?.() ?? null);
const [catalogError, setCatalogError] = React.useState<string | null>(null);
const [clock, setClock] = React.useState<Clock | null>(() => (backend.mode === "demo" && catalog ? backend.clock(catalog.business) : null));
const [session, setSession] = React.useState<Session | null>(null);
const [authReady, setAuthReady] = React.useState(false);
// Secret link: ?manage=<token> (read without an effect so the first client render already shows it).
const urlToken = React.useSyncExternalStore(noopSubscribe, readManageParam, () => null);
const [modeState, setMode] = React.useState<Mode | null>(manageToken ? "manage" : null);
const [tokenState, setToken] = React.useState<string | null>(null);
const mode: Mode = modeState ?? (urlToken ? "manage" : defaultMode);
const token = tokenState ?? manageToken ?? urlToken;
const [adminTab, setAdminTab] = React.useState<"schedule" | "setup">("schedule");
const [schedDate, setSchedDate] = React.useState<string | null>(null);
const [schedMode, setSchedMode] = React.useState<ScheduleMode>("day");
const [openId, setOpenId] = React.useState<string | null>(null);
const [focusId, setFocusId] = React.useState<string | null>(null);
const [signInOpen, setSignInOpen] = React.useState(false);
const [badge, setBadge] = React.useState(true);
const [patch, setPatch] = React.useState<{ base: unknown; id: string; value: Partial<Appointment> } | null>(null);
const [catalogNonce, setCatalogNonce] = React.useState(0);
const { toasts, push, dismiss } = useToasts();
/* ------------------------------ bootstrapping ----------------------------- */
React.useEffect(() => {
let live = true;
backend.loadCatalog().then(
(c) => {
if (!live) return;
setCatalog(c);
setCatalogError(null);
setClock(backend.clock(c.business));
},
(e: unknown) => live && setCatalogError(toBookingError(e).message),
);
return () => {
live = false;
};
}, [backend, catalogNonce]);
React.useEffect(() => {
if (backend.mode === "demo" || !catalog) return;
const id = window.setInterval(() => setClock(backend.clock(catalog.business)), 30_000);
return () => window.clearInterval(id);
}, [backend, catalog]);
React.useEffect(() => {
let live = true;
backend.getSession().then((s) => {
if (!live) return;
setSession(s);
setAuthReady(true);
});
const off = backend.onAuthChange((s) => {
setSession(s);
setAuthReady(true);
});
return () => {
live = false;
off();
};
}, [backend]);
/* -------------------------------- schedule -------------------------------- */
const isStaff = session?.role === "staff" || session?.role === "admin";
const date = schedDate ?? clock?.date ?? "";
const from = date ? startOfWeek(date) : "";
const to = from ? addDays(from, 6) : "";
const listQ = useQuery(
mode === "admin" && isStaff && from ? `sched|${from}|${session?.userId}` : null,
async () => {
const [bookings, timeOff] = await Promise.all([backend.listBookings(from, to), backend.listTimeOff(from, to)]);
return { bookings, timeOff };
},
{ keepPrevious: true },
);
const reloadRef = React.useRef(listQ.reload);
React.useLayoutEffect(() => {
reloadRef.current = listQ.reload;
});
React.useEffect(() => backend.onBookingsChange(() => reloadRef.current()), [backend]);
const base = listQ.data;
const appointments = React.useMemo(() => {
const list = base?.bookings ?? [];
return patch && patch.base === base ? list.map((a) => (a.id === patch.id ? { ...a, ...patch.value } : a)) : list;
}, [base, patch]);
const timeOff = base?.timeOff ?? [];
const move = async (id: string, p: Pick<Appointment, "date" | "start" | "staffId">, undoable = true) => {
const before = appointments.find((a) => a.id === id);
if (!before || !catalog) return;
setPatch({ base, id, value: p });
try {
await backend.moveBooking(id, p);
listQ.reload();
if (undoable) {
const who = catalog.staff.find((s) => s.id === p.staffId)?.name.split(" ")[0];
push(`Moved ${before.customer.name} to ${fmtShort(p.date)}, ${fmtTime(p.start)}${p.staffId !== before.staffId ? ` with ${who}` : ""}`, {
undo: () => void move(id, { date: before.date, start: before.start, staffId: before.staffId }, false),
});
}
} catch (e) {
setPatch(null);
push(toBookingError(e).message, { tone: "error" });
}
};
const setStatus = async (id: string, status: AppointmentStatus, undoable = true) => {
const before = appointments.find((a) => a.id === id);
if (!before || before.status === status) return;
setPatch({ base, id, value: { status } });
try {
await backend.setBookingStatus(id, status);
listQ.reload();
if (undoable) push(`${before.customer.name}: ${status}`, { undo: () => void setStatus(id, before.status, false) });
} catch (e) {
setPatch(null);
push(toBookingError(e).message, { tone: "error" });
}
};
const signOut = async () => {
await backend.signOut();
setOpenId(null);
push("Signed out");
};
const open = appointments.find((a) => a.id === openId) ?? null;
const pending = appointments.filter((a) => a.status === "pending" && clock && a.date >= clock.date).length;
const blocked = !!open || signInOpen;
/* --------------------------------- render --------------------------------- */
if (!catalog || !clock) {
return (
<div className={cn("grid h-[760px] w-full place-items-center bg-background text-foreground", className)}>
{catalogError ? (
<div className="max-w-sm p-6 text-center">
<p className="text-sm font-medium">Couldn’t load the booking page</p>
<p className="mt-1 text-xs text-muted-foreground">{catalogError}</p>
<Button className="mx-auto mt-4" onClick={() => setCatalogNonce((n) => n + 1)}>
<RefreshCw className="size-3.5" aria-hidden /> Retry
</Button>
</div>
) : (
<Spinner className="size-6 text-primary" />
)}
</div>
);
}
const { business } = catalog;
const tabs = [
{ id: "book" as const, label: "Book online", short: "Book", icon: Store },
{ id: "admin" as const, label: "Schedule", short: "Admin", icon: CalendarDays },
];
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", className)}>
<header inert={blocked ? true : undefined} className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:gap-3 sm:px-5">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-primary to-fuchsia-500 text-white shadow-sm shadow-primary/30">
<Scissors className="size-4" aria-hidden />
</span>
<div className="hidden min-w-0 sm:block">
<div className="truncate text-sm font-semibold leading-tight">{business.name}</div>
<div className="truncate text-[11px] text-muted-foreground">{business.tagline}</div>
</div>
<div role="tablist" aria-label="Mode" className="flex rounded-xl bg-muted p-1 sm:ml-4">
{tabs.map((t) => {
const on = mode === t.id || (t.id === "book" && mode === "manage");
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={on}
onClick={() => {
setMode(t.id);
setOpenId(null);
}}
className={cn("relative flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3", on ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{on && <motion.span layoutId="bk-kit-mode" className="absolute inset-0 rounded-lg bg-background shadow-xs dark:bg-accent" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<t.icon className="relative size-3.5" aria-hidden />
<span className="relative sm:hidden">{t.short}</span>
<span className="relative hidden sm:inline">{t.label}</span>
{t.id === "admin" && isStaff && pending > 0 && <span className="relative grid h-4 min-w-4 place-items-center rounded-full bg-amber-500 px-1 text-[10px] font-bold tabular-nums text-white">{pending}</span>}
</button>
);
})}
</div>
<div className="ml-auto flex min-w-0 items-center gap-2">
<div className="hidden text-right text-[11px] text-muted-foreground lg:block">
<div className="font-medium text-foreground">{fmtShort(clock.date)}</div>
<div>{business.address}</div>
</div>
{session && (
<div className="flex min-w-0 items-center gap-1.5 rounded-full border bg-card py-0.5 pl-0.5 pr-0.5 sm:pr-1 lg:ml-2">
<Avatar name={session.name || session.email} size="sm" />
<span className="hidden min-w-0 max-w-28 truncate text-xs font-medium md:inline">{session.name || session.email}</span>
<span className="hidden rounded-full bg-muted px-1.5 text-[10px] font-semibold capitalize text-muted-foreground md:inline">{session.role}</span>
<IconButton label="Sign out" onClick={() => void signOut()} className="size-6 rounded-full">
<LogOut className="size-3.5" />
</IconButton>
</div>
)}
</div>
</header>
<AnimatePresence initial={false}>
{backend.mode === "demo" && showDemoBadge && badge && (
<motion.div key="demo" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="shrink-0 overflow-hidden border-b bg-gradient-to-r from-primary/[0.07] via-fuchsia-500/[0.06] to-transparent">
<div role="status" className="flex items-center gap-2 px-3 py-1.5 text-[11px] sm:px-5">
<Database className="size-3.5 shrink-0 text-primary" aria-hidden />
<span className="min-w-0 flex-1 truncate">
<span className="font-semibold">Demo mode</span>
<span className="text-muted-foreground"> — connect Supabase to go live. Data resets on reload.</span>
</span>
<button type="button" aria-label="Dismiss demo notice" onClick={() => setBadge(false)} className="grid size-5 shrink-0 place-items-center rounded text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<X className="size-3" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div inert={blocked ? true : undefined} className="min-h-0 flex-1">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={mode === "admin" ? `admin-${adminTab}` : mode === "manage" ? `manage-${token}` : "book"} className="h-full" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
{mode === "book" && (
<BookingFlow
backend={backend}
catalog={catalog}
clock={clock}
session={session}
manageUrl={manageUrl}
toast={push}
onBooked={(b, t) => {
onBooked?.(b, t);
}}
onManage={(t) => {
setToken(t);
setMode("manage");
}}
onSignIn={() => setSignInOpen(true)}
onSignOut={() => void signOut()}
onViewSchedule={
isStaff
? (a) => {
setSchedDate(a.date);
setSchedMode("day");
setAdminTab("schedule");
setMode("admin");
setFocusId(a.id);
}
: undefined
}
/>
)}
{mode === "manage" && token && <ManageBooking backend={backend} catalog={catalog} clock={clock} token={token} toast={push} onBack={() => setMode("book")} />}
{mode === "admin" &&
(!authReady ? (
<div className="grid h-full place-items-center">
<Spinner className="size-6 text-primary" />
</div>
) : !isStaff ? (
<div className="grid h-full place-items-center overflow-y-auto p-6">
<SignInForm
title={session ? "Staff access only" : "Staff sign-in"}
sub={session ? `You're signed in as ${session.email} (customer). Sign in with a staff or admin account.` : "Sign in to see the schedule, move bookings and manage services, hours and time off."}
onSignIn={(e, p) => backend.signIn(e, p)}
demoAccounts={backend.mode === "demo" ? DEMO_ACCOUNTS.filter((a) => a.role !== "customer") : undefined}
/>
</div>
) : (
<div className="flex h-full min-h-0 flex-col">
<div role="tablist" aria-label="Admin sections" className="flex items-center gap-1 border-b px-3 pt-1.5 sm:px-5">
{(
[
{ id: "schedule", label: "Schedule", icon: CalendarRange },
{ id: "setup", label: session?.role === "admin" ? "Setup" : "Time off", icon: Settings2 },
] as const
).map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={adminTab === t.id}
onClick={() => {
setAdminTab(t.id);
setOpenId(null);
}}
className={cn("relative flex h-8 items-center gap-1.5 px-2.5 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", adminTab === t.id ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
<t.icon className="size-3.5" aria-hidden /> {t.label}
{adminTab === t.id && <motion.span layoutId="bk-admin-tab" className="absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-primary" />}
</button>
))}
</div>
<div className="min-h-0 flex-1">
{adminTab === "schedule" ? (
<ScheduleView
business={business}
services={catalog.services}
staff={catalog.staff}
appointments={appointments}
timeOff={timeOff}
session={session}
loading={listQ.loading}
today={clock.date}
nowMinutes={clock.minutes}
date={date}
mode={schedMode}
focusId={focusId}
onDate={setSchedDate}
onMode={setSchedMode}
onOpen={setOpenId}
onMove={(id, p) => void move(id, p)}
onReject={(reason) => push(reason, { tone: "error" })}
/>
) : (
session && (
<SetupView
backend={backend}
catalog={catalog}
clock={clock}
session={session}
toast={push}
onCatalog={(c) => {
setCatalog(c);
listQ.reload();
}}
/>
)
)}
</div>
</div>
))}
</motion.div>
</AnimatePresence>
</div>
<AnimatePresence>
{open && mode === "admin" && (
<>
<motion.div key="scrim" aria-hidden initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setOpenId(null)} className="absolute inset-0 z-40 bg-foreground/10 dark:bg-black/50" />
<AppointmentPanel
key={`${open.id}-${open.date}-${open.start}-${open.staffId}`}
appt={open}
business={business}
services={catalog.services}
staff={catalog.staff}
appointments={appointments}
timeOff={timeOff}
session={session}
onClose={() => setOpenId(null)}
onStatus={(s) => void setStatus(open.id, s)}
onReschedule={(p) => void move(open.id, p)}
/>
</>
)}
{signInOpen && (
<SignInDialog
key="signin"
title="Sign in to book faster"
sub="Your details are filled in and bookings are saved to your account."
demoAccounts={backend.mode === "demo" ? DEMO_ACCOUNTS : undefined}
onClose={() => setSignInOpen(false)}
onSignIn={async (e, p) => {
const s = await backend.signIn(e, p);
setSignInOpen(false);
push(`Signed in as ${s.name || s.email}`);
}}
/>
)}
</AnimatePresence>
<Toasts toasts={toasts} dismiss={dismiss} />
</div>
</MotionConfig>
);
}
export default BookingKit;