feat(bookings): improve admin booking wizard and user dashboard
Backend: - Enriched GetAllUserBookings response with calculated total_amount, amount_paid, and duration_minutes. - Refactored GetBookingHandler to return a flat booking object matching frontend expectations. - Added account_role to admin user list response and sorted users by booking activity. - Corrected function name oo to AdminCreateBookingForUserHandler. Frontend: - Rebuilt BookingCreateModal into a 4-step wizard supporting guest bookings, service overrides, and real-time availability checks. - Fixed account dashboard logic to correctly identify upcoming vs past bookings and sort unpaid items to the top. - Extracted booking flow into a shared BookingFlow component. - Redirected admin users from home page to /today.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(), bookingId }: Props = $props();
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
services: Array<{
|
||||
service_name?: string;
|
||||
service_description?: string;
|
||||
price?: number;
|
||||
duration_minutes?: number;
|
||||
}>;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
payment_type: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
invoice_number?: number;
|
||||
is_vat_applicable: boolean;
|
||||
vat_amount?: number;
|
||||
net_amount?: number;
|
||||
vat_rate?: number;
|
||||
}>;
|
||||
total_amount: number;
|
||||
amount_paid: number;
|
||||
amount_due: number;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
let selectedBooking = $state<Booking | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
let totalDuration = $derived(
|
||||
selectedBooking?.services?.reduce((sum, service) => sum + (service.duration_minutes || 0), 0) ||
|
||||
0
|
||||
);
|
||||
|
||||
async function fetchBookingDetails() {
|
||||
if (!bookingId) return;
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/bookings/${bookingId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
selectedBooking = data;
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load booking: ' + text);
|
||||
open = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching booking:', err);
|
||||
toast.error('Network error');
|
||||
open = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
setTimeout(() => (selectedBooking = null), 200);
|
||||
} else if (bookingId && !selectedBooking) {
|
||||
fetchBookingDetails();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-3xl">
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Modal.Title class="text-lg font-semibold">Booking Details</Modal.Title>
|
||||
{#if selectedBooking}
|
||||
<div class="mt-1 text-sm text-gray-500">ID: {selectedBooking.id}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedBooking}
|
||||
<!-- Logic: Only show chip if Booking is Future OR (Past AND Unpaid) -->
|
||||
{@const isPastBooking = new Date(selectedBooking.start_time) < new Date()}
|
||||
{@const isUnpaid = selectedBooking.amount_due > 0}
|
||||
{@const showChip = !isPastBooking || isUnpaid}
|
||||
|
||||
{#if showChip}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
||||
{isPastBooking
|
||||
? 'bg-red-100 text-red-800' // Red if past & unpaid
|
||||
: selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: selectedBooking.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center p-8 text-gray-500">Loading...</div>
|
||||
{:else if selectedBooking}
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Appointment Details -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Appointment Details
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
||||
<div class="font-medium">
|
||||
{(() => {
|
||||
const date = new SvelteDate(selectedBooking.start_time);
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Duration</div>
|
||||
<div class="font-medium">{totalDuration} minutes</div>
|
||||
</div>
|
||||
{#if selectedBooking.notes}
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-xs text-gray-500">Notes</div>
|
||||
<div class="mt-1 rounded-md border border-gray-300 bg-white p-2 text-sm">
|
||||
{selectedBooking.notes}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
{#if selectedBooking.services && selectedBooking.services.length > 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Services
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each selectedBooking.services as service, index (index)}
|
||||
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="font-medium">{service.service_name || '—'}</div>
|
||||
{#if service.service_description}
|
||||
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">{service.duration_minutes} min</span>
|
||||
<span class="font-semibold">£{service.price?.toFixed(2) || '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Financial Summary -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Financial Summary
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Total Amount</span>
|
||||
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Amount Paid</span>
|
||||
<span class="font-semibold text-green-700"
|
||||
>£{selectedBooking.amount_paid.toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
|
||||
<span class="font-medium text-gray-900">Amount Due</span>
|
||||
<span
|
||||
class="text-lg font-bold {selectedBooking.amount_due > 0
|
||||
? 'text-red-600'
|
||||
: 'text-green-600'}"
|
||||
>
|
||||
£{selectedBooking.amount_due.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
{#if selectedBooking.payments && selectedBooking.payments.length > 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Payment History
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each selectedBooking.payments as payment (payment.id)}
|
||||
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium capitalize"
|
||||
>{payment.payment_method.replace('_', ' ')}</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{payment.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: payment.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{payment.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{payment.payment_type.charAt(0).toUpperCase() +
|
||||
payment.payment_type.slice(1)}
|
||||
</div>
|
||||
{#if payment.is_vat_applicable}
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
|
||||
{#if payment.vat_amount}
|
||||
<div>
|
||||
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount.toFixed(
|
||||
2
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new SvelteDate(payment.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right font-semibold">
|
||||
£{payment.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button onclick={() => (open = false)}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
@@ -262,7 +262,7 @@
|
||||
<Textarea
|
||||
id="booking-notes"
|
||||
bind:value={notes}
|
||||
placeholder="Add any notes about this booking... (client will see this)"
|
||||
placeholder="Add any notes about this booking... (client will see this, appears on receipt)"
|
||||
rows={3}
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import BookingCreateModal from '$lib/components/admin/BookingCreateModal.svelte';
|
||||
|
||||
let showCreateModal = false;
|
||||
let selectedUserId: string | null = null;
|
||||
|
||||
function openForUser(userId: string) {
|
||||
selectedUserId = userId;
|
||||
showCreateModal = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Call-In / Walk-In Booking
|
||||
Call-In / Social Messaging Booking
|
||||
</h3>
|
||||
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
Create and confirm a booking immediately while speaking with the client.
|
||||
Create a booking and check timeslots for a discussed appointed
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -31,5 +20,5 @@
|
||||
</div>
|
||||
|
||||
{#if showCreateModal}
|
||||
<BookingCreateModal bind:open={showCreateModal} initialUserId={selectedUserId} />
|
||||
<BookingCreateModal bind:open={showCreateModal} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import WalkInCreateModal from '$lib/components/admin/WalkInCreateModal.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import type { AvailableHoursDay } from '$lib/types/booking';
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let slotInfo = $state<{
|
||||
isAvailableNow: boolean;
|
||||
waitMinutes?: number;
|
||||
durationMinutes: number;
|
||||
startTime?: string;
|
||||
slotEndMinutes?: number; // Store for live countdown
|
||||
} | null>(null);
|
||||
let loading = $state(true);
|
||||
let noSlotsToday = $state(false);
|
||||
let currentTime = $state(new Date());
|
||||
|
||||
onMount(() => {
|
||||
calculateSlotAvailability();
|
||||
|
||||
// Update current time every minute for live countdown
|
||||
const interval = setInterval(() => {
|
||||
currentTime = new Date();
|
||||
}, 60000); // Update every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts "HH:MM" or "HH:MM:SS" time string to minutes since midnight
|
||||
*/
|
||||
function timeToMinutes(time: string): number {
|
||||
const parts = time.split(':').map(Number);
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts minutes to hours and minutes for display
|
||||
*/
|
||||
function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours > 0 && mins > 0) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''}, ${mins} minute${mins !== 1 ? 's' : ''}`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''}`;
|
||||
} else {
|
||||
return `${mins} minute${mins !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate live remaining time based on current time
|
||||
*/
|
||||
function getLiveRemainingMinutes(): number | null {
|
||||
if (!slotInfo?.isAvailableNow || !slotInfo.slotEndMinutes) return null;
|
||||
|
||||
const now = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||
const remaining = slotInfo.slotEndMinutes - now;
|
||||
return Math.max(0, remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate live wait time based on current time
|
||||
*/
|
||||
function getLiveWaitMinutes(): number | null {
|
||||
if (slotInfo?.isAvailableNow || !slotInfo?.startTime) return null;
|
||||
|
||||
const now = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||
const slotStartMinutes = timeToMinutes(slotInfo.startTime);
|
||||
const wait = slotStartMinutes - now;
|
||||
return Math.max(0, wait);
|
||||
}
|
||||
|
||||
async function calculateSlotAvailability() {
|
||||
loading = true;
|
||||
noSlotsToday = false;
|
||||
|
||||
try {
|
||||
const now = new SvelteDate();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
|
||||
// Fetch today's available hours
|
||||
const response = await fetch(`/api/scheduling/available-hours?start=${today}&end=${today}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
noSlotsToday = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const data: AvailableHoursDay[] = await response.json();
|
||||
const todayData = data[0];
|
||||
|
||||
if (!todayData || !todayData.isOpen || !todayData.slots || todayData.slots.length === 0) {
|
||||
noSlotsToday = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Current time in minutes since midnight
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
// Check if we're currently in an available slot
|
||||
for (const slot of todayData.slots) {
|
||||
const slotStartMinutes = timeToMinutes(slot.startTime);
|
||||
const slotEndMinutes = timeToMinutes(slot.endTime);
|
||||
|
||||
// Are we currently within this slot?
|
||||
if (currentMinutes >= slotStartMinutes && currentMinutes < slotEndMinutes) {
|
||||
const remainingMinutes = slotEndMinutes - currentMinutes;
|
||||
slotInfo = {
|
||||
isAvailableNow: true,
|
||||
durationMinutes: remainingMinutes,
|
||||
slotEndMinutes: slotEndMinutes // Store for live countdown
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Is this a future slot?
|
||||
if (slotStartMinutes > currentMinutes) {
|
||||
const waitMinutes = slotStartMinutes - currentMinutes;
|
||||
const durationMinutes = slotEndMinutes - slotStartMinutes;
|
||||
slotInfo = {
|
||||
isAvailableNow: false,
|
||||
waitMinutes,
|
||||
durationMinutes,
|
||||
startTime: slot.startTime
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No current or future slots available
|
||||
noSlotsToday = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to calculate slot availability', err);
|
||||
noSlotsToday = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">Walk-In Booking</h3>
|
||||
|
||||
{#if loading}
|
||||
<div class="mb-4 h-12 animate-pulse rounded bg-gray-100"></div>
|
||||
{:else if noSlotsToday}
|
||||
<p class="mb-4 text-sm text-gray-500">No slots available for walk-in today</p>
|
||||
{:else if slotInfo?.isAvailableNow}
|
||||
{@const liveRemaining = getLiveRemainingMinutes()}
|
||||
{#if liveRemaining !== null && liveRemaining > 0}
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
Available now for <span class="font-semibold text-gray-700"
|
||||
>{formatDuration(liveRemaining)}</span
|
||||
>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mb-4 text-sm text-gray-500">No slots available for walk-in today</p>
|
||||
{/if}
|
||||
{:else if slotInfo && !slotInfo.isAvailableNow}
|
||||
{@const liveWait = getLiveWaitMinutes()}
|
||||
{#if liveWait !== null && liveWait > 0}
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
Next slot available in <span class="font-semibold text-gray-700"
|
||||
>{formatDuration(liveWait)}</span
|
||||
>
|
||||
at {formatTime(slotInfo.startTime!)}, for
|
||||
<span class="font-semibold text-gray-700">{formatDuration(slotInfo.durationMinutes)}</span>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
Available now for <span class="font-semibold text-gray-700"
|
||||
>{formatDuration(slotInfo.durationMinutes)}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
onclick={() => (showCreateModal = true)}
|
||||
disabled={noSlotsToday || (slotInfo?.isAvailableNow && (getLiveRemainingMinutes() ?? 0) <= 0)}
|
||||
>
|
||||
Start Walk-In Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showCreateModal}
|
||||
<WalkInCreateModal
|
||||
bind:open={showCreateModal}
|
||||
maxSlotDuration={slotInfo?.durationMinutes ?? 0}
|
||||
availableStartTime={slotInfo?.isAvailableNow ? undefined : slotInfo?.startTime}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,718 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { getLocalTimeZone } from '@internationalized/date';
|
||||
|
||||
// UI Components
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
// Booking Components
|
||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||
|
||||
// Types
|
||||
import type { Service } from '$lib/types/booking';
|
||||
|
||||
// =============== Props ===============
|
||||
interface Props {
|
||||
open: boolean;
|
||||
maxSlotDuration?: number;
|
||||
availableStartTime?: string; // "HH:MM" or "HH:MM:SS" format from the available slot
|
||||
onBookingCreated?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
maxSlotDuration = 0,
|
||||
availableStartTime,
|
||||
onBookingCreated
|
||||
}: Props = $props();
|
||||
|
||||
// =============== State ===============
|
||||
let currentStep = $state(1);
|
||||
|
||||
// Step 1: Customer Selection
|
||||
let userType = $state<'member' | 'guest'>('member');
|
||||
let userQuery = $state('');
|
||||
let users = $state<
|
||||
Array<{ id: string; full_name: string; email?: string; phone?: string; account_role: string }>
|
||||
>([]);
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let guestName = $state('');
|
||||
let guestPhone = $state('');
|
||||
let loadingUsers = $state(false);
|
||||
|
||||
// Step 2: Services
|
||||
let services = $state<Service[]>([]);
|
||||
let selectedServices = $state<Service[]>([]);
|
||||
let loadingServices = $state(true);
|
||||
|
||||
// Step 3: Service Overrides & Notes
|
||||
let notes = $state('');
|
||||
let serviceOverrides = $state<
|
||||
Record<
|
||||
string,
|
||||
{ price: string; duration: string; originalPrice: number; originalDuration: number }
|
||||
>
|
||||
>({});
|
||||
|
||||
let submitting = $state(false);
|
||||
|
||||
// =============== Derived Helpers ===============
|
||||
function getTotalDuration() {
|
||||
return selectedServices.reduce((total, service) => {
|
||||
const override = serviceOverrides[service.id];
|
||||
const duration =
|
||||
override && override.duration ? parseInt(override.duration) : service.duration_minutes;
|
||||
return total + (isNaN(duration) ? 0 : duration);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getTotalPrice() {
|
||||
return selectedServices.reduce((total, service) => {
|
||||
const override = serviceOverrides[service.id];
|
||||
const price = override && override.price ? parseFloat(override.price) : service.price;
|
||||
return total + (isNaN(price) ? 0 : price);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0 && mins > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
||||
|
||||
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration);
|
||||
|
||||
const canProceedStep1 = $derived(
|
||||
userType === 'member' ? !!selectedUserId : !!(guestName.trim() && guestPhone.trim())
|
||||
);
|
||||
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
|
||||
|
||||
// =============== Effects ===============
|
||||
let wasOpen = false;
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
resetState();
|
||||
fetchServices();
|
||||
fetchUsers();
|
||||
}
|
||||
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
currentStep = 1;
|
||||
userType = 'member';
|
||||
userQuery = '';
|
||||
users = [];
|
||||
selectedUserId = null;
|
||||
guestName = '';
|
||||
guestPhone = '';
|
||||
selectedServices = [];
|
||||
notes = '';
|
||||
serviceOverrides = {};
|
||||
}
|
||||
|
||||
// =============== Data Fetching ===============
|
||||
async function fetchUsers() {
|
||||
loadingUsers = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/users?page=1&per_page=10&q=${encodeURIComponent(userQuery)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Filter out specific roles
|
||||
const excludedRoles = ['admin', 'guest', 'affiliate'];
|
||||
users = (data.users || []).filter(
|
||||
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users', err);
|
||||
toast.error('Failed to load users');
|
||||
} finally {
|
||||
loadingUsers = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchServices() {
|
||||
loadingServices = true;
|
||||
try {
|
||||
const response = await fetch('/api/services', {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
services = await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch services', err);
|
||||
toast.error('Failed to load services');
|
||||
} finally {
|
||||
loadingServices = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Logic ===============
|
||||
function toggleService(service: Service) {
|
||||
const index = selectedServices.findIndex((s) => s.id === service.id);
|
||||
if (index >= 0) {
|
||||
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||
const newOverrides = { ...serviceOverrides };
|
||||
delete newOverrides[service.id];
|
||||
serviceOverrides = newOverrides;
|
||||
} else {
|
||||
selectedServices = [...selectedServices, service];
|
||||
serviceOverrides = {
|
||||
...serviceOverrides,
|
||||
[service.id]: {
|
||||
price: service.price.toFixed(2),
|
||||
duration: service.duration_minutes.toString(),
|
||||
originalPrice: service.price,
|
||||
originalDuration: service.duration_minutes
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Submission ===============
|
||||
async function submitBooking() {
|
||||
submitting = true;
|
||||
|
||||
try {
|
||||
// Validate duration doesn't exceed available slot
|
||||
if (maxSlotDuration > 0 && getTotalDuration() > maxSlotDuration) {
|
||||
toast.error(
|
||||
`Selected services (${formattedTotalDuration}) exceed available slot (${formatDuration(maxSlotDuration)})`
|
||||
);
|
||||
submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let finalUserId = selectedUserId;
|
||||
|
||||
if (userType === 'guest') {
|
||||
// TODO: Implement /api/users/guest endpoint
|
||||
// For now, show error
|
||||
toast.error('Guest booking not yet implemented');
|
||||
return;
|
||||
|
||||
// const createRes = await fetch('/api/users/guest', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// Authorization: `Bearer ${authStore.currentToken}`
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// name: guestName,
|
||||
// phone: guestPhone
|
||||
// })
|
||||
// });
|
||||
|
||||
// if (!createRes.ok) {
|
||||
// toast.error('Failed to create guest user');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const guestUser = await createRes.json();
|
||||
// finalUserId = guestUser.id;
|
||||
}
|
||||
|
||||
if (!finalUserId) throw new Error('User ID required');
|
||||
|
||||
// Use the available slot start time from the widget
|
||||
let start: Date;
|
||||
|
||||
if (availableStartTime) {
|
||||
// Parse the time from the widget (format: "HH:MM" or "HH:MM:SS")
|
||||
const [hours, minutes] = availableStartTime.split(':').map(Number);
|
||||
const now = new SvelteDate();
|
||||
start = new SvelteDate(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
hours,
|
||||
minutes,
|
||||
0,
|
||||
0
|
||||
);
|
||||
} else {
|
||||
// Fallback: Calculate immediate start time (rounded to next 15 min)
|
||||
const now = new SvelteDate();
|
||||
start = new SvelteDate(now);
|
||||
const minutes = start.getMinutes();
|
||||
const remainder = 15 - (minutes % 15);
|
||||
if (remainder !== 15 && remainder !== 0) {
|
||||
start.setMinutes(minutes + remainder);
|
||||
}
|
||||
start.setSeconds(0);
|
||||
start.setMilliseconds(0);
|
||||
}
|
||||
|
||||
const dateTimeStr = start.toISOString();
|
||||
|
||||
const overrides = [];
|
||||
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
|
||||
const priceChanged = Math.abs(parseFloat(data.price) - data.originalPrice) > 0.01;
|
||||
const durationChanged = parseInt(data.duration) !== data.originalDuration;
|
||||
|
||||
if (priceChanged || durationChanged) {
|
||||
overrides.push({
|
||||
service_id: serviceId,
|
||||
override_price: priceChanged ? parseFloat(data.price) : null,
|
||||
override_duration_minutes: durationChanged ? parseInt(data.duration) : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
user_id: finalUserId,
|
||||
start_time: dateTimeStr,
|
||||
service_ids: selectedServices.map((s) => s.id),
|
||||
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||
notes: notes.trim() || null
|
||||
};
|
||||
|
||||
const res = await fetch('/api/admin/bookings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('Booking created successfully!');
|
||||
open = false;
|
||||
onBookingCreated?.();
|
||||
} else {
|
||||
const errorText = await res.text();
|
||||
console.error('Booking creation failed:', errorText);
|
||||
toast.error(`Failed to create booking: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Booking submission error:', err);
|
||||
toast.error('An error occurred while creating booking');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Input handlers
|
||||
function handlePriceInput(serviceId: string, value: string) {
|
||||
const override = serviceOverrides[serviceId];
|
||||
if (!override) return;
|
||||
|
||||
let cleaned = value.replace(/[^\d.]/g, '');
|
||||
const parts = cleaned.split('.');
|
||||
if (parts.length > 2) cleaned = parts[0] + '.' + parts.slice(1).join('');
|
||||
if (cleaned.includes('.')) {
|
||||
const [int, dec] = cleaned.split('.');
|
||||
cleaned = int + '.' + dec.substring(0, 2);
|
||||
}
|
||||
|
||||
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, price: cleaned } };
|
||||
}
|
||||
|
||||
function handleDurationInput(serviceId: string, value: string) {
|
||||
const override = serviceOverrides[serviceId];
|
||||
if (!override) return;
|
||||
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, duration: cleaned } };
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Walk-In Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
Quickly book a walk-in customer with immediate time slot reservation
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="px-6 pb-4">
|
||||
<!-- Step 1: Customer Selection -->
|
||||
{#if currentStep === 1}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Select Customer</Card.Title>
|
||||
<Card.Description>Choose an existing member or create a guest booking</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-6 border-b border-gray-200">
|
||||
<button
|
||||
class="pb-2 text-sm font-medium transition-colors {userType === 'member'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
onclick={() => {
|
||||
userType = 'member';
|
||||
selectedUserId = null;
|
||||
}}
|
||||
>
|
||||
Member
|
||||
</button>
|
||||
<button
|
||||
class="pb-2 text-sm font-medium transition-colors {userType === 'guest'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
onclick={() => {
|
||||
userType = 'guest';
|
||||
guestName = '';
|
||||
guestPhone = '';
|
||||
}}
|
||||
>
|
||||
Guest / Non-Member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if userType === 'member'}
|
||||
<!-- Native Input using oninput to prevent reactivity bugs -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Search by name, email or phone..."
|
||||
value={userQuery}
|
||||
oninput={(e) => {
|
||||
userQuery = e.currentTarget.value;
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
fetchUsers();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button onclick={fetchUsers} disabled={loadingUsers}>
|
||||
{loadingUsers ? '...' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Compact Results List -->
|
||||
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
|
||||
{#if loadingUsers}
|
||||
<div class="space-y-2 p-2">
|
||||
{#each Array(3) as _}
|
||||
<Skeleton class="h-10 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if users.length === 0}
|
||||
<div class="flex items-center justify-center p-8 text-sm text-gray-500">
|
||||
{userQuery
|
||||
? 'No users found. Try a different search.'
|
||||
: 'Search for a user above to get started.'}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{#each users.slice(0, 4) as user (user.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center justify-between px-4 py-3 text-left transition-colors hover:bg-fuchsia-50 {selectedUserId ===
|
||||
user.id
|
||||
? 'bg-fuchsia-100 font-medium'
|
||||
: ''}"
|
||||
onclick={() => (selectedUserId = user.id)}
|
||||
>
|
||||
<div>
|
||||
<div class="text-base font-medium">{user.full_name}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{#if user.email && user.phone}
|
||||
{user.email} • {user.phone}
|
||||
{:else if user.email}
|
||||
{user.email}
|
||||
{:else if user.phone}
|
||||
{user.phone}
|
||||
{:else}
|
||||
No contact info
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedUserId === user.id}
|
||||
<svg
|
||||
class="h-5 w-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Guest Form - Using Native Input -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="guest-name">Guest Name *</Label>
|
||||
<input
|
||||
id="guest-name"
|
||||
type="text"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Jane Doe"
|
||||
value={guestName}
|
||||
oninput={(e) => (guestName = e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="guest-phone">Phone Number *</Label>
|
||||
<input
|
||||
id="guest-phone"
|
||||
type="tel"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="07700 900000"
|
||||
value={guestPhone}
|
||||
oninput={(e) => (guestPhone = e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
|
||||
Booking as a guest creates a temporary record. Encourage them to sign up for
|
||||
loyalty benefits.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
<BookingActions
|
||||
canBack={false}
|
||||
canNext={canProceedStep1}
|
||||
nextLabel="Next: Choose Services"
|
||||
on:next={() => currentStep++}
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 2: Service Selection -->
|
||||
{#if currentStep === 2}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Services</Card.Title>
|
||||
<Card.Description>Select one or more treatments for this appointment</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
{#if loadingServices}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#each Array(4) as _}
|
||||
<Skeleton class="h-28 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if services.length === 0}
|
||||
<p class="py-8 text-center text-gray-500">No services available.</p>
|
||||
{:else}
|
||||
<ServiceSelector
|
||||
{services}
|
||||
selected={selectedServices}
|
||||
loading={loadingServices}
|
||||
ontoggle={toggleService}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if selectedServices.length > 0}
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<h4 class="mb-2 font-semibold">Selected Services</h4>
|
||||
<div class="space-y-2">
|
||||
{#each selectedServices as service (service.id)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>{service.name}</span>
|
||||
<span>{service.duration_minutes} mins • £{service.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex justify-between text-sm font-semibold">
|
||||
<span>Estimated Duration:</span>
|
||||
<span>{formattedTotalDuration}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm font-semibold">
|
||||
<span>Total Cost:</span>
|
||||
<span>£{getTotalPrice()}</span>
|
||||
</div>
|
||||
{#if maxSlotDuration > 0}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Available Slot Duration:</span>
|
||||
<span class={isOverDuration ? 'font-semibold text-red-600' : ''}>
|
||||
{formatDuration(maxSlotDuration)}
|
||||
</span>
|
||||
</div>
|
||||
{#if isOverDuration}
|
||||
<div class="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||
<strong>Warning:</strong> Selected services ({formattedTotalDuration})
|
||||
exceed available slot duration ({formatDuration(maxSlotDuration)}). Please
|
||||
remove services or customize durations.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
||||
<Button disabled={!canProceedStep2} onclick={() => currentStep++}>
|
||||
Next: Customize Services
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 3: Service Overrides & Notes -->
|
||||
{#if currentStep === 3}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Customize Services</Card.Title>
|
||||
<Card.Description>
|
||||
Adjust pricing or duration if needed, and add appointment notes
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<div>
|
||||
<h4 class="mb-3 font-semibold">Service Details</h4>
|
||||
<p class="mb-4 text-sm text-gray-600">
|
||||
Override default pricing or duration for special cases (discounts, extended
|
||||
sessions, etc.)
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
{#each selectedServices as service}
|
||||
<!-- Safety check to ensure override exists -->
|
||||
{#if serviceOverrides[service.id]}
|
||||
<div class="rounded-lg border bg-white p-4">
|
||||
<div class="mb-3 font-medium">{service.name}</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="price-{service.id}" class="text-xs text-gray-600"
|
||||
>Price (£)</Label
|
||||
>
|
||||
<!-- Native Input with oninput -->
|
||||
<input
|
||||
id="price-{service.id}"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
value={serviceOverrides[service.id]?.price || service.price.toFixed(2)}
|
||||
oninput={(e) => handlePriceInput(service.id, e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="duration-{service.id}" class="text-xs text-gray-600"
|
||||
>Duration (min)</Label
|
||||
>
|
||||
<!-- Native Input with oninput -->
|
||||
<input
|
||||
id="duration-{service.id}"
|
||||
type="number"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
value={serviceOverrides[service.id]?.duration ||
|
||||
service.duration_minutes}
|
||||
oninput={(e) => handleDurationInput(service.id, e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if serviceOverrides[service.id] && (Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 || parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration)}
|
||||
<div class="mt-2 text-xs text-amber-600">
|
||||
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01}
|
||||
Price modified from £{serviceOverrides[
|
||||
service.id
|
||||
].originalPrice.toFixed(2)}
|
||||
{/if}
|
||||
|
||||
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 && parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
|
||||
•
|
||||
{/if}
|
||||
|
||||
{#if parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
|
||||
Duration modified from {serviceOverrides[service.id].originalDuration} mins
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<div class="flex justify-between text-sm font-semibold">
|
||||
<span>Total Duration:</span>
|
||||
<span>{formattedTotalDuration}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between text-sm font-semibold">
|
||||
<span>Total Cost:</span>
|
||||
<span>£{getTotalPrice().toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="notes">Appointment Notes (extras only, client will see this)</Label>
|
||||
<!-- Native Textarea -->
|
||||
<textarea
|
||||
id="notes"
|
||||
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
bind:value={notes}
|
||||
placeholder="Any special requirements, preferences, or notes about this booking..."
|
||||
></textarea>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onclick={submitBooking}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{submitting ? 'Creating Booking...' : 'Create Walk-In Booking'}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
export let canBack = false;
|
||||
export let canNext = false;
|
||||
export let isSubmitting = false;
|
||||
export let nextLabel = 'Next';
|
||||
export let submitLabel = 'Submit';
|
||||
export let showSubmit = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<Button variant="outline" disabled={!canBack} onclick={() => dispatch('back')}>Back</Button>
|
||||
|
||||
{#if showSubmit}
|
||||
<Button
|
||||
disabled={!canNext || isSubmitting}
|
||||
onclick={() => dispatch('submit')}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isSubmitting ? 'Processing...' : submitLabel}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button disabled={!canNext} onclick={() => dispatch('next')}>
|
||||
{nextLabel}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,942 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// Components
|
||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||
import BookingSummary from '$lib/components/booking/BookingSummary.svelte';
|
||||
import StepIndicator from '$lib/components/booking/StepIndicator.svelte';
|
||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
|
||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||
import {
|
||||
extractBookedSlots,
|
||||
getLunchProtectionForSlots,
|
||||
type TimeSlot
|
||||
} from '$lib/lunchProtection';
|
||||
|
||||
import type {
|
||||
Service,
|
||||
CustomerInfo,
|
||||
WorkingHoursDay,
|
||||
AvailableHoursDay
|
||||
} from '$lib/types/booking';
|
||||
|
||||
// =============== State Management ===============
|
||||
let currentStep = $state<number>(1);
|
||||
let selectedServices = $state<Service[]>([]);
|
||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||
let selectedTime = $state<string | null>(null);
|
||||
let customerInfo = $state<CustomerInfo>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
specialRequests: ''
|
||||
});
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// =============== Services Management ===============
|
||||
let services = $state<Service[]>([]);
|
||||
let servicesLoading = $state(true);
|
||||
|
||||
async function fetchServices() {
|
||||
servicesLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/services', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: Service[] = await response.json();
|
||||
services = data;
|
||||
} else {
|
||||
console.error('Failed to fetch services:', response.status);
|
||||
toast.error('Failed to load services');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching services:', err);
|
||||
toast.error('Network error loading services');
|
||||
} finally {
|
||||
servicesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== ADD: Lunch Protection ===============
|
||||
const lunchProtectionStatus = $derived(() => {
|
||||
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const dateStr = selectedDate.toString();
|
||||
const dayWorkingHours = workingHours[dateStr];
|
||||
const dayAvailableHours = availableHours[dateStr];
|
||||
|
||||
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Extract existing bookings from the gap between working hours and available hours
|
||||
const existingBookings = extractBookedSlots(
|
||||
dayWorkingHours.startTime,
|
||||
dayWorkingHours.endTime,
|
||||
dayAvailableHours.slots
|
||||
);
|
||||
|
||||
// Get lunch protection status for all slots
|
||||
return getLunchProtectionForSlots(
|
||||
dayWorkingHours.startTime,
|
||||
dayWorkingHours.endTime,
|
||||
existingBookings,
|
||||
getTotalDuration(),
|
||||
15, // 15 minute slot intervals
|
||||
false // User journey - requires 1h minimum
|
||||
);
|
||||
});
|
||||
|
||||
// =============== Working Hours & Available Hours ===============
|
||||
let workingHours = $state<Record<
|
||||
string,
|
||||
{ isOpen: boolean; startTime: string; endTime: string }
|
||||
> | null>(null);
|
||||
|
||||
let availableHours = $state<Record<
|
||||
string,
|
||||
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||
> | null>(null);
|
||||
|
||||
let loadingWorkingHours = $state<boolean>(false);
|
||||
let loadingAvailableHours = $state<boolean>(false);
|
||||
|
||||
const workingHoursCache = new SvelteMap<
|
||||
string,
|
||||
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||
>();
|
||||
|
||||
const availableHoursCache = new SvelteMap<
|
||||
string,
|
||||
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
|
||||
>();
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
workingHoursCache.clear();
|
||||
availableHoursCache.clear();
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize date boundaries
|
||||
const today = new SvelteDate();
|
||||
const tomorrow = new SvelteDate(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const maxDate = new SvelteDate();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
|
||||
// Create CalendarDate objects
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
maxDate.getMonth() + 1,
|
||||
maxDate.getDate()
|
||||
);
|
||||
|
||||
let placeholder = $state<CalendarDate>(minDate);
|
||||
|
||||
$effect(() => {
|
||||
fetchServices();
|
||||
});
|
||||
$effect(() => {
|
||||
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
|
||||
fetchHoursForMonth(placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchHoursForMonth(date: CalendarDate) {
|
||||
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||
|
||||
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
|
||||
workingHours = workingHoursCache.get(monthKey)!;
|
||||
availableHours = availableHoursCache.get(monthKey)!;
|
||||
return;
|
||||
}
|
||||
|
||||
loadingWorkingHours = true;
|
||||
loadingAvailableHours = true;
|
||||
|
||||
try {
|
||||
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
||||
const endOfMonth = new CalendarDate(
|
||||
date.year,
|
||||
date.month,
|
||||
date.calendar.getDaysInMonth(date)
|
||||
);
|
||||
|
||||
const startStr = startOfMonth.toString();
|
||||
const endStr = endOfMonth.toString();
|
||||
|
||||
// Fetch working hours
|
||||
const workingHoursResponse = await fetch(
|
||||
`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`
|
||||
);
|
||||
if (!workingHoursResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${workingHoursResponse.status}`);
|
||||
}
|
||||
|
||||
const workingHoursData: Array<WorkingHoursDay> = await workingHoursResponse.json();
|
||||
const workingHoursMap: Record<
|
||||
string,
|
||||
{ isOpen: boolean; startTime: string; endTime: string }
|
||||
> = {};
|
||||
|
||||
workingHoursData.forEach((day) => {
|
||||
workingHoursMap[day.date] = {
|
||||
isOpen: day.isOpen,
|
||||
startTime: day.startTime,
|
||||
endTime: day.endTime
|
||||
};
|
||||
});
|
||||
|
||||
workingHoursCache.set(monthKey, workingHoursMap);
|
||||
workingHours = workingHoursMap;
|
||||
|
||||
// Fetch available hours
|
||||
const availableHoursResponse = await fetch(
|
||||
`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`
|
||||
);
|
||||
if (!availableHoursResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${availableHoursResponse.status}`);
|
||||
}
|
||||
|
||||
const availableHoursData: Array<AvailableHoursDay> = await availableHoursResponse.json();
|
||||
const availableHoursMap: Record<
|
||||
string,
|
||||
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||
> = {};
|
||||
|
||||
availableHoursData.forEach((day) => {
|
||||
availableHoursMap[day.date] = {
|
||||
isOpen: day.isOpen,
|
||||
slots: day.slots
|
||||
};
|
||||
});
|
||||
|
||||
availableHoursCache.set(monthKey, availableHoursMap);
|
||||
availableHours = availableHoursMap;
|
||||
|
||||
if (!selectedDate) {
|
||||
setDefaultSelectedDate(workingHoursMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hours:', error);
|
||||
if (!selectedDate) {
|
||||
selectedDate = minDate;
|
||||
}
|
||||
} finally {
|
||||
loadingWorkingHours = false;
|
||||
loadingAvailableHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setDefaultSelectedDate(
|
||||
hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||
) {
|
||||
const currentDate = new SvelteDate();
|
||||
const maxDateJs = new SvelteDate(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
maxCalendarDate.day
|
||||
);
|
||||
|
||||
const daysDifference = Math.floor(
|
||||
(maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const daysToCheck = Math.min(daysDifference, 180);
|
||||
|
||||
for (let i = 1; i <= daysToCheck; i++) {
|
||||
const nextDate = new SvelteDate(currentDate);
|
||||
nextDate.setDate(currentDate.getDate() + i);
|
||||
const dateStr = nextDate.toISOString().split('T')[0];
|
||||
|
||||
if (hoursMap[dateStr]?.isOpen) {
|
||||
selectedDate = new CalendarDate(
|
||||
nextDate.getFullYear(),
|
||||
nextDate.getMonth() + 1,
|
||||
nextDate.getDate()
|
||||
);
|
||||
// Also update placeholder to show the month with first available date
|
||||
placeholder = new CalendarDate(
|
||||
nextDate.getFullYear(),
|
||||
nextDate.getMonth() + 1,
|
||||
1 // First day of the month
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedDate) {
|
||||
const tomorrow = new SvelteDate();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
selectedDate = new CalendarDate(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth() + 1,
|
||||
tomorrow.getDate()
|
||||
);
|
||||
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Time Slot Generation ===============
|
||||
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
const date = new SvelteDate();
|
||||
date.setHours(hours, minutes, 0, 0);
|
||||
date.setMinutes(date.getMinutes() + durationMinutes);
|
||||
const endHours = date.getHours().toString().padStart(2, '0');
|
||||
const endMinutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${endHours}:${endMinutes}`;
|
||||
}
|
||||
|
||||
function timeToMinutes(time: string): number {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function calculatePreviousTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
let totalMinutes = hours * 60 + minutes;
|
||||
totalMinutes -= 15;
|
||||
|
||||
const prevHours = Math.floor(totalMinutes / 60);
|
||||
const prevMinutes = totalMinutes % 60;
|
||||
return `${String(prevHours).padStart(2, '0')}:${String(prevMinutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function generateAvailableTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
|
||||
if (!date || !workingHours || !availableHours) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateStr = date.toString();
|
||||
const dayWorkingHours = workingHours[dateStr];
|
||||
const dayAvailableHours = availableHours[dateStr];
|
||||
|
||||
if (
|
||||
!dayWorkingHours ||
|
||||
!dayWorkingHours.isOpen ||
|
||||
!dayAvailableHours ||
|
||||
!dayAvailableHours.slots
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slots: string[] = [];
|
||||
const now = new SvelteDate();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(today) === 0;
|
||||
|
||||
for (const slot of dayAvailableHours.slots) {
|
||||
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
|
||||
|
||||
let startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const minimumStartMinutes = currentMinutes + 120;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
|
||||
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
||||
const slotEndMinutes = minutes + duration;
|
||||
|
||||
if (slotEndMinutes <= endTotalMinutes) {
|
||||
const hour = Math.floor(minutes / 60);
|
||||
const minute = minutes % 60;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
slots.push(timeStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function generateGroupedTimeSlots(
|
||||
duration: number,
|
||||
date: CalendarDate | undefined
|
||||
): Array<{
|
||||
type: 'available' | 'unavailable';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isGrouped?: boolean;
|
||||
}> {
|
||||
if (!date || !workingHours) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateStr = date.toString();
|
||||
const dayWorkingHours = workingHours[dateStr];
|
||||
|
||||
if (!dayWorkingHours || !dayWorkingHours.isOpen) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupedSlots: Array<{
|
||||
type: 'available' | 'unavailable';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isGrouped?: boolean;
|
||||
}> = [];
|
||||
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
|
||||
|
||||
let startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
const now = new SvelteDate();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(today) === 0;
|
||||
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const minimumStartMinutes = currentMinutes + 120;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
|
||||
let currentUnavailableStart: string | null = null;
|
||||
let lastAvailableEndTime: string | null = null;
|
||||
|
||||
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
||||
const hour = Math.floor(minutes / 60);
|
||||
const minute = minutes % 60;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
|
||||
const isAvailable = availableSlots.includes(timeStr);
|
||||
|
||||
if (isAvailable) {
|
||||
if (currentUnavailableStart !== null) {
|
||||
// Use the end time of the last available slot as the start of unavailable period
|
||||
const unavailableStartTime = lastAvailableEndTime || currentUnavailableStart;
|
||||
const groupEndTime = calculatePreviousTime(timeStr);
|
||||
groupedSlots.push({
|
||||
type: 'unavailable',
|
||||
startTime: unavailableStartTime,
|
||||
endTime: groupEndTime,
|
||||
isGrouped: true
|
||||
});
|
||||
currentUnavailableStart = null;
|
||||
}
|
||||
|
||||
const slotEndTime = calculateEndTime(timeStr, duration);
|
||||
lastAvailableEndTime = slotEndTime;
|
||||
groupedSlots.push({
|
||||
type: 'available',
|
||||
startTime: timeStr,
|
||||
endTime: slotEndTime
|
||||
});
|
||||
|
||||
if (timeToMinutes(slotEndTime) >= endTotalMinutes) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (currentUnavailableStart === null) {
|
||||
currentUnavailableStart = timeStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUnavailableStart !== null) {
|
||||
const lastAvailableSlot = groupedSlots.filter((s) => s.type === 'available').pop();
|
||||
const lastAvailableEnd = lastAvailableSlot ? timeToMinutes(lastAvailableSlot.endTime) : 0;
|
||||
|
||||
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
|
||||
|
||||
if (unavailableStartMinutes < endTotalMinutes && lastAvailableEnd < endTotalMinutes) {
|
||||
// Use the end time of the last available slot for the final unavailable period
|
||||
const unavailableStartTime = lastAvailableSlot
|
||||
? lastAvailableSlot.endTime
|
||||
: currentUnavailableStart;
|
||||
groupedSlots.push({
|
||||
type: 'unavailable',
|
||||
startTime: unavailableStartTime,
|
||||
endTime: dayWorkingHours.endTime,
|
||||
isGrouped: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groupedSlots;
|
||||
}
|
||||
|
||||
// =============== Date Availability Check ===============
|
||||
function isDateUnavailable(date: DateValue): boolean {
|
||||
if (!(date instanceof CalendarDate)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!workingHours) return true;
|
||||
|
||||
const dateStr = date.toString();
|
||||
const dayHours = workingHours[dateStr];
|
||||
|
||||
if (!dayHours) return true;
|
||||
if (!dayHours.isOpen) return true;
|
||||
|
||||
// If no services selected, don't check availability slots
|
||||
// This allows calendar to show open/closed days
|
||||
if (selectedServices.length === 0) {
|
||||
return false; // Show all working days as available
|
||||
}
|
||||
|
||||
const duration = getTotalDuration();
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
if (availableSlots.length === 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// =============== Helper Functions ===============
|
||||
function getTotalDuration() {
|
||||
return selectedServices.reduce(
|
||||
(total, service: Service) => total + service.duration_minutes,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function getTotalPrice() {
|
||||
return selectedServices.reduce((total, service: Service) => total + service.price, 0);
|
||||
}
|
||||
|
||||
function toggleService(service: Service) {
|
||||
const index = selectedServices.findIndex((s) => s.id === service.id);
|
||||
const wasSelected = index >= 0;
|
||||
|
||||
if (wasSelected) {
|
||||
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||
} else {
|
||||
selectedServices = [...selectedServices, service];
|
||||
}
|
||||
|
||||
// Only clear if we're on the date/time selection step
|
||||
if (currentStep === 2 && selectedDate) {
|
||||
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
||||
availableHoursCache.delete(monthKey); // Only delete current month
|
||||
fetchHoursForMonth(selectedDate);
|
||||
}
|
||||
|
||||
selectedTime = null;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
function getDayWithOrdinal(date: CalendarDate): string {
|
||||
const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString(
|
||||
'en-GB',
|
||||
{
|
||||
month: 'long'
|
||||
}
|
||||
);
|
||||
const day = date.day;
|
||||
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
return monthName + ' ' + day + 'st';
|
||||
case 2:
|
||||
return monthName + ' ' + day + 'nd';
|
||||
case 3:
|
||||
return monthName + ' ' + day + 'rd';
|
||||
default:
|
||||
return monthName + ' ' + day + 'th';
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Derived Values ===============
|
||||
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
||||
const groupedTimeSlots = $derived(
|
||||
currentStep === 2 && selectedServices.length > 0 && selectedDate
|
||||
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
|
||||
: []
|
||||
);
|
||||
const formattedSelectedDate = $derived(
|
||||
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
|
||||
);
|
||||
|
||||
// =============== Navigation ===============
|
||||
function nextStep() {
|
||||
if (currentStep < 4) {
|
||||
currentStep++;
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep > 1) {
|
||||
currentStep--;
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Validation ===============
|
||||
const canProceedStep1 = $derived(selectedServices.length > 0);
|
||||
const canProceedStep2 = $derived(!!(selectedDate && selectedTime));
|
||||
const canProceedStep3 = $derived(
|
||||
authStore.isAuthenticated
|
||||
? !!(
|
||||
authStore.currentUser?.firstName &&
|
||||
authStore.currentUser?.lastName &&
|
||||
authStore.currentUser?.email &&
|
||||
authStore.currentUser?.phone
|
||||
)
|
||||
: !!(
|
||||
customerInfo.firstName &&
|
||||
customerInfo.lastName &&
|
||||
customerInfo.email &&
|
||||
customerInfo.phone
|
||||
)
|
||||
);
|
||||
|
||||
// =============== Submission ===============
|
||||
async function submitBooking() {
|
||||
isSubmitting = true;
|
||||
try {
|
||||
console.log('Submitting booking:', {
|
||||
services: selectedServices,
|
||||
date: selectedDate,
|
||||
time: selectedTime,
|
||||
customer: authStore.isAuthenticated ? authStore.currentUser : customerInfo
|
||||
});
|
||||
|
||||
toast.success('Booking submitted successfully!');
|
||||
} catch (error) {
|
||||
console.error('Booking submission failed:', error);
|
||||
toast.error('Failed to submit booking. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl p-6">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="mb-2 text-3xl font-bold">Book Your Appointment</h1>
|
||||
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
||||
</div>
|
||||
|
||||
<StepIndicator {currentStep} />
|
||||
|
||||
<!-- Step 1: Service Selection -->
|
||||
{#if currentStep === 1}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Your Services</Card.Title>
|
||||
<Card.Description>Select one or more treatments for your appointment</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<ServiceSelector
|
||||
{services}
|
||||
selected={selectedServices}
|
||||
loading={servicesLoading}
|
||||
ontoggle={toggleService}
|
||||
/>
|
||||
|
||||
{#if selectedServices.length > 0}
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<h4 class="mb-2 font-semibold">Selected Services</h4>
|
||||
<div class="space-y-2">
|
||||
{#each selectedServices as service (service.id)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>{service.name}</span>
|
||||
<span>{service.duration_minutes} mins • £{service.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex justify-between text-sm font-semibold">
|
||||
<span>Estimated Duration:</span>
|
||||
<span>{formattedTotalDuration}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm font-semibold">
|
||||
<span>Total Cost:</span>
|
||||
<span>£{getTotalPrice()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
<BookingActions
|
||||
canBack={false}
|
||||
canNext={canProceedStep1}
|
||||
nextLabel="Next: Select Date & Time"
|
||||
on:next={nextStep}
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 2: Date & Time Selection -->
|
||||
{#if currentStep === 2}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Date & Time</Card.Title>
|
||||
<Card.Description>
|
||||
{selectedServices.map((s) => s.name).join(', ')} • {formattedTotalDuration} total • £{getTotalPrice()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<Card.Root class="gap-0 border-0 p-0">
|
||||
<Card.Content class="relative p-0 md:pr-56">
|
||||
{#if loadingWorkingHours}
|
||||
<div class="flex items-center justify-center p-6">
|
||||
<p>Loading available dates...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
{placeholder}
|
||||
minValue={minDate}
|
||||
maxValue={maxCalendarDate}
|
||||
{isDateUnavailable}
|
||||
onchange={(newDate) => {
|
||||
selectedDate = newDate;
|
||||
selectedTime = null;
|
||||
}}
|
||||
onPlaceholderChange={(newPlaceholder) => {
|
||||
placeholder = newPlaceholder;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if loadingAvailableHours}
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex w-56 items-center justify-center border-l p-6"
|
||||
>
|
||||
<p class="text-sm text-gray-500">Loading times...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TimeSlotPicker
|
||||
date={selectedDate}
|
||||
{groupedTimeSlots}
|
||||
{selectedTime}
|
||||
formattedDate={formattedSelectedDate}
|
||||
onselect={(time) => {
|
||||
selectedTime = time;
|
||||
}}
|
||||
lunchProtectionStatus={lunchProtectionStatus()}
|
||||
/>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Card.Content>
|
||||
|
||||
<!-- Mobile appointment summary -->
|
||||
<div class="border-t px-6 py-4 text-center text-sm md:hidden">
|
||||
{#if selectedDate && selectedTime}
|
||||
Appointment for
|
||||
<span class="font-medium">
|
||||
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</span>
|
||||
<br />at <span class="font-medium">{selectedTime}</span>
|
||||
{:else}
|
||||
Select a date and time
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card.Footer class="flex justify-between border-t px-6 !py-5">
|
||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Desktop appointment summary -->
|
||||
<div class="hidden text-sm md:block">
|
||||
{#if selectedDate && selectedTime}
|
||||
Appointment for
|
||||
<span class="font-medium">
|
||||
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</span>
|
||||
at <span class="font-medium">{selectedTime}</span>
|
||||
{:else}
|
||||
Select a date and time
|
||||
{/if}
|
||||
</div>
|
||||
<Button disabled={!canProceedStep2} onclick={nextStep}>Next: Your Details</Button>
|
||||
</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 3: Customer Details -->
|
||||
{#if currentStep === 3}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Your Details</Card.Title>
|
||||
<Card.Description>Please confirm your contact information</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
showCustomer={false}
|
||||
/>
|
||||
|
||||
{#if !authStore.isAuthenticated}
|
||||
<p class="mb-4 text-center text-sm text-yellow-600">
|
||||
You are checking out as a guest, so you will miss out on a loyalty stamp. Please login
|
||||
for full membership benefits.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="firstName">First Name *</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
bind:value={customerInfo.firstName}
|
||||
placeholder="Enter your first name"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="lastName">Last Name *</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
bind:value={customerInfo.lastName}
|
||||
placeholder="Enter your last name"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={customerInfo.email}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
bind:value={customerInfo.phone}
|
||||
placeholder="Enter your phone number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="requests">Special Requests (Optional)</Label>
|
||||
<Textarea
|
||||
id="requests"
|
||||
bind:value={customerInfo.specialRequests}
|
||||
placeholder="Any allergies, preferences, or special requirements..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600">
|
||||
{#if !authStore.isAuthenticated}
|
||||
<p>* Required fields</p>
|
||||
{/if}
|
||||
<p class="mt-2">
|
||||
By booking, you agree to our Terms & Conditions and Privacy Policy. We'll send you
|
||||
appointment reminders via email and/or SMS.
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||
<Button
|
||||
disabled={!canProceedStep3}
|
||||
onclick={nextStep}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
Next: Payment
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 4: Payment (complete) -->
|
||||
{#if currentStep === 4}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Payment Confirmation</Card.Title>
|
||||
<Card.Description>Review and complete your booking</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<!-- Payment form placeholder -->
|
||||
<div class="rounded-lg bg-white p-6">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Payment</h2>
|
||||
<p class="text-gray-600">Square payment integration will be added here.</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||
<Button
|
||||
disabled={!canProceedStep3 || isSubmitting}
|
||||
onclick={submitBooking}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isSubmitting ? 'Processing...' : 'Complete Booking'}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import type { Service, CustomerInfo } from '$lib/types/booking';
|
||||
import type { CalendarDate } from '@internationalized/date';
|
||||
import { getLocalTimeZone } from '@internationalized/date';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
|
||||
let {
|
||||
services = [],
|
||||
date = undefined,
|
||||
time = null,
|
||||
customer = null,
|
||||
showCustomer = false
|
||||
}: {
|
||||
services?: Service[];
|
||||
date?: CalendarDate;
|
||||
time?: string | null;
|
||||
customer?: CustomerInfo | null;
|
||||
showCustomer?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Computed values
|
||||
const totalPrice = $derived(services.reduce((sum, service) => sum + service.price, 0));
|
||||
|
||||
const totalDuration = $derived(
|
||||
services.reduce((sum, service) => sum + service.duration_minutes, 0)
|
||||
);
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(time: string): string {
|
||||
const parts = time.split(':').map(Number);
|
||||
const hours = parts[0];
|
||||
const minutes = parts.length > 1 ? parts[1] : 0;
|
||||
|
||||
if (hours === 12 && minutes === 0) {
|
||||
return 'Noon';
|
||||
} else if (hours === 0 && minutes === 0) {
|
||||
return 'Midnight';
|
||||
}
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<h4 class="mb-2 font-semibold">Booking Summary</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
{#if services.length > 0}
|
||||
<div>
|
||||
<span class="font-medium">Services:</span>
|
||||
<div class="mt-1 ml-4 space-y-1">
|
||||
{#each services as service (service.id)}
|
||||
<div class="flex justify-between">
|
||||
<span>{service.name}</span>
|
||||
<span>£{service.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if date}
|
||||
<div class="flex justify-between">
|
||||
<span>Date:</span>
|
||||
<span class="font-medium">
|
||||
{date.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if time}
|
||||
<div class="flex justify-between">
|
||||
<span>Time:</span>
|
||||
<span class="font-medium">{formatTime(time)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if services.length > 0}
|
||||
<div class="flex justify-between">
|
||||
<span>Estimated Duration:</span>
|
||||
<span class="font-medium">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showCustomer && customer}
|
||||
<Separator class="my-2" />
|
||||
<div>
|
||||
<span class="font-medium">Contact Information:</span>
|
||||
<div class="mt-1 ml-4 space-y-1">
|
||||
<div>{customer.firstName} {customer.lastName}</div>
|
||||
<div>{customer.email}</div>
|
||||
<div>{customer.phone}</div>
|
||||
{#if customer.specialRequests}
|
||||
<div class="mt-2">
|
||||
<span class="font-medium">Special Requests:</span>
|
||||
<div class="text-gray-600">{customer.specialRequests}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if services.length > 0}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex justify-between font-semibold">
|
||||
<span>Total Cost:</span>
|
||||
<span>£{totalPrice}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { CalendarDate, DateValue } from '@internationalized/date';
|
||||
import Calendar from '$lib/components/ui/calendar/calendar.svelte';
|
||||
|
||||
let {
|
||||
date,
|
||||
placeholder,
|
||||
minValue,
|
||||
maxValue,
|
||||
isDateUnavailable,
|
||||
onchange,
|
||||
onPlaceholderChange
|
||||
}: {
|
||||
date: CalendarDate | undefined;
|
||||
placeholder: CalendarDate;
|
||||
minValue: CalendarDate;
|
||||
maxValue: CalendarDate;
|
||||
isDateUnavailable: (date: DateValue) => boolean;
|
||||
onchange?: (date: CalendarDate | undefined) => void;
|
||||
onPlaceholderChange?: (date: CalendarDate) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center p-6">
|
||||
<Calendar
|
||||
type="single"
|
||||
bind:value={date}
|
||||
bind:placeholder
|
||||
{isDateUnavailable}
|
||||
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
|
||||
weekdayFormat="short"
|
||||
{minValue}
|
||||
{maxValue}
|
||||
locale="en-GB"
|
||||
onValueChange={(v: DateValue | undefined) => {
|
||||
if (onchange) {
|
||||
onchange(v as CalendarDate | undefined);
|
||||
}
|
||||
}}
|
||||
onPlaceholderChange={(p: DateValue) => {
|
||||
if (onPlaceholderChange) {
|
||||
onPlaceholderChange(p as CalendarDate);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { Service } from '$lib/types/booking';
|
||||
|
||||
let {
|
||||
service,
|
||||
selected = false,
|
||||
onclick
|
||||
}: {
|
||||
service: Service;
|
||||
selected?: boolean;
|
||||
onclick?: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50 hover:text-accent-foreground {selected
|
||||
? 'bg-fuchsia-100'
|
||||
: ''}"
|
||||
onclick={() => onclick?.()}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold">{service.name}</h3>
|
||||
<p class="text-sm text-gray-600">{service.description}</p>
|
||||
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>{service.duration_minutes} mins</span>
|
||||
<span>£{service.price}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ml-3 flex h-5 w-5 items-center justify-center rounded border-2 {selected
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-gray-300'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if selected}
|
||||
<svg class="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import ServiceCard from './ServiceCard.svelte';
|
||||
import type { Service } from '$lib/types/booking';
|
||||
|
||||
let {
|
||||
services = [],
|
||||
selected = [],
|
||||
loading = true,
|
||||
ontoggle
|
||||
}: {
|
||||
services?: Service[];
|
||||
selected?: Service[];
|
||||
loading?: boolean;
|
||||
ontoggle?: (service: Service) => void;
|
||||
} = $props();
|
||||
|
||||
function isServiceSelected(service: Service): boolean {
|
||||
return selected.some((s) => s.id === service.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#if loading}
|
||||
<p>Loading services...</p>
|
||||
{:else if services.length === 0}
|
||||
<p>No services available at the moment.</p>
|
||||
{:else}
|
||||
{#each services as service (service.id)}
|
||||
<ServiceCard
|
||||
{service}
|
||||
selected={isServiceSelected(service)}
|
||||
onclick={() => ontoggle?.(service)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
export let currentStep: number;
|
||||
export let steps: string[] = ['Service', 'Date & Time', 'Details', 'Payment'];
|
||||
|
||||
const totalSteps = steps.length;
|
||||
</script>
|
||||
|
||||
<div class="mb-8 grid grid-cols-2 gap-4 md:flex md:items-center md:justify-center md:space-x-4">
|
||||
{#each steps as step, index (step)}
|
||||
{@const stepNumber = index + 1}
|
||||
{@const isActive = stepNumber <= currentStep}
|
||||
{@const isLastStep = index === totalSteps - 1}
|
||||
|
||||
<div class="flex items-center justify-start md:justify-center">
|
||||
<!-- Step circle -->
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium {isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-gray-200 text-gray-600'}"
|
||||
>
|
||||
{stepNumber}
|
||||
</div>
|
||||
|
||||
<!-- Step label -->
|
||||
<span class="ml-2 text-sm font-medium {isActive ? 'text-primary' : 'text-gray-600'}">
|
||||
{step}
|
||||
</span>
|
||||
|
||||
<!-- Connector line (desktop only, not after last step) -->
|
||||
{#if !isLastStep}
|
||||
<div
|
||||
class="mx-4 hidden h-0.5 w-8 md:block {stepNumber < currentStep
|
||||
? 'bg-primary'
|
||||
: 'bg-gray-200'}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import type { CalendarDate } from '@internationalized/date';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
let {
|
||||
date,
|
||||
groupedTimeSlots = [],
|
||||
selectedTime = null,
|
||||
formattedDate,
|
||||
onselect,
|
||||
lunchProtectionStatus = new Map()
|
||||
}: {
|
||||
date: CalendarDate | undefined;
|
||||
groupedTimeSlots?: Array<{
|
||||
type: 'available' | 'unavailable';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isGrouped?: boolean;
|
||||
}>;
|
||||
selectedTime?: string | null;
|
||||
formattedDate?: string;
|
||||
onselect?: (time: string) => void;
|
||||
lunchProtectionStatus?: Map<
|
||||
string,
|
||||
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
|
||||
>;
|
||||
} = $props();
|
||||
|
||||
function formatTime(time: string): string {
|
||||
const parts = time.split(':').map(Number);
|
||||
const hours = parts[0];
|
||||
const minutes = parts.length > 1 ? parts[1] : 0;
|
||||
|
||||
if (hours === 12 && minutes === 0) {
|
||||
return 'Noon';
|
||||
} else if (hours === 0 && minutes === 0) {
|
||||
return 'Midnight';
|
||||
}
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="no-scrollbar inset-y-0 right-0 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
|
||||
>
|
||||
{#if groupedTimeSlots.length > 0}
|
||||
{#if formattedDate}
|
||||
<div class="grid justify-center gap-2">{formattedDate}</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-2">
|
||||
{#each groupedTimeSlots as slot (slot.startTime)}
|
||||
{#if slot.type === 'available'}
|
||||
{@const protection = lunchProtectionStatus.get(slot.startTime)}
|
||||
{#if protection?.isBlocked}
|
||||
<!-- Blocked by lunch protection -->
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
|
||||
disabled
|
||||
title={protection.warningMessage || 'Lunch protection'}
|
||||
>
|
||||
{formatTime(slot.startTime)}
|
||||
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<!-- Available slot (possibly with warning for admin) -->
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
if (onselect) {
|
||||
onselect(slot.startTime);
|
||||
}
|
||||
}}
|
||||
class={`w-full hover:bg-fuchsia-50 ${
|
||||
slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''
|
||||
} ${protection?.showWarning ? 'border-amber-400 bg-amber-50' : ''}`}
|
||||
title={protection?.warningMessage}
|
||||
>
|
||||
{#if protection?.showWarning}
|
||||
<span class="mr-1 text-amber-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{formatTime(slot.startTime)}
|
||||
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Unavailable slot (already booked) -->
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
|
||||
disabled
|
||||
>
|
||||
{formatTime(slot.startTime)}
|
||||
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !date}
|
||||
<p class="text-center text-sm text-gray-500">Select a date first</p>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-gray-500">No available slots</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Lunch Protection Utility
|
||||
*
|
||||
* Ensures that a minimum lunch break is preserved in the middle 50% of the working day.
|
||||
* - User journeys: Requires 1h minimum lunch gap (blocks slots that would reduce below 1h)
|
||||
* - Admin journeys: Requires 30min minimum, warns if < 1h remaining
|
||||
*/
|
||||
|
||||
export interface TimeSlot {
|
||||
startTime: string; // "HH:MM" format
|
||||
endTime: string; // "HH:MM" format
|
||||
}
|
||||
|
||||
export interface LunchProtectionResult {
|
||||
isBlocked: boolean;
|
||||
showWarning: boolean;
|
||||
warningMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert "HH:MM" time string to minutes since midnight
|
||||
*/
|
||||
export function timeToMinutes(time: string): number {
|
||||
const parts = time.split(':');
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parts.length > 1 ? parseInt(parts[1], 10) : 0;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert minutes since midnight to "HH:MM" format
|
||||
*/
|
||||
export function minutesToTime(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the middle 50% window of a working day
|
||||
* Example: 9:00-17:00 -> middle 50% is 11:00-15:00
|
||||
*/
|
||||
export function calculateMiddleWindow(
|
||||
dayStartTime: string,
|
||||
dayEndTime: string
|
||||
): { windowStart: number; windowEnd: number } {
|
||||
const startMinutes = timeToMinutes(dayStartTime);
|
||||
const endMinutes = timeToMinutes(dayEndTime);
|
||||
|
||||
const totalDuration = endMinutes - startMinutes;
|
||||
const quarterDuration = Math.floor(totalDuration / 4);
|
||||
|
||||
// Middle 50%: from 25% to 75% of the day
|
||||
return {
|
||||
windowStart: startMinutes + quarterDuration,
|
||||
windowEnd: endMinutes - quarterDuration
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the largest gap in the middle window after accounting for bookings
|
||||
* Returns the duration in minutes of the largest gap
|
||||
*/
|
||||
export function findLargestLunchGap(
|
||||
middleWindowStart: number,
|
||||
middleWindowEnd: number,
|
||||
existingBookings: TimeSlot[],
|
||||
proposedBooking?: TimeSlot
|
||||
): number {
|
||||
const allBookings: TimeSlot[] = [...existingBookings];
|
||||
if (proposedBooking) {
|
||||
allBookings.push(proposedBooking);
|
||||
}
|
||||
|
||||
// Filter to only bookings that overlap with the middle window
|
||||
const relevantBookings = allBookings
|
||||
.filter((booking) => {
|
||||
const bookingStart = timeToMinutes(booking.startTime);
|
||||
const bookingEnd = timeToMinutes(booking.endTime);
|
||||
return bookingStart < middleWindowEnd && bookingEnd > middleWindowStart;
|
||||
})
|
||||
.map((booking) => ({
|
||||
startTime: Math.max(timeToMinutes(booking.startTime), middleWindowStart),
|
||||
endTime: Math.min(timeToMinutes(booking.endTime), middleWindowEnd)
|
||||
}))
|
||||
.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
if (relevantBookings.length === 0) {
|
||||
return middleWindowEnd - middleWindowStart;
|
||||
}
|
||||
|
||||
let largestGap = 0;
|
||||
|
||||
const firstBookingStart = relevantBookings[0].startTime;
|
||||
if (firstBookingStart > middleWindowStart) {
|
||||
largestGap = Math.max(largestGap, firstBookingStart - middleWindowStart);
|
||||
}
|
||||
|
||||
for (let i = 0; i < relevantBookings.length - 1; i++) {
|
||||
const gapStart = relevantBookings[i].endTime;
|
||||
const gapEnd = relevantBookings[i + 1].startTime;
|
||||
if (gapEnd > gapStart) {
|
||||
largestGap = Math.max(largestGap, gapEnd - gapStart);
|
||||
}
|
||||
}
|
||||
|
||||
const lastBookingEnd = relevantBookings[relevantBookings.length - 1].endTime;
|
||||
if (lastBookingEnd < middleWindowEnd) {
|
||||
largestGap = Math.max(largestGap, middleWindowEnd - lastBookingEnd);
|
||||
}
|
||||
|
||||
return largestGap;
|
||||
}
|
||||
|
||||
export const LUNCH_MINIMUM_USER = 60;
|
||||
export const LUNCH_MINIMUM_ADMIN = 30;
|
||||
export const LUNCH_WARNING_THRESHOLD = 60;
|
||||
|
||||
/**
|
||||
* Check if a proposed booking slot violates lunch protection
|
||||
*/
|
||||
export function checkLunchProtection(
|
||||
dayStartTime: string,
|
||||
dayEndTime: string,
|
||||
existingBookings: TimeSlot[],
|
||||
proposedSlotStart: string,
|
||||
proposedSlotEnd: string,
|
||||
isAdmin: boolean
|
||||
): LunchProtectionResult {
|
||||
const { windowStart, windowEnd } = calculateMiddleWindow(dayStartTime, dayEndTime);
|
||||
|
||||
const windowDuration = windowEnd - windowStart;
|
||||
const minimumRequired = isAdmin ? LUNCH_MINIMUM_ADMIN : LUNCH_MINIMUM_USER;
|
||||
|
||||
if (windowDuration < minimumRequired) {
|
||||
return { isBlocked: false, showWarning: false };
|
||||
}
|
||||
|
||||
const proposedBooking: TimeSlot = {
|
||||
startTime: proposedSlotStart,
|
||||
endTime: proposedSlotEnd
|
||||
};
|
||||
|
||||
const largestGap = findLargestLunchGap(windowStart, windowEnd, existingBookings, proposedBooking);
|
||||
|
||||
if (isAdmin) {
|
||||
if (largestGap < LUNCH_MINIMUM_ADMIN) {
|
||||
return {
|
||||
isBlocked: true,
|
||||
showWarning: false,
|
||||
warningMessage: `This booking would leave no lunch break (minimum 30 minutes required).`
|
||||
};
|
||||
}
|
||||
|
||||
if (largestGap < LUNCH_WARNING_THRESHOLD) {
|
||||
const gapMinutes = Math.round(largestGap);
|
||||
return {
|
||||
isBlocked: false,
|
||||
showWarning: true,
|
||||
warningMessage: `Warning: This booking would reduce lunch break to ${gapMinutes} minutes.`
|
||||
};
|
||||
}
|
||||
|
||||
return { isBlocked: false, showWarning: false };
|
||||
} else {
|
||||
if (largestGap < LUNCH_MINIMUM_USER) {
|
||||
return {
|
||||
isBlocked: true,
|
||||
showWarning: false,
|
||||
warningMessage: `This booking would leave insufficient lunch break (minimum 1 hour required).`
|
||||
};
|
||||
}
|
||||
|
||||
return { isBlocked: false, showWarning: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract booked slots from working hours and available hours
|
||||
* The backend returns available slots (working hours minus bookings)
|
||||
* We reverse-engineer the bookings by finding gaps in available slots
|
||||
*/
|
||||
export function extractBookedSlots(
|
||||
dayStartTime: string,
|
||||
dayEndTime: string,
|
||||
availableSlots: TimeSlot[]
|
||||
): TimeSlot[] {
|
||||
const dayStart = timeToMinutes(dayStartTime);
|
||||
const dayEnd = timeToMinutes(dayEndTime);
|
||||
const bookedSlots: TimeSlot[] = [];
|
||||
|
||||
const sortedSlots = [...availableSlots].sort(
|
||||
(a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)
|
||||
);
|
||||
|
||||
let currentPos = dayStart;
|
||||
|
||||
for (const slot of sortedSlots) {
|
||||
const slotStart = timeToMinutes(slot.startTime);
|
||||
const slotEnd = timeToMinutes(slot.endTime);
|
||||
|
||||
// If there's a gap before this slot, it's a booking
|
||||
if (slotStart > currentPos) {
|
||||
bookedSlots.push({
|
||||
startTime: minutesToTime(currentPos),
|
||||
endTime: minutesToTime(slotStart)
|
||||
});
|
||||
}
|
||||
|
||||
currentPos = Math.max(currentPos, slotEnd);
|
||||
}
|
||||
|
||||
// Check for booking at the end of the day
|
||||
if (currentPos < dayEnd) {
|
||||
bookedSlots.push({
|
||||
startTime: minutesToTime(currentPos),
|
||||
endTime: minutesToTime(dayEnd)
|
||||
});
|
||||
}
|
||||
|
||||
return bookedSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lunch protection status for all time slots on a given day
|
||||
*/
|
||||
export function getLunchProtectionForSlots(
|
||||
dayStartTime: string,
|
||||
dayEndTime: string,
|
||||
existingBookings: TimeSlot[],
|
||||
slotDuration: number,
|
||||
slotInterval: number,
|
||||
isAdmin: boolean
|
||||
): Map<string, LunchProtectionResult> {
|
||||
const results = new Map<string, LunchProtectionResult>();
|
||||
|
||||
const { windowStart, windowEnd } = calculateMiddleWindow(dayStartTime, dayEndTime);
|
||||
|
||||
const windowDuration = windowEnd - windowStart;
|
||||
const minimumRequired = isAdmin ? LUNCH_MINIMUM_ADMIN : LUNCH_MINIMUM_USER;
|
||||
|
||||
if (windowDuration < minimumRequired) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const dayStartMinutes = timeToMinutes(dayStartTime);
|
||||
const dayEndMinutes = timeToMinutes(dayEndTime);
|
||||
|
||||
for (
|
||||
let slotStart = dayStartMinutes;
|
||||
slotStart + slotDuration <= dayEndMinutes;
|
||||
slotStart += slotInterval
|
||||
) {
|
||||
const slotStartStr = minutesToTime(slotStart);
|
||||
const slotEndStr = minutesToTime(slotStart + slotDuration);
|
||||
|
||||
const result = checkLunchProtection(
|
||||
dayStartTime,
|
||||
dayEndTime,
|
||||
existingBookings,
|
||||
slotStartStr,
|
||||
slotEndStr,
|
||||
isAdmin
|
||||
);
|
||||
|
||||
if (result.isBlocked || result.showWarning) {
|
||||
results.set(slotStartStr, result);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
patch_test_duration_hours: number;
|
||||
minimum_age_required: number;
|
||||
}
|
||||
|
||||
export interface CustomerInfo {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
specialRequests: string;
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
time: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface WorkingHoursDay {
|
||||
date: string;
|
||||
weekday: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isOpen: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AvailableHoursDay {
|
||||
date: string;
|
||||
weekday: number;
|
||||
isOpen: boolean;
|
||||
slots: Array<{ startTime: string; endTime: string }>;
|
||||
source: string;
|
||||
}
|
||||
Reference in New Issue
Block a user