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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user