fix(frontend): Svelte 5 reactivity, each block keys, and path resolution

Add proper keys to #each blocks across 15+ components to fix reordering bugs. Replace new Date() with SvelteDate in reactive contexts. Use $derived for computed values (totalPages). Use resolve() from $app/paths for all internal navigation hrefs. Add ARIA labels and keyboard accessibility to NavBar mobile menu. Remove unused handleRetry from UserPaymentModal.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-04 01:08:36 +01:00
co-authored by Sisyphus
parent c76c3f52f7
commit 6c16d65476
33 changed files with 417 additions and 300 deletions
@@ -835,7 +835,7 @@
</div> </div>
{:else} {:else}
<div <div
class="no-scrollbar mt-2 flex max-h-40 min-h-[100px] w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4" class="no-scrollbar mt-2 flex max-h-40 min-h-25 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
> >
<div class="grid justify-center gap-2 text-sm text-gray-600"> <div class="grid justify-center gap-2 text-sm text-gray-600">
{newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { {newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', {
@@ -927,9 +927,9 @@
onclick={() => { onclick={() => {
selectedServices = selectedServices.filter((s) => s.id !== service.id); selectedServices = selectedServices.filter((s) => s.id !== service.id);
}} }}
class="cursor-pointer rounded-lg border border-input bg-background bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50" class="cursor-pointer rounded-lg border border-input bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50"
> >
<div class="flex h-full min-h-[6rem] flex-col justify-between"> <div class="flex h-full min-h-24 flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p> <p class="text-sm text-gray-600">{service.description || ''}</p>
@@ -966,7 +966,7 @@
}} }}
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50" class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
> >
<div class="flex h-full min-h-[6rem] flex-col justify-between"> <div class="flex h-full min-h-24 flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p> <p class="text-sm text-gray-600">{service.description || ''}</p>
@@ -1055,9 +1055,9 @@
onclick={() => { onclick={() => {
selectedServices = selectedServices.filter((s) => s.id !== service.id); selectedServices = selectedServices.filter((s) => s.id !== service.id);
}} }}
class="cursor-pointer rounded-lg border border-input bg-background bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50" class="cursor-pointer rounded-lg border border-input bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50"
> >
<div class="flex h-full min-h-[6rem] flex-col justify-between"> <div class="flex h-full min-h-24 flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p> <p class="text-sm text-gray-600">{service.description || ''}</p>
@@ -1089,7 +1089,7 @@
}} }}
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50" class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
> >
<div class="flex h-full min-h-[6rem] flex-col justify-between"> <div class="flex h-full min-h-24 flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p> <p class="text-sm text-gray-600">{service.description || ''}</p>
@@ -22,7 +22,7 @@
email?: string; email?: string;
phone?: string; phone?: string;
}; };
services: Array<{ services?: Array<{
service_id: string; service_id: string;
service_name?: string; service_name?: string;
price?: number; price?: number;
@@ -632,7 +632,7 @@
<!-- Decline Confirmation Dialog --> <!-- Decline Confirmation Dialog -->
<AlertDialog.Root bind:open={showDeclineConfirm}> <AlertDialog.Root bind:open={showDeclineConfirm}>
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-60">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Decline this booking?</AlertDialog.Title> <AlertDialog.Title>Decline this booking?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
@@ -85,7 +85,7 @@
let selectedTime = $state<string | null>(null); let selectedTime = $state<string | null>(null);
let workingHours = $state<Record<string, DayHours> | null>(null); let workingHours = $state<Record<string, DayHours> | null>(null);
let availableHours = $state<Record<string, DayAvailability> | null>(null); let availableHours = $state<Record<string, DayAvailability> | null>(null);
let loadingWorkingHours = $state(false); let _loadingWorkingHours = $state(false);
let loadingAvailableHours = $state(false); let loadingAvailableHours = $state(false);
let hoursRangeGeneration = $state(0); let hoursRangeGeneration = $state(0);
let hoursMonthGeneration = $state(0); let hoursMonthGeneration = $state(0);
@@ -340,7 +340,7 @@
hoursRangeGeneration++; hoursRangeGeneration++;
const gen = hoursRangeGeneration; const gen = hoursRangeGeneration;
loadingWorkingHours = true; _loadingWorkingHours = true;
loadingAvailableHours = true; loadingAvailableHours = true;
try { try {
@@ -388,7 +388,7 @@
console.error('Failed to fetch hours', err); console.error('Failed to fetch hours', err);
toast.error('Failed to load availability'); toast.error('Failed to load availability');
} finally { } finally {
loadingWorkingHours = false; _loadingWorkingHours = false;
loadingAvailableHours = false; loadingAvailableHours = false;
} }
} }
@@ -410,7 +410,7 @@
hoursMonthGeneration++; hoursMonthGeneration++;
const gen = hoursMonthGeneration; const gen = hoursMonthGeneration;
loadingWorkingHours = true; _loadingWorkingHours = true;
loadingAvailableHours = true; loadingAvailableHours = true;
try { try {
@@ -453,7 +453,7 @@
console.error('Failed to fetch hours', err); console.error('Failed to fetch hours', err);
toast.error('Failed to load availability'); toast.error('Failed to load availability');
} finally { } finally {
loadingWorkingHours = false; _loadingWorkingHours = false;
loadingAvailableHours = false; loadingAvailableHours = false;
loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey)); loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey));
} }
@@ -547,7 +547,7 @@
return; return;
} }
const now = new Date(); const now = new SvelteDate();
const diff = reservationExpiresAt.getTime() - now.getTime(); const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) { if (diff <= 0) {
@@ -862,7 +862,7 @@
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200"> <div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
{#if loadingUsers} {#if loadingUsers}
<div class="space-y-2 p-2"> <div class="space-y-2 p-2">
{#each Array(3) as _} {#each Array(3) as _, i (i)}
<Skeleton class="h-10 w-full" /> <Skeleton class="h-10 w-full" />
{/each} {/each}
</div> </div>
@@ -970,7 +970,7 @@
<Card.Content class="space-y-4"> <Card.Content class="space-y-4">
{#if loadingServices} {#if loadingServices}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(4) as _} {#each Array(4) as _, i (i)}
<Skeleton class="h-28 w-full" /> <Skeleton class="h-28 w-full" />
{/each} {/each}
</div> </div>
@@ -6,7 +6,8 @@
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte'; import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte'; import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format'; import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format';
import type { Booking } from '$lib/types/booking'; import type { Booking, BookingService } from '$lib/types/booking';
import type { Payment } from '$lib/types';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -83,7 +84,7 @@
: undefined, : undefined,
// Services // Services
services: (data.services || []).map((s) => ({ services: (data.services || []).map((s: BookingService) => ({
booking_id: s.booking_id, booking_id: s.booking_id,
service_id: s.service_id, service_id: s.service_id,
service_name: s.service_name, service_name: s.service_name,
@@ -95,7 +96,7 @@
})), })),
// Payments // Payments
payments: (data.payments || []).map((p) => ({ payments: (data.payments || []).map((p: Payment) => ({
id: p.id, id: p.id,
booking_id: p.booking_id, booking_id: p.booking_id,
payment_type: p.payment_type, payment_type: p.payment_type,
@@ -349,8 +350,11 @@
title="Copy referral code" title="Copy referral code"
class="inline-flex items-center justify-center rounded p-1 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700" class="inline-flex items-center justify-center rounded p-1 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700"
onclick={() => { onclick={() => {
navigator.clipboard.writeText(selectedBooking.user!.referral_code!); const code = selectedBooking?.user?.referral_code;
toast.success('Referral code copied'); if (code) {
navigator.clipboard.writeText(code);
toast.success('Referral code copied');
}
}} }}
> >
<svg <svg
@@ -454,7 +458,7 @@
{:else if payment.payment_method === 'cash'}Cash {:else if payment.payment_method === 'cash'}Cash
{:else if payment.payment_method === 'giftcard'}Gift Card {:else if payment.payment_method === 'giftcard'}Gift Card
{:else if payment.payment_method === 'discount'}Discount {:else if payment.payment_method === 'discount'}Discount
{:else}{payment.payment_method.replace('_', ' ')} {:else}{(payment.payment_method as string).replace('_', ' ')}
{/if} {/if}
</span> </span>
<span <span
@@ -634,7 +634,7 @@
<!-- Remove Service Confirmation --> <!-- Remove Service Confirmation -->
<AlertDialog.Root bind:open={showRemoveConfirm}> <AlertDialog.Root bind:open={showRemoveConfirm}>
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-60">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Remove Service?</AlertDialog.Title> <AlertDialog.Title>Remove Service?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
@@ -364,7 +364,7 @@
<!-- Deny Confirmation Dialog --> <!-- Deny Confirmation Dialog -->
<AlertDialog.Root bind:open={showDenyConfirm}> <AlertDialog.Root bind:open={showDenyConfirm}>
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-60">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Deny this change request?</AlertDialog.Title> <AlertDialog.Title>Deny this change request?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
@@ -605,7 +605,7 @@
<!-- Delete Exception Confirmation --> <!-- Delete Exception Confirmation -->
<AlertDialog.Root bind:open={showDeleteExceptionAlert}> <AlertDialog.Root bind:open={showDeleteExceptionAlert}>
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-60">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Delete exception group?</AlertDialog.Title> <AlertDialog.Title>Delete exception group?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte'; import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -59,179 +58,230 @@
}); });
} }
/** Convert image to AVIF at 0.55 quality for full-size */ /** Load a File or Blob into an HTMLImageElement. */
function toAvifBlob(file: File): Promise<Blob> { function loadImage(src: File | Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => resolve(img);
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'));
// Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/avif' });
resolve(typedBlob);
},
'image/avif',
0.72
);
};
img.onerror = () => reject(new Error('Image load failed')); img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(file); img.src = URL.createObjectURL(src);
}); });
} }
/** Resize to max 1500px on the *short* side, only scale down, never up. */ /** Encode ImageData to AVIF via Web Worker. */
function resizeShortSide(blob: Blob): Promise<Blob> { function encodeAvif(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const worker = new Worker(
img.onload = () => { new URL('$lib/workers/avif-encoder.ts', import.meta.url),
let { width, height } = img; { type: 'module' }
const maxShortSide = 1500; );
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
// Only resize if image is larger than target worker.terminate();
const shortSide = Math.min(width, height); if (e.data.error) return reject(new Error(e.data.error));
if (shortSide > maxShortSide) { resolve(new Blob([e.data.encoded], { type: 'image/avif' }));
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'));
// Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/avif' });
resolve(typedBlob);
},
'image/avif',
0.72
);
}; };
img.onerror = () => reject(new Error('Image load failed')); worker.onerror = (err) => { worker.terminate(); reject(err); };
img.src = URL.createObjectURL(blob); worker.postMessage({ imageData, quality });
}); });
} }
/** Create a 250×250 thumbnail using multi-pass downsampling for better quality. */ /** Encode ImageData to WebP via Web Worker. */
function createThumbnail(blob: Blob): Promise<Blob> { function encodeWebp(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const worker = new Worker(
img.onload = () => { new URL('$lib/workers/webp-encoder.ts', import.meta.url),
const targetSize = 250; { type: 'module' }
let srcWidth = img.width; );
let srcHeight = img.height; worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
let sourceCanvas: HTMLCanvasElement | null = null; worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
// Multi-pass downsampling with tunable scale factor resolve(new Blob([e.data.encoded], { type: 'image/webp' }));
// Lower SCALE_FACTOR (0.5) = more passes = softer/blurrier
// Higher SCALE_FACTOR (0.75+) = fewer passes = sharper but risking pixelation
// Sweet spot: 0.65-0.70
const SCALE_FACTOR = 0.2;
const STOP_THRESHOLD = targetSize * 1.1; // Stop when close enough
while (Math.min(srcWidth, srcHeight) > STOP_THRESHOLD) {
const scale = Math.max(SCALE_FACTOR, targetSize / Math.min(srcWidth, srcHeight));
const newWidth = Math.round(srcWidth * scale);
const newHeight = Math.round(srcHeight * scale);
const nextCanvas = document.createElement('canvas');
nextCanvas.width = newWidth;
nextCanvas.height = newHeight;
const nextCtx = nextCanvas.getContext('2d');
if (!nextCtx) return reject(new Error('2D context not available'));
nextCtx.imageSmoothingEnabled = true;
nextCtx.imageSmoothingQuality = 'high';
// Draw from source (either original image or previous canvas)
if (sourceCanvas === null) {
nextCtx.drawImage(img, 0, 0, newWidth, newHeight);
} else {
nextCtx.drawImage(sourceCanvas, 0, 0, newWidth, newHeight);
}
sourceCanvas = nextCanvas;
srcWidth = newWidth;
srcHeight = newHeight;
}
// Final crop to exact 250×250 square from center
const finalCanvas = document.createElement('canvas');
finalCanvas.width = targetSize;
finalCanvas.height = targetSize;
const finalCtx = finalCanvas.getContext('2d');
if (!finalCtx) return reject(new Error('2D context not available'));
finalCtx.imageSmoothingEnabled = true;
finalCtx.imageSmoothingQuality = 'high';
// Center crop from the final source (either image or downsampled canvas)
if (sourceCanvas === null) {
// No downsampling was needed, crop directly from image
const offsetX = (img.width - targetSize) / 2;
const offsetY = (img.height - targetSize) / 2;
finalCtx.drawImage(
img,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
} else {
// Crop from the downsampled canvas
const offsetX = (srcWidth - targetSize) / 2;
const offsetY = (srcHeight - targetSize) / 2;
finalCtx.drawImage(
sourceCanvas,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
}
finalCanvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
// Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/webp' });
resolve(typedBlob);
},
'image/webp',
0.8
);
}; };
img.onerror = () => reject(new Error('Image load failed')); worker.onerror = (err) => { worker.terminate(); reject(err); };
img.src = URL.createObjectURL(blob); worker.postMessage({ imageData, quality });
}); });
} }
/** Encode ImageData to JPEG via Web Worker. */
function encodeJpeg(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('$lib/workers/jpeg-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/jpeg' }));
};
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.postMessage({ imageData, quality });
});
}
/** Encode ImageData to JPEG XL via Web Worker. Returns null if unavailable. */
function encodeJxl(imageData: ImageData, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
const worker = new Worker(
new URL('$lib/workers/jxl-encoder.ts', import.meta.url),
{ type: 'module' }
);
const timeout = setTimeout(() => { worker.terminate(); resolve(null); }, 10000);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
clearTimeout(timeout);
worker.terminate();
if (e.data.error) return resolve(null);
resolve(new Blob([e.data.encoded], { type: 'image/jxl' }));
};
worker.onerror = () => { clearTimeout(timeout); worker.terminate(); resolve(null); };
worker.postMessage({ imageData, quality });
});
}
/** Create a 250×250 center-cropped thumbnail canvas using multi-pass downsampling. */
function createThumbnailCanvas(img: HTMLImageElement): HTMLCanvasElement {
const targetSize = 250;
let srcWidth = img.width;
let srcHeight = img.height;
let sourceCanvas: HTMLCanvasElement | null = null;
const SCALE_FACTOR = 0.2;
const STOP_THRESHOLD = targetSize * 1.1;
while (Math.min(srcWidth, srcHeight) > STOP_THRESHOLD) {
const scale = Math.max(SCALE_FACTOR, targetSize / Math.min(srcWidth, srcHeight));
const newWidth = Math.round(srcWidth * scale);
const newHeight = Math.round(srcHeight * scale);
const nextCanvas = document.createElement('canvas');
nextCanvas.width = newWidth;
nextCanvas.height = newHeight;
const nextCtx = nextCanvas.getContext('2d')!;
nextCtx.imageSmoothingEnabled = true;
nextCtx.imageSmoothingQuality = 'high';
if (sourceCanvas === null) {
nextCtx.drawImage(img, 0, 0, newWidth, newHeight);
} else {
nextCtx.drawImage(sourceCanvas, 0, 0, newWidth, newHeight);
}
sourceCanvas = nextCanvas;
srcWidth = newWidth;
srcHeight = newHeight;
}
// Final crop to exact 250×250 square from center
const finalCanvas = document.createElement('canvas');
finalCanvas.width = targetSize;
finalCanvas.height = targetSize;
const finalCtx = finalCanvas.getContext('2d')!;
finalCtx.imageSmoothingEnabled = true;
finalCtx.imageSmoothingQuality = 'high';
if (sourceCanvas === null) {
const offsetX = (img.width - targetSize) / 2;
const offsetY = (img.height - targetSize) / 2;
finalCtx.drawImage(
img,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
} else {
const offsetX = (srcWidth - targetSize) / 2;
const offsetY = (srcHeight - targetSize) / 2;
finalCtx.drawImage(
sourceCanvas,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
}
return finalCanvas;
}
/** Resize to max 1500px on the short side, only scale down, never up. Returns canvas. */
function createFullCanvas(img: HTMLImageElement): HTMLCanvasElement {
let { width, height } = img;
const maxShortSide = 1500;
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')!;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, width, height);
return canvas;
}
/** Generate all thumbnail variants (AVIF, WebP, JPEG) from a single file. */
async function generateThumbnailVariants(file: File): Promise<{
avif: Blob;
webp: Blob;
jpg: Blob;
}> {
const img = await loadImage(file);
const canvas = createThumbnailCanvas(img);
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const [avif, webp, jpg] = await Promise.all([
encodeAvif(imageData, 80),
encodeWebp(imageData, 80),
encodeJpeg(imageData, 85)
]);
return { avif, webp, jpg };
}
/** Generate all full-size variants (AVIF, WebP, JPEG, optional JXL) from a single file. */
async function generateFullVariants(file: File): Promise<{
avif: Blob;
webp: Blob;
jpg: Blob;
jxl: Blob | null;
}> {
const img = await loadImage(file);
const canvas = createFullCanvas(img);
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const [avif, webp, jpg, jxl] = await Promise.all([
encodeAvif(imageData, 72),
encodeWebp(imageData, 80),
encodeJpeg(imageData, 85),
encodeJxl(imageData, 75)
]);
return { avif, webp, jpg, jxl };
}
let availableTags = $state<string[]>([]); let availableTags = $state<string[]>([]);
let loadingTags = $state(true); let loadingTags = $state(true);
let isMobile = $state(false); let isMobile = $state(false);
@@ -403,7 +453,7 @@
tags = tags.filter((t) => t !== tag); tags = tags.filter((t) => t !== tag);
} }
/** Core upload function now processes the images before sending. */ /** Core upload function multi-format: generates AVIF+WebP+JPEG+JXL variants. */
async function uploadOneOrMany() { async function uploadOneOrMany() {
// Add any pending input as tag before uploading // Add any pending input as tag before uploading
if (input.trim()) { if (input.trim()) {
@@ -425,44 +475,32 @@
const fd = new FormData(); const fd = new FormData();
try { try {
/* -------- 1. Convert to base image format (AVIF) ------- */ /* -------- 1. Generate all full-size variants (AVIF, WebP, JPEG, JXL) ------- */
uploadStatus[fileKey] = 'Converting to AVIF...'; uploadStatus[fileKey] = 'Generating full-size variants...';
const avifBlob = await toAvifBlob(file); const fullVariants = await generateFullVariants(file);
/* -------- 2. Create the two processed versions IN PARALLEL ------- */ /* -------- 2. Generate all thumbnail variants (AVIF, WebP, JPEG) ------- */
uploadStatus[fileKey] = 'Compressing...'; uploadStatus[fileKey] = 'Generating thumbnail variants...';
const [resizedBlob, thumbBlob] = await Promise.all([ const thumbVariants = await generateThumbnailVariants(file);
resizeShortSide(avifBlob).then((blob) => {
uploadStatus[fileKey] = 'Full size ready';
return blob;
}),
createThumbnail(avifBlob).then((blob) => {
uploadStatus[fileKey] = 'Thumbnail ready';
return blob;
})
]);
/* -------- 3. Generate filenames -------------------------------- */ /* -------- 3. Generate filenames -------------------------------- */
const ts = startTs - i; // 1 ms decrement per file const ts = startTs - i; // 1 ms decrement per file
const baseName = `${ts}.avif`;
const thumbName = `${ts}_thumb.webp`;
/* -------- 4. Attach to FormData -------------------------------- */ /* -------- 4. Attach all variants to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName); fd.append('file_full_avif', fullVariants.avif, `${ts}_full.avif`);
fd.append('thumbnail', thumbBlob, thumbName); fd.append('file_full_webp', fullVariants.webp, `${ts}_full.webp`);
fd.append('file_full_jpg', fullVariants.jpg, `${ts}_full.jpg`);
if (fullVariants.jxl) {
fd.append('file_full_jxl', fullVariants.jxl, `${ts}_full.jxl`);
}
fd.append('file_thumb_avif', thumbVariants.avif, `${ts}_thumb.avif`);
fd.append('file_thumb_webp', thumbVariants.webp, `${ts}_thumb.webp`);
fd.append('file_thumb_jpg', thumbVariants.jpg, `${ts}_thumb.jpg`);
if (tags.length > 0) { if (tags.length > 0) {
fd.append('tags', tags.join(',')); fd.append('tags', tags.join(','));
} }
/* Debug: log what we're sending
console.log(`Uploading ${fileKey}:`, {
baseName,
thumbName,
thumbMimeType: thumbBlob.type,
fullMimeType: resizedBlob.type
});
*/
/* -------- 5. Call the API ------------------------------------- */ /* -------- 5. Call the API ------------------------------------- */
uploadStatus[fileKey] = 'Uploading...'; uploadStatus[fileKey] = 'Uploading...';
const response = await fetch('/api/portfolio/images', { const response = await fetch('/api/portfolio/images', {
@@ -843,7 +881,7 @@
<!-- Confirmation Dialog --> <!-- Confirmation Dialog -->
<AlertDialog.Root bind:open={showConfirmUploadAlert}> <AlertDialog.Root bind:open={showConfirmUploadAlert}>
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-60">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Confirm Upload</AlertDialog.Title> <AlertDialog.Title>Confirm Upload</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
@@ -462,7 +462,7 @@
</div> </div>
<div class="space-y-2 pt-2"> <div class="space-y-2 pt-2">
<label class="text-sm font-medium">Applicable Services</label> <div class="text-sm font-medium">Applicable Services</div>
<p class="mb-2 text-xs text-gray-500">Select which services require this patch test</p> <p class="mb-2 text-xs text-gray-500">Select which services require this patch test</p>
<div class="max-h-48 space-y-2 overflow-y-auto rounded-md border bg-gray-50 p-2"> <div class="max-h-48 space-y-2 overflow-y-auto rounded-md border bg-gray-50 p-2">
{#if availableServices.length === 0} {#if availableServices.length === 0}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
@@ -51,7 +51,7 @@
let workingHoursCache: Record<string, Record<string, any>> = {}; let workingHoursCache: Record<string, Record<string, any>> = {};
let availableHoursCache: Record<string, Record<string, any>> = {}; let availableHoursCache: Record<string, Record<string, any>> = {};
let loadingMonths: Record<string, boolean> = {}; let loadingMonths: Record<string, boolean> = {};
let loadingMonthKeys: Set<string> = new Set(); let loadingMonthKeys = new SvelteSet<string>();
let initialLoadDone = $state(false); let initialLoadDone = $state(false);
let rescheduleAutoSelectDone = $state(false); let rescheduleAutoSelectDone = $state(false);
@@ -220,7 +220,7 @@
if (loadingMonths[monthKey]) return; if (loadingMonths[monthKey]) return;
if (loadingMonthKeys.has(monthKey)) return; if (loadingMonthKeys.has(monthKey)) return;
loadingMonthKeys = new Set(loadingMonthKeys).add(monthKey); loadingMonthKeys.add(monthKey);
loadingMonths[monthKey] = true; loadingMonths[monthKey] = true;
hoursMonthGeneration++; hoursMonthGeneration++;
@@ -265,7 +265,7 @@
toast.error('Failed to load availability'); toast.error('Failed to load availability');
} finally { } finally {
delete loadingMonths[monthKey]; delete loadingMonths[monthKey];
loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey)); loadingMonthKeys.delete(monthKey);
loadingHours = false; loadingHours = false;
} }
} }
@@ -280,7 +280,7 @@
workingHoursCache = {}; workingHoursCache = {};
availableHoursCache = {}; availableHoursCache = {};
loadingMonths = {}; loadingMonths = {};
loadingMonthKeys = new Set(); loadingMonthKeys = new SvelteSet<string>();
rescheduleAutoSelectDone = false; rescheduleAutoSelectDone = false;
placeholder = new CalendarDate( placeholder = new CalendarDate(
new SvelteDate().getFullYear(), new SvelteDate().getFullYear(),
@@ -41,11 +41,11 @@
let creatingService = $state(false); let creatingService = $state(false);
let serviceErrors = $state<Record<string, string>>({}); let serviceErrors = $state<Record<string, string>>({});
function validatePrice(price: any): string { function validatePrice(price: number | string): string {
if (price === null || price === undefined || price === '') { if (price === null || price === undefined || price === '') {
return 'Price is required'; return 'Price is required';
} }
const numPrice = parseFloat(price); const numPrice = parseFloat(typeof price === 'number' ? price.toString() : price);
if (isNaN(numPrice)) { if (isNaN(numPrice)) {
return 'Price must be a valid number'; return 'Price must be a valid number';
} }
@@ -62,7 +62,7 @@
return ''; return '';
} }
function validateDuration(value: any, field: string): string { function validateDuration(value: number | string, field: string): string {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return 'Field cannot be empty'; return 'Field cannot be empty';
} }
@@ -735,7 +735,7 @@
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs text-gray-600">Time</label> <div class="text-xs text-gray-600">Time</div>
{#if hoursLoading} {#if hoursLoading}
<Skeleton class="h-9 w-full" /> <Skeleton class="h-9 w-full" />
{:else if !selectedWorkingHours} {:else if !selectedWorkingHours}
@@ -912,7 +912,7 @@
</Modal.Root> </Modal.Root>
<AlertDialog.Root bind:open={showDeleteAlert}> <AlertDialog.Root bind:open={showDeleteAlert}>
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-60">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title> <AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
@@ -231,7 +231,7 @@
return; return;
} }
const now = new Date(); const now = new SvelteDate();
const diff = reservationExpiresAt.getTime() - now.getTime(); const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) { if (diff <= 0) {
@@ -152,7 +152,7 @@
return; return;
} }
const now = new Date(); const now = new SvelteDate();
const diff = reservationExpiresAt.getTime() - now.getTime(); const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) { if (diff <= 0) {
@@ -545,9 +545,9 @@
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200"> <div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
{#if loadingUsers} {#if loadingUsers}
<div class="space-y-2 p-2"> <div class="space-y-2 p-2">
{#each Array(3) as _} {#each Array(3) as _, i (i)}
<Skeleton class="h-10 w-full" /> <Skeleton class="h-10 w-full" />
{/each} {/each}
</div> </div>
{:else if users.length === 0} {:else if users.length === 0}
<div class="flex items-center justify-center p-8 text-sm text-gray-500"> <div class="flex items-center justify-center p-8 text-sm text-gray-500">
@@ -653,7 +653,7 @@
<Card.Content class="space-y-4"> <Card.Content class="space-y-4">
{#if loadingServices} {#if loadingServices}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(4) as _} {#each Array(4) as _, i (i)}
<Skeleton class="h-28 w-full" /> <Skeleton class="h-28 w-full" />
{/each} {/each}
</div> </div>
@@ -534,7 +534,7 @@
<!-- Save Default Hours Confirmation --> <!-- Save Default Hours Confirmation -->
<AlertDialog.Root bind:open={showSaveDefaultHoursAlert}> <AlertDialog.Root bind:open={showSaveDefaultHoursAlert}>
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-60">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Save default hours?</AlertDialog.Title> <AlertDialog.Title>Save default hours?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
@@ -14,6 +14,7 @@
// staff adjusting working hours; the app does not need timezone-aware scheduling logic. // staff adjusting working hours; the app does not need timezone-aware scheduling logic.
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
@@ -171,7 +172,7 @@
const data = await response.json(); const data = await response.json();
paymentMethods = data.payment_methods ?? []; paymentMethods = data.payment_methods ?? [];
if (paymentMethods.length > 0 && !selectedPaymentMethod) { if (paymentMethods.length > 0 && !selectedPaymentMethod) {
const defaultCard = paymentMethods.find((m: any) => m.is_default) ?? paymentMethods[0]; const defaultCard = paymentMethods.find((m) => 'is_default' in m && m.is_default) ?? paymentMethods[0];
selectedPaymentMethod = defaultCard.id; selectedPaymentMethod = defaultCard.id;
} }
} else { } else {
@@ -323,7 +324,7 @@
return; return;
} }
const now = new Date(); const now = new SvelteDate();
const diff = reservationExpiresAt.getTime() - now.getTime(); const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) { if (diff <= 0) {
@@ -1378,7 +1379,7 @@
</p> </p>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4"> <div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4">
<Button <Button
onclick={() => goto('/login')} onclick={() => goto(resolve('/login'))}
variant="outline" variant="outline"
class="border-fuchsia-200 hover:bg-fuchsia-100" class="border-fuchsia-200 hover:bg-fuchsia-100"
> >
@@ -2001,12 +2002,12 @@
<p class="text-sm text-amber-800"> <p class="text-sm text-amber-800">
<strong>Please note:</strong> Because you included special requests, the cost and duration <strong>Please note:</strong> Because you included special requests, the cost and duration
shown are estimates. We may adjust these after reviewing your requirements. You'll receive shown are estimates. We may adjust these after reviewing your requirements. You'll receive
a notification once your booking is approved. {authStore.isAuthenticated ? ' a notification' : ' an email'} once your booking is approved.
</p> </p>
</div> </div>
{/if} {/if}
{#if !calculateDepositRequired()} {#if !calculateDepositRequired() && authStore.isAuthenticated && authStore.currentUser?.role !== 'admin' && authStore.currentUser?.role !== 'guest'}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
<h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3> <h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3>
<p class="mb-4 text-gray-600"> <p class="mb-4 text-gray-600">
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import type { Service } from '$lib/types/booking'; import type { Service } from '$lib/types/booking';
let { let {
@@ -26,7 +27,7 @@
onclick={() => !isGrayedOut && onclick?.()} onclick={() => !isGrayedOut && onclick?.()}
disabled={isGrayedOut} disabled={isGrayedOut}
> >
<div class="flex h-full min-h-[6rem] flex-col justify-between"> <div class="flex h-full min-h-24 flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
{#if isGrayedOut} {#if isGrayedOut}
@@ -37,7 +38,7 @@
Patch test expired Patch test expired
{/if} {/if}
{#if showContactLink} {#if showContactLink}
- <a href="/contact" class="underline">contact us</a> - <a href={resolve('/contact')} class="underline">contact us</a>
{/if} {/if}
</p> </p>
{:else} {:else}
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition'; import { slide, fly } from 'svelte/transition';
import { navigating, page } from '$app/stores'; import { navigating, page } from '$app/stores';
import { goto } from '$app/navigation'; import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
@@ -126,12 +126,12 @@
<!-- Center: Desktop Links --> <!-- Center: Desktop Links -->
<div class="hidden space-x-8 md:flex"> <div class="hidden space-x-8 md:flex">
{#each links as link} {#each links as link (link.href)}
{#if canShow(link)} {#if canShow(link)}
{#if isLoading} {#if isLoading}
<Skeleton class={`h-4 ${link.width} rounded`} /> <Skeleton class={`h-4 ${link.width} rounded`} />
{:else} {:else}
<a href={link.href} class="font-medium text-gray-800 hover:text-primary" <a href={resolve(link.href)} class="font-medium text-gray-800 hover:text-primary"
>{link.label}</a >{link.label}</a
> >
{/if} {/if}
@@ -142,7 +142,7 @@
<div class="hidden items-center gap-4 md:flex"> <div class="hidden items-center gap-4 md:flex">
{#if isAuthenticated} {#if isAuthenticated}
<a <a
href="/notifications" href={resolve('/notifications')}
class="relative text-gray-600 hover:text-primary" class="relative text-gray-600 hover:text-primary"
aria-label="Notifications" aria-label="Notifications"
> >
@@ -170,7 +170,7 @@
</a> </a>
{/if} {/if}
{#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'} {#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'}
<Button href="/login">Login</Button> <Button href={resolve('/login')}>Login</Button>
{/if} {/if}
{#if isLoading} {#if isLoading}
<Skeleton class="h-8 w-16 rounded" /> <Skeleton class="h-8 w-16 rounded" />
@@ -179,18 +179,19 @@
<!-- Mobile: Burger --> <!-- Mobile: Burger -->
<div class="flex items-center md:hidden"> <div class="flex items-center md:hidden">
<button onclick={toggleMenu} class="relative focus:outline-none" aria-label="Toggle menu"> <button
<svg class="h-6 w-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onclick={toggleMenu}
<path class="relative h-6 w-6 focus:outline-none"
stroke-linecap="round" aria-label="Toggle menu"
stroke-linejoin="round" class:open={mobileMenuOpen}
stroke-width="2" >
d="M4 6h16M4 12h16M4 18h16" <span class="hamburger-line"></span>
/> <span class="hamburger-line"></span>
</svg> <span class="hamburger-line"></span>
{#if unreadCount > 0 && !mobileMenuOpen} <span class="hamburger-line"></span>
{#if unreadCount > 0}
<span <span
class="absolute -top-1 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white" class="notification-badge absolute -top-1 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
> >
{unreadCount > 9 ? '9+' : unreadCount} {unreadCount > 9 ? '9+' : unreadCount}
</span> </span>
@@ -203,7 +204,11 @@
{#if mobileMenuOpen} {#if mobileMenuOpen}
<div <div
class="fixed inset-0 top-16 bg-black/30 backdrop-blur-[1px] md:hidden" class="fixed inset-0 top-16 bg-black/30 backdrop-blur-[1px] md:hidden"
role="button"
tabindex="0"
aria-label="Close menu"
onclick={toggleMenu} onclick={toggleMenu}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleMenu(); } }}
></div> ></div>
{/if} {/if}
@@ -214,13 +219,13 @@
class="relative z-50 border-b border-gray-200 bg-background md:hidden" class="relative z-50 border-b border-gray-200 bg-background md:hidden"
> >
<div class="space-y-1 px-2 pt-2 pb-3"> <div class="space-y-1 px-2 pt-2 pb-3">
{#each links as link} {#each links as link (link.href)}
{#if canShow(link)} {#if canShow(link)}
{#if isLoading} {#if isLoading}
<Skeleton class="h-4 w-full rounded" /> <Skeleton class="h-4 w-full rounded" />
{:else} {:else}
<a <a
href={link.href} href={resolve(link.href)}
class="block rounded px-3 py-2 text-center text-primary hover:text-gray-800" class="block rounded px-3 py-2 text-center text-primary hover:text-gray-800"
> >
{link.label} {link.label}
@@ -231,12 +236,13 @@
{#if isAuthenticated} {#if isAuthenticated}
<a <a
href="/notifications" href={resolve('/notifications')}
class="flex items-center justify-center gap-2 rounded px-3 py-2 text-primary hover:text-gray-800" class="flex items-center justify-center gap-2 rounded px-3 py-2 text-primary hover:text-gray-800"
> >
<span>Notifications</span> <span>Notifications</span>
{#if unreadCount > 0} {#if unreadCount > 0}
<span <span
in:fly={{ y: -8, duration: 200, delay: 50 }}
class="flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white" class="flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
> >
{unreadCount > 9 ? '9+' : unreadCount} {unreadCount > 9 ? '9+' : unreadCount}
@@ -248,7 +254,7 @@
{#if isLoading} {#if isLoading}
<Skeleton class="mt-2 h-8 w-full rounded" /> <Skeleton class="mt-2 h-8 w-full rounded" />
{:else if !isAuthenticated} {:else if !isAuthenticated}
<Button href="/login" class="mt-2 w-full text-center">Login</Button> <Button href={resolve('/login')} class="mt-2 w-full text-center">Login</Button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -259,4 +265,70 @@
.frosty-nav { .frosty-nav {
backdrop-filter: saturate(180%) blur(10px); backdrop-filter: saturate(180%) blur(10px);
} }
/* Hamburger animation - 4 lines, middle two overlap when closed */
.hamburger-line {
display: block;
position: absolute;
height: 2px;
width: 100%;
background: #374151;
border-radius: 2px;
opacity: 1;
left: 0;
transform: rotate(0deg);
transition: 0.125s ease-in-out;
}
.hamburger-line:nth-child(1) {
top: 3px;
}
/* Spans 2 and 3 overlap at same position, span 3 hidden when closed */
.hamburger-line:nth-child(2) {
top: 10px;
}
.hamburger-line:nth-child(3) {
top: 10px;
opacity: 0;
}
.hamburger-line:nth-child(4) {
top: 17px;
}
/* Open state - transform to X */
.open .hamburger-line:nth-child(1) {
top: 10px;
width: 0%;
left: 50%;
}
.open .hamburger-line:nth-child(2) {
transform: rotate(45deg);
}
.open .hamburger-line:nth-child(3) {
opacity: 1;
transform: rotate(-45deg);
}
.open .hamburger-line:nth-child(4) {
top: 10px;
width: 0%;
left: 50%;
}
/* Badge animations */
.notification-badge {
opacity: 1;
transform: translateY(0);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.open .notification-badge {
opacity: 0;
transform: translateY(8px);
}
</style> </style>
@@ -43,7 +43,7 @@
<div class="relative w-full overflow-hidden"> <div class="relative w-full overflow-hidden">
<div class="animate-scroll flex"> <div class="animate-scroll flex">
{#each [...portfolioImages, ...portfolioImages] as image} {#each [...portfolioImages, ...portfolioImages] as image, i (i)}
<div class="group flex-shrink-0"> <div class="group flex-shrink-0">
<img <img
src={image.src} src={image.src}
@@ -426,7 +426,6 @@
<Dialog.Root <Dialog.Root
open={true} open={true}
onOpenChange={(open) => !open && handleClose()} onOpenChange={(open) => !open && handleClose()}
onOpenAutoFocus={(e) => e.preventDefault()}
> >
<Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto"> <Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
<Dialog.Header> <Dialog.Header>
@@ -452,7 +451,7 @@
<input <input
type="text" type="text"
inputmode="decimal" inputmode="decimal"
tabindex="-1" tabindex={-1}
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none" class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
value={serviceOverrides[service.service_id]?.price ?? value={serviceOverrides[service.service_id]?.price ??
service.price?.toFixed(2) ?? service.price?.toFixed(2) ??
@@ -607,7 +606,7 @@
<Input <Input
type="text" type="text"
inputmode="decimal" inputmode="decimal"
tabindex="-1" tabindex={-1}
placeholder="Custom tip amount" placeholder="Custom tip amount"
value={customTipAmount} value={customTipAmount}
oninput={handleCustomTipInput} oninput={handleCustomTipInput}
@@ -657,7 +656,7 @@
id="cash-amount" id="cash-amount"
type="text" type="text"
inputmode="decimal" inputmode="decimal"
tabindex="-1" tabindex={-1}
value={cashAmount} value={cashAmount}
oninput={handleCashInput} oninput={handleCashInput}
class="pl-7 text-lg font-semibold" class="pl-7 text-lg font-semibold"
@@ -713,7 +712,7 @@
id="gift-card-id" id="gift-card-id"
type="text" type="text"
inputmode="text" inputmode="text"
tabindex="-1" tabindex={-1}
value={giftCardId} value={giftCardId}
oninput={handleGiftCardInput} oninput={handleGiftCardInput}
placeholder="XXXX XXXX XXXX" placeholder="XXXX XXXX XXXX"
@@ -354,11 +354,6 @@
makePayment('partial', Math.round(partialAmountNum * 100)); makePayment('partial', Math.round(partialAmountNum * 100));
} }
function handleRetry() {
status = 'idle';
error = null;
}
function handleClose() { function handleClose() {
stopPolling(); stopPolling();
onClose(); onClose();
@@ -920,7 +920,7 @@
checkOverlappingBookings(); checkOverlappingBookings();
}} }}
> >
{#each availableStartOptions as opt} {#each availableStartOptions as opt (opt.totalMin)}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option> <option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each} {/each}
</select> </select>
@@ -948,7 +948,7 @@
checkOverlappingBookings(); checkOverlappingBookings();
}} }}
> >
{#each availableEndOptions as opt} {#each availableEndOptions as opt (opt.totalMin)}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option> <option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each} {/each}
</select> </select>
@@ -1047,7 +1047,7 @@
</Modal.Root> </Modal.Root>
<AlertDialog.Root bind:open={showDeleteAlert}> <AlertDialog.Root bind:open={showDeleteAlert}>
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-60">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title> <AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
<AlertDialog.Description <AlertDialog.Description
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import * as Button from '$lib/components/ui/button/index.js'; import * as Button from '$lib/components/ui/button/index.js';
let { ref = $bindable(null), ...restProps }: Button.Props = $props(); let { ...restProps }: Record<string, unknown> = $props();
let ref = $state<HTMLElement | null>(null);
</script> </script>
<Button.Root bind:ref type="submit" {...restProps} /> <Button.Root bind:ref={ref} type="submit" {...restProps} />
+3 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import PortfolioCarousel from '$lib/components/layout/PortfolioCarousel.svelte'; import PortfolioCarousel from '$lib/components/layout/PortfolioCarousel.svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
@@ -124,9 +125,9 @@
</p> </p>
{#if authStore.currentUser?.role === 'admin'} {#if authStore.currentUser?.role === 'admin'}
<Button href="/today" class="px-6 py-3 text-lg">View your day</Button> <Button href={resolve('/today')} class="px-6 py-3 text-lg">View your day</Button>
{:else} {:else}
<Button href="/book" class="px-6 py-3 text-lg">Book an Appointment</Button> <Button href={resolve('/book')} class="px-6 py-3 text-lg">Book an Appointment</Button>
{/if} {/if}
{/if} {/if}
</section> </section>
@@ -365,7 +365,7 @@
$effect(() => { $effect(() => {
if (pageState !== 'authorized') return; if (pageState !== 'authorized') return;
const tickId = setInterval(() => { const tickId = setInterval(() => {
const n = new Date(); const n = new SvelteDate();
nowMinutes = n.getHours() * 60 + n.getMinutes(); nowMinutes = n.getHours() * 60 + n.getMinutes();
}, 30_000); }, 30_000);
return () => clearInterval(tickId); return () => clearInterval(tickId);
@@ -420,6 +420,7 @@
{:else} {:else}
<div <div
bind:this={scrollContainer} bind:this={scrollContainer}
role="region"
aria-label="Week schedule" aria-label="Week schedule"
class="schedule-wrapper flex-1 overflow-auto rounded-lg border bg-white select-none" class="schedule-wrapper flex-1 overflow-auto rounded-lg border bg-white select-none"
class:cursor-grab={!isDragging} class:cursor-grab={!isDragging}
@@ -241,7 +241,7 @@
<div> <div>
<div class="mb-3 text-sm font-medium text-gray-500">Services</div> <div class="mb-3 text-sm font-medium text-gray-500">Services</div>
<div class="space-y-2"> <div class="space-y-2">
{#each booking.services as service} {#each booking.services as service (service.id)}
<div class="flex justify-between rounded bg-gray-50 p-3"> <div class="flex justify-between rounded bg-gray-50 p-3">
<div> <div>
<div class="font-medium">{service.service_name}</div> <div class="font-medium">{service.service_name}</div>
+6 -6
View File
@@ -175,7 +175,7 @@
<div class="text-lg font-semibold">{currentAppointment.customer.name}</div> <div class="text-lg font-semibold">{currentAppointment.customer.name}</div>
<div class="text-sm text-gray-600">{currentAppointment.customer.phone}</div> <div class="text-sm text-gray-600">{currentAppointment.customer.phone}</div>
<div class="mt-1 flex flex-wrap gap-1"> <div class="mt-1 flex flex-wrap gap-1">
{#each currentAppointment.services as service} {#each currentAppointment.services as service, i (i)}
<Badge variant="outline" class="text-xs">{service}</Badge> <Badge variant="outline" class="text-xs">{service}</Badge>
{/each} {/each}
</div> </div>
@@ -186,7 +186,7 @@
<div> <div>
<div class="mb-2 text-sm font-semibold text-gray-700">Service Progress</div> <div class="mb-2 text-sm font-semibold text-gray-700">Service Progress</div>
<div class="space-y-2"> <div class="space-y-2">
{#each currentAppointment.checklist as item} {#each currentAppointment.checklist as item (item.item)}
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<Checkbox checked={item.done} /> <Checkbox checked={item.done} />
<span class={item.done ? 'text-gray-400 line-through' : ''}>{item.item}</span> <span class={item.done ? 'text-gray-400 line-through' : ''}>{item.item}</span>
@@ -225,7 +225,7 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="space-y-3"> <div class="space-y-3">
{#each todayAppointments as apt} {#each todayAppointments as apt, i (i)}
<div <div
class="flex items-center gap-4 rounded-lg border p-3 transition-all hover:shadow-md" class="flex items-center gap-4 rounded-lg border p-3 transition-all hover:shadow-md"
> >
@@ -297,7 +297,7 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="space-y-3"> <div class="space-y-3">
{#each upcomingArrivals as arrival} {#each upcomingArrivals as arrival, i (i)}
<div class="rounded-lg border bg-gray-50 p-3"> <div class="rounded-lg border bg-gray-50 p-3">
<div class="mb-1 flex items-center justify-between"> <div class="mb-1 flex items-center justify-between">
<span class="font-medium">{arrival.customer}</span> <span class="font-medium">{arrival.customer}</span>
@@ -324,7 +324,7 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="space-y-3"> <div class="space-y-3">
{#each recentCheckouts as checkout} {#each recentCheckouts as checkout, i (i)}
<div class="rounded-lg border p-3"> <div class="rounded-lg border p-3">
<div class="mb-1 flex items-center justify-between"> <div class="mb-1 flex items-center justify-between">
<span class="font-medium">{checkout.customer}</span> <span class="font-medium">{checkout.customer}</span>
@@ -387,7 +387,7 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="space-y-3"> <div class="space-y-3">
{#each loyaltyToday as loyalty} {#each loyaltyToday as loyalty, i (i)}
<div class="rounded-lg border bg-gray-50 p-3"> <div class="rounded-lg border bg-gray-50 p-3">
<div class="mb-1 flex items-center justify-between"> <div class="mb-1 flex items-center justify-between">
<span class="font-medium">{loyalty.customer}</span> <span class="font-medium">{loyalty.customer}</span>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
@@ -614,14 +615,14 @@
<Label for="privacy"> <Label for="privacy">
I agree to the I agree to the
<a <a
href="/terms" href={resolve('/terms')}
class="font-semibold text-primary hover:underline" class="font-semibold text-primary hover:underline"
target="_blank" target="_blank"
rel="noopener noreferrer">Terms & Conditions</a rel="noopener noreferrer">Terms & Conditions</a
> >
and and
<a <a
href="/privacy" href={resolve('/privacy')}
class="font-semibold text-primary hover:underline" class="font-semibold text-primary hover:underline"
target="_blank" target="_blank"
rel="noopener noreferrer">Privacy Policy</a rel="noopener noreferrer">Privacy Policy</a
@@ -331,7 +331,7 @@
return parts.join(' — '); return parts.join(' — ');
} }
const totalPages = Math.ceil(total / perPage); const totalPages = $derived(Math.ceil(total / perPage));
onMount(() => { onMount(() => {
if (pageState === 'authorized') { if (pageState === 'authorized') {
@@ -307,7 +307,7 @@
<div class="border-t pt-3"> <div class="border-t pt-3">
<div class="text-sm text-gray-500">Services</div> <div class="text-sm text-gray-500">Services</div>
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
{#each booking.services as service} {#each booking.services as service (service.id)}
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-700">{service.service_name}</span> <span class="text-gray-700">{service.service_name}</span>
<span class="text-gray-500" <span class="text-gray-500"
+5 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js';
@@ -87,7 +88,7 @@
<Card.Content> <Card.Content>
<div class="py-12 text-center"> <div class="py-12 text-center">
<div class="mb-4 text-lg text-gray-600">No services available at the moment.</div> <div class="mb-4 text-lg text-gray-600">No services available at the moment.</div>
<Button href="/contact" variant="outline">Contact Us</Button> <Button href={resolve('/contact')} variant="outline">Contact Us</Button>
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -201,9 +202,9 @@
<Card.Content class="p-6 text-center md:p-8"> <Card.Content class="p-6 text-center md:p-8">
<h2 class="mb-4 text-lg font-semibold text-primary md:text-xl">Ready to Book?</h2> <h2 class="mb-4 text-lg font-semibold text-primary md:text-xl">Ready to Book?</h2>
<div class="flex flex-col gap-3 md:flex-row md:justify-center md:gap-4"> <div class="flex flex-col gap-3 md:flex-row md:justify-center md:gap-4">
<Button href="/book" class="px-6 py-3 md:px-8">Book Appointment</Button> <Button href={resolve('/book')} class="px-6 py-3 md:px-8">Book Appointment</Button>
<Button <Button
href="/contact" href={resolve('/contact')}
variant="outline" variant="outline"
class="border-primary/30 px-6 py-3 hover:bg-primary/10 md:px-8" class="border-primary/30 px-6 py-3 hover:bg-primary/10 md:px-8"
> >
+7 -5
View File
@@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte'; import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
import type { Booking, BookingService } from '$lib/types/booking';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading'); let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
let bookings = $state<any[]>([]); let bookings = $state<Booking[]>([]);
let loading = $state(false); let loading = $state(false);
let selectedBookingId = $state<string | null>(null); let selectedBookingId = $state<string | null>(null);
let showBookingModal = $state(false); let showBookingModal = $state(false);
@@ -53,13 +55,13 @@
const now = new SvelteDate(); const now = new SvelteDate();
bookings = (data.bookings || []) bookings = (data.bookings || [])
.filter((b: any) => { .filter((b: Booking) => {
const startTime = new SvelteDate(b.start_time); const startTime = new SvelteDate(b.start_time);
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000); const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now; return endTime > now;
}) })
.sort( .sort(
(a: any, b: any) => (a: Booking, b: Booking) =>
new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime() new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
); );
} catch (err) { } catch (err) {
@@ -107,7 +109,7 @@
<Card.Description>You don't have any upcoming appointments.</Card.Description> <Card.Description>You don't have any upcoming appointments.</Card.Description>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<Button href="/book">Book an Appointment</Button> <Button href={resolve('/book')}>Book an Appointment</Button>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{:else} {:else}
@@ -146,7 +148,7 @@
<div> <div>
{#if booking.services && booking.services.length > 0} {#if booking.services && booking.services.length > 0}
<p class="font-medium"> <p class="font-medium">
{booking.services.map((s: any) => s.service_name).join(', ')} {booking.services.map((s: BookingService) => s.service_name).join(', ')}
</p> </p>
{/if} {/if}
{#if booking.total_amount} {#if booking.total_amount}
+1 -1
View File
@@ -336,7 +336,7 @@
<div class="border-t pt-3"> <div class="border-t pt-3">
<div class="text-sm text-gray-500">Services</div> <div class="text-sm text-gray-500">Services</div>
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
{#each booking.services as service} {#each booking.services as service (service.id)}
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-700">{service.service_name}</span> <span class="text-gray-700">{service.service_name}</span>
<span class="text-gray-500" <span class="text-gray-500"