Fazekit

Code

"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;

More in Business

View all →