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:
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user