mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-05 04:52:59 +00:00
cleanup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user