Files
Crussell/frontend/src/lib/components/booking/BookingFlow.svelte
T

2192 lines
68 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 CharCounter from '$lib/components/ui/CharCounter.svelte';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
// salon app. All customers are physically in the UK and book UK appointment slots. We do NOT
// auto-adjust for international timezones — the slot time shown is the actual UK salon time.
// Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled manually by
// staff adjusting working hours; the app does not need timezone-aware scheduling logic.
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { 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 UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import {
extractBookedSlots,
getLunchProtectionForSlots,
type TimeSlot
} from '$lib/lunchProtection';
import type {
Service,
CustomerInfo,
WorkingHoursDay,
AvailableHoursDay,
BookingService,
BookingStatus
} from '$lib/types/booking';
// =============== State Management ===============
let currentStep = $state<number>(authStore.isAuthenticated ? 1 : 0);
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);
let idempotencyKey = $state<string>('');
// =============== Payment State ===============
let userDepositsRequired = $state<number>(0);
let hasActiveBooking = $state<boolean>(false);
let activeBookingCheckDone = $state<boolean>(false);
let paymentMethods = $state<
Array<{ id: string; brand: string; last4: string; expiry_month: number; expiry_year: number }>
>([]);
let paymentMethodsLoading = $state(false);
let selectedPaymentMethod = $state<string | null>(null);
let showNewCardForm = $state(false);
let isProcessingPayment = $state(false);
// New card form fields
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
// Payment flow state
let depositPaid = $state(false);
let showPaymentForm = $state(false);
let depositCardFormValid = $derived(
selectedPaymentMethod !== null ||
(showNewCardForm &&
newCardNumber.replace(/\s/g, '').length >= 13 &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3)
);
// Email existence check (guest flow only)
let emailChecking = $state(false);
let emailSuggestion = $state<string | null>(null);
let emailError = $state('');
let emailFormatValid = $derived(
!customerInfo.email ||
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(customerInfo.email)
);
let allGuestFieldsValid = $derived(
customerInfo.firstName &&
customerInfo.lastName &&
customerInfo.email &&
emailFormatValid &&
customerInfo.phone &&
isValidUKPhone(customerInfo.phone)
);
function validateEmailFormat(email: string) {
if (!email) {
emailError = '';
return;
}
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email)) {
emailError = 'Please enter a valid email address';
} else {
emailError = '';
}
}
async function checkEmailExists(email: string) {
if (!email || !/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email)) {
emailSuggestion = null;
return;
}
// Don't check unless ALL guest fields have valid input
if (!allGuestFieldsValid) {
emailSuggestion = null;
return;
}
emailChecking = true;
try {
// Send all 4 fields to enable the backend to match beyond just email
const params = new URLSearchParams({
email: email.toLowerCase(),
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
phone: toE164UK(customerInfo.phone) ?? customerInfo.phone
});
const resp = await fetch(`/api/check-email?${params}`);
if (resp.ok) {
const data = await resp.json();
emailSuggestion = data.suggestion ?? null;
}
} catch {
// Network error - silently ignore, don't block booking
emailSuggestion = null;
} finally {
emailChecking = false;
}
}
let emailCheckTimeout: ReturnType<typeof setTimeout> | null = null;
function debouncedEmailCheck() {
if (emailCheckTimeout) clearTimeout(emailCheckTimeout);
if (allGuestFieldsValid) {
emailCheckTimeout = setTimeout(() => checkEmailExists(customerInfo.email), 500);
}
}
// Confirmation state
let confirmedBooking = $state<{
id: string;
status: string;
start_time: string;
notes: string;
} | null>(null);
let showPayEarlyModal = $state(false);
// =============== Payment Functions ===============
async function fetchUserDepositsRequired() {
if (!authStore.isAuthenticated) {
userDepositsRequired = 0;
return;
}
try {
const response = await fetch('/api/user', {
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (response.ok) {
const user = await response.json();
userDepositsRequired = user.deposits_required ?? 0;
}
} catch (err) {
console.error('Failed to fetch user deposits status:', err);
userDepositsRequired = 0;
}
}
async function fetchActiveBookingStatus() {
if (!authStore.isAuthenticated) {
hasActiveBooking = false;
activeBookingCheckDone = true;
return;
}
activeBookingCheckDone = false;
try {
// Check for pending bookings
const pendingResp = await fetch('/api/bookings?status=pending&perPage=1', {
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (pendingResp.ok) {
const data = await pendingResp.json();
if (data.bookings && data.bookings.length > 0) {
hasActiveBooking = true;
activeBookingCheckDone = true;
return;
}
}
// Check for confirmed bookings
const confirmedResp = await fetch('/api/bookings?status=confirmed&perPage=1', {
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (confirmedResp.ok) {
const data = await confirmedResp.json();
hasActiveBooking = data.bookings && data.bookings.length > 0;
}
} catch (err) {
console.error('Failed to check active booking status:', err);
hasActiveBooking = false;
} finally {
activeBookingCheckDone = true;
}
}
async function fetchPaymentMethods() {
if (!authStore.isAuthenticated) {
paymentMethods = [];
return;
}
paymentMethodsLoading = true;
try {
const response = await fetch('/api/user/payment-methods', {
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (response.ok) {
const data = await response.json();
paymentMethods = data.payment_methods ?? [];
if (paymentMethods.length > 0 && !selectedPaymentMethod) {
const defaultCard =
paymentMethods.find((m) => 'is_default' in m && m.is_default) ?? paymentMethods[0];
selectedPaymentMethod = defaultCard.id;
}
} else {
paymentMethods = [];
}
} catch (err) {
console.error('Failed to fetch payment methods:', err);
paymentMethods = [];
} finally {
paymentMethodsLoading = false;
}
}
function calculateDepositRequired(): boolean {
if (!selectedDate || !selectedTime) return false;
// Deposit required if user has deposits_required > 0 AND appointment is within 24 hours
const [hours, minutes] = selectedTime.split(':').map(Number);
const appointmentDate = selectedDate.toDate(getLocalTimeZone());
appointmentDate.setHours(hours, minutes, 0, 0);
const now = new SvelteDate();
const hoursUntilAppointment = (appointmentDate.getTime() - now.getTime()) / (1000 * 60 * 60);
return userDepositsRequired > 0 && hoursUntilAppointment <= 24;
}
function calculateDepositAmount(): number {
return Math.round(getTotalPrice() * 0.2 * 100) / 100;
}
async function processPayment(amount: number) {
isProcessingPayment = true;
try {
// TODO: Integrate Square SDK for actual payment processing
// For now, simulate successful payment after a delay
await new Promise((resolve) => setTimeout(resolve, 1500));
toast.success('Payment successful!');
depositPaid = true;
showPaymentForm = false;
nextStep();
} catch (err) {
toast.error('Payment failed. Please try again.');
} finally {
isProcessingPayment = false;
}
}
function handlePayNow() {
showPaymentForm = true;
if (authStore.isAuthenticated) {
fetchPaymentMethods();
}
}
function handleSkipPayment() {
depositPaid = false;
nextStep();
}
function formatCardExpiry(month: number, year: number): string {
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
}
function formatDepositCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function formatDepositExpiry(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
// Fetch user deposit and active booking status when step 1 is reached
$effect(() => {
if (currentStep === 1 && authStore.isAuthenticated) {
fetchUserDepositsRequired();
fetchActiveBookingStatus();
}
});
// =============== Slot Reservation System ===============
let reservationId = $state<string | null>(null);
let reservationExpiresAt = $state<Date | null>(null);
let reservationCountdown = $state<string>('');
let reservationExpired = $state(false);
let isReserving = $state(false);
// =============== Slot Reservation Functions ===============
async function reserveSlot() {
isReserving = true;
try {
if (!selectedDate || !selectedTime) {
toast.error('Please select a date and time');
return false;
}
const [hours, minutes] = selectedTime.split(':').map(Number);
const bookingDate = selectedDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours, minutes, 0, 0);
const startTimeISO = bookingDate.toISOString();
const serviceIds = selectedServices.map((s) => s.id);
const response = await fetch('/api/bookings/reserve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_time: startTimeISO, service_ids: serviceIds })
});
if (!response.ok) {
const errorText = await response.text();
if (response.status === 429) {
toast.error('Too many active reservations. Please wait or log in.');
} else if (response.status === 409) {
toast.error('This time slot is no longer available. Please choose a different time.');
await refreshAvailableHours();
} else {
toast.error('Failed to reserve slot. Please try again.');
}
return false;
}
const data = await response.json();
reservationId = data.id;
reservationExpiresAt = new Date(data.expires_at);
reservationExpired = false;
startCountdown();
return true;
} catch (error) {
toast.error('Network error while reserving slot.');
return false;
} finally {
isReserving = false;
}
}
function startCountdown() {
if (!reservationExpiresAt) return;
const updateCountdown = () => {
if (!reservationExpiresAt) {
reservationCountdown = '';
return;
}
const now = new SvelteDate();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
reservationCountdown = '00:00';
reservationExpired = true;
reservationId = null;
reservationExpiresAt = null;
toast.error('Your reservation has expired. Please select a new time slot.');
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
reservationCountdown = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
};
updateCountdown();
const interval = setInterval(() => {
if (reservationExpired) {
clearInterval(interval);
return;
}
updateCountdown();
}, 1000);
}
async function refreshAvailableHours() {
if (!selectedDate) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
delete availableHoursCache[monthKey];
await fetchHoursForMonth(selectedDate);
}
// =============== 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();
// Sort: valid patch tests first (by name), then grayed out (by name)
const valid: Service[] = [];
const grayedOut: Service[] = [];
for (const service of data) {
if (service.patch_test_status === 'required' || service.patch_test_status === 'expired') {
grayedOut.push(service);
} else {
valid.push(service);
}
}
// Sort each group alphabetically
valid.sort((a, b) => a.name.localeCompare(b.name));
grayedOut.sort((a, b) => a.name.localeCompare(b.name));
// Combine: valid first, then grayed out
services = [...valid, ...grayedOut];
} 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 & 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);
let workingHoursCache: Record<
string,
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
> = {};
let availableHoursCache: Record<
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);
let userNavigatedCalendar = $state(false);
let bookingFlowAutoSelectDone = $state(false);
$effect(() => {
fetchServices();
});
// Track which months are currently being fetched (prevents duplicate requests)
let loadingMonths: Record<string, boolean> = {};
// Preload current + next month on first render; subsequent months fetched individually
let initialLoadDone = $state(false);
$effect(() => {
if (!initialLoadDone) {
// Pre-seed cache for current + next month
for (let i = 0; i < 2; i++) {
let mYear = placeholder.year;
let mMonth = placeholder.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
if (!(key in workingHoursCache)) {
workingHoursCache[key] = null as unknown as Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
>;
availableHoursCache[key] = null as unknown as Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
>;
loadingMonths[key] = true;
}
}
fetchHoursRange(placeholder, 2);
initialLoadDone = true;
}
});
// Safety net: fetch silently when navigating to an uncached month
// (uses skipLoadingFlags=true to prevent layout shift / scroll snap)
$effect(() => {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (initialLoadDone && !(monthKey in workingHoursCache)) {
fetchHoursForMonth(placeholder, true);
}
});
// Data-driven auto-selection: auto-select the first available date when data loads
$effect(() => {
if (
workingHours &&
availableHours &&
!selectedDate &&
selectedServices.length > 0 &&
!userNavigatedCalendar &&
!bookingFlowAutoSelectDone
) {
bookingFlowAutoSelectDone = true;
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 (workingHours[dateStr]?.isOpen) {
const calDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
nextDate.getDate()
);
if (!isDateUnavailable(calDate)) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(nextDate.getFullYear(), nextDate.getMonth() + 1, 1);
}
return;
}
}
}
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
selectedDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
}
}
});
async function fetchHoursRange(startDate: CalendarDate, months: number) {
// Calculate end month manually (CalendarDate is immutable)
let endYear = startDate.year;
let endMonth = startDate.month + months - 1;
while (endMonth > 12) {
endMonth -= 12;
endYear++;
}
const endMonthDate = new CalendarDate(endYear, endMonth, 1);
const daysInEndMonth = endMonthDate.calendar.getDaysInMonth(endMonthDate);
const startStr = startDate.toString();
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
loadingWorkingHours = true;
loadingAvailableHours = true;
try {
const [whRes, ahRes] = await Promise.all([
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`)
]);
if (!whRes.ok || !ahRes.ok) {
throw new Error(`HTTP error! wh: ${whRes.status}, ah: ${ahRes.status}`);
}
const whData: Array<WorkingHoursDay> = await whRes.json();
const ahData: Array<AvailableHoursDay> = await ahRes.json();
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => {
whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime };
});
const ahMap: Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
> = {};
ahData.forEach((d) => {
ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots };
});
// Cache by month key
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
workingHoursCache[key] = whMap;
availableHoursCache[key] = ahMap;
delete loadingMonths[key];
}
// MERGE instead of replace — preserves data from previously loaded months
workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
} catch (error) {
console.error('Failed to fetch hours:', error);
// Clean up loadingMonths for the range
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
delete loadingMonths[key];
}
if (!selectedDate) {
selectedDate = minDate;
}
} finally {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
async function fetchHoursForMonth(date: CalendarDate, skipLoadingFlags = false) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
// Only use cache if the value is truthy (not a pre-seeded null placeholder)
if (workingHoursCache[monthKey] && availableHoursCache[monthKey]) {
// MERGE instead of replace — preserves data from other loaded months
workingHours = { ...workingHours, ...workingHoursCache[monthKey] };
availableHours = { ...availableHours, ...availableHoursCache[monthKey] };
return;
}
// Prevent duplicate concurrent requests for the same month
if (loadingMonths[monthKey]) return;
loadingMonths[monthKey] = true;
if (!skipLoadingFlags) {
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[monthKey] = workingHoursMap;
workingHours = { ...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[monthKey] = availableHoursMap;
availableHours = { ...availableHours, ...availableHoursMap };
} catch (error) {
console.error('Failed to fetch hours:', error);
if (!selectedDate) {
selectedDate = minDate;
}
} finally {
delete loadingMonths[monthKey];
if (!skipLoadingFlags) {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
}
// =============== 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 = Math.ceil((startHour * 60 + startMinute) / 15) * 15;
const endTotalMinutes = endHour * 60 + endMinute;
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
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,
lunchProtectionMap: Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
> = new Map()
): 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 = Math.ceil((currentMinutes + 60) / 15) * 15;
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) && !lunchProtectionMap.get(timeStr)?.isBlocked;
if (isAvailable) {
if (currentUnavailableStart !== null) {
const groupEndTime = calculatePreviousTime(timeStr);
const unavailableStartTime = currentUnavailableStart || lastAvailableEndTime;
if (
unavailableStartTime &&
timeToMinutes(unavailableStartTime) < timeToMinutes(groupEndTime)
) {
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;
// No available hours data for this date = data not loaded = unavailable
if (!availableHours?.[dateStr]) return true;
// API returned empty slots = no availability at all
if (!availableHours[dateStr].slots || availableHours[dateStr].slots.length === 0) return true;
if (selectedServices.length === 0) {
return false;
}
const duration = getTotalDuration();
const availableSlots = generateAvailableTimeSlots(duration, date);
if (availableSlots.length === 0) return true;
const dayAvailableHours = availableHours[dateStr];
// dayAvailableHours.slots is already checked above, but keep this guard for safety
if (dayAvailableHours.slots) {
const existingBookings = extractBookedSlots(
dayHours.startTime,
dayHours.endTime,
dayAvailableHours.slots
);
const lunchProtection = getLunchProtectionForSlots(
dayHours.startTime,
dayHours.endTime,
existingBookings,
duration,
15,
false
);
const validSlots = availableSlots.filter((t) => !lunchProtection.get(t)?.isBlocked);
if (validSlots.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')}`;
delete availableHoursCache[monthKey];
fetchHoursForMonth(selectedDate);
}
// Reset date selection when services change so auto-select can re-run
bookingFlowAutoSelectDone = false;
selectedDate = undefined;
selectedTime = null;
}
// =============== Lunch Protection for Rendering ===============
function getLunchProtectionStatus() {
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
return new Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
>();
}
const dateStr = selectedDate.toString();
const dayWH = workingHours[dateStr];
const dayAH = availableHours[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) return new Map();
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
return getLunchProtectionForSlots(
dayWH.startTime,
dayWH.endTime,
existingBookings,
getTotalDuration(),
15,
false
);
}
// Select a time slot with server-side re-validation
async function selectTimeWithValidation(time: string) {
selectedTime = time;
await refreshAndValidateSlot();
}
// Re-fetch available hours silently and check if selectedTime is still available
// Uses skipLoadingFlags=true to prevent UI judder (loading spinners hide DatePicker/TimeSlotPicker)
async function refreshAndValidateSlot() {
if (!selectedDate || !selectedTime) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
delete availableHoursCache[monthKey];
await fetchHoursForMonth(selectedDate, true);
const dateStr = selectedDate.toString();
const dayAvailable = availableHours?.[dateStr]?.slots;
if (!dayAvailable || dayAvailable.length === 0) {
toast.error('Sorry, this slot is no longer available. Please choose a different time.');
selectedTime = null;
return false;
}
const duration = getTotalDuration();
const [selHour, selMinute] = selectedTime.split(':').map(Number);
const selStart = selHour * 60 + selMinute;
const selEnd = selStart + duration;
const stillAvailable = dayAvailable.some((slot) => {
const [sH, sM] = slot.startTime.split(':').map(Number);
const [eH, eM] = slot.endTime.split(':').map(Number);
return selStart >= sH * 60 + sM && selEnd <= eH * 60 + eM;
});
if (!stillAvailable) {
toast.error('Sorry, this slot was just taken. Please choose a different time.');
selectedTime = null;
return false;
}
return true;
}
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 lunchProtectionMap = $derived(getLunchProtectionStatus());
const groupedTimeSlots = $derived(
currentStep === 2 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionMap)
: []
);
const formattedSelectedDate = $derived(
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
);
let depositRequired = $derived(calculateDepositRequired());
let totalSteps = $derived(depositRequired ? 5 : 4);
let stepLabels = $derived(
authStore.isAuthenticated
? depositRequired
? ['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
: ['Service', 'Date & Time', 'Details', 'Confirmation']
: ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
);
// =============== Navigation ===============
async function nextStep() {
if (currentStep === 0) {
currentStep = 1;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
return;
}
// Step 2 -> Step 3: Re-validate slot, then reserve
if (currentStep === 2) {
const slotStillFree = await refreshAndValidateSlot();
if (!slotStillFree) return;
const reserved = await reserveSlot();
if (!reserved) return;
}
// Step 3 -> Step 4 (if deposit required) or Step 4 (confirmation, if no deposit)
if (currentStep === 3) {
if (calculateDepositRequired()) {
currentStep = 4;
} else {
await submitAndProceed();
}
return;
}
// Step 4: if deposit required, this is payment step -> submit booking -> step 5
// Step 4: if no deposit, this is confirmation step -> nothing
if (currentStep === 4 && calculateDepositRequired()) {
await submitAndProceed();
return;
}
if (currentStep < (depositRequired ? 5 : 4)) {
currentStep++;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
}
}
async function submitAndProceed() {
isSubmitting = true;
try {
if (!idempotencyKey) {
// Generate UUID v4 manually for environments where crypto.randomUUID() is unavailable
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
array[6] = (array[6] & 0x0f) | 0x40; // version 4
array[8] = (array[8] & 0x3f) | 0x80; // variant 1
idempotencyKey = [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
if (!selectedDate || !selectedTime) {
toast.error('Please select a date and time');
isSubmitting = false;
return;
}
const [hours, minutes] = selectedTime.split(':').map(Number);
const bookingDate = selectedDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours, minutes, 0, 0);
const startTimeISO = bookingDate.toISOString();
const serviceIds = selectedServices.map((s) => s.id);
let guestUserId: string | null = null;
if (!authStore.isAuthenticated) {
const guestResponse = await fetch('/api/users/guest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
email: customerInfo.email,
phone: toE164UK(customerInfo.phone) ?? customerInfo.phone
})
});
if (!guestResponse.ok) {
const errorText = await guestResponse.text();
if (guestResponse.status === 409) {
toast.error('Email already registered — please log in to book.');
} else {
toast.error('Failed to create guest account. Please try again.');
}
isSubmitting = false;
return;
}
const guestData = await guestResponse.json();
guestUserId = guestData.id;
}
const requestBody: Record<string, unknown> = {
service_ids: serviceIds,
start_time: startTimeISO,
notes: customerInfo.specialRequests || null
};
if (guestUserId) {
requestBody.user_id = guestUserId;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
};
if (authStore.currentToken) {
headers['Authorization'] = `Bearer ${authStore.currentToken}`;
}
const response = await fetch('/api/bookings', {
method: 'POST',
headers,
body: JSON.stringify(requestBody)
});
if (response.ok) {
const booking = await response.json();
confirmedBooking = {
id: booking.id,
status: booking.status,
start_time: booking.start_time,
notes: booking.notes || ''
};
currentStep = depositRequired ? 5 : 4;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
} else {
const errorText = await response.text();
let errorMessage = errorText.trim();
if (!errorMessage) {
errorMessage = 'Failed to submit booking. Please try again.';
}
// Backend may return plain text or JSON
try {
const errorData = JSON.parse(errorText);
if (errorData.error) errorMessage = errorData.error;
} catch {
/* use raw text from backend */
}
if (response.status === 409) {
if (errorMessage.includes('active booking') || errorMessage.includes('already have')) {
toast.error(
'You already have an active booking. Please complete or cancel it before creating a new one.'
);
} else {
toast.error('This time slot is no longer available. Please choose a different time.');
}
} else if (errorMessage.includes('patch test') || errorMessage.includes('Patch test')) {
toast.error(errorMessage + ' Please complete a patch test first.');
} else if (
errorMessage.includes('48 hours') ||
errorMessage.includes('48h') ||
errorMessage.includes('advance')
) {
toast.error(errorMessage);
} else if (errorMessage.includes('deposit') || errorMessage.includes('Deposit')) {
toast.error(errorMessage);
} else if (response.status === 400) {
toast.error(errorMessage);
} else {
toast.error('Failed to submit booking: ' + errorMessage);
}
console.error('Booking submission failed:', response.status, errorText);
}
} catch (error) {
console.error('Booking submission error:', error);
toast.error('Network error. Please check your connection and try again.');
} finally {
isSubmitting = false;
}
}
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 &&
emailFormatValid &&
customerInfo.phone &&
isValidUKPhone(customerInfo.phone) &&
!emailSuggestion
)) && !reservationExpired
);
const canProceedStep4 = $derived(true);
</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}
steps={stepLabels}
startAt={authStore.isAuthenticated ? 1 : 0}
className={currentStep === 0 ? 'md:hidden' : ''}
/>
{#if currentStep === 0}
<Card.Root class="border-fuchsia-200 bg-fuchsia-50">
<Card.Header class="text-center">
<Card.Title>Welcome</Card.Title>
<Card.Description>Log in for the best booking experience</Card.Description>
</Card.Header>
<Card.Content class="p-6 text-center">
<p class="mb-4 text-sm text-muted-foreground">
Guest checkout does not receive loyalty stamps or seasonal discounts.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4">
<Button
onclick={() => goto(resolve('/login'))}
variant="outline"
class="border-fuchsia-200 hover:bg-fuchsia-100"
>
Log In
</Button>
<Button onclick={nextStep}>Continue as Guest</Button>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- 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">
<!-- Warning: Active booking limit for deposit-owing users -->
{#if authStore.isAuthenticated && userDepositsRequired > 0 && hasActiveBooking}
<div
class="rounded-lg border border-amber-200/60 bg-gradient-to-r from-amber-50 to-amber-100/50 p-4"
>
<div class="flex gap-3">
<div class="flex-shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-amber-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="space-y-1 text-sm text-amber-900">
<h4 class="font-semibold text-amber-800">Booking Limit While Deposits Are Owed</h4>
{#if userDepositsRequired === 1}
<p>
You have <span class="font-medium">1 deposit remaining</span>. After this
deposit is paid, you'll be able to book in advance again with no further
deposits required.
</p>
{:else}
<p>
You currently owe <span class="font-medium"
>{userDepositsRequired} deposits</span
>. You can only have <span class="font-medium">1 upcoming booking</span> at a time
while deposits are outstanding.
</p>
<p>
Once your current booking is complete and paid for, you'll be able to book
again.
</p>
{/if}
</div>
</div>
</div>
{/if}
<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={!authStore.isAuthenticated}
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;
userNavigatedCalendar = true;
}}
/>
{/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) => {
selectTimeWithValidation(time);
}}
/>
{/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">
{#if reservationExpired}
<div class="rounded-lg bg-red-50 p-4 text-center">
<p class="text-red-600">Reservation expired — please go back and select a new time</p>
</div>
{:else}
<div class="rounded-lg bg-blue-50 p-4 text-center">
<p class="text-blue-700">
Your slot is reserved for {reservationCountdown} — complete your booking before time expires
</p>
</div>
{/if}
<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 and any
possible seasonal discounts. 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"
onblur={debouncedEmailCheck}
/>
</div>
<div class="space-y-2">
<Label for="lastName">Last Name *</Label>
<Input
id="lastName"
bind:value={customerInfo.lastName}
placeholder="Enter your last name"
onblur={debouncedEmailCheck}
/>
</div>
<div class="space-y-2">
<Label for="email">Email *</Label>
<Input
id="email"
type="email"
bind:value={customerInfo.email}
placeholder="Enter your email"
onblur={() => {
validateEmailFormat(customerInfo.email);
debouncedEmailCheck();
}}
oninput={() => {
emailSuggestion = null;
emailError = '';
debouncedEmailCheck();
}}
/>
{#if emailError}
<span class="text-xs font-medium text-red-500">{emailError}</span>
{/if}
{#if emailChecking}
<span class="text-xs text-gray-500">Checking...</span>
{/if}
</div>
<div class="space-y-2">
<Label for="phone">Phone Number *</Label>
<PhoneInput
id="phone"
bind:value={customerInfo.phone}
placeholder="07123 456789"
onerrorchange={(err) => {
if (!err) debouncedEmailCheck();
}}
/>
</div>
</div>
{#if emailSuggestion === 'login'}
<p class="text-xs font-medium text-red-500">
This email belongs to a registered user. Please
<a href="/login" class="underline hover:text-red-800">log in</a>
instead to access your bookings and rewards.
</p>
{:else if emailSuggestion === 'check'}
<p class="text-xs font-medium text-amber-600">
This email might belong to an existing account. Please double-check or
<a href="/login" class="underline hover:text-amber-800">log in</a>.
</p>
{/if}
{/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}
/>
<CharCounter text={customerInfo.specialRequests} />
</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>
<p class="mt-2 text-xs text-gray-500">
<strong>Cancellation Policy:</strong> Free cancellation up to 24 hours before your appointment.
Cancellations within 24 hours may incur a deposit penalty.
</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"
>
{#if reservationExpired}
Reservation Expired
{:else if depositRequired}
Next: Payment
{:else}
Confirm Booking
{/if}
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 4: Deposit Payment (only shown if deposit required) -->
{#if currentStep === 4 && depositRequired}
<Card.Root>
<Card.Header>
<Card.Title>Pay Your Deposit</Card.Title>
<Card.Description>
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
your appointment.
</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}
/>
{#if !showPaymentForm}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-6">
<h3 class="mb-2 text-lg font-semibold text-amber-800">Deposit Required</h3>
<p class="mb-4 text-amber-700">
Due to your booking being within 24 hours, a deposit is required.
</p>
<div class="flex flex-wrap gap-3">
<Button
onclick={() => {
showPaymentForm = true;
}}
class="bg-primary text-primary-foreground"
>
Pay Deposit Now
</Button>
<Button variant="outline" onclick={nextStep}>Pay at Appointment</Button>
</div>
</div>
{:else}
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
{:else if paymentMethods.length > 0}
<div class="mb-6">
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
<div class="space-y-3">
{#each paymentMethods as method (method.id)}
<div
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
method.id
? 'border-primary bg-primary/5'
: ''}"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
>
{method.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {method.last4}</span>
<span class="ml-2 text-gray-500">
{formatCardExpiry(method.expiry_month, method.expiry_year)}
</span>
</div>
</div>
<Button
size="sm"
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
}}
>
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
</Button>
</div>
{/each}
</div>
</div>
{/if}
{#if !showNewCardForm}
<Button
variant="outline"
class="mb-6"
onclick={() => {
showNewCardForm = true;
selectedPaymentMethod = null;
}}
>
+ Add new card
</Button>
{/if}
{/if}
{#if showNewCardForm || !authStore.isAuthenticated}
<div class="mb-6 rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-4 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-4">
<div class="space-y-2">
<Label for="cardNumber">Card Number</Label>
<Input
id="cardNumber"
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={(e) =>
(newCardNumber = formatDepositCardNumber(
(e.target as HTMLInputElement).value
))}
placeholder="1234 5678 9012 3456"
maxlength={19}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="cardExpiry">Expiry (MM/YY)</Label>
<Input
id="cardExpiry"
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={(e) =>
(newCardExpiry = formatDepositExpiry(
(e.target as HTMLInputElement).value
))}
placeholder="MM/YY"
maxlength={5}
/>
</div>
<div class="space-y-2">
<Label for="cardCVC">CVC</Label>
<Input
id="cardCVC"
type="text"
inputmode="numeric"
bind:value={newCardCVC}
placeholder="123"
maxlength={4}
/>
</div>
</div>
{#if authStore.isAuthenticated}
<div class="flex items-center gap-2">
<Checkbox id="saveCard" bind:checked={saveCardForFuture} />
<Label for="saveCard" class="text-sm font-normal">
Save card for next time
</Label>
</div>
{/if}
</div>
</div>
{/if}
<div class="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
onclick={() => {
showPaymentForm = false;
selectedPaymentMethod = null;
showNewCardForm = false;
}}
>
Cancel
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment ? 'Processing...' : `Pay £${calculateDepositAmount()}`}
</Button>
</div>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={prevStep}>Back</Button>
<Button
disabled={isSubmitting}
onclick={nextStep}
class="bg-primary text-primary-foreground"
>
{isSubmitting ? 'Processing...' : 'Continue'}
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 5: Confirmation (or Step 4 if no deposit required) -->
{#if currentStep === 5 || (currentStep === 4 && !depositRequired)}
{#if confirmedBooking}
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
{@const dateStr = bookingDate.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
{@const timeStr = bookingDate.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
<Card.Root class="border-emerald-200">
<Card.Header class="text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full {isRequested
? 'bg-amber-100'
: 'bg-emerald-100'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 {isRequested ? 'text-amber-600' : 'text-emerald-600'}"
viewBox="0 0 20 20"
fill="currentColor"
>
<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"
/>
</svg>
</div>
<Card.Title class="text-2xl font-bold"
>{isRequested ? 'Booking Requested' : 'Booking Confirmed'}</Card.Title
>
<Card.Description class="mt-2 text-base">
{isRequested
? "Your booking has been submitted and is awaiting approval. We'll notify you once it's confirmed."
: 'Your appointment has been booked successfully.'}
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium text-gray-500">Confirmation Number</span>
<span class="font-mono text-lg font-bold text-gray-900">{confirmedBooking.id}</span>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Date</div>
<div class="font-medium">{dateStr}</div>
</div>
<div>
<div class="text-xs text-gray-500">Time</div>
<div class="font-medium">{timeStr}</div>
</div>
<div>
<div class="text-xs text-gray-500">Duration</div>
<div class="font-medium">{getTotalDuration()} minutes (estimated)</div>
</div>
<div>
<div class="text-xs text-gray-500">Status</div>
<div class="font-medium">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {isRequested
? 'bg-amber-100 text-amber-800'
: 'bg-emerald-100 text-emerald-800'}"
>
{isRequested ? 'Pending Approval' : 'Confirmed'}
</span>
</div>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
<h4 class="mb-3 text-sm font-semibold text-gray-600 uppercase">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 class="text-gray-600"
>{service.duration_minutes} min • £{service.price}</span
>
</div>
{/each}
<div class="border-t pt-2">
<div class="flex justify-between font-semibold">
<span>Total (estimated)</span>
<span>£{getTotalPrice()}</span>
</div>
</div>
</div>
</div>
{#if isRequested}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="text-sm text-amber-800">
<strong>Please note:</strong> Because you included special requests, the cost and
duration shown are estimates. We may adjust these after reviewing your requirements.
You'll receive
{authStore.isAuthenticated ? ' a notification' : ' an email'} once your booking is approved.
</p>
</div>
{/if}
{#if !calculateDepositRequired() && authStore.isAuthenticated && authStore.currentUser?.role !== 'admin' && authStore.currentUser?.role !== 'guest'}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
<h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3>
<p class="mb-4 text-gray-600">
You can pay when you arrive, or pay ahead of time to speed things up.
</p>
<Button
onclick={() => (showPayEarlyModal = true)}
class="bg-emerald-600 text-white hover:bg-emerald-700"
>
Pay Early
</Button>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-center">
<Button
onclick={() => (window.location.href = authStore.isAuthenticated ? '/schedule' : '/')}
class="w-full"
>
{authStore.isAuthenticated ? 'View My Bookings' : 'Return Home'}
</Button>
</Card.Footer>
</Card.Root>
{:else}
<Card.Root>
<Card.Content class="flex items-center justify-center p-12">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-gray-600">Confirming your booking...</p>
</div>
</Card.Content>
</Card.Root>
{/if}
{/if}
{#if showPayEarlyModal && confirmedBooking}
{@const booking = confirmedBooking}
<UserPaymentModal
booking={{
id: booking.id,
status: booking.status as BookingStatus,
start_time: booking.start_time,
notes: booking.notes,
services: selectedServices.map((s) => ({
booking_id: booking.id,
service_id: s.id,
service_name: s.name,
price: s.price,
duration_minutes: s.duration_minutes
})) as BookingService[],
total_amount: getTotalPrice(),
amount_paid: 0,
amount_due: getTotalPrice(),
deposit_required: false,
deposit_paid: true,
payments: [],
duration_minutes: getTotalDuration(),
created_at: new SvelteDate().toISOString(),
updated_at: new SvelteDate().toISOString()
}}
onClose={() => (showPayEarlyModal = false)}
onComplete={() => {
showPayEarlyModal = false;
}}
canSaveCards={authStore.currentUser?.role === 'verified_email' ||
authStore.currentUser?.role === 'affiliate'}
/>
{/if}
</div>