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:
@@ -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;
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user