Files
Bookra/apps/frontend/src/routes/public-booking-route.tsx
T
Tomas DvorakandDevin 33b6e2a2a6 feat(intake): structured per-service intake questions
Services declare up to 8 intake fields (text/yesno, required) stored
as JSONB; shared/intake.go is the single normalization point — slug
keys, label/dup/unknown-key rules, yes/no canonicalization. Booking
create validates answers against the service declaration (missing
required -> 400), unknown keys are dropped before persist.

Answers surface on UpcomingBooking and CustomerBookingView; the
services page gains a compact editor and the public form renders
declared questions under contact details. Verified live end-to-end.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 18:04:32 +02:00

632 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { A, useParams } from "@solidjs/router";
import { For, Show, createMemo, createResource, createSignal } from "solid-js";
import { apiClient } from "../lib/api-client";
import { useI18n } from "../providers/i18n-provider";
import type { components } from "@bookra/api-client/generated/types";
import { BookraCharacter } from "../components/bookra-character";
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Input, SkeletonCard, Textarea } from "../components/ui";
type PublicService = components["schemas"]["PublicService"];
type PublicStaff = components["schemas"]["PublicStaff"];
export function PublicBookingRoute() {
const params = useParams();
const i18n = useI18n();
const tenantSlug = () => params.tenantSlug;
const [bookingResult, setBookingResult] = createSignal<string | null>(null);
const [bookingError, setBookingError] = createSignal<string | null>(null);
const [submittingSlot, setSubmittingSlot] = createSignal<string | null>(null);
const [customerName, setCustomerName] = createSignal("");
const [customerEmail, setCustomerEmail] = createSignal("");
const [customerPhone, setCustomerPhone] = createSignal("");
const [notes, setNotes] = createSignal("");
const [intakeAnswers, setIntakeAnswers] = createSignal<Record<string, string>>({});
const [highlightContact, setHighlightContact] = createSignal(false);
const [selectedServiceId, setSelectedServiceId] = createSignal<string | null>(null);
const [selectedStaffId, setSelectedStaffId] = createSignal<string | null>(null);
const [selectedDate, setSelectedDate] = createSignal<string | null>(null);
let contactFormRef: HTMLDivElement | undefined;
const [catalog] = createResource(() => {
const slug = tenantSlug();
if (!slug) return null;
return apiClient.GET("/v1/public/tenants/{tenantSlug}/catalog", {
params: { path: { tenantSlug: slug } },
});
});
const [availability, { refetch }] = createResource(() => {
const slug = tenantSlug();
if (!slug) return null;
return apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", {
params: {
path: {
tenantSlug: slug,
},
},
});
});
const dayParams = createMemo(() => {
const slug = tenantSlug();
const serviceId = selectedServiceId();
const date = selectedDate();
if (!slug || !serviceId || !date) return null;
return { slug, serviceId, date, staffId: selectedStaffId() };
});
const [dayAvailability, { refetch: refetchDay }] = createResource(dayParams, (source) =>
apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", {
params: {
path: { tenantSlug: source.slug },
query: {
serviceId: source.serviceId,
date: source.date,
...(source.staffId ? { staffId: source.staffId } : {}),
},
},
}),
);
const services = createMemo<PublicService[]>(() => catalog.latest?.data?.services ?? []);
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
const intakeFields = createMemo(
() => services().find((s) => s.id === selectedServiceId())?.intakeFields ?? [],
);
const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class"));
const classDays = createMemo(() => {
const groups = new Map<string, any[]>();
for (const slot of classSlots()) {
const d = new Date(slot.startsAt);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const day = groups.get(key);
if (day) day.push(slot); else groups.set(key, [slot]);
}
return [...groups.entries()]
.sort(([, a], [, b]) => a[0].startsAt.localeCompare(b[0].startsAt))
.map(([key, slots]) => ({ key, slots, date: new Date(slots[0].startsAt) }));
});
const daySlots = createMemo(() => dayAvailability.latest?.data?.slots ?? []);
const hasCatalog = createMemo(() => services().length > 0);
const tenantTimezone = createMemo(
() => availability.latest?.data?.timezone ?? dayAvailability.latest?.data?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
);
const days = createMemo(() => {
const tz = tenantTimezone();
const locale = i18n.locale();
const items: { value: string; weekday: string; day: string; month: string }[] = [];
const now = Date.now();
for (let offset = 0; offset < 14; offset++) {
const date = new Date(now + offset * 86400000);
items.push({
value: new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(date),
weekday: new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: "short" }).format(date),
day: new Intl.DateTimeFormat(locale, { timeZone: tz, day: "numeric" }).format(date),
month: new Intl.DateTimeFormat(locale, { timeZone: tz, month: "short" }).format(date),
});
}
return items;
});
const formatPrice = (priceCents: number) => {
if (!priceCents) return i18n.t("booking.service.free");
return new Intl.NumberFormat(i18n.locale() === "cs" ? "cs-CZ" : "en-US", {
style: "currency",
currency: "CZK",
maximumFractionDigits: 0,
}).format(priceCents / 100);
};
const pickService = (serviceId: string) => {
setSelectedServiceId(serviceId);
setSelectedDate(null);
setBookingError(null);
};
const pickStaff = (staffId: string | null) => {
setSelectedStaffId(staffId);
};
const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => {
if (!customerName().trim() || !customerEmail().trim()) {
setBookingError(i18n.t("booking.customerRequired"));
setHighlightContact(true);
contactFormRef?.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => setHighlightContact(false), 2000);
return;
}
setSubmittingSlot(slot.startsAt);
setBookingResult(null);
setBookingError(null);
const slug = tenantSlug();
if (!slug) {
setBookingError(i18n.locale() === 'cs' ? 'Chybí identifikátor podniku' : 'Business identifier missing');
setSubmittingSlot(null);
return;
}
const response = await apiClient.POST("/v1/public/bookings", {
body: {
tenantSlug: slug,
bookingMode: slot.mode,
serviceId: slot.serviceId ?? undefined,
classSessionId: slot.classSessionId ?? undefined,
staffId: slot.staffId ?? undefined,
locationId: slot.locationId ?? undefined,
customerName: customerName().trim(),
customerEmail: customerEmail().trim(),
customerPhone: customerPhone().trim() || undefined,
notes: notes().trim(),
intake: intakeFields().length > 0 ? intakeAnswers() : undefined,
startsAt: slot.startsAt,
endsAt: slot.endsAt,
},
});
const payload = response as { data?: components["schemas"]["CreateBookingResponse"]; error?: unknown };
if (payload.error) {
setBookingError(`${i18n.t("booking.failed")}: ${String(payload.error)}`);
} else if (payload.data) {
setBookingResult(`${i18n.t("booking.created")} ${payload.data.reference}`);
setNotes("");
}
setSubmittingSlot(null);
void refetch();
void refetchDay();
};
return (
<section class="section-container py-12 animate-fade-in">
{/* Header */}
<div class="max-w-3xl mb-10">
<h1 class="text-display-lg font-semibold text-ink mb-4 animate-slide-up">
{i18n.t("booking.title")}
</h1>
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{i18n.t("booking.body")}
</p>
</div>
<Show when={!tenantSlug()}>
<div class="max-w-2xl mx-auto text-center py-16">
<BookraCharacter pose="404" size="lg" animate={false} class="mx-auto mb-6" />
<h2 class="text-display-md font-semibold text-ink mb-3">
{i18n.locale() === 'cs' ? 'Podnik nenalezen' : 'Business not found'}
</h2>
<p class="text-ink-muted mb-6">
{i18n.locale() === 'cs'
? 'Zkontrolujte URL adresu nebo kontaktujte provozovatele.'
: 'Please check the URL or contact the service provider.'}
</p>
<A href="/" class="btn-primary inline-flex items-center gap-2">
<HomeIcon />
{i18n.locale() === 'cs' ? 'Zpět na hlavní stránku' : 'Back to home'}
</A>
</div>
</Show>
<Show when={tenantSlug()}>
<div class="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
{/* Sidebar - Contact form - ORDER 1 on mobile, 2 on desktop */}
<div class="space-y-6 order-1 lg:order-2" ref={(el) => { contactFormRef = el; }}>
<Card class={`surface-elevated animate-slide-up transition-all duration-300 ${highlightContact() ? 'ring-2 ring-accent shadow-lg' : ''}`} style={{ "animation-delay": "0.3s" }}>
<CardHeader>
<CardTitle class="font-display text-xl">{i18n.t("booking.customer.title")}</CardTitle>
<CardDescription>{i18n.t("booking.customer.body")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Input
autocomplete="name"
label={i18n.t("booking.customer.name")}
onInput={(event) => setCustomerName(event.currentTarget.value)}
required
value={customerName()}
/>
<Input
autocomplete="email"
label={i18n.t("booking.customer.email")}
onInput={(event) => setCustomerEmail(event.currentTarget.value)}
required
type="email"
value={customerEmail()}
/>
<Input
autocomplete="tel"
label={i18n.t("booking.customer.phone")}
onInput={(event) => setCustomerPhone(event.currentTarget.value)}
type="tel"
value={customerPhone()}
/>
<Textarea
label={i18n.t("booking.customer.notes")}
onInput={(event) => setNotes(event.currentTarget.value)}
rows={3}
value={notes()}
/>
<Show when={intakeFields().length > 0}>
<div class="flex flex-col gap-3 border-t border-border/60 pt-3">
<For each={intakeFields()}>
{(field) => (
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-ink-muted">
{field.label}
<Show when={field.required}>
<span class="text-red-500"> *</span>
</Show>
</label>
<Show
when={field.type === "yesno"}
fallback={
<input
class="form-input"
type="text"
value={intakeAnswers()[field.key] ?? ""}
onInput={(e) => setIntakeAnswers({ ...intakeAnswers(), [field.key]: e.currentTarget.value })}
/>
}
>
<select
class="form-input"
value={intakeAnswers()[field.key] ?? ""}
onChange={(e) => setIntakeAnswers({ ...intakeAnswers(), [field.key]: e.currentTarget.value })}
>
<option value="">—</option>
<option value="yes">{i18n.t("booking.intakeYes")}</option>
<option value="no">{i18n.t("booking.intakeNo")}</option>
</select>
</Show>
</div>
)}
</For>
</div>
</Show>
</CardContent>
</Card>
<Card class="bg-gradient-to-br from-ink to-ink/95 text-canvas border-ink h-fit animate-slide-up shadow-xl" style={{ "animation-delay": "0.3s" }}>
<CardHeader>
<div class="flex items-center gap-2 mb-2">
<div class="w-8 h-8 rounded-lg bg-canvas/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-canvas/80">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<Badge variant="outline" class="border-canvas/30 text-canvas/70">{i18n.t("booking.business")}</Badge>
</div>
<div class="flex items-center gap-3">
<BookraCharacter pose="hello" size="sm" animate={true} />
<CardTitle class="font-display text-xl">{tenantSlug()}</CardTitle>
</div>
</CardHeader>
<CardContent class="space-y-4">
<p class="text-sm text-canvas/80 leading-relaxed">
{i18n.t("booking.sidebar.body")}
</p>
<div class="flex items-center gap-3 pt-4 border-t border-canvas/10">
<div class="w-10 h-10 rounded-full bg-canvas/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-canvas/70">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-canvas">{i18n.t("booking.help.title")}</p>
<p class="text-xs text-canvas/60">{i18n.t("booking.help.body")}</p>
</div>
</div>
</CardContent>
</Card>
{/* Quick info card */}
<Card class="surface-elevated animate-slide-up" style={{ "animation-delay": "0.4s" }}>
<CardContent class="p-6">
<h4 class="font-display font-semibold text-ink mb-3">{i18n.t("booking.expect.title")}</h4>
<ul class="space-y-2 text-sm text-ink-muted">
<li class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent mt-0.5 shrink-0">
<polyline points="20 6 9 17 4 12"/>
</svg>
{i18n.t("booking.expect.confirmation")}
</li>
<li class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent mt-0.5 shrink-0">
<polyline points="20 6 9 17 4 12"/>
</svg>
{i18n.t("booking.expect.reminders")}
</li>
<li class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent mt-0.5 shrink-0">
<polyline points="20 6 9 17 4 12"/>
</svg>
{i18n.t("booking.expect.rescheduling")}
</li>
</ul>
</CardContent>
</Card>
{/* Location map */}
<Card class="surface-elevated animate-slide-up overflow-hidden" style={{ "animation-delay": "0.45s" }}>
<CardContent class="p-0">
<div class="p-4 border-b border-border flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>
</svg>
<span class="font-display font-semibold text-ink text-sm">{i18n.locale() === 'cs' ? 'Lokace' : 'Location'}</span>
</div>
<div class="aspect-video w-full bg-canvas-subtle">
<iframe
src="https://www.openstreetmap.org/export/embed.html?bbox=14.3%2C50.0%2C14.6%2C50.1&layer=mapnik&marker=50.05%2C14.45"
class="w-full h-full border-0"
loading="lazy"
title={i18n.locale() === 'cs' ? 'Mapa lokace' : 'Location map'}
/>
</div>
<div class="p-4 text-sm text-ink-muted">
{i18n.locale() === 'cs'
? 'Přesnou adresu zobrazíme po potvrzení rezervace.'
: 'Exact address shown after booking confirmation.'}
</div>
</CardContent>
</Card>
</div>
{/* Main booking card - Slots - ORDER 2 on mobile, 1 on desktop */}
<Card class="surface-elevated animate-slide-up order-2 lg:order-1" style={{ "animation-delay": "0.2s" }}>
<CardHeader>
<div class="flex items-center gap-2 mb-2">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</div>
<Badge variant="secondary">{i18n.t("booking.slots")}</Badge>
</div>
<CardTitle class="font-display text-2xl">{i18n.t("booking.selectTime")}</CardTitle>
<CardDescription>{i18n.t("booking.selectTimeBody")}</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
{/* Success message */}
<Show when={bookingResult()}>
<div class="rounded-card border border-success/30 bg-success-subtle/50 px-4 py-5 text-sm text-success flex items-center gap-4 animate-scale-in">
<BookraCharacter pose="success" size="sm" animate={false} />
<div class="flex-1">
<p class="font-semibold text-success">{i18n.t("booking.confirmed")}</p>
<p class="text-success/80">{bookingResult()}</p>
</div>
</div>
</Show>
<Show when={bookingError()}>
<div class="rounded-card border border-error/30 bg-error-subtle/50 px-4 py-5 text-sm text-error flex items-center gap-4 animate-scale-in">
<BookraCharacter pose="lookup" size="sm" animate={false} class="opacity-80" />
<div class="flex-1">
<p class="font-semibold text-error">{i18n.t("booking.failed")}</p>
<p class="text-error/80">{bookingError()}</p>
</div>
</div>
</Show>
{/* Loading state */}
<Show when={availability.loading || catalog.loading}>
<div class="space-y-4">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
</Show>
<Show when={!availability.loading && !catalog.loading}>
{/* Stepped appointment flow */}
<Show when={hasCatalog()}>
<div class="space-y-6">
{/* Service picker */}
<div class="space-y-3">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.service")}</p>
<div class="grid gap-3 sm:grid-cols-2">
<For each={services()}>
{(service) => (
<button
type="button"
onClick={() => pickService(service.id)}
class={`rounded-card border p-4 text-left transition-all duration-200 ${
selectedServiceId() === service.id
? "border-accent bg-accent/5 ring-2 ring-accent/20 shadow-sm"
: "border-border bg-canvas-subtle/50 hover:border-accent/40 hover:bg-canvas-subtle"
}`}
>
<p class="font-display font-semibold text-ink">{service.name}</p>
<p class="mt-1 text-sm text-ink-muted">
{service.durationMinutes} min
<span class="mx-1.5 text-ink-subtle">·</span>
{formatPrice(service.priceCents)}
</p>
</button>
)}
</For>
</div>
</div>
<Show when={selectedServiceId()}>
{/* Staff preference */}
<Show when={staff().length > 1}>
<div class="space-y-3 animate-fade-in">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.staff")}</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
onClick={() => pickStaff(null)}
class={`rounded-full border px-4 py-2 text-sm font-medium transition-all duration-200 ${
selectedStaffId() === null
? "border-accent bg-accent text-canvas shadow-sm"
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
}`}
>
{i18n.t("booking.staff.any")}
</button>
<For each={staff()}>
{(member) => (
<button
type="button"
onClick={() => pickStaff(member.id)}
class={`rounded-full border px-4 py-2 text-sm font-medium transition-all duration-200 ${
selectedStaffId() === member.id
? "border-accent bg-accent text-canvas shadow-sm"
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
}`}
>
{member.displayName}
</button>
)}
</For>
</div>
</div>
</Show>
{/* Day picker */}
<div class="space-y-3 animate-fade-in">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.date")}</p>
<div class="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1">
<For each={days()}>
{(item) => (
<button
type="button"
onClick={() => setSelectedDate(item.value)}
class={`flex min-w-[64px] shrink-0 flex-col items-center rounded-card border px-3 py-2.5 transition-all duration-200 ${
selectedDate() === item.value
? "border-accent bg-accent text-canvas shadow-sm"
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
}`}
>
<span class="text-[11px] font-medium uppercase tracking-wide opacity-80">{item.weekday}</span>
<span class="font-display text-lg font-semibold leading-tight">{item.day}</span>
<span class="text-[11px] opacity-70">{item.month}</span>
</button>
)}
</For>
</div>
</div>
{/* Time grid */}
<Show when={selectedDate()}>
<div class="space-y-3 animate-fade-in">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.time")}</p>
<Show when={dayAvailability.loading}>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
</div>
</Show>
<Show when={!dayAvailability.loading && daySlots().length === 0}>
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-6 text-center">
<BookraCharacter pose="sleep" size="sm" animate={false} class="mx-auto mb-3" />
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.emptyDay")}</p>
</div>
</Show>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
<For each={daySlots()}>
{(slot) => (
<button
type="button"
disabled={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
class={`rounded-card border px-2 py-2.5 text-center transition-all duration-200 disabled:opacity-60 ${
submittingSlot() === slot.startsAt
? "border-accent bg-accent/10"
: "border-border bg-canvas-subtle/50 hover:border-accent hover:bg-accent/5 hover:shadow-sm"
}`}
>
<span class="block font-display text-sm font-semibold text-ink">
{new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: "2-digit", minute: "2-digit", timeZone: tenantTimezone() })}
</span>
<Show when={slot.staffName}>
<span class="block truncate text-[11px] text-ink-subtle">{slot.staffName}</span>
</Show>
</button>
)}
</For>
</div>
</div>
</Show>
</Show>
</div>
</Show>
{/* Class sessions */}
<Show when={classSlots().length > 0}>
<div class="space-y-4" classList={{ "mt-8": hasCatalog() }}>
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.classes")}</p>
<div class="space-y-5">
<For each={classDays()}>
{(day) => (
<div>
<p class="text-sm font-semibold text-ink mb-2 capitalize">
{day.date.toLocaleDateString(i18n.locale(), { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
<div class="divide-y divide-border/60 surface-card overflow-hidden">
<For each={day.slots}>
{(slot) => (
<div class="flex items-center justify-between gap-3 px-4 py-3.5 hover:bg-canvas-subtle/40 transition-colors">
<div class="flex items-center gap-4 min-w-0">
<span class="text-sm font-semibold text-ink whitespace-nowrap tabular-nums w-[104px] shrink-0">
{new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })}
{" – "}
{new Date(slot.endsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })}
</span>
<div class="min-w-0">
<p class="font-medium text-ink truncate">{slot.label ?? "Bookable slot"}</p>
<Show when={typeof slot.remainingCapacity === "number"}>
<p class={`text-xs font-medium ${slot.remainingCapacity === 0 ? "text-ink-subtle" : slot.remainingCapacity <= 3 ? "text-[hsl(var(--warning))]" : "text-success"}`}>
{slot.remainingCapacity === 0
? i18n.t("booking.full")
: `${slot.remainingCapacity} ${i18n.t("booking.spotsAvailable")}`}
</p>
</Show>
</div>
</div>
<Button
isLoading={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
size="sm"
variant={slot.remainingCapacity === 0 ? "secondary" : "primary"}
class="whitespace-nowrap shrink-0"
>
{slot.remainingCapacity === 0 ? i18n.t("booking.joinWaitlist") : i18n.t("booking.submit")}
</Button>
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Empty state */}
<Show when={!hasCatalog() && classSlots().length === 0}>
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-8 text-center">
<BookraCharacter pose="sleep" size="md" animate={true} class="mx-auto mb-4" />
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.empty")}</p>
<p class="text-xs text-ink-subtle mt-2">{i18n.t("booking.emptyHint")}</p>
</div>
</Show>
</Show>
</CardContent>
</Card>
</div>
</Show>
</section>
);
}
const HomeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
);