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.
2832 lines
97 KiB
Svelte
2832 lines
97 KiB
Svelte
<script lang="ts">
|
||
import { Button } from '$lib/components/ui/button/index.js';
|
||
import * as Card from '$lib/components/ui/card/index.js';
|
||
import { Input } from '$lib/components/ui/input/index.js';
|
||
import { 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>
|