feat: improve booking flow with auth headers and reservation release
CI / Go vulnerabilities (push) Failing after 42s
CI / Frontend lint & types (push) Failing after 59s
CI / Tests (push) Successful in 1m27s
CI / Race detector (push) Failing after 3m35s

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-07-05 11:48:14 +01:00
co-authored by Sisyphus
parent 296a67770e
commit 2069a99f8c
2 changed files with 92 additions and 56 deletions
@@ -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<typeof setInterval> | 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