← Files WixARCHIVED FILE
skills/wix-headless-templates/bookings/project/src/components/bookings/ServiceBookingView.tsx
16.3 KB · Oct 8, 2026 · 12:02 UTC
// REFERENCE booking surface: time-zone label, staff filter (with photos), the slot picker ONE DAY AT
// A TIME (a strip of the days that have availability with the week pagers at its ends, then that
// day's times bucketed into morning / afternoon / evening) with next availability, a course's dates
// and seats, a class's upcoming sessions, participants, deposit choice, add-ons, the schema-driven
// form, and the CTA labelled from ctaState — on the @theme tokens. A week of a busy calendar is well
// over a hundred slots; listing every day at once buries the form and reads as a wall of numbers.
// Correct and complete; per the skill's model you design and build your own on useBookingFlow.
// Mount client:only — availability is timezone/session-specific.
import { useEffect, useState } from "react";
import { useBookingFlow } from "../../hooks/bookings/useBookingFlow";
import type { CtaState, ServiceDetail, Slot } from "../../wix/bookings/types";
const chip = (selected: boolean, disabled = false) =>
`rounded-control border px-4 py-1.5 text-sm transition-colors ${
disabled
? "cursor-not-allowed border-border text-muted-foreground line-through opacity-60"
: selected
? "border-primary bg-primary text-primary-foreground"
: "border-border text-foreground hover:bg-secondary"
}`;
const dayTile = (selected: boolean) =>
`w-[68px] shrink-0 rounded-md border px-1 py-2 text-center transition-colors ${selected ? "border-primary bg-primary text-primary-foreground" : "border-border text-foreground hover:bg-secondary"}`;
const pager = "w-[68px] shrink-0 rounded-md border border-border px-1 py-2 text-center text-xs leading-tight text-foreground transition-colors hover:bg-secondary";
const input = "w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-shadow focus:ring-2 focus:ring-primary";
const heading = "mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground";
// startLocal is "YYYY-MM-DDThh:mm:ss" (no zone): chars 11–13 are the local hour, no Date parsing needed.
const hourOf = (s: Slot): number => Number(s.startLocal.slice(11, 13));
const BUCKETS: { label: string; test: (h: number) => boolean }[] = [
{ label: "Morning", test: (h) => h < 12 },
{ label: "Afternoon", test: (h) => h >= 12 && h < 17 },
{ label: "Evening", test: (h) => h >= 17 },
];
const fmtDay = (dayKey: string, opts: Intl.DateTimeFormatOptions): string => {
try {
return new Date(`${dayKey}T12:00:00`).toLocaleDateString(undefined, opts);
} catch {
return dayKey;
}
};
const dateLabel = (iso: string | null): string => {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
} catch {
return iso.slice(0, 10);
}
};
/** The CTA label follows ctaState, then the money: the deposit when one is being paid, else the price. */
function ctaLabel(service: ServiceDetail, ctaState: CtaState, payDeposit: boolean): string {
if (ctaState === "viewCourse") return "Not available";
const verb = ctaState === "requestToBook" ? "Request to book" : "Book";
if (service.free) return `${verb} — free`;
if (service.deposit && payDeposit && service.deposit.amount) return `${verb} · deposit ${service.deposit.amount}`;
if (!service.price) return verb;
return `${verb} · ${service.priceFrom ? "from " : ""}${service.price}`;
}
export default function ServiceBookingView({ service }: { service: ServiceDetail }) {
const {
days,
nextWeek,
prevWeek,
jumpTo,
nextAvailable,
staffId,
setStaffId,
selectedSlot,
setSelectedSlot,
timeZoneLabel,
displayTimeZone,
customerCanChangeTimeZone,
setDisplayTimeZone,
course,
offeredDays,
sessions,
hasMoreSessions,
loadMoreSessions,
participants,
maxParticipants,
setParticipants,
payDeposit,
setPayDeposit,
addOnGroups,
addOns,
addOnsTotal,
toggleAddOn,
setAddOnQuantity,
canSelectMore,
formFields,
values,
setValue,
ctaState,
canBook,
book,
booking,
confirmed,
error,
} = useBookingFlow(service);
const [activeDay, setActiveDay] = useState<string | null>(null);
// Follow the data: land on the first day with availability, and don't strand the view on a day
// that left when the week moved.
useEffect(() => {
if (days && days.length && !days.some((d) => d.dayKey === activeDay)) setActiveDay(days[0].dayKey);
}, [days, activeDay]);
const isCourse = service.type === "COURSE";
const current = days?.find((d) => d.dayKey === activeDay) ?? days?.[0] ?? null;
const buckets = current ? BUCKETS.map((b) => ({ label: b.label, slots: current.slots.filter((s) => b.test(hourOf(s))) })).filter((b) => b.slots.length) : [];
// Switching day drops a slot chosen on another day — otherwise the CTA names a time that isn't on screen.
const pickDay = (dayKey: string) => {
setActiveDay(dayKey);
if (selectedSlot && selectedSlot.dayKey !== dayKey) setSelectedSlot(null);
};
if (confirmed) {
const when = isCourse ? (service.course?.startDate ? ` — starts ${dateLabel(service.course.startDate)}` : "") : selectedSlot ? ` — ${selectedSlot.dayKey} at ${selectedSlot.label}` : "";
return (
<div className="rounded-lg border border-border bg-secondary p-8 text-center">
<p className="text-lg font-semibold">{ctaState === "requestToBook" ? "Request sent" : "You're booked!"}</p>
<p className="mt-2 text-sm text-muted-foreground">
{service.name}
{when}.{" "}
{ctaState === "requestToBook" ? "You'll hear back once the request is approved." : "A confirmation email is on its way."}
</p>
</div>
);
}
return (
<div>
{isCourse && (
<div className="mb-5 rounded-lg border border-border p-4 text-sm">
<p className={heading}>Course</p>
{service.course?.startDate && (
<p>
{dateLabel(service.course.startDate)}
{service.course.endDate ? ` – ${dateLabel(service.course.endDate)}` : ""}
</p>
)}
{offeredDays.length > 0 && <p className="mt-1 text-muted-foreground">{offeredDays.map((d) => d.slice(0, 3)).join(", ")}</p>}
{course && course.spotsLeft != null && (
<p className="mt-1 text-muted-foreground">{course.full ? "Full" : `${course.spotsLeft} spot${course.spotsLeft === 1 ? "" : "s"} left`}</p>
)}
{service.course?.ended && <p className="mt-1 text-muted-foreground">This course has ended.</p>}
</div>
)}
{service.staff.length > 1 && !isCourse && (
<div className="mb-5">
<p className={heading}>Staff</p>
<div className="flex flex-wrap gap-2">
<button type="button" className={chip(staffId === undefined)} onClick={() => setStaffId(undefined)}>
Anyone
</button>
{service.staff.map((m) => (
<button key={m.id} type="button" className={`${chip(staffId === m.id)} inline-flex items-center gap-2`} onClick={() => setStaffId(m.id)}>
{m.imageUrl && <img src={m.imageUrl} alt="" className="h-5 w-5 rounded-full object-cover" />}
{m.name}
</button>
))}
</div>
</div>
)}
{!isCourse && (
<>
<p className={heading}>Pick a day</p>
{days === null ? (
<div className="flex gap-2" aria-busy="true">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="h-14 w-[68px] animate-pulse rounded-md bg-secondary" />
))}
</div>
) : (
<div className="flex gap-2 overflow-x-auto pb-1" role="group" aria-label="Choose a day">
<button type="button" onClick={prevWeek} className={pager} aria-label="Previous week">
Earlier
</button>
{days.length === 0 && (
<p className="self-center text-sm text-muted-foreground">
No times this week.{" "}
{nextAvailable && nextAvailable.length > 0 ? (
<button type="button" className="underline" onClick={() => jumpTo(nextAvailable[0].dayKey)}>
Next available: {nextAvailable[0].dayKey} at {nextAvailable[0].label}
</button>
) : nextAvailable === null ? (
"Looking for the next available time…"
) : (
"Try the next week."
)}
</p>
)}
{days.map((day) => (
<button key={day.dayKey} type="button" aria-pressed={day.dayKey === current?.dayKey} className={dayTile(day.dayKey === current?.dayKey)} onClick={() => pickDay(day.dayKey)}>
<span className="block text-[10px] uppercase tracking-wide opacity-70">{fmtDay(day.dayKey, { weekday: "short" })}</span>
<span className="block text-lg font-semibold leading-tight">{fmtDay(day.dayKey, { day: "numeric" })}</span>
</button>
))}
<button type="button" onClick={nextWeek} className={pager} aria-label="Next week">
More
<br />
dates
</button>
</div>
)}
{current && (
<div className="mt-5">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<p className="text-sm font-medium">{fmtDay(current.dayKey, { weekday: "long", month: "short", day: "numeric" })}</p>
<span className="text-xs text-muted-foreground">
{current.slots.length} {current.slots.length === 1 ? "time" : "times"}
{timeZoneLabel ? ` · in ${timeZoneLabel}` : ""}
</span>
</div>
{customerCanChangeTimeZone && (
<p className="mb-3 text-xs text-muted-foreground">
<button type="button" className="underline" onClick={() => setDisplayTimeZone(displayTimeZone === "BUSINESS" ? "CUSTOMER" : "BUSINESS")}>
{displayTimeZone === "BUSINESS" ? "Show in my time zone" : "Show in the business time zone"}
</button>
</p>
)}
<div className="space-y-4">
{buckets.map((b) => (
<div key={b.label}>
<p className="mb-2 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">{b.label}</p>
<div className="flex flex-wrap gap-2">
{b.slots.map((s) => (
<button
key={s.key}
type="button"
disabled={!s.bookable}
title={s.location?.name || undefined}
className={chip(selectedSlot?.key === s.key, !s.bookable)}
onClick={() => setSelectedSlot(s)}
>
{s.label}
{!s.bookable ? (s.waitlistCapacity ? " · Full, waitlist" : " · Full") : s.remainingCapacity != null && s.remainingCapacity <= 3 ? ` · ${s.remainingCapacity} left` : ""}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{sessions && sessions.length > 0 && (
<div className="mt-6">
<p className={heading}>Upcoming sessions</p>
<ul className="space-y-1 text-sm">
{sessions.map((s) => (
<li key={s.id} className={s.isCancelled ? "text-muted-foreground line-through" : ""}>
{s.dayLabel} · {s.label}
{s.staff.length > 0 ? ` · ${s.staff.map((m) => m.name).join(", ")}` : ""}
{s.isFullyBooked ? " · Full" : s.spotsLeft != null ? ` · ${s.spotsLeft} left` : ""}
</li>
))}
</ul>
{hasMoreSessions && (
<button type="button" className="mt-2 text-sm underline" onClick={() => void loadMoreSessions()}>
More sessions
</button>
)}
</div>
)}
{maxParticipants > 1 && (
<label className="mt-6 block max-w-md">
<span className={heading}>Participants</span>
<select value={participants} onChange={(e) => setParticipants(Number(e.target.value))} className={input}>
{Array.from({ length: maxParticipants }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</label>
)}
{addOnGroups.length > 0 && (
<div className="mt-6 space-y-4">
{addOnGroups.map((g) => (
<div key={g.id}>
<p className={heading}>{g.prompt || g.name}</p>
<div className="flex flex-wrap gap-2">
{g.addOns.map((a) => {
const q = addOns[a.id] ?? 0;
const blocked = q === 0 && !canSelectMore(g.id);
return (
<span key={a.id} className="inline-flex items-center gap-1">
<button type="button" disabled={blocked} className={chip(q > 0, blocked)} onClick={() => toggleAddOn(a.id)}>
{a.name}
{a.price ? ` · ${a.price}` : ""}
{a.durationMinutes ? ` · +${a.durationMinutes} min` : ""}
</button>
{q > 0 && a.maxQuantity && a.maxQuantity > 1 && (
<select value={q} onChange={(e) => setAddOnQuantity(a.id, Number(e.target.value))} className="rounded-md border border-border bg-background px-2 py-1 text-sm">
{Array.from({ length: a.maxQuantity }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
)}
</span>
);
})}
</div>
</div>
))}
{addOnsTotal && <p className="text-sm text-muted-foreground">Add-ons: {addOnsTotal} (added at the venue)</p>}
</div>
)}
{service.deposit?.fullUpfrontAllowed && (
<div className="mt-6">
<p className={heading}>Payment</p>
<div className="flex flex-wrap gap-2">
<button type="button" className={chip(payDeposit)} onClick={() => setPayDeposit(true)}>
Pay deposit{service.deposit.amount ? ` · ${service.deposit.amount}` : ""}
</button>
<button type="button" className={chip(!payDeposit)} onClick={() => setPayDeposit(false)}>
Pay in full{service.price ? ` · ${service.price}` : ""}
</button>
</div>
</div>
)}
<div className="mt-6 grid max-w-md gap-3">
{formFields.map((f) => (
<label key={f.target} className="block">
<span className={heading}>{f.label}{f.required ? " *" : ""}</span>
{f.options?.length ? (
<select value={values[f.target] ?? ""} onChange={(e) => setValue(f.target, e.target.value)} className={input}>
<option value="">Choose…</option>
{f.options.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
) : (
<input
type={f.type === "EMAIL" ? "email" : f.type === "PHONE" ? "tel" : f.type === "NUMBER" ? "number" : f.type === "URL" ? "url" : "text"}
value={values[f.target] ?? ""}
required={f.required}
onChange={(e) => setValue(f.target, e.target.value)}
className={input}
/>
)}
</label>
))}
</div>
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<button
type="button"
disabled={!canBook || booking}
onClick={() => book().catch(() => {})}
className="mt-5 rounded-control bg-primary px-8 py-3 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
>
{booking ? "Booking…" : ctaLabel(service, ctaState, payDeposit)}
</button>
</div>
);
}
SHA-256: ffc7003e7b4ee2e6e3287c212c2222e98b69fb8e4b0ac152c86dc10e1b49d8b8