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:
@@ -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}!`,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 24–48 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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user