Fazekit

Code

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

More in Business

View all →