feat(booking): day-grouped class list with honest capacity states

Class sessions rendered as a flat stack of tall cards below the
stepped picker, and a full session still offered "Book now" while
silently producing a waitlisted booking. Sessions now group by day
with compact timetable rows; empty sessions show Full and switch the
action to Join waitlist, and low capacity shows a warning count.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 13:16:43 +02:00
1 parent 5884203e9d
commit 10df4e4c64
3 files changed
+63 -47

No files matched your search

@@ -512,6 +512,8 @@ const dictionaries = {
"booking.spotsAvailable": "volná místa",
"booking.submitting": "Rezervuji...",
"booking.submit": "Rezervovat",
"booking.joinWaitlist": "Na čekací listinu",
"booking.full": "Obsazeno",
"booking.business": "Podnik",
"booking.sidebar.body": "Rezervace proběhne online. Potvrzení a připomenutí přijdou e-mailem.",
"booking.customer.title": "Kontaktní údaje",
@@ -1088,6 +1090,8 @@ const dictionaries = {
"booking.spotsAvailable": "spots available",
"booking.submitting": "Booking...",
"booking.submit": "Book now",
"booking.joinWaitlist": "Join waitlist",
"booking.full": "Full",
"booking.business": "Business",
"booking.sidebar.body": "Book online. Confirmation and reminders are sent by email.",
"booking.customer.title": "Contact details",
@@ -69,6 +69,18 @@ export function PublicBookingRoute() {
const services = createMemo<PublicService[]>(() => catalog.latest?.data?.services ?? []);
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
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);
@@ -505,55 +517,51 @@ export function PublicBookingRoute() {
<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>
<For each={classSlots()}>
{(slot) => (
<Card variant="default" class="group surface-card hover:border-accent/30 transition-all duration-300" padding="lg">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-2">
<div class="flex items-center gap-2">
<Badge variant="default" class="font-display text-xs uppercase tracking-wider">
{slot.mode}
</Badge>
<Show when={typeof slot.remainingCapacity === "number" && slot.remainingCapacity <= 3}>
<Badge variant="warning" size="sm">{i18n.t("booking.only")} {slot.remainingCapacity} {i18n.t("booking.left")}</Badge>
</Show>
</div>
<h3 class="font-display text-lg font-semibold text-ink">{slot.label ?? "Bookable slot"}</h3>
<div class="flex items-center gap-4 text-sm text-ink-muted">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{new Date(slot.startsAt).toLocaleDateString(i18n.locale(), { weekday: 'short', month: 'short', day: 'numeric' })}
</span>
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{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>
<Show when={typeof slot.remainingCapacity === "number"}>
<p class="text-sm text-success font-medium flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{slot.remainingCapacity} {i18n.t("booking.spotsAvailable")}
</p>
</Show>
<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>
<Button
isLoading={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
size="md"
class="shadow-md hover:shadow-lg transition-shadow whitespace-nowrap"
>
{i18n.t("booking.submit")}
</Button>
</div>
</Card>
)}
</For>
)}
</For>
</div>
</div>
</Show>
+4
View File
@@ -116,6 +116,10 @@
- `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client
- classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance
- remaining: live staging validation
- `[done/partial]` Milestone 18: Public class list polish
- class sessions on the public booking page are now grouped by day (localized weekday + date headers) with compact timetable-style rows instead of one tall card per session — consistent with the stepped appointment picker above
- capacity states read honestly: spots-left in success/warning tones, `Obsazeno`/`Full` when empty, and the action switches to `Na čekací listinu`/`Join waitlist` (previously a full session still said "Book now" while silently producing a waitlisted booking)
- remaining: live staging validation
## Overview
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.