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:
2026-06-18 16:27:17 +01:00
co-authored by Sisyphus
parent 05690af87e
commit bac7cab4a0
12 changed files with 205 additions and 55 deletions
+24 -5
View File
@@ -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>
+1 -6
View File
@@ -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}
+100 -4
View File
@@ -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;
@@ -538,9 +552,9 @@
</div>
<!-- Summary Stats -->
{#if computeBookingStats(gdprData)}
{@const stats = computeBookingStats(gdprData)}
<h2 class="mb-3 text-xl font-bold">Summary</h2>
{#if 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">
<span class="text-xs text-gray-400">Member Since</span>
@@ -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;
}
+21 -7
View File
@@ -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"
+32 -18
View File
@@ -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,12 +380,17 @@
// ensuring the featured image always loads
if (imgParam) {
const targetId = imgParam.trim();
fetchImageById(targetId).then((img) => {
if (img) {
featuredImage = img;
openModal(img);
}
});
// 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;
openModal(img);
}
});
}
}
const observer = new IntersectionObserver(
+2 -2
View File
@@ -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>
+3 -1
View File
@@ -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"
+1 -1
View File
@@ -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">