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
@@ -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.