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