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');