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 { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__bookingFlowCountdownInterval?: ReturnType<typeof setInterval> | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||||
import BookingSummary from '$lib/components/booking/BookingSummary.svelte';
|
import BookingSummary from '$lib/components/booking/BookingSummary.svelte';
|
||||||
@@ -358,6 +365,10 @@
|
|||||||
let reservationExpired = $state(false);
|
let reservationExpired = $state(false);
|
||||||
let _isReserving = $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 ===============
|
// =============== Slot Reservation Functions ===============
|
||||||
async function reserveSlot() {
|
async function reserveSlot() {
|
||||||
_isReserving = true;
|
_isReserving = true;
|
||||||
@@ -375,7 +386,10 @@
|
|||||||
|
|
||||||
const response = await fetch('/api/bookings/reserve', {
|
const response = await fetch('/api/bookings/reserve', {
|
||||||
method: 'POST',
|
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 })
|
body: JSON.stringify({ start_time: startTimeISO, service_ids: serviceIds })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,6 +410,8 @@
|
|||||||
_reservationId = data.id;
|
_reservationId = data.id;
|
||||||
reservationExpiresAt = new Date(data.expires_at);
|
reservationExpiresAt = new Date(data.expires_at);
|
||||||
reservationExpired = false;
|
reservationExpired = false;
|
||||||
|
_reservedSlotTime = selectedTime;
|
||||||
|
_reservedSlotDate = selectedDate ? selectedDate.toString() : null;
|
||||||
startCountdown();
|
startCountdown();
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -409,6 +425,12 @@
|
|||||||
function startCountdown() {
|
function startCountdown() {
|
||||||
if (!reservationExpiresAt) return;
|
if (!reservationExpiresAt) return;
|
||||||
|
|
||||||
|
// Clear any existing countdown interval to prevent duplicates
|
||||||
|
if (window.__bookingFlowCountdownInterval) {
|
||||||
|
clearInterval(window.__bookingFlowCountdownInterval);
|
||||||
|
window.__bookingFlowCountdownInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
if (!reservationExpiresAt) {
|
if (!reservationExpiresAt) {
|
||||||
reservationCountdown = '';
|
reservationCountdown = '';
|
||||||
@@ -423,6 +445,12 @@
|
|||||||
reservationExpired = true;
|
reservationExpired = true;
|
||||||
_reservationId = null;
|
_reservationId = null;
|
||||||
reservationExpiresAt = 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.');
|
toast.error('Your reservation has expired. Please select a new time slot.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -433,9 +461,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateCountdown();
|
updateCountdown();
|
||||||
const interval = setInterval(() => {
|
window.__bookingFlowCountdownInterval = setInterval(() => {
|
||||||
if (reservationExpired) {
|
if (reservationExpired) {
|
||||||
clearInterval(interval);
|
if (window.__bookingFlowCountdownInterval) {
|
||||||
|
clearInterval(window.__bookingFlowCountdownInterval);
|
||||||
|
window.__bookingFlowCountdownInterval = null;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateCountdown();
|
updateCountdown();
|
||||||
@@ -461,7 +492,7 @@
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${authStore.currentToken}`
|
...(authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -657,9 +688,10 @@
|
|||||||
loadingAvailableHours = true;
|
loadingAvailableHours = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const authHeaders = authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {};
|
||||||
const [whRes, ahRes] = await Promise.all([
|
const [whRes, ahRes] = await Promise.all([
|
||||||
fetch(`/api/scheduling/working-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}`)
|
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, { headers: authHeaders })
|
||||||
]);
|
]);
|
||||||
if (!whRes.ok || !ahRes.ok) {
|
if (!whRes.ok || !ahRes.ok) {
|
||||||
throw new Error(`HTTP error! wh: ${whRes.status}, ah: ${ahRes.status}`);
|
throw new Error(`HTTP error! wh: ${whRes.status}, ah: ${ahRes.status}`);
|
||||||
@@ -750,9 +782,12 @@
|
|||||||
const startStr = startOfMonth.toString();
|
const startStr = startOfMonth.toString();
|
||||||
const endStr = endOfMonth.toString();
|
const endStr = endOfMonth.toString();
|
||||||
|
|
||||||
|
const authHeaders = authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {};
|
||||||
|
|
||||||
// Fetch working hours
|
// Fetch working hours
|
||||||
const workingHoursResponse = await fetch(
|
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) {
|
if (!workingHoursResponse.ok) {
|
||||||
throw new Error(`HTTP error! status: ${workingHoursResponse.status}`);
|
throw new Error(`HTTP error! status: ${workingHoursResponse.status}`);
|
||||||
@@ -777,7 +812,8 @@
|
|||||||
|
|
||||||
// Fetch available hours
|
// Fetch available hours
|
||||||
const availableHoursResponse = await fetch(
|
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) {
|
if (!availableHoursResponse.ok) {
|
||||||
throw new Error(`HTTP error! status: ${availableHoursResponse.status}`);
|
throw new Error(`HTTP error! status: ${availableHoursResponse.status}`);
|
||||||
@@ -1266,12 +1302,47 @@
|
|||||||
return;
|
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) {
|
if (currentStep === 2) {
|
||||||
|
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();
|
const slotStillFree = await refreshAndValidateSlot();
|
||||||
if (!slotStillFree) return;
|
if (!slotStillFree) return;
|
||||||
const reserved = await reserveSlot();
|
const reserved = await reserveSlot();
|
||||||
if (!reserved) return;
|
if (!reserved) return;
|
||||||
|
// reserveSlot() stores _reservedSlotTime/_reservedSlotDate internally
|
||||||
|
currentStep = 3;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3 -> Final step (Payment if deposit required, else submit booking)
|
// 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 ===============
|
// =============== Validation ===============
|
||||||
const isBlockedByActiveBooking = $derived(
|
const isBlockedByActiveBooking = $derived(
|
||||||
authStore.isAuthenticated && userDepositsRequired > 0 && hasActiveBooking
|
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