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">
|
<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}!`,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user