Files
Crussell/frontend/src/lib/components/admin/WalkInCreateModal.svelte
T
popertots bffb984ebb feat(auth,security,scheduling): JWT revocation, S3 fix, notes validation, docs, tests
- JWT revocation with JTI (UUID v4): in-memory tracking, POST /api/logout,
  refresh handler revokes old JTI, RequireAuth rejects revoked tokens
- Fix extractKey for S3 portfolio deletion: extracts full key path from URLs
  instead of just filename, preventing orphaned storage files
- Notes validation: max=1000000 on all 13 Notes fields across 4 booking structs
- CharCounter: grapheme-aware counter (Intl.Segmenter), threshold 750K,
  color-coded, integrated into 6 booking/admin components
- loginInProgress: timestamp-based tracking, 30s staleness, 20-entry cap (429),
  ticker cleanup for stuck entries
- Profile picture 15MB client-side limit, portfolio 20MB backend limit
- Exceptional scheduling: expand query start to Monday of week
- TodayCalendar: week-range fetching, closing time indicator, short-day lunch skip
- NavBar: link reorder, mobile burger badge, slide transition, backdrop
- ImageUpload: 20MB limit with visual feedback
- formatDateISO: shared YYYY-MM-DD utility, shouldApplyLunchProtection helper
- Update README.md and all Obsidian docs (Overview, Technical, Admin, Future Work)
- Add 28 new tests: JWT (11), auth handlers (7), portfolio extractKey (5),
  notes validation (5). go build + go vet clean with test,dev tags
2026-06-03 11:17:41 +01:00

833 lines
27 KiB
Svelte

<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';
import CharCounter from '$lib/components/ui/CharCounter.svelte';
// 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;
reservationExpiresAt?: Date | null;
onBookingCreated?: () => void;
onclose?: () => void;
}
let {
open = $bindable(),
maxSlotDuration = 0,
availableStartTime,
reservationExpiresAt,
onBookingCreated,
onclose
}: 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);
let idempotencyKey = $state<string>('');
// Countdown state
let reservationCountdown = $state<string>('');
let isReservationExpired = $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());
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
// =============== Effects ===============
let wasOpen = false;
$effect(() => {
if (open && !wasOpen) {
resetState();
fetchServices();
fetchUsers();
}
wasOpen = open;
});
// Refetch services when selected user changes (for eligibility)
$effect(() => {
if (open && selectedUserId) {
fetchServices();
}
});
// Handle reservation countdown
$effect(() => {
if (open && reservationExpiresAt) {
startCountdown();
} else {
reservationCountdown = '';
isReservationExpired = false;
}
});
function startCountdown() {
if ((window as any).__walkInModalCountdownInterval) {
clearInterval((window as any).__walkInModalCountdownInterval);
}
const updateCountdown = () => {
if (!reservationExpiresAt) {
reservationCountdown = '';
return;
}
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
reservationCountdown = 'Expired';
isReservationExpired = true;
if ((window as any).__walkInModalCountdownInterval) {
clearInterval((window as any).__walkInModalCountdownInterval);
}
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
reservationCountdown = `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
updateCountdown();
(window as any).__walkInModalCountdownInterval = setInterval(updateCountdown, 1000);
}
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=4&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 {
let url = '/api/services';
// If a user is selected, get eligibility for that user
if (selectedUserId) {
url = `/api/services/eligible-for/${selectedUserId}`;
}
const response = await fetch(url, {
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 {
// Generate idempotency key if not already set (reused on retry)
if (!idempotencyKey) {
idempotencyKey = crypto.randomUUID();
}
// 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') {
const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Walk-in',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Guest',
phone: phone,
email: `walkin-${Date.now()}@guest.invalid`
})
});
if (!createRes.ok) {
toast.error('Failed to create guest user');
submitting = false;
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',
'Idempotency-Key': idempotencyKey,
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (res.ok) {
toast.success('Booking created successfully!');
open = false;
window.dispatchEvent(new CustomEvent('bookingApproved'));
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-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl"
>
<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">
<!-- Reservation Countdown Banner -->
{#if reservationExpiresAt && !isReservationExpired}
<div class="mx-6 mt-4 rounded-lg border border-green-200 bg-green-50 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg
class="h-5 w-5 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span class="text-sm font-medium text-green-800"> Slot held for </span>
</div>
<span class="font-mono text-sm font-semibold text-green-700">
{reservationCountdown}
</span>
</div>
</div>
{:else if isReservationExpired}
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 p-3">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span class="text-sm font-medium text-red-800">
Slot released — please re-check availability
</span>
</div>
</div>
{/if}
<!-- 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 (optional — helps us reach you if running late)"
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}
showContactLink={false}
/>
{/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 (service.id)}
<!-- 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-1 gap-4 sm:grid-cols-2">
<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>
<CharCounter text={notes} />
</div>
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
<Button
disabled={submitting || isReservationExpired}
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>