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.
This commit is contained in:
@@ -0,0 +1,942 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// Components
|
||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||
import BookingSummary from '$lib/components/booking/BookingSummary.svelte';
|
||||
import StepIndicator from '$lib/components/booking/StepIndicator.svelte';
|
||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
|
||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||
import {
|
||||
extractBookedSlots,
|
||||
getLunchProtectionForSlots,
|
||||
type TimeSlot
|
||||
} from '$lib/lunchProtection';
|
||||
|
||||
import type {
|
||||
Service,
|
||||
CustomerInfo,
|
||||
WorkingHoursDay,
|
||||
AvailableHoursDay
|
||||
} from '$lib/types/booking';
|
||||
|
||||
// =============== State Management ===============
|
||||
let currentStep = $state<number>(1);
|
||||
let selectedServices = $state<Service[]>([]);
|
||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||
let selectedTime = $state<string | null>(null);
|
||||
let customerInfo = $state<CustomerInfo>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
specialRequests: ''
|
||||
});
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// =============== Services Management ===============
|
||||
let services = $state<Service[]>([]);
|
||||
let servicesLoading = $state(true);
|
||||
|
||||
async function fetchServices() {
|
||||
servicesLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/services', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: Service[] = await response.json();
|
||||
services = data;
|
||||
} else {
|
||||
console.error('Failed to fetch services:', response.status);
|
||||
toast.error('Failed to load services');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching services:', err);
|
||||
toast.error('Network error loading services');
|
||||
} finally {
|
||||
servicesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== ADD: 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();
|
||||
}
|
||||
|
||||
// Extract existing bookings from the gap between working hours and available hours
|
||||
const existingBookings = extractBookedSlots(
|
||||
dayWorkingHours.startTime,
|
||||
dayWorkingHours.endTime,
|
||||
dayAvailableHours.slots
|
||||
);
|
||||
|
||||
// Get lunch protection status for all slots
|
||||
return getLunchProtectionForSlots(
|
||||
dayWorkingHours.startTime,
|
||||
dayWorkingHours.endTime,
|
||||
existingBookings,
|
||||
getTotalDuration(),
|
||||
15, // 15 minute slot intervals
|
||||
false // User journey - requires 1h minimum
|
||||
);
|
||||
});
|
||||
|
||||
// =============== Working Hours & Available Hours ===============
|
||||
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<boolean>(false);
|
||||
let loadingAvailableHours = $state<boolean>(false);
|
||||
|
||||
const workingHoursCache = new SvelteMap<
|
||||
string,
|
||||
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||
>();
|
||||
|
||||
const availableHoursCache = new SvelteMap<
|
||||
string,
|
||||
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
|
||||
>();
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
workingHoursCache.clear();
|
||||
availableHoursCache.clear();
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize date boundaries
|
||||
const today = new SvelteDate();
|
||||
const tomorrow = new SvelteDate(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const maxDate = new SvelteDate();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
|
||||
// Create CalendarDate objects
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
maxDate.getMonth() + 1,
|
||||
maxDate.getDate()
|
||||
);
|
||||
|
||||
let placeholder = $state<CalendarDate>(minDate);
|
||||
|
||||
$effect(() => {
|
||||
fetchServices();
|
||||
});
|
||||
$effect(() => {
|
||||
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
|
||||
fetchHoursForMonth(placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
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 startStr = startOfMonth.toString();
|
||||
const endStr = endOfMonth.toString();
|
||||
|
||||
// Fetch working hours
|
||||
const workingHoursResponse = await fetch(
|
||||
`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`
|
||||
);
|
||||
if (!workingHoursResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${workingHoursResponse.status}`);
|
||||
}
|
||||
|
||||
const workingHoursData: Array<WorkingHoursDay> = await workingHoursResponse.json();
|
||||
const workingHoursMap: Record<
|
||||
string,
|
||||
{ isOpen: boolean; startTime: string; endTime: string }
|
||||
> = {};
|
||||
|
||||
workingHoursData.forEach((day) => {
|
||||
workingHoursMap[day.date] = {
|
||||
isOpen: day.isOpen,
|
||||
startTime: day.startTime,
|
||||
endTime: day.endTime
|
||||
};
|
||||
});
|
||||
|
||||
workingHoursCache.set(monthKey, workingHoursMap);
|
||||
workingHours = workingHoursMap;
|
||||
|
||||
// Fetch available hours
|
||||
const availableHoursResponse = await fetch(
|
||||
`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`
|
||||
);
|
||||
if (!availableHoursResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${availableHoursResponse.status}`);
|
||||
}
|
||||
|
||||
const availableHoursData: Array<AvailableHoursDay> = await availableHoursResponse.json();
|
||||
const availableHoursMap: Record<
|
||||
string,
|
||||
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||
> = {};
|
||||
|
||||
availableHoursData.forEach((day) => {
|
||||
availableHoursMap[day.date] = {
|
||||
isOpen: day.isOpen,
|
||||
slots: day.slots
|
||||
};
|
||||
});
|
||||
|
||||
availableHoursCache.set(monthKey, availableHoursMap);
|
||||
availableHours = availableHoursMap;
|
||||
|
||||
if (!selectedDate) {
|
||||
setDefaultSelectedDate(workingHoursMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hours:', error);
|
||||
if (!selectedDate) {
|
||||
selectedDate = minDate;
|
||||
}
|
||||
} finally {
|
||||
loadingWorkingHours = false;
|
||||
loadingAvailableHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setDefaultSelectedDate(
|
||||
hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||
) {
|
||||
const currentDate = new SvelteDate();
|
||||
const maxDateJs = new SvelteDate(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
maxCalendarDate.day
|
||||
);
|
||||
|
||||
const daysDifference = Math.floor(
|
||||
(maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const daysToCheck = Math.min(daysDifference, 180);
|
||||
|
||||
for (let i = 1; i <= daysToCheck; i++) {
|
||||
const nextDate = new SvelteDate(currentDate);
|
||||
nextDate.setDate(currentDate.getDate() + i);
|
||||
const dateStr = nextDate.toISOString().split('T')[0];
|
||||
|
||||
if (hoursMap[dateStr]?.isOpen) {
|
||||
selectedDate = new CalendarDate(
|
||||
nextDate.getFullYear(),
|
||||
nextDate.getMonth() + 1,
|
||||
nextDate.getDate()
|
||||
);
|
||||
// Also update placeholder to show the month with first available date
|
||||
placeholder = new CalendarDate(
|
||||
nextDate.getFullYear(),
|
||||
nextDate.getMonth() + 1,
|
||||
1 // First day of the month
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedDate) {
|
||||
const tomorrow = new SvelteDate();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
selectedDate = new CalendarDate(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth() + 1,
|
||||
tomorrow.getDate()
|
||||
);
|
||||
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Time Slot Generation ===============
|
||||
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
const date = new SvelteDate();
|
||||
date.setHours(hours, minutes, 0, 0);
|
||||
date.setMinutes(date.getMinutes() + durationMinutes);
|
||||
const endHours = date.getHours().toString().padStart(2, '0');
|
||||
const endMinutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${endHours}:${endMinutes}`;
|
||||
}
|
||||
|
||||
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 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 ||
|
||||
!dayWorkingHours.isOpen ||
|
||||
!dayAvailableHours ||
|
||||
!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;
|
||||
|
||||
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) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const minimumStartMinutes = currentMinutes + 120;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
|
||||
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')}`;
|
||||
slots.push(timeStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const minimumStartMinutes = currentMinutes + 120;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
|
||||
let currentUnavailableStart: string | null = null;
|
||||
let lastAvailableEndTime: string | null = null;
|
||||
|
||||
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 isAvailable = availableSlots.includes(timeStr);
|
||||
|
||||
if (isAvailable) {
|
||||
if (currentUnavailableStart !== null) {
|
||||
// Use the end time of the last available slot as the start of unavailable period
|
||||
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) {
|
||||
// Use the end time of the last available slot for the final unavailable period
|
||||
const unavailableStartTime = lastAvailableSlot
|
||||
? lastAvailableSlot.endTime
|
||||
: currentUnavailableStart;
|
||||
groupedSlots.push({
|
||||
type: 'unavailable',
|
||||
startTime: unavailableStartTime,
|
||||
endTime: dayWorkingHours.endTime,
|
||||
isGrouped: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groupedSlots;
|
||||
}
|
||||
|
||||
// =============== Date Availability Check ===============
|
||||
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();
|
||||
const dayHours = workingHours[dateStr];
|
||||
|
||||
if (!dayHours) return true;
|
||||
if (!dayHours.isOpen) return true;
|
||||
|
||||
// If no services selected, don't check availability slots
|
||||
// This allows calendar to show open/closed days
|
||||
if (selectedServices.length === 0) {
|
||||
return false; // Show all working days as available
|
||||
}
|
||||
|
||||
const duration = getTotalDuration();
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
if (availableSlots.length === 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// =============== Helper Functions ===============
|
||||
function getTotalDuration() {
|
||||
return selectedServices.reduce(
|
||||
(total, service: Service) => total + service.duration_minutes,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function getTotalPrice() {
|
||||
return selectedServices.reduce((total, service: Service) => total + service.price, 0);
|
||||
}
|
||||
|
||||
function toggleService(service: Service) {
|
||||
const index = selectedServices.findIndex((s) => s.id === service.id);
|
||||
const wasSelected = index >= 0;
|
||||
|
||||
if (wasSelected) {
|
||||
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||
} else {
|
||||
selectedServices = [...selectedServices, service];
|
||||
}
|
||||
|
||||
// Only clear if we're on the date/time selection step
|
||||
if (currentStep === 2 && selectedDate) {
|
||||
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
||||
availableHoursCache.delete(monthKey); // Only delete current month
|
||||
fetchHoursForMonth(selectedDate);
|
||||
}
|
||||
|
||||
selectedTime = null;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Derived Values ===============
|
||||
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
||||
const groupedTimeSlots = $derived(
|
||||
currentStep === 2 && selectedServices.length > 0 && selectedDate
|
||||
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
|
||||
: []
|
||||
);
|
||||
const formattedSelectedDate = $derived(
|
||||
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
|
||||
);
|
||||
|
||||
// =============== Navigation ===============
|
||||
function nextStep() {
|
||||
if (currentStep < 4) {
|
||||
currentStep++;
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep > 1) {
|
||||
currentStep--;
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Validation ===============
|
||||
const canProceedStep1 = $derived(selectedServices.length > 0);
|
||||
const canProceedStep2 = $derived(!!(selectedDate && selectedTime));
|
||||
const canProceedStep3 = $derived(
|
||||
authStore.isAuthenticated
|
||||
? !!(
|
||||
authStore.currentUser?.firstName &&
|
||||
authStore.currentUser?.lastName &&
|
||||
authStore.currentUser?.email &&
|
||||
authStore.currentUser?.phone
|
||||
)
|
||||
: !!(
|
||||
customerInfo.firstName &&
|
||||
customerInfo.lastName &&
|
||||
customerInfo.email &&
|
||||
customerInfo.phone
|
||||
)
|
||||
);
|
||||
|
||||
// =============== Submission ===============
|
||||
async function submitBooking() {
|
||||
isSubmitting = true;
|
||||
try {
|
||||
console.log('Submitting booking:', {
|
||||
services: selectedServices,
|
||||
date: selectedDate,
|
||||
time: selectedTime,
|
||||
customer: authStore.isAuthenticated ? authStore.currentUser : customerInfo
|
||||
});
|
||||
|
||||
toast.success('Booking submitted successfully!');
|
||||
} catch (error) {
|
||||
console.error('Booking submission failed:', error);
|
||||
toast.error('Failed to submit booking. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl p-6">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="mb-2 text-3xl font-bold">Book Your Appointment</h1>
|
||||
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
||||
</div>
|
||||
|
||||
<StepIndicator {currentStep} />
|
||||
|
||||
<!-- Step 1: Service Selection -->
|
||||
{#if currentStep === 1}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Your Services</Card.Title>
|
||||
<Card.Description>Select one or more treatments for your appointment</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<ServiceSelector
|
||||
{services}
|
||||
selected={selectedServices}
|
||||
loading={servicesLoading}
|
||||
ontoggle={toggleService}
|
||||
/>
|
||||
|
||||
{#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-end">
|
||||
<BookingActions
|
||||
canBack={false}
|
||||
canNext={canProceedStep1}
|
||||
nextLabel="Next: Select Date & Time"
|
||||
on:next={nextStep}
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 2: Date & Time Selection -->
|
||||
{#if currentStep === 2}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Date & Time</Card.Title>
|
||||
<Card.Description>
|
||||
{selectedServices.map((s) => s.name).join(', ')} • {formattedTotalDuration} total • £{getTotalPrice()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<Card.Root class="gap-0 border-0 p-0">
|
||||
<Card.Content class="relative p-0 md:pr-56">
|
||||
{#if loadingWorkingHours}
|
||||
<div class="flex items-center justify-center p-6">
|
||||
<p>Loading available dates...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
{placeholder}
|
||||
minValue={minDate}
|
||||
maxValue={maxCalendarDate}
|
||||
{isDateUnavailable}
|
||||
onchange={(newDate) => {
|
||||
selectedDate = newDate;
|
||||
selectedTime = null;
|
||||
}}
|
||||
onPlaceholderChange={(newPlaceholder) => {
|
||||
placeholder = newPlaceholder;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if loadingAvailableHours}
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex w-56 items-center justify-center border-l p-6"
|
||||
>
|
||||
<p class="text-sm text-gray-500">Loading times...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TimeSlotPicker
|
||||
date={selectedDate}
|
||||
{groupedTimeSlots}
|
||||
{selectedTime}
|
||||
formattedDate={formattedSelectedDate}
|
||||
onselect={(time) => {
|
||||
selectedTime = time;
|
||||
}}
|
||||
lunchProtectionStatus={lunchProtectionStatus()}
|
||||
/>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Card.Content>
|
||||
|
||||
<!-- Mobile appointment summary -->
|
||||
<div class="border-t px-6 py-4 text-center text-sm md:hidden">
|
||||
{#if selectedDate && selectedTime}
|
||||
Appointment for
|
||||
<span class="font-medium">
|
||||
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</span>
|
||||
<br />at <span class="font-medium">{selectedTime}</span>
|
||||
{:else}
|
||||
Select a date and time
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card.Footer class="flex justify-between border-t px-6 !py-5">
|
||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Desktop appointment summary -->
|
||||
<div class="hidden text-sm md:block">
|
||||
{#if selectedDate && selectedTime}
|
||||
Appointment for
|
||||
<span class="font-medium">
|
||||
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</span>
|
||||
at <span class="font-medium">{selectedTime}</span>
|
||||
{:else}
|
||||
Select a date and time
|
||||
{/if}
|
||||
</div>
|
||||
<Button disabled={!canProceedStep2} onclick={nextStep}>Next: Your Details</Button>
|
||||
</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 3: Customer Details -->
|
||||
{#if currentStep === 3}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Your Details</Card.Title>
|
||||
<Card.Description>Please confirm your contact information</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
showCustomer={false}
|
||||
/>
|
||||
|
||||
{#if !authStore.isAuthenticated}
|
||||
<p class="mb-4 text-center text-sm text-yellow-600">
|
||||
You are checking out as a guest, so you will miss out on a loyalty stamp. Please login
|
||||
for full membership benefits.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="firstName">First Name *</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
bind:value={customerInfo.firstName}
|
||||
placeholder="Enter your first name"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="lastName">Last Name *</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
bind:value={customerInfo.lastName}
|
||||
placeholder="Enter your last name"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={customerInfo.email}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
bind:value={customerInfo.phone}
|
||||
placeholder="Enter your phone number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="requests">Special Requests (Optional)</Label>
|
||||
<Textarea
|
||||
id="requests"
|
||||
bind:value={customerInfo.specialRequests}
|
||||
placeholder="Any allergies, preferences, or special requirements..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600">
|
||||
{#if !authStore.isAuthenticated}
|
||||
<p>* Required fields</p>
|
||||
{/if}
|
||||
<p class="mt-2">
|
||||
By booking, you agree to our Terms & Conditions and Privacy Policy. We'll send you
|
||||
appointment reminders via email and/or SMS.
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||
<Button
|
||||
disabled={!canProceedStep3}
|
||||
onclick={nextStep}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
Next: Payment
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 4: Payment (complete) -->
|
||||
{#if currentStep === 4}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Payment Confirmation</Card.Title>
|
||||
<Card.Description>Review and complete your booking</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<!-- Payment form placeholder -->
|
||||
<div class="rounded-lg bg-white p-6">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Payment</h2>
|
||||
<p class="text-gray-600">Square payment integration will be added here.</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||
<Button
|
||||
disabled={!canProceedStep3 || isSubmitting}
|
||||
onclick={submitBooking}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isSubmitting ? 'Processing...' : 'Complete Booking'}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user