feat: wire slot reservation into all booking flows

Customer flow: reservation fires on Date/Time → Details transition with
re-validation on time slot tap and on 'Next' click to prevent simultaneous
bookings. 5-step flow: Service → Date/Time → Reserve → Details (countdown)
→ Payment & Review → Confirm. Guest users redirected to home on success.

Admin call-in: reserve slot before final submission (60min TTL).
Admin walk-in: reserve slot on modal open (5min TTL).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-30 11:45:20 +01:00
co-authored by Sisyphus
parent 52ca9b425b
commit c5b7d9a078
4 changed files with 607 additions and 31 deletions
@@ -23,15 +23,19 @@
interface Props {
open: boolean;
maxSlotDuration?: number;
availableStartTime?: string; // "HH:MM" or "HH:MM:SS" format from the available slot
availableStartTime?: string;
reservationExpiresAt?: Date | null;
onBookingCreated?: () => void;
onclose?: () => void;
}
let {
open = $bindable(),
maxSlotDuration = 0,
availableStartTime,
onBookingCreated
reservationExpiresAt,
onBookingCreated,
onclose
}: Props = $props();
// =============== State ===============
@@ -64,6 +68,10 @@
let submitting = $state(false);
// Countdown state
let reservationCountdown = $state<string>('');
let isReservationExpired = $state(false);
// =============== Derived Helpers ===============
function getTotalDuration() {
return selectedServices.reduce((total, service) => {
@@ -123,6 +131,48 @@
}
});
// Handle reservation countdown
$effect(() => {
if (open && reservationExpiresAt) {
startCountdown();
} else {
reservationCountdown = '';
isReservationExpired = false;
}
});
function startCountdown() {
if ((window as any).__walkInModalCountdownInterval) {
clearInterval((window as any).__walkInModalCountdownInterval);
}
const updateCountdown = () => {
if (!reservationExpiresAt) {
reservationCountdown = '';
return;
}
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
reservationCountdown = 'Expired';
isReservationExpired = true;
if ((window as any).__walkInModalCountdownInterval) {
clearInterval((window as any).__walkInModalCountdownInterval);
}
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
reservationCountdown = `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
updateCountdown();
(window as any).__walkInModalCountdownInterval = setInterval(updateCountdown, 1000);
}
function resetState() {
currentStep = 1;
userType = 'member';
@@ -305,6 +355,8 @@
notes: notes.trim() || null
};
// TODO: When guest booking is fully implemented, ensure walk-in guest reservations properly transition to real bookings.
const res = await fetch('/api/admin/bookings', {
method: 'POST',
headers: {
@@ -366,6 +418,35 @@
</Modal.Header>
<div class="px-6 pb-4">
<!-- Reservation Countdown Banner -->
{#if reservationExpiresAt && !isReservationExpired}
<div class="mx-6 mt-4 rounded-lg bg-green-50 border border-green-200 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span class="text-sm font-medium text-green-800">
Slot held for
</span>
</div>
<span class="text-sm font-mono font-semibold text-green-700">
{reservationCountdown}
</span>
</div>
</div>
{:else if isReservationExpired}
<div class="mx-6 mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-sm font-medium text-red-800">
Slot released — please re-check availability
</span>
</div>
</div>
{/if}
<!-- Step 1: Customer Selection -->
{#if currentStep === 1}
<Card.Root>
@@ -717,7 +798,7 @@
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
<Button
disabled={submitting}
disabled={submitting || isReservationExpired}
onclick={submitBooking}
class="bg-primary text-primary-foreground"
>