From 0b3d26914bf6baae449cfd52d80958ed496061a2 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 29 May 2026 16:07:02 +0100 Subject: [PATCH] refactor: booking flow wizard with step indicator improvements Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../lib/components/booking/BookingFlow.svelte | 128 +++++++++++++----- .../components/booking/StepIndicator.svelte | 13 +- 2 files changed, 102 insertions(+), 39 deletions(-) diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 4fa9a75..8ebefe0 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -12,6 +12,7 @@ // Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled manually by // staff adjusting working hours; the app does not need timezone-aware scheduling logic. import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; + import { goto } from '$app/navigation'; import { authStore } from '$lib/stores/auth.svelte'; import { toast } from 'svelte-sonner'; import { SvelteDate } from 'svelte/reactivity'; @@ -40,7 +41,7 @@ } from '$lib/types/booking'; // =============== State Management =============== - let currentStep = $state(1); + let currentStep = $state(authStore.isAuthenticated ? 1 : 0); let selectedServices = $state([]); let selectedDate = $state(undefined); let selectedTime = $state(null); @@ -78,7 +79,10 @@ let depositCardFormValid = $derived( selectedPaymentMethod !== null || - (showNewCardForm && newCardNumber.replace(/\s/g, '').length >= 13 && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3) + (showNewCardForm && + newCardNumber.replace(/\s/g, '').length >= 13 && + /^\d{2}\/\d{2}$/.test(newCardExpiry) && + newCardCVC.length >= 3) ); // Confirmation state @@ -188,7 +192,7 @@ const appointmentDate = selectedDate.toDate(getLocalTimeZone()); appointmentDate.setHours(hours, minutes, 0, 0); - const now = new Date(); + const now = new SvelteDate(); const hoursUntilAppointment = (appointmentDate.getTime() - now.getTime()) / (1000 * 60 * 60); return userDepositsRequired > 0 && hoursUntilAppointment <= 24; @@ -464,8 +468,14 @@ } const key = `${mYear}-${String(mMonth).padStart(2, '0')}`; if (!(key in workingHoursCache)) { - workingHoursCache[key] = null as unknown as Record; - availableHoursCache[key] = null as unknown as Record }>; + workingHoursCache[key] = null as unknown as Record< + string, + { isOpen: boolean; startTime: string; endTime: string } + >; + availableHoursCache[key] = null as unknown as Record< + string, + { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> } + >; loadingMonths[key] = true; } } @@ -485,7 +495,14 @@ // Data-driven auto-selection: auto-select the first available date when data loads $effect(() => { - if (workingHours && availableHours && !selectedDate && selectedServices.length > 0 && !userNavigatedCalendar && !bookingFlowAutoSelectDone) { + if ( + workingHours && + availableHours && + !selectedDate && + selectedServices.length > 0 && + !userNavigatedCalendar && + !bookingFlowAutoSelectDone + ) { bookingFlowAutoSelectDone = true; const currentDate = new SvelteDate(); @@ -514,11 +531,7 @@ if (!isDateUnavailable(calDate)) { selectedDate = calDate; if (!userNavigatedCalendar) { - placeholder = new CalendarDate( - nextDate.getFullYear(), - nextDate.getMonth() + 1, - 1 - ); + placeholder = new CalendarDate(nextDate.getFullYear(), nextDate.getMonth() + 1, 1); } return; } @@ -572,7 +585,10 @@ whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; }); - const ahMap: Record }> = {}; + const ahMap: Record< + string, + { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> } + > = {}; ahData.forEach((d) => { ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }; }); @@ -789,7 +805,10 @@ function generateGroupedTimeSlots( duration: number, date: CalendarDate | undefined, - lunchProtectionMap: Map = new Map() + lunchProtectionMap: Map< + string, + { isBlocked: boolean; showWarning: boolean; warningMessage?: string } + > = new Map() ): Array<{ type: 'available' | 'unavailable'; startTime: string; @@ -1107,13 +1126,23 @@ let depositRequired = $derived(calculateDepositRequired()); let totalSteps = $derived(depositRequired ? 5 : 4); let stepLabels = $derived( - depositRequired - ? ['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation'] - : ['Service', 'Date & Time', 'Details', 'Confirmation'] + authStore.isAuthenticated + ? depositRequired + ? ['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation'] + : ['Service', 'Date & Time', 'Details', 'Confirmation'] + : ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment', 'Confirmation'] ); // =============== Navigation =============== async function nextStep() { + if (currentStep === 0) { + currentStep = 1; + setTimeout(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, 50); + return; + } + // Step 2 -> Step 3: Re-validate slot, then reserve if (currentStep === 2) { const slotStillFree = await refreshAndValidateSlot(); @@ -1329,10 +1358,29 @@

