feat(frontend): update admin components

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-18 16:26:58 +01:00
co-authored by Sisyphus
parent 165d3d6c38
commit b23a8b89c4
16 changed files with 370 additions and 121 deletions
@@ -2,12 +2,15 @@
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import CharCounter from '$lib/components/ui/CharCounter.svelte';
import type { BookingDiscount } from '$lib/types/booking';
interface Props {
open: boolean;
@@ -28,6 +31,7 @@
price?: number;
duration_minutes?: number;
}>;
discounts?: BookingDiscount[];
};
onApproved: () => void;
}
@@ -328,7 +332,7 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to confirm: ' + text, { id: loadingToast });
toast.error('Failed to confirm: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error('Error confirming booking:', err);
@@ -358,7 +362,7 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to decline: ' + text, { id: loadingToast });
toast.error('Failed to decline: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error('Error declining booking:', err);
@@ -371,7 +375,7 @@
</script>
<Modal.Root bind:open>
<Modal.Content class="!z-[70] max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-lg md:max-w-2xl">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Approve Booking</Modal.Title>
<Modal.Description>
@@ -452,35 +456,72 @@
</div>
{/if}
<!-- Customer Contact Info -->
<!-- Appointment Details -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Contact
Appointment Details
</h3>
<div class="space-y-2">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.full_name || '—'}</div>
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
<div class="font-medium">{getBookingDateTime()}</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
{#if getTotalDuration() > 0}
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{booking.user?.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
<div class="text-xs text-gray-500">Duration</div>
<div class="font-medium">{formatDuration(getTotalDuration())}</div>
</div>
{/if}
<div>
<div class="text-xs text-gray-500">Created</div>
<div class="text-sm">{formatDateTime(booking.created_at)}</div>
</div>
</div>
{#if booking.notes}
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="mb-1 text-xs font-semibold text-amber-800">Booking Notes</div>
<div class="text-sm text-amber-900">{booking.notes}</div>
</div>
{/if}
</div>
<!-- Booking Date & Time -->
<!-- Customer Information -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Booking Date & Time
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Information
</h3>
<div class="text-lg font-medium">{getBookingDateTime()}</div>
<div class="mb-4 flex items-center gap-4">
<div>
<div class="text-lg font-semibold">{booking.user?.full_name || '—'}</div>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Email</div>
{#if booking.user?.email}
<a
href="mailto:{booking.user.email}"
class="font-medium break-words text-blue-600 hover:underline"
>{booking.user.email}</a
>
{:else}
<div class="font-medium"></div>
{/if}
</div>
<div>
<div class="text-xs text-gray-500">Phone</div>
{#if booking.user?.phone}
<a
href="tel:{booking.user.phone}"
class="font-medium text-blue-600 hover:underline">{booking.user.phone}</a
>
{:else}
<div class="font-medium"></div>
{/if}
</div>
</div>
</div>
<!-- Booking Notes -->
@@ -503,8 +544,8 @@
{/if}
</div>
<!-- Service Overrides -->
<div>
<!-- Services & Pricing -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Services & Pricing
</h3>
@@ -530,7 +571,7 @@
type="text"
inputmode="decimal"
value={serviceOverrides[service.service_id].price}
oninput={(e) => handlePriceInput(service.service_id, e.target.value)}
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
onblur={() => {
// Format to 2 decimal places on blur if needed
const val = serviceOverrides[service.service_id].price;
@@ -582,7 +623,7 @@
min="1"
step="1"
value={serviceOverrides[service.service_id].duration}
oninput={(e) => handleDurationInput(service.service_id, e.target.value)}
oninput={(e) => handleDurationInput(service.service_id, e.currentTarget.value)}
class="no-spin w-full"
placeholder={service.duration_minutes?.toString() || '60'}
/>
@@ -602,13 +643,25 @@
{/each}
</div>
</div>
{#if booking.discounts && booking.discounts.length > 0}
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
<p class="font-medium">Applied Discounts</p>
{#each booking.discounts as d}
<p class="mt-1">
- {d.discount_source === 'loyalty' ? 'Loyalty Stamp Card' : d.campaign_name || 'Promo Campaign'}
({d.discount_percent}% off): -£{d.discount_amount.toFixed(2)}
</p>
{/each}
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-between gap-2">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span class="font-medium">Total: £{getTotalCost().toFixed(2)}</span>
<span class="text-gray-400">|</span>
<span>{getTotalDuration()} min</span>
<span class="text-lg font-semibold">£{getTotalCost().toFixed(2)}</span>
<span class="text-gray-300">·</span>
<span class="text-sm text-gray-500">{getTotalDuration()} min</span>
</div>
<div class="flex items-center gap-2">
<Button
@@ -648,16 +701,3 @@
</AlertDialog.Content>
</AlertDialog.Root>
<style>
/* Hide number input arrows for all number inputs in the component */
:global(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
:global(input[type='number']::-webkit-outer-spin-button),
:global(input[type='number']::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
</style>
@@ -730,7 +730,7 @@
}
};
showCustomCreateForm = false;
newCustomService = { name: '', price: '', duration_minutes: '' };
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
customServiceErrors = { name: '', price: '', duration_minutes: '' };
toast.success('Custom service created and added');
} else {
@@ -1,11 +1,15 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format';
import { POLICY } from '$lib/constants/policy';
import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking';
interface Props {
@@ -19,6 +23,63 @@
let selectedBooking = $state<Booking | null>(null);
let showApprovalModal = $state(false);
let showRescheduleModal = $state(false);
let showCancelModal = $state(false);
let forgiveFeesCancel = $state(false);
let forgiveNoShowCancel = $state(false);
let cancelling = $state(false);
// Derived values for cancel confirmation
let totalPaid = $derived(
(selectedBooking?.payments ?? [])
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0)
);
let hoursUntilAppt = $derived(
selectedBooking
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
: Infinity
);
let protectedDeposit = $derived(
selectedBooking ? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT) : 0
);
let estimatedRefund = $derived(
forgiveFeesCancel
? totalPaid
: hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS
? totalPaid
: hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
? Math.max(0, totalPaid - protectedDeposit)
: 0
);
async function handleAdminCancel() {
if (!selectedBooking) return;
cancelling = true;
try {
const body: Record<string, unknown> = {};
if (forgiveFeesCancel) body.forgive_fees = true;
if (forgiveNoShowCancel) body.forgive_noshow = true;
const response = await fetch(`/api/admin/bookings/${selectedBooking.id}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(body)
});
if (response.ok) {
toast.success('Booking cancelled');
showCancelModal = false;
fetchBookingDetails();
} else {
const text = await response.text();
toast.error('Failed to cancel: ' + text);
}
} catch (err) {
toast.error('Network error');
} finally {
cancelling = false;
}
}
// Calculate total duration from services
// IMPORTANT: Use override_duration_minutes when present — services may have been
@@ -242,7 +303,7 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Appointment Details
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
<div class="font-medium">
@@ -333,7 +394,7 @@
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Email</div>
{#if selectedBooking.user?.email}
@@ -560,7 +621,10 @@
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit'].includes(selectedBooking.status)}
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed'].includes(selectedBooking.status)}
<Button variant="destructive" size="sm" onclick={() => (showCancelModal = true)}>
Cancel Booking
</Button>
<Button variant="outline" onclick={() => (showRescheduleModal = true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -606,3 +670,102 @@
}}
/>
{/if}
{#if selectedBooking && showCancelModal}
<Modal.Root bind:open={showCancelModal}>
<Modal.Content class="!z-[80] max-w-[calc(100%-2rem)] sm:max-w-md">
<Modal.Header>
<Modal.Title>Cancel Booking</Modal.Title>
<Modal.Description>
{@const apptHours = Math.round(hoursUntilAppt)}
{#if totalPaid > 0}
<p>
This booking has payments totalling <span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
</p>
{:else}
<p>Are you sure you want to cancel this booking?</p>
{/if}
{#if totalPaid > 0}
<div class="mt-3 rounded-md border p-3 text-sm {apptHours < 24
? 'border-red-200 bg-red-50 text-red-800'
: 'border-amber-200 bg-amber-50 text-amber-800'}">
<p class="font-medium">
{apptHours > POLICY.FULL_REFUND_THRESHOLD_HOURS ? `Full Refund — Over ${POLICY.FULL_REFUND_THRESHOLD_HOURS}h notice` : apptHours >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS ? `Partial Refund ${apptHours}h notice` : `No Refund Under ${POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}h notice`}
</p>
<p class="mt-1">
{apptHours > POLICY.FULL_REFUND_THRESHOLD_HOURS ? `You've given over ${POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. £${totalPaid.toFixed(2)} will be refunded in full.` : apptHours >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS ? `You've paid £${totalPaid.toFixed(2)}. Up to ${POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of the total (£${(selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT).toFixed(2)}) counts as protected deposit. £${protectedDeposit.toFixed(2)} will be retained and £${estimatedRefund.toFixed(2)} will be refunded.` : `Under ${POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}h notice. The full £${totalPaid.toFixed(2)} is retained to cover the lost slot.`}
</p>
<details class="mt-1 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
<p class="mt-1">
{apptHours > 72 ? "Over 72 hours' notice means no deposit protection applies — a full refund is given regardless." : apptHours >= 24 ? "Between 2472 hours' notice, up to 50% of the total (" + '£' + (selectedBooking.total_amount * 0.5).toFixed(2) + ") is treated as a protected deposit to cover the lost slot. The remaining balance above that is refunded." : "Under 24 hours' notice, the full amount paid (" + '£' + totalPaid.toFixed(2) + ") is retained. This also counts as a no-show toward deposit obligations."}
</p>
</details>
<label class="mt-2 flex items-center gap-2 cursor-pointer">
<Checkbox bind:checked={forgiveFeesCancel} />
<span class="text-xs">Forgive fees — refund the <strong>full</strong> amount paid</span>
</label>
<details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
<p class="mt-1">"We've applied a full refund to this booking as a goodwill gesture. No deposit protection will be applied."</p>
</details>
<p class="mt-3 font-medium">No-Show Record</p>
<p class="mt-1">This cancellation counts as a no-show toward deposit obligations.</p>
<details class="mt-1 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
<p class="mt-1">"This cancellation will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings to secure your appointment."</p>
</details>
<label class="mt-2 flex items-center gap-2 cursor-pointer">
<Checkbox bind:checked={forgiveNoShowCancel} />
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
</label>
<details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
<p class="mt-1">"We've waived the no-show record for this cancellation so your deposit obligations remain unaffected."</p>
</details>
</div>
{:else if apptHours < 24}
<div class="mt-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
<p class="font-semibold text-red-900">No-Show Warning — {apptHours}h before appointment</p>
<p class="mt-1">This booking has no payments but is under 24 hours' notice. Cancelling counts as a no-show toward deposit obligations.</p>
<details class="mt-1 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
<p class="mt-1">"This cancellation will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings to secure your appointment."</p>
</details>
<label class="mt-2 flex items-center gap-2 cursor-pointer">
<Checkbox bind:checked={forgiveNoShowCancel} />
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
</label>
<details class="ml-6 mt-1 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
<p class="mt-1">"We've waived the no-show record for this cancellation. Your deposit obligations will not be affected."</p>
</details>
</div>
{:else}
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
<p class="font-medium">Clean Cancellation</p>
<p class="mt-1">This booking has no payments. The booking will be removed cleanly.</p>
</div>
{/if}
<p class="mt-3 text-xs text-gray-500">
<PolicyPopover>
{#snippet trigger()}
<span class="underline">View full cancellation policy →</span>
{/snippet}
</PolicyPopover>
</p>
</Modal.Description>
</Modal.Header>
<Modal.Footer class="flex gap-2">
<Button variant="outline" onclick={() => (showCancelModal = false)} disabled={cancelling}>
Keep Booking
</Button>
<Button variant="destructive" onclick={handleAdminCancel} disabled={cancelling}>
{cancelling ? 'Cancelling...' : 'Yes, Cancel'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
@@ -167,9 +167,9 @@
completed: 'bg-green-100 text-green-800',
client_cancelled: 'bg-red-100 text-red-800',
we_cancelled: 'bg-rose-100 text-rose-800',
're-schedule': 'bg-purple-100 text-purple-800',
no_show: 'bg-gray-100 text-gray-800',
no_deposit: 'bg-orange-100 text-orange-800'
pending_release: 'bg-orange-100 text-orange-800',
deposit_lapsed: 'bg-yellow-100 text-yellow-800'
};
return `${baseClasses} ${statusMap[status] || 'bg-gray-100 text-gray-800'}`;
}
@@ -182,9 +182,9 @@
completed: 'bg-green-600',
client_cancelled: 'bg-red-600',
we_cancelled: 'bg-rose-600',
're-schedule': 'bg-purple-600',
no_show: 'bg-gray-600',
no_deposit: 'bg-orange-600'
pending_release: 'bg-orange-600',
deposit_lapsed: 'bg-yellow-600'
};
return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`;
}
@@ -1,6 +1,7 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@@ -154,7 +155,7 @@
await fetchServices();
} else {
const err = await response.text();
toast.error(`Failed: ${err}`);
toast.error('Failed: ' + sanitizeText(err));
}
} catch {
toast.error('Network error');
@@ -177,7 +178,8 @@
} else if (response.status === 409) {
toast.error('A service with this name already exists');
} else {
toast.error(`Failed: ${await response.text()}`);
const errText = await response.text();
toast.error('Failed: ' + sanitizeText(errText));
}
} catch {
toast.error('Network error');
@@ -197,7 +199,8 @@
} else if (response.status === 409) {
toast.error('Cannot delete: used in bookings. Promote first.');
} else {
toast.error(`Failed: ${await response.text()}`);
const errText = await response.text();
toast.error('Failed: ' + sanitizeText(errText));
}
} catch {
toast.error('Network error');
@@ -10,6 +10,7 @@
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Modal from '$lib/components/ui/dialog';
import { Badge } from '$lib/components/ui/badge';
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
type Campaign = {
id: string;
@@ -282,28 +283,6 @@
}
}
function statusBadge(status: string) {
const map: Record<
string,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
draft: { label: 'Draft', variant: 'secondary' },
active: { label: 'Active', variant: 'default' },
completed: { label: 'Completed', variant: 'outline' },
cancelled: { label: 'Cancelled', variant: 'destructive' }
};
const s = map[status] || map.draft;
return `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
s.variant === 'default'
? 'bg-emerald-100 text-emerald-800'
: s.variant === 'secondary'
? 'bg-gray-100 text-gray-800'
: s.variant === 'destructive'
? 'bg-red-100 text-red-800'
: 'bg-blue-100 text-blue-800'
}">${s.label}</span>`;
}
function typeLabel(c: Campaign): string {
if (c.campaign_type === 'time_based') return 'Time-based';
return MILESTONE_LABELS[c.milestone_type] || c.milestone_type;
@@ -361,7 +340,7 @@
<td class="py-3 font-medium">{c.name}</td>
<td class="py-3 text-gray-600">{typeLabel(c)}</td>
<td class="py-3">{c.discount_percent}%</td>
<td class="py-3">{@html statusBadge(c.status)}</td>
<td class="py-3"><StatusBadge status={c.status} /></td>
<td class="py-3 text-gray-600">{redemptionDisplay(c)}</td>
<td class="py-3">
<div class="flex justify-end gap-1">
@@ -412,7 +391,7 @@
<div class="rounded-lg border p-4">
<div class="mb-2 flex items-start justify-between">
<div class="font-medium">{c.name}</div>
{@html statusBadge(c.status)}
<StatusBadge status={c.status} />
</div>
<div class="mb-3 grid grid-cols-2 gap-2 text-xs text-gray-600">
<div>Type: {typeLabel(c)}</div>
@@ -108,7 +108,7 @@
override_price: s.override_price,
override_duration_minutes: s.override_duration_minutes
})),
payments: (data.payments || []).map((p) => ({
payments: (data.payments || []).map((p: any) => ({
id: p.id,
booking_id: p.booking_id,
payment_type: p.payment_type,
@@ -675,7 +675,7 @@
type="text"
inputmode="decimal"
value={overridePrice}
oninput={(e) => handlePriceInput(e.target.value)}
oninput={(e) => handlePriceInput(e.currentTarget.value)}
onblur={() => {
if (overridePrice) {
if (!overridePrice.includes('.')) {
@@ -718,7 +718,7 @@
min="1"
step="1"
value={overrideDuration}
oninput={(e) => handleDurationInput(e.target.value)}
oninput={(e: Event) => handleDurationInput((e.target as HTMLInputElement).value)}
class="no-spin w-full"
placeholder={overrideOriginalDuration.toString()}
/>
@@ -489,14 +489,14 @@
);
function handleEphemeralCardNumberInput(e: Event) {
const target = e.currentTarget;
const target = e.currentTarget as HTMLInputElement;
const clean = target.value.replace(/\D/g, '');
const formatted = clean.match(/.{1,4}/g)?.join(' ') || clean;
ephemeralCardNumber = formatted.slice(0, 19);
}
function handleEphemeralExpiryInput(e: Event) {
const target = e.currentTarget;
const target = e.currentTarget as HTMLInputElement;
const clean = target.value.replace(/\D/g, '');
if (clean.length > 2) {
ephemeralCardExpiry = clean.slice(0, 2) + '/' + clean.slice(2, 4);
@@ -506,7 +506,7 @@
}
function handleEphemeralCvcInput(e: Event) {
const target = e.currentTarget;
const target = e.currentTarget as HTMLInputElement;
ephemeralCardCVC = target.value.replace(/\D/g, '').slice(0, 4);
}
@@ -2,6 +2,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -36,29 +37,11 @@
let exceptionGroupsLoading = $state(true);
let savingHours = $state(false);
let isFormValid = $derived(
exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0
);
let formErrors = $state({
name: '',
weeks: ''
});
function validateNameField() {
formErrors.name = exceptionDraft.name.trim() === '' ? 'Name is required' : '';
}
function validateWeeksField() {
formErrors.weeks =
exceptionDraft.weekStarts.length === 0 ? 'At least one week must be selected' : '';
}
function validateAllFields() {
validateNameField();
validateWeeksField();
}
// Exception modal state
let showExceptionModal = $state(false);
let exceptionDraft = $state<ExceptionGroup>({
@@ -76,6 +59,24 @@
]
});
let isFormValid = $derived(
exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0
);
function validateNameField() {
formErrors.name = exceptionDraft.name.trim() === '' ? 'Name is required' : '';
}
function validateWeeksField() {
formErrors.weeks =
exceptionDraft.weekStarts.length === 0 ? 'At least one week must be selected' : '';
}
function validateAllFields() {
validateNameField();
validateWeeksField();
}
let weekRangeFrom = $state('');
let weekRangeTo = $state('');
@@ -147,13 +148,13 @@
if (data === null || data.length === 0) {
return;
}
exceptionGroups = data.map((group) => ({
exceptionGroups = data.map((group: any) => ({
id: group.id,
name: group.name,
description: group.description,
weekStarts: group.weekStarts || [],
hours:
group.hours?.map((h) => ({
group.hours?.map((h: any) => ({
id: h.id,
weekday: h.weekday,
start_time: formatTime(h.startTime),
@@ -213,7 +214,7 @@
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to create: ' + text, { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error('Error creating exception group:', err);
@@ -244,7 +245,7 @@
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + text, { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error('Error deleting exception group:', err);
@@ -50,8 +50,8 @@
}
function validateNoticeDurationField() {
const val = formData.notice_duration_hours;
if (val === null || val === undefined || val === '') {
const val = formData.notice_duration_hours as number | null | undefined;
if (val === null || val === undefined) {
formErrors.notice_duration = 'Must be a positive number';
return;
}
@@ -64,8 +64,8 @@
}
function validateExpiryField() {
const val = formData.expiry_months;
if (val === null || val === undefined || val === '') {
const val = formData.expiry_months as number | null | undefined;
if (val === null || val === undefined) {
formErrors.expiry = 'Must be at least 1 month';
return;
}
@@ -90,11 +90,8 @@
!formErrors.expiry &&
formData.notice_duration_hours !== null &&
formData.notice_duration_hours !== undefined &&
formData.notice_duration_hours !== '' &&
Number(formData.notice_duration_hours) >= 0 &&
formData.expiry_months !== null &&
(formData.notice_duration_hours as number) >= 0 &&
formData.expiry_months !== undefined &&
formData.expiry_months !== '' &&
Number(formData.expiry_months) >= 1
);
@@ -3,6 +3,8 @@
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { Checkbox } from '$lib/components/ui/checkbox';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
@@ -65,6 +67,21 @@
maxDate.getDate()
);
let hoursUntilAppointment = $derived(
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
);
let hasPayments = $derived(
(booking.amount_paid ?? 0) > 0
);
let showNoticeWarning = $derived(
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
);
let showNoShowWarning = $derived(
!hasPayments && hoursUntilAppointment < 24 && hoursUntilAppointment >= 0
);
let forgiveFees = $state(false);
let forgiveNoShow = $state(false);
let bookingDuration = $derived(
booking.services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
@@ -305,13 +322,16 @@
saving = true;
const loadingToast = toast.loading('Rescheduling booking...');
try {
const body: Record<string, unknown> = { start_time: newStartTime };
if (forgiveFees) body.forgive_fees = true;
if (forgiveNoShow) body.forgive_noshow = true;
const response = await fetch(`/api/admin/bookings/${booking.id}/reschedule`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({ start_time: newStartTime })
body: JSON.stringify(body)
});
if (response.ok) {
toast.success('Booking rescheduled!', { id: loadingToast });
@@ -408,7 +428,7 @@
</script>
<Modal.Root bind:open>
<Modal.Content class="!z-[70] max-h-[90vh] max-w-4xl overflow-y-auto">
<Modal.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-4xl">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Reschedule Booking</Modal.Title>
<Modal.Description>
@@ -417,6 +437,49 @@
</Modal.Header>
<div class="px-6 pb-4">
{#if showNoticeWarning || showNoShowWarning}
<div class="mb-4 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<p class="font-medium text-amber-900">Short Notice Reschedule</p>
{#if hasPayments}
<p class="mt-1">Rescheduling may forfeit deposit protection on payments made.</p>
<details class="mt-1 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
<p class="mt-1">"Rescheduling within {Math.round(hoursUntilAppointment)}h of the original time with payments present means deposit protection applies — up to 50% of the total (up to £{(booking.total_amount * 0.5).toFixed(2)}) could be retained depending on notice period."</p>
</details>
<label class="mt-2 flex items-center gap-2 cursor-pointer">
<Checkbox bind:checked={forgiveFees} />
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span>
</label>
<details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
<p class="mt-1">"We've waived deposit protection on this reschedule as a goodwill gesture. The full amount moves to the new appointment instead of having up to 50% retained as deposit."</p>
</details>
{/if}
<p class="mt-1">This time change counts as a no-show toward deposit obligations.</p>
<details class="mt-1 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
<p class="mt-1">"This time change will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings."</p>
</details>
<label class="mt-2 flex items-center gap-2 cursor-pointer">
<Checkbox bind:checked={forgiveNoShow} />
<span class="text-xs">Forgive no-show — this reschedule will <strong>not</strong> count toward deposit obligations</span>
</label>
<details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
<p class="mt-1">"We've waived the no-show record for this reschedule so your deposit obligations are unaffected."</p>
</details>
</div>
<p class="mt-2 text-xs text-gray-500">
<PolicyPopover>
{#snippet trigger()}
<span class="underline">View full cancellation policy →</span>
{/snippet}
</PolicyPopover>
</p>
{/if}
<Card.Root class="mb-4">
<Card.Content class="pt-4">
<div class="text-sm font-medium">
@@ -2,6 +2,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { generateUUID } from '$lib/utils/uuid';
type CartItem = {
id: string;
@@ -26,7 +27,7 @@
if (existing) {
existing.qty++;
} else {
cart = [...cart, { id: crypto.randomUUID(), label, price, qty: 1 }];
cart = [...cart, { id: generateUUID(), label, price, qty: 1 }];
}
}
@@ -2,6 +2,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { formatDuration } from '$lib/utils/format';
import { Button } from '$lib/components/ui/button';
@@ -431,7 +432,7 @@
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to create: ' + text, { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error('Error creating time blocker:', err);
@@ -461,7 +462,7 @@
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + text, { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error('Error deleting time blocker:', err);
@@ -54,9 +54,9 @@
| 'completed'
| 'client_cancelled'
| 'we_cancelled'
| 're-schedule'
| 'no_show'
| 'no_deposit';
| 'pending_release'
| 'deposit_lapsed';
deposit_required: boolean;
deposit_paid: boolean;
services: Array<{
@@ -412,7 +412,7 @@
? 'bg-green-100 text-green-800'
: booking.status === 'client_cancelled' ||
booking.status === 'we_cancelled' ||
booking.status === 'no_deposit'
booking.status === 'deposit_lapsed'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}"
>
@@ -1,5 +1,6 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { generateUUID } from '$lib/utils/uuid';
import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity';
import { getLocalTimeZone } from '@internationalized/date';
@@ -386,7 +387,7 @@
}
};
showCustomCreateForm = false;
newCustomService = { name: '', price: '', duration_minutes: '' };
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
customServiceErrors = { name: '', price: '', duration_minutes: '' };
toast.success('Custom service created and added');
} else {
@@ -407,7 +408,7 @@
try {
// Generate idempotency key if not already set (reused on retry)
if (!idempotencyKey) {
idempotencyKey = crypto.randomUUID();
idempotencyKey = generateUUID();
}
// Validate duration doesn't exceed available slot
@@ -112,7 +112,7 @@
if (response.ok) {
const data = await response.json();
defaultHours = data.map((hour) => ({
defaultHours = data.map((hour: any) => ({
weekday: hour.weekday,
start_time: formatTime(hour.startTime),
end_time: formatTime(hour.endTime),