My account
This commit is contained in:
@@ -0,0 +1,992 @@
|
||||
<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';
|
||||
|
||||
// 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';
|
||||
|
||||
// =============== 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' | 'admin'>('general');
|
||||
let menuBorderOffset = $state(0);
|
||||
let menuBorderWidth = $state(0);
|
||||
|
||||
// Tab colors for background animation
|
||||
const tabColors = {
|
||||
general: '#ff8c00',
|
||||
history: '#f54888',
|
||||
referral: '#e0b115',
|
||||
admin: '#4343f5'
|
||||
};
|
||||
|
||||
function setActiveTab(tab: 'general' | 'history' | 'referral' | 'admin', event: MouseEvent) {
|
||||
activeTab = tab;
|
||||
const button = event.currentTarget as HTMLElement;
|
||||
const menu = button.parentElement as HTMLElement;
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
menuBorderOffset = buttonRect.left - menuRect.left - (menuBorderWidth - buttonRect.width) / 2;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
// Set initial border position
|
||||
const activeButton = document.querySelector('.menu__item.active') as HTMLElement;
|
||||
if (activeButton) {
|
||||
const menu = activeButton.parentElement as HTMLElement;
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const buttonRect = activeButton.getBoundingClientRect();
|
||||
menuBorderWidth = 174.4; // 10.9em at 1.5em font-size
|
||||
menuBorderOffset =
|
||||
buttonRect.left - menuRect.left - (menuBorderWidth - buttonRect.width) / 2;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 bookings = $state<Booking[]>([]);
|
||||
let loadingUser = $state(true);
|
||||
let loadingBookings = $state(true);
|
||||
|
||||
// =============== 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;
|
||||
console.log(userData);
|
||||
} 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 ===============
|
||||
async function fetchBookings() {
|
||||
if (pageState !== 'authorized') return;
|
||||
|
||||
loadingBookings = true;
|
||||
try {
|
||||
const response = await fetch('/api/user/bookings', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
bookings = data.bookings || [];
|
||||
} else {
|
||||
// toast.error('Failed to load booking history');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching bookings:', err);
|
||||
toast.error('Network error loading bookings');
|
||||
} finally {
|
||||
loadingBookings = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (pageState === 'authorized') {
|
||||
fetchUserData();
|
||||
fetchBookings();
|
||||
}
|
||||
});
|
||||
|
||||
// =============== Password Change ===============
|
||||
let showPasswordModal = $state(false);
|
||||
let passwordData = $state({
|
||||
current: '',
|
||||
new: '',
|
||||
confirm: ''
|
||||
});
|
||||
let changingPassword = $state(false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== 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!');
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Format Date ===============
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new SvelteDate(dateString);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new SvelteDate(dateString);
|
||||
return date.toLocaleString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</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={(e) => setActiveTab('general', e)}
|
||||
>
|
||||
<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={(e) => setActiveTab('history', e)}
|
||||
>
|
||||
<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={(e) => setActiveTab('referral', e)}
|
||||
>
|
||||
<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>
|
||||
<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={(e) => setActiveTab('admin', e)}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-1 h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M12 1v6m0 6v6m5.3-17.3-4.2 4.2m-2.2 2.2-4.2 4.2m17.3 0-4.2-4.2m-2.2-2.2-4.2-4.2"
|
||||
/>
|
||||
</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 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>
|
||||
<label class="text-sm font-medium text-gray-600">First Name</label>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.firstName}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-600">Last Name</label>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.lastName}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-600">Email</label>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.email}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-600">Phone</label>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.phone || '—'}
|
||||
</div>
|
||||
</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 && userData.loyaltyStamps && userData.loyaltyStamps < 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">
|
||||
{userData.loyaltyStamps}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full text-center">
|
||||
<div class="text-sm font-medium text-emerald-800">
|
||||
Congratulations! You've earned 10% off your next appointment!
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if activeTab === 'history'}
|
||||
<!-- Booking History -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Booking History</Card.Title>
|
||||
<Card.Description>Your past and upcoming appointments</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
{#if loadingBookings}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-32 w-full" />
|
||||
{/each}
|
||||
{:else if bookings.length === 0}
|
||||
<div class="py-8 text-center text-gray-500">No bookings found</div>
|
||||
{:else}
|
||||
{#each bookings as booking (booking.id)}
|
||||
<div class="rounded-lg border p-4 hover:bg-gray-50">
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<div>
|
||||
<div class="font-semibold">{formatDateTime(booking.start_time)}</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{booking.duration_minutes} minutes
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium {booking.status ===
|
||||
'confirmed'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: booking.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: booking.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{booking.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
{#if booking.services && booking.services.length > 0}
|
||||
<div class="mb-3 space-y-2">
|
||||
<div class="text-xs font-medium text-gray-600">Services:</div>
|
||||
{#each booking.services as service (service.service_name)}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>{service.service_name}</span>
|
||||
<span class="font-medium">£{service.price.toFixed(2)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<!-- Payment Summary -->
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Total:</span>
|
||||
<span class="font-semibold">£{booking.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Paid:</span>
|
||||
<span class="font-semibold text-green-700"
|
||||
>£{booking.amount_paid.toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
{#if booking.amount_due > 0}
|
||||
<div class="flex justify-between border-t pt-1">
|
||||
<span class="font-medium text-gray-900">Due:</span>
|
||||
<span class="font-bold text-red-600">£{booking.amount_due.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
{#if booking.payments && booking.payments.length > 0}
|
||||
<div class="mt-3 space-y-1 rounded-lg bg-gray-50 p-2">
|
||||
<div class="text-xs font-medium text-gray-600">Payments:</div>
|
||||
{#each booking.payments as payment (payment.id)}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="capitalize"
|
||||
>{payment.payment_method.replace('_', ' ')} ({payment.payment_type})</span
|
||||
>
|
||||
<span class="font-medium">£{payment.amount.toFixed(2)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/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">Your Referral Code</div>
|
||||
<div class="mb-4 text-4xl font-bold tracking-wider">
|
||||
{#if userData.referralCode}
|
||||
{userData.referralCode.match(/.{1,4}/g)?.join('-')}
|
||||
{/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.referral_code_uses || 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.referral_code_uses || 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 their first</li>
|
||||
<li>• You earn 1 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 === '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 />
|
||||
|
||||
<!-- 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">
|
||||
<menu class="menu">
|
||||
<button
|
||||
class="menu__item {activeTab === 'general' ? 'active' : ''}"
|
||||
style="--bgColorItem: {tabColors.general}"
|
||||
onclick={(e) => setActiveTab('general', e)}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="menu__item {activeTab === 'history' ? 'active' : ''}"
|
||||
style="--bgColorItem: {tabColors.history}"
|
||||
onclick={(e) => setActiveTab('history', e)}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="menu__item {activeTab === 'referral' ? 'active' : ''}"
|
||||
style="--bgColorItem: {tabColors.referral}"
|
||||
onclick={(e) => setActiveTab('referral', e)}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="menu__item {activeTab === 'admin' ? 'active' : ''}"
|
||||
style="--bgColorItem: {tabColors.admin}"
|
||||
onclick={(e) => setActiveTab('admin', e)}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M12 1v6m0 6v6m5.3-17.3-4.2 4.2m-2.2 2.2-4.2 4.2m17.3 0-4.2-4.2m-2.2-2.2-4.2-4.2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="menu__border" style="transform: translate3d({menuBorderOffset}px, 0, 0)"></div>
|
||||
</menu>
|
||||
|
||||
<div class="svg-container">
|
||||
<svg viewBox="0 0 202.9 45.5">
|
||||
<clipPath
|
||||
id="menu"
|
||||
clipPathUnits="objectBoundingBox"
|
||||
transform="scale(0.0049285362247413 0.021978021978022)"
|
||||
>
|
||||
<path
|
||||
d="M6.7,45.5c5.7,0.1,14.1-0.4,23.3-4c5.7-2.3,9.9-5,18.1-10.5c10.7-7.1,11.8-9.2,20.6-14.3c5-2.9,9.2-5.2,15.2-7
|
||||
c7.1-2.1,13.3-2.3,17.6-2.1c4.2-0.2,10.5,0.1,17.6,2.1c6.1,1.8,10.2,4.1,15.2,7c8.8,5,9.9,7.1,20.6,14.3c8.3,5.5,12.4,8.2,18.1,10.5
|
||||
c9.2,3.6,17.6,4.2,23.3,4H6.7z"
|
||||
/>
|
||||
</clipPath>
|
||||
</svg>
|
||||
</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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</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}>
|
||||
{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}
|
||||
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
font-size: 1.5em;
|
||||
padding: 0 2.85em;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bgColorMenu);
|
||||
}
|
||||
|
||||
.menu__item {
|
||||
all: unset;
|
||||
flex-grow: 1;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
align-items: center;
|
||||
will-change: transform;
|
||||
justify-content: center;
|
||||
padding: 0.55em 0 0.85em;
|
||||
transition: transform var(--duration);
|
||||
}
|
||||
|
||||
.menu__item::before {
|
||||
content: '';
|
||||
z-index: -1;
|
||||
width: 4.2em;
|
||||
height: 4.2em;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
transform: scale(0);
|
||||
transition:
|
||||
background-color var(--duration),
|
||||
transform var(--duration);
|
||||
}
|
||||
|
||||
.menu__item.active {
|
||||
transform: translate3d(0, -0.8em, 0);
|
||||
}
|
||||
|
||||
.menu__item.active::before {
|
||||
transform: scale(1);
|
||||
background-color: var(--bgColorItem);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2.6em;
|
||||
height: 2.6em;
|
||||
stroke: white;
|
||||
fill: transparent;
|
||||
stroke-width: 1pt;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 400;
|
||||
}
|
||||
|
||||
@keyframes strok {
|
||||
100% {
|
||||
stroke-dashoffset: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.menu__border {
|
||||
left: 0;
|
||||
bottom: 99%;
|
||||
width: 10.9em;
|
||||
height: 2.4em;
|
||||
position: absolute;
|
||||
clip-path: url(#menu);
|
||||
will-change: transform;
|
||||
background-color: var(--bgColorMenu);
|
||||
transition: transform var(--duration);
|
||||
}
|
||||
|
||||
.svg-container {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 50em) {
|
||||
.menu {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
// tailwind.config.js (or .ts)
|
||||
import { fontFamily } from 'tailwindcss/defaultTheme';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{html,js,svelte,ts}'
|
||||
// Add other relevant paths if necessary
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', ...fontFamily.sans]
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
// Add any existing plugins required by shadcn-svelte
|
||||
],
|
||||
safelist: [
|
||||
// Safelist fuchsia colors you intend to use
|
||||
{ pattern: /bg-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ },
|
||||
{ pattern: /text-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ },
|
||||
{ pattern: /border-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ }
|
||||
// Add other fuchsia utilities if needed (e.g., ring, placeholder)
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user