mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(booking): stepped public booking flow with service/staff selection
The public booking page showed a capped flat preview of slots with no way to pick a service, day, or staff member. Replaces it with a stepped flow: pick service, optional staff preference, pick a day, pick a time. Backend: availability accepts serviceId/staffId/date filters (default response unchanged for the embed widget), a new public catalog endpoint exposes bookable services and active staff, and slots carry staffName. Slot generation and booking creation now honor tenant-wide and per-staff blocked days; appointment requests must fit inside a matching availability rule and reference tenant-owned, active staff and locations. 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
09f780e7a6
commit
d9aa29dbcb
9 files changed
+940
-118
No files matched your search
@@ -475,6 +475,14 @@ const dictionaries = {
|
||||
"booking.selectTimeBody": "Vyberte si z dostupných termínů pro služby a lekce.",
|
||||
"booking.empty": "Momentálně nejsou k dispozici žádné termíny.",
|
||||
"booking.emptyHint": "Zkuste to prosím později.",
|
||||
"booking.emptyDay": "V tento den není nic volného.",
|
||||
"booking.step.service": "Vyberte službu",
|
||||
"booking.step.staff": "S kým?",
|
||||
"booking.step.date": "Vyberte den",
|
||||
"booking.step.time": "Vyberte čas",
|
||||
"booking.staff.any": "Bez preference",
|
||||
"booking.service.free": "Zdarma",
|
||||
"booking.classes": "Skupinové lekce",
|
||||
"booking.only": "Zbývají",
|
||||
"booking.left": "místa",
|
||||
"booking.spotsAvailable": "volná místa",
|
||||
@@ -1019,6 +1027,14 @@ const dictionaries = {
|
||||
"booking.selectTimeBody": "Choose from available appointment and class times.",
|
||||
"booking.empty": "No bookable times are available right now.",
|
||||
"booking.emptyHint": "Please check again later.",
|
||||
"booking.emptyDay": "No times available on this day.",
|
||||
"booking.step.service": "Choose a service",
|
||||
"booking.step.staff": "With whom?",
|
||||
"booking.step.date": "Pick a day",
|
||||
"booking.step.time": "Pick a time",
|
||||
"booking.staff.any": "No preference",
|
||||
"booking.service.free": "Free",
|
||||
"booking.classes": "Group classes",
|
||||
"booking.only": "Only",
|
||||
"booking.left": "left",
|
||||
"booking.spotsAvailable": "spots available",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { A, useParams } from "@solidjs/router";
|
||||
import { For, Show, createResource, createSignal } from "solid-js";
|
||||
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, Tooltip } from "../components/ui";
|
||||
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();
|
||||
@@ -18,7 +21,19 @@ export function PublicBookingRoute() {
|
||||
const [customerPhone, setCustomerPhone] = createSignal("");
|
||||
const [notes, setNotes] = createSignal("");
|
||||
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;
|
||||
@@ -31,6 +46,72 @@ export function PublicBookingRoute() {
|
||||
});
|
||||
});
|
||||
|
||||
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 classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class"));
|
||||
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"));
|
||||
@@ -77,6 +158,7 @@ export function PublicBookingRoute() {
|
||||
|
||||
setSubmittingSlot(null);
|
||||
void refetch();
|
||||
void refetchDay();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -273,7 +355,7 @@ export function PublicBookingRoute() {
|
||||
</Show>
|
||||
|
||||
{/* Loading state */}
|
||||
<Show when={availability.loading}>
|
||||
<Show when={availability.loading || catalog.loading}>
|
||||
<div class="space-y-4">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
@@ -281,96 +363,209 @@ export function PublicBookingRoute() {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Slots list */}
|
||||
<div class="space-y-4">
|
||||
<Show
|
||||
when={!availability.loading && availability.latest?.data?.slots?.length}
|
||||
fallback={
|
||||
<Show when={!availability.loading}>
|
||||
<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>
|
||||
<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>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<For each={availability.latest?.data?.slots ?? []}>
|
||||
{(slot, index) => (
|
||||
<Card
|
||||
variant="default"
|
||||
class="group surface-card hover:border-accent/30 transition-all duration-300"
|
||||
style={{ "animation-delay": `${0.1 * (index() + 3)}s` }}
|
||||
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={slot.mode === "appointment" ? "secondary" : "default"}
|
||||
class="font-display text-xs uppercase tracking-wider"
|
||||
</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"
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
<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>
|
||||
<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(undefined, { 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(undefined, { hour: '2-digit', minute: '2-digit' })} - {new Date(slot.endsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={typeof slot.remainingCapacity === "number" && slot.mode === "class"}>
|
||||
<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>
|
||||
<Tooltip content="Book this slot now">
|
||||
<Button
|
||||
isLoading={submittingSlot() === slot.startsAt}
|
||||
onClick={() => void bookSlot(slot)}
|
||||
size="md"
|
||||
class="shadow-md hover:shadow-lg transition-shadow whitespace-nowrap"
|
||||
>
|
||||
{submittingSlot() === slot.startsAt ? (
|
||||
<>
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||
</svg>
|
||||
{i18n.t("booking.submitting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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="mr-2">
|
||||
<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>
|
||||
{i18n.t("booking.submit")}
|
||||
</>
|
||||
)}
|
||||
{i18n.t("booking.submit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
Reference in new issue
Block a user