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">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import PortfolioCarousel from '$lib/components/layout/PortfolioCarousel.svelte';
import { authStore } from '$lib/stores/auth.svelte';
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 = [
(name: string) => `Welcome back, ${name}!`,
(name: string) => `Great to see you again, ${name}!`,
+10 -24
View File
@@ -470,7 +470,7 @@
loadingUpcoming = true;
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
const response = await fetch(`/api/bookings?start_date=${today}&per_page=10&page=1`, {
@@ -488,13 +488,13 @@
}
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
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)
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;
});
@@ -514,7 +514,7 @@
loadingPast = true;
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}`, {
method: 'GET',
headers: {
@@ -555,7 +555,7 @@
if (!aUnpaid && bUnpaid) return 1;
// 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;
@@ -1217,22 +1217,9 @@
<Skeleton class="h-32 w-full" />
{:else if userData?.referralCode}
<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
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>
<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>
<Button onclick={copyReferralCode} variant="outline" class="w-full">
<svg
@@ -1802,9 +1789,8 @@
{/if}
<!-- User Booking Modal -->
{#if showBookingModal && selectedBookingId}
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
{/if}
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
<style>
/* Mobile Tab Menu Styles */
+4 -6
View File
@@ -283,11 +283,9 @@
</div>
<!-- Modals -->
{#if showUserModal && selectedUserId}
<UserModal bind:open={showUserModal} userId={selectedUserId} {openBookingModal} />
{/if}
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} onReschedule={handleReschedule} />
{#if showBookingModal && selectedBookingId}
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId} onReschedule={handleReschedule} />
{/if}
{/if}
+230 -123
View File
@@ -33,7 +33,8 @@
lastName: '',
confirmPassword: '',
phone: '',
dateOfBirth: ''
dateOfBirth: '',
referralCode: ''
});
// Validation state
@@ -53,7 +54,8 @@
lastName: '',
confirmPassword: '',
phone: '',
dateOfBirth: ''
dateOfBirth: '',
referralCode: ''
};
validationErrors = {
email: '',
@@ -171,6 +173,37 @@
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
function normalizeFormData() {
return {
@@ -180,6 +213,8 @@
password: formData.password,
phone: formData.phone.replace(/[\s\-()]/g, ''), // Remove formatting
dateOfBirth: formData.dateOfBirth.trim(),
// Strip dashes to send raw 12 characters
referralCode: formData.referralCode.replace(/-/g, '').trim() || undefined,
agreedToPolicy: agreedToPolicy
};
}
@@ -202,8 +237,17 @@
if (response.ok) {
const data = await response.json();
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 });
window.location.href = '/';
window.location.href = redirectTo;
} else if (response.status === 409) {
toast.error('Login already in progress. Please wait.', { id: loadingToast });
} else if (response.status === 401) {
@@ -361,146 +405,209 @@
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
class="space-y-6"
>
{#if !isLogin}
<!-- Registration Fields -->
<div class="grid grid-cols-2 gap-4">
<!-- SECTION 1: LOGIN DETAILS -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">What we need to log you in</h3>
<div class="space-y-2">
<RequiredLabel forId="firstName" text="First Name" />
<RequiredLabel forId="email" text="Email" />
<Input
id="firstName"
placeholder="John"
maxlength={50}
bind:value={formData.firstName}
onblur={() => (formData.firstName = formData.firstName.trim())}
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="lastName" text="Last Name" />
<RequiredLabel forId="password" text="Password" />
<Input
id="lastName"
placeholder="Doe"
maxlength={50}
bind:value={formData.lastName}
onblur={() => (formData.lastName = formData.lastName.trim())}
id="password"
type="password"
placeholder="Enter your password"
maxlength={72}
bind:value={formData.password}
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 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}
<Separator class="w-full" />
<!-- SECTION 2: CONTACT DETAILS -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">What we need to know about you</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<RequiredLabel forId="firstName" text="First Name" />
<Input
id="firstName"
placeholder="John"
maxlength={50}
bind:value={formData.firstName}
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 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}
<Separator class="w-full" />
<!-- SECTION 3: REFERRAL -->
<div class="space-y-4">
<!-- <h3 class="text-lg font-semibold">Referral</h3> -->
<div class="space-y-2">
<Label for="referralCode">Referral Code (optional)</Label>
<Input
id="referralCode"
placeholder="a2c4-e6g8-i0k2"
maxlength={14}
value={formData.referralCode}
oninput={handleReferralInput}
/>
<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>
{/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}
<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">
<Checkbox id="privacy" bind:checked={agreedToPolicy} class="mt-1" />
<div class="text-sm leading-snug">
+12 -13
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
@@ -268,8 +269,8 @@
}
function formatRelative(iso: string): string {
const d = new Date(iso);
const now = new Date();
const d = new SvelteDate(iso);
const now = new SvelteDate();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
@@ -289,12 +290,12 @@
}
function formatBookingDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
const d = new SvelteDate(iso);
const now = new SvelteDate();
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new SvelteDate(today);
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);
if (diffDays === 0) return 'Today';
@@ -488,13 +489,11 @@
/>
{/if}
{#if showBookingModal && selectedBooking}
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking.id} />
{/if}
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking?.id ?? ''} />
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} />
{#if showUserModal && selectedUserId}
<UserModal bind:open={showUserModal} userId={selectedUserId} />
{/if}
{#if showEditRequestModal && selectedEditRequest}
<EditRequestModal
+144 -115
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/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 { authStore } from '$lib/stores/auth.svelte';
import { formatDuration } from '$lib/utils/format';
type Service = {
id: string;
@@ -18,7 +19,6 @@
let services = $state<Service[]>([]);
let servicesLoading = $state(true);
// Fetch active services for the price list
async function fetchServices() {
servicesLoading = true;
try {
@@ -36,152 +36,181 @@
} else {
console.error('Failed to fetch services:', response.status);
toast.error('Failed to load services');
// Fallback to empty array
services = [];
}
} catch (err) {
console.error('Error fetching services:', err);
toast.error('Network error loading services');
// Fallback to empty array
services = [];
} finally {
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 {
return ${price.toFixed(2)}`;
}
// Initialize on component mount
$effect(() => {
fetchServices();
});
</script>
<div class="mx-auto max-w-4xl p-4 md:p-6">
<div class="mb-6 text-center md:mb-8">
<h1 class="mb-2 text-2xl font-bold md:text-3xl">Our Prices</h1>
<div class="mx-auto max-w-4xl space-y-6 p-4 md:p-6">
<div>
<h1 class="text-2xl font-bold md:text-3xl">Our Prices</h1>
<p class="text-gray-600">Professional beauty treatments with transparent pricing</p>
</div>
{#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">
{#each services as service, index}
<!-- Service item layout -->
<div
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="min-w-0 flex-1">
<h4 class="text-foreground font-semibold">{service.name}</h4>
<p class="text-muted-foreground mt-1 text-sm">{service.description}</p>
<!-- Duration shown on mobile -->
<p class="text-muted-foreground mt-2 text-sm md:hidden">
<span class="inline-flex items-center">
<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)}
{#each Array(5) as _, i (i)}
<Card.Root>
<Card.Content>
<div class="flex items-start justify-between">
<div class="flex-1 space-y-2">
<Skeleton class="h-5 w-40" />
<Skeleton class="h-4 w-64" />
<Skeleton class="h-4 w-24" />
</div>
<div class="space-y-2 text-right">
<Skeleton class="ml-auto h-6 w-20" />
<Skeleton class="ml-auto h-4 w-16" />
</div>
</div>
</div>
</div>
<!-- Separator between services (except last one) -->
{#if index < services.length - 1}
<Separator class="bg-muted/60" />
{/if}
</Card.Content>
</Card.Root>
{/each}
</div>
<!-- Important Information -->
<Card.Root
class="mt-6 border-amber-200/60 bg-gradient-to-r from-amber-50 to-amber-100/50 md:mt-8"
>
<Card.Content class="pt-4 md:pt-6">
<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>
{:else if services.length === 0}
<Card.Root>
<Card.Content>
<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>
</Card.Content>
</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="from-primary/5 to-secondary/30 mt-6 rounded-xl bg-gradient-to-br p-6 text-center md:mt-8 md:p-8"
>
<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">
<Button href="/book" class="px-6 py-3 md:px-8">Book Appointment</Button>
<Button
href="/contact"
variant="outline"
class="border-primary/30 hover:bg-primary/10 px-6 py-3 md:px-8"
>
Get in Touch
</Button>
</div>
<div class="mt-3 flex flex-wrap gap-2">
{#if service.patch_test_duration_hours > 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="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"
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>
<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}
</div>
+5 -5
View File
@@ -36,7 +36,7 @@
async function fetchBookings() {
loading = true;
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`, {
headers: {
'Content-Type': 'application/json',
@@ -50,16 +50,16 @@
}
const data = await response.json();
const now = new Date();
const now = new SvelteDate();
bookings = (data.bookings || [])
.filter((b: any) => {
const startTime = new Date(b.start_time);
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
const startTime = new SvelteDate(b.start_time);
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now;
})
.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) {
console.error('Error fetching bookings:', err);
+4 -4
View File
@@ -206,16 +206,16 @@
return;
}
const now = new Date();
const now = new SvelteDate();
const sorted = bookings.sort((a, b) => {
const dateA = new Date(a.start_time).getTime();
const dateB = new Date(b.start_time).getTime();
const dateA = new SvelteDate(a.start_time).getTime();
const dateB = new SvelteDate(b.start_time).getTime();
return dateB - dateA;
});
// Find most recent past booking (start_time <= now)
const pastBooking = sorted.find((b) => {
const startTime = new Date(b.start_time);
const startTime = new SvelteDate(b.start_time);
return startTime <= now;
});
+15 -21
View File
@@ -119,13 +119,13 @@
<!-- quick booking Grid -->
<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) -->
<div class="col-span-1 lg:col-span-2">
<!-- Left Column: Create a booking for a walk-in customer (full width on mobile, 2/3 on large) -->
<div class="col-span-2 sm:col-span-1 lg:col-span-2">
<WalkInBooking />
</div>
<!-- Right Column: Call-in / Social messaging booking (1/3 width on large screens, half on mobile) -->
<div class="col-span-1">
<!-- Right Column: Call-in / Social messaging booking (1/3 width on large screens, half on sm+) -->
<div class="col-span-2 sm:col-span-1">
<CallInBooking />
</div>
</div>
@@ -161,23 +161,17 @@
</div>
<!-- Modals -->
{#if showBookingModal && selectedBookingId}
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
{/if}
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
{#if showEditBookingModal && selectedBookingId}
<EditBookingModal
bind:open={showEditBookingModal}
bookingId={selectedBookingId}
nextAppointmentStart={editBookingNextStart}
onSaved={() => {
// Refresh the page data after save
showBookingModal = false;
}}
/>
{/if}
<EditBookingModal
bind:open={showEditBookingModal}
bookingId={selectedBookingId ?? ''}
nextAppointmentStart={editBookingNextStart}
onSaved={() => {
// Refresh the page data after save
showBookingModal = false;
}}
/>
{#if showUserModal && selectedUserId}
<UserModal bind:open={showUserModal} userId={selectedUserId} {openBookingModal} />
{/if}
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
{/if}