feat: unify walk-in and call-in reservation flows with 15min TTL, guest booking support, and slot awareness

- backend/handlers/bookings/admin_reserve.go:
  - Add explicit reservation_type field ("walkin" | "callin") to request struct
  - Remove TTL-based heuristic for type detection
  - Walk-in: uses duration_minutes, allows null user_id, 1min past grace
  - Call-in: requires service_ids, validates future time, calculates duration from services
  - Both types now use 15-minute TTL

- backend/handlers/scheduling/time-blockers.go:
  - Update CleanupOldReservations: both walkin and callin use 15min TTL (was 10min/60min)

- frontend/WalkInBooking.svelte:
  - Full rewrite of reservation logic
  - If available now and >15min remaining: reserve from now to slot end
  - If <=15min or not available: reserve next full slot
  - Always reserves before opening modal (never open without hold)
  - Passes reservedDuration to modal
  - TTL changed from 5 to 15 minutes

- frontend/WalkInCreateModal.svelte:
  - Replace dead commented-out guest code with working guest creation
  - Guest account created at submit time (not earlier)
  - Phone defaults to +447700900000 if blank
  - Phone field marked optional with helper text
  - Name split into firstName/lastName for backend
  - Validation relaxed: only name required for guests

- frontend/BookingCreateModal.svelte:
  - TTL changed from 60 to 15 minutes
  - Add reservation_type: "callin" to reserve payload
  - Guest creation uses correct firstName/lastName fields
  - Default guest phone to +447700900000
  - Reservation no longer requires selectedUserId (works for guests)

