mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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>
632 lines
32 KiB
TypeScript
632 lines
32 KiB
TypeScript
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>
|
||
);
|