Files
Crussell/frontend/src/routes/account/+page.svelte
T
2026-05-31 00:57:33 +01:00

1854 lines
55 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 UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
// zxcvbn-ts imports
import { zxcvbn, zxcvbnOptions } 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
zxcvbnOptions.setOptions({
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 { Separator } from '$lib/components/ui/separator';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
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';
goto('/login', { replaceState: true });
return;
}
pageState = 'authorized';
});
// =============== Tab State ===============
let activeTab = $state<'general' | 'history' | 'referral' | 'cards' | 'admin'>('general');
let 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);
let pendingRedemption = $state(false);
let uploadingPic = $state(false);
let notifPrefs = $state({ emailEnabled: true, smsEnabled: true, browserPushEnabled: true });
type SavedCard = {
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
is_default: boolean;
};
let savedCards = $state<SavedCard[]>([]);
let loadingCards = $state(false);
async function fetchSavedCards() {
loadingCards = true;
try {
const res = await fetch('/api/user/payment-methods', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
savedCards = await res.json();
}
} catch {
toast.error('Failed to load saved cards');
} finally {
loadingCards = false;
}
}
async function deleteCard(card: SavedCard) {
try {
const res = await fetch(`/api/user/payment-methods/${card.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
toast.success('Card removed');
savedCards = savedCards.filter((c) => c.id !== card.id);
} else {
toast.error('Failed to remove card');
}
} catch {
toast.error('Network error');
}
}
let showAddCard = $state(false);
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let addingCard = $state(false);
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;
}
async function addCard() {
const cardNum = newCardNumber.replace(/\s/g, '');
if (cardNum.length < 13 || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || newCardCVC.length < 3) {
toast.error('Please fill in all card details correctly');
return;
}
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('Card has expired');
return;
}
addingCard = true;
try {
const res = await fetch('/api/user/payment-methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
card_number: cardNum,
expiry: newCardExpiry,
cvc: newCardCVC
})
});
if (res.ok) {
toast.success('Card added');
showAddCard = false;
newCardNumber = '';
newCardExpiry = '';
newCardCVC = '';
fetchSavedCards();
} else {
const errText = await res.text();
toast.error(errText || 'Failed to add card');
}
} catch {
toast.error('Network error');
} finally {
addingCard = false;
}
}
async function fetchNotifPrefs() {
try {
const res = await fetch('/api/user/notification-preferences', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
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 fetch('/api/user/notification-preferences', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
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('');
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
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 fetch('/api/user/profile-picture', {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
},
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)
function validatePhone(phone: string): boolean {
if (!phone) {
phoneError = '';
return true;
}
const cleanPhone = phone.replace(/[\s\-()]/g, '');
// UK phone regex: +44 followed by 10-11 digits, or 0 followed by 10-11 digits
const phoneRegex = /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/;
const isValid = phoneRegex.test(cleanPhone);
phoneError = isValid ? '' : 'Invalid UK phone number';
return isValid;
}
// Format phone number as user types
function formatPhoneInput(value: string): string {
// Remove all non-digits except +
const digits = value.replace(/[^\d+]/g, '');
// Format UK numbers
if (digits.startsWith('+44')) {
return digits; // Keep +44 as is
}
if (digits.startsWith('44')) {
return '+' + digits;
}
if (digits.startsWith('0')) {
// Format 07xx xxx xxxx
if (digits.length <= 5) {
return digits;
}
if (digits.length <= 10) {
return digits.slice(0, 5) + ' ' + digits.slice(5);
}
return digits.slice(0, 5) + ' ' + digits.slice(5, 10) + ' ' + digits.slice(10, 12);
}
return digits;
}
function startEditPhone() {
phoneInput = userData?.phone || '';
phoneError = '';
editingPhone = true;
}
function cancelEditPhone() {
editingPhone = false;
phoneInput = '';
phoneError = '';
}
async function savePhone() {
const formattedPhone = phoneInput.replace(/[\s\-()]/g, '');
if (!validatePhone(formattedPhone)) {
return;
}
savingPhone = true;
const loadingToast = toast.loading('Updating phone number...');
try {
const response = await fetch('/api/user/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: userData?.firstName,
lastName: userData?.lastName,
phone: formattedPhone
})
});
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(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;
}
}
// =============== Fetch User Data ===============
async function fetchUserData() {
if (pageState !== 'authorized') return;
loadingUser = true;
try {
const response = await fetch('/api/user/profile', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
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 SvelteDate().toISOString().split('T')[0]; // YYYY-MM-DD
// Fetch more items (e.g. 10) to ensure we find upcoming ones even if the first few are past
const response = await fetch(`/api/bookings?start_date=${today}&per_page=10&page=1`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (!response.ok) {
const text = await response.text();
toast.error('Failed to load upcoming bookings: ' + 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: any) => {
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 SvelteDate().toISOString().split('T')[0];
const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (!response.ok) {
const text = await response.text();
toast.error('Failed to load past bookings: ' + text);
return;
}
const data = await response.json();
let bookings = data.bookings || [];
// 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: any) => {
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: any, b: any) => {
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();
}
});
// =============== Password Change ===============
let showPasswordModal = $state(false);
let passwordData = $state({
current: '',
new: '',
confirm: ''
});
let changingPassword = $state(false);
// Password strength using zxcvbn
let newPasswordStrength = $derived(passwordData.new ? zxcvbn(passwordData.new) : null);
let isPasswordStrongEnough = $derived(
!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2
);
let 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 fetch('/api/user/change-password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
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(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 fetch('/api/user/account', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
toast.success('Account deleted successfully', { id: loadingToast });
authStore.logout();
goto('/');
} else {
const text = await response.text();
toast.error(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>
<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 Array(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="text-3xl 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
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>
<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>
<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 canSaveCards}
<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';
fetchSavedCards();
}}
>
<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
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-blue-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-blue-200"
>
{initials}
</div>
{/if}
{:else}
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-blue-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 Array(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>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
{userData.firstName}
</div>
</div>
<div>
<span class="text-sm font-medium text-gray-600">Last Name</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
{userData.lastName}
</div>
</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">
<Input
id="phone"
type="tel"
bind:value={phoneInput}
placeholder="Enter phone number"
class="font-medium"
/>
{#if phoneError}
<p class="text-sm text-red-500">{phoneError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={savePhone} disabled={savingPhone}>
{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>
<Separator class="my-4" />
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
<div class="flex items-center justify-between">
{#if userData && stamps < 10}
<div class="text-sm font-medium text-emerald-800">
Loyalty Stamps until next reward:
</div>
<div class="text-3xl font-bold text-emerald-700">
{10 - stamps}
</div>
{:else if userData && stamps >= 10}
<div class="w-full text-center">
<div class="text-sm font-medium text-emerald-800">
Your loyalty card is full! Your next completed appointment will receive 10%
off.
</div>
</div>
{/if}
<Separator />
</div>
</div>
{/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 Array(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 Array(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}
</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">Times Used</div>
</div>
<div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold">
£{(userData.referralCodeUses || 0) * 5}
</div>
<div class="text-sm">Total Saved</div>
</div>
</div>
<Card.Root
class="mt-6 border-amber-200/60 bg-gradient-to-r from-amber-50 to-amber-100/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={(e) =>
(newCardNumber = formatCardNumber((e.target as HTMLInputElement).value))}
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={(e) =>
(newCardExpiry = formatExpiryDate(
(e.target as HTMLInputElement).value
))}
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={(e) =>
(newCardCVC = (e.target as HTMLInputElement).value
.replace(/\D/g, '')
.substring(0, 4))}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
</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}>
Add Card
</Button>
</div>
</div>
{:else if savedCards.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 savedCards 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={() => deleteCard(card)}
>
Remove
</Button>
</div>
{/each}
<Button variant="outline" class="w-full" onclick={() => (showAddCard = true)}>
+ Add a Card
</Button>
</div>
{/if}
</Card.Content>
</Card.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 />
<!-- 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 bg-gray-200 peer-checked:bg-blue-600 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 bg-gray-200 peer-checked:bg-blue-600 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 bg-gray-200 peer-checked:bg-blue-600 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}
<!-- 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 />
<!-- 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>
</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
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>
<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>
<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 canSaveCards}
<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"
>
<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
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>
{/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>