feat(frontend): add custom services management UI components

Add CustomServicesManagement component for CRUD operations on custom services. Update BookingCreateModal, WalkInCreateModal, and BookingsCard to support custom service selection. Minor improvements to ServicesManagement and ImageUpload.

Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-15 16:58:28 +01:00
co-authored by Sisyphus
parent c62e8322b5
commit 8f8bf83b64
6 changed files with 1036 additions and 19 deletions
@@ -12,6 +12,7 @@
// Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs // Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs
// but keeping the Label and other components. // but keeping the Label and other components.
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import CharCounter from '$lib/components/ui/CharCounter.svelte'; import CharCounter from '$lib/components/ui/CharCounter.svelte';
@@ -25,7 +26,7 @@
import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte'; import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte';
// Types // Types
import type { Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking'; import type { Service, CustomService, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import { import {
buildLunchProtection, buildLunchProtection,
@@ -67,6 +68,73 @@
let selectedServices = $state<Service[]>([]); let selectedServices = $state<Service[]>([]);
let loadingServices = $state(true); 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)
};
}
let 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: '' };
requestAnimationFrame(() => {
const modalContent = document.querySelector('[data-custom-form-container]');
if (modalContent) {
modalContent.scrollTop = modalContent.scrollHeight;
}
});
}
}
// Step 3: Service Overrides & Notes // Step 3: Service Overrides & Notes
let notes = $state(''); let notes = $state('');
let serviceOverrides = $state< let serviceOverrides = $state<
@@ -478,7 +546,8 @@
localDate.setHours(hours, minutes, 0, 0); localDate.setHours(hours, minutes, 0, 0);
const startTimeISO = localDate.toISOString(); const startTimeISO = localDate.toISOString();
const serviceIds = selectedServices.map((s) => s.id); const serviceIds = selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id);
const customServiceIds = selectedServices.filter(s => (s as any).is_custom).map((s) => s.id);
// Build service overrides payload // Build service overrides payload
const overrides = []; const overrides = [];
@@ -492,7 +561,7 @@
} }
} }
const payload = { const payload: Record<string, any> = {
user_id: selectedUserId || null, user_id: selectedUserId || null,
start_time: startTimeISO, start_time: startTimeISO,
service_ids: serviceIds, service_ids: serviceIds,
@@ -500,6 +569,9 @@
ttl_minutes: 15, ttl_minutes: 15,
reservation_type: 'callin' reservation_type: 'callin'
}; };
if (customServiceIds.length > 0) {
payload.custom_service_ids = customServiceIds;
}
const response = await fetch('/api/admin/bookings/reserve', { const response = await fetch('/api/admin/bookings/reserve', {
method: 'POST', method: 'POST',
@@ -598,6 +670,80 @@
selectedTime = null; selectedTime = null;
} }
async function fetchCustomServices() {
loadingCustomServices = true;
try {
const params = new URLSearchParams();
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: '', price: '', duration_minutes: '' };
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;
}
}
const groupedTimeSlots = $derived( const groupedTimeSlots = $derived(
currentStep === 4 && selectedServices.length > 0 && selectedDate currentStep === 4 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots( ? generateGroupedTimeSlots(
@@ -715,10 +861,11 @@
} }
} }
const payload = { const payload: Record<string, any> = {
user_id: finalUserId, user_id: finalUserId,
start_time: dateTimeStr, start_time: dateTimeStr,
service_ids: selectedServices.map((s) => s.id), service_ids: selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id),
custom_service_ids: selectedServices.filter(s => (s as any).is_custom).map((s) => s.id),
service_overrides: overrides.length > 0 ? overrides : undefined, service_overrides: overrides.length > 0 ? overrides : undefined,
notes: notes.trim() || null notes: notes.trim() || null
}; };
@@ -993,6 +1140,147 @@
/> />
{/if} {/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="booking-cs-name" class="text-sm font-medium">Name *</label>
<Input
id="booking-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="booking-cs-desc" class="text-sm font-medium">Description</label>
<Input
id="booking-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="booking-cs-price" class="text-sm font-medium">Price (£) *</label>
<Input
id="booking-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="booking-cs-dur" class="text-sm font-medium">Duration *</label>
<select
id="booking-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:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {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="booking-cs-age" class="text-sm font-medium">Minimum Age</label>
<Input
id="booking-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={() => { showCustomCreateForm = false; newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' }; customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' }; }}>
Cancel
</Button>
</div>
</div>
</div>
</div>
{#if selectedServices.length > 0} {#if selectedServices.length > 0}
<div class="rounded-lg bg-gray-50 p-4"> <div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold">Selected Services</h4> <h4 class="mb-2 font-semibold">Selected Services</h4>
@@ -28,7 +28,7 @@
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: page.toString(), page: page.toString(),
per_page: '4' per_page: '3'
}); });
let url = '/api/admin/bookings'; let url = '/api/admin/bookings';
@@ -0,0 +1,449 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Separator } from '$lib/components/ui/separator';
type CustomService = {
id: string;
name: string;
description: string | null;
price: number;
duration_minutes: number;
notes: string | null;
created_at: string;
created_by?: string;
usage_count: number;
last_used_at: string | null;
};
let services = $state<CustomService[]>([]);
let total = $state(0);
let page = $state(1);
let perPage = $state(10);
let searchQuery = $state('');
let loading = $state(true);
let creating = $state(false);
let showCreateModal = $state(false);
let serviceErrors = $state<Record<string, string>>({});
let newService = $state({
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
});
const durationOptions = Array.from({ length: 32 }, (_, i) => (i + 1) * 15);
function validatePrice(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)) return 'Must be a valid number';
if (num <= 0) return 'Must be greater than 0';
if (!/^\d+(\.\d{1,2})?$/.test(p.trim())) return 'Up to 2 decimal places';
return '';
}
function validateDuration(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)) return 'Must be a valid number';
if (num <= 0) return 'Must be greater than 0';
return '';
}
function validateName(name: unknown): string {
const n = name === null || name === undefined ? '' : String(name);
if (!n.trim()) return 'Name is required';
if (n.trim().length > 100) return 'Max 100 characters';
return '';
}
function validateMinimumAge(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Required';
const num = parseInt(v);
if (isNaN(num)) return 'Must be a valid number';
if (num < 0 || num > 100) return 'Must be between 0 and 100';
return '';
}
function updateAllErrors() {
serviceErrors = {
name: validateName(newService.name),
price: validatePrice(newService.price),
duration_minutes: validateDuration(newService.duration_minutes),
minimum_age_required: validateMinimumAge(newService.minimum_age_required)
};
}
function validateField(field: string) {
if (field === 'name') serviceErrors.name = validateName(newService.name);
if (field === 'price') serviceErrors.price = validatePrice(newService.price);
if (field === 'duration_minutes') serviceErrors.duration_minutes = validateDuration(newService.duration_minutes);
if (field === 'minimum_age_required') serviceErrors.minimum_age_required = validateMinimumAge(newService.minimum_age_required);
}
let isFormValid = $derived(
(newService.name ?? '').trim() !== '' &&
!serviceErrors.name &&
!serviceErrors.price &&
!serviceErrors.duration_minutes &&
!serviceErrors.minimum_age_required
);
async function fetchServices() {
loading = true;
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: perPage.toString()
});
if (searchQuery.trim()) params.set('q', searchQuery.trim());
const response = await fetch(`/api/admin/custom-services?${params}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
const data = await response.json();
services = data.services;
total = data.total;
} else {
toast.error('Failed to load custom services');
}
} catch {
toast.error('Network error loading custom services');
} finally {
loading = false;
}
}
async function createService() {
updateAllErrors();
if (!isFormValid) {
toast.error('Please fix the validation errors');
return;
}
creating = 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: (newService.name ?? '').trim(),
description: (newService.description ?? '').trim() || undefined,
price: parseFloat(newService.price ?? '0'),
duration_minutes: parseInt(newService.duration_minutes ?? '0'),
minimum_age_required: parseInt(newService.minimum_age_required ?? '0') || 0
})
});
if (response.ok) {
toast.success('Custom service created');
showCreateModal = false;
resetForm();
await fetchServices();
} else {
const err = await response.text();
toast.error(`Failed: ${err}`);
}
} catch {
toast.error('Network error');
} finally {
creating = false;
}
}
async function promoteService(id: string, name: string) {
if (!confirm(`Promote "${name}" to a regular catalog service? This will migrate all booking references.`)) return;
try {
const response = await fetch(`/api/admin/custom-services/${id}/promote`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
const data = await response.json();
toast.success(`Promoted to service "${name}" (ID: ${data.new_service_id})`);
await fetchServices();
} else if (response.status === 409) {
toast.error('A service with this name already exists');
} else {
toast.error(`Failed: ${await response.text()}`);
}
} catch {
toast.error('Network error');
}
}
async function deleteService(id: string) {
if (!confirm('Delete this custom service?')) return;
try {
const response = await fetch(`/api/admin/custom-services/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
toast.success('Deleted');
await fetchServices();
} else if (response.status === 409) {
toast.error('Cannot delete: used in bookings. Promote first.');
} else {
toast.error(`Failed: ${await response.text()}`);
}
} catch {
toast.error('Network error');
}
}
function resetForm() {
newService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
serviceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
}
$effect(() => {
fetchServices();
});
</script>
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<Card.Title>Custom Services</Card.Title>
<Card.Description>
One-off services for bridal parties, special requests, and custom bookings.
</Card.Description>
</div>
<Button onclick={() => { resetForm(); showCreateModal = true; }}>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Custom Service
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex gap-2">
<Input
placeholder="Search custom services..."
bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') { page = 1; fetchServices(); } }}
class="max-w-sm"
/>
<Button variant="outline" onclick={() => { page = 1; fetchServices(); }}>Search</Button>
</div>
<Separator />
{#if loading}
<div class="space-y-3">
{#each Array(3) as _, i (i)}
<Skeleton class="h-12 w-full" />
{/each}
</div>
{:else if services.length === 0}
<div class="py-8 text-center text-gray-500">
{searchQuery ? 'No custom services match your search.' : 'No custom services yet.'}
</div>
{:else}
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="py-3 font-medium">Name</th>
<th class="py-3 font-medium">Description</th>
<th class="py-3 text-right font-medium">Price</th>
<th class="py-3 text-right font-medium">Duration</th>
<th class="py-3 text-right font-medium">Used</th>
<th class="py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each services as service (service.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium">{service.name}</td>
<td class="py-3 text-gray-600">
{#if service.description}
<div class="line-clamp-1" title={service.description}>{service.description}</div>
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
<td class="py-3 text-right">{service.duration_minutes} min</td>
<td class="py-3 text-right">
{service.usage_count}×{service.last_used_at ? ` (last: ${new Date(service.last_used_at).toLocaleDateString()})` : ''}
</td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Button variant="outline" size="sm" onclick={() => promoteService(service.id, service.name)}>
Promote
</Button>
<Button variant="destructive" size="sm" onclick={() => deleteService(service.id)}>
Delete
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="space-y-4 md:hidden">
{#each services as service (service.id)}
<div class="rounded-lg border p-4 hover:bg-gray-50">
<div class="space-y-3">
<div class="flex items-start justify-between">
<h3 class="font-medium">{service.name}</h3>
<span class="text-xs text-gray-500">{service.usage_count}× used</span>
</div>
{#if service.description}
<p class="text-sm text-gray-600">{service.description}</p>
{/if}
<div class="flex justify-between text-sm">
<div><span class="font-medium">Price:</span> £{service.price.toFixed(2)}</div>
<div><span class="font-medium">Duration:</span> {service.duration_minutes} min</div>
</div>
<div class="flex gap-2 pt-2">
<Button variant="outline" size="sm" class="flex-1" onclick={() => promoteService(service.id, service.name)}>Promote</Button>
<Button variant="destructive" size="sm" class="flex-1" onclick={() => deleteService(service.id)}>Delete</Button>
</div>
</div>
</div>
{/each}
</div>
{#if total > perPage}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">
Page {page} of {Math.ceil(total / perPage)} ({total} total)
</span>
<div class="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => { page--; fetchServices(); }}>Previous</Button>
<Button variant="outline" size="sm" disabled={page >= Math.ceil(total / perPage)} onclick={() => { page++; fetchServices(); }}>Next</Button>
</div>
</div>
{/if}
{/if}
</Card.Content>
</Card.Root>
<Modal.Root bind:open={showCreateModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Add Custom Service</Modal.Title>
<Modal.Description>Create a one-off service for special requests.</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div class="space-y-2">
<label for="cs-name" class="text-sm font-medium">Name *</label>
<Input
id="cs-name"
type="text"
maxlength={100}
placeholder="e.g., Bridal Party French Tips"
bind:value={newService.name}
oninput={() => validateField('name')}
onblur={() => validateField('name')}
class="w-full {serviceErrors.name ? 'border-red-500' : ''}"
/>
{#if serviceErrors.name}
<p class="text-sm text-red-600">{serviceErrors.name}</p>
{/if}
</div>
<div class="space-y-2">
<label for="cs-desc" class="text-sm font-medium">Description</label>
<Input
id="cs-desc"
type="text"
placeholder="Brief description"
bind:value={newService.description}
class="w-full"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<label for="cs-price" class="text-sm font-medium">Price (£) *</label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-sm text-gray-500">£</span>
<Input
id="cs-price"
type="number"
inputmode="decimal"
step="0.01"
min="0"
placeholder="0.00"
bind:value={newService.price}
oninput={() => validateField('price')}
onblur={() => validateField('price')}
class="w-full pl-8 {serviceErrors.price ? 'border-red-500' : ''}"
/>
</div>
{#if serviceErrors.price}
<p class="text-sm text-red-600">{serviceErrors.price}</p>
{/if}
</div>
<div class="space-y-2">
<label for="cs-duration" class="text-sm font-medium">Duration *</label>
<select
id="cs-duration"
bind:value={newService.duration_minutes}
onchange={() => validateField('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:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.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 serviceErrors.duration_minutes}
<p class="text-sm text-red-600">{serviceErrors.duration_minutes}</p>
{/if}
</div>
</div>
<div class="space-y-2">
<label for="cs-min-age" class="text-sm font-medium">Minimum Age</label>
<Input
id="cs-min-age"
type="number"
inputmode="numeric"
min="0"
max="100"
placeholder="0"
bind:value={newService.minimum_age_required}
oninput={() => validateField('minimum_age_required')}
onblur={() => validateField('minimum_age_required')}
class="w-full {serviceErrors.minimum_age_required ? 'border-red-500' : ''}"
/>
{#if serviceErrors.minimum_age_required}
<p class="text-sm text-red-600">{serviceErrors.minimum_age_required}</p>
{/if}
<p class="text-xs text-gray-500">0 for no age restriction</p>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => { showCreateModal = false; resetForm(); }} disabled={creating}>Cancel</Button>
<Button onclick={createService} disabled={creating || !isFormValid}>
{creating ? 'Creating...' : 'Create'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
@@ -4,7 +4,6 @@
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte'; import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import heic2any from 'heic2any';
// =============== Image Upload =============== // =============== Image Upload ===============
let uploading = $state(false); let uploading = $state(false);
@@ -50,6 +49,7 @@
} }
async function convertHeicToPng(file: File): Promise<File> { async function convertHeicToPng(file: File): Promise<File> {
const heic2any = (await import('heic2any')).default;
const result = await heic2any({ blob: file, toType: 'image/png' }); const result = await heic2any({ blob: file, toType: 'image/png' });
const blob = Array.isArray(result) ? result[0] : result; const blob = Array.isArray(result) ? result[0] : result;
const pngName = file.name.replace(/\.(heic|heif)$/i, '.png'); const pngName = file.name.replace(/\.(heic|heif)$/i, '.png');
@@ -23,6 +23,8 @@
created_by?: string; created_by?: string;
}; };
const durationOptions = Array.from({ length: 32 }, (_, i) => (i + 1) * 15);
let services = $state<Service[]>([]); let services = $state<Service[]>([]);
let newService = $state< let newService = $state<
Omit<Service, 'id' | 'created_at' | 'created_by' | 'patch_test_duration_hours'> Omit<Service, 'id' | 'created_at' | 'created_by' | 'patch_test_duration_hours'>
@@ -563,18 +565,18 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label for="service-duration" class="text-sm font-medium">Duration (minutes) *</label> <label for="service-duration" class="text-sm font-medium">Duration *</label>
<Input <select
id="service-duration" id="service-duration"
type="number"
inputmode="numeric"
min="1"
step="1"
placeholder="60"
bind:value={newService.duration_minutes} bind:value={newService.duration_minutes}
onblur={validateDurationField} onblur={validateDurationField}
class="w-full {serviceErrors.duration_minutes ? 'border-red-500' : ''}" class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.duration_minutes ? 'border-red-500' : ''}"
/> >
<option value={0}>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 serviceErrors.duration_minutes} {#if serviceErrors.duration_minutes}
<p class="text-sm text-red-600">{serviceErrors.duration_minutes}</p> <p class="text-sm text-red-600">{serviceErrors.duration_minutes}</p>
{/if} {/if}
@@ -10,6 +10,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import CharCounter from '$lib/components/ui/CharCounter.svelte'; import CharCounter from '$lib/components/ui/CharCounter.svelte';
@@ -20,7 +21,7 @@
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte'; import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
// Types // Types
import type { Service } from '$lib/types/booking'; import type { Service, CustomService } from '$lib/types/booking';
// =============== Props =============== // =============== Props ===============
interface Props { interface Props {
@@ -61,6 +62,67 @@
let selectedServices = $state<Service[]>([]); let selectedServices = $state<Service[]>([]);
let loadingServices = $state(true); 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)
};
}
let 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 // Step 3: Service Overrides & Notes
let notes = $state(''); let notes = $state('');
let serviceOverrides = $state< let serviceOverrides = $state<
@@ -264,6 +326,80 @@
} }
} }
async function fetchCustomServices() {
loadingCustomServices = true;
try {
const params = new URLSearchParams();
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: '', price: '', duration_minutes: '' };
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 =============== // =============== Submission ===============
async function submitBooking() { async function submitBooking() {
submitting = true; submitting = true;
@@ -362,10 +498,11 @@
} }
} }
const payload = { const payload: Record<string, any> = {
user_id: finalUserId, user_id: finalUserId,
start_time: dateTimeStr, start_time: dateTimeStr,
service_ids: selectedServices.map((s) => s.id), service_ids: selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id),
custom_service_ids: selectedServices.filter(s => (s as any).is_custom).map((s) => s.id),
service_overrides: overrides.length > 0 ? overrides : undefined, service_overrides: overrides.length > 0 ? overrides : undefined,
notes: notes.trim() || null notes: notes.trim() || null
}; };
@@ -679,6 +816,147 @@
/> />
{/if} {/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:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {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} {#if selectedServices.length > 0}
<div class="rounded-lg bg-gray-50 p-4"> <div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold">Selected Services</h4> <h4 class="mb-2 font-semibold">Selected Services</h4>