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

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

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

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

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

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

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

3562 lines
115 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore, type User } from '$lib/stores/auth.svelte';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationOutcome,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { generateUUID } from '$lib/utils/uuid';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
import { range } from '$lib/utils/format';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
// zxcvbn-ts imports
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
import * as languageCommon from '@zxcvbn-ts/language-common';
import * as languageEn from '@zxcvbn-ts/language-en';
// set up options so that feedback, dictionary etc. are included
const zxcvbn = new ZxcvbnFactory({
translations: languageEn.translations,
graphs: languageCommon.adjacencyGraphs,
dictionary: {
...languageCommon.dictionary,
...languageEn.dictionary
}
});
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { EmailInput } from '$lib/components/ui/email-input/index.js';
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
import { Separator } from '$lib/components/ui/separator';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import Cropper from 'svelte-easy-crop';
// =============== Auth & Page State ===============
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated) {
pageState = 'unauthorized';
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/login', { replaceState: true });
return;
}
pageState = 'authorized';
});
// =============== Tab State ===============
let activeTab = $state<'general' | 'history' | 'referral' | 'cards' | 'admin'>('general');
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
type Booking = {
id: string;
start_time: string;
status: string;
notes?: string;
services: Array<{
service_name: string;
price: number;
duration_minutes: number;
}>;
payments: Array<{
id: string;
amount: number;
payment_method: string;
payment_type: string;
status: string;
created_at: string;
}>;
total_amount: number;
amount_paid: number;
amount_due: number;
duration_minutes: number;
created_at: string;
};
let userData = $state<User | null>(null);
let loadingUser = $state(true);
let stamps = $state(0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let pendingRedemption = $state(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let uploadingPic = $state(false);
function getStampPath(slotNum: number): string {
const petals = 7 + (slotNum % 4); // 7, 8, 9, 10 petals
const amp = 2.5 + (slotNum % 3) * 0.5; // 2.5, 3.0, 3.5 amplitude
const phase = slotNum * 12; // phase offset in degrees
const R = 42; // base radius
const centerX = 50;
const centerY = 50;
let path = '';
const steps = 120;
for (let i = 0; i < steps; i++) {
const angleDeg = (i * 360) / steps;
const angleRad = (angleDeg * Math.PI) / 180;
const phaseRad = (phase * Math.PI) / 180;
const r = R + amp * Math.sin(petals * angleRad + phaseRad);
const x = (centerX + r * Math.cos(angleRad)).toFixed(1);
const y = (centerY + r * Math.sin(angleRad)).toFixed(1);
if (i === 0) {
path += `M ${x} ${y}`;
} else {
path += ` L ${x} ${y}`;
}
}
return path + ' Z';
}
const notifPrefs = $state({ emailEnabled: true, smsEnabled: true, browserPushEnabled: true });
const loadingCards = $state(false);
let cardToDelete = $state<SavedCard | null>(null);
let showDeleteCardDialog = $state(false);
// Add-a-Card (Square Web Payments tokenization)
let addCardSquareCardInput = $state<SquareCardInput | null>(null);
let addCardReady = $state(false);
let addingCard = $state(false);
async function addCard() {
if (!addCardSquareCardInput) return;
let token: string;
try {
token = await addCardSquareCardInput.tokenize();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
addingCard = true;
try {
const res = await apiFetch('/api/user/payment-methods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_token: token })
});
if (res.ok) {
toast.success('Card saved');
await savedCardsStore.invalidate();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to add card');
}
} catch {
toast.error('Network error');
} finally {
addingCard = false;
}
}
async function deleteCard(card: SavedCard) {
try {
const res = await apiFetch(`/api/user/payment-methods/${card.id}`, {
method: 'DELETE'
});
if (res.ok) {
toast.success('Card removed');
await savedCardsStore.invalidate();
} else {
toast.error('Failed to remove card');
}
} catch {
toast.error('Network error');
} finally {
cardToDelete = null;
}
}
// =============== Gift Card State ===============
let giftCardBalance = $state(0);
let loadingBalance = $state(false);
let giftCardCode = $state('');
let redeemingGiftCard = $state(false);
let showRedeemConfirm = $state(false);
// Synchronous double-click guard for redeemGiftCard. The redeem POST is
// one-shot; a rapid second click would fire a duplicate redeem (the backend
// FOR UPDATE lock makes the second fail with "already been redeemed",
// showing an error toast right after a success). Svelte 5 reactivity is
// async, so the reactive `disabled` may not have propagated before a fast
// second click — this non-reactive flag is checked at entry before any
// await and cleared in finally.
let isRedeemingSync = false;
// Buy Gift Card State
let buyAmount = $state<10 | 20 | 50>(10);
let buyRecipientType = $state<'self' | 'friend'>('self');
let buyRecipientEmail = $state('');
let buySelectedCard = $state('');
let buyingGiftCard = $state(false);
// Synchronous double-click guard for buyGiftCard. buyingGiftCard is only set
// to true AFTER tokenization, so during the tokenize await the reactive
// `disabled` on the Pay button is not yet active and a rapid second click
// would tokenize twice (minting a second nonce, wasting one). This non-
// reactive flag is checked at entry before any await and cleared in finally.
let isBuyingSync = false;
let purchaseResultCode = $state<string | null>(null);
let buyCardSelection = $state<CardSelection | null>(null);
let buyCardSelectionValid = $state(false);
let buySaveCard = $state(false);
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let buyNonce = $state('');
// Cached SCA verification token paired with buyNonce (both one-shot, reused
// together on retry). The verification token is amount-bound, so changing
// the amount invalidates the cached pair.
let buyVerificationToken = $state('');
let buyTokenAmount = $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 buyTokenizedAt = $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 buyTokenizedForSaveCard = $state(false);
// B6/B10: gift-card buys charge a saved card (or save a new card for reuse)
// whenever the backend enforces the 2FA gate — the CARD OWNER's current
// verification code must be carried on the charge. Shared two-factor-code
// state (code, reveal, show/missing derivations, "Request a new code"
// handler) — see $lib/stores/twoFactorCode.svelte.ts.
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
// 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 buyLastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let buyWaitingForSCA = $state(false);
// Retryable purchase failure message shown above the Pay button (challenge
// cancelled/failed, decline) so the retry affordance matches the outcome.
let buyError = $state<string | null>(null);
const buyTwoFactor = useTwoFactorCodeForSavedCard({
enabled: () => buyTwoFactorEnabled,
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard),
scaAvailable: () => !shouldFallbackTo2FA(buyLastSCAOutcome)
});
// Client-side mirror of the £500/day online purchase cap. The backend is
// authoritative — this counter only reflects confirmed purchases made in
// this session, so a user is told they've hit the cap instead of being
// silently rejected on the next attempt. It is not persisted, so it resets
// on page reload; any rejection the counter can't foresee still surfaces
// through the backend's error toast.
const DAILY_GIFT_CARD_BUY_LIMIT = 500;
let buyDailyTotal = $state(0);
const buyLimitReached = $derived(buyDailyTotal >= DAILY_GIFT_CARD_BUY_LIMIT);
// Cached idempotency key: generated once per purchase attempt, reused on
// retry (so a lost-response retry dedups instead of double-charging),
// cleared on success. Reset when the amount or payment method changes.
let buyIdempotencyKey = $state('');
let buyKeyedAmount = $state(0);
let buyKeyedCard = $state('');
// My Gift Cards — online purchases within the 14-day cooling-off window
// (Consumer Contracts Regulations 2013), each with the rolling expiry date
// shown (T&C: "the expiry date is displayed in your account").
let myGiftCards = $state<
Array<{
code: string;
amount: number;
purchased_at: string;
expiry_date?: string;
cancellable: boolean;
cancellation_reason?: string;
payment_id?: string;
}>
>([]);
let loadingMyGiftCards = $state(false);
let cancellingCode = $state<string | null>(null);
let cardToCancel = $state<(typeof myGiftCards)[number] | null>(null);
let showCancelConfirm = $state(false);
// Derived validation for Buy Gift Card form — delegated to CardSelection.
const isBuyCardValid = $derived(buyCardSelectionValid);
async function fetchGiftCardBalance() {
loadingBalance = true;
try {
const res = await apiFetch('/api/user/giftcards/balance');
if (res.ok) {
const data = await res.json();
giftCardBalance = data.balance;
}
} catch {
// ignore
} finally {
loadingBalance = false;
}
}
async function fetchMyGiftCards() {
loadingMyGiftCards = true;
try {
const res = await apiFetch('/api/user/giftcards');
if (res.ok) {
const data = await res.json();
myGiftCards = data.gift_cards || [];
}
} catch {
// ignore — the section renders with the empty state
} finally {
loadingMyGiftCards = false;
}
}
async function cancelGiftCard() {
if (!cardToCancel) return;
cancellingCode = cardToCancel.code;
try {
const res = await apiFetch('/api/user/giftcards/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: cardToCancel.code,
...(cardToCancel.payment_id ? { payment_id: cardToCancel.payment_id } : {})
})
});
if (res.ok) {
const data = await res.json();
toast.success(data.message || 'Gift card cancelled and refunded');
cardToCancel = null;
await fetchMyGiftCards();
await fetchGiftCardBalance();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to cancel gift card');
}
} catch {
toast.error('Network error cancelling gift card');
} finally {
cancellingCode = null;
}
}
async function redeemGiftCard() {
if (isRedeemingSync) return;
isRedeemingSync = true;
try {
if (giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12) {
toast.error('Invalid gift card code format');
return;
}
redeemingGiftCard = true;
try {
const res = await apiFetch('/api/user/giftcards/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: giftCardCode })
});
if (res.ok) {
const data = await res.json();
toast.success(
`Success! Redeemed ${formatCurrency(data.amount_redeemed)} to your balance.`
);
giftCardCode = '';
await fetchGiftCardBalance();
// The redeemed card is consumed — refresh the cancellable list so
// it no longer shows a stale "Cancel & refund" row (mirror of the
// buy path which refreshes both balance and list).
await fetchMyGiftCards();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to redeem gift card');
}
} catch (err) {
console.error('redeemGiftCard error:', err);
toast.error('Network error');
} finally {
redeemingGiftCard = false;
}
} finally {
isRedeemingSync = false;
}
}
async function buyGiftCard() {
if (isBuyingSync) return;
isBuyingSync = true;
try {
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (buySelectedCard) {
// saved card — nothing to tokenize
} else if (buyCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the backend
// idempotency key dedups).
if (
!buyNonce ||
buyTokenizedForSaveCard !== buySaveCard ||
isNonceStale(buyTokenizedAt, buyTokenAmount, buyAmount * 100)
) {
try {
const tokenized = await buyCardSelection.tokenizeWithVerification(
buyAmount * 100,
{
givenName: userData?.firstName,
familyName: userData?.lastName,
email: userData?.email
},
buySaveCard
);
buyNonce = tokenized.nonce;
buyVerificationToken = tokenized.verificationToken ?? '';
buyTokenAmount = buyAmount * 100;
buyTokenizedAt = Date.now();
buyTokenizedForSaveCard = buySaveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
buyLastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
buyingGiftCard = false;
return;
}
}
newCardToken = buyNonce;
verificationToken = buyVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
buyingGiftCard = false;
return;
}
buyingGiftCard = true;
try {
const cardId = buySelectedCard;
// Cache the idempotency key per amount+card so a lost-response retry
// reuses the same key (backend dedups) instead of double-charging.
// Regenerate when the amount or card changes.
// 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 and cleared on a failed charge, so keying on it
// would regenerate the key on retry and a network-timeout retry
// (where the charge actually landed) would double-charge.
const cardKey = cardId || 'new-card';
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
buyIdempotencyKey = generateUUID();
buyKeyedAmount = buyAmount;
buyKeyedCard = cardKey;
}
// Proactive saved-card (ccof) SCA: run the client-side challenge
// BEFORE the first buy attempt so it carries a fresh
// verification_token — a naked ccof is never sent. Only
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
// fallback); a cancelled/failed challenge does NOT charge — the
// user taps Buy again to re-run it, and the cached idempotency
// key above is never regenerated across the challenge-then-charge.
if (cardId && !verificationToken) {
buyWaitingForSCA = true;
try {
const proactive = await runBuySCAProactively();
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
buyError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(buyError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
buyWaitingForSCA = false;
}
}
const res = await submitPaymentWithRetry(() =>
apiFetch('/api/user/giftcards/buy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: buyAmount * 100, // pence
recipient_type: buyRecipientType,
recipient_email: buyRecipientEmail,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}),
idempotency_key: buyIdempotencyKey
})
})
);
if (res.ok) {
const data = await res.json();
toast.success('Gift card purchased successfully!');
purchaseResultCode = data.code;
// Track confirmed purchases toward the £500/day cap (the backend
// is authoritative; this only feeds the client-side nudge).
buyDailyTotal += buyAmount;
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
buyTwoFactor.setCode('');
buyTwoFactor.reveal = false;
await fetchGiftCardBalance();
} else {
// Capture the status BEFORE consuming the body — the
// saved-card SCA classification below needs it, and text()
// can only be read once.
const status = res.status;
const errText = await res.text();
// A 402 carrying the structured verification-required signal
// (or the dev/mock text parity) surfaces the SCA-first
// guidance. A plain decline 402 shows the normal decline
// error — it must not be relabeled "requires verification".
const verificationRequired = isVerificationRequiredSignal(status, errText);
// 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 charge can be retried with a fresh code.
const buyErrMsg = verificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: extractErrorMessage(errText) || 'Failed to purchase gift card';
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
buyTwoFactor.reveal = true;
}
buyError = buyErrMsg;
toast.error(buyErrMsg);
// A definitive charge failure (e.g. declined card) consumes the
// nonce + SCA verification token — clear the cached pair so the
// next retry re-tokenizes fresh.
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the
// purchase did NOT land — a retry that re-runs SCA and mints a
// fresh token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED
// under the same key. Regenerate the key on 402 so the next Buy
// click gets a fresh key + fresh pending row. Keep it on
// 503/network (ambiguous) and on challenge-cancelled/sca-failed.
if (status === 402) {
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
}
}
} catch (err) {
console.error('buyGiftCard error:', err);
toast.error('Network error');
// Same for thrown errors (network / malformed response): a retry must
// re-tokenize fresh rather than resubmit a consumed nonce.
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
} finally {
buyingGiftCard = false;
}
} finally {
isBuyingSync = false;
}
}
/**
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first
* gift-card buy attempt (never after a 402). Square's
* tokenize(verificationDetails, squareCardId) determines UP FRONT whether
* buyer verification is required and returns a fresh verification_token
* bound to the exact amount:
* - 'verified' → the caller charges with the returned token (the first
* attempt carries it — a naked ccof is never sent);
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
* to the only available gate and the caller proceeds WITHOUT a token;
* - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge: the
* pending row stays retryable and the user taps Buy again to re-run the
* challenge.
*/
async function runBuySCAProactively(): Promise<{
outcome: SavedCardVerificationOutcome;
verificationToken?: string;
}> {
const squareCardId = savedCardsStore.cards.find(
(c) => c.id === buySelectedCard
)?.square_card_id;
if (!squareCardId) {
buyLastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(buyAmount * 100, squareCardId, {
givenName: userData?.firstName,
familyName: userData?.lastName,
email: userData?.email
});
} catch (_err) {
buyLastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
buyLastSCAOutcome = result.outcome;
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
}
function formatAndPreserveCursor(
input: HTMLInputElement,
formatter: (val: string) => string,
charRegex: RegExp = /\d/
): string {
const rawValue = input.value;
const oldSelectionStart = input.selectionStart || 0;
let charsBeforeCursor = 0;
for (let i = 0; i < oldSelectionStart; i++) {
if (charRegex.test(rawValue[i])) {
charsBeforeCursor++;
}
}
const formatted = formatter(rawValue);
input.value = formatted;
let newSelectionStart = 0;
let charsFound = 0;
for (let i = 0; i < formatted.length; i++) {
if (charsFound === charsBeforeCursor) {
break;
}
if (charRegex.test(formatted[i])) {
charsFound++;
}
newSelectionStart++;
}
requestAnimationFrame(() => {
input.setSelectionRange(newSelectionStart, newSelectionStart);
});
return formatted;
}
function handleGiftCardInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(
input,
(val) => {
let raw = val.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
if (raw.length > 12) raw = raw.slice(0, 12);
let clean = '';
if (raw.length > 0) clean += raw.slice(0, 4);
if (raw.length > 4) clean += '-' + raw.slice(4, 8);
if (raw.length > 8) clean += '-' + raw.slice(8, 12);
return clean;
},
/[a-zA-Z0-9]/
);
giftCardCode = formatted;
}
$effect(() => {
if (savedCardsStore.cards.length === 0 && buySelectedCard !== '') {
buySelectedCard = '';
}
});
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatCardCode(id: string): string {
const raw = id.toLowerCase().replace(/[^a-z0-9]/g, '');
if (raw.length <= 4) return raw.toUpperCase();
if (raw.length <= 8) return raw.slice(0, 4).toUpperCase() + '-' + raw.slice(4, 8).toUpperCase();
return (
raw.slice(0, 4).toUpperCase() +
'-' +
raw.slice(4, 8).toUpperCase() +
'-' +
raw.slice(8, 12).toUpperCase()
);
}
function formatShortDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/London'
});
}
async function fetchNotifPrefs() {
try {
const res = await apiFetch('/api/user/notification-preferences');
if (res.ok) {
const data = await res.json();
notifPrefs.emailEnabled = data.emailEnabled ?? true;
notifPrefs.smsEnabled = data.smsEnabled ?? true;
notifPrefs.browserPushEnabled = data.browserPushEnabled ?? true;
}
} catch {
/* silently fail */
}
}
async function saveNotifPrefs() {
try {
await apiFetch('/api/user/notification-preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(notifPrefs)
});
} catch {
/* silently fail */
}
}
// =============== Two-Factor Authentication State ===============
let twoFAMethod = $state<'email' | 'sms'>('email');
let twoFACode = $state('');
let twoFASetupPending = $state(false);
let twoFASettingUp = $state(false);
let twoFAVerifying = $state(false);
let twoFADisabling = $state(false);
let selectedTwoFA = $state<'none' | 'email' | 'sms'>('none');
let showDisableTwoFADialog = $state(false);
let showDisableCodeEntry = $state(false);
let twoFADisableCode = $state('');
let twoFADisableConfirming = $state(false);
// Locally-selected 2FA state, behaving as a radio group: none, email, or sms —
// never both. Initialised from the saved profile state so the toggles reflect
// reality on load and resync after every setup/verify/disable round-trip
// (authStore.refreshProfile).
$effect(() => {
const user = authStore.currentUser;
if (
user?.twoFactorEnabled &&
(user.twoFactorMethod === 'email' || user.twoFactorMethod === 'sms')
) {
selectedTwoFA = user.twoFactorMethod;
} else {
selectedTwoFA = 'none';
}
});
// The user's saved 2FA state, derived from the profile
const savedTwoFAState: 'none' | 'email' | 'sms' = $derived(
authStore.currentUser?.twoFactorEnabled &&
(authStore.currentUser.twoFactorMethod === 'email' ||
authStore.currentUser.twoFactorMethod === 'sms')
? authStore.currentUser.twoFactorMethod
: 'none'
);
// True only while the locally-selected state differs from the user's saved choices
const twoFADirty = $derived(selectedTwoFA !== savedTwoFAState);
async function startTwoFASetup() {
twoFASettingUp = true;
try {
const res = await apiFetch('/api/user/2fa/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: twoFAMethod })
});
if (res.ok) {
const data = await res.json().catch(() => ({}));
twoFASetupPending = true;
twoFACode = '';
toast.success(data.message ?? 'Verification code sent');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to start two-factor setup');
}
} catch {
toast.error('Network error');
} finally {
twoFASettingUp = false;
}
}
async function verifyTwoFASetup() {
twoFAVerifying = true;
try {
const res = await apiFetch('/api/user/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: twoFACode })
});
if (res.ok) {
twoFASetupPending = false;
twoFACode = '';
await authStore.refreshProfile();
toast.success('Two-factor authentication enabled');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Invalid verification code');
}
} catch {
toast.error('Network error');
} finally {
twoFAVerifying = false;
}
}
async function disableTwoFA() {
twoFADisabling = true;
try {
const res = await apiFetch('/api/user/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: '' })
});
if (res.ok) {
await authStore.refreshProfile();
toast.success('Two-factor authentication disabled');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to disable two-factor authentication');
}
} catch {
toast.error('Network error');
} finally {
twoFADisabling = false;
}
}
// Apply the locally-selected 2FA state. A method selected runs the existing
// setup+verify flow; both toggles off disables 2FA (guarded by the payment-rules
// warning dialog when 2FA is currently enabled).
async function applyTwoFA() {
if (selectedTwoFA === 'none') {
if (authStore.currentUser?.twoFactorEnabled) {
showDisableTwoFADialog = true;
}
return;
}
twoFAMethod = selectedTwoFA;
await startTwoFASetup();
}
async function confirmDisableTwoFA() {
showDisableTwoFADialog = false;
if (authStore.currentUser?.twoFactorRequired) {
await sendDisableCode();
} else {
await disableTwoFA();
}
}
// Enforced environments require a verification code before 2FA can be
// disabled. Mint one first (the backend mints + delivers it), then show
// the code-entry step.
async function sendDisableCode() {
try {
const res = await apiFetch('/api/user/2fa/disable/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
showDisableCodeEntry = true;
twoFADisableCode = '';
toast.success('Verification code sent');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to send verification code');
}
} catch {
toast.error('Network error');
}
}
async function confirmDisableWithCode() {
twoFADisableConfirming = true;
try {
const res = await apiFetch('/api/user/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: twoFADisableCode })
});
if (res.ok) {
showDisableCodeEntry = false;
twoFADisableCode = '';
await authStore.refreshProfile();
toast.success('Two-factor authentication disabled');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to disable two-factor authentication');
}
} catch {
toast.error('Network error');
} finally {
twoFADisableConfirming = false;
}
}
// Image cropper state
let cropDialogOpen = $state(false);
let cropImageUrl = $state('');
let cropArea = $state<{ x: number; y: number; width: number; height: number } | null>(null);
let crop = $state({ x: 0, y: 0 });
let zoom = $state(1);
let previewUrl = $state('');
const PROFILE_PIC_MAX_SIZE = 15 * 1024 * 1024; // 15MB
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
if (file.size > PROFILE_PIC_MAX_SIZE) {
toast.error('Profile picture must be under 15MB');
return;
}
cropImageUrl = URL.createObjectURL(file);
cropDialogOpen = true;
}
}
async function handleCropSave() {
if (!cropArea || !cropImageUrl) return;
const img = new Image();
img.src = cropImageUrl;
await new Promise((resolve) => {
img.onload = resolve;
});
const canvas = document.createElement('canvas');
canvas.width = 350;
canvas.height = 350;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, cropArea.x, cropArea.y, cropArea.width, cropArea.height, 0, 0, 350, 350);
canvas.toBlob(
(blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
previewUrl = url;
handleProfilePicUpload(blob).then(() => {
URL.revokeObjectURL(cropImageUrl);
cropImageUrl = '';
cropArea = null;
cropDialogOpen = false;
});
},
'image/jpeg',
0.9
);
}
function handleCropCancel() {
if (cropImageUrl) {
URL.revokeObjectURL(cropImageUrl);
}
cropImageUrl = '';
cropArea = null;
cropDialogOpen = false;
}
async function handleProfilePicUpload(blob: Blob) {
uploadingPic = true;
try {
const formData = new FormData();
formData.append('file', blob, 'profile.jpg');
const uploadResponse = await apiFetch('/api/user/profile-picture', {
method: 'POST',
body: formData
});
if (uploadResponse.ok) {
const data = await uploadResponse.json();
if (userData) {
userData.profilePicUrl = data.url;
}
toast.success('Profile picture updated');
} else {
toast.error('Failed to upload profile picture');
}
} catch (err) {
console.error('Upload error:', err);
toast.error('Failed to upload profile picture');
} finally {
uploadingPic = false;
}
}
// =============== Phone Edit Mode ===============
let editingPhone = $state(false);
let phoneInput = $state('');
let phoneError = $state('');
let savingPhone = $state(false);
// Phone validation (UK format)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function validatePhone(phone: string): boolean {
return isValidUKPhone(phone);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function formatPhoneInput(value: string): string {
return formatPhoneDisplay(value);
}
function startEditPhone() {
phoneInput = userData?.phone || '';
phoneError = '';
editingPhone = true;
}
function cancelEditPhone() {
editingPhone = false;
phoneInput = '';
phoneError = '';
}
async function savePhone() {
const formattedPhone = toE164UK(phoneInput);
if (!formattedPhone) {
phoneError = 'Invalid UK phone number';
toast.error('Please enter a valid UK phone number');
return;
}
savingPhone = true;
const loadingToast = toast.loading('Updating phone number...');
try {
const response = await apiFetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: userData?.firstName,
lastName: userData?.lastName,
phone: formattedPhone // already E.164 from toE164UK
})
});
if (response.ok) {
toast.success('Phone number updated successfully!', { id: loadingToast });
editingPhone = false;
// Refresh user data
await fetchUserData();
} else {
const text = await response.text();
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to update phone number', {
id: loadingToast
});
}
} catch (err) {
console.error('Error updating phone:', err);
toast.error('Network error', { id: loadingToast });
} finally {
savingPhone = false;
}
}
// =============== Name Edit Mode ===============
let editingFirstName = $state(false);
let editingLastName = $state(false);
let firstNameInput = $state('');
let lastNameInput = $state('');
let firstNameError = $state('');
let lastNameError = $state('');
let savingName = $state(false);
// Name validation (unicode letters, spaces, hyphen, apostrophe, dot)
const nameRegex = /^[\p{L}\p{M}\s'.-]+$/u;
function startEditFirstName() {
firstNameInput = userData?.firstName || '';
firstNameError = '';
editingFirstName = true;
editingLastName = false;
}
function startEditLastName() {
lastNameInput = userData?.lastName || '';
lastNameError = '';
editingLastName = true;
editingFirstName = false;
}
function cancelEditName() {
editingFirstName = false;
editingLastName = false;
firstNameInput = '';
lastNameInput = '';
firstNameError = '';
lastNameError = '';
}
async function saveName() {
const newFirstName = editingFirstName ? firstNameInput.trim() : userData?.firstName || '';
const newLastName = editingLastName ? lastNameInput.trim() : userData?.lastName || '';
// Validate
if (!newFirstName || !newLastName) {
if (!newFirstName) firstNameError = 'First name is required';
if (!newLastName) lastNameError = 'Last name is required';
toast.error('Name is required');
return;
}
if (newFirstName.length > 50) {
firstNameError = 'First name must be 50 characters or less';
toast.error('First name is too long');
return;
}
if (newLastName.length > 50) {
lastNameError = 'Last name must be 50 characters or less';
toast.error('Last name is too long');
return;
}
if (!nameRegex.test(newFirstName)) {
firstNameError = 'Invalid characters in first name';
toast.error('Please use only letters, spaces, hyphens, apostrophes, or dots');
return;
}
if (!nameRegex.test(newLastName)) {
lastNameError = 'Invalid characters in last name';
toast.error('Please use only letters, spaces, hyphens, apostrophes, or dots');
return;
}
savingName = true;
const loadingToast = toast.loading('Updating name...');
try {
const response = await apiFetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: newFirstName,
lastName: newLastName,
phone: userData?.phone || ''
})
});
if (response.ok) {
toast.success('Name updated successfully!', { id: loadingToast });
editingFirstName = false;
editingLastName = false;
firstNameInput = '';
lastNameInput = '';
firstNameError = '';
lastNameError = '';
// Refresh user data to get updated info (including previous names)
await fetchUserData();
} else {
const text = await response.text();
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to update name', {
id: loadingToast
});
}
} catch (err) {
console.error('Error updating name:', err);
toast.error('Network error', { id: loadingToast });
} finally {
savingName = false;
}
}
// =============== GDPR Export Status ===============
const GDPR_COOLDOWN_MS = 12 * 60 * 60 * 1000;
let gdprExportMeta = $state<{ exported_at?: string } | null>(null);
let gdprCountdown = $state<string | null>(null);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
function updateCountdown(exportedAt: number) {
const elapsed = Date.now() - exportedAt;
const remaining = GDPR_COOLDOWN_MS - elapsed;
if (remaining <= 0) {
gdprExportMeta = null;
gdprCountdown = null;
return;
}
const hours = Math.floor(remaining / (60 * 60 * 1000));
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
const seconds = Math.floor((remaining % (60 * 1000)) / 1000);
if (hours > 0) {
gdprCountdown = `New export available in ${hours}h ${minutes}m`;
} else if (minutes > 0) {
gdprCountdown = `New export available in ${minutes}m ${seconds}s`;
} else {
gdprCountdown = `New export available in ${seconds}s`;
}
}
function startCountdown(exportedAt: string) {
stopCountdown();
const ts = new Date(exportedAt).getTime();
updateCountdown(ts);
if (gdprExportMeta) {
countdownTimer = setInterval(() => updateCountdown(ts), 1000);
}
}
function stopCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
async function fetchGdprExportStatus() {
try {
const res = await apiFetch('/api/user/gdpr-export');
if (res.ok) {
const data = await res.json();
if (data.export_metadata?.exported_at) {
const elapsed = Date.now() - new Date(data.export_metadata.exported_at).getTime();
if (elapsed < GDPR_COOLDOWN_MS) {
gdprExportMeta = data.export_metadata;
startCountdown(data.export_metadata.exported_at);
return;
}
}
}
// No valid export: clear everything.
gdprExportMeta = null;
gdprCountdown = null;
} catch {
// silently fail
}
}
// =============== Fetch User Data ===============
async function fetchUserData() {
if (pageState !== 'authorized') return;
loadingUser = true;
try {
const response = await apiFetch('/api/user/profile', {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
const data = await response.json();
userData = data;
stamps = userData?.loyaltyStamps ?? 0;
pendingRedemption = stamps >= 10;
} else {
toast.error('Failed to load profile data');
}
} catch (err) {
console.error('Error fetching user data:', err);
toast.error('Network error loading profile');
} finally {
loadingUser = false;
}
}
// =============== Fetch Bookings ===============
let upcomingBookings = $state<Booking[]>([]);
let pastBookings = $state<Booking[]>([]);
let pastPage = $state(1);
let pastTotalPages = $state(1);
let loadingUpcoming = $state(false);
let loadingPast = $state(false);
// =============== Fetch Upcoming Bookings (next 3) ===============
async function fetchUpcomingBookings() {
if (pageState !== 'authorized') return;
loadingUpcoming = true;
try {
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
// Fetch more items (e.g. 10) to ensure we find upcoming ones even if the first few are past
const response = await apiFetch(`/api/bookings?start_date=${today}&per_page=10&page=1`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
const text = await response.text();
toast.error('Failed to load upcoming bookings: ' + sanitizeText(extractErrorMessage(text)));
return;
}
const data = await response.json();
const now = new Date();
// Filter: Calculate end time (Start + Duration) and check if it's in the future
const activeOrFutureBookings = (data.bookings || []).filter((b: Booking) => {
const startTime = parseWallClockDate(b.start_time);
// Add duration (in ms)
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now;
});
// Take only the top 3
upcomingBookings = activeOrFutureBookings.slice(0, 3);
} catch (err) {
console.error('Error fetching upcoming bookings:', err);
toast.error('Network error loading upcoming bookings');
} finally {
loadingUpcoming = false;
}
}
// =============== Fetch Past Bookings (paginated 10 per page) ===============
async function fetchPastBookings(page = 1) {
if (pageState !== 'authorized') return;
loadingPast = true;
try {
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const response = await apiFetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
const text = await response.text();
toast.error('Failed to load past bookings: ' + sanitizeText(extractErrorMessage(text)));
return;
}
const data = await response.json();
let bookings = data.bookings || [];
// Only include bookings that have actually finished (endTime <= now)
const now = new Date();
bookings = bookings.filter((b: Booking) => {
const startTime = parseWallClockDate(b.start_time);
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime <= now;
});
// FIX: Manually calculate amount_due for the list
// The list API often returns 0 for amount_due/amount_paid,
// so we derive it from total_amount.
bookings = bookings.map((b: Booking) => {
const total = b.total_amount || 0;
const paid = b.amount_paid || 0;
return {
...b,
amount_due: total - paid // Force calculate the balance
};
});
// SORT LOGIC: Unpaid first, then by most recent
bookings.sort((a: Booking, b: Booking) => {
const aUnpaid = (a.amount_due || 0) > 0;
const bUnpaid = (b.amount_due || 0) > 0;
// If A is unpaid and B is not, A comes first
if (aUnpaid && !bUnpaid) return -1;
// If B is unpaid and A is not, B comes first
if (!aUnpaid && bUnpaid) return 1;
// If both have same payment status, sort by Date DESC (newest first)
return (
parseWallClockDate(b.start_time).getTime() - parseWallClockDate(a.start_time).getTime()
);
});
pastBookings = bookings;
pastPage = data.page || page;
pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
} catch (err) {
console.error('Error fetching past bookings:', err);
toast.error('Network error loading past bookings');
} finally {
loadingPast = false;
}
}
// =============== Pagination Helpers ===============
function goToPastPage(page: number) {
if (page < 1 || page > pastTotalPages) return;
fetchPastBookings(page);
}
$effect(() => {
if (pageState === 'authorized') {
fetchUserData();
fetchUpcomingBookings();
fetchPastBookings();
fetchNotifPrefs();
fetchGdprExportStatus();
fetchMyGiftCards();
}
});
// =============== Password Change ===============
let showPasswordModal = $state(false);
let passwordData = $state({
current: '',
new: '',
confirm: ''
});
let changingPassword = $state(false);
// Password strength using zxcvbn
const newPasswordStrength = $derived(passwordData.new ? zxcvbn.check(passwordData.new) : null);
const isPasswordStrongEnough = $derived(
!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2
);
const passwordsMatch = $derived(
passwordData.confirm === '' || passwordData.new === passwordData.confirm
);
async function changePassword() {
if (passwordData.new !== passwordData.confirm) {
toast.error('New passwords do not match');
return;
}
if (passwordData.new.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
if (!isPasswordStrongEnough) {
toast.error('Please choose a stronger password');
return;
}
changingPassword = true;
const loadingToast = toast.loading('Changing password...');
try {
const response = await apiFetch('/api/user/change-password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: passwordData.current,
new_password: passwordData.new
})
});
if (response.ok) {
toast.success('Password changed successfully!', { id: loadingToast });
showPasswordModal = false;
passwordData = { current: '', new: '', confirm: '' };
} else {
const text = await response.text();
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to change password', {
id: loadingToast
});
}
} catch (err) {
console.error('Error changing password:', err);
toast.error('Network error', { id: loadingToast });
} finally {
changingPassword = false;
}
}
// =============== Booking modal ==============
// =============== Modal State ===============
let showBookingModal = $state(false);
let selectedBookingId = $state<string | null>(null);
function openBookingModal(id: string) {
selectedBookingId = id;
showBookingModal = true;
}
// =============== Account Deletion ===============
let showDeleteAlert = $state(false);
let deleteConfirmText = $state('');
let deletingAccount = $state(false);
async function deleteAccount() {
if (deleteConfirmText !== 'DELETE') {
toast.error('Please type DELETE to confirm');
return;
}
deletingAccount = true;
const loadingToast = toast.loading('Deleting account...');
try {
const response = await apiFetch('/api/user/account', {
method: 'DELETE'
});
if (response.ok) {
toast.success('Account deleted successfully', { id: loadingToast });
authStore.logout();
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/');
} else {
const text = await response.text();
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to delete account', {
id: loadingToast
});
}
} catch (err) {
console.error('Error deleting account:', err);
toast.error('Network error', { id: loadingToast });
} finally {
deletingAccount = false;
}
}
// =============== Copy Referral Code ===============
function copyReferralCode() {
if (userData?.referralCode) {
navigator.clipboard.writeText(userData.referralCode.replace(/-/g, ''));
toast.success('Referral code copied to clipboard!');
}
}
function formatDateTime(dateString: string): string {
const date = parseWallClockDate(dateString);
// Get date parts
const day = date.getDate();
const month = date.toLocaleString('en-GB', { month: 'short' });
const year = date.getFullYear();
// Get time parts
const hours = date.getHours();
const minutes = date.getMinutes();
// Special cases for midnight and noon
let timeStr;
if (hours === 12 && minutes === 0) {
timeStr = 'Noon';
} else if (hours === 0 && minutes === 0) {
timeStr = 'Midnight';
} else {
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
timeStr = `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
return `${day} ${month} ${year}, ${timeStr}`;
}
</script>
<svelte:head>
<script>
(function () {
// Pre-hydration auth guard: reads localStorage directly because the Svelte
// authStore hasn't initialized yet at this point (async+$state). This runs
// synchronously in <svelte:head> before any rendering, preventing a flash
// of protected content. The authStore handles post-hydration auth.
try {
var token = localStorage.getItem('authToken');
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
<style>
:root {
--bgColorMenu: #1d1d27;
--duration: 0.7s;
}
</style>
</svelte:head>
{#if pageState === 'loading'}
<div class="mx-auto max-w-4xl space-y-6 p-4 pb-32">
<div class="mb-8 text-center">
<Skeleton class="mx-auto h-8 w-48" />
<Skeleton class="mx-auto mt-2 h-4 w-64" />
</div>
<Card.Root>
<Card.Content class="space-y-4 pt-6">
{#each range(5) as i (i)}
<Skeleton class="h-12 w-full" />
{/each}
</Card.Content>
</Card.Root>
</div>
{:else if pageState === 'authorized'}
<div class="mx-auto max-w-4xl space-y-6 p-4 pb-32">
<!-- Header -->
<div class="mb-8 text-center">
<h1 class="font-['Playfair_Display'] text-4xl font-bold">My Account</h1>
<p class="text-gray-600">Manage your profile, bookings, and settings</p>
</div>
<!-- Desktop Tab Menu (Show at top of content) -->
<div class="desktop-tab-menu">
<div class="flex rounded-lg border bg-gray-50 p-1">
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'general'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'general')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
General
</button>
{#if authStore.currentUser?.role !== 'admin'}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'history')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
History
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin'}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'referral')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Referral
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin' && canSaveCards}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'cards'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => {
activeTab = 'cards';
savedCardsStore.fetch();
savedCardsStore.invalidate();
fetchGiftCardBalance();
fetchMyGiftCards();
}}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Cards
</button>
{/if}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'admin'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'admin')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Admin
</button>
</div>
</div>
<!-- Tab Content -->
<div class="tab-content">
{#if activeTab === 'general'}
<!-- General Details -->
<Card.Root>
<Card.Header>
<Card.Title>Profile Information</Card.Title>
<Card.Description>Your personal details and account information</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if userData}
{@const initials =
userData.firstName && userData.lastName
? userData.firstName
.split(' ')
.map((n) => n[0])
.join('') +
userData.lastName
.split(' ')
.map((n) => n[0])
.join('')
: ''}
{@const hasImage = !!userData.profilePicUrl || !!previewUrl}
{@const displayUrl = previewUrl || userData.profilePicUrl || ''}
<div class="flex flex-col items-center gap-4">
{#if hasImage || initials}
{#if hasImage}
<img
src={displayUrl}
alt="Profile"
class="h-24 w-24 rounded-full object-cover ring-4 ring-fuchsia-200"
/>
{:else}
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-fuchsia-200"
>
{initials}
</div>
{/if}
{:else}
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-fuchsia-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
{/if}
<Button
variant="outline"
onclick={() => document.getElementById('profile-pic-input')?.click()}
>
Upload profile picture
</Button>
<input
id="profile-pic-input"
type="file"
accept="image/*"
class="hidden"
onchange={handleFileSelect}
/>
</div>
{/if}
<Dialog.Root bind:open={cropDialogOpen}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title>Crop Profile Picture</Dialog.Title>
</Dialog.Header>
<div class="relative h-64 w-full">
{#if cropImageUrl}
<Cropper
image={cropImageUrl}
aspect={1}
cropShape="round"
showGrid={false}
bind:crop
bind:zoom
oncropcomplete={(e) => {
cropArea = e.pixels;
}}
/>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={handleCropCancel}>Cancel</Button>
<Button onclick={handleCropSave}>Save</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{#if loadingUser}
{#each range(6) as i (i)}
<Skeleton class="h-12 w-full" />
{/each}
{:else if userData}
<div class="grid gap-4 md:grid-cols-2">
<div>
<span class="text-sm font-medium text-gray-600">First Name</span>
{#if editingFirstName}
<div class="mt-1 space-y-2">
<Input
id="first-name"
bind:value={firstNameInput}
placeholder="Enter first name"
maxlength={50}
error={firstNameError}
/>
{#if firstNameError}
<p class="text-xs text-red-500">{firstNameError}</p>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={saveName}
disabled={savingName || !firstNameInput.trim()}
>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onclick={cancelEditName}
disabled={savingName}
>
Cancel
</Button>
</div>
</div>
{:else}
<div
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
>
<span>{userData.firstName}</span>
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
<div>
<span class="text-sm font-medium text-gray-600">Last Name</span>
{#if editingLastName}
<div class="mt-1 space-y-2">
<Input
id="last-name"
bind:value={lastNameInput}
placeholder="Enter last name"
maxlength={50}
error={lastNameError}
/>
{#if lastNameError}
<p class="text-xs text-red-500">{lastNameError}</p>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={saveName}
disabled={savingName || !lastNameInput.trim()}
>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onclick={cancelEditName}
disabled={savingName}
>
Cancel
</Button>
</div>
</div>
{:else}
<div
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
>
<span>{userData.lastName}</span>
<Button size="sm" variant="ghost" onclick={startEditLastName}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
<div>
<span class="text-sm font-medium text-gray-600">Email</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
{userData.email}
</div>
</div>
<div>
<span class="text-sm font-medium text-gray-600">Phone</span>
{#if editingPhone}
<div class="mt-1 space-y-2">
<PhoneInput
id="phone"
bind:value={phoneInput}
bind:error={phoneError}
placeholder="Enter phone number"
/>
<div class="flex gap-2">
<Button
size="sm"
onclick={savePhone}
disabled={savingPhone || !isValidUKPhone(phoneInput)}
>
{savingPhone ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onclick={cancelEditPhone}
disabled={savingPhone}
>
Cancel
</Button>
</div>
</div>
{:else}
<div
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
>
<span>{userData.phone || '—'}</span>
<Button size="sm" variant="ghost" onclick={startEditPhone}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
</div>
{#if authStore.currentUser?.role !== 'admin'}
<Separator class="my-4" />
{#if stamps >= 10}
<div
class="mb-3 rounded-xl border-2 border-emerald-200 bg-emerald-50 p-4 text-center"
>
<p class="text-sm font-bold tracking-wide text-emerald-800">
Card Completed — 10% Off Your Next Booking!
</p>
</div>
{/if}
<div class="rounded-xl border-2 border-fuchsia-200 bg-fuchsia-50 p-5 sm:p-6">
{#if userData}
<div class="mb-5 text-center sm:text-left">
<h3 class="text-sm font-semibold text-gray-900">Loyalty Stamp Card</h3>
<p class="mt-0.5 text-xs text-gray-500">
{stamps < 10
? `Collect ${10 - stamps} more stamp${10 - stamps === 1 ? '' : 's'} to get 10% off your next booking.`
: (() => {
const fc = Math.floor(stamps / 10);
return `You have ${fc === 1 ? 'a' : fc} full stampcard${fc === 1 ? '' : 's'} ready to take advantage of at your next booking!`;
})()}
</p>
</div>
<div class="grid grid-cols-5 gap-3 sm:grid-cols-10">
{#each range(10) as i (i)}
{@const slotNum = i + 1}
{@const rot = ((slotNum * 37 + 13) % 7) - 3}
{#if slotNum <= (stamps > 0 ? stamps % 10 || 10 : 0)}
<div
class="aspect-square transition-transform duration-200 hover:scale-110"
>
<div
class="relative flex h-full w-full items-center justify-center text-fuchsia-300"
style="transform: rotate({rot}deg)"
>
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 100 100">
<defs>
<mask id="stamp-mask-{slotNum}">
<path d={getStampPath(slotNum)} fill="white" />
<path
d="M 50 25 L 56 43 L 75 43 L 60 53.5 L 66 71.5 L 50 62 L 34 71.5 L 40 53.5 L 25 43 L 44 43 Z"
fill="black"
/>
</mask>
</defs>
<path
d={getStampPath(slotNum)}
fill="currentColor"
mask="url(#stamp-mask-{slotNum})"
/>
</svg>
</div>
</div>
{:else}
<div
class="aspect-square transition-transform duration-200 hover:scale-110"
>
<div
class="relative flex h-full w-full items-center justify-center text-fuchsia-300/40 transition-colors duration-200 hover:text-fuchsia-400/60"
style="transform: rotate({rot}deg)"
>
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 100 100">
<path
d={getStampPath(slotNum)}
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-dasharray="3 3"
/>
</svg>
<span
class="relative z-10 text-[10px] leading-none font-semibold text-fuchsia-400/50"
>{slotNum}</span
>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</Card.Content>
</Card.Root>
{:else if activeTab === 'history'}
<!-- Upcoming Bookings -->
{#if upcomingBookings.length > 0}
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Upcoming Appointments</Card.Title>
<Card.Description
>Next {upcomingBookings.length < 3 ? upcomingBookings.length : 3} upcoming bookings</Card.Description
>
</Card.Header>
<Card.Content class="space-y-2">
{#if loadingUpcoming}
{#each range(3) as i (i)}
<Skeleton class="h-16 w-full" />
{/each}
{:else}
{#each upcomingBookings as b (b.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">{formatDateTime(b.start_time)}</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<!-- Show Status Chip for Upcoming -->
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
'confirmed'
? 'bg-emerald-100 text-emerald-800'
: b.status === 'pending'
? 'bg-amber-100 text-amber-800'
: b.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'}"
>
{b.status}
</span>
<!-- Services: Only show if data exists -->
{#if b.services && b.services.length > 0}
<span>
- {(() => {
const services = b.services.map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
{/if}
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
</Card.Content>
</Card.Root>
{/if}
<!-- Past Bookings -->
<Card.Root>
<Card.Header>
<Card.Title>Past Appointments</Card.Title>
<Card.Description>Previous bookings</Card.Description>
</Card.Header>
<Card.Content class="space-y-2">
{#if loadingPast}
{#each range(5) as i (i)}
<Skeleton class="h-16 w-full" />
{/each}
{:else if pastBookings.length === 0}
<div class="py-4 text-center text-gray-500">No past bookings</div>
{:else}
{#each pastBookings as b (b.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">{formatDateTime(b.start_time)}</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<!-- Unpaid Chip: Matches the 'Confirmed' chip style but uses Red for urgency -->
{#if (b.amount_due || 0) > 0}
<span
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
>
Unpaid
</span>
{/if}
<!-- Services: Hidden if empty -->
{#if b.services && b.services.length > 0}
<span>
- {(() => {
const services = b.services.map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
{/if}
{#if b.total_amount}
<span class="font-medium text-gray-700">
— £{b.total_amount.toFixed(2)}
</span>
{/if}
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
<!-- Pagination Controls -->
{#if pastTotalPages > 1}
<div class="mt-2 flex justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={pastPage === 1}
onclick={() => goToPastPage(pastPage - 1)}>Prev</Button
>
<span class="px-2 py-1 text-sm text-gray-700">{pastPage} / {pastTotalPages}</span>
<Button
variant="outline"
size="sm"
disabled={pastPage === pastTotalPages}
onclick={() => goToPastPage(pastPage + 1)}>Next</Button
>
</div>
{/if}
</Card.Content>
</Card.Root>
{:else if activeTab === 'referral'}
<!-- Referral Program -->
<Card.Root>
<Card.Header>
<Card.Title>Referral Program</Card.Title>
<Card.Description>Share your code and earn rewards</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if loadingUser}
<Skeleton class="h-32 w-full" />
{:else if userData?.referralCode}
<div class="rounded-lg border p-6 text-center">
<div class="mb-3 text-sm font-medium text-muted-foreground">Your Referral Code</div>
<div class="mb-4 flex items-center justify-center">
{#each userData.referralCode.match(/.{1,4}/g) as part, i (i)}<span
class="inline-flex min-w-[3.5rem] items-center justify-center border-b-2 border-b-border px-1 pb-1 text-2xl font-bold tracking-widest text-foreground sm:min-w-[5rem] sm:text-4xl"
>{part}</span
>{#if i < 2}<span
class="mx-1 text-xl font-bold text-muted-foreground select-none sm:mx-2 sm:text-3xl"
aria-hidden="true"></span
>{/if}{/each}
</div>
<Button onclick={copyReferralCode} variant="outline" class="w-full">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy Code
</Button>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold">
{userData.referralCodeUses || 0}
</div>
<div class="text-sm">Friends Referred</div>
</div>
<div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold">
£{(userData.referralSavings || 0).toFixed(2)}
</div>
<div class="text-sm">Total Saved</div>
</div>
</div>
<Card.Root class="mt-6 border-amber-200/60 bg-amber-50">
<Card.Content class="pt-4 md:pt-6">
<div class="space-y-2 text-sm text-amber-900">
<h4 class="font-semibold text-amber-800">How it works:</h4>
<ul class="space-y-1 pl-4">
<li>• Share your referral code with friends</li>
<li>• They get 10% off their first booking</li>
<li>• You get 10% off your next booking after they claim</li>
<li>• You earn 3 loyalty stamp for each use to keep the savings going</li>
</ul>
</div>
</Card.Content>
</Card.Root>
{:else}
<div class="py-8 text-center text-gray-500">No referral code available</div>
{/if}
</Card.Content>
</Card.Root>
{:else if activeTab === 'cards'}
<!-- Saved Cards -->
<Card.Root>
<Card.Header>
<Card.Title>Saved Cards</Card.Title>
<Card.Description>
Manage your saved payment methods &mdash; cards are stored securely with our payment
provider (Square).
<PolicyPopover label="privacy policy" href="/privacy-policy" />
</Card.Description>
</Card.Header>
<Card.Content>
{#if loadingCards}
<div class="space-y-3">
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
</div>
{:else}
<div class="space-y-3">
{#if savedCardsStore.cards.length === 0}
<p class="py-2 text-center text-gray-500">No saved cards yet</p>
{:else}
{#each savedCardsStore.cards as card (card.id)}
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div>
<div class="text-sm font-medium">
**** {card.last_4}
</div>
<div class="text-xs text-gray-500">
Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
</div>
</div>
</div>
<Button
size="sm"
variant="ghost"
class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => {
cardToDelete = card;
showDeleteCardDialog = true;
}}
>
Remove
</Button>
</div>
{/each}
{/if}
<div class="border-t pt-4">
<div class="mb-3 text-sm font-medium text-gray-700">Add a new card</div>
{#if isSquareConfigured()}
<SquareCardInput
bind:this={addCardSquareCardInput}
onReady={(r) => (addCardReady = r)}
/>
<Button
class="mt-3 w-full"
onclick={addCard}
disabled={addingCard || !addCardReady}
loading={addingCard}
>
{addingCard ? 'Adding...' : 'Add Card'}
</Button>
{:else}
<CardEntryUnavailable
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
/>
{/if}
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Redeem & Buy Gift Cards -->
<div class="mt-6 grid gap-6 md:grid-cols-2">
<!-- Redeem Gift Card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
/>
</svg>
Redeem Gift Card
</Card.Title>
<Card.Description
>Redeem a gift card directly to your account balance.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex items-center justify-between rounded-lg bg-accent p-4">
<div>
<div class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
Your Balance
</div>
<div class="mt-1 text-2xl font-bold text-card-foreground">
{loadingBalance ? '...' : formatCurrency(giftCardBalance)}
</div>
</div>
</div>
<div class="space-y-2">
<label for="redeem-code" class="text-sm font-medium text-gray-700"
>Enter Gift Card Code</label
>
<div class="flex gap-2">
<Input
id="redeem-code"
type="text"
placeholder="xxxx-xxxx-xxxx"
maxlength={14}
value={giftCardCode}
oninput={handleGiftCardInput}
class="font-mono"
/>
<Button
onclick={() => (showRedeemConfirm = true)}
disabled={redeemingGiftCard ||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
>
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
<AlertDialog.Root bind:open={showRedeemConfirm}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Redeem Gift Card</AlertDialog.Title>
<AlertDialog.Description>
Claiming this gift card will add its remaining balance directly to your account
balance, which can be used toward future bookings.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="space-y-3 px-6 py-4 text-sm text-muted-foreground">
<div class="space-y-2 rounded-lg border bg-amber-50/50 p-3">
<p>
<strong class="text-foreground">What happens when I claim?</strong>
</p>
<ul class="list-disc space-y-1 pl-4">
<li>The gift card value is added to your account balance.</li>
<li>
Account balances do not expire, but gift card codes become invalid once
redeemed.
</li>
<li>This action is final and cannot be reversed.</li>
</ul>
</div>
<div class="space-y-2 rounded-lg border bg-blue-50/50 p-3">
<p>
<strong class="text-foreground">Legal &amp; GDPR Information</strong>
</p>
<ul class="list-disc space-y-1 pl-4">
<li>
Your personal data (name, email, transaction history) is processed in
accordance with UK data protection law.
</li>
<li>
Financial records are retained for 7 years as required by HMRC, after which
personally identifiable information is anonymised.
</li>
<li>
You can request a full copy of your data or deletion of your account at any
time via your account settings.
</li>
</ul>
</div>
<p class="text-xs text-muted-foreground italic">
By redeeming this gift card, you agree to our
<a
href="/terms"
class="font-semibold text-primary hover:underline"
target="_blank"
rel="noopener noreferrer external">Terms &amp; Conditions</a
>.
</p>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={redeemGiftCard}
>Confirm &amp; Redeem</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Buy Gift Card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="5" width="20" height="14" rx="2" ry="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Buy a Gift Card
</Card.Title>
<Card.Description
>Purchase a gift card online for yourself or a friend.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
{#if purchaseResultCode}
<div class="space-y-3 rounded-lg border border-green-100 bg-green-50 p-4">
<div class="flex items-center gap-2 text-sm font-medium text-green-800">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-green-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
Purchase Successful!
</div>
{#if buyRecipientType === 'self'}
<p class="text-xs text-green-700">
Your purchase of <strong>{formatCurrency(buyAmount)}</strong> has been automatically
added to your account balance!
</p>
{:else}
<p class="text-xs text-green-700">Here is your gift card code:</p>
<div
class="rounded border border-green-200 bg-white py-2 text-center font-mono text-lg font-bold tracking-wider text-green-800"
>
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] font-semibold text-amber-600 italic">
⚠️ Please save this code and send it to your friend — no email was sent.
</p>
{/if}
<Button
size="sm"
variant="outline"
onclick={() => (purchaseResultCode = null)}
class="w-full"
>
Buy Another Card
</Button>
</div>
{:else}
<div class="space-y-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Select Value</span
>
<div class="grid grid-cols-3 gap-2">
{#each [10, 20, 50] as amount (amount)}
<button
type="button"
class="rounded-lg border py-2.5 text-center text-sm font-semibold transition-colors {buyAmount ===
amount
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (buyAmount = amount as 10 | 20 | 50)}
>
{formatCurrency(amount)}
</button>
{/each}
</div>
<p class="text-xs text-gray-500">
Gift-card purchases are limited to £500 per day.
</p>
{#if buyLimitReached}
<p class="text-xs font-semibold text-amber-600">
You've reached today's £500 gift-card purchase limit.
</p>
{/if}
</div>
<div class="space-y-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Recipient</span
>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType ===
'self'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (buyRecipientType = 'self')}
>
For Myself (Auto-Redeem)
</button>
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType ===
'friend'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (buyRecipientType = 'friend')}
>
For a Friend (Gift Code)
</button>
</div>
</div>
{#if buyRecipientType === 'friend'}
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700"
>Friend's Email (Optional)</label
>
<EmailInput
id="recipient-email"
bind:value={buyRecipientEmail}
placeholder="friend@example.com (blank to send to yourself)"
class="mt-1"
/>
</div>
{/if}
<div class="space-y-3 border-t pt-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Payment Method</span
>
<CardSelection
bind:this={buyCardSelection}
cards={savedCardsStore.cards}
{canSaveCards}
bind:selectedCardId={buySelectedCard}
bind:saveCard={buySaveCard}
onValidityChange={(v) => (buyCardSelectionValid = v)}
/>
<!-- B6/B10: saved-card gift-card charges require the card owner's
current 2FA verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={buyTwoFactor.code}
showInput={buyTwoFactor.showInput}
enabled={buyTwoFactorEnabled}
/>
{#if buyTwoFactor.showInput && buyTwoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={buyTwoFactor.requesting}
disabled={buyTwoFactor.requesting}
onclick={buyTwoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
{#if buyWaitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
{#if buyError}
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{buyError}</p>
<Button
variant="outline"
size="sm"
class="mt-2 w-full"
onclick={() => (buyError = null)}
>
Try Again
</Button>
</div>
{/if}
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard ||
!isBuyCardValid ||
buyTwoFactor.missing ||
buyDailyTotal + buyAmount > DAILY_GIFT_CARD_BUY_LIMIT}
class="mt-2 w-full"
>
{buyingGiftCard
? 'Processing Payment...'
: buyLimitReached
? 'Daily purchase limit reached'
: `Pay ${formatCurrency(buyAmount)}`}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">
Secure payment powered by Square
</p>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- My Gift Cards (14-day cooling-off cancellation) -->
<Card.Root class="mt-6">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
My Gift Cards
</Card.Title>
<Card.Description>
Gift cards you purchased online. Under UK law you have 14 days from purchase to cancel
and receive a full refund to your original payment method.
</Card.Description>
</Card.Header>
<Card.Content>
{#if loadingMyGiftCards}
<Skeleton class="h-16 w-full" />
{:else if myGiftCards.length === 0}
<p class="py-2 text-center text-gray-500">
No online gift card purchases yet. Buy a gift card above to get started.
</p>
{:else}
<div class="space-y-3">
{#each myGiftCards as gc (gc.code)}
<div
class="flex flex-col gap-2 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<div class="font-mono text-sm font-bold text-gray-900">
{formatCardCode(gc.code)}
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
<span>Value: <strong>{formatCurrency(gc.amount)}</strong></span>
<span>Purchased: {formatShortDate(gc.purchased_at)}</span>
{#if gc.expiry_date}
<span>Expires: {formatShortDate(gc.expiry_date)}</span>
{/if}
</div>
</div>
{#if gc.cancellable}
<Button
size="sm"
variant="outline"
onclick={() => {
cardToCancel = gc;
showCancelConfirm = true;
}}
disabled={cancellingCode === gc.code}
>
{cancellingCode === gc.code ? 'Cancelling...' : 'Cancel & refund'}
</Button>
{:else if gc.cancellation_reason}
<span class="text-xs text-gray-500 italic">{gc.cancellation_reason}</span>
{/if}
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
<AlertDialog.Root bind:open={showCancelConfirm}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Cancel this gift card?</AlertDialog.Title>
<AlertDialog.Description>
{cardToCancel
? `You will be refunded ${formatCurrency(cardToCancel.amount)} to the payment method you used to buy it, and the gift card will no longer be usable.`
: ''}
</AlertDialog.Description>
</AlertDialog.Header>
<div class="space-y-3 px-6 py-4 text-sm text-muted-foreground">
<div class="space-y-2 rounded-lg border bg-blue-50/50 p-3">
<p>
<strong class="text-foreground">14-day cooling-off period</strong>
</p>
<ul class="list-disc space-y-1 pl-4">
<li>You may cancel an online gift-card purchase within 14 days of buying it.</li>
<li>The full amount is refunded to your original payment method.</li>
<li>Once cancelled, the gift card cannot be used or redeemed.</li>
</ul>
</div>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel>Keep gift card</AlertDialog.Cancel>
<AlertDialog.Action onclick={cancelGiftCard}
>Confirm cancellation &amp; refund</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{:else if activeTab === 'admin'}
<!-- Admin Settings -->
<Card.Root>
<Card.Header>
<Card.Title>Account Settings</Card.Title>
<Card.Description>Manage your security and account preferences</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<!-- Change Password -->
<div>
<h3 class="mb-2 text-sm font-semibold">Password</h3>
<p class="mb-3 text-sm text-gray-600">
Update your password to keep your account secure
</p>
<Button onclick={() => (showPasswordModal = true)} variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Change Password
</Button>
</div>
<Separator />
<!-- Log Out Button -->
<div>
<h3 class="mb-2 text-sm font-semibold">Session</h3>
<p class="mb-3 text-sm text-gray-600">Log out of this account on this device.</p>
<Button onclick={() => authStore.logout()} variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 8l4 4-4 4" />
<path d="M3 12h18" />
</svg>
Log Out
</Button>
</div>
<Separator />
<!-- Export My Data -->
<div>
<h3 class="mb-2 text-sm font-semibold">Data Privacy</h3>
<p class="mb-3 text-sm text-gray-600">
View and export all personal data we hold about you
</p>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<Button onclick={() => goto('/gdpr')} variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export My Data
</Button>
{#if gdprCountdown}
<p class="mt-2 text-xs text-gray-500">
{gdprCountdown}
</p>
{/if}
</div>
<Separator />
<!-- Notification Preferences (non-admin users only) -->
{#if authStore.currentUser?.role !== 'admin'}
<div>
<h3 class="mb-2 text-sm font-semibold">Notifications</h3>
<p class="mb-3 text-sm text-gray-600">
Choose how you receive booking reminders and updates
</p>
<div class="space-y-3">
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">Email</div>
<div class="text-xs text-gray-500">Booking confirmations and reminders</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={notifPrefs.emailEnabled}
onchange={async () => {
notifPrefs.emailEnabled = !notifPrefs.emailEnabled;
await saveNotifPrefs();
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">SMS</div>
<div class="text-xs text-gray-500">Text message reminders</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={notifPrefs.smsEnabled}
onchange={async () => {
notifPrefs.smsEnabled = !notifPrefs.smsEnabled;
await saveNotifPrefs();
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">Browser</div>
<div class="text-xs text-gray-500">In-browser notifications</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={notifPrefs.browserPushEnabled}
onchange={async () => {
notifPrefs.browserPushEnabled = !notifPrefs.browserPushEnabled;
await saveNotifPrefs();
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
</div>
</div>
<Separator />
{/if}
<!-- Two-Factor Authentication (visible to all roles; the notification
preferences above are the role-gated part of this area) -->
<div>
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
<p class="mb-3 text-sm text-gray-600">
Protect online card payments with a one-time verification code
</p>
{#if authStore.currentUser?.twoFactorEnabled}
<div class="mb-3 rounded-lg border p-3">
<div class="text-sm font-medium">
Enabled
{#if authStore.currentUser?.twoFactorMethod}
({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'})
{/if}
</div>
<div class="mt-1 text-xs text-gray-500">
A verification code is required for online card payments
</div>
</div>
{:else if authStore.currentUser?.twoFactorRequired}
<div
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
>
You must enable 2FA to use online card payments.
</div>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">Email</div>
<div class="text-xs text-gray-500">Receive your verification code by email</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={selectedTwoFA === 'email'}
onchange={() => {
selectedTwoFA = selectedTwoFA === 'email' ? 'none' : 'email';
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">SMS</div>
<div class="text-xs text-gray-500">
Receive your verification code by text message
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={selectedTwoFA === 'sms'}
onchange={() => {
selectedTwoFA = selectedTwoFA === 'sms' ? 'none' : 'sms';
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
</div>
{#if twoFASetupPending}
<div class="mt-3 flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFACode}
/>
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
{twoFAVerifying ? 'Verifying...' : 'Verify'}
</Button>
</div>
{/if}
{#if showDisableCodeEntry}
<div class="mt-3">
<p class="mb-2 text-sm text-gray-600">
Enter the verification code to disable two-factor authentication.
</p>
<div class="flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFADisableCode}
disabled={twoFADisableConfirming}
/>
<Button disabled={twoFADisableConfirming} onclick={confirmDisableWithCode}>
{twoFADisableConfirming ? 'Disabling...' : 'Confirm Disable'}
</Button>
</div>
</div>
{/if}
{#if twoFADirty && !twoFASetupPending}
<Button
class="mt-3"
disabled={twoFASettingUp || twoFADisabling}
onclick={applyTwoFA}
>
{twoFASettingUp ? 'Sending...' : 'Apply'}
</Button>
{/if}
</div>
<Separator />
<div>
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
<p class="mb-3 text-sm text-gray-600">
View our cancellation, deposit, and no-show policies
</p>
<PolicyPopover>
{#snippet trigger()}
<Button variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/><polyline points="14 2 14 8 20 8" /><line
x1="16"
y1="13"
x2="8"
y2="13"
/><line x1="16" y1="17" x2="8" y2="17" /></svg
>
Cancellation & Deposit Policy
</Button>
{/snippet}
</PolicyPopover>
<PolicyPopover label="privacy policy" href="/privacy-policy">
{#snippet trigger()}
<Button variant="outline" class="mt-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Privacy Policy
</Button>
{/snippet}
</PolicyPopover>
</div>
<Separator />
{#if authStore.currentUser?.role !== 'admin'}
<!-- Delete Account -->
<div>
<h3 class="mb-2 text-sm font-semibold text-red-600">Danger Zone</h3>
<p class="mb-3 text-sm text-gray-600">
Once you delete your account, there is no going back. Please be certain.
</p>
<Button onclick={() => (showDeleteAlert = true)} variant="destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
Delete Account
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
<!-- Mobile Tab Menu (Fixed at bottom) -->
<div class="mobile-tab-menu">
<div class="flex rounded-lg border bg-gray-50 p-1">
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'general'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'general')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
General
</button>
{#if authStore.currentUser?.role !== 'admin'}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'history')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
History
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin'}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'referral')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Referral
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin' && canSaveCards}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'cards'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => {
activeTab = 'cards';
savedCardsStore.fetch();
savedCardsStore.invalidate();
fetchGiftCardBalance();
fetchMyGiftCards();
}}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Cards
</button>
{/if}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'admin'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'admin')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Admin
</button>
</div>
</div>
</div>
<!-- Password Change Modal -->
{#if showPasswordModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card.Root class="max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto">
<Card.Header>
<Card.Title>Change Password</Card.Title>
<Card.Description>Enter your current and new password</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div>
<label for="current-password" class="text-sm font-medium">Current Password</label>
<Input
id="current-password"
type="password"
bind:value={passwordData.current}
placeholder="Enter current password"
class="mt-1"
/>
</div>
<div>
<label for="new-password" class="text-sm font-medium">New Password</label>
<Input
id="new-password"
type="password"
bind:value={passwordData.new}
placeholder="Enter new password"
class="mt-1"
/>
{#if passwordData.new && newPasswordStrength}
<div class="mt-2 space-y-1">
<div class="flex h-1.5 w-full overflow-hidden rounded bg-gray-200">
<div
class="transition-all duration-300"
style="width: {(newPasswordStrength.score + 1) *
20}%; background-color: {newPasswordStrength.score < 2
? '#ef4444'
: newPasswordStrength.score === 2
? '#f59e0b'
: newPasswordStrength.score === 3
? '#22c55e'
: '#15803d'}"
></div>
</div>
<p
class="text-xs {newPasswordStrength.score < 2
? 'text-red-500'
: newPasswordStrength.score === 2
? 'text-amber-500'
: 'text-green-600'}"
>
{newPasswordStrength.feedback.warning
? newPasswordStrength.feedback.warning
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][newPasswordStrength.score]}`}
</p>
{#if newPasswordStrength.feedback.suggestions.length > 0}
<p class="text-xs text-gray-500">
{newPasswordStrength.feedback.suggestions[0]}
</p>
{/if}
</div>
{/if}
</div>
<div>
<label for="confirm-password" class="text-sm font-medium">Confirm New Password</label>
<Input
id="confirm-password"
type="password"
bind:value={passwordData.confirm}
placeholder="Confirm new password"
class="mt-1"
/>
{#if !passwordsMatch}
<p class="mt-1 text-xs text-red-500">Passwords do not match</p>
{/if}
</div>
</Card.Content>
<Card.Footer class="flex justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showPasswordModal = false;
passwordData = { current: '', new: '', confirm: '' };
}}
>
Cancel
</Button>
<Button onclick={changePassword} disabled={changingPassword || !isPasswordStrongEnough}>
{changingPassword ? 'Changing...' : 'Change Password'}
</Button>
</Card.Footer>
</Card.Root>
</div>
{/if}
<!-- Delete Account Alert -->
<AlertDialog.Root bind:open={showDeleteAlert}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Account?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete your account and remove all
your data from our servers.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="px-6 py-4">
<label for="delete-confirm" class="text-sm font-medium"
>Type <strong>DELETE</strong> to confirm:</label
>
<Input
id="delete-confirm"
type="text"
bind:value={deleteConfirmText}
placeholder="DELETE"
class="mt-2"
/>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel
onclick={() => {
deleteConfirmText = '';
}}
>
Cancel
</AlertDialog.Cancel>
<Button
variant="destructive"
onclick={deleteAccount}
disabled={deletingAccount || deleteConfirmText !== 'DELETE'}
>
{deletingAccount ? 'Deleting...' : 'Delete Account'}
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Delete Saved Card Confirmation -->
<AlertDialog.Root
bind:open={showDeleteCardDialog}
onOpenChange={(open) => {
if (!open) cardToDelete = null;
}}
>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Remove saved card?</AlertDialog.Title>
<AlertDialog.Description>
{#if cardToDelete}
Remove {cardToDelete.brand} card ending in {cardToDelete.last_4}?
{/if}
You can add it again later.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (cardToDelete = null)}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() => cardToDelete && deleteCard(cardToDelete)}
class="bg-red-600 hover:bg-red-700"
>
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Disable Two-Factor Authentication Warning -->
<AlertDialog.Root bind:open={showDisableTwoFADialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Disable two-factor authentication?</AlertDialog.Title>
<AlertDialog.Description>
Disabling two-factor authentication means online card payments will be blocked while 2FA
is required. You can re-enable it at any time.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmDisableTwoFA} class="bg-red-600 hover:bg-red-700">
Disable
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if}
<!-- User Booking Modal -->
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
<style>
/* Mobile Tab Menu Styles */
.mobile-tab-menu {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
display: block;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-tab-menu > div {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.desktop-tab-menu {
display: none;
margin-bottom: 1.5rem;
}
@media (min-width: 768px) {
.mobile-tab-menu {
display: none;
}
.desktop-tab-menu {
display: block;
}
}
</style>