fix(ui): improve reservation lifecycle in BookingFlow

- Release reservation on service change, date change, and step-back navigation
- Add synchronous double-click payment guard (isProcessingPaymentSync)
- Immutable update for confirmedBooking to prevent race-condition overcharge
- Generate fresh idempotency key per submission attempt (was reused across
  component lifetime, causing stale-booking illusion on re-submit)
- Release reservation after successful booking submission
- Extract releaseReservation() helper for DRY reservation cleanup
- Race-condition guard in selectTimeWithValidation: pass clicked time
  explicitly so stale validation can't clobber a newer selection
This commit is contained in:
2026-07-06 17:58:14 +01:00
parent 98d561e8b6
commit e831953e5b
3 changed files with 162 additions and 65 deletions
@@ -40,6 +40,7 @@
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
import { generateUUID } from '$lib/utils/uuid';
import type {
Service,
@@ -88,6 +89,11 @@
let selectedPaymentMethod = $state<string | null>(null);
let showNewCardForm = $state(false);
let isProcessingPayment = $state(false);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so `isProcessingPayment` may not propagate to the
// button's `disabled` binding before a fast second click fires. This non-
// reactive flag is checked synchronously at the start of processPayment.
let isProcessingPaymentSync = false;
// New card form fields
let newCardNumber = $state('');
@@ -282,6 +288,11 @@
}
async function processPayment(amount: number) {
// Synchronous double-click guard — set BEFORE any await so a rapid second
// click is rejected immediately, even before the reactive `disabled` has
// propagated to the button.
if (isProcessingPaymentSync) return;
isProcessingPaymentSync = true;
isProcessingPayment = true;
paymentAttempted = false;
try {
@@ -296,7 +307,7 @@
const body: Record<string, unknown> = {
payment_type: 'deposit',
amount: amountCents,
idempotency_key: crypto.randomUUID?.() ?? Date.now().toString()
idempotency_key: generateUUID()
};
if (selectedPaymentMethod) {
@@ -321,9 +332,16 @@
if (response.ok) {
depositPaid = true;
confirmedBooking.deposit_paid = true;
confirmedBooking.amount_paid = (confirmedBooking.amount_paid || 0) + amount;
confirmedBooking.amount_due = Math.max(0, (confirmedBooking.amount_due || 0) - amount);
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
// in place, potential overcharge on double-click race.)
confirmedBooking = {
...confirmedBooking,
deposit_paid: true,
amount_paid: (confirmedBooking.amount_paid || 0) + amount,
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - amount)
};
toast.success('Payment successful!');
} else {
const text = await response.text();
@@ -335,6 +353,7 @@
);
} finally {
isProcessingPayment = false;
isProcessingPaymentSync = false;
}
}
@@ -416,6 +435,34 @@
}
}
// Best-effort release of the currently held reservation. Idempotent: safe
// to call when no reservation exists. Clears all reservation state so a
// subsequent reservation must be re-acquired.
async function releaseReservation() {
if (!_reservationId) return;
const idToRelease = _reservationId;
// Clear local state first so a slow DELETE doesn't block the UI.
_reservationId = null;
reservationExpiresAt = null;
reservationCountdown = '';
reservationExpired = false;
_reservedSlotTime = null;
_reservedSlotDate = null;
if (window.__bookingFlowCountdownInterval) {
clearInterval(window.__bookingFlowCountdownInterval);
window.__bookingFlowCountdownInterval = null;
}
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 reservation', idToRelease, res.status);
} catch (e) {
console.warn('Error releasing reservation', idToRelease, e);
}
}
function startCountdown() {
if (!reservationExpiresAt) return;
@@ -1153,6 +1200,12 @@
selectedServices = [...selectedServices, service];
}
// Service change invalidates any held reservation (duration may have changed).
// Release before re-fetching hours so the next reservation starts fresh.
if (currentStep >= 2 && _reservationId) {
releaseReservation();
}
// Only clear if we're on the date/time selection step
if (currentStep === 2 && selectedDate) {
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
@@ -1193,14 +1246,30 @@
// Select a time slot with server-side re-validation
async function selectTimeWithValidation(time: string) {
// Race-condition guard: capture the user's click and pass it explicitly
// to the validator. If the user clicks a different time before this
// validation completes, the stale result must not clobber the newer
// selection.
selectedTime = time;
await refreshAndValidateSlot();
// Time change invalidates any held reservation (slot is now stale).
// Only release if the new time differs from the reserved one — if the
// user is re-clicking the same reserved time, keep the reservation.
if (_reservationId && time !== _reservedSlotTime) {
await releaseReservation();
}
await refreshAndValidateSlot(time);
}
// Re-fetch available hours silently and check if selectedTime is still available
// Re-fetch available hours silently and check if the requested time is still
// available. `validateForTime` should be the time the user clicked, NOT the
// current selectedTime — this prevents a slow validation for a stale click
// from clearing a newer selection.
// Uses skipLoadingFlags=true to prevent UI judder (loading spinners hide DatePicker/TimeSlotPicker)
async function refreshAndValidateSlot() {
if (!selectedDate || !selectedTime) return;
async function refreshAndValidateSlot(validateForTime: string | null = null) {
const timeToCheck = validateForTime ?? selectedTime;
if (!selectedDate || !timeToCheck) return false;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
delete availableHoursCache[monthKey];
@@ -1210,12 +1279,12 @@
const dayAvailable = availableHours?.[dateStr]?.slots;
if (!dayAvailable || dayAvailable.length === 0) {
toast.error('Sorry, this slot is no longer available. Please choose a different time.');
selectedTime = null;
if (selectedTime === timeToCheck) selectedTime = null;
return false;
}
const duration = getTotalDuration();
const [selHour, selMinute] = selectedTime.split(':').map(Number);
const [selHour, selMinute] = timeToCheck.split(':').map(Number);
const selStart = selHour * 60 + selMinute;
const selEnd = selStart + duration;
@@ -1227,7 +1296,7 @@
if (!stillAvailable) {
toast.error('Sorry, this slot was just taken. Please choose a different time.');
selectedTime = null;
if (selectedTime === timeToCheck) selectedTime = null;
return false;
}
@@ -1317,29 +1386,7 @@
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;
}
}
await releaseReservation();
const slotStillFree = await refreshAndValidateSlot();
if (!slotStillFree) return;
@@ -1379,24 +1426,12 @@
async function submitAndProceed() {
_isSubmitting = true;
try {
if (!idempotencyKey) {
// Generate UUID v4 manually for environments where crypto.randomUUID() is unavailable
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
array[6] = (array[6] & 0x0f) | 0x40; // version 4
array[8] = (array[8] & 0x3f) | 0x80; // variant 1
idempotencyKey = [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
// Always generate a fresh idempotency key per submission attempt. The
// previous behavior kept the key across the component lifetime, which
// meant a second submission (after a success, back-nav, change) would
// hit the backend with the same key and get the ORIGINAL booking back,
// making the user believe they made a new booking when they didn't.
idempotencyKey = generateUUID();
if (!selectedDate || !selectedTime) {
toast.error('Please select a date and time');
@@ -1479,6 +1514,12 @@
total_amount: booking.total_amount || getTotalPrice(),
duration_minutes: booking.duration_minutes || getTotalDuration()
};
// Booking is now persisted — release the temporary reservation
// (best-effort, no UI block). Also clear the idempotency key so
// any future submission in this component lifetime gets a fresh
// key and is treated as a NEW booking attempt.
releaseReservation();
idempotencyKey = '';
currentStep = finalStep;
fetchDiscountPreview();
setTimeout(() => {
@@ -1531,6 +1572,12 @@
function prevStep() {
if (currentStep > 1) {
// If leaving step 2 (date/time) and a reservation is still held, release
// it — the user is abandoning this selection. Going from step 3 back to
// step 2 keeps the reservation (the user is just reviewing).
if (currentStep === 2 && _reservationId) {
releaseReservation();
}
currentStep--;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -1764,6 +1811,10 @@
maxValue={maxCalendarDate}
{isDateUnavailable}
onchange={(newDate) => {
// Date change invalidates any held reservation (slot is now stale).
if (_reservationId) {
releaseReservation();
}
selectedDate = newDate;
selectedTime = null;
}}