- docs: Update Future Work backlog to mark completed items
This commit is contained in:
2026-05-03 15:09:50 +01:00
parent bff86a6660
commit 6808752e0d
7 changed files with 748 additions and 152 deletions
@@ -361,7 +361,7 @@
// =============== Reservation ===============
async function reserveSlot(): Promise<boolean> {
if (!selectedUserId || !selectedDate || !selectedTime) {
if (!selectedDate || !selectedTime) {
return false;
}
@@ -387,19 +387,22 @@
}
}
const payload = {
user_id: selectedUserId || null,
start_time: startTimeISO,
service_ids: serviceIds,
service_overrides: overrides.length > 0 ? overrides : [],
ttl_minutes: 15,
reservation_type: 'callin'
};
const response = await fetch('/api/admin/bookings/reserve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
user_id: selectedUserId,
start_time: startTimeISO,
service_ids: serviceIds,
service_overrides: overrides.length > 0 ? overrides : [],
ttl_minutes: 60
})
body: JSON.stringify(payload)
});
if (response.ok) {
@@ -732,28 +735,32 @@
try {
let finalUserId = selectedUserId;
if (userType === 'guest') {
const createRes = await fetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
name: guestName,
phone: guestPhone
})
});
if (userType === 'guest') {
const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Guest',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Customer',
phone: phone,
email: `callin-${Date.now()}@guest.invalid`
})
});
if (!createRes.ok) {
toast.error('Failed to create guest user');
return;
}
const guestUser = await createRes.json();
finalUserId = guestUser.id;
if (!createRes.ok) {
toast.error('Failed to create guest user');
submitting = false;
return;
}
const guestUser = await createRes.json();
finalUserId = guestUser.id;
}
if (!finalUserId) throw new Error('User ID required');
if (!selectedDate || !selectedTime) throw new Error('Date and time required');
@@ -9,6 +9,8 @@
import type { AvailableHoursDay } from '$lib/types/booking';
const RESERVATION_TTL = 15;
let showCreateModal = $state(false);
let slotInfo = $state<{
isAvailableNow: boolean;
@@ -21,38 +23,30 @@
let noSlotsToday = $state(false);
let currentTime = $state(new Date());
// Reservation state for walk-in
let reservationId = $state<string | null>(null);
let reservationExpiresAt = $state<Date | null>(null);
let reservationCountdown = $state<string>('');
let isReserving = $state(false);
let reservedDuration = $state(0);
onMount(() => {
calculateSlotAvailability();
// Update current time every minute for live countdown
const interval = setInterval(() => {
currentTime = new Date();
}, 60000); // Update every minute
}, 60000);
return () => clearInterval(interval);
});
/**
* Converts "HH:MM" or "HH:MM:SS" time string to minutes since midnight
*/
function timeToMinutes(time: string): number {
const parts = time.split(':').map(Number);
return parts[0] * 60 + parts[1];
}
/**
* Converts minutes to hours and minutes for display
*/
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0 && mins > 0) {
return `${hours} hour${hours !== 1 ? 's' : ''}, ${mins} minute${mins !== 1 ? 's' : ''}`;
} else if (hours > 0) {
@@ -62,23 +56,15 @@
}
}
/**
* Calculate live remaining time based on current time
*/
function getLiveRemainingMinutes(): number | null {
if (!slotInfo?.isAvailableNow || !slotInfo.slotEndMinutes) return null;
const now = currentTime.getHours() * 60 + currentTime.getMinutes();
const remaining = slotInfo.slotEndMinutes - now;
return Math.max(0, remaining);
}
/**
* Calculate live wait time based on current time
*/
function getLiveWaitMinutes(): number | null {
if (slotInfo?.isAvailableNow || !slotInfo?.startTime) return null;
const now = currentTime.getHours() * 60 + currentTime.getMinutes();
const slotStartMinutes = timeToMinutes(slotInfo.startTime);
const wait = slotStartMinutes - now;
@@ -93,7 +79,6 @@
const now = new SvelteDate();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
// Fetch today's available hours
const response = await fetch(`/api/scheduling/available-hours?start=${today}&end=${today}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
@@ -111,26 +96,22 @@
return;
}
// Current time in minutes since midnight
const currentMinutes = now.getHours() * 60 + now.getMinutes();
// Check if we're currently in an available slot
for (const slot of todayData.slots) {
const slotStartMinutes = timeToMinutes(slot.startTime);
const slotEndMinutes = timeToMinutes(slot.endTime);
// Are we currently within this slot?
if (currentMinutes >= slotStartMinutes && currentMinutes < slotEndMinutes) {
const remainingMinutes = slotEndMinutes - currentMinutes;
slotInfo = {
isAvailableNow: true,
durationMinutes: remainingMinutes,
slotEndMinutes: slotEndMinutes // Store for live countdown
slotEndMinutes: slotEndMinutes
};
return;
}
// Is this a future slot?
if (slotStartMinutes > currentMinutes) {
const waitMinutes = slotStartMinutes - currentMinutes;
const durationMinutes = slotEndMinutes - slotStartMinutes;
@@ -144,7 +125,6 @@
}
}
// No current or future slots available
noSlotsToday = true;
} catch (err) {
console.error('Failed to calculate slot availability', err);
@@ -161,7 +141,7 @@
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
async function reserveWalkInSlot(startTime: string): Promise<boolean> {
async function reserveWalkInSlot(startTime: string, durationMinutes: number): Promise<boolean> {
isReserving = true;
try {
@@ -189,13 +169,16 @@
start_time: startTimeISO,
service_ids: [],
service_overrides: [],
ttl_minutes: 5
ttl_minutes: RESERVATION_TTL,
reservation_type: 'walkin',
duration_minutes: durationMinutes
})
});
if (response.ok) {
const data = await response.json();
reservationId = data.id;
reservedDuration = data.duration_minutes;
reservationExpiresAt = new Date(data.expires_at);
startWalkInCountdown();
return true;
@@ -252,15 +235,39 @@
(window as any).__walkInCountdownInterval = setInterval(updateCountdown, 1000);
}
function handleStartWalkIn() {
if (slotInfo?.isAvailableNow) {
showCreateModal = true;
} else if (slotInfo?.startTime) {
reserveWalkInSlot(slotInfo.startTime).then((reserved) => {
if (reserved) {
showCreateModal = true;
async function handleStartWalkIn() {
if (!slotInfo) return;
let reserveTime: string;
let reserveDuration: number;
if (slotInfo.isAvailableNow) {
const liveRemaining = getLiveRemainingMinutes() ?? 0;
if (liveRemaining > RESERVATION_TTL) {
// Available now with >15min remaining — reserve from now to slot end
const now = new SvelteDate();
const currentMin = now.getHours() * 60 + now.getMinutes();
reserveTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
reserveDuration = slotInfo.slotEndMinutes! - currentMin;
} else {
// Available now but ≤15min — reserve next slot instead
await calculateSlotAvailability();
if (!slotInfo || slotInfo.isAvailableNow || !slotInfo.startTime) {
toast.error('No suitable slot available');
return;
}
});
reserveTime = slotInfo.startTime;
reserveDuration = slotInfo.durationMinutes;
}
} else {
// Not available now — reserve the next slot
reserveTime = slotInfo.startTime!;
reserveDuration = slotInfo.durationMinutes;
}
const reserved = await reserveWalkInSlot(reserveTime, reserveDuration);
if (reserved) {
showCreateModal = true;
}
}
@@ -269,6 +276,7 @@
reservationId = null;
reservationExpiresAt = null;
reservationCountdown = '';
reservedDuration = 0;
if ((window as any).__walkInCountdownInterval) {
clearInterval((window as any).__walkInCountdownInterval);
}
@@ -325,8 +333,8 @@
{#if showCreateModal}
<WalkInCreateModal
bind:open={showCreateModal}
maxSlotDuration={slotInfo?.durationMinutes ?? 0}
availableStartTime={slotInfo?.isAvailableNow ? undefined : slotInfo?.startTime}
maxSlotDuration={reservedDuration}
availableStartTime={undefined}
reservationExpiresAt={reservationExpiresAt}
onclose={handleModalClose}
/>
@@ -107,7 +107,7 @@
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration);
const canProceedStep1 = $derived(
userType === 'member' ? !!selectedUserId : !!(guestName.trim() && guestPhone.trim())
userType === 'member' ? !!selectedUserId : !!guestName.trim()
);
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
@@ -271,36 +271,35 @@
return;
}
let finalUserId = selectedUserId;
let finalUserId = selectedUserId;
if (userType === 'guest') {
// TODO: Implement /api/users/guest endpoint
// For now, show error
toast.error('Guest booking not yet implemented');
if (userType === 'guest') {
const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Walk-in',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Guest',
phone: phone,
email: `walkin-${Date.now()}@guest.invalid`
})
});
if (!createRes.ok) {
toast.error('Failed to create guest user');
submitting = false;
return;
// const createRes = await fetch('/api/users/guest', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// Authorization: `Bearer ${authStore.currentToken}`
// },
// body: JSON.stringify({
// name: guestName,
// phone: guestPhone
// })
// });
// if (!createRes.ok) {
// toast.error('Failed to create guest user');
// return;
// }
// const guestUser = await createRes.json();
// finalUserId = guestUser.id;
}
if (!finalUserId) throw new Error('User ID required');
const guestUser = await createRes.json();
finalUserId = guestUser.id;
}
if (!finalUserId) throw new Error('User ID required');
// Use the available slot start time from the widget
let start: Date;
@@ -355,8 +354,6 @@
notes: notes.trim() || null
};
// TODO: When guest booking is fully implemented, ensure walk-in guest reservations properly transition to real bookings.
const res = await fetch('/api/admin/bookings', {
method: 'POST',
headers: {
@@ -595,17 +592,17 @@
oninput={(e) => (guestName = e.currentTarget.value)}
/>
</div>
<div class="space-y-2">
<Label for="guest-phone">Phone Number *</Label>
<input
id="guest-phone"
type="tel"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="07700 900000"
value={guestPhone}
oninput={(e) => (guestPhone = e.currentTarget.value)}
/>
</div>
<div class="space-y-2">
<Label for="guest-phone">Phone Number</Label>
<input
id="guest-phone"
type="tel"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="07700 900000 (optional — helps us reach you if running late)"
value={guestPhone}
oninput={(e) => (guestPhone = e.currentTarget.value)}
/>
</div>
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
Booking as a guest creates a temporary record. Encourage them to sign up for
loyalty benefits.