"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { CalendarDays, Scissors, Store } from "lucide-react";
import { cn } from "@/lib/utils";
import { AppointmentPanel } from "./appointment-panel";
import { BookingFlow } from "./booking-flow";
import { fmtShort, fmtTime } from "./booking-utils";
import { Toasts, useToasts } from "./booking-ui";
import { BUSINESS, SEED_APPOINTMENTS, SEED_NOW_MINUTES, SEED_TODAY, SERVICES, STAFF } from "./data";
import { ScheduleView, type ScheduleMode } from "./schedule-view";
import type { Appointment, AppointmentStatus, BusinessInfo, Service, StaffMember } from "./types";
export type { Appointment, AppointmentStatus, BusinessInfo, Service, StaffMember } from "./types";
export interface BookingAppProps {
business?: BusinessInfo;
services?: Service[];
staff?: StaffMember[];
/** Existing appointments. Defaults to a seeded, deterministic week. */
initialAppointments?: Appointment[];
/** "Today" (YYYY-MM-DD) and the current time in minutes — pass real values in production. */
today?: string;
nowMinutes?: number;
/** Which side opens first. */
defaultMode?: "book" | "admin";
/** Called when a customer completes a booking. */
onBook?: (appointment: Appointment) => void;
/** Called after any appointment change (status, reschedule, new booking). */
onChange?: (appointments: Appointment[]) => void;
className?: string;
}
let seq = 0;
export function BookingApp({
business = BUSINESS,
services = SERVICES,
staff = STAFF,
initialAppointments,
today = SEED_TODAY,
nowMinutes = SEED_NOW_MINUTES,
defaultMode = "book",
onBook,
onChange,
className,
}: BookingAppProps) {
const [appointments, setAppointments] = React.useState<Appointment[]>(initialAppointments ?? SEED_APPOINTMENTS);
const [mode, setMode] = React.useState<"book" | "admin">(defaultMode);
const [schedDate, setSchedDate] = React.useState(today);
const [schedMode, setSchedMode] = React.useState<ScheduleMode>("day");
const [openId, setOpenId] = React.useState<string | null>(null);
const [focusId, setFocusId] = React.useState<string | null>(null);
const { toasts, push, dismiss } = useToasts();
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(appointments);
}, [appointments]);
const patch = (id: string, p: Partial<Appointment>) => setAppointments((list) => list.map((a) => (a.id === id ? { ...a, ...p } : a)));
const move = (id: string, p: Pick<Appointment, "date" | "start" | "staffId">) => {
const before = appointments.find((a) => a.id === id);
if (!before) return;
patch(id, p);
const who = 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: () => patch(id, { date: before.date, start: before.start, staffId: before.staffId }),
});
};
const setStatus = (id: string, status: AppointmentStatus) => {
const before = appointments.find((a) => a.id === id);
if (!before || before.status === status) return;
patch(id, { status });
push(`${before.customer.name}: ${status}`, { undo: () => patch(id, { status: before.status }) });
};
const open = appointments.find((a) => a.id === openId) ?? null;
const pending = appointments.filter((a) => a.status === "pending" && a.date >= today).length;
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={open ? true : undefined} className="flex h-14 shrink-0 items-center gap-3 border-b px-3 sm:px-5">
<span className="grid size-8 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="ml-auto flex rounded-xl bg-muted p-1 sm:ml-6">
{(
[
{ id: "book", label: "Book online", short: "Book", icon: Store },
{ id: "admin", label: "Schedule", short: "Schedule", icon: CalendarDays },
] as const
).map((t) => {
const on = mode === t.id;
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-3 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", on ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{on && <motion.span layoutId="booking-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" && 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 hidden text-right text-[11px] text-muted-foreground md:block">
<div className="font-medium text-foreground">{fmtShort(today)}</div>
<div>{business.address}</div>
</div>
</header>
<div inert={open ? true : undefined} className="min-h-0 flex-1">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={mode} className="h-full" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
{mode === "book" ? (
<BookingFlow
business={business}
services={services}
staff={staff}
appointments={appointments}
today={today}
nowMinutes={nowMinutes}
onBook={(a) => {
const appt: Appointment = { ...a, id: `bk${(++seq).toString(36)}${a.date.slice(5).replace("-", "")}${a.start}` };
setAppointments((list) => [...list, appt]);
onBook?.(appt);
return appt;
}}
onViewSchedule={(a) => {
setSchedDate(a.date);
setSchedMode("day");
setMode("admin");
setFocusId(a.id);
}}
/>
) : (
<ScheduleView
business={business}
services={services}
staff={staff}
appointments={appointments}
today={today}
nowMinutes={nowMinutes}
date={schedDate}
mode={schedMode}
focusId={focusId}
onDate={setSchedDate}
onMode={setSchedMode}
onOpen={setOpenId}
onMove={move}
onReject={(reason) => push(reason, { tone: "error" })}
/>
)}
</motion.div>
</AnimatePresence>
</div>
<AnimatePresence>
{open && (
<>
<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={services}
staff={staff}
appointments={appointments}
onClose={() => setOpenId(null)}
onStatus={(s) => setStatus(open.id, s)}
onReschedule={(p) => move(open.id, p)}
/>
</>
)}
</AnimatePresence>
<Toasts toasts={toasts} dismiss={dismiss} />
</div>
</MotionConfig>
);
}
export default BookingApp;