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
+165 -166
View File
@@ -5,203 +5,202 @@ import { goto } from '$app/navigation';
export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
export interface DecodedToken {
user_id: string;
role: UserRole;
exp: number;
user_id: string;
role: UserRole;
exp: number;
}
export interface User {
id: string;
email: string;
role: UserRole;
firstName: string;
lastName: string;
phone?: string;
dateOfBirth?: string;
loyaltyStamps?: number;
referralCode?: string;
profilePicUrl?: string;
id: string;
email: string;
role: UserRole;
firstName: string;
lastName: string;
phone?: string;
dateOfBirth?: string;
loyaltyStamps?: number;
referralCode?: string;
referralCodeUses?: number;
profilePicUrl?: string;
}
class AuthStore {
private token = $state<string | null>(null);
private user = $state<User | null>(null);
private loading = $state(true);
private token = $state<string | null>(null);
private user = $state<User | null>(null);
private loading = $state(true);
constructor() {
if (browser) {
this.initializeAuth();
}
}
constructor() {
if (browser) {
this.initializeAuth();
}
}
get isAuthenticated() {
return this.token !== null && this.user !== null;
}
get isAuthenticated() {
return this.token !== null && this.user !== null;
}
get currentUser() {
return this.user;
}
get currentUser() {
return this.user;
}
get currentToken() {
return this.token;
}
get currentToken() {
return this.token;
}
get isLoading() {
return this.loading;
}
get isLoading() {
return this.loading;
}
get hasLoaded() {
return !this.loading;
}
get hasLoaded() {
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() {
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 decodeToken(token: string): DecodedToken | null {
try {
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 decodeToken(token: string): DecodedToken | null {
try {
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 {
return decoded.exp * 1000 < Date.now();
}
private isTokenExpired(decoded: DecodedToken): boolean {
return decoded.exp * 1000 < Date.now();
}
// Simple setters - UI handles the API calls
setToken(token: string) {
this.token = token;
if (browser) {
localStorage.setItem('authToken', token);
}
// Simple setters - UI handles the API calls
setToken(token: string) {
this.token = token;
if (browser) {
localStorage.setItem('authToken', token);
}
// Decode to get basic info
const decoded = this.decodeToken(token);
if (decoded) {
this.user = {
id: decoded.user_id,
role: decoded.role,
email: '',
firstName: '',
lastName: ''
};
this.fetchUserProfile();
}
}
// Decode to get basic info
const decoded = this.decodeToken(token);
if (decoded) {
this.user = {
id: decoded.user_id,
role: decoded.role,
email: '',
firstName: '',
lastName: ''
};
this.fetchUserProfile();
}
}
private async fetchUserProfile() {
if (!this.token) return;
private async fetchUserProfile() {
if (!this.token) return;
try {
const response = await fetch('/api/user/profile', {
headers: {
Authorization: `Bearer ${this.token}`
}
});
try {
const response = await fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch profile');
}
if (!response.ok) {
throw new Error('Failed to fetch profile');
}
const userData = await response.json();
this.user = userData;
} catch (error) {
console.error('Failed to fetch user profile:', error);
this.clearAuth();
}
}
const userData = await response.json();
this.user = userData;
} catch (error) {
console.error('Failed to fetch user profile:', error);
this.clearAuth();
}
}
// inside AuthStore
logout = () => {
this.clearAuth();
goto('/');
};
// inside AuthStore
logout = () => {
this.clearAuth();
goto('/');
};
private clearAuth() {
this.token = null;
this.user = null;
if (browser) {
localStorage.removeItem('authToken');
}
}
hasRole(requiredRole: UserRole | UserRole[]): boolean {
if (!this.user) return false;
private clearAuth() {
this.token = null;
this.user = null;
if (browser) {
localStorage.removeItem('authToken');
}
}
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
return roles.includes(this.user.role);
}
hasRole(requiredRole: UserRole | UserRole[]): boolean {
if (!this.user) return false;
isAdmin(): boolean {
return this.hasRole('admin');
}
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
return roles.includes(this.user.role);
}
isVerified(): boolean {
return this.hasRole(['verified_email', 'admin']);
}
isAdmin(): boolean {
return this.hasRole('admin');
}
// Refresh token before it expires
async refreshTokenIfNeeded() {
if (!this.token) return;
isVerified(): boolean {
return this.hasRole(['verified_email', 'admin']);
}
const decoded = this.decodeToken(this.token);
if (!decoded) {
this.clearAuth();
return;
}
// Refresh token before it expires
async refreshTokenIfNeeded() {
if (!this.token) return;
// Refresh if token expires in less than 2 weeks
const threeDays = 2 * 7 * 24 * 60 * 60 * 1000;
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 (!decoded) {
this.clearAuth();
return;
}
if (response.ok) {
const data = await response.json();
this.setToken(data.token);
} else {
this.clearAuth();
}
} catch (error) {
console.error('Token refresh failed:', error);
}
}
}
// Refresh if token expires in less than 2 weeks
const threeDays = 2 * 7 * 24 * 60 * 60 * 1000;
if (decoded.exp * 1000 - Date.now() < threeDays) {
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();
}
// 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';
</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">
{#if authStore.isLoading}
<!-- Skeleton loading state -->
@@ -16,7 +25,7 @@
</div>
<Skeleton class="mx-auto h-12 w-48" />
{: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}
Welcome to Crussell Nails
{:else if authStore.currentUser?.role === 'unverified_email'}
+103 -101
View File
@@ -35,40 +35,6 @@
// =============== 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;
@@ -99,6 +65,7 @@
let bookings = $state<Booking[]>([]);
let loadingUser = $state(true);
let loadingBookings = $state(true);
let stamps = $state(0);
// =============== Fetch User Data ===============
async function fetchUserData() {
@@ -117,7 +84,7 @@
if (response.ok) {
const data = await response.json();
userData = data;
console.log(userData);
stamps = userData?.loyaltyStamps ?? 0;
} else {
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 {
const date = new SvelteDate(dateString);
return date.toLocaleString('en-GB', {
@@ -323,7 +280,7 @@
'general'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(e) => setActiveTab('general', e)}
onclick={(_) => (activeTab = 'general')}
>
<svg
class="mx-auto mb-1 h-5 w-5"
@@ -342,7 +299,7 @@
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(e) => setActiveTab('history', e)}
onclick={(_) => (activeTab = 'history')}
>
<svg
class="mx-auto mb-1 h-5 w-5"
@@ -363,7 +320,7 @@
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(e) => setActiveTab('referral', e)}
onclick={(_) => (activeTab = 'referral')}
>
<svg
class="mx-auto mb-1 h-5 w-5"
@@ -384,7 +341,7 @@
'admin'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(e) => setActiveTab('admin', e)}
onclick={(_) => (activeTab = 'admin')}
>
<svg
class="mx-auto mb-1 h-5 w-5"
@@ -393,10 +350,8 @@
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"
/>
<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>
@@ -449,12 +404,12 @@
<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}
{#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">
{userData.loyaltyStamps}
{10 - stamps}
</div>
{:else}
<div class="w-full text-center">
@@ -572,12 +527,23 @@
<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">
<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}
{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}
</div>
<Button onclick={copyReferralCode} variant="outline" class="w-full">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -597,13 +563,13 @@
<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}
{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.referral_code_uses || 0) * 5}
£{(userData.referralCodeUses || 0) * 5}
</div>
<div class="text-sm">Total Saved</div>
</div>
@@ -618,7 +584,7 @@
<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 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>
</ul>
</div>
@@ -659,6 +625,26 @@
</Button>
</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 />
<!-- Delete Account -->
@@ -691,74 +677,90 @@
<!-- Mobile Tab Menu (Fixed at bottom) -->
<div class="mobile-tab-menu">
<menu class="menu">
<div class="flex rounded-lg border bg-gray-50 p-1">
<button
class="menu__item {activeTab === 'general' ? 'active' : ''}"
style="--bgColorItem: {tabColors.general}"
onclick={(e) => setActiveTab('general', e)}
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="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" />
<circle cx="12" cy="7" r="4" />
</svg>
General
</button>
<button
class="menu__item {activeTab === 'history' ? 'active' : ''}"
style="--bgColorItem: {tabColors.history}"
onclick={(e) => setActiveTab('history', e)}
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="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" />
<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="menu__item {activeTab === 'referral' ? 'active' : ''}"
style="--bgColorItem: {tabColors.referral}"
onclick={(e) => setActiveTab('referral', e)}
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="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" />
<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="menu__item {activeTab === 'admin' ? 'active' : ''}"
style="--bgColorItem: {tabColors.admin}"
onclick={(e) => setActiveTab('admin', e)}
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="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)"
<svg
class="mx-auto mb-1 h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<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>
<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>
+146 -10
View File
@@ -54,28 +54,168 @@
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() {
if (!uploadFiles.length) return;
uploading = true;
uploadResults = [];
uploadProgress = 0;
const startTs = Date.now(); // timestamp of button click
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
const fd = new FormData();
fd.append('file', file);
try {
// Note: API call is mocked here, replace with your actual endpoint
// Mock success/fail
/* -------- 1. Turn whatever the user gave us into JPEG ------- */
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
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 {
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) {
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);
@@ -583,7 +723,6 @@
});
if (response.ok) {
const data = await response.json();
console.log('Bookings API response:', data); // Debug log
if (data.bookings && data.bookings.length === 0) {
bookings = [];
@@ -615,7 +754,6 @@
amount_due: b.amount_due || 0,
duration_minutes: b.duration_minutes || 0
}));
console.log('Mapped bookings:', bookings); // Debug log
} else {
const text = await response.text();
toast.error('Failed to load bookings: ' + text);
@@ -653,7 +791,6 @@
);
if (response.ok) {
const data = await response.json();
console.log('Search API response:', data); // Debug log
// Map the search response correctly (same structure as fetchBookings)
bookings = data.bookings.map((b) => ({
@@ -701,7 +838,6 @@
});
if (response.ok) {
const data = await response.json();
console.log('Booking details API response:', data); // Debug log
selectedBooking = {
id: data.id,
+55 -39
View File
@@ -627,7 +627,14 @@
const canProceedStep1 = $derived(selectedServices.length > 0);
const canProceedStep2 = $derived(selectedDate && selectedTime);
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>
@@ -877,7 +884,7 @@
<Card.Root>
<Card.Header>
<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.Content class="space-y-6">
<!-- Booking Summary -->
@@ -922,42 +929,49 @@
</div>
<!-- Contact Form -->
<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"
/>
{#if !authStore.isAuthenticated}
<p class="mb-4 text-center text-sm text-yellow-600">
You are checking out as a guest, so you will miss out on a loyalty stamp. Please login
for full membership benefits.
</p>
<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 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>
{/if}
<div class="space-y-2">
<Label for="requests">Special Requests (Optional)</Label>
@@ -970,10 +984,12 @@
</div>
<div class="text-sm text-gray-600">
<p>* Required fields</p>
{#if !authStore.isAuthenticated}
<p>* Required fields</p>
{/if}
<p class="mt-2">
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>
</div>
</Card.Content>