This commit is contained in:
Tomas Dvorak
2026-05-05 09:48:07 +02:00
parent d854614a87
commit 48c3e15a38
295 changed files with 178381 additions and 1039 deletions
+301 -64
View File
@@ -1,39 +1,60 @@
import { useParams } from "@solidjs/router";
import { A, useParams } from "@solidjs/router";
import { For, Show, 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";
export function PublicBookingRoute() {
const params = useParams();
const i18n = useI18n();
const tenantSlug = () => params.tenantSlug ?? "studio-atelier";
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 [availability, { refetch }] = createResource(() =>
apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", {
const [customerName, setCustomerName] = createSignal("");
const [customerEmail, setCustomerEmail] = createSignal("");
const [notes, setNotes] = createSignal("");
const [availability, { refetch }] = createResource(() => {
const slug = tenantSlug();
if (!slug) return null;
return apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", {
params: {
path: {
tenantSlug: tenantSlug(),
tenantSlug: slug,
},
},
}),
);
});
});
const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => {
if (!customerName().trim() || !customerEmail().trim()) {
setBookingError(i18n.t("booking.customerRequired"));
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: tenantSlug(),
tenantSlug: slug,
bookingMode: slot.mode,
serviceId: slot.serviceId ?? undefined,
classSessionId: slot.classSessionId ?? undefined,
staffId: slot.staffId ?? undefined,
locationId: slot.locationId ?? undefined,
customerName: "Demo Customer",
customerEmail: "customer@bookra.dev",
customerName: customerName().trim(),
customerEmail: customerEmail().trim(),
notes: notes().trim(),
startsAt: slot.startsAt,
endsAt: slot.endsAt,
},
@@ -41,9 +62,10 @@ export function PublicBookingRoute() {
const payload = response as { data?: components["schemas"]["CreateBookingResponse"]; error?: unknown };
if (payload.error) {
setBookingResult(`Booking failed: ${String(payload.error)}`);
setBookingError(`${i18n.t("booking.failed")}: ${String(payload.error)}`);
} else if (payload.data) {
setBookingResult(`Created ${payload.data.status} booking ${payload.data.reference}`);
setBookingResult(`${i18n.t("booking.created")} ${payload.data.reference}`);
setNotes("");
}
setSubmittingSlot(null);
@@ -51,60 +73,275 @@ export function PublicBookingRoute() {
};
return (
<section class="grid gap-8 py-10 lg:grid-cols-[1.1fr_0.9fr]">
<div class="rounded-panel border border-black/10 bg-white/70 p-8 shadow-card">
<h1 class="text-3xl font-semibold tracking-tight">{i18n.t("booking.title")}</h1>
<p class="mt-3 max-w-2xl text-base leading-7 text-slate">{i18n.t("booking.body")}</p>
<Show when={bookingResult()}>
<div class="mt-6 rounded-panel border border-pine/20 bg-pine/10 px-4 py-3 text-sm text-pine">
{bookingResult()}
</div>
</Show>
<div class="mt-8 grid gap-3">
<Show
when={availability.latest?.data?.slots?.length}
fallback={
<div class="rounded-panel border border-dashed border-black/10 bg-canvas/70 p-5 text-sm leading-6 text-slate">
{i18n.t("booking.empty")}
</div>
}
>
<For each={availability.latest?.data?.slots ?? []}>
{(slot) => (
<article class="rounded-panel border border-black/10 bg-canvas/70 p-5">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-sm uppercase tracking-[0.2em] text-slate">{slot.mode}</p>
<h2 class="mt-2 text-xl font-semibold">{slot.label ?? "Bookable slot"}</h2>
<p class="mt-2 text-sm leading-6 text-slate">
{new Date(slot.startsAt).toLocaleString()} - {new Date(slot.endsAt).toLocaleTimeString()}
</p>
<Show when={typeof slot.remainingCapacity === "number"}>
<p class="mt-2 text-sm text-pine">Remaining capacity: {slot.remainingCapacity}</p>
</Show>
</div>
<button
class="rounded-full bg-ink px-5 py-3 text-sm font-medium text-canvas disabled:opacity-50"
disabled={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
type="button"
>
{submittingSlot() === slot.startsAt ? "Booking..." : "Book demo slot"}
</button>
</div>
</article>
)}
</For>
</Show>
</div>
</div>
<div class="rounded-panel border border-black/10 bg-pine p-8 text-canvas shadow-card">
<p class="text-sm uppercase tracking-[0.2em] text-canvas/70">Tenant slug</p>
<p class="mt-4 text-2xl font-semibold">{tenantSlug()}</p>
<p class="mt-4 text-sm leading-7 text-canvas/80">
This route is ready to consume live availability from the Go API once Neon-backed tenant data is configured.
<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">
<Card class="surface-elevated animate-slide-up" 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()}
/>
<Textarea
label={i18n.t("booking.customer.notes")}
onInput={(event) => setNotes(event.currentTarget.value)}
rows={3}
value={notes()}
/>
</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>
</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}>
<div class="space-y-4">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</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>
</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"
>
{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(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")}
</>
)}
</Button>
</Tooltip>
</div>
</Card>
)}
</For>
</Show>
</div>
</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>
);