Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1835 lines
54 KiB
Svelte
1835 lines
54 KiB
Svelte
<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 Date().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 Date();
|
|
|
|
// Filter: Calculate end time (Start + Duration) and check if it's in the future
|
|
const activeOrFutureBookings = (data.bookings || []).filter((b: any) => {
|
|
const startTime = new Date(b.start_time);
|
|
// Add duration (in ms)
|
|
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
|
return endTime > now;
|
|
});
|
|
|
|
// Take only the top 3
|
|
upcomingBookings = activeOrFutureBookings.slice(0, 3);
|
|
} catch (err) {
|
|
console.error('Error fetching upcoming bookings:', err);
|
|
toast.error('Network error loading upcoming bookings');
|
|
} finally {
|
|
loadingUpcoming = false;
|
|
}
|
|
}
|
|
|
|
// =============== Fetch Past Bookings (paginated 10 per page) ===============
|
|
async function fetchPastBookings(page = 1) {
|
|
if (pageState !== 'authorized') return;
|
|
|
|
loadingPast = true;
|
|
try {
|
|
const today = new Date().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 Date(b.start_time).getTime() - new Date(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);
|
|
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-4 py-2 text-sm 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-5 w-5"
|
|
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-4 py-2 text-sm 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-5 w-5"
|
|
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-4 py-2 text-sm 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-5 w-5"
|
|
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-4 py-2 text-sm 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-5 w-5"
|
|
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-4 py-2 text-sm 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-5 w-5"
|
|
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-2 text-sm font-medium text-slate-700">Your Referral Code</div>
|
|
|
|
<div
|
|
class="mb-4 flex items-baseline justify-center space-x-2
|
|
text-4xl font-bold tracking-wider text-slate-900"
|
|
>
|
|
{#if userData.referralCode}
|
|
{#each userData.referralCode.match(/.{1,4}/g) as part (part)}
|
|
<span
|
|
class="rounded-sm border-b-1 border-slate-300 px-1 py-0.5 text-slate-900"
|
|
>
|
|
{part}
|
|
</span>
|
|
{/each}
|
|
{/if}
|
|
</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-4 py-2 text-sm 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-5 w-5"
|
|
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-4 py-2 text-sm 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-5 w-5"
|
|
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-4 py-2 text-sm 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-5 w-5"
|
|
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-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
|
'cards'
|
|
? 'bg-white text-gray-900 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'}"
|
|
onclick={(_) => (activeTab = 'cards')}
|
|
>
|
|
<svg
|
|
class="mx-auto mb-1 h-5 w-5"
|
|
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-4 py-2 text-sm 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-5 w-5"
|
|
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 -->
|
|
{#if showBookingModal && selectedBookingId}
|
|
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
|
|
{/if}
|
|
|
|
<style>
|
|
/* Mobile Tab Menu Styles */
|
|
.mobile-tab-menu {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 50;
|
|
display: block;
|
|
}
|
|
|
|
.desktop-tab-menu {
|
|
display: none;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.mobile-tab-menu {
|
|
display: none;
|
|
}
|
|
|
|
.desktop-tab-menu {
|
|
display: block;
|
|
}
|
|
}
|
|
</style>
|