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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user