From 2069a99f8c3fa092be6cbecbc4a89312a059d001 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 5 Jul 2026 11:48:14 +0100 Subject: [PATCH] feat: improve booking flow with auth headers and reservation release Send auth token in reserve/availability/working-hours requests so the backend can exclude the user's own reservations. Cancel old reservation via DELETE before reserving a new slot. Track reserved slot time/date for back-navigate detection. Clean up countdown interval via onDestroy and window reference to prevent duplicates. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../lib/components/booking/BookingFlow.svelte | 105 +++++++++++++++--- .../src/routes/api/[...path]/+server.ts.txt | 43 ------- 2 files changed, 92 insertions(+), 56 deletions(-) delete mode 100644 frontend/src/routes/api/[...path]/+server.ts.txt diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 353dc44..62cb08b 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -24,8 +24,15 @@ import { authStore } from '$lib/stores/auth.svelte'; import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte'; import { toast } from 'svelte-sonner'; + import { onDestroy } from 'svelte'; import { SvelteDate } from 'svelte/reactivity'; + declare global { + interface Window { + __bookingFlowCountdownInterval?: ReturnType | null; + } + } + // Components import BookingActions from '$lib/components/booking/BookingActions.svelte'; import BookingSummary from '$lib/components/booking/BookingSummary.svelte'; @@ -358,6 +365,10 @@ let reservationExpired = $state(false); let _isReserving = $state(false); + // Track the reserved slot so we can detect changes on back-navigate in nextStep() + let _reservedSlotTime: string | null = $state(null); + let _reservedSlotDate: string | null = $state(null); + // =============== Slot Reservation Functions =============== async function reserveSlot() { _isReserving = true; @@ -375,7 +386,10 @@ const response = await fetch('/api/bookings/reserve', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}) + }, body: JSON.stringify({ start_time: startTimeISO, service_ids: serviceIds }) }); @@ -396,6 +410,8 @@ _reservationId = data.id; reservationExpiresAt = new Date(data.expires_at); reservationExpired = false; + _reservedSlotTime = selectedTime; + _reservedSlotDate = selectedDate ? selectedDate.toString() : null; startCountdown(); return true; } catch (_error) { @@ -409,6 +425,12 @@ function startCountdown() { if (!reservationExpiresAt) return; + // Clear any existing countdown interval to prevent duplicates + if (window.__bookingFlowCountdownInterval) { + clearInterval(window.__bookingFlowCountdownInterval); + window.__bookingFlowCountdownInterval = null; + } + const updateCountdown = () => { if (!reservationExpiresAt) { reservationCountdown = ''; @@ -423,6 +445,12 @@ reservationExpired = true; _reservationId = null; reservationExpiresAt = null; + _reservedSlotTime = null; + _reservedSlotDate = null; + if (window.__bookingFlowCountdownInterval) { + clearInterval(window.__bookingFlowCountdownInterval); + window.__bookingFlowCountdownInterval = null; + } toast.error('Your reservation has expired. Please select a new time slot.'); return; } @@ -433,9 +461,12 @@ }; updateCountdown(); - const interval = setInterval(() => { + window.__bookingFlowCountdownInterval = setInterval(() => { if (reservationExpired) { - clearInterval(interval); + if (window.__bookingFlowCountdownInterval) { + clearInterval(window.__bookingFlowCountdownInterval); + window.__bookingFlowCountdownInterval = null; + } return; } updateCountdown(); @@ -461,7 +492,7 @@ method: 'GET', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${authStore.currentToken}` + ...(authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}) } }); @@ -657,9 +688,10 @@ loadingAvailableHours = true; try { + const authHeaders = authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}; const [whRes, ahRes] = await Promise.all([ - fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`), - fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`) + fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, { headers: authHeaders }), + fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, { headers: authHeaders }) ]); if (!whRes.ok || !ahRes.ok) { throw new Error(`HTTP error! wh: ${whRes.status}, ah: ${ahRes.status}`); @@ -750,9 +782,12 @@ const startStr = startOfMonth.toString(); const endStr = endOfMonth.toString(); + const authHeaders = authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}; + // Fetch working hours const workingHoursResponse = await fetch( - `/api/scheduling/working-hours?start=${startStr}&end=${endStr}` + `/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, + { headers: authHeaders } ); if (!workingHoursResponse.ok) { throw new Error(`HTTP error! status: ${workingHoursResponse.status}`); @@ -777,7 +812,8 @@ // Fetch available hours const availableHoursResponse = await fetch( - `/api/scheduling/available-hours?start=${startStr}&end=${endStr}` + `/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, + { headers: authHeaders } ); if (!availableHoursResponse.ok) { throw new Error(`HTTP error! status: ${availableHoursResponse.status}`); @@ -1266,12 +1302,47 @@ return; } - // Step 2 -> Step 3: Re-validate slot, then reserve + // Step 2 -> Step 3: Check if we already hold a valid reservation for this exact slot if (currentStep === 2) { - const slotStillFree = await refreshAndValidateSlot(); - if (!slotStillFree) return; - const reserved = await reserveSlot(); - if (!reserved) return; + const sameSlot = _reservationId && !reservationExpired && + _reservedSlotTime === selectedTime && + _reservedSlotDate === (selectedDate ? selectedDate.toString() : null); + + if (sameSlot) { + // Same slot — keep existing reservation, go straight to step 3 + currentStep = 3; + } else { + // Different slot or no reservation — release old one first, then reserve new + if (_reservationId) { + try { + const res = await fetch('/api/bookings/reserve', { + method: 'DELETE', + headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {} + }); + if (!res.ok) console.warn('Failed to release old reservation', res.status); + } catch (e) { + console.warn('Error releasing old reservation', e); + } + _reservationId = null; + reservationExpiresAt = null; + reservationCountdown = ''; + reservationExpired = false; + _reservedSlotTime = null; + _reservedSlotDate = null; + if (window.__bookingFlowCountdownInterval) { + clearInterval(window.__bookingFlowCountdownInterval); + window.__bookingFlowCountdownInterval = null; + } + } + + const slotStillFree = await refreshAndValidateSlot(); + if (!slotStillFree) return; + const reserved = await reserveSlot(); + if (!reserved) return; + // reserveSlot() stores _reservedSlotTime/_reservedSlotDate internally + currentStep = 3; + } + return; } // Step 3 -> Final step (Payment if deposit required, else submit booking) @@ -1461,6 +1532,14 @@ } } + // Clean up countdown interval on component destroy (do NOT call DELETE — let TTL expire naturally) + onDestroy(() => { + if (typeof window !== 'undefined' && window.__bookingFlowCountdownInterval) { + clearInterval(window.__bookingFlowCountdownInterval); + window.__bookingFlowCountdownInterval = null; + } + }); + // =============== Validation =============== const isBlockedByActiveBooking = $derived( authStore.isAuthenticated && userDepositsRequired > 0 && hasActiveBooking diff --git a/frontend/src/routes/api/[...path]/+server.ts.txt b/frontend/src/routes/api/[...path]/+server.ts.txt deleted file mode 100644 index 703c085..0000000 --- a/frontend/src/routes/api/[...path]/+server.ts.txt +++ /dev/null @@ -1,43 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -const BACKEND_URL = - import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080'; - -async function proxyRequest(request: Request, path: string) { - const incomingUrl = new URL(request.url); - - const queryString = incomingUrl.search; - - const url = `${BACKEND_URL}/api/${path}${queryString}`; - - try { - const headers = new Headers(request.headers); - headers.delete('host'); - - const backendRes = await fetch(url, { - method: request.method, - headers, - body: ['GET', 'HEAD'].includes(request.method) - ? undefined - : await request.text() - }); - - // Forward everything transparently - const resHeaders = new Headers(backendRes.headers); - return new Response(backendRes.body, { - status: backendRes.status, - headers: resHeaders - }); - } catch (err) { - console.error('Proxy error:', err); - return error(502, 'Backend unreachable'); - } -} - - -export const GET: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); -export const POST: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); -export const PUT: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); -export const DELETE: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); -export const PATCH: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); \ No newline at end of file