Portfolio frontend, various fixes
This commit is contained in:
@@ -51,6 +51,7 @@ frontend/.vite
|
||||
frontend/.svelte2tsx-language-server-files
|
||||
frontend/build/
|
||||
frontend/dist/
|
||||
frontend/static/portfolio/*
|
||||
|
||||
# ------------------------------------
|
||||
# 4. PHP/SabreDAV
|
||||
|
||||
@@ -29,6 +29,7 @@ type UserProfile struct {
|
||||
Role string `json:"role"`
|
||||
LoyaltyStamps int `json:"loyaltyStamps"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
ReferralCodeUses int `json:"referralCodeUses"`
|
||||
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
|
||||
}
|
||||
|
||||
@@ -51,13 +52,14 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT
|
||||
id, email, n_first_name, n_last_name, phone,
|
||||
date_of_birth::text, account_role, loyalty_stamps,
|
||||
referral_code, profile_pic_url
|
||||
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 {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface User {
|
||||
dateOfBirth?: string;
|
||||
loyaltyStamps?: number;
|
||||
referralCode?: string;
|
||||
referralCodeUses?: number;
|
||||
profilePicUrl?: string;
|
||||
}
|
||||
|
||||
@@ -54,7 +55,6 @@ class AuthStore {
|
||||
return !this.loading;
|
||||
}
|
||||
|
||||
|
||||
private initializeAuth() {
|
||||
const storedToken = localStorage.getItem('authToken');
|
||||
if (storedToken) {
|
||||
@@ -119,7 +119,7 @@ class AuthStore {
|
||||
try {
|
||||
const response = await fetch('/api/user/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
Authorization: `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
|
||||
@@ -141,7 +141,6 @@ class AuthStore {
|
||||
goto('/');
|
||||
};
|
||||
|
||||
|
||||
private clearAuth() {
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
@@ -182,7 +181,7 @@ class AuthStore {
|
||||
const response = await fetch('/api/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
Authorization: `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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="mx-auto mb-1 h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
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="mx-auto mb-1 h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
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="mx-auto mb-1 h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
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
|
||||
class="mx-auto mb-1 h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
Admin
|
||||
</button>
|
||||
|
||||
<div 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>
|
||||
|
||||
@@ -54,28 +54,168 @@
|
||||
uploadFiles = files;
|
||||
}
|
||||
|
||||
/** Helper: turn any File into a JPEG‑encoded 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, center‑cropped). */
|
||||
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; // 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,
|
||||
|
||||
@@ -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,6 +929,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Contact Form -->
|
||||
{#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>
|
||||
@@ -958,6 +971,7 @@
|
||||
/>
|
||||
</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">
|
||||
{#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>
|
||||
|
||||
Reference in New Issue
Block a user