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
@@ -4,7 +4,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { CalendarDate } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import type { AvailableHoursDay, Service } from '$lib/types/booking';
@@ -66,6 +66,43 @@
return () => clearInterval(interval);
});
// Release any held reservation if the component unmounts while a slot
// is still reserved (e.g. admin navigates away from the page). Best-effort
// — TTL cleanup will eventually run if this fails.
onDestroy(() => {
if (typeof window !== 'undefined' && window.__walkInCountdownInterval) {
clearInterval(window.__walkInCountdownInterval);
}
if (_reservationId) {
releaseWalkInReservation();
}
});
async function releaseWalkInReservation() {
if (!_reservationId) return;
const idToRelease = _reservationId;
// Clear local state first so a slow DELETE doesn't block the UI.
_reservationId = null;
reservationExpiresAt = null;
_reservationCountdown = '';
reservedDuration = 0;
reservedStartTime = null;
if (window.__walkInCountdownInterval) {
clearInterval(window.__walkInCountdownInterval);
}
try {
const res = await fetch('/api/admin/bookings/reserve', {
method: 'DELETE',
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (!res.ok && res.status !== 404) {
console.warn('Failed to release walk-in reservation', idToRelease, res.status);
}
} catch (e) {
console.warn('Error releasing walk-in reservation', idToRelease, e);
}
}
function timeToMinutes(time: string): number {
const parts = time.split(':').map(Number);
return parts[0] * 60 + parts[1];
@@ -375,14 +412,7 @@
function handleModalClose() {
showCreateModal = false;
_reservationId = null;
reservationExpiresAt = null;
_reservationCountdown = '';
reservedDuration = 0;
reservedStartTime = null;
if (window.__walkInCountdownInterval) {
clearInterval(window.__walkInCountdownInterval);
}
releaseWalkInReservation();
}
</script>