Professional beauty treatments in a calm and friendly environment

- + + + {#if currentStep === 0} + + + Welcome + Log in for the best booking experience + + +

+ Guest checkout does not receive loyalty stamps or seasonal discounts. +

+
+ + +
+
+
+ {/if} {#if currentStep === 1} @@ -1419,7 +1467,7 @@ { - placeholder = newPlaceholder; - userNavigatedCalendar = true; - }} + onPlaceholderChange={(newPlaceholder) => { + placeholder = newPlaceholder; + userNavigatedCalendar = true; + }} /> {/if} @@ -1554,8 +1602,8 @@ {#if !authStore.isAuthenticated}

- You are checking out as a guest, so you will miss out on a loyalty stamp. Please login - for full membership benefits. + You are checking out as a guest, so you will miss out on a loyalty stamp and any + possible seasonal discounts. Please login for full membership benefits.

@@ -1756,7 +1804,10 @@ type="text" inputmode="numeric" value={newCardNumber} - oninput={(e) => (newCardNumber = formatDepositCardNumber((e.target as HTMLInputElement).value))} + oninput={(e) => + (newCardNumber = formatDepositCardNumber( + (e.target as HTMLInputElement).value + ))} placeholder="1234 5678 9012 3456" maxlength={19} /> @@ -1769,14 +1820,24 @@ type="text" inputmode="numeric" value={newCardExpiry} - oninput={(e) => (newCardExpiry = formatDepositExpiry((e.target as HTMLInputElement).value))} + oninput={(e) => + (newCardExpiry = formatDepositExpiry( + (e.target as HTMLInputElement).value + ))} placeholder="MM/YY" maxlength={5} />
- +
{#if authStore.isAuthenticated} @@ -1996,14 +2057,15 @@ deposit_paid: true, payments: [], duration_minutes: getTotalDuration(), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() + created_at: new SvelteDate().toISOString(), + updated_at: new SvelteDate().toISOString() }} onClose={() => (showPayEarlyModal = false)} onComplete={() => { showPayEarlyModal = false; }} - canSaveCards={authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'} + canSaveCards={authStore.currentUser?.role === 'verified_email' || + authStore.currentUser?.role === 'affiliate'} /> {/if} diff --git a/frontend/src/lib/components/booking/StepIndicator.svelte b/frontend/src/lib/components/booking/StepIndicator.svelte index d6030a6..14879bc 100644 --- a/frontend/src/lib/components/booking/StepIndicator.svelte +++ b/frontend/src/lib/components/booking/StepIndicator.svelte @@ -1,14 +1,16 @@ -
+
{#each steps as step, index (step)} - {@const stepNumber = index + 1} - {@const isActive = stepNumber <= currentStep} + {@const displayNumber = startAt + index} + {@const isActive = displayNumber <= currentStep} {@const isLastStep = index === totalSteps - 1}
@@ -18,7 +20,7 @@ ? 'bg-primary text-primary-foreground' : 'bg-gray-200 text-gray-600'}" > - {stepNumber} + {displayNumber}
@@ -26,10 +28,9 @@ {step} - {#if !isLastStep}