From 8f8bf83b64cfcd544143d9966a32a9b256f7abbd Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 15 Jun 2026 16:58:28 +0100 Subject: [PATCH] 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 --- .../admin/BookingCreateModal.svelte | 298 +++++++++++- .../lib/components/admin/BookingsCard.svelte | 2 +- .../admin/CustomServicesManagement.svelte | 449 ++++++++++++++++++ .../lib/components/admin/ImageUpload.svelte | 2 +- .../admin/ServicesManagement.svelte | 20 +- .../components/admin/WalkInCreateModal.svelte | 284 ++++++++++- 6 files changed, 1036 insertions(+), 19 deletions(-) create mode 100644 frontend/src/lib/components/admin/CustomServicesManagement.svelte diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index c714581..bc86d96 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -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([]); let loadingServices = $state(true); + // Custom Services + let customServices = $state>([]); + 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>({}); + + 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 = { 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 = { 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} + +
+

Or book a custom service

+
+
+ { if (e.key === 'Enter') fetchCustomServices(); }} + class="flex-1" + /> + +
+ {#if loadingCustomServices} +
+ {#each Array(3) as _, i (i)} + + {/each} +
+ {:else if customServices.length > 0} +
+ {#each customServices as cs (cs.id)} + + {/each} +
+ {/if} + +
+
+
+
+ + 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} +

{customServiceErrors.name}

+ {/if} +
+
+ + +
+
+
+ + customServiceErrors.price = validateCsPrice(newCustomService.price)} + onblur={() => customServiceErrors.price = validateCsPrice(newCustomService.price)} + placeholder="0.00" + class={customServiceErrors.price ? 'border-red-500' : ''} + /> + {#if customServiceErrors.price} +

{customServiceErrors.price}

+ {/if} +
+
+ + + {#if customServiceErrors.duration_minutes} +

{customServiceErrors.duration_minutes}

+ {/if} +
+
+
+ + customServiceErrors.minimum_age_required = validateCsMinimumAge(newCustomService.minimum_age_required)} + class="w-full {customServiceErrors.minimum_age_required ? 'border-red-500' : ''}" + /> + {#if customServiceErrors.minimum_age_required} +

{customServiceErrors.minimum_age_required}

+ {/if} +

0 for no age restriction

+
+
+ + +
+
+
+
+ {#if selectedServices.length > 0}

Selected Services

diff --git a/frontend/src/lib/components/admin/BookingsCard.svelte b/frontend/src/lib/components/admin/BookingsCard.svelte index 9f4a8e9..79c9e3e 100644 --- a/frontend/src/lib/components/admin/BookingsCard.svelte +++ b/frontend/src/lib/components/admin/BookingsCard.svelte @@ -28,7 +28,7 @@ try { const params = new URLSearchParams({ page: page.toString(), - per_page: '4' + per_page: '3' }); let url = '/api/admin/bookings'; diff --git a/frontend/src/lib/components/admin/CustomServicesManagement.svelte b/frontend/src/lib/components/admin/CustomServicesManagement.svelte new file mode 100644 index 0000000..5a69a74 --- /dev/null +++ b/frontend/src/lib/components/admin/CustomServicesManagement.svelte @@ -0,0 +1,449 @@ + + + + +
+
+ Custom Services + + One-off services for bridal parties, special requests, and custom bookings. + +
+ +
+
+ + +
+ { if (e.key === 'Enter') { page = 1; fetchServices(); } }} + class="max-w-sm" + /> + +
+ + + + {#if loading} +
+ {#each Array(3) as _, i (i)} + + {/each} +
+ {:else if services.length === 0} +
+ {searchQuery ? 'No custom services match your search.' : 'No custom services yet.'} +
+ {:else} + + +
+ {#each services as service (service.id)} +
+
+
+

{service.name}

+ {service.usage_count}× used +
+ {#if service.description} +

{service.description}

+ {/if} +
+
Price: £{service.price.toFixed(2)}
+
Duration: {service.duration_minutes} min
+
+
+ + +
+
+
+ {/each} +
+ + {#if total > perPage} +
+ + Page {page} of {Math.ceil(total / perPage)} ({total} total) + +
+ + +
+
+ {/if} + {/if} +
+
+ + + + + Add Custom Service + Create a one-off service for special requests. + + +
+
+ + validateField('name')} + onblur={() => validateField('name')} + class="w-full {serviceErrors.name ? 'border-red-500' : ''}" + /> + {#if serviceErrors.name} +

{serviceErrors.name}

+ {/if} +
+ +
+ + +
+ +
+
+ +
+ £ + validateField('price')} + onblur={() => validateField('price')} + class="w-full pl-8 {serviceErrors.price ? 'border-red-500' : ''}" + /> +
+ {#if serviceErrors.price} +

{serviceErrors.price}

+ {/if} +
+ +
+ + + {#if serviceErrors.duration_minutes} +

{serviceErrors.duration_minutes}

+ {/if} +
+
+ +
+ + validateField('minimum_age_required')} + onblur={() => validateField('minimum_age_required')} + class="w-full {serviceErrors.minimum_age_required ? 'border-red-500' : ''}" + /> + {#if serviceErrors.minimum_age_required} +

{serviceErrors.minimum_age_required}

+ {/if} +

0 for no age restriction

+
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/admin/ImageUpload.svelte b/frontend/src/lib/components/admin/ImageUpload.svelte index 589419f..5a41565 100644 --- a/frontend/src/lib/components/admin/ImageUpload.svelte +++ b/frontend/src/lib/components/admin/ImageUpload.svelte @@ -4,7 +4,6 @@ import FileDropZone from '$lib/components/ui/file-drop-zone.svelte'; import { authStore } from '$lib/stores/auth.svelte'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; - import heic2any from 'heic2any'; // =============== Image Upload =============== let uploading = $state(false); @@ -50,6 +49,7 @@ } async function convertHeicToPng(file: File): Promise { + const heic2any = (await import('heic2any')).default; const result = await heic2any({ blob: file, toType: 'image/png' }); const blob = Array.isArray(result) ? result[0] : result; const pngName = file.name.replace(/\.(heic|heif)$/i, '.png'); diff --git a/frontend/src/lib/components/admin/ServicesManagement.svelte b/frontend/src/lib/components/admin/ServicesManagement.svelte index 862a182..7da5469 100644 --- a/frontend/src/lib/components/admin/ServicesManagement.svelte +++ b/frontend/src/lib/components/admin/ServicesManagement.svelte @@ -23,6 +23,8 @@ created_by?: string; }; + const durationOptions = Array.from({ length: 32 }, (_, i) => (i + 1) * 15); + let services = $state([]); let newService = $state< Omit @@ -563,18 +565,18 @@
- - Duration * + {#if serviceErrors.duration_minutes}

{serviceErrors.duration_minutes}

{/if} diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index 063eb0b..05efe24 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -10,6 +10,7 @@ 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'; @@ -20,7 +21,7 @@ import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte'; // Types - import type { Service } from '$lib/types/booking'; + import type { Service, CustomService } from '$lib/types/booking'; // =============== Props =============== interface Props { @@ -61,6 +62,67 @@ let selectedServices = $state([]); let loadingServices = $state(true); + // Custom Services + let customServices = $state>([]); + 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>({}); + + 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 let notes = $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 =============== async function submitBooking() { submitting = true; @@ -362,10 +498,11 @@ } } - const payload = { + const payload: Record = { 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 }; @@ -679,6 +816,147 @@ /> {/if} + +
+

Or book a custom service

+
+
+ { if (e.key === 'Enter') fetchCustomServices(); }} + class="flex-1" + /> + +
+ {#if loadingCustomServices} +
+ {#each Array(3) as _, i (i)} + + {/each} +
+ {:else if customServices.length > 0} +
+ {#each customServices as cs (cs.id)} + + {/each} +
+ {/if} + +
+
+
+
+ + 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} +

{customServiceErrors.name}

+ {/if} +
+
+ + +
+
+
+ + customServiceErrors.price = validateCsPrice(newCustomService.price)} + onblur={() => customServiceErrors.price = validateCsPrice(newCustomService.price)} + placeholder="0.00" + class={customServiceErrors.price ? 'border-red-500' : ''} + /> + {#if customServiceErrors.price} +

{customServiceErrors.price}

+ {/if} +
+
+ + + {#if customServiceErrors.duration_minutes} +

{customServiceErrors.duration_minutes}

+ {/if} +
+
+
+ + customServiceErrors.minimum_age_required = validateCsMinimumAge(newCustomService.minimum_age_required)} + class="w-full {customServiceErrors.minimum_age_required ? 'border-red-500' : ''}" + /> + {#if customServiceErrors.minimum_age_required} +

{customServiceErrors.minimum_age_required}

+ {/if} +

0 for no age restriction

+
+
+ + +
+
+
+
+ {#if selectedServices.length > 0}

Selected Services