Files
Crussell/frontend/src/lib/components/booking/BookingFlow.svelte
T
popertots 5dae0bba08 feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup
SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.

BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
  the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
  writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
  at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
  (SCA primary); no token => 2FA fallback requires delivery channel + consume +
  insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
  {sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
  the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
  customer_initiated=false (merchant-initiated, no SCA, no liability shift);
  customer-initiated online flows keep true

FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
  fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
  with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
  (booking/tip/till/gift-card/admin); 'waiting for approval in your banking
  app' state on admin surfaces; 2FA backup-only UX in the shared composable

MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
  ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
  grandfathered; frontend helper tests

DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).

26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
2026-08-22 00:34:50 +01:00

2832 lines
97 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 { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { EmailInput } from '$lib/components/ui/email-input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import CharCounter from '$lib/components/ui/CharCounter.svelte';
import { Separator } from '$lib/components/ui/separator/index.js';
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
// salon app. All customers are physically in the UK and book UK appointment slots. We do NOT
// auto-adjust for international timezones — the slot time shown is the actual UK salon time.
// Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled automatically:
// formatLocalDateTime converts wall-clock time to UTC using the correct DST offset for the
// target date (via @internationalized/date's CalendarDate.toDate which applies the target
// date's timezone rules, not the current date's). The backend stores all timestamps as
// TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at
// "10am June 15" stays at 10am BST regardless of when the booking was made.
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch, getAuthHeaders } from '$lib/utils/api';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { onDestroy } from 'svelte';
// Components
import BookingActions from '$lib/components/booking/BookingActions.svelte';
import BookingSummary from '$lib/components/booking/BookingSummary.svelte';
import StepIndicator from '$lib/components/booking/StepIndicator.svelte';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import {
canSaveCardsForRole,
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import {
formatLocalDateTime,
getLondonTodayCalendarDate,
parseWallClockDate
} from '$lib/utils/timeSlots';
import { generateUUID } from '$lib/utils/uuid';
import type {
Service,
CustomerInfo,
WorkingHoursDay,
AvailableHoursDay,
BookingService,
BookingStatus,
Payment
} from '$lib/types/booking';
// =============== State Management ===============
let currentStep = $state<number>(0);
let authReady = $state(false);
// Wait for auth store to finish initializing before deciding which step to show.
// This prevents a flash of the login prompt on SSR + hydration — the skeleton
// displays while auth checks are pending, then the correct screen appears.
$effect(() => {
if (authStore.hasLoaded && !authReady) {
authReady = true;
currentStep = authStore.isAuthenticated ? 1 : 0;
}
});
let selectedServices = $state<Service[]>([]);
let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null);
const customerInfo = $state<CustomerInfo>({
firstName: '',
lastName: '',
email: '',
phone: '',
specialRequests: ''
});
let idempotencyKey = $state<string>('');
// =============== Payment State ===============
let userDepositsRequired = $state<number>(0);
let hasActiveBooking = $state<boolean>(false);
// Saved cards, in the API shape (last_4/exp_month/exp_year) — consumed by
// CardSelection.svelte which renders the list, new-card toggle, and consent.
let paymentMethods = $state<
Array<{
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
is_default?: boolean;
// Square's card-on-file id (`ccof:...`), needed to run the saved-card
// SCA challenge (tokenizeSavedCardWithVerification).
square_card_id?: string;
}>
>([]);
let paymentMethodsLoading = $state(false);
// Once-per-payment-step fetch guard. Without it, the effect below re-runs on
// every state change and re-assigns a FRESH paymentMethods array (even an
// empty one), which re-triggers the effect → infinite refetch loop when the
// user has zero saved cards. Set synchronously BEFORE the await so re-entry
// is impossible even while the request is in flight.
let paymentMethodsFetched = $state(false);
let selectedPaymentMethod = $state('');
let paymentCardSelection = $state<CardSelection | null>(null);
let paymentCardSelectionValid = $state(false);
let depositSaveCard = $state(false);
let isProcessingPayment = $state(false);
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let depositNonce = $state('');
// Cached SCA verification token paired with depositNonce (tokenizeWithVerification
// returns both; both are one-shot and must be reused together on retry). The
// verification token is amount-bound, so a changed deposit amount forces a
// fresh tokenization.
let depositVerificationToken = $state('');
let depositTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let depositTokenizedAt = $state(0);
// Save intent at tokenization time: the SCA verification token is bound to
// CHARGE vs CHARGE_AND_STORE, so toggling the save-card checkbox after a
// tokenize must force a fresh tokenization rather than reuse a token minted
// with the wrong intent.
let depositTokenizedForSaveCard = $state(false);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so `isProcessingPayment` may not propagate to the
// button's `disabled` binding before a fast second click fires. This non-
// reactive flag is checked synchronously at the start of processPayment.
let isProcessingPaymentSync = false;
// Payment flow state
let depositPaid = $state(false);
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
// B6/B10: saved-card deposits (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () =>
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard),
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
});
const depositCardFormValid = $derived(paymentCardSelectionValid);
// VAT registration status from public business info (via shared store)
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
// Email existence check (guest flow only)
let emailChecking = $state(false);
let emailSuggestion = $state<string | null>(null);
let emailError = $state('');
const emailFormatValid = $derived(
!customerInfo.email ||
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(customerInfo.email)
);
const allGuestFieldsValid = $derived(
customerInfo.firstName &&
customerInfo.lastName &&
customerInfo.email &&
emailFormatValid &&
customerInfo.phone &&
isValidUKPhone(customerInfo.phone)
);
async function checkEmailExists(email: string) {
if (!email || !/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email)) {
emailSuggestion = null;
return;
}
// Don't check unless ALL guest fields have valid input
if (!allGuestFieldsValid) {
emailSuggestion = null;
return;
}
emailChecking = true;
try {
// Send all 4 fields to enable the backend to match beyond just email
const params = new URLSearchParams({
email: email.toLowerCase(),
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
phone: toE164UK(customerInfo.phone) ?? customerInfo.phone
});
const resp = await fetch(`/api/check-email?${params}`);
if (resp.ok) {
const data = await resp.json();
emailSuggestion = data.suggestion ?? null;
}
} catch {
// Network error - silently ignore, don't block booking
emailSuggestion = null;
} finally {
emailChecking = false;
}
}
let emailCheckTimeout: ReturnType<typeof setTimeout> | null = null;
function debouncedEmailCheck() {
if (emailCheckTimeout) clearTimeout(emailCheckTimeout);
if (allGuestFieldsValid) {
emailCheckTimeout = setTimeout(() => checkEmailExists(customerInfo.email), 500);
}
}
// Confirmation state
let confirmedBooking = $state<{
id: string;
status: string;
start_time: string;
notes: string;
deposit_required: boolean;
deposit_paid: boolean;
deposit_amount: number;
amount_paid: number;
amount_due: number;
payments: Payment[];
total_amount: number;
duration_minutes: number;
} | null>(null);
let showPayEarlyModal = $state(false);
let discountPreview = $state<{
eligible: boolean;
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
original_total: number;
discounted_total: number;
} | null>(null);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
// =============== Payment Functions ===============
async function fetchUserDepositsRequired() {
if (!authStore.isAuthenticated) {
userDepositsRequired = 0;
return;
}
try {
const response = await apiFetch('/api/user/profile');
if (response.ok) {
const user = await response.json();
userDepositsRequired = user.deposits_required ?? 0;
}
} catch {
userDepositsRequired = 0;
}
}
async function fetchActiveBookingStatus() {
if (!authStore.isAuthenticated) {
hasActiveBooking = false;
return;
}
try {
// Check for pending bookings
const pendingResp = await apiFetch('/api/bookings?status=pending&perPage=1');
if (pendingResp.ok) {
const data = await pendingResp.json();
if (data.bookings && data.bookings.length > 0) {
hasActiveBooking = true;
return;
}
}
// Check for confirmed bookings
const confirmedResp = await apiFetch('/api/bookings?status=confirmed&perPage=1');
if (confirmedResp.ok) {
const data = await confirmedResp.json();
hasActiveBooking = data.bookings && data.bookings.length > 0;
}
} catch {
hasActiveBooking = false;
}
}
function calculateDepositRequired(): boolean {
if (!selectedDate || !selectedTime) return false;
// Deposit required if user has deposits_required > 0, regardless of booking window
return userDepositsRequired > 0;
}
function calculateDepositAmount(): number {
return Math.round(getTotalPrice() * 0.2 * 100) / 100;
}
async function fetchDiscountPreview() {
if (!confirmedBooking?.id) return;
try {
const resp = await apiFetch(`/api/bookings/${confirmedBooking.id}/discount-preview`);
if (resp.ok) {
const data = await resp.json();
// Only show time-based (auto-apply) discounts on the confirmation screen
if (data.eligible && data.discounts?.length > 0) {
discountPreview = data;
}
}
} catch (_err) {
console.error('Failed to fetch discount preview:', _err);
}
}
async function processPayment(_amount: number) {
// Synchronous double-click guard — set BEFORE any await so a rapid second
// click is rejected immediately, even before the reactive `disabled` has
// propagated to the button.
if (isProcessingPaymentSync) return;
isProcessingPaymentSync = true;
isProcessingPayment = true;
paymentAttempted = false;
try {
// Create the booking only if one does not already exist. A retry after
// a failed deposit charge (or a lost response) must NOT re-create a
// booking — the existing confirmedBooking is the one to charge, and
// the backend enforces one-active-booking for deposit-required users,
// so a second submission would 409 and orphan an unpaid booking.
if (!confirmedBooking) {
await submitAndProceed();
}
if (!confirmedBooking) {
toast.error('Booking was not created. Please try again.');
return;
}
// Charge the SERVER-computed deposit: the booking response carries the
// authoritative deposit_amount (20% of the server-side total, which
// accounts for discounts / admin adjustments). The client-side
// getTotalPrice()*0.2 estimate can diverge and under-charge.
const depositAmount =
confirmedBooking.deposit_amount && confirmedBooking.deposit_amount > 0
? confirmedBooking.deposit_amount
: _amount;
const amountPence = Math.round(depositAmount * 100);
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedPaymentMethod) {
// saved card — nothing to tokenize; B6/B10 requires the customer's
// current 2FA verification code (collected in the charge form)
// when the backend enforces the gate.
} else if (paymentCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the
// backend idempotency key dedups). Tokenizing AFTER the booking is
// created so the SCA verification amount matches the exact charge.
// The verification token is amount-bound, so a changed deposit
// amount forces a fresh tokenization.
if (
!depositNonce ||
depositTokenizedForSaveCard !== depositSaveCard ||
isNonceStale(depositTokenizedAt, depositTokenAmount, amountPence)
) {
try {
const tokenized = await paymentCardSelection.tokenizeWithVerification(
amountPence,
{
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
email: customerInfo.email || authStore.currentUser?.email
},
depositSaveCard
);
depositNonce = tokenized.nonce;
depositVerificationToken = tokenized.verificationToken ?? '';
depositTokenAmount = amountPence;
depositTokenizedAt = Date.now();
depositTokenizedForSaveCard = depositSaveCard;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = depositNonce;
verificationToken = depositVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
return;
}
// Cache the idempotency key per amount+card so a lost-response retry
// reuses it (backend dedups) instead of double-charging. The new-card
// identity is a STABLE sentinel, NOT the cnon: nonce: the nonce is
// one-shot (cleared once spent), so keying on it would regenerate the
// key on re-tokenize and a lost-response retry could double-charge.
const cardKey = selectedPaymentMethod || 'new-card';
if (
!depositIdempotencyKey ||
depositKeyedAmount !== amountPence ||
depositKeyedCard !== cardKey
) {
depositIdempotencyKey = generateUUID();
depositKeyedAmount = amountPence;
depositKeyedCard = cardKey;
}
const body: Record<string, unknown> = {
payment_type: 'deposit',
amount: amountPence,
idempotency_key: depositIdempotencyKey,
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
};
paymentAttempted = true;
await submitDepositPayment({
body,
amountPence,
depositAmount,
confirmOverflowTip: false
});
} catch {
toast.error(
'An error occurred. Your booking may still be confirmed — check your appointments.'
);
// A thrown error (network / malformed response) means the nonce + SCA
// verification token are unreliable — clear them so a retry
// re-tokenizes fresh. The idempotency key stays for dedup.
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
} finally {
isProcessingPayment = false;
isProcessingPaymentSync = false;
}
}
// Submits the deposit payment request and processes the outcome. Shared by
// the initial attempt and the overflow-tip confirm resend so both use the
// exact same success/error handling. `confirmOverflowTip` adds the backend's
// opt-in flag for a pre-start overpayment; the resend reuses the SAME body
// (cached nonce / verification token / idempotency key) as the rejected
// attempt — the guard fired before any Square call, so the tokens are
// unconsumed and the key is still the correct dedup identity.
async function submitDepositPayment(options: {
body: Record<string, unknown>;
amountPence: number;
depositAmount: number;
confirmOverflowTip: boolean;
}): Promise<void> {
const { body, amountPence, depositAmount, confirmOverflowTip } = options;
if (!confirmedBooking) return;
const bookingId = confirmedBooking.id;
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${bookingId}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({
...body,
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
})
})
);
if (response.ok) {
depositPaid = true;
depositIdempotencyKey = '';
depositKeyedAmount = 0;
depositKeyedCard = '';
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
depositSaveCard = false;
twoFactor.setCode('');
twoFactor.reveal = false;
overflowConfirm = null;
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
// in place, potential overcharge on double-click race.)
confirmedBooking = {
...confirmedBooking,
deposit_paid: true,
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
};
toast.success('Payment successful!');
return;
}
const text = await response.text();
// Saved-card (ccof) SCA: the backend returns 402 + `verification_required`
// when Square requires buyer verification and no verification_token was
// supplied. Run the client-side 3DS challenge and retry with the fresh
// token — the SAME body and idempotency key (never regenerated here). A
// token-carrying retry is never re-intercepted (the backend skips the 2FA
// gate when a verification_token is present).
if (
selectedPaymentMethod &&
!body.verification_token &&
isVerificationRequiredSignal(response.status, text)
) {
await runDepositSCA({ body, amountPence, depositAmount, confirmOverflowTip });
return;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the deposit can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(response.status, extractErrorMessage(text))) {
twoFactor.reveal = true;
}
// Pre-start overpayment guard on stale booking data: park the rejected
// request (body + amount) and surface the Confirm/Cancel prompt instead
// of a dead-end 400. The cached nonce + SCA verification token +
// idempotency key are NOT cleared — the confirm resend is the same
// logical charge.
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) {
// The backend's overflow guard compares against the DISCOUNTED
// remaining (remaining + eligible campaign credit), and for a
// deposit it charges req.Amount the campaign credit (the
// frontend sends deposits raw). Both the displayed overflow and
// the amount actually charged must therefore account for the
// eligible campaign discount — mirroring UserPaymentModal so the
// two surfaces can't show different amounts for the same booking.
const depositDiscountPence = campaignDiscountPence(discountPreview);
overflowConfirm = {
amountPence,
overflowPence: Math.max(
0,
amountPence -
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
depositDiscountPence
),
chargePence: Math.max(0, amountPence - depositDiscountPence),
depositAmount,
body
};
return;
}
// A 409 "already paid" (double-tab, or a lost-response retry that
// actually landed) must not leave the user wedged on the pay form
// with a stale deposit_paid=false — money was taken. Reconcile
// against the server's truth so the confirmation gate (depositPaid
// / confirmedBooking.deposit_paid) opens and the user reaches the
// confirmation screen. The body-text match is a belt-and-braces
// fallback for 4xx responses that still report the charge as
// already processed.
if (response.status === 409 || /already|paid|processed/.test(text.toLowerCase())) {
try {
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`);
if (bookingResp.ok) {
const serverBooking = await bookingResp.json();
// Immutable update — spread, never mutate (see audit note above).
confirmedBooking = {
...confirmedBooking,
status: serverBooking.status ?? confirmedBooking.status,
deposit_paid: serverBooking.deposit_paid ?? confirmedBooking.deposit_paid,
deposit_amount: serverBooking.deposit_amount ?? confirmedBooking.deposit_amount,
amount_paid: serverBooking.amount_paid ?? confirmedBooking.amount_paid,
amount_due: serverBooking.amount_due ?? confirmedBooking.amount_due,
payments: serverBooking.payments ?? confirmedBooking.payments,
total_amount: serverBooking.total_amount ?? confirmedBooking.total_amount
};
depositPaid = confirmedBooking.deposit_paid;
toast.success('Payment successful!');
} else {
toast.warning(
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
}
} catch {
toast.warning(
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
}
} else {
toast.warning(
extractErrorMessage(text) || 'Payment failed — you can pay again from your booking details.'
);
}
// A definitive charge failure (declined card, any 4xx) consumes the
// nonce + SCA verification token (Square nonces are single-use) —
// clear the cached pair so a retry re-tokenizes fresh instead of
// resubmitting a spent nonce for up to 240s. The idempotency key
// stays so a lost-response retry still dedups against the original
// charge (matches the TipPayment pattern).
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
}
/**
* Saved-card (ccof) SCA challenge, run when the deposit charge came back 402
* with the verification-required signal. 'verified' retries the SAME deposit
* body with the fresh verification_token and the SAME cached idempotency key
* (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the
* pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable'
* demotes 2FA from backup to the available gate.
*/
async function runDepositSCA(options: {
body: Record<string, unknown>;
amountPence: number;
depositAmount: number;
confirmOverflowTip: boolean;
}) {
const squareCardId = paymentMethods.find((c) => c.id === selectedPaymentMethod)?.square_card_id;
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
toast.error(VERIFICATION_REQUIRED_MESSAGE);
return;
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(options.amountPence, squareCardId, {
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
email: customerInfo.email || authStore.currentUser?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
toast.error(err instanceof Error ? err.message : 'Card verification failed');
return;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
await submitDepositPayment({
...options,
body: { ...options.body, verification_token: result.verificationToken }
});
return;
}
twoFactor.reveal = true;
toast.error(
result.outcome === 'sca-unavailable'
? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
: "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
}
let paymentAttempted = $state(false);
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
// rejects a deposit payment that exceeds the booking's remaining balance
// before the appointment has started unless the request carries
// `confirm_overflow_tip: true` (a tip is gratuity for service already
// rendered). This fires on STALE booking data where the user would otherwise
// be stuck with an unresolvable 400. The rejected request body (including
// the cached nonce / SCA token / idempotency key) is parked here and a
// Confirm/Cancel prompt is shown; Confirm resends the SAME body with the
// flag, Cancel returns to the amount-editing form.
let overflowConfirm = $state<{
amountPence: number;
overflowPence: number;
// Actual amount the backend will charge. Deposits are sent RAW and the
// backend charges amountPence minus the eligible campaign credit, so
// this can differ from amountPence (mirrors UserPaymentModal).
chargePence?: number;
depositAmount: number;
body: Record<string, unknown>;
} | null>(null);
function cancelOverflowConfirmation() {
overflowConfirm = null;
isProcessingPayment = false;
isProcessingPaymentSync = false;
}
// Confirm the pre-start overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip.
async function confirmOverflowPayment() {
const pending = overflowConfirm;
if (!pending || isProcessingPayment) return;
isProcessingPayment = true;
isProcessingPaymentSync = true;
try {
await submitDepositPayment({
body: pending.body,
amountPence: pending.amountPence,
depositAmount: pending.depositAmount,
confirmOverflowTip: true
});
} finally {
isProcessingPayment = false;
isProcessingPaymentSync = false;
}
}
// Cached idempotency key per deposit attempt (amount + card): reused on
// retry so a lost-response retry dedups instead of double-charging,
// regenerated when the amount or card changes. Matches the tip-flow pattern.
let depositIdempotencyKey = $state('');
let depositKeyedAmount = $state(0);
let depositKeyedCard = $state('');
async function fetchPaymentMethods() {
if (paymentMethodsLoading || paymentMethods.length > 0) return;
if (!authStore.isAuthenticated) return;
paymentMethodsLoading = true;
try {
const response = await apiFetch('/api/user/payment-methods');
if (response.ok) {
// CardSelection auto-selects the default card once cards load.
paymentMethods = await response.json();
}
} catch {
// non-fatal — the new-card form remains available
} finally {
paymentMethodsLoading = false;
}
}
// Fetch user deposit and active booking status when step 1 is reached
$effect(() => {
if (currentStep === 1 && authStore.isAuthenticated) {
fetchUserDepositsRequired();
fetchActiveBookingStatus();
}
});
// Fetch saved cards when the deposit payment step is shown. The
// paymentMethodsFetched guard makes this run exactly ONCE per mount: the
// effect body re-runs on unrelated state changes, but the guard short-
// circuits before fetchPaymentMethods() can read/write any tracked state,
// so no fetch can be triggered by the empty-array assignment (the root
// cause of the infinite refetch loop for users with zero saved cards).
// Once-per-mount is acceptable — a user who navigates away and back keeps
// the already-loaded card list.
$effect(() => {
if (
currentStep === finalStep &&
depositRequired &&
authStore.isAuthenticated &&
!paymentMethodsFetched
) {
paymentMethodsFetched = true;
fetchPaymentMethods();
}
});
// =============== Slot Reservation System ===============
let _reservationId = $state<string | null>(null);
let reservationExpiresAt = $state<Date | null>(null);
let reservationCountdown = $state<string>('');
let reservationExpired = $state(false);
// Track the reserved slot so we can detect changes on back-navigate in nextStep()
let _reservedSlotTime: string | null = $state(null);
let _reservedSlotDate: string | null = $state(null);
// =============== Slot Reservation Functions ===============
async function reserveSlot() {
try {
if (!selectedDate || !selectedTime) {
toast.error('Please select a date and time');
return false;
}
const [hours, minutes] = selectedTime.split(':').map(Number);
const bookingDate = selectedDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours, minutes, 0, 0);
const startTimeISO = formatLocalDateTime(bookingDate);
const serviceIds = selectedServices.map((s) => s.id);
const response = await apiFetch('/api/bookings/reserve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ start_time: startTimeISO, service_ids: serviceIds })
});
if (!response.ok) {
if (response.status === 429) {
toast.error('Too many active reservations. Please wait or log in.');
} else if (response.status === 409) {
toast.error('This time slot is no longer available. Please choose a different time.');
await refreshAvailableHours();
} else {
toast.error('Failed to reserve slot. Please try again.');
}
return false;
}
const data = await response.json();
_reservationId = data.id;
reservationExpiresAt = new Date(data.expires_at);
reservationExpired = false;
_reservedSlotTime = selectedTime;
_reservedSlotDate = selectedDate ? selectedDate.toString() : null;
startCountdown();
return true;
} catch {
toast.error('Network error while reserving slot.');
return false;
}
}
// Best-effort release of the currently held reservation. Idempotent: safe
// to call when no reservation exists. Clears all reservation state so a
// subsequent reservation must be re-acquired.
async function releaseReservation() {
if (!_reservationId) return;
const idToRelease = _reservationId;
// Clear local state first so a slow DELETE doesn't block the UI.
_reservationId = null;
reservationExpiresAt = null;
reservationCountdown = '';
reservationExpired = false;
_reservedSlotTime = null;
_reservedSlotDate = null;
if (window.__bookingFlowCountdownInterval) {
clearInterval(window.__bookingFlowCountdownInterval);
window.__bookingFlowCountdownInterval = null;
}
try {
const res = await apiFetch('/api/bookings/reserve', { method: 'DELETE' });
if (!res.ok) console.warn('Failed to release reservation', idToRelease, res.status);
} catch (e) {
console.warn('Error releasing reservation', idToRelease, e);
}
}
function startCountdown() {
if (!reservationExpiresAt) return;
// Clear any existing countdown interval to prevent duplicates
if (window.__bookingFlowCountdownInterval) {
clearInterval(window.__bookingFlowCountdownInterval);
window.__bookingFlowCountdownInterval = null;
}
const updateCountdown = () => {
if (!reservationExpiresAt) {
reservationCountdown = '';
return;
}
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
reservationCountdown = '00:00';
reservationExpired = true;
_reservationId = null;
reservationExpiresAt = null;
_reservedSlotTime = null;
_reservedSlotDate = null;
if (window.__bookingFlowCountdownInterval) {
clearInterval(window.__bookingFlowCountdownInterval);
window.__bookingFlowCountdownInterval = null;
}
toast.error('Your reservation has expired. Please select a new time slot.');
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
reservationCountdown = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
};
updateCountdown();
window.__bookingFlowCountdownInterval = setInterval(() => {
if (reservationExpired) {
if (window.__bookingFlowCountdownInterval) {
clearInterval(window.__bookingFlowCountdownInterval);
window.__bookingFlowCountdownInterval = null;
}
return;
}
updateCountdown();
}, 1000);
}
async function refreshAvailableHours() {
if (!selectedDate) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
delete availableHoursCache[monthKey];
await fetchHoursForMonth(selectedDate);
}
// =============== Services Management ===============
let services = $state<Service[]>([]);
let servicesLoading = $state(true);
async function fetchServices() {
servicesLoading = true;
try {
const response = await apiFetch('/api/services');
if (response.ok) {
const data: Service[] = await response.json();
// Sort: valid patch tests first (by name), then grayed out (by name)
const valid: Service[] = [];
const grayedOut: Service[] = [];
for (const service of data) {
if (service.patch_test_status === 'required' || service.patch_test_status === 'expired') {
grayedOut.push(service);
} else {
valid.push(service);
}
}
// Sort each group alphabetically
valid.sort((a, b) => a.name.localeCompare(b.name));
grayedOut.sort((a, b) => a.name.localeCompare(b.name));
// Combine: valid first, then grayed out
services = [...valid, ...grayedOut];
} else {
toast.error('Failed to load services');
}
} catch {
toast.error('Network error loading services');
} finally {
servicesLoading = false;
}
}
// =============== Working Hours & Available Hours ===============
let workingHours = $state<Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
> | null>(null);
let availableHours = $state<Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
> | null>(null);
let loadingWorkingHours = $state<boolean>(false);
let loadingAvailableHours = $state<boolean>(false);
const workingHoursCache: Record<
string,
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
> = {};
const availableHoursCache: Record<
string,
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
> = {};
// Initialize date boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(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 placeholder = $state<CalendarDate>(minDate);
let userNavigatedCalendar = $state(false);
let bookingFlowAutoSelectDone = $state(false);
$effect(() => {
fetchServices();
ensureBusinessInfo();
});
// Track which months are currently being fetched (prevents duplicate requests)
const loadingMonths: Record<string, boolean> = {};
// Preload current + next month on first render; subsequent months fetched individually
let initialLoadDone = $state(false);
$effect(() => {
if (!initialLoadDone) {
// Pre-seed cache for current + next month
for (let i = 0; i < 2; i++) {
let mYear = placeholder.year;
let mMonth = placeholder.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
if (!(key in workingHoursCache)) {
workingHoursCache[key] = null as unknown as Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
>;
availableHoursCache[key] = null as unknown as Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
>;
loadingMonths[key] = true;
}
}
fetchHoursRange(placeholder, 2);
initialLoadDone = true;
}
});
// Safety net: fetch silently when navigating to an uncached month
// (uses skipLoadingFlags=true to prevent layout shift / scroll snap)
$effect(() => {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (initialLoadDone && !(monthKey in workingHoursCache)) {
fetchHoursForMonth(placeholder, true);
}
});
// Data-driven auto-selection: auto-select the first available date when data loads
$effect(() => {
if (
workingHours &&
availableHours &&
!selectedDate &&
selectedServices.length > 0 &&
!userNavigatedCalendar &&
!bookingFlowAutoSelectDone
) {
bookingFlowAutoSelectDone = true;
const currentDate = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new Date(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
);
const daysDifference = Math.floor(
(maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
if (workingHours[dateStr]?.isOpen) {
const calDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
nextDate.getDate()
);
if (!isDateUnavailable(calDate)) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(nextDate.getFullYear(), nextDate.getMonth() + 1, 1);
}
return;
}
}
}
const tomorrowCal = getLondonTodayCalendarDate();
const tomorrowDate = new CalendarDate(
tomorrowCal.year,
tomorrowCal.month,
tomorrowCal.day + 1
);
selectedDate = tomorrowDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
}
}
});
// 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;
}
});
async function fetchHoursRange(startDate: CalendarDate, months: number) {
// Calculate end month manually (CalendarDate is immutable)
let endYear = startDate.year;
let endMonth = startDate.month + months - 1;
while (endMonth > 12) {
endMonth -= 12;
endYear++;
}
const endMonthDate = new CalendarDate(endYear, endMonth, 1);
const daysInEndMonth = endMonthDate.calendar.getDaysInMonth(endMonthDate);
const startStr = startDate.toString();
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
loadingWorkingHours = true;
loadingAvailableHours = true;
try {
const [whRes, ahRes] = await Promise.all([
apiFetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
apiFetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`)
]);
if (!whRes.ok || !ahRes.ok) {
throw new Error(`HTTP error! wh: ${whRes.status}, ah: ${ahRes.status}`);
}
const whData: Array<WorkingHoursDay> = await whRes.json();
const ahData: Array<AvailableHoursDay> = await ahRes.json();
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => {
whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime };
});
const ahMap: Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
> = {};
ahData.forEach((d) => {
ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots };
});
// Cache by month key
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
workingHoursCache[key] = whMap;
availableHoursCache[key] = ahMap;
delete loadingMonths[key];
}
// MERGE instead of replace — preserves data from previously loaded months
workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
} catch {
// Clean up loadingMonths for the range
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
delete loadingMonths[key];
}
if (!selectedDate) {
selectedDate = minDate;
}
} finally {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
async function fetchHoursForMonth(date: CalendarDate, skipLoadingFlags = false) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
// Only use cache if the value is truthy (not a pre-seeded null placeholder)
if (workingHoursCache[monthKey] && availableHoursCache[monthKey]) {
// MERGE instead of replace — preserves data from other loaded months
workingHours = { ...workingHours, ...workingHoursCache[monthKey] };
availableHours = { ...availableHours, ...availableHoursCache[monthKey] };
return;
}
// Prevent duplicate concurrent requests for the same month
if (loadingMonths[monthKey]) return;
loadingMonths[monthKey] = true;
if (!skipLoadingFlags) {
loadingWorkingHours = true;
loadingAvailableHours = true;
}
try {
const startOfMonth = new CalendarDate(date.year, date.month, 1);
const endOfMonth = new CalendarDate(
date.year,
date.month,
date.calendar.getDaysInMonth(date)
);
const startStr = startOfMonth.toString();
const endStr = endOfMonth.toString();
// Fetch working hours
const workingHoursResponse = await apiFetch(
`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`
);
if (!workingHoursResponse.ok) {
throw new Error(`HTTP error! status: ${workingHoursResponse.status}`);
}
const workingHoursData: Array<WorkingHoursDay> = await workingHoursResponse.json();
const workingHoursMap: Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
> = {};
workingHoursData.forEach((day) => {
workingHoursMap[day.date] = {
isOpen: day.isOpen,
startTime: day.startTime,
endTime: day.endTime
};
});
workingHoursCache[monthKey] = workingHoursMap;
workingHours = { ...workingHours, ...workingHoursMap };
// Fetch available hours
const availableHoursResponse = await apiFetch(
`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`
);
if (!availableHoursResponse.ok) {
throw new Error(`HTTP error! status: ${availableHoursResponse.status}`);
}
const availableHoursData: Array<AvailableHoursDay> = await availableHoursResponse.json();
const availableHoursMap: Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
> = {};
availableHoursData.forEach((day) => {
availableHoursMap[day.date] = {
isOpen: day.isOpen,
slots: day.slots
};
});
availableHoursCache[monthKey] = availableHoursMap;
availableHours = { ...availableHours, ...availableHoursMap };
} catch {
if (!selectedDate) {
selectedDate = minDate;
}
} finally {
delete loadingMonths[monthKey];
if (!skipLoadingFlags) {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
}
// =============== Time Slot Generation ===============
function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
const date = new Date();
date.setHours(hours, minutes, 0, 0);
date.setMinutes(date.getMinutes() + durationMinutes);
const endHours = date.getHours().toString().padStart(2, '0');
const endMinutes = date.getMinutes().toString().padStart(2, '0');
return `${endHours}:${endMinutes}`;
}
function timeToMinutes(time: string): number {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
}
function calculatePreviousTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
let totalMinutes = hours * 60 + minutes;
totalMinutes -= 15;
const prevHours = Math.floor(totalMinutes / 60);
const prevMinutes = totalMinutes % 60;
return `${String(prevHours).padStart(2, '0')}:${String(prevMinutes).padStart(2, '0')}`;
}
function generateAvailableTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
if (!date || !workingHours || !availableHours) {
return [];
}
const dateStr = date.toString();
const dayWorkingHours = workingHours[dateStr];
const dayAvailableHours = availableHours[dateStr];
if (
!dayWorkingHours ||
!dayWorkingHours.isOpen ||
!dayAvailableHours ||
!dayAvailableHours.slots
) {
return [];
}
const slots: string[] = [];
const today = getLondonTodayCalendarDate();
const now = new Date();
const londonTimeStr = now.toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
const isToday = date.compare(today) === 0;
for (const slot of dayAvailableHours.slots) {
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
let startTotalMinutes = Math.ceil((startHour * 60 + startMinute) / 15) * 15;
const endTotalMinutes = endHour * 60 + endMinute;
if (isToday) {
const currentMinutes = londonHours * 60 + londonMinutes;
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
const slotEndMinutes = minutes + duration;
if (slotEndMinutes <= endTotalMinutes) {
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
slots.push(timeStr);
}
}
}
return slots;
}
function generateGroupedTimeSlots(
duration: number,
date: CalendarDate | undefined,
lunchProtectionMap: Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
> = new Map()
): Array<{
type: 'available' | 'unavailable';
startTime: string;
endTime: string;
isGrouped?: boolean;
}> {
if (!date || !workingHours) {
return [];
}
const dateStr = date.toString();
const dayWorkingHours = workingHours[dateStr];
if (!dayWorkingHours || !dayWorkingHours.isOpen) {
return [];
}
const groupedSlots: Array<{
type: 'available' | 'unavailable';
startTime: string;
endTime: string;
isGrouped?: boolean;
}> = [];
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
const todayCal = getLondonTodayCalendarDate();
const now = new Date();
const londonTimeStr = now.toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
const isToday = date.compare(todayCal) === 0;
if (isToday) {
const currentMinutes = londonHours * 60 + londonMinutes;
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
const availableSlots = generateAvailableTimeSlots(duration, date);
let currentUnavailableStart: string | null = null;
let lastAvailableEndTime: string | null = null;
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
const slotDateTime = date.toDate(getLocalTimeZone());
slotDateTime.setHours(hour, minute, 0, 0);
const hoursUntilSlot = (slotDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
const isBlockedByDepositAdvance =
userDepositsRequired > 0 && hoursUntilSlot < POLICY.DEPOSIT_ADVANCE_HOURS;
const isAvailable =
availableSlots.includes(timeStr) &&
!lunchProtectionMap.get(timeStr)?.isBlocked &&
!isBlockedByDepositAdvance;
if (isAvailable) {
if (currentUnavailableStart !== null) {
const groupEndTime = calculatePreviousTime(timeStr);
const unavailableStartTime = currentUnavailableStart || lastAvailableEndTime;
if (
unavailableStartTime &&
timeToMinutes(unavailableStartTime) < timeToMinutes(groupEndTime)
) {
groupedSlots.push({
type: 'unavailable',
startTime: unavailableStartTime,
endTime: groupEndTime,
isGrouped: true
});
}
currentUnavailableStart = null;
}
const slotEndTime = calculateEndTime(timeStr, duration);
lastAvailableEndTime = slotEndTime;
groupedSlots.push({
type: 'available',
startTime: timeStr,
endTime: slotEndTime
});
if (timeToMinutes(slotEndTime) >= endTotalMinutes) {
break;
}
} else {
if (currentUnavailableStart === null) {
currentUnavailableStart = timeStr;
}
}
}
if (currentUnavailableStart !== null) {
const lastAvailableSlot = groupedSlots.filter((s) => s.type === 'available').pop();
const lastAvailableEnd = lastAvailableSlot ? timeToMinutes(lastAvailableSlot.endTime) : 0;
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
if (unavailableStartMinutes < endTotalMinutes && lastAvailableEnd < endTotalMinutes) {
// Use the end time of the last available slot for the final unavailable period
const unavailableStartTime = lastAvailableSlot
? lastAvailableSlot.endTime
: currentUnavailableStart;
groupedSlots.push({
type: 'unavailable',
startTime: unavailableStartTime,
endTime: dayWorkingHours.endTime,
isGrouped: true
});
}
}
return groupedSlots;
}
// =============== Date Availability Check ===============
function isDateUnavailable(date: DateValue): boolean {
if (!(date instanceof CalendarDate)) {
return true;
}
if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) {
return true;
}
if (!workingHours) return true;
const dateStr = date.toString();
const dayHours = workingHours[dateStr];
if (!dayHours) return true;
if (!dayHours.isOpen) return true;
// No available hours data for this date = data not loaded = unavailable
if (!availableHours?.[dateStr]) return true;
// API returned empty slots = no availability at all
if (!availableHours[dateStr].slots || availableHours[dateStr].slots.length === 0) return true;
if (selectedServices.length === 0) {
return false;
}
const duration = getTotalDuration();
const availableSlots = generateAvailableTimeSlots(duration, date);
if (availableSlots.length === 0) return true;
const dayAvailableHours = availableHours[dateStr];
// dayAvailableHours.slots is already checked above, but keep this guard for safety
if (dayAvailableHours.slots) {
const existingBookings = extractBookedSlots(
dayHours.startTime,
dayHours.endTime,
dayAvailableHours.slots
);
const lunchProtection = getLunchProtectionForSlots(
dayHours.startTime,
dayHours.endTime,
existingBookings,
duration,
15,
false
);
const now = new Date();
const validSlots = availableSlots.filter((t) => {
if (lunchProtection.get(t)?.isBlocked) return false;
if (userDepositsRequired > 0) {
const [h, m] = t.split(':').map(Number);
const slotDate = date.toDate(getLocalTimeZone());
slotDate.setHours(h, m, 0, 0);
const hoursUntil = (slotDate.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursUntil < POLICY.DEPOSIT_ADVANCE_HOURS) return false;
}
return true;
});
if (validSlots.length === 0) return true;
}
return false;
}
// =============== Helper Functions ===============
function getTotalDuration() {
return selectedServices.reduce(
(total, service: Service) => total + service.duration_minutes,
0
);
}
function getTotalPrice() {
return selectedServices.reduce((total, service: Service) => total + service.price, 0);
}
function toggleService(service: Service) {
const index = selectedServices.findIndex((s) => s.id === service.id);
const wasSelected = index >= 0;
if (wasSelected) {
selectedServices = selectedServices.filter((s) => s.id !== service.id);
} else {
selectedServices = [...selectedServices, service];
}
// Service change invalidates any held reservation (duration may have changed).
// Release before re-fetching hours so the next reservation starts fresh.
if (currentStep >= 2 && _reservationId) {
releaseReservation();
}
// Only clear if we're on the date/time selection step
if (currentStep === 2 && selectedDate) {
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
delete availableHoursCache[monthKey];
fetchHoursForMonth(selectedDate);
}
// Reset date selection when services change so auto-select can re-run
bookingFlowAutoSelectDone = false;
selectedDate = undefined;
selectedTime = null;
}
// =============== Lunch Protection for Rendering ===============
function getLunchProtectionStatus() {
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
return new Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
>();
}
const dateStr = selectedDate.toString();
const dayWH = workingHours[dateStr];
const dayAH = availableHours[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) return new Map();
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
return getLunchProtectionForSlots(
dayWH.startTime,
dayWH.endTime,
existingBookings,
getTotalDuration(),
15,
false
);
}
// Select a time slot with server-side re-validation
async function selectTimeWithValidation(time: string) {
// Race-condition guard: capture the user's click and pass it explicitly
// to the validator. If the user clicks a different time before this
// validation completes, the stale result must not clobber the newer
// selection.
selectedTime = time;
// Time change invalidates any held reservation (slot is now stale).
// Only release if the new time differs from the reserved one — if the
// user is re-clicking the same reserved time, keep the reservation.
if (_reservationId && time !== _reservedSlotTime) {
await releaseReservation();
}
await refreshAndValidateSlot(time);
}
// Re-fetch available hours silently and check if the requested time is still
// available. `validateForTime` should be the time the user clicked, NOT the
// current selectedTime — this prevents a slow validation for a stale click
// from clearing a newer selection.
// Uses skipLoadingFlags=true to prevent UI judder (loading spinners hide DatePicker/TimeSlotPicker)
async function refreshAndValidateSlot(validateForTime: string | null = null) {
const timeToCheck = validateForTime ?? selectedTime;
if (!selectedDate || !timeToCheck) return false;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
delete availableHoursCache[monthKey];
await fetchHoursForMonth(selectedDate, true);
const dateStr = selectedDate.toString();
const dayAvailable = availableHours?.[dateStr]?.slots;
if (!dayAvailable || dayAvailable.length === 0) {
toast.error('Sorry, this slot is no longer available. Please choose a different time.');
if (selectedTime === timeToCheck) selectedTime = null;
return false;
}
const duration = getTotalDuration();
const [selHour, selMinute] = timeToCheck.split(':').map(Number);
const selStart = selHour * 60 + selMinute;
const selEnd = selStart + duration;
const stillAvailable = dayAvailable.some((slot) => {
const [sH, sM] = slot.startTime.split(':').map(Number);
const [eH, eM] = slot.endTime.split(':').map(Number);
return selStart >= sH * 60 + sM && selEnd <= eH * 60 + eM;
});
if (!stillAvailable) {
toast.error('Sorry, this slot was just taken. Please choose a different time.');
if (selectedTime === timeToCheck) selectedTime = null;
return false;
}
return true;
}
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours === 0) {
return `${remainingMinutes} minutes`;
} else if (remainingMinutes === 0) {
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
} else {
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
}
}
function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', {
month: 'long'
});
const day = date.day;
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
switch (day % 10) {
case 1:
return monthName + ' ' + day + 'st';
case 2:
return monthName + ' ' + day + 'nd';
case 3:
return monthName + ' ' + day + 'rd';
default:
return monthName + ' ' + day + 'th';
}
}
// =============== Derived Values ===============
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
const lunchProtectionMap = $derived(getLunchProtectionStatus());
const groupedTimeSlots = $derived(
currentStep === 2 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionMap)
: []
);
const formattedSelectedDate = $derived(
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
);
const depositRequired = $derived(calculateDepositRequired());
const totalSteps = $derived(authStore.isAuthenticated ? 4 : 5);
// StepIndicator uses displayNumber = startAt + index. currentStep aligns with displayNumber,
// not the array index. For auth: startAt=1, totalSteps=4 → last displayNumber=4.
// For guest: startAt=0, totalSteps=5 → last displayNumber=4. Always evaluates to 4.
const finalStep = $derived(totalSteps - 1 + (authStore.isAuthenticated ? 1 : 0));
const stepLabels = $derived(
authStore.isAuthenticated
? ['Service', 'Date & Time', 'Details', 'Payment']
: userDepositsRequired > 0
? ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment']
: ['Welcome', 'Service', 'Date & Time', 'Details', 'Confirmation']
);
// =============== Navigation ===============
async function nextStep() {
if (currentStep === 0) {
currentStep = 1;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
return;
}
// Step 2 -> Step 3: Check if we already hold a valid reservation for this exact slot
if (currentStep === 2) {
const sameSlot =
_reservationId &&
!reservationExpired &&
_reservedSlotTime === selectedTime &&
_reservedSlotDate === (selectedDate ? selectedDate.toString() : null);
if (sameSlot) {
// Same slot — keep existing reservation, go straight to step 3
currentStep = 3;
} else {
// Different slot or no reservation — release old one first, then reserve new
await releaseReservation();
const slotStillFree = await refreshAndValidateSlot();
if (!slotStillFree) return;
const reserved = await reserveSlot();
if (!reserved) return;
// reserveSlot() stores _reservedSlotTime/_reservedSlotDate internally
currentStep = 3;
}
return;
}
// Step 3 -> Final step (Payment if deposit required, else submit booking)
if (currentStep === 3) {
if (calculateDepositRequired()) {
currentStep = finalStep;
} else {
await submitAndProceed();
}
return;
}
// Final step with deposit: user must pay before booking is created.
// Payment is handled by processPayment(), not nextStep().
// This guards against manual increment from the payment step.
if (currentStep === finalStep && calculateDepositRequired()) {
return;
}
if (currentStep < finalStep) {
currentStep++;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
}
}
async function submitAndProceed() {
try {
// Always generate a fresh idempotency key per submission attempt. The
// previous behavior kept the key across the component lifetime, which
// meant a second submission (after a success, back-nav, change) would
// hit the backend with the same key and get the ORIGINAL booking back,
// making the user believe they made a new booking when they didn't.
idempotencyKey = generateUUID();
if (!selectedDate || !selectedTime) {
toast.error('Please select a date and time');
return;
}
const [hours, minutes] = selectedTime.split(':').map(Number);
const bookingDate = selectedDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours, minutes, 0, 0);
const startTimeISO = formatLocalDateTime(bookingDate);
const serviceIds = selectedServices.map((s) => s.id);
let guestUserId: string | null = null;
if (!authStore.isAuthenticated) {
const guestResponse = await fetch('/api/users/guest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
email: customerInfo.email,
phone: toE164UK(customerInfo.phone) ?? customerInfo.phone
})
});
if (!guestResponse.ok) {
if (guestResponse.status === 409) {
toast.error('Email already registered — please log in to book.');
} else {
toast.error('Failed to create guest account. Please try again.');
}
return;
}
const guestData = await guestResponse.json();
guestUserId = guestData.id;
}
const requestBody: Record<string, unknown> = {
service_ids: serviceIds,
start_time: startTimeISO,
notes: customerInfo.specialRequests || null
};
if (guestUserId) {
requestBody.user_id = guestUserId;
}
const response = await apiFetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
...getAuthHeaders()
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
const booking = await response.json();
confirmedBooking = {
id: booking.id,
status: booking.status,
start_time: booking.start_time,
notes: booking.notes || '',
deposit_required: booking.deposit_required ?? false,
deposit_paid: booking.deposit_paid ?? false,
deposit_amount: booking.deposit_amount ?? 0,
amount_paid: booking.amount_paid ?? 0,
amount_due: booking.amount_due || getTotalPrice(),
payments: booking.payments ?? [],
total_amount: booking.total_amount || getTotalPrice(),
duration_minutes: booking.duration_minutes || getTotalDuration()
};
// Booking is now persisted — release the temporary reservation
// (best-effort, no UI block). Also clear the idempotency key so
// any future submission in this component lifetime gets a fresh
// key and is treated as a NEW booking attempt.
releaseReservation();
idempotencyKey = '';
currentStep = finalStep;
fetchDiscountPreview();
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
} else {
const errorText = await response.text();
let errorMessage = extractErrorMessage(errorText);
if (!errorMessage) {
errorMessage = 'Failed to submit booking. Please try again.';
}
if (response.status === 409) {
if (errorMessage.includes('active booking') || errorMessage.includes('already have')) {
toast.error(
'You already have an active booking. Please complete or cancel it before creating a new one.'
);
} else {
toast.error('This time slot is no longer available. Please choose a different time.');
}
} else if (errorMessage.includes('patch test') || errorMessage.includes('Patch test')) {
toast.error(errorMessage + ' Please complete a patch test first.');
} else if (
errorMessage.includes('48 hours') ||
errorMessage.includes('48h') ||
errorMessage.includes('advance')
) {
toast.error(errorMessage);
} else if (errorMessage.includes('deposit') || errorMessage.includes('Deposit')) {
toast.error(errorMessage);
} else if (response.status === 400) {
toast.error(errorMessage);
} else {
toast.error('Failed to submit booking: ' + errorMessage);
}
}
} catch {
toast.error('Network error. Please check your connection and try again.');
}
}
function prevStep() {
if (currentStep > 1) {
// If leaving step 2 (date/time) and a reservation is still held, release
// it — the user is abandoning this selection. Going from step 3 back to
// step 2 keeps the reservation (the user is just reviewing).
if (currentStep === 2 && _reservationId) {
releaseReservation();
}
currentStep--;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
}
}
// Clean up countdown interval on component destroy (do NOT call DELETE — let TTL expire naturally)
onDestroy(() => {
if (typeof window !== 'undefined' && window.__bookingFlowCountdownInterval) {
clearInterval(window.__bookingFlowCountdownInterval);
window.__bookingFlowCountdownInterval = null;
}
});
// =============== Validation ===============
const isBlockedByActiveBooking = $derived(
authStore.isAuthenticated && userDepositsRequired > 0 && hasActiveBooking
);
const canProceedStep1 = $derived(selectedServices.length > 0 && !isBlockedByActiveBooking);
const canProceedStep2 = $derived(!!(selectedDate && selectedTime) && !isBlockedByActiveBooking);
const canProceedStep3 = $derived(
!isBlockedByActiveBooking &&
(authStore.isAuthenticated
? !!(
authStore.currentUser?.firstName &&
authStore.currentUser?.lastName &&
authStore.currentUser?.email &&
authStore.currentUser?.phone
)
: !!(
customerInfo.firstName &&
customerInfo.lastName &&
customerInfo.email &&
emailFormatValid &&
customerInfo.phone &&
isValidUKPhone(customerInfo.phone) &&
!emailSuggestion
)) &&
!reservationExpired
);
</script>
<div class="mx-auto max-w-4xl p-6">
<div class="mb-8 text-center">
<h1 class="mb-2 font-['Playfair_Display'] text-4xl font-bold">Book Your Appointment</h1>
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
</div>
{#if !authReady}
<div class="mb-6 flex items-center justify-center gap-2">
{#each [1, 2, 3, 4] as _ (_)}
<div class="flex items-center gap-1">
<div class="h-8 w-8 animate-pulse rounded-full bg-gray-200"></div>
<div class="h-3 w-16 animate-pulse rounded bg-gray-200"></div>
</div>
{#if _ < 4}
<div class="mx-1 h-0.5 w-8 animate-pulse rounded bg-gray-200"></div>
{/if}
{/each}
</div>
<Card.Root>
<Card.Header>
<div class="h-7 w-40 animate-pulse rounded bg-gray-200"></div>
<div class="mt-2 h-4 w-64 animate-pulse rounded bg-gray-200"></div>
</Card.Header>
<Card.Content class="space-y-4">
<div class="h-16 animate-pulse rounded-lg bg-gray-100"></div>
<div class="h-16 animate-pulse rounded-lg bg-gray-100"></div>
<div class="h-16 animate-pulse rounded-lg bg-gray-100"></div>
</Card.Content>
</Card.Root>
{:else}
<StepIndicator
{currentStep}
steps={stepLabels}
startAt={authStore.isAuthenticated ? 1 : 0}
className={currentStep === 0 ? 'md:hidden' : ''}
/>
{#if currentStep === 0}
<Card.Root class="border-fuchsia-200 bg-fuchsia-50">
<Card.Header class="text-center">
<Card.Title>Welcome</Card.Title>
<Card.Description>Log in for the best booking experience</Card.Description>
</Card.Header>
<Card.Content class="p-6 text-center">
<p class="mb-4 text-sm text-muted-foreground">
Guest checkout does not receive loyalty stamps or seasonal discounts.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4">
<Button
onclick={() => goto(resolve('/login'))}
variant="outline"
class="border-fuchsia-200 hover:bg-fuchsia-100"
>
Log In
</Button>
<Button onclick={nextStep}>Continue as Guest</Button>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Step 1: Service Selection -->
{#if currentStep === 1}
<Card.Root>
<Card.Header>
<Card.Title>Choose Your Services</Card.Title>
<Card.Description>Select one or more treatments for your appointment</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Warning: Active booking limit for deposit-owing users -->
{#if authStore.isAuthenticated && userDepositsRequired > 0 && hasActiveBooking}
<div class="rounded-lg border border-amber-200/60 bg-amber-50 p-4">
<div class="flex gap-3">
<div class="flex-shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-amber-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="space-y-1 text-sm text-amber-900">
<p class="font-semibold text-amber-800">One Booking at a Time</p>
<p>
We are currently asking for deposits on upcoming bookings. While this is active,
only one online booking can be made at a time. If you need another appointment
please <a
href="/contact"
target="_blank"
rel="noopener noreferrer external"
class="font-medium underline">contact us</a
>.
</p>
</div>
</div>
</div>
{/if}
<ServiceSelector
{services}
selected={selectedServices}
loading={servicesLoading}
ontoggle={toggleService}
/>
<div
class="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4 text-center"
>
<p class="text-sm text-gray-600">
Need something different? Select the closest service and add a note, or contact us for
a bespoke treatment.
</p>
<a
href={resolve('/contact')}
class="mt-1 inline-block text-sm font-medium text-blue-600 hover:underline"
>
Arrange a custom booking →
</a>
</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()}{#if vatRegistered}
<span class="text-xs font-normal text-gray-400">incl. VAT</span>{/if}</span
>
</div>
</div>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-end">
<BookingActions
canBack={!authStore.isAuthenticated}
canNext={canProceedStep1}
nextLabel="Next: Select Date & Time"
on:next={nextStep}
/>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 2: Date & Time Selection -->
{#if currentStep === 2}
<Card.Root>
<Card.Header>
<Card.Title>Choose Date & Time</Card.Title>
<Card.Description>
{selectedServices.map((s) => s.name).join(', ')}{formattedTotalDuration} total • £{getTotalPrice()}
</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<Card.Root class="gap-0 border-0 p-0">
<Card.Content class="relative p-0 md:pr-56">
{#if loadingWorkingHours}
<div class="flex items-center justify-center p-6">
<p>Loading available dates...</p>
</div>
{:else}
<DatePicker
date={selectedDate}
{placeholder}
minValue={minDate}
maxValue={maxCalendarDate}
{isDateUnavailable}
onchange={(newDate) => {
// Date change invalidates any held reservation (slot is now stale).
if (_reservationId) {
releaseReservation();
}
selectedDate = newDate;
selectedTime = null;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
userNavigatedCalendar = true;
}}
/>
{/if}
{#if loadingAvailableHours}
<div
class="absolute inset-y-0 right-0 hidden w-56 items-center justify-center border-l p-6 md:flex"
>
<p class="text-sm text-gray-500">Loading times...</p>
</div>
<!-- md:hidden twin: time slots render in-flow below the calendar on mobile -->
<div class="flex items-center justify-center gap-2 border-t p-6 md:hidden">
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-amber-600"
></div>
<p class="text-sm text-gray-500">Loading available hours...</p>
</div>
{:else}
<TimeSlotPicker
date={selectedDate}
{groupedTimeSlots}
{selectedTime}
formattedDate={formattedSelectedDate}
onselect={(time) => {
selectTimeWithValidation(time);
}}
/>
{/if}
</Card.Content>
</Card.Root>
</Card.Content>
<!-- Mobile appointment summary -->
<div class="border-t px-6 py-4 text-center text-sm md:hidden">
{#if selectedDate && selectedTime}
Appointment for
<span class="font-medium">
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'short'
})}
</span>
<br />at <span class="font-medium">{selectedTime}</span>
{:else}
Select a date and time
{/if}
</div>
<Card.Footer class="flex justify-between border-t px-6 !py-5">
<Button variant="outline" onclick={prevStep}>Back</Button>
<div class="flex items-center space-x-4">
<!-- Desktop appointment summary -->
<div class="hidden text-sm md:block">
{#if selectedDate && selectedTime}
Appointment for
<span class="font-medium">
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'short'
})}
</span>
at <span class="font-medium">{selectedTime}</span>
{:else}
Select a date and time
{/if}
</div>
<Button disabled={!canProceedStep2} onclick={nextStep}>Next: Your Details</Button>
</div>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 3: Customer Details -->
{#if currentStep === 3}
<div class="mb-6 text-center">
<h2 class="font-['Playfair_Display'] text-2xl font-bold">Almost There</h2>
{#if !authStore.isAuthenticated}
<p class="mt-1 text-gray-500">Just a couple more details</p>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>Your Details</Card.Title>
<Card.Description>Please confirm your contact information</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
{#if reservationExpired}
<div class="rounded-lg bg-red-50 p-4 text-center">
<p class="text-red-600">Reservation expired — please go back and select a new time</p>
</div>
{:else}
<div class="rounded-lg bg-blue-50 p-4 text-center">
<p class="text-blue-700">
Your slot will be held for {reservationCountdown} — complete your booking before time
expires
</p>
</div>
{/if}
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
showCustomer={false}
/>
{#if !authStore.isAuthenticated}
<p class="mb-4 text-center text-sm text-yellow-600">
You are checking out as a guest, so you will miss out on a loyalty stamp and any
possible seasonal discounts. Please login for full membership benefits.
</p>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="firstName">First Name *</Label>
<Input
id="firstName"
bind:value={customerInfo.firstName}
placeholder="Enter your first name"
onblur={debouncedEmailCheck}
/>
</div>
<div class="space-y-2">
<Label for="lastName">Last Name *</Label>
<Input
id="lastName"
bind:value={customerInfo.lastName}
placeholder="Enter your last name"
onblur={debouncedEmailCheck}
/>
</div>
<div class="space-y-2">
<Label for="email">Email *</Label>
<EmailInput
id="email"
bind:value={customerInfo.email}
placeholder="Enter your email"
required
onvaluechange={() => {
emailSuggestion = null;
debouncedEmailCheck();
}}
onerrorchange={(err) => {
emailError = err;
}}
onblur={() => {
debouncedEmailCheck();
}}
/>
{#if emailError}
<span class="text-xs font-medium text-red-500">{emailError}</span>
{/if}
{#if emailChecking}
<span class="text-xs text-gray-500">Checking...</span>
{/if}
</div>
<div class="space-y-2">
<Label for="phone">Phone Number *</Label>
<PhoneInput
id="phone"
bind:value={customerInfo.phone}
placeholder="07123 456789"
onerrorchange={(err) => {
if (!err) debouncedEmailCheck();
}}
/>
</div>
</div>
{#if emailSuggestion === 'login'}
<p class="text-xs font-medium text-red-500">
This email belongs to a registered user. Please
<a href={resolve('/login')} class="underline hover:text-red-800">log in</a>
instead to access your bookings and rewards.
</p>
{:else if emailSuggestion === 'check'}
<p class="text-xs font-medium text-amber-600">
This email might belong to an existing account. Please double-check or
<a href={resolve('/login')} class="underline hover:text-amber-800">log in</a>.
</p>
{/if}
{/if}
<div class="space-y-2">
<Label for="requests">Special Requests (Optional)</Label>
<Textarea
id="requests"
bind:value={customerInfo.specialRequests}
placeholder="Any allergies, preferences, or special requirements..."
rows={3}
/>
<CharCounter text={customerInfo.specialRequests} />
</div>
<div class="text-sm text-gray-600">
{#if !authStore.isAuthenticated}
<p>* Required fields</p>
{/if}
<p class="mt-2">
By booking, you agree to our Terms & Conditions and Privacy Policy. We'll send you
appointment reminders via email and/or SMS.
</p>
<p class="mt-2 text-xs text-gray-500">
<strong>Cancellation Policy:</strong> If paying early a free full refund will be given if
cancelled more than 72 hours before your appointment. Between 24-72 hours, up to 50% of
the booking total may be retained as a protected deposit. Cancellations within 24 hours
are non-refundable and count as a no-show against your account.
</p>
<p class="mt-1 text-xs text-gray-500">
<PolicyPopover>
{#snippet trigger()}
<span class="underline">Read full cancellation policy →</span>
{/snippet}
</PolicyPopover>
</p>
</div>
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={prevStep}>Back</Button>
<Button
disabled={!canProceedStep3}
onclick={nextStep}
class="bg-primary text-primary-foreground"
>
{#if reservationExpired}
Reservation Expired
{:else if depositRequired}
Next: Payment
{:else}
Confirm Booking
{/if}
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 4: Payment & Confirmation (final step) -->
{#if currentStep === finalStep}
{#if confirmedBooking && (depositPaid || !depositRequired || confirmedBooking.deposit_paid)}
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
{@const bookingDate = parseWallClockDate(confirmedBooking.start_time)}
{@const dateStr = bookingDate.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
{@const timeStr = bookingDate.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
<Card.Root class="border-emerald-200">
{#if isProcessingPayment && paymentAttempted}
<Card.Header class="text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-amber-600"
></div>
</div>
<Card.Title class="text-2xl font-bold">Processing Payment</Card.Title>
<Card.Description class="mt-2 text-base">
Your booking is confirmed. We're processing your payment — this should only take a
moment.
</Card.Description>
</Card.Header>
{:else}
<Card.Header class="text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full {isRequested
? 'bg-amber-100'
: 'bg-emerald-100'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 {isRequested ? 'text-amber-600' : 'text-emerald-600'}"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
<Card.Title class="text-2xl font-bold"
>{isRequested ? 'Booking Requested' : 'Booking Confirmed'}</Card.Title
>
<Card.Description class="mt-2 text-base">
{isRequested
? "Your booking has been submitted and is awaiting approval. We'll notify you once it's confirmed."
: 'Your appointment has been booked successfully.'}
</Card.Description>
</Card.Header>
{/if}
<Card.Content class="space-y-6">
{#if !(isProcessingPayment && paymentAttempted)}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium text-gray-500">Confirmation Number</span>
<span class="font-mono text-lg font-bold text-gray-900"
>{confirmedBooking.id}</span
>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Date</div>
<div class="font-medium">{dateStr}</div>
</div>
<div>
<div class="text-xs text-gray-500">Time</div>
<div class="font-medium">{timeStr}</div>
</div>
<div>
<div class="text-xs text-gray-500">Duration</div>
<div class="font-medium">{getTotalDuration()} minutes (estimated)</div>
</div>
<div>
<div class="text-xs text-gray-500">Status</div>
<div class="font-medium">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {isRequested
? 'bg-amber-100 text-amber-800'
: 'bg-emerald-100 text-emerald-800'}"
>
{isRequested ? 'Pending Approval' : 'Confirmed'}
</span>
</div>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
<h4 class="mb-3 text-sm font-semibold text-gray-600 uppercase">Services</h4>
<div class="space-y-2">
{#each selectedServices as service (service.id)}
<div class="flex justify-between text-sm">
<span>{service.name}</span>
<span class="text-gray-600"
>{service.duration_minutes} min • £{service.price}</span
>
</div>
{/each}
<div class="border-t pt-2">
{#if discountPreview?.eligible}
{#each discountPreview.discounts as d (d.name)}
<div class="flex justify-between text-sm text-gray-600">
<span>{d.name}</span>
<span>{d.amount.toFixed(2)}</span>
</div>
{/each}
<div class="flex justify-between font-semibold text-emerald-700">
<span>Estimated Total After Discount</span>
<span>£{discountPreview.discounted_total.toFixed(2)}</span>
</div>
{:else}
<div class="flex justify-between font-semibold">
<span>Total (estimated)</span>
<span
>£{getTotalPrice()}{#if vatRegistered}
<span class="text-xs font-normal text-gray-400">incl. VAT</span
>{/if}</span
>
</div>
{/if}
</div>
</div>
</div>
{#if isRequested}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="text-sm text-amber-800">
<strong>Please note:</strong> Because you included special requests, the cost
and duration shown are estimates. We may adjust these after reviewing your
requirements. You'll receive
{authStore.isAuthenticated ? ' a notification' : ' an email'} once your booking is
approved.
</p>
</div>
{/if}
{#if authStore.isAuthenticated && authStore.currentUser?.role !== 'admin' && authStore.currentUser?.role !== 'guest'}
{#if depositPaid}
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-6 text-center">
<div
class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100"
>
<svg class="h-5 w-5 text-emerald-600" viewBox="0 0 20 20" fill="currentColor"
><path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/></svg
>
</div>
<h3 class="text-lg font-semibold text-emerald-800">Deposit Paid</h3>
<p class="mt-1 text-emerald-700">
Your deposit of <strong>£{calculateDepositAmount().toFixed(2)}</strong> has been
paid successfully. See you at your appointment!
</p>
</div>
{:else if depositRequired}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
<h3 class="mb-2 text-lg font-semibold text-amber-800">Deposit Not Paid</h3>
<p class="mb-4 text-amber-700">
Your booking is confirmed but the deposit of <strong
>£{calculateDepositAmount().toFixed(2)}</strong
>
was not paid. If the deposit remains unpaid within 24 hours of your appointment,
the slot may be released and the booking could be cancelled or rebooked by someone
else.
</p>
<p class="mb-4 text-xs text-amber-600">
<PolicyPopover>
{#snippet trigger()}
<span class="underline">Read our cancellation policy →</span>
{/snippet}
</PolicyPopover>
</p>
<Button
onclick={() => (showPayEarlyModal = true)}
class="bg-amber-600 text-white hover:bg-amber-700"
>
Pay Deposit Now
</Button>
</div>
{:else}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
<h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3>
<p class="mb-4 text-gray-600">
You can pay when you arrive, or pay ahead of time to speed things up.
</p>
<Button
onclick={() => (showPayEarlyModal = true)}
class="bg-emerald-600 text-white hover:bg-emerald-700"
>
Pay Early
</Button>
</div>
{/if}
{/if}
{/if}
</Card.Content>
<Card.Footer class="flex justify-center">
<Button
onclick={() => (window.location.href = authStore.isAuthenticated ? '/schedule' : '/')}
class="w-full"
>
{authStore.isAuthenticated ? 'View My Bookings' : 'Return Home'}
</Button>
</Card.Footer>
</Card.Root>
{:else if depositRequired}
<Card.Root>
<Card.Header>
<Card.Title>Pay Your Deposit</Card.Title>
<Card.Description>
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required
to secure your appointment.
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
{#if overflowConfirm}
<!-- Pre-start overpayment confirmation: the backend rejected the
payment because the booking's remaining balance has changed
since it was loaded (stale data). The excess over the
remaining balance will be recorded as a tip once confirmed.
Shared markup with the customer payment modal
(OverflowTipConfirm) so the two surfaces can't drift. -->
<OverflowTipConfirm
overflowPence={overflowConfirm.overflowPence}
discountNote={overflowConfirm.chargePence !== undefined &&
overflowConfirm.chargePence < overflowConfirm.amountPence
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
: undefined}
loading={isProcessingPayment}
onConfirm={confirmOverflowPayment}
onCancel={cancelOverflowConfirmation}
/>
{:else}
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={authStore.isAuthenticated
? {
firstName: authStore.currentUser?.firstName ?? '',
lastName: authStore.currentUser?.lastName ?? '',
email: authStore.currentUser?.email ?? '',
phone: authStore.currentUser?.phone ?? '',
specialRequests: customerInfo.specialRequests
}
: customerInfo}
showCustomer={true}
/>
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="mb-6 py-4 text-center text-gray-500">
Loading payment methods...
</div>
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={paymentMethods}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={[]}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}
<!-- B6/B10: saved-card deposits require the customer's
current 2FA verification code when the backend
enforces the gate. -->
<div class="mb-6">
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid || twoFactor.missing}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit ${formatCurrency(
depositChargePence(
Math.round(calculateDepositAmount() * 100),
campaignDiscountPence(discountPreview)
)
)}`}
</Button>
</div>
<p class="mt-4 text-center text-xs text-gray-500">
Secure payment powered by Square
</p>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
{/if}
{#if showPayEarlyModal && confirmedBooking}
{@const bk = confirmedBooking}
<UserPaymentModal
booking={{
id: bk.id,
status: bk.status as BookingStatus,
start_time: bk.start_time,
notes: bk.notes,
services: selectedServices.map((s) => ({
booking_id: bk.id,
service_id: s.id,
service_name: s.name,
price: s.price,
duration_minutes: s.duration_minutes
})) as BookingService[],
total_amount: bk.total_amount,
amount_paid: bk.amount_paid,
amount_due: bk.amount_due,
deposit_required: bk.deposit_required,
deposit_paid: bk.deposit_paid,
payments: bk.payments,
duration_minutes: bk.duration_minutes,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}}
onClose={() => (showPayEarlyModal = false)}
onComplete={() => {
showPayEarlyModal = false;
}}
{canSaveCards}
/>
{/if}
{/if}
</div>