From 7bc212942283d417fce01c171ac6898e30c94139 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 28 Nov 2025 18:03:06 +0000 Subject: [PATCH] Portfolio frontend, various fixes --- .gitignore | 1 + backend/handlers/user/profile.go | 40 +-- frontend/src/lib/stores/auth.svelte.ts | 331 +++++++++++------------ frontend/src/routes/+page.svelte | 11 +- frontend/src/routes/account/+page.svelte | 204 +++++++------- frontend/src/routes/admin/+page.svelte | 156 ++++++++++- frontend/src/routes/book/+page.svelte | 94 ++++--- 7 files changed, 501 insertions(+), 336 deletions(-) diff --git a/.gitignore b/.gitignore index 519e784..9a46093 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ frontend/.vite frontend/.svelte2tsx-language-server-files frontend/build/ frontend/dist/ +frontend/static/portfolio/* # ------------------------------------ # 4. PHP/SabreDAV diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index ab0a04d..3b84aba 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -20,16 +20,17 @@ import ( var titleCaser = cases.Title(language.English) type UserProfile struct { - ID string `json:"id"` - Email string `json:"email"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - Phone *string `json:"phone,omitempty"` - DateOfBirth *string `json:"dateOfBirth,omitempty"` - Role string `json:"role"` - LoyaltyStamps int `json:"loyaltyStamps"` - ReferralCode string `json:"referralCode"` - ProfilePicURL *string `json:"profilePicUrl,omitempty"` + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Phone *string `json:"phone,omitempty"` + DateOfBirth *string `json:"dateOfBirth,omitempty"` + Role string `json:"role"` + LoyaltyStamps int `json:"loyaltyStamps"` + ReferralCode string `json:"referralCode"` + ReferralCodeUses int `json:"referralCodeUses"` + ProfilePicURL *string `json:"profilePicUrl,omitempty"` } type UpdateProfileRequest struct { @@ -48,16 +49,17 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) { var user UserProfile err := db.DB.QueryRow(r.Context(), ` - SELECT - id, email, n_first_name, n_last_name, phone, - date_of_birth::text, account_role, loyalty_stamps, - referral_code, profile_pic_url - FROM users - WHERE id = $1 - `, userID).Scan( + SELECT + id, email, n_first_name, n_last_name, phone, + date_of_birth::text, account_role, loyalty_stamps, + referral_code, profile_pic_url, + (SELECT COUNT(*) FROM user_referrals WHERE referrer_id = users.id AND claimed_booking_id IS NOT NULL) AS referral_code_uses + FROM users + WHERE id = $1 + `, userID).Scan( &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.Phone, &user.DateOfBirth, &user.Role, - &user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, + &user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, &user.ReferralCodeUses, ) if err != nil { @@ -187,7 +189,7 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { // Update DB _, err = db.DB.Exec(r.Context(), ` - UPDATE users + UPDATE users SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW() WHERE id = $4 `, req.FirstName, req.LastName, req.Phone, userID) diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts index cf4b4f0..1d62674 100644 --- a/frontend/src/lib/stores/auth.svelte.ts +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -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(null); - private user = $state(null); - private loading = $state(true); + private token = $state(null); + private user = $state(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(); \ No newline at end of file +export const authStore = new AuthStore(); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index f1b2e7a..e5d7b9f 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -5,6 +5,15 @@ import { Skeleton } from '$lib/components/ui/skeleton'; + + + + + +
{#if authStore.isLoading} @@ -16,7 +25,7 @@ {:else} -

+

{#if !authStore.isAuthenticated} Welcome to Crussell Nails {:else if authStore.currentUser?.role === 'unverified_email'} diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 2a657a3..767f0b0 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -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([]); 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')} > setActiveTab('history', e)} + onclick={(_) => (activeTab = 'history')} > setActiveTab('referral', e)} + onclick={(_) => (activeTab = 'referral')} > setActiveTab('admin', e)} + onclick={(_) => (activeTab = 'admin')} > - - + + Admin @@ -449,12 +404,12 @@
- {#if userData && userData.loyaltyStamps && userData.loyaltyStamps < 10} + {#if userData && stamps < 10}
Loyalty Stamps until next reward:
- {userData.loyaltyStamps} + {10 - stamps}
{:else}
@@ -572,12 +527,23 @@ {:else if userData?.referralCode}
-
Your Referral Code
-
+
Your Referral Code
+ +
{#if userData.referralCode} - {userData.referralCode.match(/.{1,4}/g)?.join('-')} + {#each userData.referralCode.match(/.{1,4}/g) as part (part)} + + {part} + + {/each} {/if}
+
@@ -659,6 +625,26 @@
+ +
+

Session

+

Log out of this account on this device.

+ +
+ @@ -691,74 +677,90 @@
- +
- - -
- -
- - - - - + + + + Admin +
diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 47d95bf..10f1258 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -54,28 +54,168 @@ uploadFiles = files; } + /** Helper: turn any File into a JPEG‑encoded Blob. */ + function toJpegBlob(file: File): Promise { + 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 { + 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, center‑cropped). */ + function createThumbnail(blob: Blob): Promise { + 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; // 1 ms 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, diff --git a/frontend/src/routes/book/+page.svelte b/frontend/src/routes/book/+page.svelte index ecbc456..f1b4b54 100644 --- a/frontend/src/routes/book/+page.svelte +++ b/frontend/src/routes/book/+page.svelte @@ -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 ); @@ -877,7 +884,7 @@ Your Details - Please provide your contact information + Please confirm your contact information @@ -922,42 +929,49 @@
-
-
- - + {#if !authStore.isAuthenticated} +

+ You are checking out as a guest, so you will miss out on a loyalty stamp. Please login + for full membership benefits. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- - -
-
- - -
-
- - -
-
+ {/if}
@@ -970,10 +984,12 @@
-

* Required fields

+ {#if !authStore.isAuthenticated} +

* Required fields

+ {/if}

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.