1023 lines
32 KiB
Svelte
1023 lines
32 KiB
Svelte
<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 Calendar from '$lib/components/ui/calendar/calendar.svelte';
|
|
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';
|
|
|
|
// Booking state
|
|
let currentStep = $state<number>(1);
|
|
let selectedServices = $state<Service[]>([]);
|
|
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
|
let selectedTime = $state<string | null>(null);
|
|
let customerInfo = $state({
|
|
firstName: '',
|
|
lastName: '',
|
|
email: '',
|
|
phone: '',
|
|
specialRequests: ''
|
|
});
|
|
|
|
// =============== Services Management (User View) ===============
|
|
type Service = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
price: number;
|
|
duration_minutes: number;
|
|
patch_test_duration_hours: number;
|
|
minimum_age_required: number;
|
|
};
|
|
|
|
let services = $state<Service[]>([]);
|
|
let servicesLoading = $state(true);
|
|
|
|
// Fetch active services for standard users
|
|
async function fetchServices() {
|
|
servicesLoading = true;
|
|
try {
|
|
const response = await fetch('/api/services', {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
// Optional: If your user endpoint requires auth
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Working hours and available hours state
|
|
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 }> }>
|
|
>();
|
|
|
|
// 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(() => {
|
|
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
|
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
|
|
fetchHoursForMonth(placeholder);
|
|
}
|
|
fetchServices();
|
|
});
|
|
|
|
// Fetch both working hours and available hours for a given month
|
|
async function fetchHoursForMonth(date: CalendarDate) {
|
|
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
|
|
|
// Use cached data if available
|
|
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
|
|
setTimeout(() => {
|
|
workingHours = workingHoursCache.get(monthKey)!;
|
|
availableHours = availableHoursCache.get(monthKey)!;
|
|
}, 0);
|
|
return;
|
|
}
|
|
|
|
loadingWorkingHours = true;
|
|
loadingAvailableHours = true;
|
|
|
|
try {
|
|
// Calculate start and end of month
|
|
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}`);
|
|
}
|
|
|
|
type WorkingHoursDay = {
|
|
date: string;
|
|
weekday: number;
|
|
startTime: string;
|
|
endTime: string;
|
|
isOpen: boolean;
|
|
source: string;
|
|
};
|
|
|
|
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}`);
|
|
}
|
|
|
|
type AvailableHoursDay = {
|
|
date: string;
|
|
weekday: number;
|
|
isOpen: boolean;
|
|
slots: Array<{ startTime: string; endTime: string }>;
|
|
source: string;
|
|
};
|
|
|
|
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;
|
|
|
|
// Set default selected date if not set
|
|
if (!selectedDate) {
|
|
setDefaultSelectedDate(workingHoursMap);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch hours:', error);
|
|
// Fallback to current date if API fails
|
|
if (!selectedDate) {
|
|
selectedDate = minDate;
|
|
}
|
|
} finally {
|
|
loadingWorkingHours = false;
|
|
loadingAvailableHours = false;
|
|
}
|
|
}
|
|
|
|
// Set default selected date to next available working day
|
|
function setDefaultSelectedDate(
|
|
hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
|
) {
|
|
const currentDate = new SvelteDate();
|
|
let nextDate = new SvelteDate(currentDate);
|
|
|
|
for (let i = 1; i < 30; i++) {
|
|
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()
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!selectedDate) {
|
|
const tomorrow = new SvelteDate();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
selectedDate = new CalendarDate(
|
|
tomorrow.getFullYear(),
|
|
tomorrow.getMonth() + 1,
|
|
tomorrow.getDate()
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates the end time based on a 24-hour start time and a duration in minutes.
|
|
*/
|
|
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}`;
|
|
}
|
|
|
|
/** Format time from HH:MM:SS to human-readable 12-hour format */
|
|
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}`;
|
|
}
|
|
|
|
// Initialize with current month
|
|
fetchHoursForMonth(minDate);
|
|
|
|
// Check if date is unavailable
|
|
function isDateUnavailable(date: DateValue): boolean {
|
|
if (!(date instanceof CalendarDate)) {
|
|
return true;
|
|
}
|
|
|
|
// Check if date is outside allowed range
|
|
if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) {
|
|
return true;
|
|
}
|
|
|
|
// Check if we have working hours data
|
|
if (!workingHours) return true; // Changed from false to true when data not loaded
|
|
|
|
const dateStr = date.toString();
|
|
const dayHours = workingHours[dateStr];
|
|
|
|
// If no data for this date, assume unavailable
|
|
if (!dayHours) return true;
|
|
|
|
// If closed, mark as unavailable
|
|
if (!dayHours.isOpen) return true;
|
|
|
|
// Check if there are any available time slots for the selected services
|
|
if (selectedServices.length > 0) {
|
|
const duration = getTotalDuration();
|
|
const availableSlots = generateAvailableTimeSlots(duration, date);
|
|
// If no slots available for the required duration, mark as unavailable
|
|
if (availableSlots.length === 0) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Use the new grouped time slots generation for visual display
|
|
const groupedTimeSlots = $derived(
|
|
selectedServices.length > 0 && selectedDate
|
|
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
|
|
: []
|
|
);
|
|
|
|
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;
|
|
|
|
// Apply 2-hour buffer for today's appointments
|
|
if (isToday) {
|
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
const minimumStartMinutes = currentMinutes + 120;
|
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
|
}
|
|
|
|
// Get available time slots that fit our duration
|
|
const availableSlots = generateAvailableTimeSlots(duration, date);
|
|
|
|
let currentUnavailableStart: string | null = null;
|
|
|
|
// Generate all 15-minute increments within working hours
|
|
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')}`;
|
|
|
|
// Check if this time slot is available (fits duration and within available hours)
|
|
const isAvailable = availableSlots.includes(timeStr);
|
|
|
|
if (isAvailable) {
|
|
// If we were building an unavailable group, push it first
|
|
if (currentUnavailableStart !== null) {
|
|
const groupEndTime = calculatePreviousTime(timeStr); // End before this available slot
|
|
groupedSlots.push({
|
|
type: 'unavailable',
|
|
startTime: currentUnavailableStart,
|
|
endTime: groupEndTime,
|
|
isGrouped: true
|
|
});
|
|
currentUnavailableStart = null;
|
|
}
|
|
|
|
// Add available slot
|
|
const slotEndTime = calculateEndTime(timeStr, duration);
|
|
groupedSlots.push({
|
|
type: 'available',
|
|
startTime: timeStr,
|
|
endTime: slotEndTime
|
|
});
|
|
|
|
// If this available slot ends at or after closing time, break out early
|
|
// to avoid generating unnecessary slots
|
|
if (timeToMinutes(slotEndTime) >= endTotalMinutes) {
|
|
break;
|
|
}
|
|
} else {
|
|
// Start or continue unavailable group
|
|
if (currentUnavailableStart === null) {
|
|
currentUnavailableStart = timeStr;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close any remaining unavailable group at the end of the day
|
|
// BUT only if there are no available slots that already reach closing time
|
|
if (currentUnavailableStart !== null) {
|
|
const lastAvailableSlot = groupedSlots.filter((s) => s.type === 'available').pop();
|
|
const lastAvailableEnd = lastAvailableSlot ? timeToMinutes(lastAvailableSlot.endTime) : 0;
|
|
|
|
// Only add the final unavailable group if:
|
|
// 1. It actually contains some time before closing
|
|
// 2. No available slot already reaches closing time
|
|
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
|
|
|
|
if (unavailableStartMinutes < endTotalMinutes && lastAvailableEnd < endTotalMinutes) {
|
|
groupedSlots.push({
|
|
type: 'unavailable',
|
|
startTime: currentUnavailableStart,
|
|
endTime: dayWorkingHours.endTime,
|
|
isGrouped: true
|
|
});
|
|
}
|
|
}
|
|
|
|
return groupedSlots;
|
|
}
|
|
|
|
// Helper: Convert time string to total minutes
|
|
function timeToMinutes(time: string): number {
|
|
const [hours, minutes] = time.split(':').map(Number);
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
// Helper: Calculate the previous 15-minute time slot
|
|
function calculatePreviousTime(time: string): string {
|
|
const [hours, minutes] = time.split(':').map(Number);
|
|
let totalMinutes = hours * 60 + minutes;
|
|
totalMinutes -= 15; // Go back 15 minutes
|
|
|
|
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];
|
|
|
|
// Add check for slots existence
|
|
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;
|
|
|
|
// Apply 2-hour buffer for today's appointments
|
|
if (isToday) {
|
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
const minimumStartMinutes = currentMinutes + 120;
|
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
|
}
|
|
|
|
// Generate 15-minute increments within this available slot
|
|
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
|
const slotEndMinutes = minutes + duration;
|
|
|
|
// Check if the full duration fits within the available slot
|
|
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 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];
|
|
}
|
|
|
|
// Clear selected date and time when services change, as they may no longer be valid
|
|
selectedTime = null;
|
|
selectedDate = undefined;
|
|
|
|
// Re-fetch available hours when services change and we're on step 2
|
|
if (currentStep === 2) {
|
|
// Clear the cache to force refetch with new duration
|
|
availableHoursCache.clear();
|
|
if (selectedDate) {
|
|
fetchHoursForMonth(selectedDate);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isServiceSelected(service: Service) {
|
|
return selectedServices.some((s) => s.id === service.id);
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
|
|
// Use the formatted duration everywhere
|
|
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
|
|
|
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);
|
|
|
|
// Re-fetch available hours when returning to step 2
|
|
if (currentStep === 2 && selectedDate) {
|
|
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
|
availableHoursCache.delete(monthKey); // Force refetch
|
|
fetchHoursForMonth(selectedDate);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleBooking() {
|
|
alert('Booking submitted! (This is just a prototype - no backend connected yet)');
|
|
}
|
|
|
|
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
|
|
);
|
|
</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>
|
|
|
|
<!-- Progress Indicator -->
|
|
<div class="mb-8 grid grid-cols-2 gap-4 md:flex md:items-center md:justify-center md:space-x-4">
|
|
{#each ['Service', 'Date & Time', 'Details', 'Payment'] as step, index (step)}
|
|
<div class="flex items-center justify-start md:justify-center">
|
|
<div
|
|
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium {index +
|
|
1 <=
|
|
currentStep
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-gray-200 text-gray-600'}"
|
|
>
|
|
{index + 1}
|
|
</div>
|
|
<span
|
|
class="ml-2 text-sm font-medium {index + 1 <= currentStep
|
|
? 'text-primary'
|
|
: 'text-gray-600'}"
|
|
>
|
|
{step}
|
|
</span>
|
|
{#if index < 3}
|
|
<div
|
|
class="mx-4 hidden h-0.5 w-8 md:block {index + 1 < currentStep
|
|
? 'bg-primary'
|
|
: 'bg-gray-200'}"
|
|
></div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
|
{#if servicesLoading}
|
|
<p>Loading services...</p>
|
|
{:else if services.length === 0}
|
|
<p>No services available at the moment.</p>
|
|
{:else}
|
|
{#each services as service (service.id)}
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer rounded-lg p-4 text-left shadow-sm transition-colors hover:bg-fuchsia-50 focus:ring-primary {isServiceSelected(
|
|
service
|
|
)
|
|
? 'bg-fuchsia-100'
|
|
: 'border-ring'}"
|
|
onclick={() => toggleService(service)}
|
|
>
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1">
|
|
<h3 class="font-semibold">{service.name}</h3>
|
|
<p class="text-sm text-gray-600">{service.description}</p>
|
|
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
|
<span>{service.duration_minutes} mins</span>
|
|
<span>£{service.price}</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="ml-3 flex h-5 w-5 items-center justify-center rounded border-2 {isServiceSelected(
|
|
service
|
|
)
|
|
? 'border-primary bg-primary'
|
|
: 'border-gray-300'}"
|
|
aria-hidden="true"
|
|
>
|
|
{#if isServiceSelected(service)}
|
|
<svg class="h-3 w-3 text-white" 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}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
{#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">
|
|
<Button disabled={!canProceedStep1} onclick={nextStep}>Next: Select Date & Time</Button>
|
|
</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">
|
|
<div class="flex items-center justify-center p-6">
|
|
<Calendar
|
|
type="single"
|
|
bind:value={selectedDate}
|
|
bind:placeholder
|
|
{isDateUnavailable}
|
|
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
|
|
weekdayFormat="short"
|
|
minValue={minDate}
|
|
maxValue={maxCalendarDate}
|
|
locale="en-GB"
|
|
/>
|
|
</div>
|
|
<div
|
|
class="no-scrollbar inset-y-0 right-0 flex max-h-48 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
|
|
>
|
|
{#if (loadingWorkingHours || loadingAvailableHours) && selectedDate}
|
|
<div class="text-center text-sm text-gray-500">Loading available times...</div>
|
|
{:else if groupedTimeSlots.length > 0}
|
|
{#if selectedDate}
|
|
<div class="grid justify-center gap-2">{getDayWithOrdinal(selectedDate)}</div>
|
|
{/if}
|
|
<!-- Grouped Time Slots Grid - ORIGINAL STYLING BUT WITH GROUPED UNAVAILABLE SLOTS -->
|
|
<div class="grid gap-2">
|
|
{#each groupedTimeSlots as slot (slot.startTime)}
|
|
{#if slot.type === 'available'}
|
|
<!-- Available slot - individual button -->
|
|
<Button
|
|
variant="outline"
|
|
onclick={() => {
|
|
selectedTime = slot.startTime;
|
|
}}
|
|
class={`w-full hover:bg-fuchsia-50 ${
|
|
slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''
|
|
}`}
|
|
>
|
|
{formatTime(slot.startTime)}
|
|
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
|
</Button>
|
|
{:else}
|
|
<!-- Unavailable slot - grouped block -->
|
|
<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 if selectedServices.length === 0}
|
|
<p class="text-center text-sm text-gray-500">Select services first</p>
|
|
{:else if !selectedDate}
|
|
<p class="text-center text-sm text-gray-500">Select a date first</p>
|
|
{:else}
|
|
<p class="text-center text-sm text-gray-500">No available slots</p>
|
|
{/if}
|
|
</div>
|
|
</Card.Content>
|
|
</Card.Root>
|
|
</Card.Content>
|
|
|
|
<!-- Mobile appointment summary - shown only on mobile -->
|
|
<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">{formatTime(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 - hidden on mobile -->
|
|
<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">{formatTime(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">
|
|
<!-- Booking Summary -->
|
|
<div class="rounded-lg bg-gray-50 p-4">
|
|
<h4 class="mb-2 font-semibold">Booking Summary</h4>
|
|
<div class="space-y-1 text-sm">
|
|
<div>
|
|
<span class="font-medium">Services:</span>
|
|
<div class="mt-1 ml-4 space-y-1">
|
|
{#each selectedServices as service (service.id)}
|
|
<div class="flex justify-between">
|
|
<span>{service.name}</span>
|
|
<span>£{service.price}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span>Date:</span>
|
|
<span class="font-medium">
|
|
{selectedDate?.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long'
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span>Time:</span>
|
|
<span class="font-medium">{selectedTime}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span>Estimated Duration:</span>
|
|
<span class="font-medium">{formattedTotalDuration}</span>
|
|
</div>
|
|
<Separator class="my-2" />
|
|
<div class="flex justify-between font-semibold">
|
|
<span>Total Cost:</span>
|
|
<span>£{getTotalPrice()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contact Form -->
|
|
{#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}
|
|
|
|
{#if currentStep === 4}
|
|
<!-- Square Payment Window -->
|
|
<!-- TODO: Add payment form -->
|
|
<div class="rounded-lg bg-white p-6">
|
|
<h2 class="mb-4 text-2xl font-semibold">Payment Confirmation</h2>
|
|
<p class="text-gray-600">
|
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus
|
|
tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices
|
|
diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci
|
|
nec nonummy molestie,
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|