style: apply prettier formatting to frontend
Backend CI / Lint & vulns (push) Failing after 1m59s
Backend CI / Tests (push) Successful in 2m1s
Backend CI / Race detector (push) Failing after 4m0s

This commit is contained in:
2026-06-25 13:26:26 +01:00
parent d4664c177c
commit ad0ad253ad
74 changed files with 3927 additions and 2632 deletions
@@ -1,11 +1,11 @@
<script lang="ts">
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 { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
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 { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -379,7 +379,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
</script>
<Modal.Root bind:open>
<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.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>
@@ -413,7 +415,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
{#if oldest?.id === ob.id}
<span class="text-amber-600" title="Booked first"></span>
{/if}
{formatUserName(ob.user?.full_name || 'Unknown', ob.user?.previous_first_name, ob.user?.previous_last_name)}
{formatUserName(
ob.user?.full_name || 'Unknown',
ob.user?.previous_first_name,
ob.user?.previous_last_name
)}
</div>
<div class="text-xs text-gray-500">
{parseWallClockDate(ob.start_time).toLocaleDateString('en-GB', {
@@ -497,7 +503,13 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
<div class="mb-4 flex items-center gap-4">
<div>
<div class="text-lg font-semibold">{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
<div class="text-lg font-semibold">
{formatUserName(
booking.user?.full_name || '—',
booking.user?.previous_first_name,
booking.user?.previous_last_name
)}
</div>
</div>
</div>
@@ -517,9 +529,8 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
<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
<a href="tel:{booking.user.phone}" class="font-medium text-blue-600 hover:underline"
>{booking.user.phone}</a
>
{:else}
<div class="font-medium"></div>
@@ -627,7 +638,8 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
min="1"
step="1"
value={serviceOverrides[service.service_id].duration}
oninput={(e) => handleDurationInput(service.service_id, e.currentTarget.value)}
oninput={(e) =>
handleDurationInput(service.service_id, e.currentTarget.value)}
class="no-spin w-full"
placeholder={service.duration_minutes?.toString() || '60'}
/>
@@ -653,7 +665,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
<p class="font-medium">Applied Discounts</p>
{#each booking.discounts as d}
<p class="mt-1">
- {d.discount_source === 'loyalty' ? 'Loyalty Stamp Card' : d.discount_source === 'referral' ? 'Referral Discount' : d.campaign_name || 'Promo Campaign'}
- {d.discount_source === 'loyalty'
? 'Loyalty Stamp Card'
: d.discount_source === 'referral'
? 'Referral Discount'
: d.campaign_name || 'Promo Campaign'}
({d.discount_percent}% off): -£{d.discount_amount.toFixed(2)}
</p>
{/each}
@@ -704,4 +720,3 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -676,9 +676,7 @@
const startTimeISO = formatLocalDateTime(localDate);
const serviceIds = selectedServices.filter((s) => !s.is_custom).map((s) => s.id);
const customServiceIds = selectedServices
.filter((s) => s.is_custom)
.map((s) => s.id);
const customServiceIds = selectedServices.filter((s) => s.is_custom).map((s) => s.id);
// Build service overrides payload
const overrides = [];
@@ -1007,7 +1005,13 @@
start_time: string;
service_ids: string[];
custom_service_ids: string[];
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
service_overrides:
| Array<{
service_id: string;
override_price: number | null;
override_duration_minutes: number | null;
}>
| undefined;
notes: string | null;
out_of_hours: boolean;
} = {
@@ -1657,7 +1661,9 @@
></path>
</svg>
<span class="text-sm font-medium text-green-800">
Slot reserved until {parseWallClockDate(reservationExpiresAt.toISOString()).toLocaleTimeString([], {
Slot reserved until {parseWallClockDate(
reservationExpiresAt.toISOString()
).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
@@ -45,11 +45,14 @@
let balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0);
let hoursUntilAppt = $derived(
selectedBooking
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
? (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
selectedBooking
? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT)
: 0
);
let estimatedRefund = $derived(
forgiveFeesCancel
@@ -216,14 +219,21 @@
}
}
function getPaymentName(payment: Payment, index: number, payments: Payment[] | undefined, discounts: BookingDiscount[] | undefined): string {
function getPaymentName(
payment: Payment,
index: number,
payments: Payment[] | undefined,
discounts: BookingDiscount[] | undefined
): string {
if (payment.payment_method === 'online_square') return 'Online Card';
if (payment.payment_method === 'in_person_card') return 'Card Machine';
if (payment.payment_method === 'cash') return 'Cash';
if (payment.payment_method === 'giftcard') return 'Gift Card';
if (payment.payment_method === 'discount') {
const discountPaymentsBefore = (payments ?? []).slice(0, index).filter(p => p.payment_method === 'discount').length;
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
const discountPaymentsBefore = (payments ?? [])
.slice(0, index)
.filter((p) => p.payment_method === 'discount').length;
const discountList = (discounts ?? []).filter((d) => d.discount_amount > 0.01);
if (discountList[discountPaymentsBefore]) {
const d = discountList[discountPaymentsBefore];
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
@@ -232,7 +242,9 @@
}
return 'Discount';
}
return (payment.payment_method as string).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (payment.payment_method as string)
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
$effect(() => {
@@ -323,8 +335,17 @@
<span
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-800"
>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
Out-of-hours
</span>
@@ -418,12 +439,22 @@
{#if selectedBooking.user?.profile_pic_url}
<img
src={selectedBooking.user.profile_pic_url}
alt={formatUserName(selectedBooking.user.full_name, selectedBooking.user.previous_first_name, selectedBooking.user.previous_last_name)}
alt={formatUserName(
selectedBooking.user.full_name,
selectedBooking.user.previous_first_name,
selectedBooking.user.previous_last_name
)}
class="h-16 w-16 rounded-full object-cover ring-4 ring-blue-200"
/>
{/if}
<div>
<div class="text-lg font-semibold">{formatUserName(selectedBooking.user?.full_name || '—', selectedBooking.user?.previous_first_name, selectedBooking.user?.previous_last_name)}</div>
<div class="text-lg font-semibold">
{formatUserName(
selectedBooking.user?.full_name || '—',
selectedBooking.user?.previous_first_name,
selectedBooking.user?.previous_last_name
)}
</div>
{#if selectedBooking.user?.date_of_birth}
<div class="text-sm text-gray-500">
{calculateAge(selectedBooking.user.date_of_birth)} years old
@@ -541,7 +572,9 @@
</div>
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
<div class="border-y border-fuchsia-100 bg-fuchsia-50/20 py-2 my-2 space-y-1 rounded-md px-2">
<div
class="my-2 space-y-1 rounded-md border-y border-fuchsia-100 bg-fuchsia-50/20 px-2 py-2"
>
{#each selectedBooking.discounts as d}
<div class="flex items-center justify-between text-xs text-fuchsia-800">
<span class="flex items-center gap-1.5">
@@ -562,14 +595,22 @@
</div>
<div class="flex items-center justify-between font-medium text-gray-900">
<span class="text-sm">Net Total</span>
<span>£{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)}</span>
<span
>£{(
selectedBooking.total_amount -
selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)
).toFixed(2)}</span
>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
<span class="font-semibold text-green-700">
£{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}
£{(selectedBooking.payments ?? [])
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0)
.toFixed(2)}
</span>
</div>
@@ -583,9 +624,7 @@
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">Balance Due</span>
<span
class="text-lg font-bold {balanceDue > 0.01
? 'text-red-600'
: 'text-green-600'}"
class="text-lg font-bold {balanceDue > 0.01 ? 'text-red-600' : 'text-green-600'}"
>
£{balanceDue.toFixed(2)}
</span>
@@ -606,7 +645,12 @@
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">
{getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)}
{getPaymentName(
payment,
index,
selectedBooking.payments,
selectedBooking.discounts
)}
</span>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
@@ -645,9 +689,8 @@
<div class="mt-2 text-xs text-gray-600">
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
<div>
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(
2
) || '0.00'}
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(2) ||
'0.00'}
</div>
</div>
{/if}
@@ -668,7 +711,9 @@
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-red-700">Refund</span>
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
<span
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
>
{refund.status}
</span>
</div>
@@ -751,71 +796,131 @@
{@const apptHours = Math.round(hoursUntilAppt)}
{#if totalPaid > 0}
<p>
This booking has payments totalling <span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
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'}">
<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`}
{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.`}
{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."}
{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">
<label class="mt-2 flex cursor-pointer items-center gap-2">
<Checkbox bind:checked={forgiveFeesCancel} />
<span class="text-xs">Forgive fees — refund the <strong>full</strong> amount paid</span>
<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>
<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>
<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">
<label class="mt-2 flex cursor-pointer items-center gap-2">
<Checkbox bind:checked={forgiveNoShowCancel} />
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
<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>
<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>
<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>
<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">
<label class="mt-2 flex cursor-pointer items-center gap-2">
<Checkbox bind:checked={forgiveNoShowCancel} />
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
<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 class="mt-1 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. 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">
<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>
@@ -284,7 +284,13 @@
</span>
{/if}
{/if}
<span>{formatUserName(b.user?.full_name || 'Unknown User', b.user?.previous_first_name, b.user?.previous_last_name)}</span>
<span
>{formatUserName(
b.user?.full_name || 'Unknown User',
b.user?.previous_first_name,
b.user?.previous_last_name
)}</span
>
<span>
- {formatServices(b.services)}
</span>
@@ -121,7 +121,8 @@
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
// UK VAT numbers: GB + 9 digits (standard) or GB + 12 digits (branch)
if (trimmed.length !== 11 && trimmed.length !== 14) return 'Must be GB followed by 9 or 12 digits';
if (trimmed.length !== 11 && trimmed.length !== 14)
return 'Must be GB followed by 9 or 12 digits';
if (!trimmed.startsWith('GB')) return 'Must start with GB';
const digits = trimmed.slice(2);
if (!/^\d+$/.test(digits)) return 'Must contain only digits after GB';
@@ -156,13 +157,16 @@
function validateField(field: string, inputValue?: unknown) {
const val = inputValue ?? form[field as keyof typeof form];
if (field === 'business_name') formErrors.business_name = validateBusinessName(val);
else if (field === 'business_address') formErrors.business_address = validateBusinessAddress(val);
else if (field === 'business_address')
formErrors.business_address = validateBusinessAddress(val);
else if (field === 'business_phone') formErrors.business_phone = validatePhone(val);
else if (field === 'business_email') formErrors.business_email = validateEmail(val);
else if (field === 'website_url') formErrors.website_url = validateWebsiteUrl(val);
else if (field === 'vat_registration_number') formErrors.vat_registration_number = validateVatNumber(val);
else if (field === 'vat_registration_number')
formErrors.vat_registration_number = validateVatNumber(val);
else if (field === 'default_vat_rate') formErrors.default_vat_rate = validateVatRate(val);
else if (field === 'gift_card_expiry_months') formErrors.gift_card_expiry_months = validateGiftCardExpiry(val);
else if (field === 'gift_card_expiry_months')
formErrors.gift_card_expiry_months = validateGiftCardExpiry(val);
else if (field === 'voucher_type') formErrors.voucher_type = validateVoucherType(val);
}
@@ -192,7 +196,9 @@
let normalisedEmail = form.business_email?.trim() ?? null;
if (normalisedEmail) normalisedEmail = normalizeEmail(normalisedEmail);
const normalisedPhone = form.business_phone ? toE164UK(form.business_phone) ?? normalisePhoneInput(form.business_phone) : null;
const normalisedPhone = form.business_phone
? (toE164UK(form.business_phone) ?? normalisePhoneInput(form.business_phone))
: null;
const normalisedForm = {
...form,
@@ -205,9 +211,16 @@
};
const fields: (keyof BusinessSettings)[] = [
'business_name', 'business_address', 'business_phone', 'business_email',
'vat_registration_number', 'is_vat_registered', 'default_vat_rate',
'website_url', 'gift_card_expiry_months', 'voucher_type'
'business_name',
'business_address',
'business_phone',
'business_email',
'vat_registration_number',
'is_vat_registered',
'default_vat_rate',
'website_url',
'gift_card_expiry_months',
'voucher_type'
];
for (const field of fields) {
@@ -235,7 +248,7 @@
if (res.ok) {
const wasVatEnabled = settings?.is_vat_registered;
const updated = await res.json() as BusinessSettings;
const updated = (await res.json()) as BusinessSettings;
settings = updated;
if (!wasVatEnabled && updated.is_vat_registered) {
toast.success('VAT enabled — past payments and till sales updated retroactively');
@@ -268,9 +281,7 @@
Business details, VAT configuration, and gift card defaults.
</Card.Description>
</div>
<Button onclick={openEditModal} disabled={loading || !settings}>
Edit Settings
</Button>
<Button onclick={openEditModal} disabled={loading || !settings}>Edit Settings</Button>
</div>
</Card.Header>
@@ -292,7 +303,9 @@
<div class="grid gap-6 md:grid-cols-2">
<!-- Business Information -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Business Information</h3>
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
Business Information
</h3>
<div class="space-y-2">
<div>
<span class="text-xs text-muted-foreground">Business Name</span>
@@ -332,15 +345,17 @@
<!-- VAT Configuration -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">VAT & Currency</h3>
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
VAT & Currency
</h3>
<div class="space-y-2">
<div>
<span class="text-xs text-muted-foreground">VAT Registered</span>
<p class="text-sm font-medium">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.is_vat_registered
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-gray-50 text-gray-600 border border-gray-200'}"
? 'border border-green-200 bg-green-50 text-green-700'
: 'border border-gray-200 bg-gray-50 text-gray-600'}"
>
{settings.is_vat_registered ? 'Registered' : 'Not Registered'}
</span>
@@ -363,7 +378,9 @@
<!-- Gift Card Configuration -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Gift Cards</h3>
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
Gift Cards
</h3>
<div class="space-y-2">
<div>
<span class="text-xs text-muted-foreground">Expiry Period</span>
@@ -373,9 +390,10 @@
<span class="text-xs text-muted-foreground">Voucher Type</span>
<p class="text-sm font-medium">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.voucher_type === 'MPV'
? 'bg-blue-50 text-blue-700 border border-blue-200'
: 'bg-purple-50 text-purple-700 border border-purple-200'}"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.voucher_type ===
'MPV'
? 'border border-blue-200 bg-blue-50 text-blue-700'
: 'border border-purple-200 bg-purple-50 text-purple-700'}"
>
{settings.voucher_type === 'MPV' ? 'MPV (0% VAT)' : 'SPV (20% VAT)'}
</span>
@@ -390,7 +408,7 @@
<!-- Edit Modal -->
<Modal.Root bind:open={showEditModal}>
<Modal.Content class="max-w-2xl max-h-[90vh] overflow-y-auto">
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title>Edit Business Settings</Modal.Title>
<Modal.Description>
@@ -398,10 +416,20 @@
</Modal.Description>
</Modal.Header>
<form class="space-y-6 py-4" onsubmit={(e) => { e.preventDefault(); saveSettings(); }}>
<form
class="space-y-6 py-4"
onsubmit={(e) => {
e.preventDefault();
saveSettings();
}}
>
<!-- Business Information Section -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">Business Information</h4>
<h4
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
>
Business Information
</h4>
<div class="space-y-2">
<Label.Root for="business_name">Business Name</Label.Root>
@@ -493,7 +521,11 @@
<!-- VAT & Currency Section -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">VAT & Currency</h4>
<h4
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
>
VAT & Currency
</h4>
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
@@ -502,18 +534,26 @@
Enable if your business is registered for UK VAT
</p>
</div>
<Checkbox id="is_vat_registered" checked={form.is_vat_registered ?? false} onCheckedChange={(v: boolean) => {
form.is_vat_registered = v;
if (!v) {
form.vat_registration_number = null;
form.default_vat_rate = 20.00;
formErrors.vat_registration_number = '';
formErrors.default_vat_rate = '';
}
}} />
<Checkbox
id="is_vat_registered"
checked={form.is_vat_registered ?? false}
onCheckedChange={(v: boolean) => {
form.is_vat_registered = v;
if (!v) {
form.vat_registration_number = null;
form.default_vat_rate = 20.0;
formErrors.vat_registration_number = '';
formErrors.default_vat_rate = '';
}
}}
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 {!form.is_vat_registered ? 'opacity-50 pointer-events-none' : ''}">
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 {!form.is_vat_registered
? 'pointer-events-none opacity-50'
: ''}"
>
<div class="space-y-2">
<Label.Root for="vat_registration_number">VAT Registration Number</Label.Root>
<Input
@@ -557,7 +597,11 @@
<!-- Gift Card Section -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">Gift Card Configuration</h4>
<h4
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
>
Gift Card Configuration
</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="space-y-2">
@@ -579,7 +623,7 @@
<Label.Root for="voucher_type">Voucher Type</Label.Root>
<select
id="voucher_type"
class="flex h-10 w-full rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
class="flex h-10 w-full rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={form.voucher_type}
onchange={() => validateField('voucher_type')}
>
@@ -89,16 +89,18 @@
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);
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);
}
let isFormValid = $derived(
(newService.name ?? '').trim() !== '' &&
!serviceErrors.name &&
!serviceErrors.price &&
!serviceErrors.duration_minutes &&
!serviceErrors.minimum_age_required
!serviceErrors.name &&
!serviceErrors.price &&
!serviceErrors.duration_minutes &&
!serviceErrors.minimum_age_required
);
async function fetchServices() {
@@ -165,7 +167,12 @@
}
async function promoteService(id: string, name: string) {
if (!confirm(`Promote "${name}" to a regular catalog service? This will migrate all booking references.`)) return;
if (
!confirm(
`Promote "${name}" to a regular catalog service? This will migrate all booking references.`
)
)
return;
try {
const response = await fetch(`/api/admin/custom-services/${id}/promote`, {
method: 'POST',
@@ -208,7 +215,13 @@
}
function resetForm() {
newService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
newService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
serviceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
}
@@ -226,8 +239,20 @@
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">
<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>
@@ -241,10 +266,21 @@
<Input
placeholder="Search custom services..."
bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') { page = 1; fetchServices(); } }}
onkeydown={(e) => {
if (e.key === 'Enter') {
page = 1;
fetchServices();
}
}}
class="max-w-sm"
/>
<Button variant="outline" onclick={() => { page = 1; fetchServices(); }}>Search</Button>
<Button
variant="outline"
onclick={() => {
page = 1;
fetchServices();
}}>Search</Button
>
</div>
<Separator />
@@ -278,7 +314,9 @@
<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>
<div class="line-clamp-1" title={service.description}>
{service.description}
</div>
{:else}
<span class="text-gray-400"></span>
{/if}
@@ -286,14 +324,24 @@
<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' })})` : ''}
{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)}>
<Button
variant="outline"
size="sm"
onclick={() => promoteService(service.id, service.name)}
>
Promote
</Button>
<Button variant="destructive" size="sm" onclick={() => deleteService(service.id)}>
<Button
variant="destructive"
size="sm"
onclick={() => deleteService(service.id)}
>
Delete
</Button>
</div>
@@ -320,8 +368,18 @@
<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>
<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>
@@ -334,8 +392,24 @@
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>
<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}
@@ -408,11 +482,17 @@
id="cs-duration"
bind:value={newService.duration_minutes}
onchange={() => validateField('duration_minutes')}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.duration_minutes ? 'border-red-500' : ''}"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {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>
<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}
@@ -443,7 +523,14 @@
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => { showCreateModal = false; resetForm(); }} disabled={creating}>Cancel</Button>
<Button
variant="outline"
onclick={() => {
showCreateModal = false;
resetForm();
}}
disabled={creating}>Cancel</Button
>
<Button onclick={createService} disabled={creating || !isFormValid}>
{creating ? 'Creating...' : 'Create'}
</Button>
@@ -166,11 +166,7 @@
if (form.campaign_type === 'time_based') {
if (!form.start_date) errors.start_date = 'Required';
if (!form.end_date) errors.end_date = 'Required';
if (
form.start_date &&
form.end_date &&
new Date(form.end_date) < new Date(form.start_date)
) {
if (form.start_date && form.end_date && new Date(form.end_date) < new Date(form.start_date)) {
errors.end_date = 'Must be after start';
}
}
@@ -1,10 +1,10 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
@@ -422,7 +422,13 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
<div class="font-medium">
{formatUserName(
booking.user?.full_name || '—',
booking.user?.previous_first_name,
booking.user?.previous_last_name
)}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
@@ -1,12 +1,12 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
interface ServiceItem {
id: string;
@@ -185,7 +185,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
<Modal.Description>
Review the requested changes to {formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}'s booking.
Review the requested changes to {formatUserName(
editRequest.user.full_name,
editRequest.user.previous_first_name,
editRequest.user.previous_last_name
)}'s booking.
</Modal.Description>
</Modal.Header>
@@ -198,14 +202,22 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
<div class="space-y-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}</div>
<div class="font-medium">
{formatUserName(
editRequest.user.full_name,
editRequest.user.previous_first_name,
editRequest.user.previous_last_name
)}
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">
{#if editRequest.user.phone}
<a href="tel:{editRequest.user.phone}" class="text-blue-600 hover:underline">{editRequest.user.phone}</a>
<a href="tel:{editRequest.user.phone}" class="text-blue-600 hover:underline"
>{editRequest.user.phone}</a
>
{:else}{/if}
</div>
</div>
@@ -213,7 +225,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">
{#if editRequest.user.email}
<a href="mailto:{editRequest.user.email}" class="text-blue-600 hover:underline">{editRequest.user.email}</a>
<a href="mailto:{editRequest.user.email}" class="text-blue-600 hover:underline"
>{editRequest.user.email}</a
>
{:else}{/if}
</div>
</div>
@@ -222,125 +236,125 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
</div>
{#if isTimeChanged()}
<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">
Date & Time Change
</h3>
<div class="space-y-3">
<div>
<div class="mb-1 text-xs text-gray-500">Before</div>
<div class="font-medium">
{formatDateLine1(editRequest.original.start_time)}
<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">
Date & Time Change
</h3>
<div class="space-y-3">
<div>
<div class="mb-1 text-xs text-gray-500">Before</div>
<div class="font-medium">
{formatDateLine1(editRequest.original.start_time)}
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(
editRequest.original.start_time,
getDuration(editRequest.original.services)
)}
</div>
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(
editRequest.original.start_time,
getDuration(editRequest.original.services)
)}
</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">After</div>
<div class="font-medium text-emerald-700">
{formatDateLine1(editRequest.proposed.start_time!)}
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(
editRequest.proposed.start_time!,
getDuration(editRequest.proposed.services)
)}
<div>
<div class="mb-1 text-xs text-gray-500">After</div>
<div class="font-medium text-emerald-700">
{formatDateLine1(editRequest.proposed.start_time!)}
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(
editRequest.proposed.start_time!,
getDuration(editRequest.proposed.services)
)}
</div>
</div>
</div>
</div>
</div>
{/if}
{#if areServicesChanged()}
<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 Change
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs text-gray-500">Original</div>
<div class="space-y-2">
{#each editRequest.original.services as service (service.id)}
{#if serviceDiff.removed.some((s) => s.id === service.id)}
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
<span class="mt-0.5 font-mono text-sm text-red-600"></span>
<div class="flex-1">
<div class="text-sm font-medium text-red-700 line-through">
{service.name}
</div>
<div class="text-xs text-red-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
<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 Change
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs text-gray-500">Original</div>
<div class="space-y-2">
{#each editRequest.original.services as service (service.id)}
{#if serviceDiff.removed.some((s) => s.id === service.id)}
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
<span class="mt-0.5 font-mono text-sm text-red-600"></span>
<div class="flex-1">
<div class="text-sm font-medium text-red-700 line-through">
{service.name}
</div>
<div class="text-xs text-red-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
</div>
{:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
<div class="flex-1">
<div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
{:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
<div class="flex-1">
<div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
</div>
{/if}
{/each}
{/if}
{/each}
</div>
</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
<div class="space-y-2">
{#each editRequest.proposed.services as service (service.id)}
{#if serviceDiff.added.some((s) => s.id === service.id)}
<div
class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
>
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
<div class="flex-1">
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
<div class="text-xs text-emerald-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
<div class="space-y-2">
{#each editRequest.proposed.services as service (service.id)}
{#if serviceDiff.added.some((s) => s.id === service.id)}
<div
class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
>
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
<div class="flex-1">
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
<div class="text-xs text-emerald-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
</div>
{:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
<div class="flex-1">
<div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
{:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
<div class="flex-1">
<div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
</div>
{/if}
{/each}
{/if}
{/each}
</div>
</div>
</div>
</div>
</div>
{/if}
{#if editRequest.proposed.notes !== editRequest.original.notes}
<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">
Booking Notes Change
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs text-gray-500">Original</div>
<div class="text-sm">{editRequest.original.notes || '—'}</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
<div class="text-sm">{editRequest.proposed.notes}</div>
<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">
Booking Notes Change
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs text-gray-500">Original</div>
<div class="text-sm">{editRequest.original.notes || '—'}</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
<div class="text-sm">{editRequest.proposed.notes}</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Request Notes -->
File diff suppressed because it is too large Load Diff
@@ -42,8 +42,14 @@
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => { URL.revokeObjectURL(url); resolve(true); };
img.onerror = () => { URL.revokeObjectURL(url); resolve(false); };
img.onload = () => {
URL.revokeObjectURL(url);
resolve(true);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(false);
};
img.src = url;
});
}
@@ -123,16 +129,20 @@
/** Encode ImageData to AVIF via Web Worker. */
function encodeAvif(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('$lib/workers/avif-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
const worker = new Worker(new URL('$lib/workers/avif-encoder.ts', import.meta.url), {
type: 'module'
});
worker.onmessage = (
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/avif' }));
};
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.onerror = (err) => {
worker.terminate();
reject(err);
};
worker.postMessage({ imageData, quality });
});
}
@@ -140,16 +150,20 @@
/** Encode ImageData to WebP via Web Worker. */
function encodeWebp(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('$lib/workers/webp-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
const worker = new Worker(new URL('$lib/workers/webp-encoder.ts', import.meta.url), {
type: 'module'
});
worker.onmessage = (
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/webp' }));
};
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.onerror = (err) => {
worker.terminate();
reject(err);
};
worker.postMessage({ imageData, quality });
});
}
@@ -157,16 +171,20 @@
/** Encode ImageData to JPEG via Web Worker. */
function encodeJpeg(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('$lib/workers/jpeg-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
const worker = new Worker(new URL('$lib/workers/jpeg-encoder.ts', import.meta.url), {
type: 'module'
});
worker.onmessage = (
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/jpeg' }));
};
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.onerror = (err) => {
worker.terminate();
reject(err);
};
worker.postMessage({ imageData, quality });
});
}
@@ -174,18 +192,26 @@
/** Encode ImageData to JPEG XL via Web Worker. Returns null if unavailable. */
function encodeJxl(imageData: ImageData, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
const worker = new Worker(
new URL('$lib/workers/jxl-encoder.ts', import.meta.url),
{ type: 'module' }
);
const timeout = setTimeout(() => { worker.terminate(); resolve(null); }, 10000);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
const worker = new Worker(new URL('$lib/workers/jxl-encoder.ts', import.meta.url), {
type: 'module'
});
const timeout = setTimeout(() => {
worker.terminate();
resolve(null);
}, 10000);
worker.onmessage = (
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
) => {
clearTimeout(timeout);
worker.terminate();
if (e.data.error) return resolve(null);
resolve(new Blob([e.data.encoded], { type: 'image/jxl' }));
};
worker.onerror = () => { clearTimeout(timeout); worker.terminate(); resolve(null); };
worker.onerror = () => {
clearTimeout(timeout);
worker.terminate();
resolve(null);
};
worker.postMessage({ imageData, quality });
});
}
@@ -610,7 +636,9 @@
<div class="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<div>
<Card.Title>Drop or Select Files</Card.Title>
<Card.Description>HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file</Card.Description>
<Card.Description
>HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file</Card.Description
>
</div>
</div>
</Card.Header>
@@ -634,7 +662,9 @@
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p class="text-sm font-medium text-gray-700">Drop images here or click to browse</p>
<p class="mt-1 text-xs text-gray-500">HEIC, AVIF, WebP, PNG, JPEG, GIF &amp; more &mdash; max 20MB per file</p>
<p class="mt-1 text-xs text-gray-500">
HEIC, AVIF, WebP, PNG, JPEG, GIF &amp; more &mdash; max 20MB per file
</p>
</div>
</FileDropZone>
@@ -688,11 +718,19 @@
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium" class:text-gray-900={!oversized} class:text-red-900={oversized}>
<p
class="truncate text-sm font-medium"
class:text-gray-900={!oversized}
class:text-red-900={oversized}
>
{f.name}
</p>
<div class="flex items-center gap-2">
<p class="text-xs" class:text-gray-500={!oversized} class:text-red-600={oversized}>
<p
class="text-xs"
class:text-gray-500={!oversized}
class:text-red-600={oversized}
>
{formatFileSize(f.size)}
{#if oversized}
&mdash; exceeds 20MB limit
@@ -740,10 +778,10 @@
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Upload Results Section -->
<!-- Upload Results Section -->
{#if uploadResults.length > 0}
<div class="border-t pt-6">
<h3 class="mb-4 font-semibold text-gray-900">Upload Results</h3>
@@ -895,7 +933,10 @@
<!-- Action Buttons -->
<div class="border-t pt-6">
<div class="flex justify-end">
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading || hasOversizedFiles}>
<Button
onclick={uploadOneOrMany}
disabled={!uploadFiles.length || uploading || hasOversizedFiles}
>
{#if uploading}
<svg
class="mr-2 h-4 w-4 animate-spin"
@@ -68,9 +68,7 @@
let hoursUntilAppointment = $derived(
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
);
let hasPayments = $derived(
(booking.amount_paid ?? 0) > 0
);
let hasPayments = $derived((booking.amount_paid ?? 0) > 0);
let showNoticeWarning = $derived(
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
);
@@ -439,30 +437,52 @@
<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>
<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">
<label class="mt-2 flex cursor-pointer items-center gap-2">
<Checkbox bind:checked={forgiveFees} />
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span>
<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>
<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>
<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">
<label class="mt-2 flex cursor-pointer items-center gap-2">
<Checkbox bind:checked={forgiveNoShow} />
<span class="text-xs">Forgive no-show — this reschedule will <strong>not</strong> count toward deposit obligations</span>
<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>
<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">
@@ -489,9 +509,12 @@
})}
</div>
<div class="mt-1 text-sm text-gray-500">
{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)} · {booking.services
?.map((s) => s.service_name)
.join(', ') || 'No services'} · {bookingDuration} min
{formatUserName(
booking.user?.full_name || 'Unknown',
booking.user?.previous_first_name,
booking.user?.previous_last_name
)} · {booking.services?.map((s) => s.service_name).join(', ') || 'No services'} · {bookingDuration}
min
</div>
</Card.Content>
</Card.Root>
@@ -570,11 +570,17 @@
id="service-duration"
bind:value={newService.duration_minutes}
onblur={validateDurationField}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.duration_minutes ? 'border-red-500' : ''}"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {serviceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
<option value={0}>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>
<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}
@@ -23,7 +23,7 @@
}
function addItem(label: string, price: number) {
const existing = cart.find(i => i.label === label);
const existing = cart.find((i) => i.label === label);
if (existing) {
existing.qty++;
} else {
@@ -40,15 +40,17 @@
}
function removeItem(id: string) {
cart = cart.filter(i => i.id !== id);
cart = cart.filter((i) => i.id !== id);
}
function updateQty(id: string, delta: number) {
cart = cart.map(i => {
if (i.id !== id) return i;
const next = i.qty + delta;
return next <= 0 ? null : { ...i, qty: next };
}).filter((i): i is CartItem => i !== null);
cart = cart
.map((i) => {
if (i.id !== id) return i;
const next = i.qty + delta;
return next <= 0 ? null : { ...i, qty: next };
})
.filter((i): i is CartItem => i !== null);
}
</script>
@@ -58,38 +60,74 @@
</div>
<div class="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Cuticle Oil', 8)}>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
onclick={() => addItem('Cuticle Oil', 8)}
>
Cuticle Oil - &pound;8
</Button>
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Nail Files (Pack)', 5)}>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
onclick={() => addItem('Nail Files (Pack)', 5)}
>
Nail Files - &pound;5
</Button>
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Hand Cream', 6)}>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
onclick={() => addItem('Hand Cream', 6)}
>
Hand Cream - &pound;6
</Button>
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Base Coat', 7)}>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
onclick={() => addItem('Base Coat', 7)}
>
Base Coat - &pound;7
</Button>
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Top Coat', 7)}>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
onclick={() => addItem('Top Coat', 7)}
>
Top Coat - &pound;7
</Button>
<div class="relative">
{#if showGiftCardInput}
<div class="flex gap-1">
<div class="relative flex-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">&pound;</span>
<span class="absolute top-1/2 left-2 -translate-y-1/2 text-xs text-gray-400"
>&pound;</span
>
<Input
type="text"
inputmode="decimal"
bind:value={giftCardAmount}
class="h-9 pl-5 text-sm"
onkeydown={(e) => { if (e.key === 'Enter') addGiftCard(); }}
onkeydown={(e) => {
if (e.key === 'Enter') addGiftCard();
}}
/>
</div>
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs">Add</Button>
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs"
>Add</Button
>
</div>
{:else}
<Button variant="outline" size="sm" class="w-full justify-start gap-2" onclick={() => showGiftCardInput = true}>
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2"
onclick={() => (showGiftCardInput = true)}
>
Gift Card
</Button>
{/if}
@@ -100,16 +138,20 @@
<div class="px-4 py-3">
{#if cart.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Tap items above to add them to the sale.</p>
<p class="py-6 text-center text-sm text-muted-foreground">
Tap items above to add them to the sale.
</p>
{:else}
<div class="max-h-48 space-y-1 overflow-y-auto">
{#each cart as item (item.id)}
<div class="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<div class="min-w-0 flex-1">
<span class="font-medium text-card-foreground">{item.label}</span>
<span class="ml-2 text-xs text-muted-foreground">{formatCurrency(item.price)} each</span>
<span class="ml-2 text-xs text-muted-foreground"
>{formatCurrency(item.price)} each</span
>
</div>
<div class="flex items-center gap-2 shrink-0">
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
@@ -125,14 +167,22 @@
>
+
</button>
<span class="w-14 text-right text-sm font-semibold tabular-nums">{formatCurrency(item.price * item.qty)}</span>
<span class="w-14 text-right text-sm font-semibold tabular-nums"
>{formatCurrency(item.price * item.qty)}</span
>
<button
type="button"
aria-label="Remove item"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600"
onclick={() => removeItem(item.id)}
>
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
@@ -153,7 +203,9 @@
<Button class="mt-3 w-full" disabled>
Charge {formatCurrency(subtotal)}
</Button>
<p class="mt-1 text-xs text-muted-foreground">Payment flow and backend integration coming soon.</p>
<p class="mt-1 text-xs text-muted-foreground">
Payment flow and backend integration coming soon.
</p>
{/if}
</div>
</div>
@@ -719,7 +719,9 @@
size="sm"
class="flex-1 text-xs"
onclick={() => {
newStartDate = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
newStartDate = new Date().toLocaleDateString('en-CA', {
timeZone: 'Europe/London'
});
onStartDateChange();
}}
>
@@ -731,7 +733,9 @@
size="sm"
class="flex-1 text-xs"
onclick={() => {
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', {
timeZone: 'Europe/London'
});
onStartDateChange();
}}
>
@@ -860,7 +864,13 @@
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-3">
<div class="min-w-0">
<div class="text-sm font-medium">{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
<div class="text-sm font-medium">
{formatUserName(
booking.user?.full_name || 'Unknown',
booking.user?.previous_first_name,
booking.user?.previous_last_name
)}
</div>
<div class="text-xs text-gray-500">
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
@@ -1,12 +1,12 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import PatchTestModal from './PatchTestModal.svelte';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import PatchTestModal from './PatchTestModal.svelte';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
interface Props {
open: boolean;
@@ -252,7 +252,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
const data = await res.json();
giftCardBalance = data.balance;
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
$effect(() => {
@@ -301,15 +303,21 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
{#if selectedUser}
<div class="space-y-6 px-4 pb-4">
<!-- Personal Information -->
<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">
Personal Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Full Name</div>
<div class="font-medium">{formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}</div>
</div>
<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">
Personal Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Full Name</div>
<div class="font-medium">
{formatUserName(
selectedUser.fullName,
selectedUser.previousFirstName,
selectedUser.previousLastName
)}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-words" title={selectedUser.email}>
@@ -637,7 +645,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
<PatchTestModal
bind:open={showPatchTestModal}
userId={selectedUser.id}
userName={formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}
userName={formatUserName(
selectedUser.fullName,
selectedUser.previousFirstName,
selectedUser.previousLastName
)}
onPatchTestAdded={() => {
fetchUserDetails();
}}
@@ -192,7 +192,9 @@
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
<div class="font-medium">
{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}
</div>
<div class="text-xs text-gray-500">
{user.email || '—'}{user.phone || '—'}
</div>
@@ -167,7 +167,9 @@
// --- Walk-in lunch protection ---
// Always block the first 60 minutes of the suggested lunch window from walk-in availability
const dayStartMinutesVal = Math.min(...todayData.slots.map((s) => timeToMinutes(s.startTime)));
const dayStartMinutesVal = Math.min(
...todayData.slots.map((s) => timeToMinutes(s.startTime))
);
const dayEndMinutesVal = Math.max(...todayData.slots.map((s) => timeToMinutes(s.endTime)));
const dayStartTimeVal = minutesToTime(dayStartMinutesVal);
const dayEndTimeVal = minutesToTime(dayEndMinutesVal);
@@ -52,7 +52,15 @@
let userType = $state<'member' | 'guest'>('member');
let userQuery = $state('');
let users = $state<
Array<{ id: string; full_name: string; email?: string; phone?: string; account_role: string; previous_first_name?: string | null; previous_last_name?: string | null }>
Array<{
id: string;
full_name: string;
email?: string;
phone?: string;
account_role: string;
previous_first_name?: string | null;
previous_last_name?: string | null;
}>
>([]);
let selectedUserId = $state<string | null>(null);
let guestName = $state('');
@@ -70,7 +78,13 @@
let customSearchQuery = $state('');
let loadingCustomServices = $state(false);
let showCustomCreateForm = $state(false);
let newCustomService = $state({ name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' });
let newCustomService = $state({
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
});
let creatingCustomService = $state(false);
let customServiceErrors = $state<Record<string, string>>({});
@@ -112,16 +126,22 @@
}
let isCustomFormValid = $derived(
(newCustomService.name ?? '').trim() !== '' &&
!customServiceErrors.name &&
!customServiceErrors.price &&
!customServiceErrors.duration_minutes &&
!customServiceErrors.minimum_age_required
!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: '' };
newCustomService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
}
}
@@ -388,7 +408,13 @@
}
};
showCustomCreateForm = false;
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
newCustomService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
customServiceErrors = { name: '', price: '', duration_minutes: '' };
toast.success('Custom service created and added');
} else {
@@ -467,7 +493,12 @@
} else {
// Fallback: Calculate immediate start time (rounded to next 15 min)
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const londonTimeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const londonTimeStr = new Date().toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [y, m, d] = londonDateStr.split('-').map(Number);
const [h, min] = londonTimeStr.split(':').map(Number);
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
@@ -502,13 +533,19 @@
start_time: string;
service_ids: string[];
custom_service_ids: string[];
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
service_overrides:
| Array<{
service_id: string;
override_price: number | null;
override_duration_minutes: number | null;
}>
| undefined;
notes: string | null;
} = {
user_id: finalUserId,
start_time: dateTimeStr,
service_ids: selectedServices.filter(s => !s.is_custom).map((s) => s.id),
custom_service_ids: selectedServices.filter(s => s.is_custom).map((s) => s.id),
service_ids: selectedServices.filter((s) => !s.is_custom).map((s) => s.id),
custom_service_ids: selectedServices.filter((s) => s.is_custom).map((s) => s.id),
service_overrides: overrides.length > 0 ? overrides : undefined,
notes: notes.trim() || null
};
@@ -720,7 +757,13 @@
onclick={() => (selectedUserId = user.id)}
>
<div>
<div class="text-base font-medium">{formatUserName(user.full_name, user.previous_first_name, user.previous_last_name)}</div>
<div class="text-base font-medium">
{formatUserName(
user.full_name,
user.previous_first_name,
user.previous_last_name
)}
</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
@@ -828,7 +871,9 @@
<Input
placeholder="Search existing custom services..."
bind:value={customSearchQuery}
onkeydown={(e) => { if (e.key === 'Enter') fetchCustomServices(); }}
onkeydown={(e) => {
if (e.key === 'Enter') fetchCustomServices();
}}
class="flex-1"
/>
<Button variant="outline" size="sm" onclick={fetchCustomServices}>Search</Button>
@@ -861,12 +906,23 @@
}}
>
<span class="font-medium">{cs.name}</span>
<span class="text-gray-500">{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0 ? ` (${cs.usage_count}×)` : ''}</span>
<span class="text-gray-500"
>{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0
? ` (${cs.usage_count}×)`
: ''}</span
>
</button>
{/each}
</div>
{/if}
<Button variant="ghost" size="sm" onclick={() => { showCustomCreateForm = true; }} class="w-full">
<Button
variant="ghost"
size="sm"
onclick={() => {
showCustomCreateForm = true;
}}
class="w-full"
>
+ Create new custom service
</Button>
</div>
@@ -877,8 +933,10 @@
<Input
id="walkin-cs-name"
bind:value={newCustomService.name}
oninput={() => customServiceErrors.name = validateCsName(newCustomService.name)}
onblur={() => customServiceErrors.name = validateCsName(newCustomService.name)}
oninput={() =>
(customServiceErrors.name = validateCsName(newCustomService.name))}
onblur={() =>
(customServiceErrors.name = validateCsName(newCustomService.name))}
placeholder="e.g., Bridal Party French Tips"
class={customServiceErrors.name ? 'border-red-500' : ''}
/>
@@ -904,8 +962,10 @@
step="0.01"
min="0"
bind:value={newCustomService.price}
oninput={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
onblur={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
oninput={() =>
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
onblur={() =>
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
placeholder="0.00"
class={customServiceErrors.price ? 'border-red-500' : ''}
/>
@@ -918,12 +978,21 @@
<select
id="walkin-cs-dur"
bind:value={newCustomService.duration_minutes}
onchange={() => customServiceErrors.duration_minutes = validateCsDuration(newCustomService.duration_minutes)}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {customServiceErrors.duration_minutes ? 'border-red-500' : ''}"
onchange={() =>
(customServiceErrors.duration_minutes = validateCsDuration(
newCustomService.duration_minutes
))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {customServiceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
<option value="">Select...</option>
{#each durationOptions as mins (mins)}
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
<option value={mins}
>{mins} min{mins >= 60
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
: ''}</option
>
{/each}
</select>
{#if customServiceErrors.duration_minutes}
@@ -941,8 +1010,13 @@
max="100"
placeholder="0"
bind:value={newCustomService.minimum_age_required}
oninput={() => customServiceErrors.minimum_age_required = validateCsMinimumAge(newCustomService.minimum_age_required)}
class="w-full {customServiceErrors.minimum_age_required ? 'border-red-500' : ''}"
oninput={() =>
(customServiceErrors.minimum_age_required = validateCsMinimumAge(
newCustomService.minimum_age_required
))}
class="w-full {customServiceErrors.minimum_age_required
? 'border-red-500'
: ''}"
/>
{#if customServiceErrors.minimum_age_required}
<p class="text-xs text-red-600">{customServiceErrors.minimum_age_required}</p>
@@ -950,7 +1024,11 @@
<p class="text-xs text-gray-500">0 for no age restriction</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={createCustomService} disabled={creatingCustomService || !isCustomFormValid}>
<Button
size="sm"
onclick={createCustomService}
disabled={creatingCustomService || !isCustomFormValid}
>
{creatingCustomService ? 'Creating...' : 'Save & Add'}
</Button>
<Button variant="outline" size="sm" onclick={() => toggleCustomForm(false)}>