Portfolio frontend, various fixes

This commit is contained in:
2025-11-28 18:03:06 +00:00
parent c1548c503f
commit 7bc2129422
7 changed files with 501 additions and 336 deletions
+1
View File
@@ -51,6 +51,7 @@ frontend/.vite
frontend/.svelte2tsx-language-server-files frontend/.svelte2tsx-language-server-files
frontend/build/ frontend/build/
frontend/dist/ frontend/dist/
frontend/static/portfolio/*
# ------------------------------------ # ------------------------------------
# 4. PHP/SabreDAV # 4. PHP/SabreDAV
+21 -19
View File
@@ -20,16 +20,17 @@ import (
var titleCaser = cases.Title(language.English) var titleCaser = cases.Title(language.English)
type UserProfile struct { type UserProfile struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName string `json:"lastName"`
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
DateOfBirth *string `json:"dateOfBirth,omitempty"` DateOfBirth *string `json:"dateOfBirth,omitempty"`
Role string `json:"role"` Role string `json:"role"`
LoyaltyStamps int `json:"loyaltyStamps"` LoyaltyStamps int `json:"loyaltyStamps"`
ReferralCode string `json:"referralCode"` ReferralCode string `json:"referralCode"`
ProfilePicURL *string `json:"profilePicUrl,omitempty"` ReferralCodeUses int `json:"referralCodeUses"`
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
} }
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
@@ -48,16 +49,17 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
var user UserProfile var user UserProfile
err := db.DB.QueryRow(r.Context(), ` err := db.DB.QueryRow(r.Context(), `
SELECT SELECT
id, email, n_first_name, n_last_name, phone, id, email, n_first_name, n_last_name, phone,
date_of_birth::text, account_role, loyalty_stamps, date_of_birth::text, account_role, loyalty_stamps,
referral_code, profile_pic_url referral_code, profile_pic_url,
FROM users (SELECT COUNT(*) FROM user_referrals WHERE referrer_id = users.id AND claimed_booking_id IS NOT NULL) AS referral_code_uses
WHERE id = $1 FROM users
`, userID).Scan( WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &user.FirstName, &user.LastName, &user.ID, &user.Email, &user.FirstName, &user.LastName,
&user.Phone, &user.DateOfBirth, &user.Role, &user.Phone, &user.DateOfBirth, &user.Role,
&user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, &user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, &user.ReferralCodeUses,
) )
if err != nil { if err != nil {
@@ -187,7 +189,7 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
// Update DB // Update DB
_, err = db.DB.Exec(r.Context(), ` _, err = db.DB.Exec(r.Context(), `
UPDATE users UPDATE users
SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW() SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW()
WHERE id = $4 WHERE id = $4
`, req.FirstName, req.LastName, req.Phone, userID) `, req.FirstName, req.LastName, req.Phone, userID)
+165 -166
View File
@@ -5,203 +5,202 @@ import { goto } from '$app/navigation';
export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate'; export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
export interface DecodedToken { export interface DecodedToken {
user_id: string; user_id: string;
role: UserRole; role: UserRole;
exp: number; exp: number;
} }
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
role: UserRole; role: UserRole;
firstName: string; firstName: string;
lastName: string; lastName: string;
phone?: string; phone?: string;
dateOfBirth?: string; dateOfBirth?: string;
loyaltyStamps?: number; loyaltyStamps?: number;
referralCode?: string; referralCode?: string;
profilePicUrl?: string; referralCodeUses?: number;
profilePicUrl?: string;
} }
class AuthStore { class AuthStore {
private token = $state<string | null>(null); private token = $state<string | null>(null);
private user = $state<User | null>(null); private user = $state<User | null>(null);
private loading = $state(true); private loading = $state(true);
constructor() { constructor() {
if (browser) { if (browser) {
this.initializeAuth(); this.initializeAuth();
} }
} }
get isAuthenticated() { get isAuthenticated() {
return this.token !== null && this.user !== null; return this.token !== null && this.user !== null;
} }
get currentUser() { get currentUser() {
return this.user; return this.user;
} }
get currentToken() { get currentToken() {
return this.token; return this.token;
} }
get isLoading() { get isLoading() {
return this.loading; return this.loading;
} }
get hasLoaded() { get hasLoaded() {
return !this.loading; return !this.loading;
} }
private initializeAuth() {
const storedToken = localStorage.getItem('authToken');
if (storedToken) {
const decoded = this.decodeToken(storedToken);
if (decoded && !this.isTokenExpired(decoded)) {
this.token = storedToken;
// Set basic user info from token
this.user = {
id: decoded.user_id,
role: decoded.role,
email: '',
firstName: '',
lastName: ''
};
this.fetchUserProfile();
} else {
this.clearAuth();
}
}
this.loading = false;
}
private initializeAuth() { private decodeToken(token: string): DecodedToken | null {
const storedToken = localStorage.getItem('authToken'); try {
if (storedToken) { const payload = token.split('.')[1];
const decoded = this.decodeToken(storedToken); const decoded = JSON.parse(atob(payload));
if (decoded && !this.isTokenExpired(decoded)) { return decoded;
this.token = storedToken; } catch (e) {
// Set basic user info from token console.error('Failed to decode token:', e);
this.user = { return null;
id: decoded.user_id, }
role: decoded.role, }
email: '',
firstName: '',
lastName: ''
};
this.fetchUserProfile();
} else {
this.clearAuth();
}
}
this.loading = false;
}
private decodeToken(token: string): DecodedToken | null { private isTokenExpired(decoded: DecodedToken): boolean {
try { return decoded.exp * 1000 < Date.now();
const payload = token.split('.')[1]; }
const decoded = JSON.parse(atob(payload));
return decoded;
} catch (e) {
console.error('Failed to decode token:', e);
return null;
}
}
private isTokenExpired(decoded: DecodedToken): boolean { // Simple setters - UI handles the API calls
return decoded.exp * 1000 < Date.now(); setToken(token: string) {
} this.token = token;
if (browser) {
localStorage.setItem('authToken', token);
}
// Simple setters - UI handles the API calls // Decode to get basic info
setToken(token: string) { const decoded = this.decodeToken(token);
this.token = token; if (decoded) {
if (browser) { this.user = {
localStorage.setItem('authToken', token); id: decoded.user_id,
} role: decoded.role,
email: '',
firstName: '',
lastName: ''
};
this.fetchUserProfile();
}
}
// Decode to get basic info private async fetchUserProfile() {
const decoded = this.decodeToken(token); if (!this.token) return;
if (decoded) {
this.user = {
id: decoded.user_id,
role: decoded.role,
email: '',
firstName: '',
lastName: ''
};
this.fetchUserProfile();
}
}
private async fetchUserProfile() { try {
if (!this.token) return; const response = await fetch('/api/user/profile', {
headers: {
Authorization: `Bearer ${this.token}`
}
});
try { if (!response.ok) {
const response = await fetch('/api/user/profile', { throw new Error('Failed to fetch profile');
headers: { }
'Authorization': `Bearer ${this.token}`
}
});
if (!response.ok) { const userData = await response.json();
throw new Error('Failed to fetch profile'); this.user = userData;
} } catch (error) {
console.error('Failed to fetch user profile:', error);
this.clearAuth();
}
}
const userData = await response.json(); // inside AuthStore
this.user = userData; logout = () => {
} catch (error) { this.clearAuth();
console.error('Failed to fetch user profile:', error); goto('/');
this.clearAuth(); };
}
}
// inside AuthStore private clearAuth() {
logout = () => { this.token = null;
this.clearAuth(); this.user = null;
goto('/'); if (browser) {
}; localStorage.removeItem('authToken');
}
}
hasRole(requiredRole: UserRole | UserRole[]): boolean {
if (!this.user) return false;
private clearAuth() { const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
this.token = null; return roles.includes(this.user.role);
this.user = null; }
if (browser) {
localStorage.removeItem('authToken');
}
}
hasRole(requiredRole: UserRole | UserRole[]): boolean { isAdmin(): boolean {
if (!this.user) return false; return this.hasRole('admin');
}
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole]; isVerified(): boolean {
return roles.includes(this.user.role); return this.hasRole(['verified_email', 'admin']);
} }
isAdmin(): boolean { // Refresh token before it expires
return this.hasRole('admin'); async refreshTokenIfNeeded() {
} if (!this.token) return;
isVerified(): boolean { const decoded = this.decodeToken(this.token);
return this.hasRole(['verified_email', 'admin']); if (!decoded) {
} this.clearAuth();
return;
}
// Refresh token before it expires // Refresh if token expires in less than 2 weeks
async refreshTokenIfNeeded() { const threeDays = 2 * 7 * 24 * 60 * 60 * 1000;
if (!this.token) return; if (decoded.exp * 1000 - Date.now() < threeDays) {
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`
}
});
const decoded = this.decodeToken(this.token); if (response.ok) {
if (!decoded) { const data = await response.json();
this.clearAuth(); this.setToken(data.token);
return; } else {
} this.clearAuth();
}
} catch (error) {
console.error('Token refresh failed:', error);
}
}
}
// Refresh if token expires in less than 2 weeks // Manual refresh method
const threeDays = 2 * 7 * 24 * 60 * 60 * 1000; async refreshProfile() {
if (decoded.exp * 1000 - Date.now() < threeDays) { await this.fetchUserProfile();
try { }
const response = await fetch('/api/refresh-token', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (response.ok) {
const data = await response.json();
this.setToken(data.token);
} else {
this.clearAuth();
}
} catch (error) {
console.error('Token refresh failed:', error);
}
}
}
// Manual refresh method
async refreshProfile() {
await this.fetchUserProfile();
}
} }
export const authStore = new AuthStore(); export const authStore = new AuthStore();
+10 -1
View File
@@ -5,6 +5,15 @@
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
</script> </script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap"
rel="stylesheet"
/>
</svelte:head>
<section class="py-20 text-center"> <section class="py-20 text-center">
{#if authStore.isLoading} {#if authStore.isLoading}
<!-- Skeleton loading state --> <!-- Skeleton loading state -->
@@ -16,7 +25,7 @@
</div> </div>
<Skeleton class="mx-auto h-12 w-48" /> <Skeleton class="mx-auto h-12 w-48" />
{:else} {:else}
<h1 class="m-2 mb-4 text-4xl font-bold"> <h1 class="m-2 mb-4 font-['Playfair_Display'] text-4xl font-bold">
{#if !authStore.isAuthenticated} {#if !authStore.isAuthenticated}
Welcome to Crussell Nails Welcome to Crussell Nails
{:else if authStore.currentUser?.role === 'unverified_email'} {:else if authStore.currentUser?.role === 'unverified_email'}
+103 -101
View File
@@ -35,40 +35,6 @@
// =============== Tab State =============== // =============== Tab State ===============
let activeTab = $state<'general' | 'history' | 'referral' | 'admin'>('general'); 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 = { type Booking = {
id: string; id: string;
@@ -99,6 +65,7 @@
let bookings = $state<Booking[]>([]); let bookings = $state<Booking[]>([]);
let loadingUser = $state(true); let loadingUser = $state(true);
let loadingBookings = $state(true); let loadingBookings = $state(true);
let stamps = $state(0);
// =============== Fetch User Data =============== // =============== Fetch User Data ===============
async function fetchUserData() { async function fetchUserData() {
@@ -117,7 +84,7 @@
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
userData = data; userData = data;
console.log(userData); stamps = userData?.loyaltyStamps ?? 0;
} else { } else {
toast.error('Failed to load profile data'); toast.error('Failed to load profile data');
} }
@@ -262,16 +229,6 @@
} }
} }
// =============== 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 { function formatDateTime(dateString: string): string {
const date = new SvelteDate(dateString); const date = new SvelteDate(dateString);
return date.toLocaleString('en-GB', { return date.toLocaleString('en-GB', {
@@ -323,7 +280,7 @@
'general' 'general'
? 'bg-white text-gray-900 shadow-sm' ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}" : 'text-gray-600 hover:text-gray-900'}"
onclick={(e) => setActiveTab('general', e)} onclick={(_) => (activeTab = 'general')}
> >
<svg <svg
class="mx-auto mb-1 h-5 w-5" class="mx-auto mb-1 h-5 w-5"
@@ -342,7 +299,7 @@
'history' 'history'
? 'bg-white text-gray-900 shadow-sm' ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}" : 'text-gray-600 hover:text-gray-900'}"
onclick={(e) => setActiveTab('history', e)} onclick={(_) => (activeTab = 'history')}
> >
<svg <svg
class="mx-auto mb-1 h-5 w-5" class="mx-auto mb-1 h-5 w-5"
@@ -363,7 +320,7 @@
'referral' 'referral'
? 'bg-white text-gray-900 shadow-sm' ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}" : 'text-gray-600 hover:text-gray-900'}"
onclick={(e) => setActiveTab('referral', e)} onclick={(_) => (activeTab = 'referral')}
> >
<svg <svg
class="mx-auto mb-1 h-5 w-5" class="mx-auto mb-1 h-5 w-5"
@@ -384,7 +341,7 @@
'admin' 'admin'
? 'bg-white text-gray-900 shadow-sm' ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}" : 'text-gray-600 hover:text-gray-900'}"
onclick={(e) => setActiveTab('admin', e)} onclick={(_) => (activeTab = 'admin')}
> >
<svg <svg
class="mx-auto mb-1 h-5 w-5" class="mx-auto mb-1 h-5 w-5"
@@ -393,10 +350,8 @@
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
> >
<circle cx="12" cy="12" r="3" /> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path <path d="M7 11V7a5 5 0 0 1 10 0v4" />
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> </svg>
Admin Admin
</button> </button>
@@ -449,12 +404,12 @@
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4"> <div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
{#if userData && userData.loyaltyStamps && userData.loyaltyStamps < 10} {#if userData && stamps < 10}
<div class="text-sm font-medium text-emerald-800"> <div class="text-sm font-medium text-emerald-800">
Loyalty Stamps until next reward: Loyalty Stamps until next reward:
</div> </div>
<div class="text-3xl font-bold text-emerald-700"> <div class="text-3xl font-bold text-emerald-700">
{userData.loyaltyStamps} {10 - stamps}
</div> </div>
{:else} {:else}
<div class="w-full text-center"> <div class="w-full text-center">
@@ -572,12 +527,23 @@
<Skeleton class="h-32 w-full" /> <Skeleton class="h-32 w-full" />
{:else if userData?.referralCode} {:else if userData?.referralCode}
<div class="rounded-lg border p-6 text-center"> <div class="rounded-lg border p-6 text-center">
<div class="mb-2 text-sm font-medium">Your Referral Code</div> <div class="mb-2 text-sm font-medium text-slate-700">Your Referral Code</div>
<div class="mb-4 text-4xl font-bold tracking-wider">
<div
class="mb-4 flex items-baseline justify-center space-x-2
text-4xl font-bold tracking-wider text-slate-900"
>
{#if userData.referralCode} {#if userData.referralCode}
{userData.referralCode.match(/.{1,4}/g)?.join('-')} {#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} {/if}
</div> </div>
<Button onclick={copyReferralCode} variant="outline" class="w-full"> <Button onclick={copyReferralCode} variant="outline" class="w-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -597,13 +563,13 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="rounded-lg border p-4 text-center"> <div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold"> <div class="text-3xl font-bold">
{userData.referral_code_uses || 0} {userData.referralCodeUses || 0}
</div> </div>
<div class="text-sm">Times Used</div> <div class="text-sm">Times Used</div>
</div> </div>
<div class="rounded-lg border p-4 text-center"> <div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold"> <div class="text-3xl font-bold">
£{(userData.referral_code_uses || 0) * 5} £{(userData.referralCodeUses || 0) * 5}
</div> </div>
<div class="text-sm">Total Saved</div> <div class="text-sm">Total Saved</div>
</div> </div>
@@ -618,7 +584,7 @@
<ul class="space-y-1 pl-4"> <ul class="space-y-1 pl-4">
<li>• Share your referral code with friends</li> <li>• Share your referral code with friends</li>
<li>• They get 10% off their first booking</li> <li>• They get 10% off their first booking</li>
<li>• You get 10% off your next booking after their first</li> <li>• You get 10% off your next booking after they claim</li>
<li>• You earn 1 loyalty stamp for each use to keep the savings going</li> <li>• You earn 1 loyalty stamp for each use to keep the savings going</li>
</ul> </ul>
</div> </div>
@@ -659,6 +625,26 @@
</Button> </Button>
</div> </div>
<!-- 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 /> <Separator />
<!-- Delete Account --> <!-- Delete Account -->
@@ -691,74 +677,90 @@
<!-- Mobile Tab Menu (Fixed at bottom) --> <!-- Mobile Tab Menu (Fixed at bottom) -->
<div class="mobile-tab-menu"> <div class="mobile-tab-menu">
<menu class="menu"> <div class="flex rounded-lg border bg-gray-50 p-1">
<button <button
class="menu__item {activeTab === 'general' ? 'active' : ''}" class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
style="--bgColorItem: {tabColors.general}" 'general'
onclick={(e) => setActiveTab('general', e)} ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'general')}
> >
<svg class="icon" viewBox="0 0 24 24"> <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" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" /> <circle cx="12" cy="7" r="4" />
</svg> </svg>
General
</button> </button>
<button <button
class="menu__item {activeTab === 'history' ? 'active' : ''}" class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
style="--bgColorItem: {tabColors.history}" 'history'
onclick={(e) => setActiveTab('history', e)} ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'history')}
> >
<svg class="icon" viewBox="0 0 24 24"> <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" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" /> <line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" /> <line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" /> <line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
History
</button> </button>
<button <button
class="menu__item {activeTab === 'referral' ? 'active' : ''}" class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
style="--bgColorItem: {tabColors.referral}" 'referral'
onclick={(e) => setActiveTab('referral', e)} ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'referral')}
> >
<svg class="icon" viewBox="0 0 24 24"> <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" /> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" /> <circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /> <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> <path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg> </svg>
Referral
</button> </button>
<button <button
class="menu__item {activeTab === 'admin' ? 'active' : ''}" class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
style="--bgColorItem: {tabColors.admin}" 'admin'
onclick={(e) => setActiveTab('admin', e)} ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'admin')}
> >
<svg class="icon" viewBox="0 0 24 24"> <svg
<circle cx="12" cy="12" r="3" /> class="mx-auto mb-1 h-5 w-5"
<path viewBox="0 0 24 24"
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" fill="none"
/> stroke="currentColor"
</svg> stroke-width="2"
</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 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
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 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
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 </svg>
c9.2,3.6,17.6,4.2,23.3,4H6.7z" Admin
/> </button>
</clipPath>
</svg>
</div> </div>
</div> </div>
</div> </div>
+146 -10
View File
@@ -54,28 +54,168 @@
uploadFiles = files; uploadFiles = files;
} }
/** Helper: turn any File into a JPEGencoded Blob. */
function toJpegBlob(file: File): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(file);
});
}
/** Resize to max 1500px on the *short* side, only scale down, never up. */
function resizeShortSide(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
const maxShortSide = 1500;
// Only resize if image is larger than target
const shortSide = Math.min(width, height);
if (shortSide > maxShortSide) {
if (width < height) {
const scale = maxShortSide / width;
width = maxShortSide;
height = Math.round(height * scale);
} else {
const scale = maxShortSide / height;
height = maxShortSide;
width = Math.round(width * scale);
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
});
}
/** Create a 250×250 thumbnail (square, centercropped). */
function createThumbnail(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const thumbSize = 250;
const { width, height } = img;
// Scale up *or* down so that the image covers 250×250
const scale = Math.max(thumbSize / width, thumbSize / height);
const scaledW = Math.round(width * scale);
const scaledH = Math.round(height * scale);
const canvas = document.createElement('canvas');
canvas.width = thumbSize;
canvas.height = thumbSize;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
// Draw the scaled image, then crop the center 250×250
ctx.drawImage(
img,
(scaledW - thumbSize) / -2, // offset to center
(scaledH - thumbSize) / -2,
scaledW,
scaledH,
0,
0,
thumbSize,
thumbSize
);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
});
}
/** Core upload function now processes the images before sending. */
async function uploadOneOrMany() { async function uploadOneOrMany() {
if (!uploadFiles.length) return; if (!uploadFiles.length) return;
uploading = true; uploading = true;
uploadResults = []; uploadResults = [];
uploadProgress = 0; uploadProgress = 0;
const startTs = Date.now(); // timestamp of button click
for (let i = 0; i < uploadFiles.length; i++) { for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i]; const file = uploadFiles[i];
const fd = new FormData(); const fd = new FormData();
fd.append('file', file);
try { try {
// Note: API call is mocked here, replace with your actual endpoint /* -------- 1. Turn whatever the user gave us into JPEG ------- */
// Mock success/fail const jpegBlob = await toJpegBlob(file);
/* -------- 2. Create the two processed versions ------------- */
const resizedBlob = await resizeShortSide(jpegBlob);
const thumbBlob = await createThumbnail(jpegBlob);
/* -------- 3. Generate filenames -------------------------------- */
const ts = startTs - i; // 1ms decrement per file
const baseName = `${ts}.jpg`;
const thumbName = `${ts}_thumb.jpg`;
/* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName); // this will be the "original"
fd.append('file', thumbBlob, thumbName); // the thumbnail
/* -------- 5. Mock the API call --------------------------------- */
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
if (file.name.toLowerCase().includes('fail')) { if (file.name.toLowerCase().includes('fail')) {
uploadResults.push({ name: file.name, error: 'Mocked API error' }); uploadResults.push({
name: file.name,
error: 'Mocked API error'
});
} else { } else {
uploadResults.push({ name: file.name, url: `/images/${file.name}` }); // In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
uploadResults.push({
name: file.name,
url: `/images/${baseName}` // pretend this is the returned URL
});
} }
} catch (err: unknown) { } catch (err: unknown) {
uploadResults.push({ name: file.name, error: err?.message || 'Network error' }); uploadResults.push({
name: file.name,
error: err instanceof Error ? err.message : 'Unknown error'
});
} }
uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100); uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100);
@@ -583,7 +723,6 @@
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
console.log('Bookings API response:', data); // Debug log
if (data.bookings && data.bookings.length === 0) { if (data.bookings && data.bookings.length === 0) {
bookings = []; bookings = [];
@@ -615,7 +754,6 @@
amount_due: b.amount_due || 0, amount_due: b.amount_due || 0,
duration_minutes: b.duration_minutes || 0 duration_minutes: b.duration_minutes || 0
})); }));
console.log('Mapped bookings:', bookings); // Debug log
} else { } else {
const text = await response.text(); const text = await response.text();
toast.error('Failed to load bookings: ' + text); toast.error('Failed to load bookings: ' + text);
@@ -653,7 +791,6 @@
); );
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
console.log('Search API response:', data); // Debug log
// Map the search response correctly (same structure as fetchBookings) // Map the search response correctly (same structure as fetchBookings)
bookings = data.bookings.map((b) => ({ bookings = data.bookings.map((b) => ({
@@ -701,7 +838,6 @@
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
console.log('Booking details API response:', data); // Debug log
selectedBooking = { selectedBooking = {
id: data.id, id: data.id,
+55 -39
View File
@@ -627,7 +627,14 @@
const canProceedStep1 = $derived(selectedServices.length > 0); const canProceedStep1 = $derived(selectedServices.length > 0);
const canProceedStep2 = $derived(selectedDate && selectedTime); const canProceedStep2 = $derived(selectedDate && selectedTime);
const canProceedStep3 = $derived( const canProceedStep3 = $derived(
customerInfo.firstName && customerInfo.lastName && customerInfo.email && customerInfo.phone authStore.isAuthenticated
? !!(
authStore.currentUser?.firstName &&
authStore.currentUser?.lastName &&
authStore.currentUser?.email &&
authStore.currentUser?.phone
)
: customerInfo.firstName && customerInfo.lastName && customerInfo.email && customerInfo.phone
); );
</script> </script>
@@ -877,7 +884,7 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Your Details</Card.Title> <Card.Title>Your Details</Card.Title>
<Card.Description>Please provide your contact information</Card.Description> <Card.Description>Please confirm your contact information</Card.Description>
</Card.Header> </Card.Header>
<Card.Content class="space-y-6"> <Card.Content class="space-y-6">
<!-- Booking Summary --> <!-- Booking Summary -->
@@ -922,42 +929,49 @@
</div> </div>
<!-- Contact Form --> <!-- Contact Form -->
<div class="grid gap-4 md:grid-cols-2"> {#if !authStore.isAuthenticated}
<div class="space-y-2"> <p class="mb-4 text-center text-sm text-yellow-600">
<Label for="firstName">First Name *</Label> You are checking out as a guest, so you will miss out on a loyalty stamp. Please login
<Input for full membership benefits.
id="firstName" </p>
bind:value={customerInfo.firstName}
placeholder="Enter your first name" <div class="grid gap-4 md:grid-cols-2">
/> <div class="space-y-2">
<Label for="firstName">First Name *</Label>
<Input
id="firstName"
bind:value={customerInfo.firstName}
placeholder="Enter your first name"
/>
</div>
<div class="space-y-2">
<Label for="lastName">Last Name *</Label>
<Input
id="lastName"
bind:value={customerInfo.lastName}
placeholder="Enter your last name"
/>
</div>
<div class="space-y-2">
<Label for="email">Email *</Label>
<Input
id="email"
type="email"
bind:value={customerInfo.email}
placeholder="Enter your email"
/>
</div>
<div class="space-y-2">
<Label for="phone">Phone Number *</Label>
<Input
id="phone"
type="tel"
bind:value={customerInfo.phone}
placeholder="Enter your phone number"
/>
</div>
</div> </div>
<div class="space-y-2"> {/if}
<Label for="lastName">Last Name *</Label>
<Input
id="lastName"
bind:value={customerInfo.lastName}
placeholder="Enter your last name"
/>
</div>
<div class="space-y-2">
<Label for="email">Email *</Label>
<Input
id="email"
type="email"
bind:value={customerInfo.email}
placeholder="Enter your email"
/>
</div>
<div class="space-y-2">
<Label for="phone">Phone Number *</Label>
<Input
id="phone"
type="tel"
bind:value={customerInfo.phone}
placeholder="Enter your phone number"
/>
</div>
</div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="requests">Special Requests (Optional)</Label> <Label for="requests">Special Requests (Optional)</Label>
@@ -970,10 +984,12 @@
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
<p>* Required fields</p> {#if !authStore.isAuthenticated}
<p>* Required fields</p>
{/if}
<p class="mt-2"> <p class="mt-2">
By booking, you agree to our Terms & Conditions and Privacy Policy. We'll send you By booking, you agree to our Terms & Conditions and Privacy Policy. We'll send you
appointment reminders via email and SMS. appointment reminders via email and/or SMS.
</p> </p>
</div> </div>
</Card.Content> </Card.Content>