feat(frontend): update page routes
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
@@ -33,6 +34,7 @@
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import Cropper from 'svelte-easy-crop';
|
||||
|
||||
@@ -778,7 +780,7 @@
|
||||
await fetchUserData();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error(text || 'Failed to update phone number', { id: loadingToast });
|
||||
toast.error(sanitizeText(text) || 'Failed to update phone number', { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating phone:', err);
|
||||
@@ -846,7 +848,7 @@
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load upcoming bookings: ' + text);
|
||||
toast.error('Failed to load upcoming bookings: ' + sanitizeText(text));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -888,7 +890,7 @@
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load past bookings: ' + text);
|
||||
toast.error('Failed to load past bookings: ' + sanitizeText(text));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1003,7 +1005,7 @@
|
||||
passwordData = { current: '', new: '', confirm: '' };
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error(text || 'Failed to change password', { id: loadingToast });
|
||||
toast.error(sanitizeText(text) || 'Failed to change password', { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error changing password:', err);
|
||||
@@ -1051,7 +1053,7 @@
|
||||
goto('/');
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error(text || 'Failed to delete account', { id: loadingToast });
|
||||
toast.error(sanitizeText(text) || 'Failed to delete account', { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting account:', err);
|
||||
@@ -2303,6 +2305,23 @@
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
View our cancellation, deposit, and no-show policies
|
||||
</p>
|
||||
<PolicyPopover>
|
||||
{#snippet trigger()}
|
||||
<Button variant="outline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
Cancellation & Deposit Policy
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PolicyPopover>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Notification Preferences (non-admin users only) -->
|
||||
{#if authStore.currentUser?.role !== 'admin'}
|
||||
<div>
|
||||
|
||||
@@ -292,12 +292,7 @@
|
||||
<UsersCard {openUserModal} />
|
||||
<BookingsCard {openBookingModal} />
|
||||
</div>
|
||||
<TimeBlockers
|
||||
{openUserModal}
|
||||
{openBookingModal}
|
||||
onReschedule={handleReschedule}
|
||||
{rescheduleVersion}
|
||||
/>
|
||||
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} />
|
||||
<HolidayHours />
|
||||
<WeeklySchedule />
|
||||
<ServicesManagement />
|
||||
|
||||
@@ -76,6 +76,11 @@
|
||||
let showEditRequestModal = $state(false);
|
||||
let selectedEditRequest = $state<EditRequest | null>(null);
|
||||
|
||||
function openBookingModal(bookingId: string) {
|
||||
selectedBooking = { id: bookingId };
|
||||
showBookingModal = true;
|
||||
}
|
||||
|
||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||||
|
||||
$effect(() => {
|
||||
@@ -94,12 +99,12 @@
|
||||
const reasonLabels: Record<string, string> = {
|
||||
pending_booking: 'Booking Pending Approval',
|
||||
edit_request: 'Customer Requested Booking Change',
|
||||
edit_requested: 'Booking Edit Requested',
|
||||
edit_requested: 'Booking Edit/Reschedule Requested',
|
||||
new_booking: 'New Booking Received',
|
||||
cancelled_booking: 'Booking Cancelled',
|
||||
late_cancellation: 'Late Cancellation (< 24h)',
|
||||
no_deposit: 'Deposit Issue',
|
||||
deposit_paid: 'Deposit Payment Received',
|
||||
deposit_not_paid_by_deadline: 'Deposit Deadline Passed',
|
||||
affiliate_claim: 'Affiliate Referral Claimed',
|
||||
'1_month_no_pay': 'No Payments in 1 Month',
|
||||
'1_week_no_pay': 'No Payments in 1 Week'
|
||||
@@ -115,7 +120,7 @@
|
||||
case 'edit_requested':
|
||||
return 'edit_approve';
|
||||
case 'late_cancellation':
|
||||
case 'no_deposit':
|
||||
case 'deposit_not_paid_by_deadline':
|
||||
case '1_week_no_pay':
|
||||
case '1_month_no_pay':
|
||||
return 'see_user';
|
||||
@@ -198,13 +203,13 @@
|
||||
selectedEditRequest = data.edit_request;
|
||||
showEditRequestModal = true;
|
||||
} else if (response.status === 404) {
|
||||
toast.error('This edit request has already been processed');
|
||||
toast.error('This edit/reschedule request has already been processed');
|
||||
} else {
|
||||
toast.error('Could not load edit request details');
|
||||
toast.error('Could not load edit/reschedule request details');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching edit request:', err);
|
||||
toast.error('Network error loading edit request');
|
||||
toast.error('Network error loading edit/reschedule request');
|
||||
}
|
||||
} else if (action === 'see_user' && notification.user_id) {
|
||||
selectedUserId = notification.user_id;
|
||||
@@ -504,7 +509,7 @@
|
||||
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking?.id ?? ''} />
|
||||
|
||||
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} />
|
||||
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} openBookingModal={openBookingModal} />
|
||||
|
||||
{#if showEditRequestModal && selectedEditRequest}
|
||||
<EditRequestModal
|
||||
|
||||
@@ -432,9 +432,10 @@
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
role="region"
|
||||
role="application"
|
||||
aria-label="Week schedule"
|
||||
class="schedule-wrapper flex-1 overflow-auto rounded-lg border bg-white select-none"
|
||||
class:cursor-grab={!isDragging}
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
|
||||
// Types
|
||||
type Service = {
|
||||
id: string;
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
override_price?: number;
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
@@ -246,11 +248,11 @@
|
||||
<div>
|
||||
<div class="font-medium">{service.service_name}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{service.override_duration_minutes ?? service.duration_minutes} mins
|
||||
{service.duration_minutes} mins
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{formatPounds(service.override_price ?? service.price)}
|
||||
{formatPounds(service.price)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
data_consent_updated_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
failed_attempts?: number;
|
||||
locked_until?: string;
|
||||
};
|
||||
bookings: Array<{
|
||||
booking_id: string;
|
||||
@@ -159,6 +161,18 @@
|
||||
booking_id: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
login_audit?: Array<{
|
||||
attempt_type: string;
|
||||
ip_address: string;
|
||||
success: boolean;
|
||||
created_at: string;
|
||||
}>;
|
||||
refresh_tokens?: Array<{
|
||||
role: string;
|
||||
revoked: boolean;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}>;
|
||||
export_metadata?: {
|
||||
exported_at: string;
|
||||
exported_by: string;
|
||||
@@ -539,7 +553,7 @@
|
||||
|
||||
<!-- Summary Stats -->
|
||||
{#if computeBookingStats(gdprData)}
|
||||
{@const stats = computeBookingStats(gdprData)}
|
||||
{@const stats = computeBookingStats(gdprData)!}
|
||||
<h2 class="mb-3 text-xl font-bold">Summary</h2>
|
||||
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
@@ -678,6 +692,18 @@
|
||||
<span class="text-xs text-gray-400">Referral Code</span>
|
||||
<p class="font-mono text-sm">{gdprData.user_profile.referral_code || '—'}</p>
|
||||
</div>
|
||||
{#if gdprData.user_profile.failed_attempts != null && gdprData.user_profile.failed_attempts > 0}
|
||||
<div>
|
||||
<span class="text-xs text-gray-400">Failed Login Attempts</span>
|
||||
<p class="text-sm">{gdprData.user_profile.failed_attempts}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if gdprData.user_profile.locked_until}
|
||||
<div>
|
||||
<span class="text-xs text-gray-400">Account Locked Until</span>
|
||||
<p class="text-sm">{fmtDateTime(gdprData.user_profile.locked_until)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<span class="text-xs text-gray-400">Privacy Policy Consent</span>
|
||||
<p class="text-sm">
|
||||
@@ -885,6 +911,76 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Login Audit -->
|
||||
{#if gdprData.login_audit && gdprData.login_audit.length > 0}
|
||||
<Card.Root class="mb-4">
|
||||
<Card.Header><Card.Title>Login Activity</Card.Title></Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
><tr class="border-b"
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Date</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Type</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Success</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each gdprData.login_audit as la (la.created_at)}
|
||||
<tr class="border-b last:border-b-0">
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(la.created_at)}</td>
|
||||
<td class="px-4 py-3">{la.attempt_type.replace(/_/g, ' ')}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {la.success
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}"
|
||||
>{la.success ? 'Yes' : 'No'}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Refresh Tokens -->
|
||||
{#if gdprData.refresh_tokens && gdprData.refresh_tokens.length > 0}
|
||||
<Card.Root class="mb-4">
|
||||
<Card.Header><Card.Title>Session Tokens</Card.Title></Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
><tr class="border-b"
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Issued</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Expires</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Status</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each gdprData.refresh_tokens as rt (rt.created_at)}
|
||||
<tr class="border-b last:border-b-0">
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rt.created_at)}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rt.expires_at)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {rt.revoked
|
||||
? 'bg-gray-100 text-gray-600'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
>{rt.revoked ? 'Revoked' : 'Active'}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Patch Tests -->
|
||||
{#if gdprData.patch_tests && gdprData.patch_tests.length > 0}
|
||||
<Card.Root class="mb-4">
|
||||
@@ -1240,7 +1336,7 @@
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
body {
|
||||
:global(body) {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { EmailInput } from '$lib/components/ui/email-input/index.js';
|
||||
@@ -16,6 +15,7 @@
|
||||
import * as languageCommon from '@zxcvbn-ts/language-common';
|
||||
import * as languageEn from '@zxcvbn-ts/language-en';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
|
||||
// set up options so that feedback, dictionary etc. are included
|
||||
zxcvbnOptions.setOptions({
|
||||
@@ -177,7 +177,7 @@
|
||||
toast.error('Invalid email or password.', { id: loadingToast });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Error: ' + text, { id: loadingToast });
|
||||
toast.error('Error: ' + sanitizeText(text), { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -211,7 +211,7 @@
|
||||
toggleMode();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Error: ' + text, { id: loadingToast });
|
||||
toast.error('Error: ' + sanitizeText(text), { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -224,7 +224,21 @@
|
||||
}
|
||||
|
||||
// when password changes, re-compute strength
|
||||
let passwordStrength = $derived(formData.password ? zxcvbn(formData.password) : null);
|
||||
let passwordStrength = $derived(formData.password ? (() => {
|
||||
const result = zxcvbn(formData.password);
|
||||
// Add minimum length check (6 chars) to the score feedback
|
||||
if (formData.password.length < 6) {
|
||||
return {
|
||||
...result,
|
||||
score: Math.min(result.score, 0), // Force weak for too short
|
||||
feedback: {
|
||||
warning: 'Password must be at least 6 characters',
|
||||
suggestions: ['Add more characters to meet the minimum length requirement']
|
||||
}
|
||||
};
|
||||
}
|
||||
return result;
|
||||
})() : null);
|
||||
|
||||
// form completion check
|
||||
let isFormComplete = $derived(
|
||||
@@ -238,7 +252,7 @@
|
||||
formData.confirmPassword &&
|
||||
formData.password === formData.confirmPassword &&
|
||||
passwordStrength &&
|
||||
passwordStrength.score >= 2 &&
|
||||
formData.password.length >= 6 && passwordStrength.score >= 2 &&
|
||||
agreedToPolicy &&
|
||||
!validationErrors.email &&
|
||||
!validationErrors.phone &&
|
||||
@@ -538,14 +552,14 @@
|
||||
<Label for="privacy">
|
||||
I agree to the
|
||||
<a
|
||||
href={resolve('/terms')}
|
||||
href="/terms"
|
||||
class="font-semibold text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Terms & Conditions</a
|
||||
>
|
||||
and
|
||||
<a
|
||||
href={resolve('/privacy')}
|
||||
href="/privacy"
|
||||
class="font-semibold text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Privacy Policy</a
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
// Types
|
||||
type Service = {
|
||||
service_id: string;
|
||||
booking_id: string;
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
@@ -307,7 +309,7 @@
|
||||
<div class="border-t pt-3">
|
||||
<div class="text-sm text-gray-500">Services</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each booking.services as service (service.id)}
|
||||
{#each booking.services as service (service.service_id || service.booking_id)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700">{service.service_name}</span>
|
||||
<span class="text-gray-500"
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
let selectedTag = $state('');
|
||||
let selectedTags = $state<string[]>([]);
|
||||
let hasMore = $state(true);
|
||||
let offset = $state(0);
|
||||
let cursor = $state('');
|
||||
const limit = 20;
|
||||
|
||||
let searchQuery = $state('');
|
||||
@@ -132,7 +132,10 @@
|
||||
}
|
||||
|
||||
function buildImageUrl(): string {
|
||||
const parts: string[] = [`limit=${limit}`, `offset=${offset}`];
|
||||
const parts: string[] = [`limit=${limit}`];
|
||||
if (cursor) {
|
||||
parts.push(`cursor=${encodeURIComponent(cursor)}`);
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
parts.push(`tags=${encodeURIComponent(selectedTags.join(','))}`);
|
||||
@@ -166,7 +169,8 @@
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newImages = data.map(
|
||||
const imagesList = data.images ?? data;
|
||||
const newImages = imagesList.map(
|
||||
(img: {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -198,6 +202,7 @@
|
||||
images = newImages;
|
||||
}
|
||||
|
||||
cursor = data.next_cursor ?? '';
|
||||
hasMore = newImages.length === limit;
|
||||
} catch (e) {
|
||||
error = true;
|
||||
@@ -236,13 +241,12 @@
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loadingMore || !hasMore) return;
|
||||
offset += limit;
|
||||
if (loadingMore || !hasMore || !cursor) return;
|
||||
fetchImages(true);
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
offset = 0;
|
||||
cursor = '';
|
||||
if (searchQuery.includes(',')) {
|
||||
selectedTags = searchQuery
|
||||
.split(',')
|
||||
@@ -275,7 +279,7 @@
|
||||
}
|
||||
|
||||
function selectFilter(category: string, value: string) {
|
||||
offset = 0;
|
||||
cursor = '';
|
||||
closeAllDropdowns();
|
||||
|
||||
if (value === '' || value === selectedFilters[category]) {
|
||||
@@ -307,7 +311,7 @@
|
||||
|
||||
function clearFilters() {
|
||||
selectedFilters = {};
|
||||
offset = 0;
|
||||
cursor = '';
|
||||
|
||||
// Build URL with reactive page state
|
||||
const url = new URL(page.url);
|
||||
@@ -324,7 +328,7 @@
|
||||
selectedTag = '';
|
||||
selectedTags = [];
|
||||
searchQuery = '';
|
||||
offset = 0;
|
||||
cursor = '';
|
||||
|
||||
// Build URL with reactive page state
|
||||
const url = new URL(page.url);
|
||||
@@ -343,12 +347,13 @@
|
||||
const tagsParam = page.url.searchParams.get('tags');
|
||||
const imgParam = page.url.searchParams.get('img');
|
||||
|
||||
selectedTag = tagParam || '';
|
||||
selectedTags = tagsParam
|
||||
selectedTag = (tagParam && tagParam.length <= 100) ? tagParam.slice(0, 100) : '';
|
||||
selectedTags = tagsParam && tagsParam.length <= 500
|
||||
? tagsParam
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
|
||||
if (selectedTag) {
|
||||
@@ -360,7 +365,11 @@
|
||||
for (const [key, value] of page.url.searchParams.entries()) {
|
||||
const match = key.match(/^filter\[(.+)\]$/);
|
||||
if (match) {
|
||||
selectedFilters[match[1]] = value;
|
||||
const filterKey = match[1].slice(0, 50);
|
||||
const filterValue = value.slice(0, 200);
|
||||
if (/^[a-zA-Z0-9_-]+$/.test(filterKey)) {
|
||||
selectedFilters[filterKey] = filterValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +380,10 @@
|
||||
// ensuring the featured image always loads
|
||||
if (imgParam) {
|
||||
const targetId = imgParam.trim();
|
||||
// Validate: only allow alphanumeric and dash
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(targetId)) {
|
||||
console.warn('Invalid img parameter, ignoring');
|
||||
} else {
|
||||
fetchImageById(targetId).then((img) => {
|
||||
if (img) {
|
||||
featuredImage = img;
|
||||
@@ -378,6 +391,7 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
<div class="h-4 w-64 rounded bg-gray-200"></div>
|
||||
<div class="space-y-4 pt-2">
|
||||
{#each Array(3) as _, i}
|
||||
<div key={i} class="rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div class="rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<div class="h-4 w-24 rounded bg-gray-200"></div>
|
||||
<div class="h-6 w-20 rounded-full bg-gray-100"></div>
|
||||
@@ -218,7 +218,7 @@
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
{#each Array(3) as _, i}
|
||||
<div key={i} class="animate-pulse rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div class="animate-pulse rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<div class="h-4 w-24 rounded bg-gray-200"></div>
|
||||
<div class="h-6 w-20 rounded-full bg-gray-100"></div>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
type BookingService = {
|
||||
service_id: string;
|
||||
booking_id: string;
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
@@ -336,7 +338,7 @@
|
||||
<div class="border-t pt-3">
|
||||
<div class="text-sm text-gray-500">Services</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each booking.services as service (service.id)}
|
||||
{#each booking.services as service (service.service_id || service.booking_id)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700">{service.service_name}</span>
|
||||
<span class="text-gray-500"
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Current/Next Appointment Card (Full Width) -->
|
||||
<CurrentAppointment {openBookingModal} {openEditBookingModal} {openUserModal} />
|
||||
<CurrentAppointment _openBookingModal={openBookingModal} {openEditBookingModal} {openUserModal} />
|
||||
|
||||
<!-- Quick Booking + Till Purchases Grid -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
||||
|
||||
Reference in New Issue
Block a user