Files
Crussell/frontend/src/lib/components/admin/BookingCreateModal.svelte
T
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00

1761 lines
58 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate, SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
import { range } from '$lib/utils/format';
// UI Components
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
// Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs
// but keeping the Label and other components.
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { Skeleton } from '$lib/components/ui/skeleton';
import CharCounter from '$lib/components/ui/CharCounter.svelte';
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
// Booking Components
import BookingActions from '$lib/components/booking/BookingActions.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import TimeSlotList from '$lib/components/booking/TimeSlotList.svelte';
import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte';
// Types
import type {
Service,
CustomService,
WorkingHoursDay,
AvailableHoursDay
} from '$lib/types/booking';
import {
buildLunchProtection,
generateAvailableTimeSlots,
generateGroupedTimeSlots,
formatTime,
formatLocalDateTime,
calculateEndTime,
timeToMinutes,
getDayWithOrdinal,
parseWallClockDate,
getLondonTodayCalendarDate,
type DayHours,
type DayAvailability
} from '$lib/utils/timeSlots';
// =============== Props ===============
interface Props {
open: boolean;
onBookingCreated?: () => void;
}
let { open = $bindable(), onBookingCreated }: Props = $props();
// =============== State ===============
let currentStep = $state(1);
// Step 1: Customer Selection
let userType = $state<'member' | 'guest'>('member');
let userQuery = $state('');
// Updated type to include account_role for filtering
let users = $state<
Array<{
id: string;
fullName: string;
email?: string;
phone?: string;
account_role: string;
previousFirstName?: string | null;
previousLastName?: string | null;
}>
>([]);
let selectedUserId = $state<string | null>(null);
let guestName = $state('');
let guestPhone = $state('');
let guestPhoneError = $state('');
let loadingUsers = $state(false);
// Step 2: Services
let services = $state<Service[]>([]);
let selectedServices = $state<Service[]>([]);
let loadingServices = $state(true);
// Custom Services
let customServices = $state<Array<CustomService & { is_custom: boolean }>>([]);
let customSearchQuery = $state('');
let loadingCustomServices = $state(false);
let showCustomCreateForm = $state(false);
let newCustomService = $state({
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
});
let creatingCustomService = $state(false);
let customServiceErrors = $state<Record<string, string>>({});
const durationOptions = Array.from({ length: 32 }, (_, i) => (i + 1) * 15);
function validateCsName(name: unknown): string {
const n = name === null || name === undefined ? '' : String(name);
if (!n.trim()) return 'Name is required';
return '';
}
function validateCsPrice(price: unknown): string {
const p = price === null || price === undefined ? '' : String(price);
if (!p.trim()) return 'Price is required';
const num = parseFloat(p);
if (isNaN(num) || num <= 0) return 'Must be greater than 0';
return '';
}
function validateCsDuration(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Duration is required';
const num = parseInt(v);
if (isNaN(num) || num <= 0) return 'Must be greater than 0';
return '';
}
function validateCsMinimumAge(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Required';
const num = parseInt(v);
if (isNaN(num) || num < 0 || num > 100) return 'Must be between 0 and 100';
return '';
}
function validateCsAll() {
customServiceErrors = {
name: validateCsName(newCustomService.name),
price: validateCsPrice(newCustomService.price),
duration_minutes: validateCsDuration(newCustomService.duration_minutes),
minimum_age_required: validateCsMinimumAge(newCustomService.minimum_age_required)
};
}
const isCustomFormValid = $derived(
(newCustomService.name ?? '').trim() !== '' &&
!customServiceErrors.name &&
!customServiceErrors.price &&
!customServiceErrors.duration_minutes &&
!customServiceErrors.minimum_age_required
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function toggleCustomForm(show: boolean) {
showCustomCreateForm = show;
if (show) {
newCustomService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
requestAnimationFrame(() => {
const modalContent = document.querySelector('[data-custom-form-container]');
if (modalContent) {
modalContent.scrollTop = modalContent.scrollHeight;
}
});
}
}
// Step 3: Service Overrides & Notes
let notes = $state('');
let serviceOverrides = $state<
Record<
string,
{ price: string; duration: string; originalPrice: number; originalDuration: number }
>
>({});
// Step 4: Date & Time
let placeholder = $state<CalendarDate>(getLondonTodayCalendarDate());
let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null);
let workingHours = $state<Record<string, DayHours> | null>(null);
let availableHours = $state<Record<string, DayAvailability> | null>(null);
let loadingAvailableHours = $state(false);
let hoursRangeGeneration = $state(0);
let hoursMonthGeneration = $state(0);
let outOfHours = $state(false);
// Keep the last-known normal (non-out-of-hours) working hours for per-slot styling
let normalWorkingHours = $state<Record<string, DayHours> | null>(null);
// Clear caches and force re-fetch when out-of-hours toggled
let prevOutOfHours = $state(false);
$effect(() => {
if (outOfHours !== prevOutOfHours) {
prevOutOfHours = outOfHours;
// Save normal hours BEFORE clearing, so we can show which slots are genuinely out-of-hours
if (outOfHours && workingHours) {
normalWorkingHours = { ...workingHours };
} else if (!outOfHours) {
normalWorkingHours = null;
}
// Clear data and caches
workingHoursCache = {};
availableHoursCache = {};
workingHours = null;
availableHours = null;
selectedTime = null;
// Directly re-fetch since caches aren't reactive (plain `let` not `$state`)
// so the safety net effect won't detect the cache deletion
if (currentStep === 4 && placeholder) {
fetchHoursRange(placeholder, 2);
}
}
});
// Date Boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
maxDate.getMonth() + 1,
maxDate.getDate()
);
let submitting = $state(false);
// =============== Reservation State ===============
let reservationId = $state<string | null>(null);
let reservationExpiresAt = $state<Date | null>(null);
let reservationCountdown = $state<string>('');
let isReserving = $state(false);
// =============== Cache ===============
let workingHoursCache: Record<string, Record<string, DayHours>> = {};
let availableHoursCache: Record<string, Record<string, DayAvailability>> = {};
let loadingMonthKeys: Set<string> = new SvelteSet();
// =============== Derived Helpers ===============
function getTotalDuration() {
return selectedServices.reduce((total, service) => {
const override = serviceOverrides[service.id];
const duration =
override && override.duration ? parseInt(override.duration) : service.duration_minutes;
return total + (isNaN(duration) ? 0 : duration);
}, 0);
}
function getTotalPrice() {
return selectedServices.reduce((total, service) => {
const override = serviceOverrides[service.id];
const price = override && override.price ? parseFloat(override.price) : service.price;
return total + (isNaN(price) ? 0 : price);
}, 0);
}
// =============== Lunch Protection ===============
const lunchProtection = $derived(
selectedDate && selectedServices.length > 0 && !outOfHours
? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true)
: new Map()
);
// =============== Debug: Slot generation ===============
/** Check if a slot time falls outside the normal (non-out-of-hours) business hours */
function isSlotOutOfHours(
dateStr: string,
timeStr: string,
duration: number,
normalWH: Record<string, DayHours> | null
): boolean {
if (!normalWH) return false;
const normalDay = normalWH[dateStr];
if (!normalDay) return false;
// Day is normally closed → ALL slots are out-of-hours
if (!normalDay.isOpen) return true;
// Slot starts before normal opening
if (timeToMinutes(timeStr) < timeToMinutes(normalDay.startTime)) return true;
// Slot ends after normal closing
if (timeToMinutes(timeStr) + duration > timeToMinutes(normalDay.endTime)) return true;
return false;
}
const groupedTimeSlots = $derived.by(() => {
const base =
currentStep === 4 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(
selectedDate,
workingHours,
availableHours,
getTotalDuration(),
lunchProtection
)
: [];
if (!outOfHours || !normalWorkingHours || !selectedDate) return base;
const dateStr = selectedDate.toString();
const duration = getTotalDuration();
return base.map((slot) => {
if (slot.type === 'available') {
return {
...slot,
outOfHours: isSlotOutOfHours(dateStr, slot.startTime, duration, normalWorkingHours)
};
}
return slot;
});
});
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours === 0) return `${remainingMinutes} minutes`;
if (remainingMinutes === 0) return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
}
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
const formattedSelectedDate = $derived(
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
);
const canProceedStep1 = $derived(
userType === 'member'
? !!selectedUserId
: !!(guestName.trim() && guestPhone.trim() && isValidUKPhone(guestPhone))
);
const canProceedStep2 = $derived(selectedServices.length > 0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const canProceedStep3 = $derived(true); // Overrides are optional
const canProceedStep4 = $derived(!!(selectedDate && selectedTime));
/** Whether the currently selected time slot is out-of-hours */
const selectedTimeOutOfHours = $derived(
outOfHours && selectedDate && selectedTime && normalWorkingHours
? isSlotOutOfHours(
selectedDate.toString(),
selectedTime,
getTotalDuration(),
normalWorkingHours
)
: false
);
// =============== Effects ===============
let wasOpen = false;
let userNavigatedCalendar = $state(false);
$effect(() => {
if (open && !wasOpen) {
resetState();
fetchServices();
fetchUsers();
}
wasOpen = open;
});
// Refetch services when selected user changes (for eligibility)
$effect(() => {
if (open && selectedUserId) {
fetchServices();
}
});
// Preload current + next month on entering step 4; individual months fetched on navigation
let bookingCreateInitialLoadDone = $state(false);
$effect(() => {
if (open && currentStep === 4 && !bookingCreateInitialLoadDone) {
fetchHoursRange(placeholder, 2);
bookingCreateInitialLoadDone = true;
}
});
// Safety net: fetch when navigating to an uncached month
$effect(() => {
if (open && currentStep === 4 && bookingCreateInitialLoadDone) {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (!(monthKey in workingHoursCache) && !loadingMonthKeys.has(monthKey)) {
fetchHoursForMonth(placeholder);
}
}
});
// Auto-select first available date once data loads (timing-safe, data-driven)
let bookingCreateAutoSelectDone = $state(false);
$effect(() => {
if (
open &&
currentStep === 4 &&
workingHours &&
availableHours &&
!selectedDate &&
selectedServices.length > 0 &&
!userNavigatedCalendar &&
!bookingCreateAutoSelectDone
) {
bookingCreateAutoSelectDone = true;
const now = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new Date(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
);
const daysDifference = Math.floor(
(maxDateJs.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
checkDate.getFullYear(),
checkDate.getMonth() + 1,
checkDate.getDate()
);
if (outOfHours) {
// Out-of-hours: just check available hours exist with slots
const dayAH = availableHours?.[dateStr];
if (dayAH?.slots?.length > 0 && !isDateUnavailable(calDate)) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, 1);
}
break;
}
} else if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, 1);
}
break;
}
}
}
});
// Clear selection when navigating to a month that doesn't contain the selected date.
// Runs AFTER all synchronous state changes settle, so clicking a date in a different
// month (fires both onPlaceholderChange and onValueChange) keeps the new selection,
// while clicking prev/next arrows without picking a date clears it.
$effect(() => {
if (
selectedDate &&
placeholder &&
(selectedDate.month !== placeholder.month || selectedDate.year !== placeholder.year)
) {
selectedDate = undefined;
selectedTime = null;
}
});
// =============== Reset State ===============
function resetState() {
currentStep = 1;
userType = 'member';
userQuery = '';
users = [];
selectedUserId = null;
guestName = '';
guestPhone = '';
selectedServices = [];
selectedDate = undefined;
selectedTime = null;
notes = '';
serviceOverrides = {};
workingHoursCache = {};
availableHoursCache = {};
workingHours = null;
availableHours = null;
bookingCreateInitialLoadDone = false;
userNavigatedCalendar = false;
bookingCreateAutoSelectDone = false;
loadingMonthKeys = new SvelteSet();
outOfHours = false;
normalWorkingHours = null;
// Clear reservation state
reservationId = null;
reservationExpiresAt = null;
reservationCountdown = '';
if (window.__bookingCreateCountdownInterval) {
clearInterval(window.__bookingCreateCountdownInterval);
}
}
// =============== Data Fetching ===============
async function fetchUsers() {
loadingUsers = true;
try {
const response = await apiFetch(
`/api/admin/users?page=1&per_page=4&q=${encodeURIComponent(userQuery)}`
);
if (response.ok) {
const data = await response.json();
// Filter out specific roles
const excludedRoles = ['admin', 'guest', 'affiliate'];
users = (data.users || []).filter(
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
);
}
} catch {
toast.error('Failed to load users');
} finally {
loadingUsers = false;
}
}
async function fetchServices() {
loadingServices = true;
try {
let url = '/api/services';
// If a user is selected, get eligibility for that user
if (selectedUserId) {
url = `/api/services/eligible-for/${selectedUserId}`;
}
const response = await apiFetch(url);
if (response.ok) {
services = await response.json();
}
} catch {
toast.error('Failed to load services');
} finally {
loadingServices = false;
}
}
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')}`;
hoursRangeGeneration++;
const gen = hoursRangeGeneration;
loadingAvailableHours = true;
try {
const [whRes, ahRes] = await Promise.all([
apiFetch(
`/api/scheduling/working-hours?start=${startStr}&end=${endStr}${outOfHours ? '&out_of_hours=true' : ''}`
),
apiFetch(
`/api/scheduling/available-hours?start=${startStr}&end=${endStr}${outOfHours ? '&out_of_hours=true' : ''}`
)
]);
if (whRes.ok && ahRes.ok) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const whMap: Record<string, DayHours> = {};
const ahMap: Record<string, DayAvailability> = {};
whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
);
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
if (gen !== hoursRangeGeneration) return; // Stale response, discard
// 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;
}
// MERGE instead of replace — preserves data from previously loaded months
workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
}
} catch {
toast.error('Failed to load availability');
} finally {
loadingAvailableHours = false;
}
}
async function fetchHoursForMonth(date: CalendarDate) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
if (monthKey in workingHoursCache && monthKey in availableHoursCache) {
// MERGE instead of replace — preserves data from other loaded months
workingHours = { ...workingHours, ...workingHoursCache[monthKey] };
availableHours = { ...availableHours, ...availableHoursCache[monthKey] };
return;
}
// Prevent re-entrant calls for the same month
if (loadingMonthKeys.has(monthKey)) return;
loadingMonthKeys = new SvelteSet(loadingMonthKeys).add(monthKey);
hoursMonthGeneration++;
const gen = hoursMonthGeneration;
loadingAvailableHours = true;
try {
const startOfMonth = new CalendarDate(date.year, date.month, 1);
const endOfMonth = new CalendarDate(
date.year,
date.month,
date.calendar.getDaysInMonth(date)
);
const [whRes, ahRes] = await Promise.all([
apiFetch(
`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}${outOfHours ? '&out_of_hours=true' : ''}`
),
apiFetch(
`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}${outOfHours ? '&out_of_hours=true' : ''}`
)
]);
if (whRes.ok && ahRes.ok) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const whMap: Record<string, DayHours> = {};
const ahMap: Record<string, DayAvailability> = {};
whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
);
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
if (gen !== hoursMonthGeneration) return; // Stale response, discard
workingHoursCache[monthKey] = whMap;
availableHoursCache[monthKey] = ahMap;
workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
}
} catch {
toast.error('Failed to load availability');
} finally {
loadingAvailableHours = false;
loadingMonthKeys = new SvelteSet([...loadingMonthKeys].filter((k) => k !== monthKey));
}
}
// =============== Reservation ===============
async function reserveSlot(): Promise<boolean> {
if (!selectedDate || !selectedTime) {
return false;
}
isReserving = true;
try {
const localDate = selectedDate.toDate(getLocalTimeZone());
const [hours, minutes] = selectedTime.split(':').map(Number);
localDate.setHours(hours, minutes, 0, 0);
const startTimeISO = formatLocalDateTime(localDate);
const serviceIds = selectedServices.filter((s) => !s.is_custom).map((s) => s.id);
const customServiceIds = selectedServices.filter((s) => s.is_custom).map((s) => s.id);
// Build service overrides payload
const overrides = [];
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
const durationChanged = parseInt(data.duration) !== data.originalDuration;
if (durationChanged) {
overrides.push({
service_id: serviceId,
override_duration_minutes: parseInt(data.duration)
});
}
}
const payload: {
user_id: string | null;
start_time: string;
service_ids: string[];
service_overrides: Array<{ service_id: string; override_duration_minutes: number }>;
ttl_minutes: number;
reservation_type: string;
out_of_hours: boolean;
custom_service_ids?: string[];
} = {
user_id: selectedUserId || null,
start_time: startTimeISO,
service_ids: serviceIds,
service_overrides: overrides.length > 0 ? overrides : [],
ttl_minutes: 15,
reservation_type: 'callin',
out_of_hours: outOfHours
};
if (customServiceIds.length > 0) {
payload.custom_service_ids = customServiceIds;
}
const response = await apiFetch('/api/admin/bookings/reserve', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
reservationId = data.id;
reservationExpiresAt = new Date(data.expires_at);
startCountdown();
return true;
} else if (response.status === 409) {
toast.error('Slot no longer available, refreshing...');
// Refresh available hours
if (selectedDate) {
fetchHoursForMonth(selectedDate);
}
reservationId = null;
reservationExpiresAt = null;
reservationCountdown = '';
return false;
} else {
const errorText = await response.text();
toast.error(`Failed to reserve slot: ${extractErrorMessage(errorText)}`);
return false;
}
} catch {
toast.error('Failed to reserve slot');
return false;
} finally {
isReserving = false;
}
}
function startCountdown() {
// Clear any existing interval
if (window.__bookingCreateCountdownInterval) {
clearInterval(window.__bookingCreateCountdownInterval);
}
const updateCountdown = () => {
if (!reservationExpiresAt) {
reservationCountdown = '';
return;
}
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
reservationCountdown = 'Expired';
reservationId = null;
reservationExpiresAt = null;
if (window.__bookingCreateCountdownInterval) {
clearInterval(window.__bookingCreateCountdownInterval);
}
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
reservationCountdown = `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
updateCountdown();
window.__bookingCreateCountdownInterval = setInterval(updateCountdown, 1000);
}
// =============== Logic ===============
function toggleService(service: Service) {
const index = selectedServices.findIndex((s) => s.id === service.id);
if (index >= 0) {
selectedServices = selectedServices.filter((s) => s.id !== service.id);
const newOverrides = { ...serviceOverrides };
delete newOverrides[service.id];
serviceOverrides = newOverrides;
} else {
selectedServices = [...selectedServices, service];
serviceOverrides = {
...serviceOverrides,
[service.id]: {
price: service.price.toFixed(2),
duration: service.duration_minutes.toString(),
originalPrice: service.price,
originalDuration: service.duration_minutes
}
};
}
selectedTime = null;
}
async function fetchCustomServices() {
loadingCustomServices = true;
try {
const params = new SvelteURLSearchParams();
if (customSearchQuery.trim()) {
params.set('q', customSearchQuery.trim());
} else {
params.set('popular', '3');
}
const response = await apiFetch(`/api/admin/custom-services?${params}`);
if (response.ok) {
const data = await response.json();
const list = data.services || data;
customServices = list.map((cs: CustomService) => ({ ...cs, is_custom: true }));
}
} catch {
console.error('Failed to fetch custom services');
} finally {
loadingCustomServices = false;
}
}
async function createCustomService() {
validateCsAll();
if (!isCustomFormValid) {
toast.error('Please fix the validation errors');
return;
}
creatingCustomService = true;
try {
const response = await apiFetch('/api/admin/custom-services', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: (newCustomService.name ?? '').trim(),
description: (newCustomService.description ?? '').trim() || undefined,
price: parseFloat(newCustomService.price ?? '0'),
duration_minutes: parseInt(newCustomService.duration_minutes ?? '0'),
minimum_age_required: parseInt(newCustomService.minimum_age_required ?? '0') || 0
})
});
if (response.ok) {
const cs = await response.json();
const customService = { ...cs, is_custom: true };
selectedServices = [...selectedServices, customService];
serviceOverrides = {
...serviceOverrides,
[cs.id]: {
price: cs.price.toFixed(2),
duration: cs.duration_minutes.toString(),
originalPrice: cs.price,
originalDuration: cs.duration_minutes
}
};
showCustomCreateForm = false;
newCustomService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
customServiceErrors = { name: '', price: '', duration_minutes: '' };
toast.success('Custom service created and added');
} else {
const err = await response.text();
toast.error(`Failed: ${extractErrorMessage(err)}`);
}
} catch {
toast.error('Network error');
} finally {
creatingCustomService = false;
}
}
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();
// Out-of-hours: only check if available hours exist with slots
if (outOfHours) {
const ahDay = availableHours?.[dateStr];
return !ahDay?.slots || ahDay.slots.length === 0;
}
const dayHours = workingHours[dateStr];
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;
// Determine the duration to check: if services are selected use their total,
// otherwise use a minimum of 15 minutes (any meaningful booking needs at least this)
const duration = selectedServices.length > 0 ? getTotalDuration() : 15;
if (duration <= 0) return true;
// Build lunch protection specifically for the date being checked
const dayProtection = buildLunchProtection(
date as CalendarDate,
workingHours,
availableHours,
duration,
true
);
const slots = generateAvailableTimeSlots(
date as CalendarDate,
workingHours,
availableHours,
duration,
dayProtection
);
if (slots.length === 0) return true;
return false;
}
// =============== Submission ===============
async function submitBooking() {
// First, reserve the slot
const reserved = await reserveSlot();
if (!reserved) {
submitting = false;
return;
}
submitting = true;
try {
let finalUserId = selectedUserId;
if (userType === 'guest') {
if (!isValidUKPhone(guestPhone)) {
toast.error('Please enter a valid UK phone number for the guest');
return;
}
const phone = toE164UK(guestPhone)!;
const createRes = await apiFetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Guest',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Customer',
phone: phone,
email: `callin-${Date.now()}@guest.invalid`
})
});
if (!createRes.ok) {
toast.error('Failed to create guest user');
submitting = false;
return;
}
const guestUser = await createRes.json();
finalUserId = guestUser.id;
}
if (!finalUserId) throw new Error('User ID required');
if (!selectedDate || !selectedTime) throw new Error('Date and time required');
const localDate = selectedDate.toDate(getLocalTimeZone());
const [hours, minutes] = selectedTime.split(':').map(Number);
localDate.setHours(hours, minutes, 0, 0);
const dateTimeStr = formatLocalDateTime(localDate);
const overrides = [];
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
const priceChanged = Math.abs(parseFloat(data.price) - data.originalPrice) > 0.01;
const durationChanged = parseInt(data.duration) !== data.originalDuration;
if (priceChanged || durationChanged) {
overrides.push({
service_id: serviceId,
override_price: priceChanged ? parseFloat(data.price) : null,
override_duration_minutes: durationChanged ? parseInt(data.duration) : null
});
}
}
const payload: {
user_id: string;
start_time: string;
service_ids: string[];
custom_service_ids: string[];
service_overrides:
| Array<{
service_id: string;
override_price: number | null;
override_duration_minutes: number | null;
}>
| undefined;
notes: string | null;
out_of_hours: boolean;
} = {
user_id: finalUserId,
start_time: dateTimeStr,
service_ids: selectedServices.filter((s) => !s.is_custom).map((s) => s.id),
custom_service_ids: selectedServices.filter((s) => s.is_custom).map((s) => s.id),
service_overrides: overrides.length > 0 ? overrides : undefined,
notes: notes.trim() || null,
out_of_hours: outOfHours
};
const res = await apiFetch('/api/admin/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (res.ok) {
toast.success('Booking created successfully!');
open = false;
window.dispatchEvent(new CustomEvent('bookingApproved'));
onBookingCreated?.();
} else {
const errorText = await res.text();
toast.error(`Failed to create booking: ${extractErrorMessage(errorText)}`);
}
} catch {
toast.error('An error occurred while creating booking');
} finally {
submitting = false;
}
}
// Input handlers
function handlePriceInput(serviceId: string, value: string) {
const override = serviceOverrides[serviceId];
if (!override) return;
let cleaned = value.replace(/[^\d.]/g, '');
const parts = cleaned.split('.');
if (parts.length > 2) cleaned = parts[0] + '.' + parts.slice(1).join('');
if (cleaned.includes('.')) {
const [int, dec] = cleaned.split('.');
cleaned = int + '.' + dec.substring(0, 2);
}
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, price: cleaned } };
}
function handleDurationInput(serviceId: string, value: string) {
const override = serviceOverrides[serviceId];
if (!override) return;
const cleaned = value.replace(/\D/g, '');
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, duration: cleaned } };
// Clear date/time and cache when duration changes
selectedDate = undefined;
selectedTime = null;
availableHoursCache = {};
}
</script>
<Modal.Root bind:open>
<Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl"
>
<Modal.Header>
<Modal.Title>Create Admin Booking</Modal.Title>
<Modal.Description>
Book an appointment for a member or guest with flexible pricing and timing
</Modal.Description>
</Modal.Header>
<div class="px-6 pb-4">
<!-- Step 1: Customer Selection -->
{#if currentStep === 1}
<Card.Root>
<Card.Header>
<Card.Title>Select Customer</Card.Title>
<Card.Description>Choose an existing member or create a guest booking</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Tabs -->
<div class="flex gap-6 border-b border-gray-200">
<button
type="button"
class="pb-2 text-sm font-medium transition-colors {userType === 'member'
? 'border-b-2 border-primary text-primary'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => {
userType = 'member';
selectedUserId = null;
}}
>
Member
</button>
<button
type="button"
class="pb-2 text-sm font-medium transition-colors {userType === 'guest'
? 'border-b-2 border-primary text-primary'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => {
userType = 'guest';
guestName = '';
guestPhone = '';
}}
>
Guest / Non-Member
</button>
</div>
{#if userType === 'member'}
<!-- Native Input using oninput to prevent reactivity bugs -->
<div class="flex items-center space-x-2">
<div class="relative flex-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
class="h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
<input
type="text"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Search by name, email or phone..."
value={userQuery}
oninput={(e) => {
userQuery = e.currentTarget.value;
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
fetchUsers();
}
}}
/>
</div>
<Button onclick={fetchUsers} disabled={loadingUsers}>
{loadingUsers ? '...' : 'Search'}
</Button>
</div>
<!-- Compact Results List -->
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
{#if loadingUsers}
<div class="space-y-2 p-2">
{#each range(3) as i (i)}
<Skeleton class="h-10 w-full" />
{/each}
</div>
{:else if users.length === 0}
<div class="flex items-center justify-center p-8 text-sm text-gray-500">
{userQuery
? 'No users found. Try a different search.'
: 'Search for a user above to get started.'}
</div>
{:else}
<ul class="divide-y divide-gray-200">
{#each users.slice(0, 4) as user (user.id)}
<li>
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between px-4 py-3 text-left transition-colors hover:bg-fuchsia-50 {selectedUserId ===
user.id
? 'bg-fuchsia-100 font-medium'
: ''}"
onclick={() => (selectedUserId = user.id)}
>
<div>
<div class="text-base font-medium">
{formatUserName(
user.fullName,
user.previousFirstName,
user.previousLastName
)}
</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
{:else if user.email}
{user.email}
{:else if user.phone}
{user.phone}
{:else}
No contact info
{/if}
</div>
</div>
{#if selectedUserId === user.id}
<svg
class="h-5 w-5 text-primary"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{:else}
<!-- Guest Form - Using Native Input -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="guest-name">Guest Name *</Label>
<input
id="guest-name"
type="text"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Jane Doe"
value={guestName}
oninput={(e) => (guestName = e.currentTarget.value)}
/>
</div>
<div class="space-y-2">
<Label for="guest-phone">Phone Number *</Label>
<PhoneInput
id="guest-phone"
bind:value={guestPhone}
bind:error={guestPhoneError}
placeholder="07700 900000"
/>
</div>
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
Booking as a guest creates a temporary record. Encourage them to sign up for
loyalty benefits.
</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-end">
<BookingActions
canBack={false}
canNext={canProceedStep1}
nextLabel="Next: Choose Services"
on:next={() => currentStep++}
/>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 2: Service Selection -->
{#if currentStep === 2}
<Card.Root>
<Card.Header>
<Card.Title>Choose Services</Card.Title>
<Card.Description>Select one or more treatments for this appointment</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if loadingServices}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each range(4) as i (i)}
<Skeleton class="h-28 w-full" />
{/each}
</div>
{:else if services.length === 0}
<p class="py-8 text-center text-gray-500">No services available.</p>
{:else}
<ServiceSelector
{services}
selected={selectedServices}
loading={loadingServices}
ontoggle={toggleService}
showContactLink={false}
/>
{/if}
<Separator />
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-600">Or book a custom service</h4>
<div class={showCustomCreateForm ? 'hidden' : ''}>
<div class="flex gap-2">
<Input
placeholder="Search existing custom services..."
bind:value={customSearchQuery}
onkeydown={(e) => {
if (e.key === 'Enter') fetchCustomServices();
}}
class="flex-1"
/>
<Button variant="outline" size="sm" onclick={fetchCustomServices}>Search</Button>
</div>
{#if loadingCustomServices}
<div class="space-y-2">
{#each range(3) as i (i)}
<Skeleton class="h-10 w-full" />
{/each}
</div>
{:else if customServices.length > 0}
<div class="space-y-2">
{#each customServices as cs (cs.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm hover:bg-gray-50"
onclick={() => {
if (!selectedServices.some((s) => s.id === cs.id)) {
selectedServices = [...selectedServices, cs];
serviceOverrides = {
...serviceOverrides,
[cs.id]: {
price: cs.price.toFixed(2),
duration: cs.duration_minutes.toString(),
originalPrice: cs.price,
originalDuration: cs.duration_minutes
}
};
toast.success(`Added "${cs.name}"`);
}
}}
>
<span class="font-medium">{cs.name}</span>
<span class="text-gray-500"
>{cs.duration_minutes} min £{cs.price.toFixed(2)}{cs.usage_count > 0
? ` (${cs.usage_count}×)`
: ''}</span
>
</button>
{/each}
</div>
{/if}
<Button
variant="ghost"
size="sm"
onclick={() => {
showCustomCreateForm = true;
}}
class="w-full"
>
+ Create new custom service
</Button>
</div>
<div class={showCustomCreateForm ? '' : 'hidden'}>
<div class="space-y-3 rounded-lg border p-4">
<div class="space-y-1">
<label for="booking-cs-name" class="text-sm font-medium">Name *</label>
<Input
id="booking-cs-name"
bind:value={newCustomService.name}
oninput={() =>
(customServiceErrors.name = validateCsName(newCustomService.name))}
onblur={() =>
(customServiceErrors.name = validateCsName(newCustomService.name))}
placeholder="e.g., Bridal Party French Tips"
class={customServiceErrors.name ? 'border-red-500' : ''}
/>
{#if customServiceErrors.name}
<p class="text-xs text-red-600">{customServiceErrors.name}</p>
{/if}
</div>
<div class="space-y-1">
<label for="booking-cs-desc" class="text-sm font-medium">Description</label>
<Input
id="booking-cs-desc"
bind:value={newCustomService.description}
placeholder="Brief description"
class="w-full"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<label for="booking-cs-price" class="text-sm font-medium">Price (£) *</label>
<Input
id="booking-cs-price"
type="number"
step="0.01"
min="0"
bind:value={newCustomService.price}
oninput={() =>
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
onblur={() =>
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
placeholder="0.00"
class={customServiceErrors.price ? 'border-red-500' : ''}
/>
{#if customServiceErrors.price}
<p class="text-xs text-red-600">{customServiceErrors.price}</p>
{/if}
</div>
<div class="space-y-1">
<label for="booking-cs-dur" class="text-sm font-medium">Duration *</label>
<select
id="booking-cs-dur"
bind:value={newCustomService.duration_minutes}
onchange={() =>
(customServiceErrors.duration_minutes = validateCsDuration(
newCustomService.duration_minutes
))}
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 {customServiceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
<option value="">Select...</option>
{#each durationOptions as mins (mins)}
<option value={mins}
>{mins} min{mins >= 60
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
: ''}</option
>
{/each}
</select>
{#if customServiceErrors.duration_minutes}
<p class="text-xs text-red-600">{customServiceErrors.duration_minutes}</p>
{/if}
</div>
</div>
<div class="space-y-1">
<label for="booking-cs-age" class="text-sm font-medium">Minimum Age</label>
<Input
id="booking-cs-age"
type="number"
inputmode="numeric"
min="0"
max="100"
placeholder="0"
bind:value={newCustomService.minimum_age_required}
oninput={() =>
(customServiceErrors.minimum_age_required = validateCsMinimumAge(
newCustomService.minimum_age_required
))}
class="w-full {customServiceErrors.minimum_age_required
? 'border-red-500'
: ''}"
/>
{#if customServiceErrors.minimum_age_required}
<p class="text-xs text-red-600">{customServiceErrors.minimum_age_required}</p>
{/if}
<p class="text-xs text-gray-500">0 for no age restriction</p>
</div>
<div class="flex gap-2">
<Button
size="sm"
onclick={createCustomService}
disabled={creatingCustomService || !isCustomFormValid}
>
{creatingCustomService ? 'Creating...' : 'Save & Add'}
</Button>
<Button
variant="outline"
size="sm"
onclick={() => {
showCustomCreateForm = false;
newCustomService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
customServiceErrors = {
name: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
}}
>
Cancel
</Button>
</div>
</div>
</div>
</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-between">
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
<Button disabled={!canProceedStep2} onclick={() => currentStep++}>
Next: Customize Services
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 3: Service Overrides & Notes -->
{#if currentStep === 3}
<Card.Root>
<Card.Header>
<Card.Title>Customize Services</Card.Title>
<Card.Description>
Adjust pricing or duration if needed, and add appointment notes
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div>
<h4 class="mb-3 font-semibold">Service Details</h4>
<p class="mb-4 text-sm text-gray-600">
Override default pricing or duration for special cases (discounts, extended
sessions, etc.)
</p>
<div class="space-y-3">
{#each selectedServices as service (service.id)}
<!-- Safety check to ensure override exists -->
{#if serviceOverrides[service.id]}
<div class="rounded-lg border bg-white p-4">
<div class="mb-3 font-medium">{service.name}</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="price-{service.id}" class="text-xs text-gray-600"
>Price (£)</Label
>
<!-- Native Input with oninput -->
<input
id="price-{service.id}"
type="text"
inputmode="decimal"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
value={serviceOverrides[service.id]?.price || service.price.toFixed(2)}
oninput={(e) => handlePriceInput(service.id, e.currentTarget.value)}
/>
</div>
<div class="space-y-2">
<Label for="duration-{service.id}" class="text-xs text-gray-600"
>Duration (min)</Label
>
<!-- Native Input with oninput -->
<input
id="duration-{service.id}"
type="number"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
value={serviceOverrides[service.id]?.duration ||
service.duration_minutes}
oninput={(e) => handleDurationInput(service.id, e.currentTarget.value)}
/>
</div>
</div>
{#if serviceOverrides[service.id] && (Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 || parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration)}
<div class="mt-2 text-xs text-amber-600">
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01}
Price modified from £{serviceOverrides[
service.id
].originalPrice.toFixed(2)}
{/if}
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 && parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
{/if}
{#if parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
Duration modified from {serviceOverrides[service.id].originalDuration} mins
{/if}
</div>
{/if}
</div>
{/if}
{/each}
</div>
</div>
<div class="rounded-lg bg-gray-50 p-4">
<div class="flex justify-between text-sm font-semibold">
<span>Total Duration:</span>
<span>{formattedTotalDuration}</span>
</div>
<div class="mt-1 flex justify-between text-sm font-semibold">
<span>Total Cost:</span>
<span>£{getTotalPrice().toFixed(2)}</span>
</div>
</div>
<div class="space-y-2">
<Label for="notes">Appointment Notes (extras only, client will see this)</Label>
<!-- Native Textarea -->
<textarea
id="notes"
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={notes}
placeholder="Any special requirements, preferences, or notes about this booking..."
></textarea>
<CharCounter text={notes} />
</div>
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
<Button onclick={() => currentStep++}>Next: Select Date & Time</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 4: Date & Time Selection -->
{#if currentStep === 4}
<Card.Root>
<Card.Header>
<Card.Title>Choose Date & Time</Card.Title>
<Card.Description>
{selectedServices.map((s) => s.name).join(', ')} {formattedTotalDuration} total £{getTotalPrice().toFixed(
2
)}
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4 p-0">
<!-- Reservation Countdown Banner -->
{#if reservationId && reservationExpiresAt}
<div class="mx-6 mt-4 rounded-lg border border-green-200 bg-green-50 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg
class="h-5 w-5 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span class="text-sm font-medium text-green-800">
Slot reserved until {parseWallClockDate(
reservationExpiresAt.toISOString()
).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<span class="font-mono text-sm font-semibold text-green-700">
({reservationCountdown} remaining)
</span>
</div>
</div>
{/if}
<div class="flex items-center justify-center p-6">
<DatePicker
date={selectedDate}
{placeholder}
minValue={minDate}
maxValue={maxCalendarDate}
{isDateUnavailable}
onchange={(newDate) => {
selectedDate = newDate;
selectedTime = null;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
userNavigatedCalendar = true;
fetchHoursForMonth(newPlaceholder);
}}
/>
</div>
<!-- Out-of-hours toggle -->
<div class="mx-6 flex items-center gap-2">
<Checkbox id="out-of-hours" bind:checked={outOfHours} />
<Label for="out-of-hours" class="cursor-pointer text-sm font-medium text-amber-600">
Out-of-hours booking
</Label>
</div>
{#if loadingAvailableHours && selectedDate}
<div class="flex items-center justify-center border-t p-6">
<p class="text-sm text-gray-500">Loading times...</p>
</div>
{:else if selectedDate}
<div class="border-t">
<div class="max-h-64 overflow-y-auto p-6">
{#if formattedSelectedDate}
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">
{formattedSelectedDate}
</div>
{/if}
<TimeSlotList
slots={groupedTimeSlots}
{selectedTime}
duration={getTotalDuration()}
protection={lunchProtection}
onSelect={(time) => {
selectedTime = time;
}}
/>
</div>
</div>
{#if selectedDate && selectedTime}
<div class="px-6 pb-4">
<SelectedTimeSummary
selectedDate={formattedSelectedDate || ''}
selectedTime={formatTime(selectedTime)}
endTime={formatTime(calculateEndTime(selectedTime, getTotalDuration()))}
duration={getTotalDuration()}
protection={lunchProtection.get(selectedTime)}
outOfHours={selectedTimeOutOfHours}
/>
</div>
{/if}
{:else}
<div class="flex items-center justify-center border-t p-6">
<p class="text-center text-sm text-gray-500">
Select a date to see available times
</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
<Button
disabled={!canProceedStep4 || submitting || isReserving}
onclick={submitBooking}
class="bg-primary text-primary-foreground"
>
{isReserving
? 'Reserving Slot...'
: submitting
? 'Creating Booking...'
: 'Create Booking'}
</Button>
</Card.Footer>
</Card.Root>
{/if}
</div>
</Modal.Content>
</Modal.Root>