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
// but keeping the Label and other components.
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';
@@ -25,7 +26,7 @@
import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte';
// Types
import type { Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import type { Service, CustomService, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import {
buildLunchProtection,
@@ -67,6 +68,73 @@
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)
};
}
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
let notes = $state('');
let serviceOverrides = $state<
@@ -478,7 +546,8 @@
localDate.setHours(hours, minutes, 0, 0);
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
const overrides = [];
@@ -492,7 +561,7 @@
}
}
const payload = {
const payload: Record<string, any> = {
user_id: selectedUserId || null,
start_time: startTimeISO,
service_ids: serviceIds,
@@ -500,6 +569,9 @@
ttl_minutes: 15,
reservation_type: 'callin'
};
if (customServiceIds.length > 0) {
payload.custom_service_ids = customServiceIds;
}
const response = await fetch('/api/admin/bookings/reserve', {
method: 'POST',
@@ -598,6 +670,80 @@
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(
currentStep === 4 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(
@@ -715,10 +861,11 @@
}
}
const payload = {
const payload: Record<string, any> = {
user_id: finalUserId,
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,
notes: notes.trim() || null
};
@@ -993,6 +1140,147 @@
/>
{/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}
<div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold">Selected Services</h4>