Files
Crussell/frontend/src/lib/components/admin/WalkInCreateModal.svelte
T

1203 lines
40 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { generateUUID } from '$lib/utils/uuid';
import { toast } from 'svelte-sonner';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { getLocalTimeZone } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
// 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 { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { Skeleton } from '$lib/components/ui/skeleton';
import CharCounter from '$lib/components/ui/CharCounter.svelte';
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
// Booking Components
import BookingActions from '$lib/components/booking/BookingActions.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
// Types
import type { Service, CustomService } 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;
previous_first_name?: string | null;
previous_last_name?: string | null;
}>
>([]);
let selectedUserId = $state<string | null>(null);
let guestName = $state('');
let guestPhone = $state('');
let guestPhoneError = $state('');
let loadingUsers = $state(false);
// Step 2: Services
let services = $state<Service[]>([]);
let selectedServices = $state<Service[]>([]);
let loadingServices = $state(true);
// Custom Services
let customServices = $state<Array<CustomService & { is_custom: boolean }>>([]);
let customSearchQuery = $state('');
let loadingCustomServices = $state(false);
let showCustomCreateForm = $state(false);
let newCustomService = $state({
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
});
let creatingCustomService = $state(false);
let customServiceErrors = $state<Record<string, string>>({});
const durationOptions = Array.from({ length: 32 }, (_, i) => (i + 1) * 15);
function validateCsName(name: unknown): string {
const n = name === null || name === undefined ? '' : String(name);
if (!n.trim()) return 'Name is required';
return '';
}
function validateCsPrice(price: unknown): string {
const p = price === null || price === undefined ? '' : String(price);
if (!p.trim()) return 'Price is required';
const num = parseFloat(p);
if (isNaN(num) || num <= 0) return 'Must be greater than 0';
return '';
}
function validateCsDuration(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Duration is required';
const num = parseInt(v);
if (isNaN(num) || num <= 0) return 'Must be greater than 0';
return '';
}
function validateCsMinimumAge(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Required';
const num = parseInt(v);
if (isNaN(num) || num < 0 || num > 100) return 'Must be between 0 and 100';
return '';
}
function validateCsAll() {
customServiceErrors = {
name: validateCsName(newCustomService.name),
price: validateCsPrice(newCustomService.price),
duration_minutes: validateCsDuration(newCustomService.duration_minutes),
minimum_age_required: validateCsMinimumAge(newCustomService.minimum_age_required)
};
}
const isCustomFormValid = $derived(
(newCustomService.name ?? '').trim() !== '' &&
!customServiceErrors.name &&
!customServiceErrors.price &&
!customServiceErrors.duration_minutes &&
!customServiceErrors.minimum_age_required
);
function toggleCustomForm(show: boolean) {
showCustomCreateForm = show;
if (show) {
newCustomService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
}
}
// 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() && guestPhone.trim() && isValidUKPhone(guestPhone))
);
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.__walkInModalCountdownInterval) {
clearInterval(window.__walkInModalCountdownInterval);
}
const updateCountdown = () => {
if (!reservationExpiresAt) {
reservationCountdown = '';
return;
}
const now = new SvelteDate();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
reservationCountdown = 'Expired';
isReservationExpired = true;
if (window.__walkInModalCountdownInterval) {
clearInterval(window.__walkInModalCountdownInterval);
}
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
reservationCountdown = `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
updateCountdown();
window.__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) {
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
}
};
}
}
async function fetchCustomServices() {
loadingCustomServices = true;
try {
const params = new SvelteURLSearchParams();
if (customSearchQuery.trim()) {
params.set('q', customSearchQuery.trim());
} else {
params.set('popular', '3');
}
const response = await fetch(`/api/admin/custom-services?${params}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
const data = await response.json();
const list = data.services || data;
customServices = list.map((cs: any) => ({ ...cs, is_custom: true }));
}
} catch {
console.error('Failed to fetch custom services');
} finally {
loadingCustomServices = false;
}
}
async function createCustomService() {
validateCsAll();
if (!isCustomFormValid) {
toast.error('Please fix the validation errors');
return;
}
creatingCustomService = true;
try {
const response = await fetch('/api/admin/custom-services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
name: (newCustomService.name ?? '').trim(),
description: (newCustomService.description ?? '').trim() || undefined,
price: parseFloat(newCustomService.price ?? '0'),
duration_minutes: parseInt(newCustomService.duration_minutes ?? '0'),
minimum_age_required: parseInt(newCustomService.minimum_age_required ?? '0') || 0
})
});
if (response.ok) {
const cs = await response.json();
const customService = { ...cs, is_custom: true };
selectedServices = [...selectedServices, customService];
serviceOverrides = {
...serviceOverrides,
[cs.id]: {
price: cs.price.toFixed(2),
duration: cs.duration_minutes.toString(),
originalPrice: cs.price,
originalDuration: cs.duration_minutes
}
};
showCustomCreateForm = false;
newCustomService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
customServiceErrors = { name: '', price: '', duration_minutes: '' };
toast.success('Custom service created and added');
} else {
const err = await response.text();
toast.error(`Failed: ${err}`);
}
} catch {
toast.error('Network error');
} finally {
creatingCustomService = false;
}
}
// =============== Submission ===============
async function submitBooking() {
submitting = true;
try {
// Generate idempotency key if not already set (reused on retry)
if (!idempotencyKey) {
idempotencyKey = generateUUID();
}
// 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') {
if (!isValidUKPhone(guestPhone)) {
toast.error('Please enter a valid UK phone number for the guest');
return;
}
const phone = toE164UK(guestPhone)!;
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 londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
} else {
// Fallback: Calculate immediate start time (rounded to next 15 min)
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const londonTimeStr = new Date().toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [y, m, d] = londonDateStr.split('-').map(Number);
const [h, min] = londonTimeStr.split(':').map(Number);
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
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 = formatLocalDateTime(start);
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: string;
start_time: string;
service_ids: string[];
custom_service_ids: string[];
service_overrides:
| Array<{
service_id: string;
override_price: number | null;
override_duration_minutes: number | null;
}>
| undefined;
notes: string | null;
} = {
user_id: finalUserId,
start_time: dateTimeStr,
service_ids: selectedServices.filter((s) => !s.is_custom).map((s) => s.id),
custom_service_ids: selectedServices.filter((s) => s.is_custom).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();
toast.error(`Failed to create booking: ${errorText}`);
}
} catch (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 _, i (i)}
<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">
{formatUserName(
user.full_name,
user.previous_first_name,
user.previous_last_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>
<PhoneInput
id="guest-phone"
bind:value={guestPhone}
bind:error={guestPhoneError}
placeholder="07700 900000"
required={false}
/>
</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 _, i (i)}
<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}
<Separator />
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-600">Or book a custom service</h4>
<div class={showCustomCreateForm ? 'hidden' : ''}>
<div class="flex gap-2">
<Input
placeholder="Search existing custom services..."
bind:value={customSearchQuery}
onkeydown={(e) => {
if (e.key === 'Enter') fetchCustomServices();
}}
class="flex-1"
/>
<Button variant="outline" size="sm" onclick={fetchCustomServices}>Search</Button>
</div>
{#if loadingCustomServices}
<div class="space-y-2">
{#each Array(3) as _, i (i)}
<Skeleton class="h-10 w-full" />
{/each}
</div>
{:else if customServices.length > 0}
<div class="space-y-2">
{#each customServices as cs (cs.id)}
<button
class="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm hover:bg-gray-50"
onclick={() => {
if (!selectedServices.some((s) => s.id === cs.id)) {
selectedServices = [...selectedServices, cs];
serviceOverrides = {
...serviceOverrides,
[cs.id]: {
price: cs.price.toFixed(2),
duration: cs.duration_minutes.toString(),
originalPrice: cs.price,
originalDuration: cs.duration_minutes
}
};
toast.success(`Added "${cs.name}"`);
}
}}
>
<span class="font-medium">{cs.name}</span>
<span class="text-gray-500"
>{cs.duration_minutes} min £{cs.price.toFixed(2)}{cs.usage_count > 0
? ` (${cs.usage_count}×)`
: ''}</span
>
</button>
{/each}
</div>
{/if}
<Button
variant="ghost"
size="sm"
onclick={() => {
showCustomCreateForm = true;
}}
class="w-full"
>
+ Create new custom service
</Button>
</div>
<div class={showCustomCreateForm ? '' : 'hidden'}>
<div class="space-y-3 rounded-lg border p-4">
<div class="space-y-1">
<label for="walkin-cs-name" class="text-sm font-medium">Name *</label>
<Input
id="walkin-cs-name"
bind:value={newCustomService.name}
oninput={() =>
(customServiceErrors.name = validateCsName(newCustomService.name))}
onblur={() =>
(customServiceErrors.name = validateCsName(newCustomService.name))}
placeholder="e.g., Bridal Party French Tips"
class={customServiceErrors.name ? 'border-red-500' : ''}
/>
{#if customServiceErrors.name}
<p class="text-xs text-red-600">{customServiceErrors.name}</p>
{/if}
</div>
<div class="space-y-1">
<label for="walkin-cs-desc" class="text-sm font-medium">Description</label>
<Input
id="walkin-cs-desc"
bind:value={newCustomService.description}
placeholder="Brief description"
class="w-full"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<label for="walkin-cs-price" class="text-sm font-medium">Price (£) *</label>
<Input
id="walkin-cs-price"
type="number"
step="0.01"
min="0"
bind:value={newCustomService.price}
oninput={() =>
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
onblur={() =>
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
placeholder="0.00"
class={customServiceErrors.price ? 'border-red-500' : ''}
/>
{#if customServiceErrors.price}
<p class="text-xs text-red-600">{customServiceErrors.price}</p>
{/if}
</div>
<div class="space-y-1">
<label for="walkin-cs-dur" class="text-sm font-medium">Duration *</label>
<select
id="walkin-cs-dur"
bind:value={newCustomService.duration_minutes}
onchange={() =>
(customServiceErrors.duration_minutes = validateCsDuration(
newCustomService.duration_minutes
))}
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 {customServiceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
<option value="">Select...</option>
{#each durationOptions as mins (mins)}
<option value={mins}
>{mins} min{mins >= 60
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
: ''}</option
>
{/each}
</select>
{#if customServiceErrors.duration_minutes}
<p class="text-xs text-red-600">{customServiceErrors.duration_minutes}</p>
{/if}
</div>
</div>
<div class="space-y-1">
<label for="walkin-cs-age" class="text-sm font-medium">Minimum Age</label>
<Input
id="walkin-cs-age"
type="number"
inputmode="numeric"
min="0"
max="100"
placeholder="0"
bind:value={newCustomService.minimum_age_required}
oninput={() =>
(customServiceErrors.minimum_age_required = validateCsMinimumAge(
newCustomService.minimum_age_required
))}
class="w-full {customServiceErrors.minimum_age_required
? 'border-red-500'
: ''}"
/>
{#if customServiceErrors.minimum_age_required}
<p class="text-xs text-red-600">{customServiceErrors.minimum_age_required}</p>
{/if}
<p class="text-xs text-gray-500">0 for no age restriction</p>
</div>
<div class="flex gap-2">
<Button
size="sm"
onclick={createCustomService}
disabled={creatingCustomService || !isCustomFormValid}
>
{creatingCustomService ? 'Creating...' : 'Save & Add'}
</Button>
<Button variant="outline" size="sm" onclick={() => toggleCustomForm(false)}>
Cancel
</Button>
</div>
</div>
</div>
</div>
{#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>