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.
1761 lines
58 KiB
Svelte
1761 lines
58 KiB
Svelte
<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>
|