mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
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.spotsAvailable": "volná místa",
|
||||||
"booking.submitting": "Rezervuji...",
|
"booking.submitting": "Rezervuji...",
|
||||||
"booking.submit": "Rezervovat",
|
"booking.submit": "Rezervovat",
|
||||||
|
"booking.joinWaitlist": "Na čekací listinu",
|
||||||
|
"booking.full": "Obsazeno",
|
||||||
"booking.business": "Podnik",
|
"booking.business": "Podnik",
|
||||||
"booking.sidebar.body": "Rezervace proběhne online. Potvrzení a připomenutí přijdou e-mailem.",
|
"booking.sidebar.body": "Rezervace proběhne online. Potvrzení a připomenutí přijdou e-mailem.",
|
||||||
"booking.customer.title": "Kontaktní údaje",
|
"booking.customer.title": "Kontaktní údaje",
|
||||||
@@ -1088,6 +1090,8 @@ const dictionaries = {
|
|||||||
"booking.spotsAvailable": "spots available",
|
"booking.spotsAvailable": "spots available",
|
||||||
"booking.submitting": "Booking...",
|
"booking.submitting": "Booking...",
|
||||||
"booking.submit": "Book now",
|
"booking.submit": "Book now",
|
||||||
|
"booking.joinWaitlist": "Join waitlist",
|
||||||
|
"booking.full": "Full",
|
||||||
"booking.business": "Business",
|
"booking.business": "Business",
|
||||||
"booking.sidebar.body": "Book online. Confirmation and reminders are sent by email.",
|
"booking.sidebar.body": "Book online. Confirmation and reminders are sent by email.",
|
||||||
"booking.customer.title": "Contact details",
|
"booking.customer.title": "Contact details",
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ export function PublicBookingRoute() {
|
|||||||
const services = createMemo<PublicService[]>(() => catalog.latest?.data?.services ?? []);
|
const services = createMemo<PublicService[]>(() => catalog.latest?.data?.services ?? []);
|
||||||
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
|
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
|
||||||
const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class"));
|
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 daySlots = createMemo(() => dayAvailability.latest?.data?.slots ?? []);
|
||||||
const hasCatalog = createMemo(() => services().length > 0);
|
const hasCatalog = createMemo(() => services().length > 0);
|
||||||
|
|
||||||
@@ -505,55 +517,51 @@ export function PublicBookingRoute() {
|
|||||||
<Show when={classSlots().length > 0}>
|
<Show when={classSlots().length > 0}>
|
||||||
<div class="space-y-4" classList={{ "mt-8": hasCatalog() }}>
|
<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>
|
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.classes")}</p>
|
||||||
<For each={classSlots()}>
|
<div class="space-y-5">
|
||||||
{(slot) => (
|
<For each={classDays()}>
|
||||||
<Card variant="default" class="group surface-card hover:border-accent/30 transition-all duration-300" padding="lg">
|
{(day) => (
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div>
|
||||||
<div class="space-y-2">
|
<p class="text-sm font-semibold text-ink mb-2 capitalize">
|
||||||
<div class="flex items-center gap-2">
|
{day.date.toLocaleDateString(i18n.locale(), { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||||
<Badge variant="default" class="font-display text-xs uppercase tracking-wider">
|
</p>
|
||||||
{slot.mode}
|
<div class="divide-y divide-border/60 surface-card overflow-hidden">
|
||||||
</Badge>
|
<For each={day.slots}>
|
||||||
<Show when={typeof slot.remainingCapacity === "number" && slot.remainingCapacity <= 3}>
|
{(slot) => (
|
||||||
<Badge variant="warning" size="sm">{i18n.t("booking.only")} {slot.remainingCapacity} {i18n.t("booking.left")}</Badge>
|
<div class="flex items-center justify-between gap-3 px-4 py-3.5 hover:bg-canvas-subtle/40 transition-colors">
|
||||||
</Show>
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
</div>
|
<span class="text-sm font-semibold text-ink whitespace-nowrap tabular-nums w-[104px] shrink-0">
|
||||||
<h3 class="font-display text-lg font-semibold text-ink">{slot.label ?? "Bookable slot"}</h3>
|
{new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })}
|
||||||
<div class="flex items-center gap-4 text-sm text-ink-muted">
|
{" – "}
|
||||||
<span class="flex items-center gap-1.5">
|
{new Date(slot.endsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
</span>
|
||||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
<div class="min-w-0">
|
||||||
</svg>
|
<p class="font-medium text-ink truncate">{slot.label ?? "Bookable slot"}</p>
|
||||||
{new Date(slot.startsAt).toLocaleDateString(i18n.locale(), { weekday: 'short', month: 'short', day: 'numeric' })}
|
<Show when={typeof slot.remainingCapacity === "number"}>
|
||||||
</span>
|
<p class={`text-xs font-medium ${slot.remainingCapacity === 0 ? "text-ink-subtle" : slot.remainingCapacity <= 3 ? "text-[hsl(var(--warning))]" : "text-success"}`}>
|
||||||
<span class="flex items-center gap-1.5">
|
{slot.remainingCapacity === 0
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
? i18n.t("booking.full")
|
||||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
: `${slot.remainingCapacity} ${i18n.t("booking.spotsAvailable")}`}
|
||||||
</svg>
|
</p>
|
||||||
{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' })}
|
</Show>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={typeof slot.remainingCapacity === "number"}>
|
<Button
|
||||||
<p class="text-sm text-success font-medium flex items-center gap-1.5">
|
isLoading={submittingSlot() === slot.startsAt}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
onClick={() => void bookSlot(slot)}
|
||||||
<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"/>
|
size="sm"
|
||||||
</svg>
|
variant={slot.remainingCapacity === 0 ? "secondary" : "primary"}
|
||||||
{slot.remainingCapacity} {i18n.t("booking.spotsAvailable")}
|
class="whitespace-nowrap shrink-0"
|
||||||
</p>
|
>
|
||||||
</Show>
|
{slot.remainingCapacity === 0 ? i18n.t("booking.joinWaitlist") : i18n.t("booking.submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
)}
|
</For>
|
||||||
</For>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- `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
|
- 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
|
- 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
|
## 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.
|
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.
|
||||||
|
|||||||
Reference in new issue
Block a user