From c5b7d9a078b994ff8d106e9e06560ac0fd2bc7d8 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 30 Apr 2026 11:45:20 +0100 Subject: [PATCH] feat: wire slot reservation into all booking flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../admin/BookingCreateModal.svelte | 149 +++++++++- .../lib/components/admin/WalkInBooking.svelte | 130 ++++++++- .../components/admin/WalkInCreateModal.svelte | 87 +++++- .../lib/components/booking/BookingFlow.svelte | 272 ++++++++++++++++-- 4 files changed, 607 insertions(+), 31 deletions(-) diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index c6b3f59..dd29089 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -91,6 +91,12 @@ let submitting = $state(false); + // =============== Reservation State =============== + let reservationId = $state(null); + let reservationExpiresAt = $state(null); + let reservationCountdown = $state(''); + let isReserving = $state(false); + // =============== Cache =============== const workingHoursCache = new SvelteMap>(); const availableHoursCache = new SvelteMap>(); @@ -240,6 +246,13 @@ availableHoursCache.clear(); workingHours = null; availableHours = null; + // Clear reservation state + reservationId = null; + reservationExpiresAt = null; + reservationCountdown = ''; + if ((window as any).__bookingCreateCountdownInterval) { + clearInterval((window as any).__bookingCreateCountdownInterval); + } } // =============== Data Fetching =============== @@ -346,6 +359,113 @@ } } + // =============== Reservation =============== + async function reserveSlot(): Promise { + if (!selectedUserId || !selectedDate || !selectedTime) { + return false; + } + + isReserving = true; + + try { + const localDate = selectedDate.toDate(getLocalTimeZone()); + const [hours, minutes] = selectedTime.split(':').map(Number); + localDate.setHours(hours, minutes, 0, 0); + const startTimeISO = localDate.toISOString(); + + const serviceIds = selectedServices.map((s) => s.id); + + // Build service overrides payload + const overrides = []; + for (const [serviceId, data] of Object.entries(serviceOverrides)) { + const durationChanged = parseInt(data.duration) !== data.originalDuration; + if (durationChanged) { + overrides.push({ + service_id: serviceId, + override_duration_minutes: parseInt(data.duration) + }); + } + } + + const response = await fetch('/api/admin/bookings/reserve', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify({ + user_id: selectedUserId, + start_time: startTimeISO, + service_ids: serviceIds, + service_overrides: overrides.length > 0 ? overrides : [], + ttl_minutes: 60 + }) + }); + + if (response.ok) { + const data = await response.json(); + reservationId = data.id; + reservationExpiresAt = new Date(data.expires_at); + startCountdown(); + return true; + } else if (response.status === 409) { + toast.error('Slot no longer available, refreshing...'); + // Refresh available hours + if (selectedDate) { + fetchHoursForMonth(selectedDate); + } + reservationId = null; + reservationExpiresAt = null; + reservationCountdown = ''; + return false; + } else { + const errorText = await response.text(); + toast.error(`Failed to reserve slot: ${errorText}`); + return false; + } + } catch (err) { + console.error('Reservation error:', err); + toast.error('Failed to reserve slot'); + return false; + } finally { + isReserving = false; + } + } + + function startCountdown() { + // Clear any existing interval + if ((window as any).__bookingCreateCountdownInterval) { + clearInterval((window as any).__bookingCreateCountdownInterval); + } + + const updateCountdown = () => { + if (!reservationExpiresAt) { + reservationCountdown = ''; + return; + } + + const now = new Date(); + const diff = reservationExpiresAt.getTime() - now.getTime(); + + if (diff <= 0) { + reservationCountdown = 'Expired'; + reservationId = null; + reservationExpiresAt = null; + if ((window as any).__bookingCreateCountdownInterval) { + clearInterval((window as any).__bookingCreateCountdownInterval); + } + 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).__bookingCreateCountdownInterval = setInterval(updateCountdown, 1000); + } + // =============== Logic =============== function toggleService(service: Service) { const index = selectedServices.findIndex((s) => s.id === service.id); @@ -600,6 +720,13 @@ // =============== Submission =============== async function submitBooking() { + // First, reserve the slot + const reserved = await reserveSlot(); + if (!reserved) { + submitting = false; + return; + } + submitting = true; try { @@ -1074,6 +1201,24 @@ + + {#if reservationId && reservationExpiresAt} +
+
+
+ + + + + Slot reserved until {reservationExpiresAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+ + ({reservationCountdown} remaining) + +
+
+ {/if} {#if loadingWorkingHours}

Loading available dates...

@@ -1184,11 +1329,11 @@ diff --git a/frontend/src/lib/components/admin/WalkInBooking.svelte b/frontend/src/lib/components/admin/WalkInBooking.svelte index 7290189..96c35ab 100644 --- a/frontend/src/lib/components/admin/WalkInBooking.svelte +++ b/frontend/src/lib/components/admin/WalkInBooking.svelte @@ -5,6 +5,7 @@ import { CalendarDate } from '@internationalized/date'; import { SvelteDate } from 'svelte/reactivity'; import { onMount } from 'svelte'; + import { toast } from 'svelte-sonner'; import type { AvailableHoursDay } from '$lib/types/booking'; @@ -14,12 +15,18 @@ waitMinutes?: number; durationMinutes: number; startTime?: string; - slotEndMinutes?: number; // Store for live countdown + slotEndMinutes?: number; } | null>(null); let loading = $state(true); let noSlotsToday = $state(false); let currentTime = $state(new Date()); + // Reservation state for walk-in + let reservationId = $state(null); + let reservationExpiresAt = $state(null); + let reservationCountdown = $state(''); + let isReserving = $state(false); + onMount(() => { calculateSlotAvailability(); @@ -153,6 +160,119 @@ const displayHours = hours % 12 || 12; return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; } + + async function reserveWalkInSlot(startTime: string): Promise { + isReserving = true; + + try { + const now = new SvelteDate(); + const [hours, minutes] = startTime.split(':').map(Number); + const start = new SvelteDate( + now.getFullYear(), + now.getMonth(), + now.getDate(), + hours, + minutes, + 0, + 0 + ); + const startTimeISO = start.toISOString(); + + const response = await fetch('/api/admin/bookings/reserve', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify({ + user_id: null, + start_time: startTimeISO, + service_ids: [], + service_overrides: [], + ttl_minutes: 5 + }) + }); + + if (response.ok) { + const data = await response.json(); + reservationId = data.id; + reservationExpiresAt = new Date(data.expires_at); + startWalkInCountdown(); + return true; + } else if (response.status === 409) { + toast.error('Slot no longer available, please try again'); + calculateSlotAvailability(); + return false; + } else { + const errorText = await response.text(); + toast.error(`Failed to reserve slot: ${errorText}`); + return false; + } + } catch (err) { + console.error('Reservation error:', err); + toast.error('Failed to reserve slot'); + return false; + } finally { + isReserving = false; + } + } + + function startWalkInCountdown() { + if ((window as any).__walkInCountdownInterval) { + clearInterval((window as any).__walkInCountdownInterval); + } + + const updateCountdown = () => { + if (!reservationExpiresAt) { + reservationCountdown = ''; + return; + } + + const now = new Date(); + const diff = reservationExpiresAt.getTime() - now.getTime(); + + if (diff <= 0) { + reservationCountdown = 'Expired'; + reservationId = null; + reservationExpiresAt = null; + toast.error('Slot released — please re-check availability'); + if ((window as any).__walkInCountdownInterval) { + clearInterval((window as any).__walkInCountdownInterval); + } + showCreateModal = false; + 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).__walkInCountdownInterval = setInterval(updateCountdown, 1000); + } + + function handleStartWalkIn() { + if (slotInfo?.isAvailableNow) { + showCreateModal = true; + } else if (slotInfo?.startTime) { + reserveWalkInSlot(slotInfo.startTime).then((reserved) => { + if (reserved) { + showCreateModal = true; + } + }); + } + } + + function handleModalClose() { + showCreateModal = false; + reservationId = null; + reservationExpiresAt = null; + reservationCountdown = ''; + if ((window as any).__walkInCountdownInterval) { + clearInterval((window as any).__walkInCountdownInterval); + } + }
@@ -194,10 +314,10 @@
@@ -207,5 +327,7 @@ bind:open={showCreateModal} maxSlotDuration={slotInfo?.durationMinutes ?? 0} availableStartTime={slotInfo?.isAvailableNow ? undefined : slotInfo?.startTime} + reservationExpiresAt={reservationExpiresAt} + onclose={handleModalClose} /> {/if} diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index 66354c7..0e9f212 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -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(''); + 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 @@
+ + {#if reservationExpiresAt && !isReservationExpired} +
+
+
+ + + + + Slot held for + +
+ + {reservationCountdown} + +
+
+ {:else if isReservationExpired} +
+
+ + + + + Slot released — please re-check availability + +
+
+ {/if} {#if currentStep === 1} @@ -717,7 +798,7 @@
- + {#if currentStep === 1} @@ -857,7 +1046,7 @@ {selectedTime} formattedDate={formattedSelectedDate} onselect={(time) => { - selectedTime = time; + selectTimeWithValidation(time); }} lunchProtectionStatus={lunchProtectionStatus()} /> @@ -916,6 +1105,18 @@ Please confirm your contact information + {#if reservationExpired} +
+

Reservation expired — please go back and select a new time

+
+ {:else} +
+

+ Your slot is reserved for {reservationCountdown} — complete your booking before time expires +

+
+ {/if} + - Next: Payment + {reservationExpired ? 'Reservation Expired' : 'Next: Review & Payment'} {/if} - + {#if currentStep === 4} - Payment Confirmation - Review and complete your booking + Payment & Review + Review your booking details - +

Payment

Square payment integration will be added here.

@@ -1033,11 +1234,38 @@ + + + {/if} + + + {#if currentStep === 5} + + + Confirm Your Booking + Ready to confirm your appointment + + +
+

+ Ready to confirm: {selectedServices.map((s) => s.name).join(', ')} on {selectedDate?.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })} at {selectedTime} +

+
+
+ + +