refactor: route pages with shared formatting and layout updates

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-05-29 16:10:29 +01:00
co-authored by Sisyphus
parent 5705ef71b8
commit ec097af0f6
9 changed files with 432 additions and 311 deletions
+8
View File
@@ -1,9 +1,17 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
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';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
// Redirect admins to /today (their homepage)
$effect(() => {
if (!authStore.isLoading && authStore.currentUser?.role === 'admin') {
goto('/today', { replaceState: true });
}
});
const returningGreetings = [ const returningGreetings = [
(name: string) => `Welcome back, ${name}!`, (name: string) => `Welcome back, ${name}!`,
(name: string) => `Great to see you again, ${name}!`, (name: string) => `Great to see you again, ${name}!`,
+10 -24
View File
@@ -470,7 +470,7 @@
loadingUpcoming = true; loadingUpcoming = true;
try { try {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD const today = new SvelteDate().toISOString().split('T')[0]; // YYYY-MM-DD
// Fetch more items (e.g. 10) to ensure we find upcoming ones even if the first few are past // Fetch more items (e.g. 10) to ensure we find upcoming ones even if the first few are past
const response = await fetch(`/api/bookings?start_date=${today}&per_page=10&page=1`, { const response = await fetch(`/api/bookings?start_date=${today}&per_page=10&page=1`, {
@@ -488,13 +488,13 @@
} }
const data = await response.json(); const data = await response.json();
const now = new Date(); const now = new SvelteDate();
// Filter: Calculate end time (Start + Duration) and check if it's in the future // Filter: Calculate end time (Start + Duration) and check if it's in the future
const activeOrFutureBookings = (data.bookings || []).filter((b: any) => { const activeOrFutureBookings = (data.bookings || []).filter((b: any) => {
const startTime = new Date(b.start_time); const startTime = new SvelteDate(b.start_time);
// Add duration (in ms) // Add duration (in ms)
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000); const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now; return endTime > now;
}); });
@@ -514,7 +514,7 @@
loadingPast = true; loadingPast = true;
try { try {
const today = new Date().toISOString().split('T')[0]; const today = new SvelteDate().toISOString().split('T')[0];
const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, { const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
method: 'GET', method: 'GET',
headers: { headers: {
@@ -555,7 +555,7 @@
if (!aUnpaid && bUnpaid) return 1; if (!aUnpaid && bUnpaid) return 1;
// If both have same payment status, sort by Date DESC (newest first) // If both have same payment status, sort by Date DESC (newest first)
return new Date(b.start_time).getTime() - new Date(a.start_time).getTime(); return new SvelteDate(b.start_time).getTime() - new SvelteDate(a.start_time).getTime();
}); });
pastBookings = bookings; pastBookings = bookings;
@@ -1217,22 +1217,9 @@
<Skeleton class="h-32 w-full" /> <Skeleton class="h-32 w-full" />
{:else if userData?.referralCode} {:else if userData?.referralCode}
<div class="rounded-lg border p-6 text-center"> <div class="rounded-lg border p-6 text-center">
<div class="mb-2 text-sm font-medium text-slate-700">Your Referral Code</div> <div class="mb-3 text-sm font-medium text-muted-foreground">Your Referral Code</div>
<div <div class="mb-4 flex items-center justify-center">{#each userData.referralCode.match(/.{1,4}/g) as part, i (i)}<span class="inline-flex min-w-[3.5rem] items-center justify-center border-b-2 border-b-border px-1 pb-1 text-2xl font-bold tracking-widest text-foreground sm:text-4xl sm:min-w-[5rem]">{part}</span>{#if i < 2}<span class="mx-1 text-xl font-bold text-muted-foreground select-none sm:text-3xl sm:mx-2" aria-hidden="true"></span>{/if}{/each}</div>
class="mb-4 flex items-baseline justify-center space-x-2
text-2xl break-all font-bold tracking-wider text-slate-900 sm:text-4xl"
>
{#if userData.referralCode}
{#each userData.referralCode.match(/.{1,4}/g) as part (part)}
<span
class="rounded-sm border-b-1 border-slate-300 px-1 py-0.5 text-slate-900"
>
{part}
</span>
{/each}
{/if}
</div>
<Button onclick={copyReferralCode} variant="outline" class="w-full"> <Button onclick={copyReferralCode} variant="outline" class="w-full">
<svg <svg
@@ -1802,9 +1789,8 @@
{/if} {/if}
<!-- User Booking Modal --> <!-- User Booking Modal -->
{#if showBookingModal && selectedBookingId} <UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
{/if}
<style> <style>
/* Mobile Tab Menu Styles */ /* Mobile Tab Menu Styles */
+4 -6
View File
@@ -283,11 +283,9 @@
</div> </div>
<!-- Modals --> <!-- Modals -->
{#if showUserModal && selectedUserId} <UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
<UserModal bind:open={showUserModal} userId={selectedUserId} {openBookingModal} />
{/if}
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} onReschedule={handleReschedule} />
{#if showBookingModal && selectedBookingId}
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId} onReschedule={handleReschedule} />
{/if}
{/if} {/if}
+230 -123
View File
@@ -33,7 +33,8 @@
lastName: '', lastName: '',
confirmPassword: '', confirmPassword: '',
phone: '', phone: '',
dateOfBirth: '' dateOfBirth: '',
referralCode: ''
}); });
// Validation state // Validation state
@@ -53,7 +54,8 @@
lastName: '', lastName: '',
confirmPassword: '', confirmPassword: '',
phone: '', phone: '',
dateOfBirth: '' dateOfBirth: '',
referralCode: ''
}; };
validationErrors = { validationErrors = {
email: '', email: '',
@@ -171,6 +173,37 @@
return isValid; return isValid;
} }
/**
* Formats referral code input into 3 blocks of 4 characters (xxxx-xxxx-xxxx).
* Handles paste, backspace, and partial inputs gracefully.
*/
function handleReferralInput(e: Event) {
const target = e.target as HTMLInputElement;
// 1. Clean the input: keep only alphanumeric characters
let raw = target.value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
// 2. Limit to 12 characters
if (raw.length > 12) {
raw = raw.slice(0, 12);
}
// 3. Reconstruct with dashes
let formatted = '';
if (raw.length > 0) {
formatted += raw.slice(0, 4);
}
if (raw.length > 4) {
formatted += '-' + raw.slice(4, 8);
}
if (raw.length > 8) {
formatted += '-' + raw.slice(8, 12);
}
// 4. Update state
formData.referralCode = formatted;
}
// Normalize data before sending // Normalize data before sending
function normalizeFormData() { function normalizeFormData() {
return { return {
@@ -180,6 +213,8 @@
password: formData.password, password: formData.password,
phone: formData.phone.replace(/[\s\-()]/g, ''), // Remove formatting phone: formData.phone.replace(/[\s\-()]/g, ''), // Remove formatting
dateOfBirth: formData.dateOfBirth.trim(), dateOfBirth: formData.dateOfBirth.trim(),
// Strip dashes to send raw 12 characters
referralCode: formData.referralCode.replace(/-/g, '').trim() || undefined,
agreedToPolicy: agreedToPolicy agreedToPolicy: agreedToPolicy
}; };
} }
@@ -202,8 +237,17 @@
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
localStorage.setItem('authToken', data.token); localStorage.setItem('authToken', data.token);
// Decode token to check role for redirect
let redirectTo = '/';
try {
const payload = JSON.parse(atob(data.token.split('.')[1]));
if (payload.role === 'admin') {
redirectTo = '/today';
}
} catch {}
toast.success('Successfully logged in!', { id: loadingToast }); toast.success('Successfully logged in!', { id: loadingToast });
window.location.href = '/'; window.location.href = redirectTo;
} else if (response.status === 409) { } else if (response.status === 409) {
toast.error('Login already in progress. Please wait.', { id: loadingToast }); toast.error('Login already in progress. Please wait.', { id: loadingToast });
} else if (response.status === 401) { } else if (response.status === 401) {
@@ -361,146 +405,209 @@
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
}} }}
class="space-y-4" class="space-y-6"
> >
{#if !isLogin} {#if !isLogin}
<!-- Registration Fields --> <!-- SECTION 1: LOGIN DETAILS -->
<div class="grid grid-cols-2 gap-4"> <div class="space-y-4">
<h3 class="text-lg font-semibold">What we need to log you in</h3>
<div class="space-y-2"> <div class="space-y-2">
<RequiredLabel forId="firstName" text="First Name" /> <RequiredLabel forId="email" text="Email" />
<Input <Input
id="firstName" id="email"
placeholder="John" type="email"
maxlength={50} placeholder="john@example.com"
bind:value={formData.firstName} maxlength={255}
onblur={() => (formData.firstName = formData.firstName.trim())} bind:value={formData.email}
onblur={() => validateEmail(formData.email)}
required required
/> />
{#if validationErrors.email}
<p class="text-sm text-red-500">{validationErrors.email}</p>
{/if}
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<RequiredLabel forId="lastName" text="Last Name" /> <RequiredLabel forId="password" text="Password" />
<Input <Input
id="lastName" id="password"
placeholder="Doe" type="password"
maxlength={50} placeholder="Enter your password"
bind:value={formData.lastName} maxlength={72}
onblur={() => (formData.lastName = formData.lastName.trim())} bind:value={formData.password}
required required
/> />
{#if passwordStrength}
<!-- Strength meter bar -->
<div class="mt-1">
<div class="h-2 overflow-hidden rounded bg-gray-200">
<div
class="h-2 transition-all"
style="
width: {(passwordStrength.score + 1) * 20}%;
background-color: {passwordStrength.score < 2
? 'var(--chart-1)'
: passwordStrength.score === 2
? 'orange'
: passwordStrength.score === 3
? 'var(--chart-2)'
: 'var(--chart-2)'};
"
></div>
</div>
<p class="mt-1 text-xs">
{passwordStrength.feedback.warning
? passwordStrength.feedback.warning
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][passwordStrength.score]}`}
</p>
{#if passwordStrength.feedback.suggestions.length > 0}
<ul class="mt-1 ml-4 list-disc text-xs text-muted-foreground">
{#each passwordStrength.feedback.suggestions as suggestion (suggestion)}
<li>{suggestion}</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<div class="space-y-2">
<RequiredLabel forId="confirmPassword" text="Confirm Password" />
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
bind:value={formData.confirmPassword}
required
/>
{#if formData.confirmPassword && formData.password !== formData.confirmPassword}
<p class="mt-1 text-sm text-red-500">Passwords do not match</p>
{/if}
</div> </div>
</div> </div>
<div class="space-y-2"> <Separator class="w-full" />
<RequiredLabel forId="phone" text="Phone Number" />
<Input <!-- SECTION 2: CONTACT DETAILS -->
id="phone" <div class="space-y-4">
type="tel" <h3 class="text-lg font-semibold">What we need to know about you</h3>
placeholder="07123 456789 or +44 7123 456789"
maxlength={20} <div class="grid grid-cols-2 gap-4">
value={formData.phone} <div class="space-y-2">
oninput={handlePhoneInput} <RequiredLabel forId="firstName" text="First Name" />
onblur={() => validatePhone(formData.phone)} <Input
required id="firstName"
/> placeholder="John"
{#if validationErrors.phone} maxlength={50}
<p class="text-sm text-red-500">{validationErrors.phone}</p> bind:value={formData.firstName}
{/if} onblur={() => (formData.firstName = formData.firstName.trim())}
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="lastName" text="Last Name" />
<Input
id="lastName"
placeholder="Doe"
maxlength={50}
bind:value={formData.lastName}
onblur={() => (formData.lastName = formData.lastName.trim())}
required
/>
</div>
</div>
<div class="space-y-2">
<RequiredLabel forId="phone" text="Phone Number" />
<Input
id="phone"
type="tel"
placeholder="07123 456789 or +44 7123 456789"
maxlength={20}
value={formData.phone}
oninput={handlePhoneInput}
onblur={() => validatePhone(formData.phone)}
required
/>
{#if validationErrors.phone}
<p class="text-sm text-red-500">{validationErrors.phone}</p>
{/if}
</div>
<div class="space-y-2">
<RequiredLabel forId="dateOfBirth" text="Date of Birth" />
<Input
id="dateOfBirth"
type="date"
bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)}
max={new SvelteDate(new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16))
.toISOString()
.split('T')[0]}
required
/>
{#if validationErrors.dateOfBirth}
<p class="text-sm text-red-500">{validationErrors.dateOfBirth}</p>
{/if}
</div>
</div> </div>
<div class="space-y-2"> <Separator class="w-full" />
<RequiredLabel forId="dateOfBirth" text="Date of Birth" />
<Input <!-- SECTION 3: REFERRAL -->
id="dateOfBirth" <div class="space-y-4">
type="date" <!-- <h3 class="text-lg font-semibold">Referral</h3> -->
bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)} <div class="space-y-2">
max={new SvelteDate(new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16)) <Label for="referralCode">Referral Code (optional)</Label>
.toISOString() <Input
.split('T')[0]} id="referralCode"
required placeholder="a2c4-e6g8-i0k2"
/> maxlength={14}
{#if validationErrors.dateOfBirth} value={formData.referralCode}
<p class="text-sm text-red-500">{validationErrors.dateOfBirth}</p> oninput={handleReferralInput}
{/if} />
<p class="text-xs text-muted-foreground">
Enter a 12-character referral code if you were referred by an existing customer
</p>
</div>
</div>
{:else}
<!-- Login Fields (Simple inline layout) -->
<div class="space-y-4">
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<Input
id="email"
type="email"
placeholder="john@example.com"
maxlength={255}
bind:value={formData.email}
onblur={() => validateEmail(formData.email)}
required
/>
{#if validationErrors.email}
<p class="text-sm text-red-500">{validationErrors.email}</p>
{/if}
</div>
<div class="space-y-2">
<RequiredLabel forId="password" text="Password" />
<Input
id="password"
type="password"
placeholder="Enter your password"
maxlength={72}
bind:value={formData.password}
required
/>
</div>
</div> </div>
{/if} {/if}
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<Input
id="email"
type="email"
placeholder="john@example.com"
maxlength={255}
bind:value={formData.email}
onblur={() => validateEmail(formData.email)}
required
/>
{#if validationErrors.email}
<p class="text-sm text-red-500">{validationErrors.email}</p>
{/if}
</div>
<div class="space-y-2">
<RequiredLabel forId="password" text="Password" />
<Input
id="password"
type="password"
placeholder="Enter your password"
maxlength={72}
bind:value={formData.password}
required
/>
{#if !isLogin && passwordStrength}
<!-- Strength meter bar -->
<div class="mt-1">
<div class="h-2 overflow-hidden rounded bg-gray-200">
<div
class="h-2 transition-all"
style="
width: {(passwordStrength.score + 1) * 20}%;
background-color: {passwordStrength.score < 2
? 'var(--chart-1)'
: passwordStrength.score === 2
? 'orange'
: passwordStrength.score === 3
? 'var(--chart-2)'
: 'var(--chart-2)'};
"
></div>
</div>
<p class="mt-1 text-xs">
{passwordStrength.feedback.warning
? passwordStrength.feedback.warning
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][passwordStrength.score]}`}
</p>
{#if passwordStrength.feedback.suggestions.length > 0}
<ul class="mt-1 ml-4 list-disc text-xs text-muted-foreground">
{#each passwordStrength.feedback.suggestions as suggestion (suggestion)}
<li>{suggestion}</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{#if !isLogin} {#if !isLogin}
<div class="space-y-2">
<RequiredLabel forId="confirmPassword" text="Confirm Password" />
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
bind:value={formData.confirmPassword}
required
/>
{#if formData.confirmPassword && formData.password !== formData.confirmPassword}
<p class="mt-1 text-sm text-red-500">Passwords do not match</p>
{/if}
</div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Checkbox id="privacy" bind:checked={agreedToPolicy} class="mt-1" /> <Checkbox id="privacy" bind:checked={agreedToPolicy} class="mt-1" />
<div class="text-sm leading-snug"> <div class="text-sm leading-snug">
+12 -13
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
@@ -268,8 +269,8 @@
} }
function formatRelative(iso: string): string { function formatRelative(iso: string): string {
const d = new Date(iso); const d = new SvelteDate(iso);
const now = new Date(); const now = new SvelteDate();
const diffMs = now.getTime() - d.getTime(); const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000); const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60); const diffHr = Math.floor(diffMin / 60);
@@ -289,12 +290,12 @@
} }
function formatBookingDate(iso: string): string { function formatBookingDate(iso: string): string {
const d = new Date(iso); const d = new SvelteDate(iso);
const now = new Date(); const now = new SvelteDate();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today); const tomorrow = new SvelteDate(today);
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()); const bookingDay = new SvelteDate(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000); const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
if (diffDays === 0) return 'Today'; if (diffDays === 0) return 'Today';
@@ -488,13 +489,11 @@
/> />
{/if} {/if}
{#if showBookingModal && selectedBooking} <BookingModal bind:open={showBookingModal} bookingId={selectedBooking?.id ?? ''} />
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking.id} />
{/if}
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} />
{#if showUserModal && selectedUserId}
<UserModal bind:open={showUserModal} userId={selectedUserId} />
{/if}
{#if showEditRequestModal && selectedEditRequest} {#if showEditRequestModal && selectedEditRequest}
<EditRequestModal <EditRequestModal
+144 -115
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
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 { Separator } from '$lib/components/ui/separator/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { formatDuration } from '$lib/utils/format';
type Service = { type Service = {
id: string; id: string;
@@ -18,7 +19,6 @@
let services = $state<Service[]>([]); let services = $state<Service[]>([]);
let servicesLoading = $state(true); let servicesLoading = $state(true);
// Fetch active services for the price list
async function fetchServices() { async function fetchServices() {
servicesLoading = true; servicesLoading = true;
try { try {
@@ -36,152 +36,181 @@
} else { } else {
console.error('Failed to fetch services:', response.status); console.error('Failed to fetch services:', response.status);
toast.error('Failed to load services'); toast.error('Failed to load services');
// Fallback to empty array
services = []; services = [];
} }
} catch (err) { } catch (err) {
console.error('Error fetching services:', err); console.error('Error fetching services:', err);
toast.error('Network error loading services'); toast.error('Network error loading services');
// Fallback to empty array
services = []; services = [];
} finally { } finally {
servicesLoading = false; servicesLoading = false;
} }
} }
// Format duration from minutes to human readable
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours === 0) {
return `${remainingMinutes} minutes`;
} else if (remainingMinutes === 0) {
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
} else {
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
}
}
// Format price as currency
function formatPrice(price: number): string { function formatPrice(price: number): string {
return ${price.toFixed(2)}`; return ${price.toFixed(2)}`;
} }
// Initialize on component mount
$effect(() => { $effect(() => {
fetchServices(); fetchServices();
}); });
</script> </script>
<div class="mx-auto max-w-4xl p-4 md:p-6"> <div class="mx-auto max-w-4xl space-y-6 p-4 md:p-6">
<div class="mb-6 text-center md:mb-8"> <div>
<h1 class="mb-2 text-2xl font-bold md:text-3xl">Our Prices</h1> <h1 class="text-2xl font-bold md:text-3xl">Our Prices</h1>
<p class="text-gray-600">Professional beauty treatments with transparent pricing</p> <p class="text-gray-600">Professional beauty treatments with transparent pricing</p>
</div> </div>
{#if servicesLoading} {#if servicesLoading}
<div class="flex justify-center py-12">
<div class="text-center">
<div class="mb-4 text-lg text-gray-600">Loading services...</div>
<div
class="border-primary inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"
></div>
</div>
</div>
{:else if services.length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-lg text-gray-600">No services available at the moment.</div>
<Button href="/contact" variant="outline">Contact Us</Button>
</div>
{:else}
<div class="space-y-4"> <div class="space-y-4">
{#each services as service, index} {#each Array(5) as _, i (i)}
<!-- Service item layout --> <Card.Root>
<div <Card.Content>
class="border-muted/40 bg-secondary/50 hover:bg-secondary/80 rounded-lg border p-4 transition-colors" <div class="flex items-start justify-between">
> <div class="flex-1 space-y-2">
<div class="flex items-start justify-between"> <Skeleton class="h-5 w-40" />
<div class="min-w-0 flex-1"> <Skeleton class="h-4 w-64" />
<h4 class="text-foreground font-semibold">{service.name}</h4> <Skeleton class="h-4 w-24" />
<p class="text-muted-foreground mt-1 text-sm">{service.description}</p> </div>
<!-- Duration shown on mobile --> <div class="space-y-2 text-right">
<p class="text-muted-foreground mt-2 text-sm md:hidden"> <Skeleton class="ml-auto h-6 w-20" />
<span class="inline-flex items-center"> <Skeleton class="ml-auto h-4 w-16" />
<svg class="text-primary/70 mr-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V5z"
clip-rule="evenodd"
/>
</svg>
{formatDuration(service.duration_minutes)}
</span>
</p>
{#if service.patch_test_duration_hours > 0}
<p class="mt-1 text-xs text-amber-600">
Patch test required {service.patch_test_duration_hours}h before appointment
</p>
{/if}
{#if service.minimum_age_required > 0}
<p class="mt-1 text-xs text-blue-600">
Minimum age: {service.minimum_age_required} years
</p>
{/if}
</div>
<div class="ml-4 text-right">
<!-- Price always visible -->
<div class="text-primary text-lg font-semibold">{formatPrice(service.price)}</div>
<!-- Duration shown on desktop -->
<div class="text-muted-foreground hidden text-sm md:block">
{formatDuration(service.duration_minutes)}
</div> </div>
</div> </div>
</div> </Card.Content>
</div> </Card.Root>
<!-- Separator between services (except last one) -->
{#if index < services.length - 1}
<Separator class="bg-muted/60" />
{/if}
{/each} {/each}
</div> </div>
{:else if services.length === 0}
<!-- Important Information --> <Card.Root>
<Card.Root <Card.Content>
class="mt-6 border-amber-200/60 bg-gradient-to-r from-amber-50 to-amber-100/50 md:mt-8" <div class="py-12 text-center">
> <div class="mb-4 text-lg text-gray-600">No services available at the moment.</div>
<Card.Content class="pt-4 md:pt-6"> <Button href="/contact" variant="outline">Contact Us</Button>
<div class="space-y-2 text-sm text-amber-900">
<h4 class="font-semibold text-amber-800">Important Information:</h4>
<ul class="space-y-1 pl-4">
<li>
• Prices and durations are estimates and may vary based on individual requirements
</li>
<li>• Patch tests are required 24-48 hours before certain treatments</li>
<li>• 24 hours notice required for cancellations</li>
<li>• Payment is due at the time of service</li>
<li>• Deposit required for new and guest accounts</li>
</ul>
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{:else}
<div class="space-y-4">
{#each services as service (service.id)}
<Card.Root class="transition-colors hover:bg-fuchsia-50">
<Card.Content>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h4 class="text-lg font-semibold text-foreground">{service.name}</h4>
<p class="mt-1 text-sm text-muted-foreground">{service.description}</p>
<!-- Call to Action --> <div class="mt-3 flex flex-wrap gap-2">
<div {#if service.patch_test_duration_hours > 0}
class="from-primary/5 to-secondary/30 mt-6 rounded-xl bg-gradient-to-br p-6 text-center md:mt-8 md:p-8" <span
> class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-muted-foreground"
<h2 class="text-primary mb-4 text-lg font-semibold md:text-xl">Ready to Book?</h2> >
<div class="flex flex-col gap-3 md:flex-row md:justify-center md:gap-4"> <svg
<Button href="/book" class="px-6 py-3 md:px-8">Book Appointment</Button> xmlns="http://www.w3.org/2000/svg"
<Button class="h-3.5 w-3.5"
href="/contact" viewBox="0 0 20 20"
variant="outline" fill="currentColor"
class="border-primary/30 hover:bg-primary/10 px-6 py-3 md:px-8" >
> <path
Get in Touch fill-rule="evenodd"
</Button> d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
</div> clip-rule="evenodd"
/>
</svg>
Patch test required {service.patch_test_duration_hours}h before
</span>
{/if}
{#if service.minimum_age_required > 0}
<span
class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-muted-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd"
/>
</svg>
Minimum age: {service.minimum_age_required} years
</span>
{/if}
</div>
</div>
<div class="shrink-0 text-right">
<div class="text-lg font-semibold text-primary">{formatPrice(service.price)}</div>
<div class="text-sm text-muted-foreground">
{formatDuration(service.duration_minutes)}
</div>
</div>
</div>
</Card.Content>
</Card.Root>
{/each}
</div> </div>
<Card.Root class="border-amber-200 bg-amber-50">
<Card.Header>
<Card.Title class="flex items-center gap-2 text-amber-800">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
Important Information
</Card.Title>
</Card.Header>
<Card.Content>
<ul class="space-y-2 text-sm text-amber-900">
<li>
<span class="font-medium">Prices and durations</span> are estimates and may vary based on
individual requirements
</li>
<li>
<span class="font-medium">Patch tests</span> are required 2448 hours before certain treatments
— look for the amber badge above
</li>
<li>
<span class="font-medium">24 hours notice</span> required for cancellations but do get in
contact with us directly for exceptional circumstances
</li>
<li>
<span class="font-medium">Payment</span> is due at the time of service
</li>
<li>
<span class="font-medium">Deposit</span> may be required for frequent no-shows
</li>
</ul>
</Card.Content>
</Card.Root>
<Card.Root class="bg-gradient-to-br from-primary/5 to-secondary/30">
<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>
<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="/contact"
variant="outline"
class="border-primary/30 px-6 py-3 hover:bg-primary/10 md:px-8"
>
Get in Touch
</Button>
</div>
</Card.Content>
</Card.Root>
{/if} {/if}
</div> </div>
+5 -5
View File
@@ -36,7 +36,7 @@
async function fetchBookings() { async function fetchBookings() {
loading = true; loading = true;
try { try {
const today = new Date().toISOString().split('T')[0]; const today = new SvelteDate().toISOString().split('T')[0];
const response = await fetch(`/api/bookings?start_date=${today}&per_page=50&page=1`, { const response = await fetch(`/api/bookings?start_date=${today}&per_page=50&page=1`, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -50,16 +50,16 @@
} }
const data = await response.json(); const data = await response.json();
const now = new Date(); const now = new SvelteDate();
bookings = (data.bookings || []) bookings = (data.bookings || [])
.filter((b: any) => { .filter((b: any) => {
const startTime = new Date(b.start_time); const startTime = new SvelteDate(b.start_time);
const endTime = new Date(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) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime() (a: any, b: any) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
); );
} catch (err) { } catch (err) {
console.error('Error fetching bookings:', err); console.error('Error fetching bookings:', err);
+4 -4
View File
@@ -206,16 +206,16 @@
return; return;
} }
const now = new Date(); const now = new SvelteDate();
const sorted = bookings.sort((a, b) => { const sorted = bookings.sort((a, b) => {
const dateA = new Date(a.start_time).getTime(); const dateA = new SvelteDate(a.start_time).getTime();
const dateB = new Date(b.start_time).getTime(); const dateB = new SvelteDate(b.start_time).getTime();
return dateB - dateA; return dateB - dateA;
}); });
// Find most recent past booking (start_time <= now) // Find most recent past booking (start_time <= now)
const pastBooking = sorted.find((b) => { const pastBooking = sorted.find((b) => {
const startTime = new Date(b.start_time); const startTime = new SvelteDate(b.start_time);
return startTime <= now; return startTime <= now;
}); });
+15 -21
View File
@@ -119,13 +119,13 @@
<!-- quick booking Grid --> <!-- quick booking Grid -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3 lg:gap-6"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-3 lg:gap-6">
<!-- Left Column: Create a booking for a walk-in customer (2/3 width on large screens, half on mobile) --> <!-- Left Column: Create a booking for a walk-in customer (full width on mobile, 2/3 on large) -->
<div class="col-span-1 lg:col-span-2"> <div class="col-span-2 sm:col-span-1 lg:col-span-2">
<WalkInBooking /> <WalkInBooking />
</div> </div>
<!-- Right Column: Call-in / Social messaging booking (1/3 width on large screens, half on mobile) --> <!-- Right Column: Call-in / Social messaging booking (1/3 width on large screens, half on sm+) -->
<div class="col-span-1"> <div class="col-span-2 sm:col-span-1">
<CallInBooking /> <CallInBooking />
</div> </div>
</div> </div>
@@ -161,23 +161,17 @@
</div> </div>
<!-- Modals --> <!-- Modals -->
{#if showBookingModal && selectedBookingId} <BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
{/if}
{#if showEditBookingModal && selectedBookingId} <EditBookingModal
<EditBookingModal bind:open={showEditBookingModal}
bind:open={showEditBookingModal} bookingId={selectedBookingId ?? ''}
bookingId={selectedBookingId} nextAppointmentStart={editBookingNextStart}
nextAppointmentStart={editBookingNextStart} onSaved={() => {
onSaved={() => { // Refresh the page data after save
// Refresh the page data after save showBookingModal = false;
showBookingModal = false; }}
}} />
/>
{/if}
{#if showUserModal && selectedUserId} <UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
<UserModal bind:open={showUserModal} userId={selectedUserId} {openBookingModal} />
{/if}
{/if} {/if}