Files
Crussell/frontend/src/routes/account/+page.svelte
T
popertots 5cc5a7f6d2 fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change
Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.

Money-safety:
- Gift-card purchase refunds no longer create money: manual refunds of a
  no-booking (gift-card purchase) payment are rejected with a clear message
  in the direct handler AND never re-issued by the sweep-resume path
  (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue).
- BuyGiftCard no-client-key fallback: derived deterministically under the
  advisory lock (pending-row reuse fixes lost-response double-charge;
  completed-row sequence advance preserves distinct-purchase collapse fix).
- Terminal completion is never unrecorded: activeTerminalCheckoutID now calls
  recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found
  COMPLETED at Square (previously only marked the row COMPLETED — a lost poll
  left the payment invisible and unrefundable).
- Sweep: provisional tmp- checkout rows are resolved against Square first
  (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail;
  ambiguous → leave pending) instead of blind-failing a possibly-live
  checkout. recordUntrackedTerminalPayment re-checks the booking status
  (FOR UPDATE) and refuses to record on a cancelled booking, inserting a
  critical_payment_log admin notification instead. Till-sale post-charge
  UPDATE now requires status='pending' (no resurrection of a clawed-back sale).

Frontend (Svelte 5):
- UserPaymentModal keeps CardSelection mounted through processing (bind:this
  ref + Square iframe survive the loyalty/tokenize awaits) — new-card
  payments work again.
- BookingFlow clears the cached nonce/verification pair on any failure (retry
  re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid'
  refetches the booking and reconciles depositPaid so the confirmation gate
  opens; Back button disabled during processing.
- Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip.

Square wire parity (mock vs real):
- processing_fee sign unified (negated at paymentFromSquare; mock agrees).
- SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard.
- GetCardsOnFile excludes disabled cards (matches ListCards).
- ForcePaymentStatus toggle + tests prove the charge path can't be status-blind.
- CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID);
  completed terminal checkout's payment resolvable by id.

Security:
- 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction
  scan reads race-free; concurrent verify+evict tests under -race.
- Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or
  known public placeholders) with openssl rand -hex 32 guidance.
- Dockerfile no longer COPYs .env (secrets injected via compose env_file).
- SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose
  fails at config time when missing.

Testing gaps closed (each verified to FAIL without its guard):
- refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention
  blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown
  in both by-key and by-id paths), resolveChargeSource Square-failure branches,
  structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending),
  deriveBookingPaymentIdempotencyKey >45-char truncation, webhook
  findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch,
  dispute.evidence / terminal.checkout dispatch.

Infra:
- local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures
  (previously died silently under ERR_EXIT with hidden output).
- Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go
  gained the missing build tag.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
2026-08-22 00:34:49 +01:00

3031 lines
94 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 { canSaveCardsForRole, isNonceStale, isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
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);
// 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('');
// 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 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();
} 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;
} 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 = generateIdempotencyKey();
buyKeyedAmount = buyAmount;
buyKeyedCard = cardKey;
}
const res = await submitPaymentWithRetry(() =>
apiFetch('/api/user/giftcards/buy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: buyAmount * 100, // cents
recipient_type: buyRecipientType,
recipient_email: buyRecipientEmail,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
idempotency_key: buyIdempotencyKey
})
})
);
if (res.ok) {
const data = await res.json();
toast.success('Gift card purchased successfully!');
purchaseResultCode = data.code;
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
await fetchGiftCardBalance();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to purchase gift card');
// 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. The idempotency key stays for
// network-timeout dedup.
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
}
} 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;
}
}
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 generateIdempotencyKey(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
array[6] = (array[6] & 0x0f) | 0x40;
array[8] = (array[8] & 0x3f) | 0x80;
return [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
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 twoFADevCode = $state('');
let twoFASettingUp = $state(false);
let twoFAVerifying = $state(false);
let twoFADisabling = $state(false);
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(() => ({}));
// Dev-only: unenforced environments return the code so the loose
// fake flow is usable without reading backend logs.
twoFADevCode = data.code ?? '';
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 = '';
twoFADevCode = '';
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;
}
}
// 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();
}
});
// =============== 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 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();
}}
>
<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] text-amber-600 italic font-semibold">
⚠️ 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>
</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)}
/>
</div>
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard || !isBuyCardValid}
class="mt-2 w-full"
>
{buyingGiftCard ? 'Processing Payment...' : `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>
{: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 />
<!-- Two-Factor Authentication -->
<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="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>
<Button
variant="outline"
class="mt-3"
disabled={twoFADisabling}
onclick={disableTwoFA}
>
{twoFADisabling ? 'Disabling...' : 'Disable'}
</Button>
</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>
{:else}
<p class="mb-3 text-xs text-gray-500">
2FA is optional right now (REQUIRE_2FA is off)
</p>
{/if}
{#if twoFASetupPending}
{#if twoFADevCode}
<p class="mb-2 text-xs text-gray-500">
Dev code: <strong>{twoFADevCode}</strong>
</p>
{/if}
<div class="flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFACode}
/>
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
{twoFAVerifying ? 'Verifying...' : 'Verify'}
</Button>
</div>
{:else}
<div class="space-y-2">
<label class="flex items-center gap-2 text-sm">
<input
type="radio"
name="twofa-method"
value="email"
bind:group={twoFAMethod}
class="accent-fuchsia-600"
/>
Email
</label>
<label class="flex items-center gap-2 text-sm">
<input
type="radio"
name="twofa-method"
value="sms"
bind:group={twoFAMethod}
class="accent-fuchsia-600"
/>
SMS
</label>
</div>
<Button class="mt-3" disabled={twoFASettingUp} onclick={startTwoFASetup}>
{twoFASettingUp ? 'Sending...' : 'Enable'}
</Button>
{/if}
{/if}
</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}
<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 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();
}}
>
<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="w-full max-w-md">
<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 class="z-60">
<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>
{/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>