Add extractErrorMessage helper for JSON error body parsing and apply sanitizeText across all toast displays. Add time_blockers test coverage for new holiday placeholder cleanup and overlapping scenarios.
537 lines
16 KiB
Svelte
537 lines
16 KiB
Svelte
<script lang="ts">
|
||
import { apiFetch } from '$lib/utils/api';
|
||
import { toast } from 'svelte-sonner';
|
||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||
import { range } from '$lib/utils/format';
|
||
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';
|
||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||
|
||
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);
|
||
const 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);
|
||
}
|
||
|
||
const 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 SvelteURLSearchParams({
|
||
page: page.toString(),
|
||
per_page: perPage.toString()
|
||
});
|
||
if (searchQuery.trim()) params.set('q', searchQuery.trim());
|
||
const response = await apiFetch(`/api/admin/custom-services?${params}`);
|
||
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 apiFetch('/api/admin/custom-services', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
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: ' + sanitizeText(extractErrorMessage(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 apiFetch(`/api/admin/custom-services/${id}/promote`, {
|
||
method: 'POST'
|
||
});
|
||
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 {
|
||
const errText = await response.text();
|
||
toast.error('Failed: ' + sanitizeText(extractErrorMessage(errText)));
|
||
}
|
||
} catch {
|
||
toast.error('Network error');
|
||
}
|
||
}
|
||
|
||
async function deleteService(id: string) {
|
||
if (!confirm('Delete this custom service?')) return;
|
||
try {
|
||
const response = await apiFetch(`/api/admin/custom-services/${id}`, {
|
||
method: 'DELETE'
|
||
});
|
||
if (response.ok) {
|
||
toast.success('Deleted');
|
||
await fetchServices();
|
||
} else if (response.status === 409) {
|
||
toast.error('Cannot delete: used in bookings. Promote first.');
|
||
} else {
|
||
const errText = await response.text();
|
||
toast.error('Failed: ' + sanitizeText(extractErrorMessage(errText)));
|
||
}
|
||
} 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 range(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('en-GB', { timeZone: 'Europe/London' })})`
|
||
: ''}
|
||
</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-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 {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>
|