feat(bookings): customer self-service rebooking with real slot picker

CustomerBookingView now carries tenantSlug/serviceId/staffId/
classSessionId so the manage page can query live availability; class
bookings resolve their session title as the display label. Reschedule
rejects waitlisted/completed bookings and session-bound class bookings
(400), collapsing a dead branch in the appointment path.

The manage page reschedule dialog replaces free-form date/time inputs
with a date → availability-slot picker filtered to appointment mode,
so customers can only move to a genuinely free slot with the correct
duration; 409 surfaces "slot just taken". Actions are status-gated:
reschedule for confirmed/pending appointments, cancel for live
bookings, explanatory note for class bookings.

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 14:58:49 +02:00
1 parent 1cd5b1ba91
commit 796c06ae66
7 files changed
+311 -87

No files matched your search

+126 -64
View File
@@ -1,5 +1,5 @@
import { useParams, useSearchParams } from "@solidjs/router";
import { createSignal, createResource, Show } from "solid-js";
import { createSignal, createResource, For, Show } from "solid-js";
import { apiClient } from "../lib/api-client";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
@@ -12,7 +12,8 @@ export function BookingManageRoute() {
const [isRescheduling, setIsRescheduling] = createSignal(false);
const [isCancelling, setIsCancelling] = createSignal(false);
const [newDate, setNewDate] = createSignal("");
const [newTime, setNewTime] = createSignal("");
const [selectedSlot, setSelectedSlot] = createSignal<{ startsAt: string; endsAt: string } | null>(null);
const [rescheduleBusy, setRescheduleBusy] = createSignal(false);
const [message, setMessage] = createSignal("");
const [actionSuccess, setActionSuccess] = createSignal<string | null>(null);
const [actionError, setActionError] = createSignal<string | null>(null);
@@ -20,7 +21,7 @@ export function BookingManageRoute() {
const token = () => searchParams.token || "";
const reference = () => params.reference || "";
const [booking] = createResource(token, async (t) => {
const [booking, { refetch: refetchBooking }] = createResource(token, async (t) => {
if (!t) return null;
const ref = reference();
if (!ref) return null;
@@ -49,12 +50,30 @@ export function BookingManageRoute() {
};
});
const [slots] = createResource(
() => [booking()?.tenantSlug, newDate(), booking()?.serviceId, booking()?.staffId] as const,
async ([slug, date, serviceId, staffId]) => {
if (!slug || !date) return [];
const query = new URLSearchParams({ date });
if (serviceId) query.set("serviceId", serviceId);
if (staffId) query.set("staffId", staffId);
try {
const response = await (apiClient as any).GET(`/v1/public/tenants/${slug}/availability?${query}`);
const list = (response.data?.slots ?? []) as any[];
return list.filter((s) => s.mode === "appointment");
} catch {
return [];
}
},
);
const handleReschedule = async () => {
setActionSuccess(null);
setActionError(null);
if (!newDate() || !newTime()) {
setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím nové datum a čas' : 'Please select a new date and time');
const slot = selectedSlot();
if (!slot) {
setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím volný termín' : 'Please pick an available slot');
return;
}
@@ -65,32 +84,36 @@ export function BookingManageRoute() {
return;
}
// Calculate new start and end times
const newStart = new Date(`${newDate()}T${newTime()}`);
const newEnd = new Date(newStart.getTime() + 60 * 60 * 1000); // Default 1 hour duration
setRescheduleBusy(true);
try {
const response = await (apiClient as any).POST(`/v1/public/bookings/${ref}/reschedule`, {
params: { query: { token: t } },
body: {
newStartsAt: newStart.toISOString(),
newEndsAt: newEnd.toISOString(),
newStartsAt: slot.startsAt,
newEndsAt: slot.endsAt,
reason: message()
}
});
if (response.error) {
setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed');
setActionError(
(response as any).response?.status === 409
? (i18n.locale() === 'cs' ? 'Tento termín byl právě obsazen, vyberte jiný' : 'That slot was just taken — please pick another')
: (i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed')
);
return;
}
setActionSuccess(i18n.locale() === 'cs' ? 'Rezervace byla přeplánována' : 'Booking rescheduled successfully');
setIsRescheduling(false);
setNewDate("");
setNewTime("");
setSelectedSlot(null);
setMessage("");
void refetchBooking();
} catch (err) {
setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed');
} finally {
setRescheduleBusy(false);
}
};
@@ -313,33 +336,44 @@ export function BookingManageRoute() {
</div>
{/* Action Buttons */}
<div class="flex flex-col sm:flex-row gap-3 pt-4">
<Button
variant="secondary"
class="flex-1"
onClick={() => setIsRescheduling(true)}
>
<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.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'}
</Button>
<Button
variant="secondary"
class="flex-1 text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))]"
onClick={() => setIsCancelling(true)}
>
<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">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
</Button>
</div>
<Show when={b().status === 'confirmed' || b().status === 'pending' || b().status === 'waitlisted'}>
<div class="flex flex-col sm:flex-row gap-3 pt-4">
<Show when={!b().classSessionId && b().status !== 'waitlisted'}>
<Button
variant="secondary"
class="flex-1"
onClick={() => setIsRescheduling(true)}
>
<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.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'}
</Button>
</Show>
<Button
variant="secondary"
class="flex-1 text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))]"
onClick={() => setIsCancelling(true)}
>
<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">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
</Button>
</div>
<Show when={b().classSessionId}>
<p class="text-sm text-ink-muted pt-1">
{i18n.locale() === 'cs'
? 'Termín lekce je pevně daný. Pro jiný termín rezervaci zrušte a objednejte jinou lekci.'
: 'Class times are fixed. To switch sessions, cancel this booking and book another class.'}
</p>
</Show>
</Show>
</CardContent>
</Card>
@@ -393,28 +427,54 @@ export function BookingManageRoute() {
</DialogDescription>
</DialogHeader>
<div class="space-y-4 mt-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Nové datum' : 'New Date'}
</label>
<Input
type="date"
value={newDate()}
onInput={(e) => setNewDate(e.currentTarget.value)}
/>
</div>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Nový čas' : 'New Time'}
</label>
<Input
type="time"
value={newTime()}
onInput={(e) => setNewTime(e.currentTarget.value)}
/>
</div>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Nové datum' : 'New date'}
</label>
<Input
type="date"
value={newDate()}
min={new Date().toISOString().slice(0, 10)}
onInput={(e) => { setNewDate(e.currentTarget.value); setSelectedSlot(null); }}
/>
</div>
<Show when={newDate()}>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Volné termíny' : 'Available times'}
</label>
<Show when={!slots.loading} fallback={
<div class="p-4 text-center text-sm text-ink-muted animate-pulse">…</div>
}>
<Show when={(slots() ?? []).length > 0} fallback={
<p class="p-4 text-sm text-ink-muted bg-canvas-subtle rounded-xl text-center">
{i18n.locale() === 'cs' ? 'V tento den nejsou volné termíny.' : 'No available times on this day.'}
</p>
}>
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
<For each={slots() ?? []}>
{(slot: any) => {
const isSelected = () => selectedSlot()?.startsAt === slot.startsAt;
return (
<button
type="button"
onClick={() => setSelectedSlot({ startsAt: slot.startsAt, endsAt: slot.endsAt })}
class={`px-2 py-2 text-sm font-medium rounded-lg border transition-colors ${
isSelected()
? 'bg-accent text-canvas border-accent'
: 'bg-canvas-subtle text-ink border-border hover:border-accent hover:text-accent'
}`}
>
{formatTime(slot.startsAt)}
</button>
);
}}
</For>
</div>
</Show>
</Show>
</div>
</Show>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Zpráva pro podnik (volitelné)' : 'Message to business (optional)'}
@@ -430,8 +490,10 @@ export function BookingManageRoute() {
<Button variant="secondary" class="flex-1" onClick={() => setIsRescheduling(false)}>
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</Button>
<Button class="flex-1" onClick={handleReschedule}>
{i18n.locale() === 'cs' ? 'Potvrdit změnu' : 'Confirm Change'}
<Button class="flex-1" onClick={handleReschedule} disabled={!selectedSlot() || rescheduleBusy()}>
{rescheduleBusy()
? '…'
: (i18n.locale() === 'cs' ? 'Potvrdit změnu' : 'Confirm Change')}
</Button>
</div>
</div>