feat: wire slot reservation into all booking flows

Customer flow: reservation fires on Date/Time → Details transition with
re-validation on time slot tap and on 'Next' click to prevent simultaneous
bookings. 5-step flow: Service → Date/Time → Reserve → Details (countdown)
→ Payment & Review → Confirm. Guest users redirected to home on success.

Admin call-in: reserve slot before final submission (60min TTL).
Admin walk-in: reserve slot on modal open (5min TTL).

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-04-30 11:45:20 +01:00
co-authored by Sisyphus
parent 52ca9b425b
commit c5b7d9a078
4 changed files with 607 additions and 31 deletions
@@ -44,6 +44,105 @@
});
let isSubmitting = $state(false);
// =============== Slot Reservation System ===============
let reservationId = $state<string | null>(null);
let reservationExpiresAt = $state<Date | null>(null);
let reservationCountdown = $state<string>('');
let reservationExpired = $state(false);
let isReserving = $state(false);
// =============== Slot Reservation Functions ===============
async function reserveSlot() {
isReserving = true;
try {
if (!selectedDate || !selectedTime) {
toast.error('Please select a date and time');
return false;
}
const [hours, minutes] = selectedTime.split(':').map(Number);
const bookingDate = selectedDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours, minutes, 0, 0);
const startTimeISO = bookingDate.toISOString();
const serviceIds = selectedServices.map((s) => s.id);
const response = await fetch('/api/bookings/reserve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_time: startTimeISO, service_ids: serviceIds })
});
if (!response.ok) {
const errorText = await response.text();
if (response.status === 429) {
toast.error('Too many active reservations. Please wait or log in.');
} else if (response.status === 409) {
toast.error('This time slot is no longer available. Please choose a different time.');
await refreshAvailableHours();
} else {
toast.error('Failed to reserve slot. Please try again.');
}
return false;
}
const data = await response.json();
reservationId = data.id;
reservationExpiresAt = new Date(data.expires_at);
reservationExpired = false;
startCountdown();
return true;
} catch (error) {
toast.error('Network error while reserving slot.');
return false;
} finally {
isReserving = false;
}
}
function startCountdown() {
if (!reservationExpiresAt) return;
const updateCountdown = () => {
if (!reservationExpiresAt) {
reservationCountdown = '';
return;
}
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
reservationCountdown = '00:00';
reservationExpired = true;
reservationId = null;
reservationExpiresAt = null;
toast.error('Your reservation has expired. Please select a new time slot.');
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
reservationCountdown = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
};
updateCountdown();
const interval = setInterval(() => {
if (reservationExpired) {
clearInterval(interval);
return;
}
updateCountdown();
}, 1000);
}
async function refreshAvailableHours() {
if (!selectedDate) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey);
await fetchHoursForMonth(selectedDate);
}
// =============== Services Management ===============
let services = $state<Service[]>([]);
let servicesLoading = $state(true);
@@ -557,13 +656,55 @@
// 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')}`;
availableHoursCache.delete(monthKey); // Only delete current month
availableHoursCache.delete(monthKey);
fetchHoursForMonth(selectedDate);
}
selectedTime = null;
}
// Select a time slot with server-side re-validation
async function selectTimeWithValidation(time: string) {
selectedTime = time;
await refreshAndValidateSlot();
}
// Re-fetch available hours and check if selectedTime is still available
async function refreshAndValidateSlot() {
if (!selectedDate || !selectedTime) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey);
await fetchHoursForMonth(selectedDate);
const dateStr = selectedDate.toString();
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;
return false;
}
const duration = getTotalDuration();
const [selHour, selMinute] = selectedTime.split(':').map(Number);
const selStart = selHour * 60 + selMinute;
const selEnd = selStart + duration;
const stillAvailable = dayAvailable.some(slot => {
const [sH, sM] = slot.startTime.split(':').map(Number);
const [eH, eM] = slot.endTime.split(':').map(Number);
return selStart >= (sH * 60 + sM) && selEnd <= (eH * 60 + eM);
});
if (!stillAvailable) {
toast.error('Sorry, this slot was just taken. Please choose a different time.');
selectedTime = null;
return false;
}
return true;
}
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
@@ -610,8 +751,16 @@
);
// =============== Navigation ===============
function nextStep() {
if (currentStep < 4) {
async function nextStep() {
// Step 2 -> Step 3: Re-validate slot, then reserve
if (currentStep === 2) {
const slotStillFree = await refreshAndValidateSlot();
if (!slotStillFree) return;
const reserved = await reserveSlot();
if (!reserved) return;
}
if (currentStep < 5) {
currentStep++;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -632,7 +781,7 @@
const canProceedStep1 = $derived(selectedServices.length > 0);
const canProceedStep2 = $derived(!!(selectedDate && selectedTime));
const canProceedStep3 = $derived(
authStore.isAuthenticated
(authStore.isAuthenticated
? !!(
authStore.currentUser?.firstName &&
authStore.currentUser?.lastName &&
@@ -644,8 +793,9 @@
customerInfo.lastName &&
customerInfo.email &&
customerInfo.phone
)
)) && !reservationExpired
);
const canProceedStep4 = $derived(true);
// =============== Submission ===============
async function submitBooking() {
@@ -666,21 +816,56 @@
// Extract service IDs
const serviceIds = selectedServices.map((s) => s.id);
// For guest users, create a guest account first
let guestUserId: string | null = null;
if (!authStore.isAuthenticated) {
const guestResponse = await fetch('/api/users/guest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
email: customerInfo.email,
phone: customerInfo.phone
})
});
if (!guestResponse.ok) {
const errorText = await guestResponse.text();
if (guestResponse.status === 409) {
toast.error('Email already registered — please log in to book.');
} else {
toast.error('Failed to create guest account. Please try again.');
}
isSubmitting = false;
return;
}
const guestData = await guestResponse.json();
guestUserId = guestData.id;
}
// Build request body
const requestBody = {
const requestBody: Record<string, unknown> = {
service_ids: serviceIds,
start_time: startTimeISO,
notes: customerInfo.specialRequests || null
};
if (guestUserId) {
requestBody.user_id = guestUserId;
}
console.log('Submitting booking:', requestBody);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (authStore.currentToken) {
headers['Authorization'] = `Bearer ${authStore.currentToken}`;
}
const response = await fetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
headers,
body: JSON.stringify(requestBody)
});
@@ -701,14 +886,18 @@
toast.success(`Booking confirmed for ${bookingDateStr} at ${bookingTimeStr}!`);
// Reset form and redirect to account page
// Clear reservation state
reservationId = null;
reservationExpiresAt = null;
// Reset form and redirect
selectedServices = [];
selectedDate = undefined;
selectedTime = null;
currentStep = 1;
// Navigate to account page to see bookings
window.location.href = '/account';
// Navigate to account page (logged-in) or home (guest)
window.location.href = authStore.isAuthenticated ? '/account' : '/';
} else {
// Handle error response
const errorText = await response.text();
@@ -760,7 +949,7 @@
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
</div>
<StepIndicator {currentStep} />
<StepIndicator {currentStep} steps={['Service', 'Date & Time', 'Details', 'Payment & Review', 'Confirm']} />
<!-- Step 1: Service Selection -->
{#if currentStep === 1}
@@ -857,7 +1046,7 @@
{selectedTime}
formattedDate={formattedSelectedDate}
onselect={(time) => {
selectedTime = time;
selectTimeWithValidation(time);
}}
lunchProtectionStatus={lunchProtectionStatus()}
/>
@@ -916,6 +1105,18 @@
<Card.Description>Please confirm your contact information</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
{#if reservationExpired}
<div class="rounded-lg bg-red-50 p-4 text-center">
<p class="text-red-600">Reservation expired — please go back and select a new time</p>
</div>
{:else}
<div class="rounded-lg bg-blue-50 p-4 text-center">
<p class="text-blue-700">
Your slot is reserved for {reservationCountdown} — complete your booking before time expires
</p>
</div>
{/if}
<BookingSummary
services={selectedServices}
date={selectedDate}
@@ -994,18 +1195,18 @@
onclick={nextStep}
class="bg-primary text-primary-foreground"
>
Next: Payment
{reservationExpired ? 'Reservation Expired' : 'Next: Review & Payment'}
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 4: Payment (complete) -->
<!-- Step 4: Payment & Review -->
{#if currentStep === 4}
<Card.Root>
<Card.Header>
<Card.Title>Payment Confirmation</Card.Title>
<Card.Description>Review and complete your booking</Card.Description>
<Card.Title>Payment & Review</Card.Title>
<Card.Description>Review your booking details</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<BookingSummary
@@ -1024,7 +1225,7 @@
showCustomer={true}
/>
<!-- Payment form placeholder -->
<!-- TODO: Integrate payment processor (Stripe/Square) here. Current flow: logged-in users go straight to confirmation, guest users show payment step before confirmation. -->
<div class="rounded-lg bg-white p-6">
<h2 class="mb-4 text-2xl font-semibold">Payment</h2>
<p class="text-gray-600">Square payment integration will be added here.</p>
@@ -1033,11 +1234,38 @@
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={prevStep}>Back</Button>
<Button
disabled={!canProceedStep3 || isSubmitting}
disabled={isSubmitting}
onclick={nextStep}
class="bg-primary text-primary-foreground"
>
Next: Confirm
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 5: Confirm -->
{#if currentStep === 5}
<Card.Root>
<Card.Header>
<Card.Title>Confirm Your Booking</Card.Title>
<Card.Description>Ready to confirm your appointment</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="rounded-lg bg-gray-50 p-6 text-center">
<p class="text-lg">
Ready to confirm: {selectedServices.map((s) => s.name).join(', ')} on {selectedDate?.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })} at {selectedTime}
</p>
</div>
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={prevStep}>Back</Button>
<Button
disabled={isSubmitting}
onclick={submitBooking}
class="bg-primary text-primary-foreground"
>
{isSubmitting ? 'Processing...' : 'Complete Booking'}
{isSubmitting ? 'Processing...' : 'Confirm Booking'}
</Button>
</Card.Footer>
</Card.Root>