Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds: - F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks - F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back - F3: post-start online overflow carved as a tip record (mirrors terminal split builder) - A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError) - A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications - A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check) - A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected - A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point) - A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface - Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications) - Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments - Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files) - Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status) - gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys) All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
3410 lines
108 KiB
Svelte
3410 lines
108 KiB
Svelte
<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,
|
||
isSavedCardVerificationRequired,
|
||
isSquareConfigured,
|
||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||
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);
|
||
|
||
// 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;
|
||
} 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;
|
||
// 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;
|
||
await fetchGiftCardBalance();
|
||
} else {
|
||
// Capture the status BEFORE consuming the body — the saved-card
|
||
// SCA check needs it, and text() can only be read once.
|
||
const status = res.status;
|
||
const errText = await res.text();
|
||
// A saved-card (ccof) charge skips the client-side SCA step, so a
|
||
// definitive 402 on the saved-card path means the issuer still
|
||
// requires verification — surface the fix instead of the generic
|
||
// backend text.
|
||
const verificationRequired = isSavedCardVerificationRequired(status, !!buySelectedCard);
|
||
toast.error(
|
||
verificationRequired
|
||
? SAVED_CARD_VERIFICATION_MESSAGE
|
||
: 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 formatShortDate(dateStr: string): string {
|
||
return new Date(dateStr).toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
timeZone: 'Europe/London'
|
||
});
|
||
}
|
||
|
||
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 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 — 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 & 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 & Conditions</a
|
||
>.
|
||
</p>
|
||
</div>
|
||
<AlertDialog.Footer>
|
||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||
<AlertDialog.Action onclick={redeemGiftCard}
|
||
>Confirm & 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>
|
||
<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)}
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
onclick={buyGiftCard}
|
||
disabled={buyingGiftCard ||
|
||
!isBuyCardValid ||
|
||
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 & 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="w-full max-w-md max-h-[calc(100dvh-2rem)] 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>
|