Files
Crussell/frontend/src/lib/components/admin/BookingCreateModal.svelte
T
popertots 50746595e7 feat(bookings): improve admin booking wizard and user dashboard
Backend:
- Enriched GetAllUserBookings response with calculated total_amount,
  amount_paid, and duration_minutes.
- Refactored GetBookingHandler to return a flat booking object matching
  frontend expectations.
- Added account_role to admin user list response and sorted users by
  booking activity.
- Corrected function name oo to AdminCreateBookingForUserHandler.

Frontend:
- Rebuilt BookingCreateModal into a 4-step wizard supporting guest
  bookings, service overrides, and real-time availability checks.
- Fixed account dashboard logic to correctly identify upcoming vs past
  bookings and sort unpaid items to the top.
- Extracted booking flow into a shared BookingFlow component.
- Redirected admin users from home page to /today.
2026-02-12 22:15:10 +00:00

1187 lines
39 KiB
Svelte

<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
// UI Components
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
// Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs
// but keeping the Label and other components.
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { Skeleton } from '$lib/components/ui/skeleton';
// Booking Components
import BookingActions from '$lib/components/booking/BookingActions.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
// Types
import type { Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
// =============== Props ===============
interface Props {
open: boolean;
onBookingCreated?: () => void;
}
let { open = $bindable(), onBookingCreated }: Props = $props();
// =============== State ===============
let currentStep = $state(1);
// Step 1: Customer Selection
let userType = $state<'member' | 'guest'>('member');
let userQuery = $state('');
// Updated type to include account_role for filtering
let users = $state<
Array<{ id: string; fullName: string; email?: string; phone?: string; account_role: string }>
>([]);
let selectedUserId = $state<string | null>(null);
let guestName = $state('');
let guestPhone = $state('');
let loadingUsers = $state(false);
// Step 2: Services
let services = $state<Service[]>([]);
let selectedServices = $state<Service[]>([]);
let loadingServices = $state(true);
// Step 3: Service Overrides & Notes
let notes = $state('');
let serviceOverrides = $state<
Record<
string,
{ price: string; duration: string; originalPrice: number; originalDuration: number }
>
>({});
// Step 4: Date & Time
let placeholder = $state<CalendarDate>(
new CalendarDate(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate())
);
let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null);
let workingHours = $state<Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
> | null>(null);
let availableHours = $state<Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
> | null>(null);
let loadingWorkingHours = $state(false);
let loadingAvailableHours = $state(false);
// Date Boundaries
const today = new SvelteDate();
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new SvelteDate();
maxDate.setMonth(today.getMonth() + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
maxDate.getMonth() + 1,
maxDate.getDate()
);
let submitting = $state(false);
// =============== Cache ===============
const workingHoursCache = new SvelteMap<string, Record<string, any>>();
const availableHoursCache = new SvelteMap<string, Record<string, any>>();
// =============== Derived Helpers ===============
function getTotalDuration() {
return selectedServices.reduce((total, service) => {
const override = serviceOverrides[service.id];
const duration =
override && override.duration ? parseInt(override.duration) : service.duration_minutes;
return total + (isNaN(duration) ? 0 : duration);
}, 0);
}
function getTotalPrice() {
return selectedServices.reduce((total, service) => {
const override = serviceOverrides[service.id];
const price = override && override.price ? parseFloat(override.price) : service.price;
return total + (isNaN(price) ? 0 : price);
}, 0);
}
// =============== Lunch Protection ===============
const lunchProtectionStatus = $derived(() => {
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
return new Map();
}
const dateStr = selectedDate.toString();
const dayWorkingHours = workingHours[dateStr];
const dayAvailableHours = availableHours[dateStr];
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) {
return new Map();
}
const existingBookings = extractBookedSlots(
dayWorkingHours.startTime,
dayWorkingHours.endTime,
dayAvailableHours.slots
);
return getLunchProtectionForSlots(
dayWorkingHours.startTime,
dayWorkingHours.endTime,
existingBookings,
getTotalDuration(),
15,
true // Admin journey - 30min minimum, warn if <1h
);
});
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours === 0) {
return `${remainingMinutes} minutes`;
} else if (remainingMinutes === 0) {
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
} else {
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
}
}
function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString(
'en-GB',
{
month: 'long'
}
);
const day = date.day;
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
switch (day % 10) {
case 1:
return monthName + ' ' + day + 'st';
case 2:
return monthName + ' ' + day + 'nd';
case 3:
return monthName + ' ' + day + 'rd';
default:
return monthName + ' ' + day + 'th';
}
}
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
const formattedSelectedDate = $derived(
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
);
const canProceedStep1 = $derived(
userType === 'member' ? !!selectedUserId : !!(guestName.trim() && guestPhone.trim())
);
const canProceedStep2 = $derived(selectedServices.length > 0);
const canProceedStep3 = $derived(true); // Overrides are optional
const canProceedStep4 = $derived(!!(selectedDate && selectedTime));
// =============== Effects ===============
let wasOpen = false;
$effect(() => {
if (open && !wasOpen) {
resetState();
fetchServices();
fetchUsers();
}
wasOpen = open;
});
$effect(() => {
if (open && currentStep === 4) {
const dateToCheck = selectedDate || placeholder;
fetchHoursForMonth(dateToCheck);
}
});
$effect(() => {
if (open && currentStep === 4 && placeholder) {
fetchHoursForMonth(placeholder);
}
});
// =============== Reset State ===============
function resetState() {
currentStep = 1;
userType = 'member';
userQuery = '';
users = [];
selectedUserId = null;
guestName = '';
guestPhone = '';
selectedServices = [];
selectedDate = undefined;
selectedTime = null;
notes = '';
serviceOverrides = {};
workingHoursCache.clear();
availableHoursCache.clear();
workingHours = null;
availableHours = null;
}
// =============== Data Fetching ===============
async function fetchUsers() {
loadingUsers = true;
try {
const response = await fetch(
`/api/admin/users?page=1&per_page=10&q=${encodeURIComponent(userQuery)}`,
{
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}
);
if (response.ok) {
const data = await response.json();
// Filter out specific roles
const excludedRoles = ['admin', 'guest', 'affiliate'];
users = (data.users || []).filter(
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
);
console.log(users);
}
} catch (err) {
console.error('Failed to fetch users', err);
toast.error('Failed to load users');
} finally {
loadingUsers = false;
}
}
async function fetchServices() {
loadingServices = true;
try {
const response = await fetch('/api/services', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
services = await response.json();
}
} catch (err) {
console.error('Failed to fetch services', err);
toast.error('Failed to load services');
} finally {
loadingServices = false;
}
}
async function fetchHoursForMonth(date: CalendarDate) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
workingHours = workingHoursCache.get(monthKey)!;
availableHours = availableHoursCache.get(monthKey)!;
return;
}
loadingWorkingHours = true;
loadingAvailableHours = true;
try {
const startOfMonth = new CalendarDate(date.year, date.month, 1);
const endOfMonth = new CalendarDate(
date.year,
date.month,
date.calendar.getDaysInMonth(date)
);
const [whRes, ahRes] = await Promise.all([
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
})
]);
if (whRes.ok && ahRes.ok) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const whMap: Record<string, any> = {};
const ahMap: Record<string, any> = {};
whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
);
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
workingHoursCache.set(monthKey, whMap);
availableHoursCache.set(monthKey, ahMap);
workingHours = whMap;
availableHours = ahMap;
}
} catch (err) {
console.error('Failed to fetch hours', err);
toast.error('Failed to load availability');
} finally {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
// =============== Logic ===============
function toggleService(service: Service) {
const index = selectedServices.findIndex((s) => s.id === service.id);
if (index >= 0) {
selectedServices = selectedServices.filter((s) => s.id !== service.id);
const newOverrides = { ...serviceOverrides };
delete newOverrides[service.id];
serviceOverrides = newOverrides;
} else {
selectedServices = [...selectedServices, service];
serviceOverrides = {
...serviceOverrides,
[service.id]: {
price: service.price.toFixed(2),
duration: service.duration_minutes.toString(),
originalPrice: service.price,
originalDuration: service.duration_minutes
}
};
}
selectedTime = null;
}
// Time Slot Generation
function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
const totalMinutes = hours * 60 + minutes + durationMinutes;
const endHours = Math.floor(totalMinutes / 60);
const endMinutes = totalMinutes % 60;
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
}
function timeToMinutes(time: string): number {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
}
function calculatePreviousTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
let totalMinutes = hours * 60 + minutes;
totalMinutes -= 15;
const prevHours = Math.floor(totalMinutes / 60);
const prevMinutes = totalMinutes % 60;
return `${String(prevHours).padStart(2, '0')}:${String(prevMinutes).padStart(2, '0')}`;
}
function formatTime(time: string): string {
const parts = time.split(':').map(Number);
const hours = parts[0];
const minutes = parts.length > 1 ? parts[1] : 0;
if (hours === 12 && minutes === 0) {
return 'Noon';
} else if (hours === 0 && minutes === 0) {
return 'Midnight';
}
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
function normalizeTime(time: string): string {
// Strip seconds if present (convert HH:MM:SS to HH:MM)
const parts = time.split(':');
return `${parts[0]}:${parts[1]}`;
}
function generateAvailableTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
if (!date || !workingHours || !availableHours) return [];
const dateStr = date.toString();
const dayWorkingHours = workingHours[dateStr];
const dayAvailableHours = availableHours[dateStr];
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) return [];
const slots: string[] = [];
const now = new SvelteDate();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(today) === 0;
const protection = lunchProtectionStatus();
for (const slot of dayAvailableHours.slots) {
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
if (isToday) {
// Calculate current time + buffer
const currentMinutes = now.getHours() * 60 + now.getMinutes();
let minimumStart = currentMinutes + 15;
// FIX: Snap to the NEXT 15-minute interval
// Math.ceil(x / 15) * 15 rounds up to the nearest 15
minimumStart = Math.ceil(minimumStart / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStart);
}
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
const slotEndMinutes = minutes + duration;
if (slotEndMinutes <= endTotalMinutes) {
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
const protectionResult = protection.get(timeStr);
if (!protectionResult || !protectionResult.isBlocked) {
slots.push(timeStr);
}
// If blocked, skip adding this slot
}
}
}
return slots;
}
function generateGroupedTimeSlots(
duration: number,
date: CalendarDate | undefined
): Array<{
type: 'available' | 'unavailable';
startTime: string;
endTime: string;
isGrouped?: boolean;
}> {
if (!date || !workingHours) return [];
const dateStr = date.toString();
const dayWorkingHours = workingHours[dateStr];
if (!dayWorkingHours || !dayWorkingHours.isOpen) return [];
const groupedSlots: Array<{
type: 'available' | 'unavailable';
startTime: string;
endTime: string;
isGrouped?: boolean;
}> = [];
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
const now = new SvelteDate();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(today) === 0;
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
let minimumStart = currentMinutes + 15;
// FIX: Also snap here so the visual blocks start at 00, 15, 30, 45
minimumStart = Math.ceil(minimumStart / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStart);
}
const availableSlots = generateAvailableTimeSlots(duration, date);
let currentUnavailableStart: string | null = null;
let lastAvailableEndTime: string | null = null;
const protection = lunchProtectionStatus();
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
const isInAvailableSlots = availableSlots.includes(timeStr);
const protectionResult = protection.get(timeStr);
const isLunchBlocked = protectionResult?.isBlocked || false;
const isAvailable = isInAvailableSlots && !isLunchBlocked;
if (isAvailable) {
if (currentUnavailableStart !== null) {
const unavailableStartTime = lastAvailableEndTime || currentUnavailableStart;
const groupEndTime = calculatePreviousTime(timeStr);
groupedSlots.push({
type: 'unavailable',
startTime: unavailableStartTime,
endTime: groupEndTime,
isGrouped: true
});
currentUnavailableStart = null;
}
const slotEndTime = calculateEndTime(timeStr, duration);
lastAvailableEndTime = slotEndTime;
groupedSlots.push({
type: 'available',
startTime: timeStr,
endTime: slotEndTime
});
if (timeToMinutes(slotEndTime) >= endTotalMinutes) break;
} else {
if (currentUnavailableStart === null) {
currentUnavailableStart = timeStr;
}
}
}
if (currentUnavailableStart !== null) {
const lastAvailableSlot = groupedSlots.filter((s) => s.type === 'available').pop();
const lastAvailableEnd = lastAvailableSlot ? timeToMinutes(lastAvailableSlot.endTime) : 0;
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
if (unavailableStartMinutes < endTotalMinutes && lastAvailableEnd < endTotalMinutes) {
const unavailableStartTime = lastAvailableSlot
? lastAvailableSlot.endTime
: currentUnavailableStart;
groupedSlots.push({
type: 'unavailable',
startTime: unavailableStartTime,
endTime: normalizeTime(dayWorkingHours.endTime),
isGrouped: true
});
}
}
return groupedSlots;
}
const groupedTimeSlots = $derived(
currentStep === 4 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
: []
);
function isDateUnavailable(date: DateValue): boolean {
if (!(date instanceof CalendarDate)) return true;
if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) return true;
if (!workingHours) return true;
const dateStr = date.toString();
if (!workingHours[dateStr]?.isOpen) return true;
if (selectedServices.length > 0) {
const duration = getTotalDuration();
const slots = generateAvailableTimeSlots(duration, date);
return slots.length === 0;
}
return false;
}
// =============== Submission ===============
async function submitBooking() {
submitting = true;
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 (!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');
if (!selectedDate || !selectedTime) throw new Error('Date and time required');
const localDate = selectedDate.toDate(getLocalTimeZone());
const [hours, minutes] = selectedTime.split(':').map(Number);
localDate.setHours(hours, minutes, 0, 0);
const dateTimeStr = localDate.toISOString();
const overrides = [];
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
const priceChanged = Math.abs(parseFloat(data.price) - data.originalPrice) > 0.01;
const durationChanged = parseInt(data.duration) !== data.originalDuration;
if (priceChanged || durationChanged) {
overrides.push({
service_id: serviceId,
override_price: priceChanged ? parseFloat(data.price) : null,
override_duration_minutes: durationChanged ? parseInt(data.duration) : null
});
}
}
const payload = {
user_id: finalUserId,
start_time: dateTimeStr,
service_ids: selectedServices.map((s) => s.id),
service_overrides: overrides.length > 0 ? overrides : undefined,
notes: notes.trim() || null
};
const res = await fetch('/api/admin/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (res.ok) {
toast.success('Booking created successfully!');
open = false;
onBookingCreated?.();
} else {
const errorText = await res.text();
console.error('Booking creation failed:', errorText);
toast.error(`Failed to create booking: ${errorText}`);
}
} catch (err) {
console.error('Booking submission error:', err);
toast.error('An error occurred while creating booking');
} finally {
submitting = false;
}
}
// Input handlers
function handlePriceInput(serviceId: string, value: string) {
const override = serviceOverrides[serviceId];
if (!override) return;
let cleaned = value.replace(/[^\d.]/g, '');
const parts = cleaned.split('.');
if (parts.length > 2) cleaned = parts[0] + '.' + parts.slice(1).join('');
if (cleaned.includes('.')) {
const [int, dec] = cleaned.split('.');
cleaned = int + '.' + dec.substring(0, 2);
}
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, price: cleaned } };
}
function handleDurationInput(serviceId: string, value: string) {
const override = serviceOverrides[serviceId];
if (!override) return;
const cleaned = value.replace(/\D/g, '');
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, duration: cleaned } };
// Clear date/time and cache when duration changes
selectedDate = undefined;
selectedTime = null;
availableHoursCache.clear();
}
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
<Modal.Header>
<Modal.Title>Create Admin Booking</Modal.Title>
<Modal.Description>
Book an appointment for a member or guest with flexible pricing and timing
</Modal.Description>
</Modal.Header>
<div class="px-6 pb-4">
<!-- Step 1: Customer Selection -->
{#if currentStep === 1}
<Card.Root>
<Card.Header>
<Card.Title>Select Customer</Card.Title>
<Card.Description>Choose an existing member or create a guest booking</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Tabs -->
<div class="flex gap-6 border-b border-gray-200">
<button
class="pb-2 text-sm font-medium transition-colors {userType === 'member'
? 'border-b-2 border-primary text-primary'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => {
userType = 'member';
selectedUserId = null;
}}
>
Member
</button>
<button
class="pb-2 text-sm font-medium transition-colors {userType === 'guest'
? 'border-b-2 border-primary text-primary'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => {
userType = 'guest';
guestName = '';
guestPhone = '';
}}
>
Guest / Non-Member
</button>
</div>
{#if userType === 'member'}
<!-- Native Input using oninput to prevent reactivity bugs -->
<div class="flex items-center space-x-2">
<div class="relative flex-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
class="h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
<input
type="text"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Search by name, email or phone..."
value={userQuery}
oninput={(e) => {
userQuery = e.currentTarget.value;
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
fetchUsers();
}
}}
/>
</div>
<Button onclick={fetchUsers} disabled={loadingUsers}>
{loadingUsers ? '...' : 'Search'}
</Button>
</div>
<!-- Compact Results List -->
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
{#if loadingUsers}
<div class="space-y-2 p-2">
{#each Array(3) as _}
<Skeleton class="h-10 w-full" />
{/each}
</div>
{:else if users.length === 0}
<div class="flex items-center justify-center p-8 text-sm text-gray-500">
{userQuery
? 'No users found. Try a different search.'
: 'Search for a user above to get started.'}
</div>
{:else}
<ul class="divide-y divide-gray-200">
{#each users.slice(0, 4) as user (user.id)}
<li>
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between px-4 py-3 text-left transition-colors hover:bg-fuchsia-50 {selectedUserId ===
user.id
? 'bg-fuchsia-100 font-medium'
: ''}"
onclick={() => (selectedUserId = user.id)}
>
<div>
<div class="text-base font-medium">{user.fullName}</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
{:else if user.email}
{user.email}
{:else if user.phone}
{user.phone}
{:else}
No contact info
{/if}
</div>
</div>
{#if selectedUserId === user.id}
<svg
class="h-5 w-5 text-primary"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{:else}
<!-- Guest Form - Using Native Input -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="guest-name">Guest Name *</Label>
<input
id="guest-name"
type="text"
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="Jane Doe"
value={guestName}
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>
<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.
</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-end">
<BookingActions
canBack={false}
canNext={canProceedStep1}
nextLabel="Next: Choose Services"
on:next={() => currentStep++}
/>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 2: Service Selection -->
{#if currentStep === 2}
<Card.Root>
<Card.Header>
<Card.Title>Choose Services</Card.Title>
<Card.Description>Select one or more treatments for this appointment</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if loadingServices}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(4) as _}
<Skeleton class="h-28 w-full" />
{/each}
</div>
{:else if services.length === 0}
<p class="py-8 text-center text-gray-500">No services available.</p>
{:else}
<ServiceSelector
{services}
selected={selectedServices}
loading={loadingServices}
ontoggle={toggleService}
/>
{/if}
{#if selectedServices.length > 0}
<div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold">Selected Services</h4>
<div class="space-y-2">
{#each selectedServices as service (service.id)}
<div class="flex justify-between text-sm">
<span>{service.name}</span>
<span>{service.duration_minutes} mins • £{service.price}</span>
</div>
{/each}
<Separator class="my-2" />
<div class="flex justify-between text-sm font-semibold">
<span>Estimated Duration:</span>
<span>{formattedTotalDuration}</span>
</div>
<div class="flex justify-between text-sm font-semibold">
<span>Total Cost:</span>
<span>£{getTotalPrice()}</span>
</div>
</div>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
<Button disabled={!canProceedStep2} onclick={() => currentStep++}>
Next: Customize Services
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 3: Service Overrides & Notes -->
{#if currentStep === 3}
<Card.Root>
<Card.Header>
<Card.Title>Customize Services</Card.Title>
<Card.Description>
Adjust pricing or duration if needed, and add appointment notes
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div>
<h4 class="mb-3 font-semibold">Service Details</h4>
<p class="mb-4 text-sm text-gray-600">
Override default pricing or duration for special cases (discounts, extended
sessions, etc.)
</p>
<div class="space-y-3">
{#each selectedServices as service (service.id)}
<!-- Safety check to ensure override exists -->
{#if serviceOverrides[service.id]}
<div class="rounded-lg border bg-white p-4">
<div class="mb-3 font-medium">{service.name}</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="price-{service.id}" class="text-xs text-gray-600"
>Price (£)</Label
>
<!-- Native Input with oninput -->
<input
id="price-{service.id}"
type="text"
inputmode="decimal"
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"
value={serviceOverrides[service.id]?.price || service.price.toFixed(2)}
oninput={(e) => handlePriceInput(service.id, e.currentTarget.value)}
/>
</div>
<div class="space-y-2">
<Label for="duration-{service.id}" class="text-xs text-gray-600"
>Duration (min)</Label
>
<!-- Native Input with oninput -->
<input
id="duration-{service.id}"
type="number"
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"
value={serviceOverrides[service.id]?.duration ||
service.duration_minutes}
oninput={(e) => handleDurationInput(service.id, e.currentTarget.value)}
/>
</div>
</div>
{#if serviceOverrides[service.id] && (Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 || parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration)}
<div class="mt-2 text-xs text-amber-600">
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01}
Price modified from £{serviceOverrides[
service.id
].originalPrice.toFixed(2)}
{/if}
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 && parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
{/if}
{#if parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
Duration modified from {serviceOverrides[service.id].originalDuration} mins
{/if}
</div>
{/if}
</div>
{/if}
{/each}
</div>
</div>
<div class="rounded-lg bg-gray-50 p-4">
<div class="flex justify-between text-sm font-semibold">
<span>Total Duration:</span>
<span>{formattedTotalDuration}</span>
</div>
<div class="mt-1 flex justify-between text-sm font-semibold">
<span>Total Cost:</span>
<span>£{getTotalPrice().toFixed(2)}</span>
</div>
</div>
<div class="space-y-2">
<Label for="notes">Appointment Notes (extras only, client will see this)</Label>
<!-- Native Textarea -->
<textarea
id="notes"
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={notes}
placeholder="Any special requirements, preferences, or notes about this booking..."
></textarea>
</div>
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
<Button onclick={() => currentStep++}>Next: Select Date & Time</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 4: Date & Time Selection -->
{#if currentStep === 4}
<Card.Root>
<Card.Header>
<Card.Title>Choose Date & Time</Card.Title>
<Card.Description>
{selectedServices.map((s) => s.name).join(', ')}{formattedTotalDuration} total • £{getTotalPrice().toFixed(
2
)}
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4 p-0">
{#if loadingWorkingHours}
<div class="flex items-center justify-center p-6">
<p>Loading available dates...</p>
</div>
{:else}
<div class="flex items-center justify-center p-6">
<DatePicker
date={selectedDate}
{placeholder}
minValue={minDate}
maxValue={maxCalendarDate}
{isDateUnavailable}
onchange={(newDate) => {
selectedDate = newDate;
selectedTime = null;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
}}
/>
</div>
{/if}
{#if loadingAvailableHours}
<div class="flex items-center justify-center border-t p-6">
<p class="text-sm text-gray-500">Loading times...</p>
</div>
{:else if selectedDate}
<div
class="no-scrollbar flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6"
>
{#if formattedSelectedDate}
<div class="grid justify-center gap-2">{formattedSelectedDate}</div>
{/if}
{#if groupedTimeSlots.length > 0}
<div class="grid gap-2">
{#each groupedTimeSlots as slot (slot.startTime)}
{#if slot.type === 'available'}
{@const protection = lunchProtectionStatus().get(slot.startTime)}
{#if protection?.isBlocked}
<!-- Blocked by lunch protection -->
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
title={protection.warningMessage || 'Lunch protection'}
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{:else}
<Button
variant="outline"
onclick={() => {
selectedTime = slot.startTime;
}}
class={`w-full hover:bg-fuchsia-50 ${
slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''
} ${protection?.showWarning ? 'border-amber-400 bg-amber-50' : ''}`}
title={protection?.warningMessage}
>
{#if protection?.showWarning}
<span class="mr-1 text-amber-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
</span>
{/if}
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
{:else}
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
{/each}
</div>
{:else}
<p class="text-center text-sm text-gray-500">No available slots</p>
{/if}
</div>
{:else}
<div class="flex items-center justify-center border-t p-6">
<p class="text-center text-sm text-gray-500">
Select a date to see available times
</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
<Button
disabled={!canProceedStep4 || submitting}
onclick={submitBooking}
class="bg-primary text-primary-foreground"
>
{submitting ? 'Creating Booking...' : 'Create Booking'}
</Button>
</Card.Footer>
</Card.Root>
{/if}
</div>
</Modal.Content>
</Modal.Root>