Files
Crussell/frontend/src/routes/account/+page.svelte
T
popertots 3eec71a56c fix: sanitize API error text display and add time_blockers tests
Add extractErrorMessage helper for JSON error body parsing and apply sanitizeText across all toast displays. Add time_blockers test coverage for new holiday placeholder cleanup and overlapping scenarios.
2026-08-22 00:34:48 +01:00

3060 lines
94 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore, type User } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
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 { Checkbox } from '$lib/components/ui/checkbox';
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(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
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);
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;
}
}
let showAddCard = $state(false);
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let addingCard = $state(false);
// =============== Gift Card State ===============
let giftCardBalance = $state(0);
let loadingBalance = $state(false);
let giftCardCode = $state('');
let redeemingGiftCard = $state(false);
let showRedeemConfirm = $state(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 buyNewCardNumber = $state('');
let buyNewCardExpiry = $state('');
let buyNewCardCVC = $state('');
let buySaveCard = $state(false);
let buyingGiftCard = $state(false);
let purchaseResultCode = $state<string | null>(null);
$effect(() => {
if (savedCardsStore.cards.length > 0 && !buySelectedCard) {
const defaultCard =
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
buySelectedCard = defaultCard.id;
}
});
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
// Derived validations for Add Saved Card form
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryInPast = $derived(
newCardExpiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(newCardExpiryParts.year, newCardExpiryParts.month);
return expiryDate < new SvelteDate();
})()
);
const isNewCardExpiryInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const addCardError = $derived(
newCardNumber.length > 0 && !isValidLuhn(newCardNumber)
? 'Invalid card number'
: isNewCardExpiryInvalidMonth
? 'Invalid expiry month'
: isNewCardExpiryInPast
? 'This card has already expired'
: newCardCVC.length > 0 && newCardCVC.length < 3
? 'CVC must be at least 3 digits'
: null
);
const isAddCardValid = $derived(
isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
);
// Derived validations for Buy Gift Card form
const buyNewCardExpiryParts = $derived(parseExpiryParts(buyNewCardExpiry));
const isBuyNewCardExpiryInPast = $derived(
buyNewCardExpiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(buyNewCardExpiryParts.year, buyNewCardExpiryParts.month);
return expiryDate < new SvelteDate();
})()
);
const isBuyNewCardExpiryInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) && buyNewCardExpiryParts === null
);
const buyCardError = $derived(
buyNewCardNumber.length > 0 && !isValidLuhn(buyNewCardNumber)
? 'Invalid card number'
: isBuyNewCardExpiryInvalidMonth
? 'Invalid expiry month'
: isBuyNewCardExpiryInPast
? 'This card has already expired'
: buyNewCardCVC.length > 0 && buyNewCardCVC.length < 3
? 'CVC must be at least 3 digits'
: null
);
const isBuyCardValid = $derived(
buySelectedCard !== '' ||
(isValidLuhn(buyNewCardNumber) &&
buyNewCardExpiryParts !== null &&
!isBuyNewCardExpiryInPast &&
buyNewCardCVC.length >= 3)
);
async function fetchGiftCardBalance() {
loadingBalance = true;
try {
const res = await apiFetch('/api/user/giftcards/balance');
if (res.ok) {
const data = await res.json();
giftCardBalance = data.balance;
}
} catch {
// ignore
} finally {
loadingBalance = false;
}
}
async function redeemGiftCard() {
if (giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12) {
toast.error('Invalid gift card code format');
return;
}
redeemingGiftCard = true;
try {
const res = await apiFetch('/api/user/giftcards/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: giftCardCode })
});
if (res.ok) {
const data = await res.json();
toast.success(`Success! Redeemed ${formatCurrency(data.amount_redeemed)} to your balance.`);
giftCardCode = '';
await fetchGiftCardBalance();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to redeem gift card');
}
} catch (err) {
console.error('redeemGiftCard error:', err);
toast.error('Network error');
} finally {
redeemingGiftCard = false;
}
}
async function buyGiftCard() {
buyingGiftCard = true;
try {
let cardId: string | undefined;
let newCardToken: string | undefined;
let saveCard = false;
if (buySelectedCard) {
cardId = buySelectedCard;
} else if (buyNewCardNumber) {
if (
!isValidLuhn(buyNewCardNumber) ||
!/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) ||
buyNewCardCVC.length < 3
) {
toast.error('Please enter valid credit card details');
buyingGiftCard = false;
return;
}
newCardToken = buyNewCardNumber;
saveCard = buySaveCard;
} else {
toast.error('Please select or enter card details');
buyingGiftCard = false;
return;
}
const idempotencyKey = generateIdempotencyKey();
const res = await 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,
card_id: cardId,
new_card_token: newCardToken,
save_card: saveCard,
idempotency_key: idempotencyKey
})
});
if (res.ok) {
const data = await res.json();
toast.success('Gift card purchased successfully!');
purchaseResultCode = data.code;
buyNewCardNumber = '';
buyNewCardExpiry = '';
buyNewCardCVC = '';
await fetchGiftCardBalance();
if (buySelectedCard === '') {
await savedCardsStore.fetch();
}
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to purchase gift card');
}
} catch (err) {
console.error('buyGiftCard error:', err);
toast.error('Network error');
} finally {
buyingGiftCard = 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 isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function formatExpiryDate(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
function handleCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatCardNumber);
newCardNumber = formatted;
}
function handleExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
newCardExpiry = formatted;
}
function handleCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) =>
val.replace(/\D/g, '').substring(0, 4)
);
newCardCVC = formatted;
}
function handleBuyCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatCardNumber);
buyNewCardNumber = formatted;
}
// Uses custom formatter with MM/YY slash and preserves cursor position
function handleBuyExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
buyNewCardExpiry = formatted;
}
function handleBuyCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) =>
val.replace(/\D/g, '').substring(0, 4)
);
buyNewCardCVC = formatted;
}
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 addCard() {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
newCardCVC.length < 3
) {
toast.error('Please fill in all card details correctly');
return;
}
const cardNum = newCardNumber.replace(/\s/g, '');
const [monthStr, yearStr] = newCardExpiry.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) {
toast.error('Invalid expiry month');
return;
}
const expiryDate = new Date(year, month);
if (expiryDate < new Date()) {
toast.error('This card has already expired');
return;
}
addingCard = true;
try {
const res = await apiFetch('/api/user/payment-methods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
card_number: cardNum,
expiry: newCardExpiry,
cvc: newCardCVC
})
});
if (res.ok) {
toast.success('Card added');
showAddCard = false;
newCardNumber = '';
newCardExpiry = '';
newCardCVC = '';
savedCardsStore.invalidate();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to add card');
}
} catch (err) {
console.error('addCard error:', err);
toast.error('Network error');
} finally {
addingCard = false;
}
}
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 */
}
}
// 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 SvelteDate();
// Filter: Calculate end time (Start + Duration) and check if it's in the future
const activeOrFutureBookings = (data.bookings || []).filter((b: Booking) => {
const startTime = new SvelteDate(b.start_time);
// Add duration (in ms)
const endTime = new SvelteDate(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 SvelteDate();
bookings = bookings.filter((b: Booking) => {
const startTime = new SvelteDate(b.start_time);
const endTime = new SvelteDate(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 new SvelteDate(b.start_time).getTime() - new SvelteDate(a.start_time).getTime();
});
pastBookings = bookings;
pastPage = data.page || page;
pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
} catch (err) {
console.error('Error fetching past bookings:', err);
toast.error('Network error loading past bookings');
} finally {
loadingPast = false;
}
}
// =============== Pagination Helpers ===============
function goToPastPage(page: number) {
if (page < 1 || page > pastTotalPages) return;
fetchPastBookings(page);
}
$effect(() => {
if (pageState === 'authorized') {
fetchUserData();
fetchUpcomingBookings();
fetchPastBookings();
fetchNotifPrefs();
fetchGdprExportStatus();
}
});
// =============== Password Change ===============
let showPasswordModal = $state(false);
let passwordData = $state({
current: '',
new: '',
confirm: ''
});
let changingPassword = $state(false);
// Password strength using zxcvbn
const newPasswordStrength = $derived(passwordData.new ? zxcvbn.check(passwordData.new) : null);
const isPasswordStrongEnough = $derived(
!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2
);
const passwordsMatch = $derived(
passwordData.confirm === '' || passwordData.new === passwordData.confirm
);
async function changePassword() {
if (passwordData.new !== passwordData.confirm) {
toast.error('New passwords do not match');
return;
}
if (passwordData.new.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
if (!isPasswordStrongEnough) {
toast.error('Please choose a stronger password');
return;
}
changingPassword = true;
const loadingToast = toast.loading('Changing password...');
try {
const response = await apiFetch('/api/user/change-password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: passwordData.current,
new_password: passwordData.new
})
});
if (response.ok) {
toast.success('Password changed successfully!', { id: loadingToast });
showPasswordModal = false;
passwordData = { current: '', new: '', confirm: '' };
} else {
const text = await response.text();
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to change password', { id: loadingToast });
}
} catch (err) {
console.error('Error changing password:', err);
toast.error('Network error', { id: loadingToast });
} finally {
changingPassword = false;
}
}
// =============== Booking modal ==============
// =============== Modal State ===============
let showBookingModal = $state(false);
let selectedBookingId = $state<string | null>(null);
function openBookingModal(id: string) {
selectedBookingId = id;
showBookingModal = true;
}
// =============== Account Deletion ===============
let showDeleteAlert = $state(false);
let deleteConfirmText = $state('');
let deletingAccount = $state(false);
async function deleteAccount() {
if (deleteConfirmText !== 'DELETE') {
toast.error('Please type DELETE to confirm');
return;
}
deletingAccount = true;
const loadingToast = toast.loading('Deleting account...');
try {
const response = await apiFetch('/api/user/account', {
method: 'DELETE'
});
if (response.ok) {
toast.success('Account deleted successfully', { id: loadingToast });
authStore.logout();
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/');
} else {
const text = await response.text();
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to delete account', { id: loadingToast });
}
} catch (err) {
console.error('Error deleting account:', err);
toast.error('Network error', { id: loadingToast });
} finally {
deletingAccount = false;
}
}
// =============== Copy Referral Code ===============
function copyReferralCode() {
if (userData?.referralCode) {
navigator.clipboard.writeText(userData.referralCode.replace(/-/g, ''));
toast.success('Referral code copied to clipboard!');
}
}
function formatDateTime(dateString: string): string {
const date = new SvelteDate(dateString);
// Get date parts
const day = date.getDate();
const month = date.toLocaleString('en-GB', { month: 'short' });
const year = date.getFullYear();
// Get time parts
const hours = date.getHours();
const minutes = date.getMinutes();
// Special cases for midnight and noon
let timeStr;
if (hours === 12 && minutes === 0) {
timeStr = 'Noon';
} else if (hours === 0 && minutes === 0) {
timeStr = 'Midnight';
} else {
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
timeStr = `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
return `${day} ${month} ${year}, ${timeStr}`;
}
</script>
<svelte:head>
<script>
(function () {
// Pre-hydration auth guard: reads localStorage directly because the Svelte
// authStore hasn't initialized yet at this point (async+$state). This runs
// synchronously in <svelte:head> before any rendering, preventing a flash
// of protected content. The authStore handles post-hydration auth.
try {
var token = localStorage.getItem('authToken');
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
<style>
:root {
--bgColorMenu: #1d1d27;
--duration: 0.7s;
}
</style>
</svelte:head>
{#if pageState === 'loading'}
<div class="mx-auto max-w-4xl space-y-6 p-4 pb-32">
<div class="mb-8 text-center">
<Skeleton class="mx-auto h-8 w-48" />
<Skeleton class="mx-auto mt-2 h-4 w-64" />
</div>
<Card.Root>
<Card.Content class="space-y-4 pt-6">
{#each range(5) as i (i)}
<Skeleton class="h-12 w-full" />
{/each}
</Card.Content>
</Card.Root>
</div>
{:else if pageState === 'authorized'}
<div class="mx-auto max-w-4xl space-y-6 p-4 pb-32">
<!-- Header -->
<div class="mb-8 text-center">
<h1 class="font-['Playfair_Display'] text-4xl font-bold">My Account</h1>
<p class="text-gray-600">Manage your profile, bookings, and settings</p>
</div>
<!-- Desktop Tab Menu (Show at top of content) -->
<div class="desktop-tab-menu">
<div class="flex rounded-lg border bg-gray-50 p-1">
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'general'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'general')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
General
</button>
{#if authStore.currentUser?.role !== 'admin'}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'history')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
History
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin'}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'referral')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Referral
</button>
{/if}
{#if canSaveCards}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'cards'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => {
activeTab = 'cards';
savedCardsStore.fetch();
savedCardsStore.invalidate();
fetchGiftCardBalance();
}}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Cards
</button>
{/if}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'admin'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'admin')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Admin
</button>
</div>
</div>
<!-- Tab Content -->
<div class="tab-content">
{#if activeTab === 'general'}
<!-- General Details -->
<Card.Root>
<Card.Header>
<Card.Title>Profile Information</Card.Title>
<Card.Description>Your personal details and account information</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if userData}
{@const initials =
userData.firstName && userData.lastName
? userData.firstName
.split(' ')
.map((n) => n[0])
.join('') +
userData.lastName
.split(' ')
.map((n) => n[0])
.join('')
: ''}
{@const hasImage = !!userData.profilePicUrl || !!previewUrl}
{@const displayUrl = previewUrl || userData.profilePicUrl || ''}
<div class="flex flex-col items-center gap-4">
{#if hasImage || initials}
{#if hasImage}
<img
src={displayUrl}
alt="Profile"
class="h-24 w-24 rounded-full object-cover ring-4 ring-fuchsia-200"
/>
{:else}
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-fuchsia-200"
>
{initials}
</div>
{/if}
{:else}
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-fuchsia-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
{/if}
<Button
variant="outline"
onclick={() => document.getElementById('profile-pic-input')?.click()}
>
Upload profile picture
</Button>
<input
id="profile-pic-input"
type="file"
accept="image/*"
class="hidden"
onchange={handleFileSelect}
/>
</div>
{/if}
<Dialog.Root bind:open={cropDialogOpen}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title>Crop Profile Picture</Dialog.Title>
</Dialog.Header>
<div class="relative h-64 w-full">
{#if cropImageUrl}
<Cropper
image={cropImageUrl}
aspect={1}
cropShape="round"
showGrid={false}
bind:crop
bind:zoom
oncropcomplete={(e) => {
cropArea = e.pixels;
}}
/>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={handleCropCancel}>Cancel</Button>
<Button onclick={handleCropSave}>Save</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{#if loadingUser}
{#each range(6) as i (i)}
<Skeleton class="h-12 w-full" />
{/each}
{:else if userData}
<div class="grid gap-4 md:grid-cols-2">
<div>
<span class="text-sm font-medium text-gray-600">First Name</span>
{#if editingFirstName}
<div class="mt-1 space-y-2">
<Input
id="first-name"
bind:value={firstNameInput}
placeholder="Enter first name"
maxlength={50}
error={firstNameError}
/>
{#if firstNameError}
<p class="text-xs text-red-500">{firstNameError}</p>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={saveName}
disabled={savingName || !firstNameInput.trim()}
>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onclick={cancelEditName}
disabled={savingName}
>
Cancel
</Button>
</div>
</div>
{:else}
<div
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
>
<span>{userData.firstName}</span>
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
<div>
<span class="text-sm font-medium text-gray-600">Last Name</span>
{#if editingLastName}
<div class="mt-1 space-y-2">
<Input
id="last-name"
bind:value={lastNameInput}
placeholder="Enter last name"
maxlength={50}
error={lastNameError}
/>
{#if lastNameError}
<p class="text-xs text-red-500">{lastNameError}</p>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={saveName}
disabled={savingName || !lastNameInput.trim()}
>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onclick={cancelEditName}
disabled={savingName}
>
Cancel
</Button>
</div>
</div>
{:else}
<div
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
>
<span>{userData.lastName}</span>
<Button size="sm" variant="ghost" onclick={startEditLastName}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
<div>
<span class="text-sm font-medium text-gray-600">Email</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
{userData.email}
</div>
</div>
<div>
<span class="text-sm font-medium text-gray-600">Phone</span>
{#if editingPhone}
<div class="mt-1 space-y-2">
<PhoneInput
id="phone"
bind:value={phoneInput}
bind:error={phoneError}
placeholder="Enter phone number"
/>
<div class="flex gap-2">
<Button
size="sm"
onclick={savePhone}
disabled={savingPhone || !isValidUKPhone(phoneInput)}
>
{savingPhone ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onclick={cancelEditPhone}
disabled={savingPhone}
>
Cancel
</Button>
</div>
</div>
{:else}
<div
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
>
<span>{userData.phone || '—'}</span>
<Button size="sm" variant="ghost" onclick={startEditPhone}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
</div>
{#if authStore.currentUser?.role !== 'admin'}
<Separator class="my-4" />
{#if stamps >= 10}
<div
class="mb-3 rounded-xl border-2 border-emerald-200 bg-emerald-50 p-4 text-center"
>
<p class="text-sm font-bold tracking-wide text-emerald-800">
Card Completed — 10% Off Your Next Booking!
</p>
</div>
{/if}
<div class="rounded-xl border-2 border-fuchsia-200 bg-fuchsia-50 p-5 sm:p-6">
{#if userData}
<div class="mb-5 text-center sm:text-left">
<h3 class="text-sm font-semibold text-gray-900">Loyalty Stamp Card</h3>
<p class="mt-0.5 text-xs text-gray-500">
{stamps < 10
? `Collect ${10 - stamps} more stamp${10 - stamps === 1 ? '' : 's'} to get 10% off your next booking.`
: (() => {
const fc = Math.floor(stamps / 10);
return `You have ${fc === 1 ? 'a' : fc} full stampcard${fc === 1 ? '' : 's'} ready to take advantage of at your next booking!`;
})()}
</p>
</div>
<div class="grid grid-cols-5 gap-3 sm:grid-cols-10">
{#each range(10) as i (i)}
{@const slotNum = i + 1}
{@const rot = ((slotNum * 37 + 13) % 7) - 3}
{#if slotNum <= (stamps > 0 ? stamps % 10 || 10 : 0)}
<div
class="aspect-square transition-transform duration-200 hover:scale-110"
>
<div
class="relative flex h-full w-full items-center justify-center text-fuchsia-300"
style="transform: rotate({rot}deg)"
>
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 100 100">
<defs>
<mask id="stamp-mask-{slotNum}">
<path d={getStampPath(slotNum)} fill="white" />
<path
d="M 50 25 L 56 43 L 75 43 L 60 53.5 L 66 71.5 L 50 62 L 34 71.5 L 40 53.5 L 25 43 L 44 43 Z"
fill="black"
/>
</mask>
</defs>
<path
d={getStampPath(slotNum)}
fill="currentColor"
mask="url(#stamp-mask-{slotNum})"
/>
</svg>
</div>
</div>
{:else}
<div
class="aspect-square transition-transform duration-200 hover:scale-110"
>
<div
class="relative flex h-full w-full items-center justify-center text-fuchsia-300/40 transition-colors duration-200 hover:text-fuchsia-400/60"
style="transform: rotate({rot}deg)"
>
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 100 100">
<path
d={getStampPath(slotNum)}
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-dasharray="3 3"
/>
</svg>
<span
class="relative z-10 text-[10px] leading-none font-semibold text-fuchsia-400/50"
>{slotNum}</span
>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</Card.Content>
</Card.Root>
{:else if activeTab === 'history'}
<!-- Upcoming Bookings -->
{#if upcomingBookings.length > 0}
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Upcoming Appointments</Card.Title>
<Card.Description
>Next {upcomingBookings.length < 3 ? upcomingBookings.length : 3} upcoming bookings</Card.Description
>
</Card.Header>
<Card.Content class="space-y-2">
{#if loadingUpcoming}
{#each range(3) as i (i)}
<Skeleton class="h-16 w-full" />
{/each}
{:else}
{#each upcomingBookings as b (b.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">{formatDateTime(b.start_time)}</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<!-- Show Status Chip for Upcoming -->
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
'confirmed'
? 'bg-emerald-100 text-emerald-800'
: b.status === 'pending'
? 'bg-amber-100 text-amber-800'
: b.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'}"
>
{b.status}
</span>
<!-- Services: Only show if data exists -->
{#if b.services && b.services.length > 0}
<span>
- {(() => {
const services = b.services.map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
{/if}
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
</Card.Content>
</Card.Root>
{/if}
<!-- Past Bookings -->
<Card.Root>
<Card.Header>
<Card.Title>Past Appointments</Card.Title>
<Card.Description>Previous bookings</Card.Description>
</Card.Header>
<Card.Content class="space-y-2">
{#if loadingPast}
{#each range(5) as i (i)}
<Skeleton class="h-16 w-full" />
{/each}
{:else if pastBookings.length === 0}
<div class="py-4 text-center text-gray-500">No past bookings</div>
{:else}
{#each pastBookings as b (b.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">{formatDateTime(b.start_time)}</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<!-- Unpaid Chip: Matches the 'Confirmed' chip style but uses Red for urgency -->
{#if (b.amount_due || 0) > 0}
<span
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
>
Unpaid
</span>
{/if}
<!-- Services: Hidden if empty -->
{#if b.services && b.services.length > 0}
<span>
- {(() => {
const services = b.services.map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
{/if}
{#if b.total_amount}
<span class="font-medium text-gray-700">
— £{b.total_amount.toFixed(2)}
</span>
{/if}
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
<!-- Pagination Controls -->
{#if pastTotalPages > 1}
<div class="mt-2 flex justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={pastPage === 1}
onclick={() => goToPastPage(pastPage - 1)}>Prev</Button
>
<span class="px-2 py-1 text-sm text-gray-700">{pastPage} / {pastTotalPages}</span>
<Button
variant="outline"
size="sm"
disabled={pastPage === pastTotalPages}
onclick={() => goToPastPage(pastPage + 1)}>Next</Button
>
</div>
{/if}
</Card.Content>
</Card.Root>
{:else if activeTab === 'referral'}
<!-- Referral Program -->
<Card.Root>
<Card.Header>
<Card.Title>Referral Program</Card.Title>
<Card.Description>Share your code and earn rewards</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if loadingUser}
<Skeleton class="h-32 w-full" />
{:else if userData?.referralCode}
<div class="rounded-lg border p-6 text-center">
<div class="mb-3 text-sm font-medium text-muted-foreground">Your Referral Code</div>
<div class="mb-4 flex items-center justify-center">
{#each userData.referralCode.match(/.{1,4}/g) as part, i (i)}<span
class="inline-flex min-w-[3.5rem] items-center justify-center border-b-2 border-b-border px-1 pb-1 text-2xl font-bold tracking-widest text-foreground sm:min-w-[5rem] sm:text-4xl"
>{part}</span
>{#if i < 2}<span
class="mx-1 text-xl font-bold text-muted-foreground select-none sm:mx-2 sm:text-3xl"
aria-hidden="true"></span
>{/if}{/each}
</div>
<Button onclick={copyReferralCode} variant="outline" class="w-full">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy Code
</Button>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold">
{userData.referralCodeUses || 0}
</div>
<div class="text-sm">Friends Referred</div>
</div>
<div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold">
£{(userData.referralSavings || 0).toFixed(2)}
</div>
<div class="text-sm">Total Saved</div>
</div>
</div>
<Card.Root class="mt-6 border-amber-200/60 bg-amber-50">
<Card.Content class="pt-4 md:pt-6">
<div class="space-y-2 text-sm text-amber-900">
<h4 class="font-semibold text-amber-800">How it works:</h4>
<ul class="space-y-1 pl-4">
<li>• Share your referral code with friends</li>
<li>• They get 10% off their first booking</li>
<li>• You get 10% off your next booking after they claim</li>
<li>• You earn 3 loyalty stamp for each use to keep the savings going</li>
</ul>
</div>
</Card.Content>
</Card.Root>
{:else}
<div class="py-8 text-center text-gray-500">No referral code available</div>
{/if}
</Card.Content>
</Card.Root>
{:else if activeTab === 'cards'}
<!-- Saved Cards -->
<Card.Root>
<Card.Header>
<Card.Title>Saved Cards</Card.Title>
<Card.Description>Manage your saved payment methods</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 if showAddCard}
<div class="space-y-4">
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-3 text-sm font-medium text-gray-700">Add New Card</h4>
<div class="space-y-3">
<div>
<label for="account-cardNumber" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input
id="account-cardNumber"
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={handleCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="account-cardExpiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input
id="account-cardExpiry"
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={handleExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="account-cardCVC" class="text-sm font-medium text-gray-700"
>CVC</label
>
<Input
id="account-cardCVC"
type="text"
inputmode="numeric"
value={newCardCVC}
oninput={handleCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
{#if addCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{addCardError}</div>
{/if}
</div>
</div>
<div class="flex gap-3">
<Button
variant="ghost"
onclick={() => {
showAddCard = false;
newCardNumber = '';
newCardExpiry = '';
newCardCVC = '';
}}
>
Cancel
</Button>
<Button
onclick={addCard}
loading={addingCard}
disabled={addingCard || !isAddCardValid}
>
Add Card
</Button>
</div>
</div>
{:else if savedCardsStore.cards.length === 0}
<div class="py-8 text-center">
<p class="text-gray-500">No saved cards yet</p>
<Button class="mt-4" onclick={() => (showAddCard = true)}>Add a Card</Button>
</div>
{:else}
<div class="space-y-3">
{#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">
<div
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
>
{card.brand}
</div>
<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}
<Button variant="outline" class="w-full" onclick={() => (showAddCard = true)}>
+ Add a Card
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Redeem & Buy Gift Cards -->
<div class="mt-6 grid gap-6 md:grid-cols-2">
<!-- Redeem Gift Card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
/>
</svg>
Redeem Gift Card
</Card.Title>
<Card.Description
>Redeem a gift card directly to your account balance.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex items-center justify-between rounded-lg bg-accent p-4">
<div>
<div class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
Your Balance
</div>
<div class="mt-1 text-2xl font-bold text-card-foreground">
{loadingBalance ? '...' : formatCurrency(giftCardBalance)}
</div>
</div>
</div>
<div class="space-y-2">
<label for="redeem-code" class="text-sm font-medium text-gray-700"
>Enter Gift Card Code</label
>
<div class="flex gap-2">
<Input
id="redeem-code"
type="text"
placeholder="xxxx-xxxx-xxxx"
maxlength={14}
value={giftCardCode}
oninput={handleGiftCardInput}
class="font-mono"
/>
<Button
onclick={() => (showRedeemConfirm = true)}
disabled={redeemingGiftCard ||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
>
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
<AlertDialog.Root bind:open={showRedeemConfirm}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Redeem Gift Card</AlertDialog.Title>
<AlertDialog.Description>
Claiming this gift card will add its remaining balance directly to your account
balance, which can be used toward future bookings.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="space-y-3 px-6 py-4 text-sm text-muted-foreground">
<div class="space-y-2 rounded-lg border bg-amber-50/50 p-3">
<p>
<strong class="text-foreground">What happens when I claim?</strong>
</p>
<ul class="list-disc space-y-1 pl-4">
<li>The gift card value is added to your account balance.</li>
<li>
Account balances do not expire, but gift card codes become invalid once
redeemed.
</li>
<li>This action is final and cannot be reversed.</li>
</ul>
</div>
<div class="space-y-2 rounded-lg border bg-blue-50/50 p-3">
<p>
<strong class="text-foreground">Legal &amp; GDPR Information</strong>
</p>
<ul class="list-disc space-y-1 pl-4">
<li>
Your personal data (name, email, transaction history) is processed in
accordance with UK data protection law.
</li>
<li>
Financial records are retained for 7 years as required by HMRC, after which
personally identifiable information is anonymised.
</li>
<li>
You can request a full copy of your data or deletion of your account at any
time via your account settings.
</li>
</ul>
</div>
<!-- TODO: Link full Terms & Conditions once the T&Cs page is created -->
<p class="text-xs text-muted-foreground italic">
By redeeming this gift card, you agree to our Terms &amp; Conditions.
<em>Link T&amp;Cs here once available.</em>
</p>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={redeemGiftCard}
>Confirm &amp; Redeem</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Buy Gift Card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="5" width="20" height="14" rx="2" ry="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Buy a Gift Card
</Card.Title>
<Card.Description
>Purchase a gift card online for yourself or a friend.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
{#if purchaseResultCode}
<div class="space-y-3 rounded-lg border border-green-100 bg-green-50 p-4">
<div class="flex items-center gap-2 text-sm font-medium text-green-800">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-green-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
Purchase Successful!
</div>
{#if buyRecipientType === 'self'}
<p class="text-xs text-green-700">
Your purchase of <strong>{formatCurrency(buyAmount)}</strong> has been automatically
added to your account balance!
</p>
{:else}
<p class="text-xs text-green-700">Here is your gift card code:</p>
<div
class="rounded border border-green-200 bg-white py-2 text-center font-mono text-lg font-bold tracking-wider text-green-800"
>
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] text-amber-600 italic font-semibold">
⚠️ Please save this code and send it to your friend — no email was sent.
</p>
{/if}
<Button
size="sm"
variant="outline"
onclick={() => (purchaseResultCode = null)}
class="w-full"
>
Buy Another Card
</Button>
</div>
{:else}
<div class="space-y-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Select Value</span
>
<div class="grid grid-cols-3 gap-2">
{#each [10, 20, 50] as amount (amount)}
<button
type="button"
class="rounded-lg border py-2.5 text-center text-sm font-semibold transition-colors {buyAmount ===
amount
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (buyAmount = amount as 10 | 20 | 50)}
>
{formatCurrency(amount)}
</button>
{/each}
</div>
</div>
<div class="space-y-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Recipient</span
>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType ===
'self'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (buyRecipientType = 'self')}
>
For Myself (Auto-Redeem)
</button>
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType ===
'friend'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (buyRecipientType = 'friend')}
>
For a Friend (Gift Code)
</button>
</div>
</div>
{#if buyRecipientType === 'friend'}
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700"
>Friend's Email (Optional)</label
>
<EmailInput
id="recipient-email"
bind:value={buyRecipientEmail}
placeholder="friend@example.com (blank to send to yourself)"
class="mt-1"
/>
</div>
{/if}
<div class="space-y-3 border-t pt-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Payment Method</span
>
{#if savedCardsStore.cards.length > 0}
<div class="space-y-2">
{#each savedCardsStore.cards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = card.id;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium text-gray-700 uppercase"
>
{card.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-xs text-gray-400"
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
</div>
</div>
{#if buySelectedCard === card.id}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
''
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = '';
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if buySelectedCard === ''}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if buySelectedCard === ''}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<div>
<label for="buy-card-num" class="text-xs font-medium text-gray-600"
>Card Number</label
>
<Input
id="buy-card-num"
type="text"
inputmode="numeric"
placeholder="1234 5678 9012 3456"
value={buyNewCardNumber}
oninput={handleBuyCardNumberInput}
maxlength={19}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="buy-card-exp" class="text-xs font-medium text-gray-600"
>Expiry (MM/YY)</label
>
<Input
id="buy-card-exp"
type="text"
inputmode="numeric"
placeholder="MM/YY"
value={buyNewCardExpiry}
oninput={handleBuyExpiryInput}
maxlength={5}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
<div>
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600"
>CVC</label
>
<Input
id="buy-card-cvc"
type="text"
inputmode="numeric"
placeholder="123"
value={buyNewCardCVC}
oninput={handleBuyCvcInput}
maxlength={4}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
</div>
{#if buyCardError}
<div class="mt-1 text-[10px] font-semibold text-red-500">
{buyCardError}
</div>
{/if}
<div class="flex items-center gap-2 pt-1">
<Checkbox id="buy-save-card" bind:checked={buySaveCard} />
<label for="buy-save-card" class="text-[10px] text-gray-500"
>Save card for future purchases</label
>
</div>
</div>
{/if}
</div>
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard || !isBuyCardValid}
class="mt-2 w-full"
>
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
</Button>
{/if}
</Card.Content>
</Card.Root>
</div>
{:else if activeTab === 'admin'}
<!-- Admin Settings -->
<Card.Root>
<Card.Header>
<Card.Title>Account Settings</Card.Title>
<Card.Description>Manage your security and account preferences</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<!-- Change Password -->
<div>
<h3 class="mb-2 text-sm font-semibold">Password</h3>
<p class="mb-3 text-sm text-gray-600">
Update your password to keep your account secure
</p>
<Button onclick={() => (showPasswordModal = true)} variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Change Password
</Button>
</div>
<Separator />
<!-- Log Out Button -->
<div>
<h3 class="mb-2 text-sm font-semibold">Session</h3>
<p class="mb-3 text-sm text-gray-600">Log out of this account on this device.</p>
<Button onclick={() => authStore.logout()} variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 8l4 4-4 4" />
<path d="M3 12h18" />
</svg>
Log Out
</Button>
</div>
<Separator />
<!-- Export My Data -->
<div>
<h3 class="mb-2 text-sm font-semibold">Data Privacy</h3>
<p class="mb-3 text-sm text-gray-600">
View and export all personal data we hold about you
</p>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<Button onclick={() => goto('/gdpr')} variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export My Data
</Button>
{#if gdprCountdown}
<p class="mt-2 text-xs text-gray-500">
{gdprCountdown}
</p>
{/if}
</div>
<Separator />
<!-- Notification Preferences (non-admin users only) -->
{#if authStore.currentUser?.role !== 'admin'}
<div>
<h3 class="mb-2 text-sm font-semibold">Notifications</h3>
<p class="mb-3 text-sm text-gray-600">
Choose how you receive booking reminders and updates
</p>
<div class="space-y-3">
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">Email</div>
<div class="text-xs text-gray-500">Booking confirmations and reminders</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={notifPrefs.emailEnabled}
onchange={async () => {
notifPrefs.emailEnabled = !notifPrefs.emailEnabled;
await saveNotifPrefs();
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">SMS</div>
<div class="text-xs text-gray-500">Text message reminders</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={notifPrefs.smsEnabled}
onchange={async () => {
notifPrefs.smsEnabled = !notifPrefs.smsEnabled;
await saveNotifPrefs();
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">Browser</div>
<div class="text-xs text-gray-500">In-browser notifications</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={notifPrefs.browserPushEnabled}
onchange={async () => {
notifPrefs.browserPushEnabled = !notifPrefs.browserPushEnabled;
await saveNotifPrefs();
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
</div>
</div>
<Separator />
{/if}
<div>
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
<p class="mb-3 text-sm text-gray-600">
View our cancellation, deposit, and no-show policies
</p>
<PolicyPopover>
{#snippet trigger()}
<Button variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/><polyline points="14 2 14 8 20 8" /><line
x1="16"
y1="13"
x2="8"
y2="13"
/><line x1="16" y1="17" x2="8" y2="17" /></svg
>
Cancellation & Deposit Policy
</Button>
{/snippet}
</PolicyPopover>
</div>
<Separator />
{#if authStore.currentUser?.role !== 'admin'}
<!-- Delete Account -->
<div>
<h3 class="mb-2 text-sm font-semibold text-red-600">Danger Zone</h3>
<p class="mb-3 text-sm text-gray-600">
Once you delete your account, there is no going back. Please be certain.
</p>
<Button onclick={() => (showDeleteAlert = true)} variant="destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
Delete Account
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
<!-- Mobile Tab Menu (Fixed at bottom) -->
<div class="mobile-tab-menu">
<div class="flex rounded-lg border bg-gray-50 p-1">
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'general'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'general')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
General
</button>
{#if authStore.currentUser?.role !== 'admin'}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'history')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
History
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin'}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'referral')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Referral
</button>
{/if}
{#if canSaveCards}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'cards'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => {
activeTab = 'cards';
savedCardsStore.fetch();
savedCardsStore.invalidate();
fetchGiftCardBalance();
}}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Cards
</button>
{/if}
<button
type="button"
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'admin'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={() => (activeTab = 'admin')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Admin
</button>
</div>
</div>
</div>
<!-- Password Change Modal -->
{#if showPasswordModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card.Root class="w-full max-w-md">
<Card.Header>
<Card.Title>Change Password</Card.Title>
<Card.Description>Enter your current and new password</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div>
<label for="current-password" class="text-sm font-medium">Current Password</label>
<Input
id="current-password"
type="password"
bind:value={passwordData.current}
placeholder="Enter current password"
class="mt-1"
/>
</div>
<div>
<label for="new-password" class="text-sm font-medium">New Password</label>
<Input
id="new-password"
type="password"
bind:value={passwordData.new}
placeholder="Enter new password"
class="mt-1"
/>
{#if passwordData.new && newPasswordStrength}
<div class="mt-2 space-y-1">
<div class="flex h-1.5 w-full overflow-hidden rounded bg-gray-200">
<div
class="transition-all duration-300"
style="width: {(newPasswordStrength.score + 1) *
20}%; background-color: {newPasswordStrength.score < 2
? '#ef4444'
: newPasswordStrength.score === 2
? '#f59e0b'
: newPasswordStrength.score === 3
? '#22c55e'
: '#15803d'}"
></div>
</div>
<p
class="text-xs {newPasswordStrength.score < 2
? 'text-red-500'
: newPasswordStrength.score === 2
? 'text-amber-500'
: 'text-green-600'}"
>
{newPasswordStrength.feedback.warning
? newPasswordStrength.feedback.warning
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][newPasswordStrength.score]}`}
</p>
{#if newPasswordStrength.feedback.suggestions.length > 0}
<p class="text-xs text-gray-500">
{newPasswordStrength.feedback.suggestions[0]}
</p>
{/if}
</div>
{/if}
</div>
<div>
<label for="confirm-password" class="text-sm font-medium">Confirm New Password</label>
<Input
id="confirm-password"
type="password"
bind:value={passwordData.confirm}
placeholder="Confirm new password"
class="mt-1"
/>
{#if !passwordsMatch}
<p class="mt-1 text-xs text-red-500">Passwords do not match</p>
{/if}
</div>
</Card.Content>
<Card.Footer class="flex justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showPasswordModal = false;
passwordData = { current: '', new: '', confirm: '' };
}}
>
Cancel
</Button>
<Button onclick={changePassword} disabled={changingPassword || !isPasswordStrongEnough}>
{changingPassword ? 'Changing...' : 'Change Password'}
</Button>
</Card.Footer>
</Card.Root>
</div>
{/if}
<!-- Delete Account Alert -->
<AlertDialog.Root bind:open={showDeleteAlert}>
<AlertDialog.Content class="z-60">
<AlertDialog.Header>
<AlertDialog.Title>Delete Account?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete your account and remove all
your data from our servers.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="px-6 py-4">
<label for="delete-confirm" class="text-sm font-medium"
>Type <strong>DELETE</strong> to confirm:</label
>
<Input
id="delete-confirm"
type="text"
bind:value={deleteConfirmText}
placeholder="DELETE"
class="mt-2"
/>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel
onclick={() => {
deleteConfirmText = '';
}}
>
Cancel
</AlertDialog.Cancel>
<Button
variant="destructive"
onclick={deleteAccount}
disabled={deletingAccount || deleteConfirmText !== 'DELETE'}
>
{deletingAccount ? 'Deleting...' : 'Delete Account'}
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Delete Saved Card Confirmation -->
<AlertDialog.Root
bind:open={showDeleteCardDialog}
onOpenChange={(open) => {
if (!open) cardToDelete = null;
}}
>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Remove saved card?</AlertDialog.Title>
<AlertDialog.Description>
{#if cardToDelete}
Remove {cardToDelete.brand} card ending in {cardToDelete.last_4}?
{/if}
You can add it again later.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (cardToDelete = null)}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() => cardToDelete && deleteCard(cardToDelete)}
class="bg-red-600 hover:bg-red-700"
>
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if}
<!-- User Booking Modal -->
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
<style>
/* Mobile Tab Menu Styles */
.mobile-tab-menu {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
display: block;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-tab-menu > div {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.desktop-tab-menu {
display: none;
margin-bottom: 1.5rem;
}
@media (min-width: 768px) {
.mobile-tab-menu {
display: none;
}
.desktop-tab-menu {
display: block;
}
}
</style>