From 4ac7768070339b5d6b825a669881f00f055ece3c Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Wed, 24 Jun 2026 23:43:58 +0100 Subject: [PATCH] refactor(frontend): timezone-safe date handling with London-aware utilities Introduce getLondonTodayCalendarDate(), parseWallClockDate(), and formatLocalDateTime() for reliable Europe/London timezone handling. Replace ad-hoc SvelteDate/new Date() usage with these utilities across all components and stores. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/app.d.ts | 6 + .../account/EditRequestModal.svelte | 48 ++- .../account/UserBookingModal.svelte | 37 +- .../lib/components/admin/ApprovalModal.svelte | 21 +- .../admin/BookingCreateModal.svelte | 122 +++--- .../components/admin/BusinessSettings.svelte | 12 +- .../admin/CustomServicesManagement.svelte | 2 +- .../admin/DiscountsManagement.svelte | 4 +- .../components/admin/EditBookingModal.svelte | 13 +- .../components/admin/EditRequestModal.svelte | 19 +- .../admin/GiftCardsManagement.svelte | 3 +- .../lib/components/admin/HolidayHours.svelte | 9 +- .../components/admin/RescheduleModal.svelte | 43 +- .../lib/components/admin/TimeBlockers.svelte | 29 +- .../src/lib/components/admin/UserModal.svelte | 17 +- .../lib/components/admin/WalkInBooking.svelte | 36 +- .../components/admin/WalkInCreateModal.svelte | 48 +-- .../lib/components/booking/BookingFlow.svelte | 386 ++++++++++-------- .../today/CurrentAppointment.svelte | 2 +- .../lib/components/today/TodayCalendar.svelte | 18 +- .../lib/components/today/TodayStats.svelte | 15 +- .../src/lib/components/ui/input/input.svelte | 5 +- .../src/lib/components/ui/map/MapArc.svelte | 3 +- frontend/src/lib/stores/auth.svelte.ts | 6 +- frontend/src/lib/types/booking.ts | 1 + frontend/src/lib/utils/format.ts | 27 +- frontend/src/lib/utils/timeSlots.ts | 130 +++--- frontend/src/routes/account/+page.svelte | 12 +- .../src/routes/admin/schedule/+page.svelte | 45 +- frontend/src/routes/gdpr/+page.svelte | 9 +- frontend/src/routes/login/+page.svelte | 3 +- frontend/src/routes/schedule/+page.svelte | 2 +- 32 files changed, 618 insertions(+), 515 deletions(-) diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 5dc4eab..e5e13f7 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -8,6 +8,12 @@ declare global { // interface PageState {} // interface Platform {} } + + interface Window { + __walkInCountdownInterval?: ReturnType; + __walkInModalCountdownInterval?: ReturnType; + __bookingCreateCountdownInterval?: ReturnType; + } } export {}; diff --git a/frontend/src/lib/components/account/EditRequestModal.svelte b/frontend/src/lib/components/account/EditRequestModal.svelte index 23aeb3d..1043dcd 100644 --- a/frontend/src/lib/components/account/EditRequestModal.svelte +++ b/frontend/src/lib/components/account/EditRequestModal.svelte @@ -15,6 +15,7 @@ getLunchProtectionForSlots, timeToMinutes } from '$lib/lunchProtection'; + import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots'; import ClockIcon from '@lucide/svelte/icons/clock'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left'; @@ -67,10 +68,10 @@ let userNavigatedCalendar = $state(false); let editRequestAutoSelectDone = $state(false); // ─── Date constants ───────────────────────────────────── - const today = new Date(); - const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate()); - const maxDate = new Date(); - maxDate.setMonth(today.getMonth() + 6); + const todayCalendarDate = getLondonTodayCalendarDate(); + const minDate = todayCalendarDate; + const maxDate = new SvelteDate(todayCalendarDate.year, todayCalendarDate.month - 1, todayCalendarDate.day); + maxDate.setMonth(todayCalendarDate.month - 1 + 6); const maxCalendarDate = new CalendarDate( maxDate.getFullYear(), maxDate.getMonth() + 1, @@ -204,8 +205,7 @@ if (!dayWH || !dayWH.isOpen || !dayAH || !dayAH.slots) return []; const slots: string[] = []; - const now = new SvelteDate(); - const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); + const todayCal = getLondonTodayCalendarDate(); const isToday = date.compare(todayCal) === 0; for (const slot of dayAH.slots) { @@ -217,7 +217,10 @@ const endMin = eh * 60 + em; if (isToday) { - const currentMin = now.getHours() * 60 + now.getMinutes(); + const now = new Date(); + const londonTime = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false }); + const [londonHours, londonMinutes] = londonTime.split(':').map(Number); + const currentMin = londonHours * 60 + londonMinutes; startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15); } @@ -261,11 +264,13 @@ let startMin = sh * 60 + sm; const endMin = eh * 60 + em; - const now = new SvelteDate(); - const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); + const todayCal = getLondonTodayCalendarDate(); const isToday = date.compare(todayCal) === 0; if (isToday) { - const currentMin = now.getHours() * 60 + now.getMinutes(); + const now = new Date(); + const londonTime = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false }); + const [londonHours, londonMinutes] = londonTime.split(':').map(Number); + const currentMin = londonHours * 60 + londonMinutes; startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15); } @@ -324,10 +329,6 @@ function isDateUnavailable(date: DateValue): boolean { const d = date as CalendarDate; - const jsDate = d.toDate(getLocalTimeZone()); - const now = new SvelteDate(); - const todayStart = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate()); - if (jsDate < todayStart) return true; if (d.compare(minDate) < 0 || d.compare(maxCalendarDate) > 0) return true; if (!workingHours) return true; const dateStr = d.toString(); @@ -431,7 +432,7 @@ return; editRequestAutoSelectDone = true; - const currentDate = new SvelteDate(); + const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00'); const maxDateJs = new SvelteDate( maxCalendarDate.year, maxCalendarDate.month - 1, @@ -446,7 +447,7 @@ for (let i = 0; i <= daysToCheck; i++) { const nextDate = new SvelteDate(currentDate); nextDate.setDate(currentDate.getDate() + i); - const dateStr = nextDate.toISOString().split('T')[0]; + const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); if (workingHours[dateStr]?.isOpen) { const calDate = new CalendarDate( @@ -464,11 +465,14 @@ } } - const tomorrow = new SvelteDate(); - tomorrow.setDate(tomorrow.getDate() + 1); - newDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, tomorrow.getDate()); + const tomorrowCal = getLondonTodayCalendarDate(); + newDate = new CalendarDate( + tomorrowCal.year, + tomorrowCal.month, + tomorrowCal.day + 1 + ); if (!userNavigatedCalendar) { - placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1); + placeholderDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1); } }); @@ -612,7 +616,7 @@ if (!workingHours || !availableHours) return 0; const bookingDate = new SvelteDate(booking.start_time); - const dateStr = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`; + const dateStr = bookingDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); const dayWH = workingHours[dateStr]; const dayAH = availableHours[dateStr]; @@ -687,7 +691,7 @@ const [hours, minutes] = newTime.split(':').map(Number); const bookingDate = newDate!.toDate(getLocalTimeZone()); bookingDate.setHours(hours || 0, minutes || 0, 0, 0); - body.new_start_time = bookingDate.toISOString(); + body.new_start_time = formatLocalDateTime(bookingDate); } if (editMode === 'services' || editMode === 'both-time') { diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 09c519f..d2ac66a 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -1,17 +1,18 @@ diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index 8bb404e..72e5dbd 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -6,6 +6,7 @@ import { getLocalTimeZone } from '@internationalized/date'; import { isValidUKPhone, toE164UK } from '$lib/utils/phone'; import { formatUserName } from '$lib/utils/nameDisplay'; + import { formatLocalDateTime } from '$lib/utils/timeSlots'; // UI Components import * as Modal from '$lib/components/ui/dialog'; @@ -213,8 +214,8 @@ }); function startCountdown() { - if ((window as any).__walkInModalCountdownInterval) { - clearInterval((window as any).__walkInModalCountdownInterval); + if (window.__walkInModalCountdownInterval) { + clearInterval(window.__walkInModalCountdownInterval); } const updateCountdown = () => { @@ -229,8 +230,8 @@ if (diff <= 0) { reservationCountdown = 'Expired'; isReservationExpired = true; - if ((window as any).__walkInModalCountdownInterval) { - clearInterval((window as any).__walkInModalCountdownInterval); + if (window.__walkInModalCountdownInterval) { + clearInterval(window.__walkInModalCountdownInterval); } return; } @@ -241,7 +242,7 @@ }; updateCountdown(); - (window as any).__walkInModalCountdownInterval = setInterval(updateCountdown, 1000); + window.__walkInModalCountdownInterval = setInterval(updateCountdown, 1000); } function resetState() { @@ -299,7 +300,6 @@ services = await response.json(); } } catch (err) { - console.error('Failed to fetch services', err); toast.error('Failed to load services'); } finally { loadingServices = false; @@ -461,19 +461,16 @@ if (availableStartTime) { // Parse the time from the widget (format: "HH:MM" or "HH:MM:SS") const [hours, minutes] = availableStartTime.split(':').map(Number); - const now = new SvelteDate(); - start = new SvelteDate( - now.getFullYear(), - now.getMonth(), - now.getDate(), - hours, - minutes, - 0, - 0 - ); + const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); + const [y, m, d] = londonDateStr.split('-').map(Number); + start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0); } else { // Fallback: Calculate immediate start time (rounded to next 15 min) - const now = new SvelteDate(); + const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); + const londonTimeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false }); + const [y, m, d] = londonDateStr.split('-').map(Number); + const [h, min] = londonTimeStr.split(':').map(Number); + const now = new SvelteDate(y, m - 1, d, h, min, 0, 0); start = new SvelteDate(now); const minutes = start.getMinutes(); const remainder = 15 - (minutes % 15); @@ -484,7 +481,7 @@ start.setMilliseconds(0); } - const dateTimeStr = start.toISOString(); + const dateTimeStr = formatLocalDateTime(start); const overrides = []; for (const [serviceId, data] of Object.entries(serviceOverrides)) { @@ -500,11 +497,18 @@ } } - const payload: Record = { + const payload: { + user_id: string; + start_time: string; + service_ids: string[]; + custom_service_ids: string[]; + service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined; + notes: string | null; + } = { user_id: finalUserId, start_time: dateTimeStr, - service_ids: selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id), - custom_service_ids: selectedServices.filter(s => (s as any).is_custom).map((s) => s.id), + service_ids: selectedServices.filter(s => !s.is_custom).map((s) => s.id), + custom_service_ids: selectedServices.filter(s => s.is_custom).map((s) => s.id), service_overrides: overrides.length > 0 ? overrides : undefined, notes: notes.trim() || null }; @@ -526,11 +530,9 @@ onBookingCreated?.(); } else { const errorText = await res.text(); - console.error('Booking creation failed:', errorText); toast.error(`Failed to create booking: ${errorText}`); } } catch (err) { - console.error('Booking submission error:', err); toast.error('An error occurred while creating booking'); } finally { submitting = false; diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index d356e7c..201d946 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -13,8 +13,12 @@ // INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only // salon app. All customers are physically in the UK and book UK appointment slots. We do NOT // auto-adjust for international timezones — the slot time shown is the actual UK salon time. - // 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. + // Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled automatically: + // formatLocalDateTime converts wall-clock time to UTC using the correct DST offset for the + // target date (via @internationalized/date's CalendarDate.toDate which applies the target + // date's timezone rules, not the current date's). The backend stores all timestamps as + // TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at + // "10am June 15" stays at 10am BST regardless of when the booking was made. import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; @@ -40,6 +44,7 @@ getLunchProtectionForSlots, type TimeSlot } from '$lib/lunchProtection'; + import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots'; import type { Service, @@ -52,7 +57,18 @@ } from '$lib/types/booking'; // =============== State Management =============== - let currentStep = $state(authStore.isAuthenticated ? 1 : 0); + let currentStep = $state(0); + let authReady = $state(false); + + // Wait for auth store to finish initializing before deciding which step to show. + // This prevents a flash of the login prompt on SSR + hydration — the skeleton + // displays while auth checks are pending, then the correct screen appears. + $effect(() => { + if (authStore.hasLoaded && !authReady) { + authReady = true; + currentStep = authStore.isAuthenticated ? 1 : 0; + } + }); let selectedServices = $state([]); let selectedDate = $state(undefined); let selectedTime = $state(null); @@ -198,7 +214,6 @@ userDepositsRequired = user.deposits_required ?? 0; } } catch (err) { - console.error('Failed to fetch user deposits status:', err); userDepositsRequired = 0; } } @@ -234,7 +249,6 @@ hasActiveBooking = data.bookings && data.bookings.length > 0; } } catch (err) { - console.error('Failed to check active booking status:', err); hasActiveBooking = false; } finally { activeBookingCheckDone = true; @@ -265,7 +279,6 @@ paymentMethods = []; } } catch (err) { - console.error('Failed to fetch payment methods:', err); paymentMethods = []; } finally { paymentMethodsLoading = false; @@ -417,7 +430,7 @@ const [hours, minutes] = selectedTime.split(':').map(Number); const bookingDate = selectedDate.toDate(getLocalTimeZone()); bookingDate.setHours(hours, minutes, 0, 0); - const startTimeISO = bookingDate.toISOString(); + const startTimeISO = formatLocalDateTime(bookingDate); const serviceIds = selectedServices.map((s) => s.id); const response = await fetch('/api/bookings/reserve', { @@ -533,11 +546,9 @@ // Combine: valid first, then grayed out services = [...valid, ...grayedOut]; } else { - console.error('Failed to fetch services:', response.status); toast.error('Failed to load services'); } } catch (err) { - console.error('Error fetching services:', err); toast.error('Network error loading services'); } finally { servicesLoading = false; @@ -569,14 +580,10 @@ > = {}; // Initialize date boundaries - const today = new SvelteDate(); - const tomorrow = new SvelteDate(today); - tomorrow.setDate(today.getDate() + 1); - const maxDate = new SvelteDate(); - maxDate.setMonth(today.getMonth() + 6); - - // Create CalendarDate objects - const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate()); + const today = getLondonTodayCalendarDate(); + const minDate = today; + const maxDate = new SvelteDate(today.year, today.month - 1, today.day); + maxDate.setMonth(today.month - 1 + 6); const maxCalendarDate = new CalendarDate( maxDate.getFullYear(), maxDate.getMonth() + 1, @@ -609,14 +616,8 @@ } const key = `${mYear}-${String(mMonth).padStart(2, '0')}`; if (!(key in workingHoursCache)) { - 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 }> } - >; + workingHoursCache[key] = null as unknown as Record; + availableHoursCache[key] = null as unknown as Record }>; loadingMonths[key] = true; } } @@ -646,7 +647,7 @@ ) { bookingFlowAutoSelectDone = true; - const currentDate = new SvelteDate(); + const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00'); const maxDateJs = new SvelteDate( maxCalendarDate.year, maxCalendarDate.month - 1, @@ -661,7 +662,7 @@ for (let i = 1; i <= daysToCheck; i++) { const nextDate = new SvelteDate(currentDate); nextDate.setDate(currentDate.getDate() + i); - const dateStr = nextDate.toISOString().split('T')[0]; + const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); if (workingHours[dateStr]?.isOpen) { const calDate = new CalendarDate( @@ -679,15 +680,15 @@ } } - const tomorrow = new SvelteDate(); - tomorrow.setDate(tomorrow.getDate() + 1); - selectedDate = new CalendarDate( - tomorrow.getFullYear(), - tomorrow.getMonth() + 1, - tomorrow.getDate() + const tomorrowCal = getLondonTodayCalendarDate(); + const tomorrowDate = new CalendarDate( + tomorrowCal.year, + tomorrowCal.month, + tomorrowCal.day + 1 ); + selectedDate = tomorrowDate; if (!userNavigatedCalendar) { - placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1); + placeholder = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1); } } }); @@ -752,7 +753,6 @@ workingHours = { ...workingHours, ...whMap }; availableHours = { ...availableHours, ...ahMap }; } catch (error) { - console.error('Failed to fetch hours:', error); // Clean up loadingMonths for the range for (let i = 0; i < months; i++) { let mYear = startDate.year; @@ -853,7 +853,6 @@ availableHoursCache[monthKey] = availableHoursMap; availableHours = { ...availableHours, ...availableHoursMap }; } catch (error) { - console.error('Failed to fetch hours:', error); if (!selectedDate) { selectedDate = minDate; } @@ -911,8 +910,10 @@ } const slots: string[] = []; - const now = new SvelteDate(); - const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); + const today = getLondonTodayCalendarDate(); + const now = new Date(); + const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false }); + const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number); const isToday = date.compare(today) === 0; for (const slot of dayAvailableHours.slots) { @@ -923,7 +924,7 @@ const endTotalMinutes = endHour * 60 + endMinute; if (isToday) { - const currentMinutes = now.getHours() * 60 + now.getMinutes(); + const currentMinutes = londonHours * 60 + londonMinutes; const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15; startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes); } @@ -979,12 +980,14 @@ let startTotalMinutes = startHour * 60 + startMinute; const endTotalMinutes = endHour * 60 + endMinute; - const now = new SvelteDate(); - const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); - const isToday = date.compare(today) === 0; + const todayCal = getLondonTodayCalendarDate(); + const now = new Date(); + const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false }); + const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number); + const isToday = date.compare(todayCal) === 0; if (isToday) { - const currentMinutes = now.getHours() * 60 + now.getMinutes(); + const currentMinutes = londonHours * 60 + londonMinutes; const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15; startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes); } @@ -1281,13 +1284,17 @@ ); let depositRequired = $derived(calculateDepositRequired()); - let totalSteps = $derived(depositRequired ? 5 : 4); + let totalSteps = $derived(authStore.isAuthenticated ? 4 : 5); + // StepIndicator uses displayNumber = startAt + index. currentStep aligns with displayNumber, + // not the array index. For auth: startAt=1, totalSteps=4 → last displayNumber=4. + // For guest: startAt=0, totalSteps=5 → last displayNumber=4. Always evaluates to 4. + let finalStep = $derived(totalSteps - 1 + (authStore.isAuthenticated ? 1 : 0)); let stepLabels = $derived( authStore.isAuthenticated - ? depositRequired - ? ['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation'] - : ['Service', 'Date & Time', 'Details', 'Confirmation'] - : ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment', 'Confirmation'] + ? ['Service', 'Date & Time', 'Details', 'Payment'] + : userDepositsRequired > 0 + ? ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment'] + : ['Welcome', 'Service', 'Date & Time', 'Details', 'Confirmation'] ); // =============== Navigation =============== @@ -1308,24 +1315,24 @@ if (!reserved) return; } - // Step 3 -> Step 4 (if deposit required) or Step 4 (confirmation, if no deposit) + // Step 3 -> Final step (Payment if deposit required, else submit booking) if (currentStep === 3) { if (calculateDepositRequired()) { - currentStep = 4; + currentStep = finalStep; } else { await submitAndProceed(); } return; } - // Step 4: if deposit required, this is payment step -> submit booking -> step 5 - // Step 4: if no deposit, this is confirmation step -> nothing - if (currentStep === 4 && calculateDepositRequired()) { - await submitAndProceed(); + // Final step with deposit: user must pay before booking is created. + // Payment is handled by processPayment(), not nextStep(). + // This guards against manual increment from the payment step. + if (currentStep === finalStep && calculateDepositRequired()) { return; } - if (currentStep < (depositRequired ? 5 : 4)) { + if (currentStep < finalStep) { currentStep++; setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -1364,7 +1371,7 @@ const [hours, minutes] = selectedTime.split(':').map(Number); const bookingDate = selectedDate.toDate(getLocalTimeZone()); bookingDate.setHours(hours, minutes, 0, 0); - const startTimeISO = bookingDate.toISOString(); + const startTimeISO = formatLocalDateTime(bookingDate); const serviceIds = selectedServices.map((s) => s.id); @@ -1436,7 +1443,7 @@ total_amount: booking.total_amount || getTotalPrice(), duration_minutes: booking.duration_minutes || getTotalDuration() }; - currentStep = depositRequired ? 5 : 4; + currentStep = finalStep; fetchDiscountPreview(); setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -1478,10 +1485,8 @@ } else { toast.error('Failed to submit booking: ' + errorMessage); } - console.error('Booking submission failed:', response.status, errorText); } } catch (error) { - console.error('Booking submission error:', error); toast.error('Network error. Please check your connection and try again.'); } finally { isSubmitting = false; @@ -1532,6 +1537,34 @@

Professional beauty treatments in a calm and friendly environment

+ {#if !authReady} + +
+ {#each [1, 2, 3, 4] as _} +
+
+
+
+ {#if _ < 4} +
+ {/if} + {/each} +
+ + + +
+
+ + +
+
+
+ + + + {:else} + {#if currentStep === 3} +
+

Almost There

+ {#if !authStore.isAuthenticated} +

Just a couple more details

+ {/if} +
Your Details @@ -1765,7 +1804,7 @@ {:else}

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

{/if} @@ -1903,119 +1942,8 @@
{/if} - - {#if currentStep === 4 && depositRequired} - - - Pay Your Deposit - - A deposit of £{calculateDepositAmount()} is required to secure - your appointment. - - - - - -
-

Pay Deposit

- - {#if authStore.isAuthenticated} - {#if paymentMethodsLoading} -
Loading payment methods...
- {:else if paymentMethods.length > 0} -
-

Saved Cards

-
- {#each paymentMethods as method (method.id)} -
-
-
- {method.brand} -
-
- **** {method.last4} - - {formatCardExpiry(method.expiry_month, method.expiry_year)} - -
-
- -
- {/each} -
-
- {/if} - - {#if !showNewCardForm} - - {/if} - {/if} - - {#if showNewCardForm || !authStore.isAuthenticated} - - {/if} - -
- - -
-
-
-
- {/if} - - - {#if currentStep === 5 || (currentStep === 4 && !depositRequired)} + + {#if currentStep === finalStep} {#if confirmedBooking} {@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0} {@const bookingDate = new SvelteDate(confirmedBooking.start_time)} @@ -2209,14 +2137,111 @@ - {:else} + {:else if depositRequired} - -
-
-

Confirming your booking...

+ + Pay Your Deposit + + A deposit of £{calculateDepositAmount()} is required to secure + your appointment. + + + + + +
+

Pay Deposit

+ + {#if authStore.isAuthenticated} + {#if paymentMethodsLoading} +
Loading payment methods...
+ {:else if paymentMethods.length > 0} +
+

Saved Cards

+
+ {#each paymentMethods as method (method.id)} +
+
+
+ {method.brand} +
+
+ **** {method.last4} + + {formatCardExpiry(method.expiry_month, method.expiry_year)} + +
+
+ +
+ {/each} +
+
+ {/if} + + {#if !showNewCardForm} + + {/if} + {/if} + + {#if showNewCardForm || !authStore.isAuthenticated} + + {/if} + +
+ + +
@@ -2255,4 +2280,5 @@ canSaveCards={authStore.isAuthenticated} /> {/if} +{/if}
diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index 86a5b47..e7e2ec2 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -148,7 +148,7 @@ const today = new SvelteDate(); const closing = new SvelteDate( today.getFullYear(), - today.getMonth() + 1, + today.getMonth(), today.getDate(), ch, cm, diff --git a/frontend/src/lib/components/today/TodayCalendar.svelte b/frontend/src/lib/components/today/TodayCalendar.svelte index 5b871b2..5a644a3 100644 --- a/frontend/src/lib/components/today/TodayCalendar.svelte +++ b/frontend/src/lib/components/today/TodayCalendar.svelte @@ -1,6 +1,7 @@