Files
Crussell/frontend/src/routes/book/+page.svelte
T

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>