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
+2 -2
View File
@@ -2,7 +2,7 @@
@import 'tw-animate-css';
@import "shadcn-svelte/tailwind.css";
@import 'shadcn-svelte/tailwind.css';
@custom-variant dark (&:is(.dark *));
@@ -122,7 +122,7 @@
@apply bg-background text-foreground;
}
.maplibregl-popup-content {
@apply bg-transparent! shadow-none! p-0! rounded-none!;
@apply rounded-none! bg-transparent! p-0! shadow-none!;
}
.maplibregl-popup-tip {
@apply hidden!;
@@ -9,7 +9,13 @@
import * as Label from '$lib/components/ui/label';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import type { Booking, BookingDiscount, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import type {
Booking,
BookingDiscount,
Service,
WorkingHoursDay,
AvailableHoursDay
} from '$lib/types/booking';
import {
extractBookedSlots,
getLunchProtectionForSlots,
@@ -70,7 +76,11 @@
// ─── Date constants ─────────────────────────────────────
const todayCalendarDate = getLondonTodayCalendarDate();
const minDate = todayCalendarDate;
const maxDate = new SvelteDate(todayCalendarDate.year, todayCalendarDate.month - 1, todayCalendarDate.day);
const maxDate = new SvelteDate(
todayCalendarDate.year,
todayCalendarDate.month - 1,
todayCalendarDate.day
);
maxDate.setMonth(todayCalendarDate.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -82,9 +92,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 noticePeriodBlocked = $derived(
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
);
@@ -218,7 +226,12 @@
if (isToday) {
const now = new Date();
const londonTime = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const londonTime = now.toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [londonHours, londonMinutes] = londonTime.split(':').map(Number);
const currentMin = londonHours * 60 + londonMinutes;
startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15);
@@ -268,7 +281,12 @@
const isToday = date.compare(todayCal) === 0;
if (isToday) {
const now = new Date();
const londonTime = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const londonTime = now.toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [londonHours, londonMinutes] = londonTime.split(':').map(Number);
const currentMin = londonHours * 60 + londonMinutes;
startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15);
@@ -466,11 +484,7 @@
}
const tomorrowCal = getLondonTodayCalendarDate();
newDate = new CalendarDate(
tomorrowCal.year,
tomorrowCal.month,
tomorrowCal.day + 1
);
newDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, tomorrowCal.day + 1);
if (!userNavigatedCalendar) {
placeholderDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
}
@@ -774,8 +788,13 @@
<p class="font-medium text-amber-900">Cannot Reschedule Online</p>
<p class="mt-1">{noticeBlockedMessage}</p>
<p class="mt-1">
<a href="/contact" target="_blank" class="underline">Contact us</a> to discuss options,
or <button type="button" onclick={() => open = false} class="inline underline cursor-pointer">cancel this booking</button>
<a href="/contact" target="_blank" class="underline">Contact us</a> to discuss
options, or
<button
type="button"
onclick={() => (open = false)}
class="inline cursor-pointer underline">cancel this booking</button
>
and rebook — note that cancellation fees may apply based on our
<PolicyPopover>
{#snippet trigger()}
@@ -788,8 +807,8 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<p class="font-medium text-amber-900">Rescheduling Within 24h</p>
<p class="mt-1">
Rescheduling within 24h counts as a no-show towards your deposit obligations. Two no-shows
within 6 months will require deposits on future bookings.
Rescheduling within 24h counts as a no-show towards your deposit obligations. Two
no-shows within 6 months will require deposits on future bookings.
<PolicyPopover>
{#snippet trigger()}
<span class="underline">Full policy</span>
@@ -800,14 +819,12 @@
{/if}
{#if booking.discounts && booking.discounts.length > 0}
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
>
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<p class="font-medium text-amber-900">Discounts Applied to This Booking</p>
<p class="mt-1">
Your booking has £{discountTotal.toFixed(2)} in savings from loyalty stamps or
promotional offers. A time change requires admin approval. If denied, you can cancel
(standard refund policy applies) and rebook at full price.
Your booking has £{discountTotal.toFixed(2)} in savings from loyalty stamps or promotional
offers. A time change requires admin approval. If denied, you can cancel (standard refund
policy applies) and rebook at full price.
</p>
</div>
{/if}
@@ -914,7 +931,7 @@
</div>
{:else}
<div
class="no-scrollbar mt-2 flex max-h-40 min-h-25 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
class="mt-2 no-scrollbar flex max-h-40 min-h-25 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
>
<div class="grid justify-center gap-2 text-sm text-gray-600">
{newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', {
@@ -1,18 +1,18 @@
<script lang="ts">
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { authStore } from '$lib/stores/auth.svelte';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.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 { Input } from '$lib/components/ui/input';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import { computeBalanceDue } from '$lib/utils/booking';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { authStore } from '$lib/stores/auth.svelte';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.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 { Input } from '$lib/components/ui/input';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import { computeBalanceDue } from '$lib/utils/booking';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
interface Props {
open: boolean;
bookingId: string;
@@ -108,11 +108,14 @@ import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
let hoursUntilAppointment = $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(
hoursUntilAppointment > POLICY.FULL_REFUND_THRESHOLD_HOURS
@@ -262,26 +265,43 @@ import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
});
function printReceipt() {
if (!selectedBooking) { toast.error('No booking data to print'); return; }
if (!selectedBooking) {
toast.error('No booking data to print');
return;
}
const pw = window.open('', '_blank');
if (!pw) { toast.error('Please allow pop-ups to print the receipt'); return; }
if (!pw) {
toast.error('Please allow pop-ups to print the receipt');
return;
}
const biz = businessSettings;
const paidPayments = selectedBooking.payments?.filter((p) => p.status === 'completed' && p.payment_method !== 'discount') ?? [];
const discountPayments = selectedBooking.payments?.filter((p) => p.payment_method === 'discount' && p.status === 'completed') ?? [];
const paidPayments =
selectedBooking.payments?.filter(
(p) => p.status === 'completed' && p.payment_method !== 'discount'
) ?? [];
const discountPayments =
selectedBooking.payments?.filter(
(p) => p.payment_method === 'discount' && p.status === 'completed'
) ?? [];
const refunds = selectedBooking.refunds?.filter((r) => r.status === 'completed') ?? [];
const grossTotal = paidPayments.reduce((s, p) => s + p.amount, 0);
const esc = (str: string | null | undefined): string => {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
const fmt = (val: number | null | undefined, fallback = '\u2014'): string => {
return val != null ? '\u00a3' + val.toFixed(2) : fallback;
};
pw.document.write(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Receipt</title>
pw.document
.write(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Receipt</title>
<style>
@page { margin: 12mm; }
* { color: #000 !important; background: transparent !important; }
@@ -313,14 +333,14 @@ import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
<h2>Services</h2>
<table>
<tr><th>Service</th><th style="text-align:right">Price</th></tr>
${(selectedBooking.services ?? []).map(s => `<tr><td>${esc(s.service_name)}${s.duration_minutes ? ' (' + s.duration_minutes + ' min)' : ''}</td><td style="text-align:right">\u00a3${(s.price ?? 0).toFixed(2)}</td></tr>`).join('')}
${(selectedBooking.services ?? []).map((s) => `<tr><td>${esc(s.service_name)}${s.duration_minutes ? ' (' + s.duration_minutes + ' min)' : ''}</td><td style="text-align:right">\u00a3${(s.price ?? 0).toFixed(2)}</td></tr>`).join('')}
</table>
<h2>Payments</h2>
<table>
<tr><th>Type</th><th>Method</th><th style="text-align:right">Net</th><th style="text-align:right">VAT</th><th style="text-align:right">Gross</th></tr>
${paidPayments.map(p => `<tr><td style="text-transform:capitalize">${p.payment_type}</td><td style="text-transform:capitalize">${p.payment_method ?? '\u2014'}</td><td style="text-align:right">${fmt(p.net_amount)}</td><td style="text-align:right">${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}</td><td style="text-align:right">\u00a3${p.amount.toFixed(2)}</td></tr>`).join('')}
${discountPayments.map(d => `<tr><td>Discount</td><td>\u2014</td><td style="text-align:right;color:#059669">-\u00a3${Math.abs(d.amount).toFixed(2)}</td><td style="text-align:right;color:#059669">\u2014</td><td style="text-align:right;color:#059669">\u2014</td></tr>`).join('')}
${refunds.map(r => `<tr><td>Refund</td><td>\u2014</td><td style="text-align:right;color:#dc2626">-\u00a3${r.amount.toFixed(2)}</td><td style="text-align:right;color:#dc2626">\u2014</td><td style="text-align:right;color:#dc2626">\u2014</td></tr>`).join('')}
${paidPayments.map((p) => `<tr><td style="text-transform:capitalize">${p.payment_type}</td><td style="text-transform:capitalize">${p.payment_method ?? '\u2014'}</td><td style="text-align:right">${fmt(p.net_amount)}</td><td style="text-align:right">${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}</td><td style="text-align:right">\u00a3${p.amount.toFixed(2)}</td></tr>`).join('')}
${discountPayments.map((d) => `<tr><td>Discount</td><td>\u2014</td><td style="text-align:right;color:#059669">-\u00a3${Math.abs(d.amount).toFixed(2)}</td><td style="text-align:right;color:#059669">\u2014</td><td style="text-align:right;color:#059669">\u2014</td></tr>`).join('')}
${refunds.map((r) => `<tr><td>Refund</td><td>\u2014</td><td style="text-align:right;color:#dc2626">-\u00a3${r.amount.toFixed(2)}</td><td style="text-align:right;color:#dc2626">\u2014</td><td style="text-align:right;color:#dc2626">\u2014</td></tr>`).join('')}
${hasVAT ? `<tr class="total"><td colspan="2"></td><td style="text-align:right">\u00a3${totalNet.toFixed(2)}</td><td style="text-align:right">\u00a3${totalVAT.toFixed(2)}</td><td style="text-align:right">\u00a3${(totalNet + totalVAT).toFixed(2)}</td></tr>` : ''}
<tr class="total"><td colspan="4" style="text-align:right">Total Paid</td><td style="text-align:right">\u00a3${grossTotal.toFixed(2)}</td></tr>
</table>
@@ -377,14 +397,21 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
}
}
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)';
@@ -505,8 +532,6 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</div>
</div>
{#if selectedBooking.services && selectedBooking.services.length > 0}
<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">
@@ -553,22 +578,20 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {parseWallClockDate(selectedBooking.deposit_deadline).toLocaleDateString(
'en-GB',
{
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
}
)} at {parseWallClockDate(selectedBooking.deposit_deadline).toLocaleTimeString(
'en-GB',
{
hour: 'numeric',
minute: '2-digit',
hour12: true
}
)}
• Due: {parseWallClockDate(
selectedBooking.deposit_deadline
).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})} at {parseWallClockDate(
selectedBooking.deposit_deadline
).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
{/if}
</div>
@@ -582,7 +605,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</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">
@@ -603,12 +628,17 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</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}
{#if hasVAT}
<div class="border-t border-gray-200 pt-2 mt-2">
<div class="mt-2 border-t border-gray-200 pt-2">
<div class="flex items-center justify-between text-xs text-gray-500">
<span>Net amount (excl. VAT)</span>
<span class="font-medium text-gray-700">£{totalNet.toFixed(2)}</span>
@@ -623,7 +653,10 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<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>
@@ -659,8 +692,13 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<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)}</span
>
>{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
{payment.status === 'completed'
@@ -688,9 +726,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
{#if payment.vat_amount}
<div>
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount.toFixed(
2
)}
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount.toFixed(2)}
</div>
{/if}
</div>
@@ -712,7 +748,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<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>
@@ -762,16 +800,28 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</div>
{#if pendingEditRequest}
{@const timeChanged = pendingEditRequest.original.start_time && pendingEditRequest.proposed?.start_time && pendingEditRequest.original.start_time !== pendingEditRequest.proposed.start_time}
{@const servicesChanged = pendingEditRequest.proposed?.services?.length && JSON.stringify(pendingEditRequest.original.services?.map(s => s.name)) !== JSON.stringify(pendingEditRequest.proposed.services?.map(s => s.name))}
{@const originalNames = (pendingEditRequest.original.services ?? []).map(s => s.name)}
{@const proposedNames = (pendingEditRequest.proposed.services ?? []).map(s => s.name)}
{@const addedServices = proposedNames.filter(n => !originalNames.includes(n))}
{@const removedServices = originalNames.filter(n => !proposedNames.includes(n))}
{@const timeChanged =
pendingEditRequest.original.start_time &&
pendingEditRequest.proposed?.start_time &&
pendingEditRequest.original.start_time !== pendingEditRequest.proposed.start_time}
{@const servicesChanged =
pendingEditRequest.proposed?.services?.length &&
JSON.stringify(pendingEditRequest.original.services?.map((s) => s.name)) !==
JSON.stringify(pendingEditRequest.proposed.services?.map((s) => s.name))}
{@const originalNames = (pendingEditRequest.original.services ?? []).map((s) => s.name)}
{@const proposedNames = (pendingEditRequest.proposed.services ?? []).map((s) => s.name)}
{@const addedServices = proposedNames.filter((n) => !originalNames.includes(n))}
{@const removedServices = originalNames.filter((n) => !proposedNames.includes(n))}
{#if timeChanged || servicesChanged}
<div class="rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm">
<div class="flex items-start gap-2.5">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
@@ -779,20 +829,38 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<p class="font-medium">Awaiting admin approval</p>
<p class="mt-0.5 text-amber-700">
{#if timeChanged}
Reschedule requested from {parseWallClockDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {parseWallClockDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
Reschedule requested from {parseWallClockDate(
pendingEditRequest.original.start_time!
).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})} to {parseWallClockDate(
pendingEditRequest.proposed.start_time!
).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}
{/if}
</p>
{#if addedServices.length > 0}
<p class="mt-1 text-amber-700">
<span class="font-medium text-green-700">Services added:</span> {addedServices.join(', ')}
<span class="font-medium text-green-700">Services added:</span>
{addedServices.join(', ')}
</p>
{/if}
{#if removedServices.length > 0}
<p class="mt-0.5 text-amber-700">
<span class="font-medium text-red-600">Services removed:</span> {removedServices.join(', ')}
<span class="font-medium text-red-600">Services removed:</span>
{removedServices.join(', ')}
</p>
{/if}
<p class="mt-1 text-xs text-amber-500">We'll let you know once it's been reviewed</p>
<p class="mt-1 text-xs text-amber-500">
We'll let you know once it's been reviewed
</p>
</div>
</div>
</div>
@@ -839,7 +907,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</Button>
{/if}
{#if hasPendingEditRequest && !depositOutstanding}
<div class="flex-1 rounded-md border border-dashed border-gray-200 bg-gray-50/50 px-3 py-2 text-center text-xs text-gray-400">
<div
class="flex-1 rounded-md border border-dashed border-gray-200 bg-gray-50/50 px-3 py-2 text-center text-xs text-gray-400"
>
Payments paused while awaiting approval
</div>
{/if}
@@ -882,31 +952,34 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
</p>
<div
class="mt-2 rounded-md border p-3 text-sm {hoursUntilAppt < POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
class="mt-2 rounded-md border p-3 text-sm {hoursUntilAppt <
POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
? 'border-red-200 bg-red-50 text-red-800'
: 'border-amber-200 bg-amber-50 text-amber-800'}"
>
{#if hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS}
<p class="font-medium text-green-800">Full Refund</p>
<p class="mt-1">
You have given over {POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. You will receive a
<strong>full refund</strong> of <strong>£{totalPaid.toFixed(2)}</strong>.
Nothing will be deducted.
You have given over {POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. You will receive
a
<strong>full refund</strong> of <strong>£{totalPaid.toFixed(2)}</strong>. Nothing
will be deducted.
</p>
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
<p class="font-medium">Partial Refund</p>
<p class="mt-1">
Based on your notice period ({hoursUntilAppt}h), up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of the total
(£{protectedDeposit.toFixed(2)}) is treated as a protected deposit and will be
retained. The remaining <strong>£{estimatedRefund.toFixed(2)}</strong> will be refunded.
Based on your notice period ({hoursUntilAppt}h), up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT *
100}% of the total (£{protectedDeposit.toFixed(2)}) is treated as a protected
deposit and will be retained. The remaining
<strong>£{estimatedRefund.toFixed(2)}</strong> will be refunded.
</p>
{:else}
<p class="font-semibold text-red-900">Cancelling With No Refund</p>
<p class="mt-1">
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. The full amount you have paid
(<strong>£{totalPaid.toFixed(2)}</strong>) will be retained to cover the lost slot.
It will also count as a <strong>no-show</strong> toward your booking history
(2 no-shows within 6 months would require deposits on future bookings).
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. The full
amount you have paid (<strong>£{totalPaid.toFixed(2)}</strong>) will be retained to
cover the lost slot. It will also count as a <strong>no-show</strong> toward your booking
history (2 no-shows within 6 months would require deposits on future bookings).
</p>
{/if}
<p class="mt-2 text-xs">
@@ -919,32 +992,26 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
for full details.
</p>
</div>
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
<p>Are you sure you want to cancel this booking?</p>
<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 and is being cancelled with
{hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS ? 'plenty of' : 'sufficient'} notice.
It will be removed completely and will not appear in your booking history.
</p>
</div>
{:else}
{#if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
<p>
Are you sure you want to cancel this booking?
<p>Are you sure you want to cancel this booking?</p>
<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">Short Notice Cancellation</p>
<p class="mt-1">
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. Cancelling
now counts as a
<strong>no-show</strong> (2 no-shows within 6 months will require deposits on future bookings).
</p>
<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 and is being cancelled with
{hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS ? 'plenty of' : 'sufficient'} notice.
It will be removed completely and will not appear in your booking history.
</p>
</div>
{:else}
<p>
Are you sure you want to cancel this booking?
</p>
<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">Short Notice Cancellation</p>
<p class="mt-1">
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. Cancelling now counts as a
<strong>no-show</strong> (2 no-shows within 6 months will require deposits on
future bookings).
</p>
</div>
{/if}
</div>
{/if}
</Modal.Description>
</Modal.Header>
@@ -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)}>
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,14 @@
outOfHours?: boolean;
}
let { selectedDate, selectedTime, endTime, duration, protection, outOfHours = false }: Props = $props();
let {
selectedDate,
selectedTime,
endTime,
duration,
protection,
outOfHours = false
}: Props = $props();
</script>
<Separator class="my-4" />
@@ -78,7 +85,8 @@
{/if}
<div
class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 {protection?.showWarning ||
protection?.isBlocked || outOfHours
protection?.isBlocked ||
outOfHours
? 'mt-3'
: ''}"
>
@@ -39,7 +39,7 @@
</script>
<div
class="no-scrollbar inset-y-0 right-0 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
class="inset-y-0 right-0 no-scrollbar flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
>
{#if groupedTimeSlots.length > 0}
{#if formattedDate}
@@ -28,7 +28,7 @@
/>
</div>
<div
class="no-scrollbar inset-y-0 right-0 flex max-h-72 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-t-0 md:border-l"
class="inset-y-0 right-0 no-scrollbar flex max-h-72 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-t-0 md:border-l"
>
<div class="grid gap-2">
{#each timeSlots as time (time)}
@@ -79,7 +79,7 @@
</div>
{#if showSaveCard}
<div class="flex items-center gap-2">
<Checkbox id="saveCard" bind:checked={saveCard} disabled={disabled} />
<Checkbox id="saveCard" bind:checked={saveCard} {disabled} />
<Label for="saveCard" class="text-sm font-normal">Save card for next time</Label>
</div>
{/if}
@@ -64,7 +64,15 @@
let customerBalance = $state(0);
let loadingCustomerBalance = $state(false);
let giftCardPaymentAmount = $state('');
let savedCardList = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
let savedCardList = $state<
Array<{
id: string;
card_brand: string;
card_last4: string;
card_expiry: string;
cardholder_name?: string;
}>
>([]);
let loadingSavedCardList = $state(false);
async function fetchCustomerGiftCardBalance() {
@@ -196,7 +204,9 @@
}
let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0));
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
let discountSum = $derived(
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
);
let netTotal = $derived(Math.max(0, subtotal - discountSum));
let tipPercentages = $derived.by(() => {
@@ -501,9 +511,7 @@
giftCardId = formatted;
}
let giftCardValid = $derived(
useAccountBalance || giftCardId.replace(/-/g, '').length === 12
);
let giftCardValid = $derived(useAccountBalance || giftCardId.replace(/-/g, '').length === 12);
async function handleGiftCardPayment() {
if (!giftCardValid) {
@@ -573,7 +581,15 @@
}
// Saved cards
let savedCards = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
let savedCards = $state<
Array<{
id: string;
card_brand: string;
card_last4: string;
card_expiry: string;
cardholder_name?: string;
}>
>([]);
let loadingSavedCards = $state(false);
let selectedSavedCardId = $state<string | null>(null);
@@ -660,10 +676,7 @@
});
</script>
<Dialog.Root
open={true}
onOpenChange={(open) => !open && handleClose()}
>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
@@ -688,7 +701,7 @@
<input
type="text"
inputmode="decimal"
tabindex={-1}
tabindex={-1}
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
value={serviceOverrides[service.service_id]?.price ??
service.price?.toFixed(2) ??
@@ -717,7 +730,10 @@
<label for="use-loyalty-admin" class="cursor-pointer select-none">
<div class="text-sm font-medium text-fuchsia-900">Use Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available
&middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE)
)})
</div>
</label>
</div>
@@ -727,14 +743,24 @@
{#if booking.discounts && booking.discounts.length > 0}
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="text-sm font-semibold text-gray-800 flex items-center gap-1.5">
<svg class="h-4 w-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
<svg
class="h-4 w-4 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"
></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
Applied Discounts
</div>
<span class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600">
<span
class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
>
{((discountSum / subtotal) * 100).toFixed(0)}% Off Total
</span>
</div>
@@ -753,18 +779,23 @@
{/if}
</span>
</div>
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span>
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span
>
</div>
{/each}
</div>
</div>
{/if}
<div class="flex justify-between items-center rounded-md border border-gray-200 bg-white p-4">
<div
class="flex items-center justify-between rounded-md border border-gray-200 bg-white p-4"
>
<span class="text-base font-semibold text-gray-700">Total</span>
<div class="flex items-baseline gap-2.5">
{#if discountSum > 0.01}
<span class="text-sm font-medium text-gray-400 line-through">{formatCurrency(subtotal)}</span>
<span class="text-sm font-medium text-gray-400 line-through"
>{formatCurrency(subtotal)}</span
>
{/if}
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
@@ -781,7 +812,11 @@
</div>
{/if}
<div class="grid grid-cols-2 gap-3 {savedCardList.length > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3'}">
<div
class="grid grid-cols-2 gap-3 {savedCardList.length > 0
? 'sm:grid-cols-4'
: 'sm:grid-cols-3'}"
>
<button
type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
@@ -882,7 +917,7 @@
</button>
</div>
<div class="sm:hidden flex flex-wrap gap-3">
<div class="flex flex-wrap gap-3 sm:hidden">
{#if savedCardList.length > 0}
<button
type="button"
@@ -940,7 +975,7 @@
<Input
type="text"
inputmode="decimal"
tabindex={-1}
tabindex={-1}
placeholder="Custom tip amount"
value={customTipAmount}
oninput={handleCustomTipInput}
@@ -962,9 +997,7 @@
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleCardPayment} class="flex-1">
Charge Card
</Button>
<Button onclick={handleCardPayment} class="flex-1">Charge Card</Button>
</div>
</div>
{:else if status === 'card-processing' || status === 'card-polling'}
@@ -990,7 +1023,7 @@
id="cash-amount"
type="text"
inputmode="decimal"
tabindex={-1}
tabindex={-1}
value={cashAmount}
oninput={handleCashInput}
class="pl-7 text-lg font-semibold"
@@ -1017,11 +1050,7 @@
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleCashPayment}
class="flex-1"
disabled={cashAmountNum < totalDue}
>
<Button onclick={handleCashPayment} class="flex-1" disabled={cashAmountNum < totalDue}>
Confirm Cash
</Button>
</div>
@@ -1042,14 +1071,16 @@
{#if (booking.user_id ?? booking.user?.id) && customerBalance > 0}
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Source</span>
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Source</span
>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {useAccountBalance
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => useAccountBalance = true}
onclick={() => (useAccountBalance = true)}
>
Account Balance ({formatCurrency(customerBalance)})
</button>
@@ -1058,7 +1089,7 @@
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {!useAccountBalance
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => useAccountBalance = false}
onclick={() => (useAccountBalance = false)}
>
Physical Gift Card Code
</button>
@@ -1068,32 +1099,39 @@
{#if useAccountBalance}
<div>
<label for="giftcard-amount" class="text-sm font-medium text-gray-700">Amount to pay with Balance (£)</label>
<label for="giftcard-amount" class="text-sm font-medium text-gray-700"
>Amount to pay with Balance (£)</label
>
<div class="mt-1 flex gap-2">
<Input
id="giftcard-amount"
type="text"
inputmode="decimal"
value={giftCardPaymentAmount}
oninput={(e) => giftCardPaymentAmount = (e.target as HTMLInputElement).value}
oninput={(e) => (giftCardPaymentAmount = (e.target as HTMLInputElement).value)}
class="flex-1 font-mono text-lg"
/>
<Button
variant="outline"
size="sm"
onclick={() => { giftCardPaymentAmount = Math.min(customerBalance, totalDue).toFixed(2); }}
onclick={() => {
giftCardPaymentAmount = Math.min(customerBalance, totalDue).toFixed(2);
}}
class="shrink-0 text-xs"
>
Full Balance
</Button>
</div>
<p class="mt-1 text-xs text-gray-500">
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance can be used.
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance
can be used.
</p>
</div>
{:else}
<div>
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card Code </label>
<label for="gift-card-id" class="text-sm font-medium text-gray-700">
Gift Card Code
</label>
<Input
id="gift-card-id"
type="text"
@@ -1105,17 +1143,15 @@
maxlength={14}
class="mt-1 font-mono text-lg tracking-widest"
/>
<p class="mt-1 text-xs text-gray-500">Enter the 12-character code printed on the gift card</p>
<p class="mt-1 text-xs text-gray-500">
Enter the 12-character code printed on the gift card
</p>
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleGiftCardPayment}
class="flex-1"
disabled={!giftCardValid}
>
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid}>
Apply Gift Card
</Button>
</div>
@@ -1136,31 +1172,46 @@
{#if loadingSavedCards}
<div class="flex justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"></div>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
</div>
{:else if savedCards.length === 0}
<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
<p class="text-sm text-gray-600">No saved cards found for this customer.</p>
<p class="mt-1 text-xs text-gray-500">Add a card via Square Dashboard or use another payment method.</p>
<p class="mt-1 text-xs text-gray-500">
Add a card via Square Dashboard or use another payment method.
</p>
</div>
{:else}
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select a Saved Card</span>
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Select a Saved Card</span
>
{#each savedCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId === card.id
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId ===
card.id
? 'border-input bg-fuchsia-100'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => selectedSavedCardId = card.id}
onclick={() => (selectedSavedCardId = card.id)}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
class="h-5 w-5 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<span class="font-medium text-gray-900">{card.card_brand} ••••{card.card_last4}</span>
<span class="font-medium text-gray-900"
>{card.card_brand} ••••{card.card_last4}</span
>
</div>
<span class="text-xs text-gray-500">{card.card_expiry}</span>
</div>
@@ -1171,23 +1222,28 @@
{/each}
</div>
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-xs text-amber-800">This card may require bank app confirmation to complete. Ensure the customer has their phone ready.</p>
<p class="text-xs text-amber-800">
This card may require bank app confirmation to complete. Ensure the customer has their
phone ready.
</p>
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleSavedCardPayment}
class="flex-1"
disabled={!selectedSavedCardId}
>
<Button onclick={handleSavedCardPayment} class="flex-1" disabled={!selectedSavedCardId}>
Charge Saved Card
</Button>
</div>
@@ -21,7 +21,18 @@
onComplete: (result: TillSaleResult) => void;
}
let { amount, itemType, action, giftCardId = undefined, userId = undefined, isGuestMode = false, guestEmail = undefined, delivery: deliveryProp = undefined, onClose, onComplete }: Props = $props();
let {
amount,
itemType,
action,
giftCardId = undefined,
userId = undefined,
isGuestMode = false,
guestEmail = undefined,
delivery: deliveryProp = undefined,
onClose,
onComplete
}: Props = $props();
type TillSaleResult = {
id: string;
@@ -74,7 +85,12 @@
}
// Selected customer info
let selectedCustomer = $state<{ id: string; name: string; previousFirstName?: string | null; previousLastName?: string | null } | null>(null);
let selectedCustomer = $state<{
id: string;
name: string;
previousFirstName?: string | null;
previousLastName?: string | null;
} | null>(null);
let isGuest = $state(false);
// Delivery choice — how the gift card value is given to the customer
@@ -111,7 +127,16 @@
// Customer Selection - Search
let userQuery = $state('');
let users = $state<Array<{ id: string; fullName: string; email?: string; phone?: string; previousFirstName?: string | null; previousLastName?: string | null }>>([]);
let users = $state<
Array<{
id: string;
fullName: string;
email?: string;
phone?: string;
previousFirstName?: string | null;
previousLastName?: string | null;
}>
>([]);
let loadingUsers = $state(false);
let currentPage = $state(1);
let totalPages = $state(1);
@@ -318,9 +343,14 @@
function selectCurrentCustomer() {
if (!currentCustomerInfo) return;
selectedCustomer = { id: currentCustomerInfo.id, name: currentCustomerInfo.name, previousFirstName: currentCustomerInfo.previousFirstName, previousLastName: currentCustomerInfo.previousLastName };
selectedCustomer = {
id: currentCustomerInfo.id,
name: currentCustomerInfo.name,
previousFirstName: currentCustomerInfo.previousFirstName,
previousLastName: currentCustomerInfo.previousLastName
};
isGuest = currentCustomerInfo.email?.endsWith('@guest.invalid') || false;
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
delivery = action === 'topup' || isGuest ? 'code' : 'account';
step = 'payment-selection';
}
@@ -395,10 +425,21 @@
}
}
function selectCustomer(user: { id: string; fullName: string; email?: string; previousFirstName?: string | null; previousLastName?: string | null }) {
selectedCustomer = { id: user.id, name: user.fullName, previousFirstName: user.previousFirstName, previousLastName: user.previousLastName };
function selectCustomer(user: {
id: string;
fullName: string;
email?: string;
previousFirstName?: string | null;
previousLastName?: string | null;
}) {
selectedCustomer = {
id: user.id,
name: user.fullName,
previousFirstName: user.previousFirstName,
previousLastName: user.previousLastName
};
isGuest = user.email?.endsWith('@guest.invalid') || false;
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
delivery = action === 'topup' || isGuest ? 'code' : 'account';
step = 'payment-selection';
}
@@ -782,7 +823,13 @@
{currentCustomerInfo.name.charAt(0).toUpperCase()}
</div>
<div>
<div class="text-sm font-medium">{formatUserName(currentCustomerInfo.name, currentCustomerInfo.previousFirstName, currentCustomerInfo.previousLastName)}</div>
<div class="text-sm font-medium">
{formatUserName(
currentCustomerInfo.name,
currentCustomerInfo.previousFirstName,
currentCustomerInfo.previousLastName
)}
</div>
{#if currentCustomerInfo.email}
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
{/if}
@@ -864,7 +911,13 @@
onclick={() => selectCustomer(userItem)}
>
<div>
<div class="text-base font-medium">{formatUserName(userItem.fullName, userItem.previousFirstName, userItem.previousLastName)}</div>
<div class="text-base font-medium">
{formatUserName(
userItem.fullName,
userItem.previousFirstName,
userItem.previousLastName
)}
</div>
<div class="text-xs text-gray-500">
{userItem.email}{#if userItem.email && userItem.phone}
&middot;
@@ -921,7 +974,11 @@
<Dialog.Description>
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
{#if selectedCustomer}
&mdash; {formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)}
&mdash; {formatUserName(
selectedCustomer.name,
selectedCustomer.previousFirstName,
selectedCustomer.previousLastName
)}
{/if}.
</Dialog.Description>
</Dialog.Header>
@@ -934,11 +991,15 @@
{#if action === 'create' && !isGuest}
<div class="space-y-1.5">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block text-muted-foreground">Fulfillment Method</span>
<span
class="block text-xs font-semibold tracking-wider text-gray-500 text-muted-foreground uppercase"
>Fulfillment Method</span
>
<div class="flex gap-2">
<button
type="button"
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery === 'account'
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery ===
'account'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'}"
onclick={() => (delivery = 'account')}
@@ -947,7 +1008,8 @@
</button>
<button
type="button"
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery === 'code'
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery ===
'code'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'}"
onclick={() => (delivery = 'code')}
@@ -1119,7 +1181,11 @@
<Dialog.Description>
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
{#if selectedCustomer}
&mdash; {formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)}
&mdash; {formatUserName(
selectedCustomer.name,
selectedCustomer.previousFirstName,
selectedCustomer.previousLastName
)}
{/if}.
</Dialog.Description>
</Dialog.Header>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
@@ -125,7 +125,9 @@ import { SvelteDate } from 'svelte/reactivity';
function handleCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
const formatted = formatAndPreserveCursor(input, (val) =>
val.replace(/\D/g, '').substring(0, 4)
);
newCardCVC = formatted;
}
@@ -167,10 +169,7 @@ import { SvelteDate } from 'svelte/reactivity';
}
let cardFormValid = $derived(
isValidLuhn(newCardNumber) &&
expiryParts !== null &&
newCardCVC.length >= 3 &&
!isExpiryInPast
isValidLuhn(newCardNumber) && expiryParts !== null && newCardCVC.length >= 3 && !isExpiryInPast
);
let cardSelected = $derived(
@@ -217,9 +216,7 @@ import { SvelteDate } from 'svelte/reactivity';
// Auto-select a sensible default payment type based on the booking's deposit
// state. The backend will split the charge into deposit + non-deposit records
// when appropriate, so this choice mainly controls the button label and amount.
let defaultType = $derived(
defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full')
);
let defaultType = $derived(defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full'));
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
$effect(() => {
paymentType = defaultType as 'full' | 'partial' | 'deposit';
@@ -233,8 +230,12 @@ import { SvelteDate } from 'svelte/reactivity';
let lockInterval: ReturnType<typeof setInterval> | null = null;
let countdownInterval: ReturnType<typeof setInterval> | null = null;
let servicesSubtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0));
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
let servicesSubtotal = $derived(
(booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0)
);
let discountSum = $derived(
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
);
let totalPaid = $derived(
booking.payments
@@ -257,18 +258,20 @@ import { SvelteDate } from 'svelte/reactivity';
// Deposit policy warning text — dynamic based on booking state
let expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
let depositPolicyWarning = $derived<string | null>({
get text(): string | null {
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
if (booking.deposit_required) {
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
let depositPolicyWarning = $derived<string | null>(
{
get text(): string | null {
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
if (booking.deposit_required) {
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
}
if (totalPaid > 0 || booking.amount_due > 0) {
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
}
return null;
}
if (totalPaid > 0 || booking.amount_due > 0) {
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
}
return null;
}
}.text);
}.text
);
let isScenarioA = $derived(depositOutstanding);
@@ -494,24 +497,20 @@ import { SvelteDate } from 'svelte/reactivity';
// Apply loyalty redemption before payment
if (useLoyalty) {
try {
const redemptionResponse = await fetch(
`/api/bookings/${booking.id}/apply-redemption`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
const redemptionResponse = await fetch(`/api/bookings/${booking.id}/apply-redemption`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
);
});
if (!redemptionResponse.ok) {
const errData = await redemptionResponse.text();
throw new Error(errData || 'Failed to apply loyalty discount');
}
} catch (err) {
status = 'error';
const msg =
err instanceof Error ? err.message : 'Failed to apply loyalty discount';
const msg = err instanceof Error ? err.message : 'Failed to apply loyalty discount';
error = msg;
toast.error(msg);
return;
@@ -669,7 +668,7 @@ import { SvelteDate } from 'svelte/reactivity';
{#if status === 'idle' || status === 'processing' || status === 'error'}
<div class="space-y-4">
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
<div
class="flex items-center gap-2 rounded-md border p-3 text-sm {lockTimer <= 60
@@ -686,7 +685,10 @@ import { SvelteDate } from 'svelte/reactivity';
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<span>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment processing</span>
<span
>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment
processing</span
>
</div>
{:else if booking.status === 'pending_release' && (lockTimer === 0 || !lockAcquired)}
<div
@@ -737,7 +739,8 @@ import { SvelteDate } from 'svelte/reactivity';
<label for="use-loyalty" class="cursor-pointer select-none">
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
</div>
</label>
</div>
@@ -749,14 +752,24 @@ import { SvelteDate } from 'svelte/reactivity';
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
<svg class="h-4 w-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<svg
class="h-4 w-4 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"
></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
Applied Discounts
</div>
{#if servicesSubtotal > 0}
<span class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600">
<span
class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
>
{((discountSum / servicesSubtotal) * 100).toFixed(0)}% Off Total
</span>
{/if}
@@ -776,7 +789,8 @@ import { SvelteDate } from 'svelte/reactivity';
{/if}
</span>
</div>
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span>
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span
>
</div>
{/each}
</div>
@@ -790,30 +804,37 @@ import { SvelteDate } from 'svelte/reactivity';
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
>
</div>
{#if discountPreview?.eligible}
{#each discountPreview.discounts as d}
<div class="flex justify-between text-sm">
<span class="text-gray-600">{d.name}</span>
<span class="font-medium text-green-700">-{formatCurrency(Math.round(d.amount * 100))}</span>
</div>
{/each}
{/if}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
</div>
{#if useLoyalty && loyaltyDiscount > 0}
{#if discountPreview?.eligible}
{#each discountPreview.discounts as d}
<div class="flex justify-between text-sm">
<span class="text-gray-600">{d.name}</span>
<span class="font-medium text-green-700"
>-{formatCurrency(Math.round(d.amount * 100))}</span
>
</div>
{/each}
{/if}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
</div>
{#if useLoyalty && loyaltyDiscount > 0}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 pt-2">
<span class="font-semibold text-gray-900">Amount Remaining</span>
<span class="text-lg font-bold text-red-600">
{formatCurrency(
Math.max(
0,
Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount
)
)}
</span>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 pt-2">
<span class="font-semibold text-gray-900">Amount Remaining</span>
<span class="text-lg font-bold text-red-600">
{formatCurrency(Math.max(0, Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount))}
</span>
</div>
</div>
<!-- Card Selection (only when idle) -->
@@ -986,7 +1007,14 @@ import { SvelteDate } from 'svelte/reactivity';
: Math.round(booking.total_amount * 0.2 * 100)
)})
{:else}
Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))}
Pay {formatCurrency(
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountCents() -
(useLoyalty ? loyaltyDiscount : 0)
)
)}
{/if}
</Button>
</div>
@@ -1036,7 +1064,7 @@ import { SvelteDate } from 'svelte/reactivity';
value={partialAmount}
oninput={handlePartialAmountInput}
class="pl-7"
disabled={status !== 'idle'}
disabled={status !== 'idle'}
/>
</div>
{#if partialValidationError}
@@ -1057,7 +1085,14 @@ import { SvelteDate } from 'svelte/reactivity';
? formatCurrency(Math.round(partialAmountNum * 100))
: 'Part'}
{:else}
Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))}
Pay {formatCurrency(
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountCents() -
(useLoyalty ? loyaltyDiscount : 0)
)
)}
{/if}
</Button>
</div>
@@ -400,8 +400,16 @@
<div class="space-y-5">
<!-- Conditional messages -->
{#if minutesUntilClosing >= 75}
<div class="flex items-start gap-2.5 rounded-md border border-teal-200 bg-teal-50/60 p-3 text-sm text-teal-800">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div
class="flex items-start gap-2.5 rounded-md border border-teal-200 bg-teal-50/60 p-3 text-sm text-teal-800"
>
<svg
class="mt-0.5 h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
@@ -412,14 +420,24 @@
</div>
{/if}
{#if minutesUntilClosing >= 30}
<div class="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50/60 p-3 text-sm text-amber-800">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div
class="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50/60 p-3 text-sm text-amber-800"
>
<svg
class="mt-0.5 h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
<div>
<p class="font-medium">Walk-ins closing soon</p>
<p class="mt-0.5 text-amber-700">There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings</p>
<p class="mt-0.5 text-amber-700">
There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings
</p>
</div>
</div>
{/if}
@@ -438,17 +456,17 @@
day: 'numeric'
})}
</div>
{/if}
{/if}
<!-- Stats grid -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{summary.total_payments_today.toFixed(2)}
<!-- Stats grid -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{summary.total_payments_today.toFixed(2)}
</div>
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
</div>
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
</div>
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Tips</div>
@@ -575,13 +593,13 @@
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{weekSummary.total_payments_today.toFixed(2)}
</div>
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
</div>
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{weekSummary.total_payments_today.toFixed(2)}
</div>
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
</div>
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Tips</div>
@@ -655,7 +673,11 @@
{#if activeAppointment.user?.profile_pic_url}
<img
src={activeAppointment.user.profile_pic_url}
alt={formatUserName(activeAppointment.user.full_name, activeAppointment.user.previous_first_name, activeAppointment.user.previous_last_name)}
alt={formatUserName(
activeAppointment.user.full_name,
activeAppointment.user.previous_first_name,
activeAppointment.user.previous_last_name
)}
class="h-14 w-14 shrink-0 rounded-full object-cover ring-2 ring-blue-200 sm:h-16 sm:w-16 sm:ring-4 md:h-20 md:w-20"
/>
{:else}
@@ -670,11 +692,15 @@
{initials}
</div>
{/if}
<div class="min-w-0">
<div class="truncate text-lg font-semibold">
{formatUserName(activeAppointment.user?.full_name || 'Guest', activeAppointment.user?.previous_first_name, activeAppointment.user?.previous_last_name)}
</div>
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
<div class="min-w-0">
<div class="truncate text-lg font-semibold">
{formatUserName(
activeAppointment.user?.full_name || 'Guest',
activeAppointment.user?.previous_first_name,
activeAppointment.user?.previous_last_name
)}
</div>
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
{#if activeAppointment.user}
<button
type="button"
@@ -364,7 +364,13 @@
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="font-medium">{formatUserName(approval.user_name || 'Guest', approval.previous_first_name, approval.previous_last_name)}</div>
<div class="font-medium">
{formatUserName(
approval.user_name || 'Guest',
approval.previous_first_name,
approval.previous_last_name
)}
</div>
<div class="mt-1 text-sm text-gray-600">
{approval.services.join(', ')}
</div>
@@ -409,7 +415,13 @@
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{formatUserName(er.user?.full_name || 'Unknown', er.user?.previous_first_name, er.user?.previous_last_name)}</span>
<span class="font-medium"
>{formatUserName(
er.user?.full_name || 'Unknown',
er.user?.previous_first_name,
er.user?.previous_last_name
)}</span
>
<span class="text-xs font-medium text-amber-600">Edit/Reschedule</span>
</div>
<div class="mt-1 text-sm text-gray-600">
@@ -57,7 +57,14 @@
start_time: string;
duration_minutes: number;
status: string;
user: { id: string; full_name: string; email: string | null; phone: string | null; previous_first_name?: string | null; previous_last_name?: string | null } | null;
user: {
id: string;
full_name: string;
email: string | null;
phone: string | null;
previous_first_name?: string | null;
previous_last_name?: string | null;
} | null;
services: string[];
};
@@ -271,7 +278,11 @@
data: { label: string };
};
type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem | ClosingTimeTimelineItem;
type TimelineItem =
| AppointmentTimelineItem
| BlockerTimelineItem
| LunchTimelineItem
| ClosingTimeTimelineItem;
let timeline = $derived.by(() => {
const items: TimelineItem[] = [];
@@ -753,7 +764,11 @@
class="block max-w-full truncate font-medium hover:text-blue-600 hover:underline"
onclick={() => openUserModal(item.data.user_id)}
>
{formatUserName(item.data.user_name, item.data.previous_first_name, item.data.previous_last_name)}
{formatUserName(
item.data.user_name,
item.data.previous_first_name,
item.data.previous_last_name
)}
</button>
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
<span class="truncate">{(item.data.services ?? []).join(', ')}</span>
@@ -877,9 +892,7 @@
</div>
{:else if item.type === 'closing'}
<div class="flex items-center gap-3 border-t border-gray-100 px-1 pt-3">
<div
class="text-xs font-medium text-gray-400"
>
<div class="text-xs font-medium text-gray-400">
{item.data.label}
</div>
<div class="h-px flex-1 bg-gray-100"></div>
@@ -1008,7 +1021,13 @@
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-2">
<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">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
@@ -1,27 +1,29 @@
<script lang="ts">
type Props = {
text: string;
maxChars?: number;
threshold?: number; // when to start showing counter
};
let { text, maxChars = 1000000, threshold = 750000 }: Props = $props();
const graphemeCount = $derived.by(() => {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
});
const shouldShow = $derived(graphemeCount > threshold);
const remaining = $derived(maxChars - graphemeCount);
const color = $derived(
graphemeCount > 950000 ? 'text-red-600' :
graphemeCount > 800000 ? 'text-yellow-600' :
'text-green-600'
);
type Props = {
text: string;
maxChars?: number;
threshold?: number; // when to start showing counter
};
let { text, maxChars = 1000000, threshold = 750000 }: Props = $props();
const graphemeCount = $derived.by(() => {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
});
const shouldShow = $derived(graphemeCount > threshold);
const remaining = $derived(maxChars - graphemeCount);
const color = $derived(
graphemeCount > 950000
? 'text-red-600'
: graphemeCount > 800000
? 'text-yellow-600'
: 'text-green-600'
);
</script>
{#if shouldShow}
<p class="text-xs {color}">
{remaining.toLocaleString()} characters remaining ({graphemeCount.toLocaleString()} / {maxChars.toLocaleString()})
</p>
<p class="text-xs {color}">
{remaining.toLocaleString()} characters remaining ({graphemeCount.toLocaleString()} / {maxChars.toLocaleString()})
</p>
{/if}
@@ -12,16 +12,22 @@
jxl?: string;
}
let { urls, type = "thumb", alt = "", class: className = "", ...imgProps }: {
let {
urls,
type = 'thumb',
alt = '',
class: className = '',
...imgProps
}: {
urls: ThumbURLs | FullURLs;
type?: "thumb" | "full";
type?: 'thumb' | 'full';
alt?: string;
class?: string;
[key: string]: unknown;
} = $props();
const fallbackSrc = $derived(urls.jpg || urls.webp || urls.avif || "");
const hasJxl = $derived(type === "full" && "jxl" in urls && (urls as FullURLs).jxl);
const fallbackSrc = $derived(urls.jpg || urls.webp || urls.avif || '');
const hasJxl = $derived(type === 'full' && 'jxl' in urls && (urls as FullURLs).jxl);
</script>
<picture>
@@ -11,6 +11,8 @@
const display = $derived(variantMap[status] ?? variantMap.draft);
</script>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {display.variantClass}">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {display.variantClass}"
>
{display.label}
</span>
@@ -20,7 +20,11 @@
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay class={typeof className === 'string' && /(!?z-\[[^\]]+\]|!?z-\d+)/.test(className) ? className.match(/(!?z-\[[^\]]+\]|!?z-\d+)/)?.[0] : ''} />
<Dialog.Overlay
class={typeof className === 'string' && /(!?z-\[[^\]]+\]|!?z-\d+)/.test(className)
? className.match(/(!?z-\[[^\]]+\]|!?z-\d+)/)?.[0]
: ''}
/>
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
@@ -5,4 +5,4 @@
let ref = $state<HTMLElement | null>(null);
</script>
<Button.Root bind:ref={ref} type="submit" {...restProps} />
<Button.Root bind:ref type="submit" {...restProps} />
+51 -51
View File
@@ -1,28 +1,28 @@
<script lang="ts">
import { onMount, onDestroy, setContext, untrack } from "svelte";
import { writable } from "svelte/store";
import MapLibreGL from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { browser } from "$app/environment";
import { resolveMapTheme } from "./theme";
import { onMount, onDestroy, setContext, untrack } from 'svelte';
import { writable } from 'svelte/store';
import MapLibreGL from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { browser } from '$app/environment';
import { resolveMapTheme } from './theme';
const theme = writable<"light" | "dark">("light");
const theme = writable<'light' | 'dark'>('light');
// Check document class for theme (works with next-themes, etc.)
function getDocumentTheme(): "light" | "dark" | null {
if (typeof document === "undefined") return null;
if (document.documentElement.classList.contains("dark")) return "dark";
if (document.documentElement.classList.contains("light")) return "light";
function getDocumentTheme(): 'light' | 'dark' | null {
if (typeof document === 'undefined') return null;
if (document.documentElement.classList.contains('dark')) return 'dark';
if (document.documentElement.classList.contains('light')) return 'light';
return null;
}
// Get system preference
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
let tailwindTheme: "light" | "dark" = $state("light");
let tailwindTheme: 'light' | 'dark' = $state('light');
type MapStyleOption = string | MapLibreGL.StyleSpecification;
@@ -39,17 +39,17 @@
};
interface Props {
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
styles?: {
light?: MapStyleOption;
dark?: MapStyleOption;
};
theme?: "light" | "dark";
theme?: 'light' | 'dark';
/** Map projection type. Use `{ type: "globe" }` for 3D globe view. */
projection?: MapLibreGL.ProjectionSpecification;
center?: [number, number];
zoom?: number;
options?: Omit<MapLibreGL.MapOptions, "container" | "style">;
options?: Omit<MapLibreGL.MapOptions, 'container' | 'style'>;
/**
* Bindable reference to the underlying MapLibre map instance.
* Useful for calling map methods imperatively from the parent.
@@ -74,8 +74,8 @@
}
const defaultStyles = {
dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
};
let {
@@ -89,7 +89,7 @@
map = $bindable(null),
viewport,
onviewportchange,
onstyleloaded,
onstyleloaded
}: Props = $props();
let mapContainer: HTMLDivElement;
@@ -111,25 +111,25 @@
center: [c.lng, c.lat],
zoom: mapInstance.getZoom(),
bearing: mapInstance.getBearing(),
pitch: mapInstance.getPitch(),
pitch: mapInstance.getPitch()
};
}
const mapStyles = $derived({
dark: styles?.dark ?? defaultStyles.dark,
light: styles?.light ?? defaultStyles.light,
light: styles?.light ?? defaultStyles.light
});
const resolvedTheme = $derived(resolveMapTheme({ explicitTheme, ambientTheme: tailwindTheme }));
const currentStyle = $derived(resolvedTheme === "light" ? mapStyles.light : mapStyles.dark);
const currentStyle = $derived(resolvedTheme === 'light' ? mapStyles.light : mapStyles.dark);
const isReady = $derived(isMounted && isLoaded && isStyleLoaded);
setContext("map", {
setContext('map', {
getMap: () => map,
isLoaded: () => hasInitiallyLoaded,
isStyleReady: () => isReady,
isStyleReady: () => isReady
});
function clearStyleTimeout() {
@@ -165,22 +165,22 @@
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
attributeFilter: ['class']
});
// Also watch for system preference changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = (e: MediaQueryListEvent) => {
// Only use system preference if no document class is set
if (!getDocumentTheme()) {
tailwindTheme = e.matches ? "dark" : "light";
tailwindTheme = e.matches ? 'dark' : 'light';
}
};
mediaQuery.addEventListener("change", handleSystemChange);
mediaQuery.addEventListener('change', handleSystemChange);
onDestroy(() => {
observer.disconnect();
mediaQuery.removeEventListener("change", handleSystemChange);
mediaQuery.removeEventListener('change', handleSystemChange);
});
}
@@ -190,13 +190,13 @@
fadeDuration: 0,
renderWorldCopies: false,
attributionControl: {
compact: true,
compact: true
},
center: viewport?.center ?? center,
zoom: viewport?.zoom ?? zoom,
bearing: viewport?.bearing ?? 0,
pitch: viewport?.pitch ?? 0,
...options,
...options
});
const styleDataHandler = () => {
@@ -229,18 +229,18 @@
onviewportchange?.(getViewport(mapInstance));
};
mapInstance.on("load", loadHandler);
mapInstance.on("styledata", styleDataHandler);
mapInstance.on("move", handleMove);
mapInstance.on('load', loadHandler);
mapInstance.on('styledata', styleDataHandler);
mapInstance.on('move', handleMove);
mapInstance.on("dragstart", () => (isInteracting = true));
mapInstance.on("dragend", () => (isInteracting = false));
mapInstance.on("zoomstart", () => (isInteracting = true));
mapInstance.on("zoomend", () => (isInteracting = false));
mapInstance.on("rotatestart", () => (isInteracting = true));
mapInstance.on("rotateend", () => (isInteracting = false));
mapInstance.on("pitchstart", () => (isInteracting = true));
mapInstance.on("pitchend", () => (isInteracting = false));
mapInstance.on('dragstart', () => (isInteracting = true));
mapInstance.on('dragend', () => (isInteracting = false));
mapInstance.on('zoomstart', () => (isInteracting = true));
mapInstance.on('zoomend', () => (isInteracting = false));
mapInstance.on('rotatestart', () => (isInteracting = true));
mapInstance.on('rotateend', () => (isInteracting = false));
mapInstance.on('pitchstart', () => (isInteracting = true));
mapInstance.on('pitchend', () => (isInteracting = false));
map = mapInstance;
});
@@ -255,7 +255,7 @@
center: viewport.center ?? current.center,
zoom: viewport.zoom ?? current.zoom,
bearing: viewport.bearing ?? current.bearing,
pitch: viewport.pitch ?? current.pitch,
pitch: viewport.pitch ?? current.pitch
};
if (
@@ -269,7 +269,7 @@
}
internalUpdate = true;
map!.once("moveend", () => {
map!.once('moveend', () => {
internalUpdate = false;
});
map.jumpTo(next);
@@ -291,12 +291,12 @@
isStyleLoaded = false;
map!.setStyle(style, { diff: true });
map!.once("styledata", () => {
map!.once('styledata', () => {
map!.jumpTo({
center: currCenter,
zoom: currZoom,
bearing: currBearing,
pitch: currPitch,
pitch: currPitch
});
});
});
@@ -330,12 +330,12 @@
{#if !isReady}
<div class="absolute inset-0 flex items-center justify-center">
<div class="flex gap-1">
<span class="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full"></span>
<span class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60"></span>
<span
class="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:150ms]"
class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60 [animation-delay:150ms]"
></span>
<span
class="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:300ms]"
class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60 [animation-delay:300ms]"
></span>
</div>
</div>
@@ -1,5 +1,5 @@
<script lang="ts" module>
import type MapLibreGL from "maplibre-gl";
import type MapLibreGL from 'maplibre-gl';
export type MapArcDatum = {
/** Unique identifier for this arc. Required for hover state tracking. */
@@ -17,8 +17,8 @@
originalEvent: MapLibreGL.MapMouseEvent;
};
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
type MapArcLineLayout = NonNullable<MapLibreGL.LineLayerSpecification["layout"]>;
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification['paint']>;
type MapArcLineLayout = NonNullable<MapLibreGL.LineLayerSpecification['layout']>;
export type MapArcProps<T extends MapArcDatum = MapArcDatum> = {
/** Array of arcs to render. Each arc must have a unique `id`. */
@@ -50,7 +50,7 @@
</script>
<script lang="ts" generics="T extends MapArcDatum = MapArcDatum">
import { useMap } from "$lib/hooks/use-map.svelte.js";
import { useMap } from '$lib/hooks/use-map.svelte.js';
let {
data,
@@ -63,18 +63,18 @@
onclick,
onhover,
interactive = true,
beforeId,
beforeId
}: MapArcProps<T> = $props();
const DEFAULT_PAINT: NonNullable<MapLibreGL.LineLayerSpecification["paint"]> = {
"line-color": "#4285F4",
"line-width": 2,
"line-opacity": 0.85,
const DEFAULT_PAINT: NonNullable<MapLibreGL.LineLayerSpecification['paint']> = {
'line-color': '#4285F4',
'line-width': 2,
'line-opacity': 0.85
};
const DEFAULT_LAYOUT: NonNullable<MapLibreGL.LineLayerSpecification["layout"]> = {
"line-join": "round",
"line-cap": "round",
const DEFAULT_LAYOUT: NonNullable<MapLibreGL.LineLayerSpecification['layout']> = {
'line-join': 'round',
'line-cap': 'round'
};
const ARC_HIT_MIN_WIDTH = 12;
@@ -123,9 +123,9 @@
}
function mergeArcPaint(
base: NonNullable<MapLibreGL.LineLayerSpecification["paint"]>,
hover: NonNullable<MapLibreGL.LineLayerSpecification["paint"]> | undefined
): NonNullable<MapLibreGL.LineLayerSpecification["paint"]> {
base: NonNullable<MapLibreGL.LineLayerSpecification['paint']>,
hover: NonNullable<MapLibreGL.LineLayerSpecification['paint']> | undefined
): NonNullable<MapLibreGL.LineLayerSpecification['paint']> {
if (!hover) return base;
const merged: Record<string, unknown> = { ...base };
for (const [key, hoverValue] of Object.entries(hover)) {
@@ -134,32 +134,32 @@
merged[key] =
baseValue === undefined
? hoverValue
: ["case", ["boolean", ["feature-state", "hover"], false], hoverValue, baseValue];
: ['case', ['boolean', ['feature-state', 'hover'], false], hoverValue, baseValue];
}
return merged as NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
return merged as NonNullable<MapLibreGL.LineLayerSpecification['paint']>;
}
const geoJSON = $derived.by<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => ({
type: "FeatureCollection",
type: 'FeatureCollection',
features: data.map((arc) => {
const { from, to, id: arcId, ...properties } = arc;
return {
id: typeof arcId === "number" ? arcId : undefined,
type: "Feature" as const,
id: typeof arcId === 'number' ? arcId : undefined,
type: 'Feature' as const,
properties: { ...properties, _arc_id: String(arcId) },
geometry: {
type: "LineString" as const,
coordinates: buildArcCoordinates(from, to, curvature, samples),
},
type: 'LineString' as const,
coordinates: buildArcCoordinates(from, to, curvature, samples)
}
};
}),
})
}));
const mergedPaint = $derived(mergeArcPaint({ ...DEFAULT_PAINT, ...paint }, hoverPaint));
const mergedLayout = $derived({ ...DEFAULT_LAYOUT, ...layout });
const hitWidth = $derived(() => {
const w = paint?.["line-width"] ?? DEFAULT_PAINT["line-width"];
const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH;
const w = paint?.['line-width'] ?? DEFAULT_PAINT['line-width'];
const base = typeof w === 'number' ? w : ARC_HIT_MIN_WIDTH;
return Math.max((base as number) + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
});
@@ -178,18 +178,18 @@
if (!map.getSource(currentSourceId)) {
map.addSource(currentSourceId, {
type: "geojson",
type: 'geojson',
data: geoJSON,
promoteId: "_arc_id",
promoteId: '_arc_id'
});
map.addLayer(
{
id: currentLayerId,
type: "line",
type: 'line',
source: currentSourceId,
layout: mergedLayout,
paint: mergedPaint,
paint: mergedPaint
},
beforeId
);
@@ -198,10 +198,10 @@
map.addLayer(
{
id: currentHitLayerId,
type: "line",
type: 'line',
source: currentSourceId,
layout: mergedLayout,
paint: { "line-color": "transparent", "line-width": hitWidth() },
paint: { 'line-color': 'transparent', 'line-width': hitWidth() }
},
beforeId
);
@@ -267,7 +267,7 @@
if (arcId) {
map.setFeatureState({ source: sourceId, id: arcId }, { hover: true });
hoveredArcId = arcId;
map.getCanvas().style.cursor = "pointer";
map.getCanvas().style.cursor = 'pointer';
if (onhover) {
const arc = getArcById(arcId);
@@ -276,7 +276,7 @@
}
}
} else {
map.getCanvas().style.cursor = "";
map.getCanvas().style.cursor = '';
if (onhover) onhover(null);
}
};
@@ -286,18 +286,18 @@
map.setFeatureState({ source: sourceId, id: hoveredArcId }, { hover: false });
hoveredArcId = null;
}
map.getCanvas().style.cursor = "";
map.getCanvas().style.cursor = '';
if (onhover) onhover(null);
};
map.on("click", targetLayer, handleClick);
map.on("mousemove", targetLayer, handleMouseMove);
map.on("mouseleave", targetLayer, handleMouseLeave);
map.on('click', targetLayer, handleClick);
map.on('mousemove', targetLayer, handleMouseMove);
map.on('mouseleave', targetLayer, handleMouseLeave);
return () => {
map.off("click", targetLayer, handleClick);
map.off("mousemove", targetLayer, handleMouseMove);
map.off("mouseleave", targetLayer, handleMouseLeave);
map.off('click', targetLayer, handleClick);
map.off('mousemove', targetLayer, handleMouseMove);
map.off('mouseleave', targetLayer, handleMouseLeave);
};
});
</script>
@@ -1,6 +1,6 @@
<script lang="ts" generics="P extends GeoJSON.GeoJsonProperties">
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
import { getContext } from 'svelte';
import MapLibreGL from 'maplibre-gl';
import { generateUUID } from '$lib/utils/uuid';
interface Props {
@@ -29,17 +29,17 @@
data,
clusterMaxZoom = 14,
clusterRadius = 50,
clusterColors = ["#22c55e", "#eab308", "#ef4444"],
clusterColors = ['#22c55e', '#eab308', '#ef4444'],
clusterThresholds = [100, 750],
pointColor = "#3b82f6",
pointColor = '#3b82f6',
onpointclick,
onclusterclick,
onclusterclick
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isStyleReady: () => boolean;
}>("map");
}>('map');
const id = generateUUID();
const sourceId = $derived(`cluster-source-${id}`);
@@ -66,72 +66,72 @@
// Add clustered GeoJSON source
map.addSource(sourceId, {
type: "geojson",
type: 'geojson',
data,
cluster: true,
clusterMaxZoom,
clusterRadius,
clusterRadius
});
// Add cluster circles layer
map.addLayer({
id: clusterLayerId,
type: "circle",
type: 'circle',
source: sourceId,
filter: ["has", "point_count"],
filter: ['has', 'point_count'],
paint: {
"circle-color": [
"step",
["get", "point_count"],
'circle-color': [
'step',
['get', 'point_count'],
clusterColors[0],
clusterThresholds[0],
clusterColors[1],
clusterThresholds[1],
clusterColors[2],
clusterColors[2]
],
"circle-radius": [
"step",
["get", "point_count"],
'circle-radius': [
'step',
['get', 'point_count'],
20,
clusterThresholds[0],
30,
clusterThresholds[1],
40,
40
],
"circle-stroke-width": 1,
"circle-stroke-color": "#fff",
"circle-opacity": 0.85,
},
'circle-stroke-width': 1,
'circle-stroke-color': '#fff',
'circle-opacity': 0.85
}
});
// Add cluster count text layer
map.addLayer({
id: clusterCountLayerId,
type: "symbol",
type: 'symbol',
source: sourceId,
filter: ["has", "point_count"],
filter: ['has', 'point_count'],
layout: {
"text-field": "{point_count_abbreviated}",
"text-font": ["Open Sans"],
"text-size": 12,
'text-field': '{point_count_abbreviated}',
'text-font': ['Open Sans'],
'text-size': 12
},
paint: {
"text-color": "#fff",
},
'text-color': '#fff'
}
});
// Add unclustered point layer
map.addLayer({
id: unclusteredLayerId,
type: "circle",
type: 'circle',
source: sourceId,
filter: ["!", ["has", "point_count"]],
filter: ['!', ['has', 'point_count']],
paint: {
"circle-color": pointColor,
"circle-radius": 5,
"circle-stroke-width": 2,
"circle-stroke-color": "#fff",
},
'circle-color': pointColor,
'circle-radius': 5,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff'
}
});
return () => {
@@ -151,7 +151,7 @@
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || typeof data === "string") return;
if (!loaded || !map || typeof data === 'string') return;
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
if (source) {
@@ -168,29 +168,29 @@
// Update cluster layer colors and sizes
if (map.getLayer(clusterLayerId)) {
map.setPaintProperty(clusterLayerId, "circle-color", [
"step",
["get", "point_count"],
map.setPaintProperty(clusterLayerId, 'circle-color', [
'step',
['get', 'point_count'],
clusterColors[0],
clusterThresholds[0],
clusterColors[1],
clusterThresholds[1],
clusterColors[2],
clusterColors[2]
]);
map.setPaintProperty(clusterLayerId, "circle-radius", [
"step",
["get", "point_count"],
map.setPaintProperty(clusterLayerId, 'circle-radius', [
'step',
['get', 'point_count'],
20,
clusterThresholds[0],
30,
clusterThresholds[1],
40,
40
]);
}
// Update unclustered point layer color
if (map.getLayer(unclusteredLayerId)) {
map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor);
map.setPaintProperty(unclusteredLayerId, 'circle-color', pointColor);
}
});
@@ -208,7 +208,7 @@
}
) => {
const features = map.queryRenderedFeatures(e.point, {
layers: [clusterLayerId],
layers: [clusterLayerId]
});
if (!features.length) return;
@@ -225,7 +225,7 @@
const zoom = await source.getClusterExpansionZoom(clusterId);
map.easeTo({
center: coordinates,
zoom,
zoom
});
}
};
@@ -241,7 +241,7 @@
const feature = e.features[0];
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [
number,
number,
number
];
// Handle world copies
@@ -254,34 +254,34 @@
// Cursor style handlers
const handleMouseEnterCluster = () => {
map.getCanvas().style.cursor = "pointer";
map.getCanvas().style.cursor = 'pointer';
};
const handleMouseLeaveCluster = () => {
map.getCanvas().style.cursor = "";
map.getCanvas().style.cursor = '';
};
const handleMouseEnterPoint = () => {
if (onpointclick) {
map.getCanvas().style.cursor = "pointer";
map.getCanvas().style.cursor = 'pointer';
}
};
const handleMouseLeavePoint = () => {
map.getCanvas().style.cursor = "";
map.getCanvas().style.cursor = '';
};
map.on("click", clusterLayerId, handleClusterClick);
map.on("click", unclusteredLayerId, handlePointClick);
map.on("mouseenter", clusterLayerId, handleMouseEnterCluster);
map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster);
map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
map.on('click', clusterLayerId, handleClusterClick);
map.on('click', unclusteredLayerId, handlePointClick);
map.on('mouseenter', clusterLayerId, handleMouseEnterCluster);
map.on('mouseleave', clusterLayerId, handleMouseLeaveCluster);
map.on('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
map.on('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
return () => {
map.off("click", clusterLayerId, handleClusterClick);
map.off("click", unclusteredLayerId, handlePointClick);
map.off("mouseenter", clusterLayerId, handleMouseEnterCluster);
map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster);
map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
map.off('click', clusterLayerId, handleClusterClick);
map.off('click', unclusteredLayerId, handlePointClick);
map.off('mouseenter', clusterLayerId, handleMouseEnterCluster);
map.off('mouseleave', clusterLayerId, handleMouseLeaveCluster);
map.off('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
map.off('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
};
});
</script>
@@ -1,15 +1,15 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
import { cn } from "$lib/utils.js";
import Plus from "@lucide/svelte/icons/plus";
import Minus from "@lucide/svelte/icons/minus";
import Locate from "@lucide/svelte/icons/locate";
import Maximize from "@lucide/svelte/icons/maximize";
import Loader2 from "@lucide/svelte/icons/loader-2";
import { getContext } from 'svelte';
import MapLibreGL from 'maplibre-gl';
import { cn } from '$lib/utils.js';
import Plus from '@lucide/svelte/icons/plus';
import Minus from '@lucide/svelte/icons/minus';
import Locate from '@lucide/svelte/icons/locate';
import Maximize from '@lucide/svelte/icons/maximize';
import Loader2 from '@lucide/svelte/icons/loader-2';
interface Props {
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
showZoom?: boolean;
showCompass?: boolean;
showLocate?: boolean;
@@ -19,29 +19,29 @@
}
let {
position = "bottom-right",
position = 'bottom-right',
showZoom = true,
showCompass = false,
showLocate = false,
showFullscreen = false,
class: className,
onlocate,
onlocate
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
}>("map");
}>('map');
let waitingForLocation = $state(false);
let compassElement: SVGSVGElement | null = $state(null);
const loaded = $derived(mapCtx.isLoaded());
const positionClasses = {
"top-left": "top-2 left-2",
"top-right": "top-2 right-2",
"bottom-left": "bottom-2 left-2",
"bottom-right": "bottom-10 right-2",
'top-left': 'top-2 left-2',
'top-right': 'top-2 right-2',
'bottom-left': 'bottom-2 left-2',
'bottom-right': 'bottom-10 right-2'
};
// Update compass rotation
@@ -57,13 +57,13 @@
compassElement.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
};
map.on("rotate", updateRotation);
map.on("pitch", updateRotation);
map.on('rotate', updateRotation);
map.on('pitch', updateRotation);
updateRotation();
return () => {
map.off("rotate", updateRotation);
map.off("pitch", updateRotation);
map.off('rotate', updateRotation);
map.off('pitch', updateRotation);
};
});
@@ -88,23 +88,23 @@
waitingForLocation = true;
if ("geolocation" in navigator) {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
const coords = {
longitude: position.coords.longitude,
latitude: position.coords.latitude,
latitude: position.coords.latitude
};
map.flyTo({
center: [coords.longitude, coords.latitude],
zoom: 14,
duration: 1500,
duration: 1500
});
onlocate?.(coords);
waitingForLocation = false;
},
(error) => {
console.error("Error getting location:", error);
console.error('Error getting location:', error);
waitingForLocation = false;
}
);
@@ -125,16 +125,16 @@
</script>
{#if loaded}
<div class={cn("absolute z-10 flex flex-col gap-1.5", positionClasses[position], className)}>
<div class={cn('absolute z-10 flex flex-col gap-1.5', positionClasses[position], className)}>
{#if showZoom}
<div
class="border-border bg-background [&>button:not(:last-child)]:border-border flex flex-col overflow-hidden rounded-md border shadow-sm [&>button:not(:last-child)]:border-b"
class="flex flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm [&>button:not(:last-child)]:border-b [&>button:not(:last-child)]:border-border"
>
<button
onclick={handleZoomIn}
aria-label="Zoom in"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
class="flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
>
<Plus class="size-4" />
</button>
@@ -142,7 +142,7 @@
onclick={handleZoomOut}
aria-label="Zoom out"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
class="flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
>
<Minus class="size-4" />
</button>
@@ -151,13 +151,13 @@
{#if showCompass}
<div
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
class="flex flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm"
>
<button
onclick={handleResetBearing}
aria-label="Reset bearing to north"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
class="flex size-8 items-center justify-center transition-all hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
>
<svg
bind:this={compassElement}
@@ -176,13 +176,13 @@
{#if showLocate}
<div
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
class="flex flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm"
>
<button
onclick={handleLocate}
aria-label="Find my location"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
class="flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
disabled={waitingForLocation}
>
{#if waitingForLocation}
@@ -196,13 +196,13 @@
{#if showFullscreen}
<div
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
class="flex flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm"
>
<button
onclick={handleFullscreen}
aria-label="Toggle fullscreen"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
class="flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
>
<Maximize class="size-4" />
</button>
@@ -1,22 +1,22 @@
<script lang="ts">
import { getContext, setContext, untrack } from "svelte";
import MapLibreGL, { type MarkerOptions } from "maplibre-gl";
import { getContext, setContext, untrack } from 'svelte';
import MapLibreGL, { type MarkerOptions } from 'maplibre-gl';
type Anchor =
| "center"
| "top"
| "bottom"
| "left"
| "right"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
| 'center'
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
interface Props {
longitude: number;
latitude: number;
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
onclick?: (e: MouseEvent) => void;
onmouseenter?: (e: MouseEvent) => void;
onmouseleave?: (e: MouseEvent) => void;
@@ -25,10 +25,10 @@
ondragend?: (lngLat: { lng: number; lat: number }) => void;
draggable?: boolean;
anchor?: Anchor;
offset?: MarkerOptions["offset"];
offset?: MarkerOptions['offset'];
rotation?: number;
pitchAlignment?: MarkerOptions["pitchAlignment"];
rotationAlignment?: MarkerOptions["rotationAlignment"];
pitchAlignment?: MarkerOptions['pitchAlignment'];
rotationAlignment?: MarkerOptions['rotationAlignment'];
}
let {
@@ -42,17 +42,17 @@
ondrag,
ondragend,
draggable = false,
anchor = "center",
anchor = 'center',
offset,
rotation,
pitchAlignment,
rotationAlignment,
rotationAlignment
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
}>("map");
}>('map');
let marker: MapLibreGL.Marker | null = $state(null);
let markerElement: HTMLDivElement | null = $state(null);
@@ -60,13 +60,13 @@
let isDragging = $state(false);
// Provide marker context for child components
setContext("marker", {
setContext('marker', {
getMarker: () => marker,
getElement: () => markerElement,
getMap: () => mapCtx.getMap(),
isReady: () => isReady,
isDraggable: () => draggable,
isDragging: () => isDragging,
isDragging: () => isDragging
});
// Create marker when map is ready
@@ -80,8 +80,8 @@
const lng = untrack(() => longitude);
const lat = untrack(() => latitude);
if (
typeof lng !== "number" ||
typeof lat !== "number" ||
typeof lng !== 'number' ||
typeof lat !== 'number' ||
Number.isNaN(lng) ||
Number.isNaN(lat)
) {
@@ -89,15 +89,15 @@
}
// Create container element programmatically
const container = document.createElement("div");
container.className = "cursor-pointer";
const container = document.createElement('div');
container.className = 'cursor-pointer';
markerElement = container;
// Build marker options
const markerOptions: MarkerOptions = {
element: container,
draggable,
anchor,
anchor
};
if (offset !== undefined) markerOptions.offset = offset;
@@ -111,10 +111,10 @@
marker = markerInstance;
// Mouse event listeners on the container
if (onclick) container.addEventListener("click", onclick);
if (onmouseenter) container.addEventListener("mouseenter", onmouseenter);
if (onclick) container.addEventListener('click', onclick);
if (onmouseenter) container.addEventListener('mouseenter', onmouseenter);
if (onmouseleave) {
container.addEventListener("mouseleave", (e) => {
container.addEventListener('mouseleave', (e) => {
if (!isDragging) onmouseleave(e);
});
}
@@ -136,23 +136,23 @@
};
if (draggable) {
markerInstance.on("dragstart", handleDragStart);
markerInstance.on("drag", handleDrag);
markerInstance.on("dragend", handleDragEnd);
markerInstance.on('dragstart', handleDragStart);
markerInstance.on('drag', handleDrag);
markerInstance.on('dragend', handleDragEnd);
}
isReady = true;
// Cleanup
return () => {
if (onclick) container.removeEventListener("click", onclick);
if (onmouseenter) container.removeEventListener("mouseenter", onmouseenter);
if (onmouseleave) container.removeEventListener("mouseleave", onmouseleave);
if (onclick) container.removeEventListener('click', onclick);
if (onmouseenter) container.removeEventListener('mouseenter', onmouseenter);
if (onmouseleave) container.removeEventListener('mouseleave', onmouseleave);
if (draggable) {
markerInstance.off("dragstart", handleDragStart);
markerInstance.off("drag", handleDrag);
markerInstance.off("dragend", handleDragEnd);
markerInstance.off('dragstart', handleDragStart);
markerInstance.off('drag', handleDrag);
markerInstance.off('dragend', handleDragEnd);
}
markerInstance.remove();
@@ -166,8 +166,8 @@
$effect(() => {
if (
marker &&
typeof longitude === "number" &&
typeof latitude === "number" &&
typeof longitude === 'number' &&
typeof latitude === 'number' &&
!Number.isNaN(longitude) &&
!Number.isNaN(latitude)
) {
@@ -1,18 +1,18 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
import { cn } from "$lib/utils.js";
import X from "@lucide/svelte/icons/x";
import { getContext } from 'svelte';
import MapLibreGL, { type PopupOptions } from 'maplibre-gl';
import { cn } from '$lib/utils.js';
import X from '@lucide/svelte/icons/x';
interface Props {
longitude: number;
latitude: number;
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
class?: string;
closeButton?: boolean;
onclose?: () => void;
offset?: PopupOptions["offset"];
anchor?: PopupOptions["anchor"];
offset?: PopupOptions['offset'];
anchor?: PopupOptions['anchor'];
closeOnClick?: boolean;
closeOnMove?: boolean;
focusAfterOpen?: boolean;
@@ -31,18 +31,18 @@
closeOnClick,
closeOnMove,
focusAfterOpen,
maxWidth,
maxWidth
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
}>("map");
}>('map');
const markerCtx =
getContext<{
isDraggable?: () => boolean;
}>("marker") || {};
}>('marker') || {};
let popup: MapLibreGL.Popup | null = null;
let wrapperElement: HTMLDivElement | null = $state(null);
@@ -56,8 +56,8 @@
// Validate coordinates
if (
typeof longitude !== "number" ||
typeof latitude !== "number" ||
typeof longitude !== 'number' ||
typeof latitude !== 'number' ||
Number.isNaN(longitude) ||
Number.isNaN(latitude)
) {
@@ -65,13 +65,13 @@
}
// Create popup container
const container = document.createElement("div");
const container = document.createElement('div');
// Build popup options
const popupOptions: PopupOptions = {
offset,
closeButton: false,
className: "maplibre-popup-transparent",
className: 'maplibre-popup-transparent'
};
// If marker is draggable, preserve popup state during movement
@@ -93,14 +93,14 @@
if (maxWidth) {
popupInstance.setMaxWidth(maxWidth);
} else {
popupInstance.setMaxWidth("none");
popupInstance.setMaxWidth('none');
}
popup = popupInstance;
// Handle close event
const handleClose = () => onclose?.();
popupInstance.on("close", handleClose);
popupInstance.on('close', handleClose);
// Move content to popup container
while (wrapperElement.firstChild) {
@@ -108,7 +108,7 @@
}
return () => {
popupInstance.off("close", handleClose);
popupInstance.off('close', handleClose);
// Move content back
while (container.firstChild) {
@@ -126,8 +126,8 @@
$effect(() => {
if (
popup &&
typeof longitude === "number" &&
typeof latitude === "number" &&
typeof longitude === 'number' &&
typeof latitude === 'number' &&
!Number.isNaN(longitude) &&
!Number.isNaN(latitude)
) {
@@ -144,8 +144,8 @@
<div bind:this={wrapperElement} style="display: contents;">
<div
class={cn(
"bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md",
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
'relative max-w-62 rounded-md border bg-popover p-3 text-popover-foreground shadow-md',
'animate-in duration-200 ease-out fade-in-0 zoom-in-95',
className
)}
>
@@ -154,7 +154,7 @@
type="button"
onclick={handleClose}
aria-label="Close popup"
class="focus-visible:ring-ring hover:bg-muted text-foreground absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm transition-colors focus:outline-none focus-visible:ring-2"
class="absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm text-foreground transition-colors hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X class="size-3.5" />
</button>
@@ -1,6 +1,6 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
import { getContext } from 'svelte';
import MapLibreGL from 'maplibre-gl';
import { generateUUID } from '$lib/utils/uuid';
interface Props {
@@ -28,7 +28,7 @@
let {
coordinates,
color = "#4285F4",
color = '#4285F4',
width = 3,
opacity = 0.8,
dashArray,
@@ -36,13 +36,13 @@
onmouseenter,
onmouseleave,
interactive = true,
id = generateUUID(),
id = generateUUID()
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isStyleReady: () => boolean;
}>("map");
}>('map');
const sourceId = $derived(`route-source-${id}`);
const layerId = $derived(`route-layer-${id}`);
@@ -60,42 +60,42 @@
// Add source
map.addSource(sourceId, {
type: "geojson",
type: 'geojson',
data: {
type: "Feature",
type: 'Feature',
properties: {},
geometry: {
type: "LineString",
coordinates,
},
},
type: 'LineString',
coordinates
}
}
});
// Build paint options with transition definitions
// Use default values here - they'll be updated by the paint property effect
const paint: MapLibreGL.LineLayerSpecification['paint'] = {
"line-color": "#94a3b8", // Start with gray (unselected color)
"line-width": 5, // Start with unselected width
"line-opacity": 0.6, // Start with unselected opacity
"line-color-transition": { duration: 300, delay: 0 },
"line-width-transition": { duration: 300, delay: 0 },
"line-opacity-transition": { duration: 300, delay: 0 },
'line-color': '#94a3b8', // Start with gray (unselected color)
'line-width': 5, // Start with unselected width
'line-opacity': 0.6, // Start with unselected opacity
'line-color-transition': { duration: 300, delay: 0 },
'line-width-transition': { duration: 300, delay: 0 },
'line-opacity-transition': { duration: 300, delay: 0 }
};
if (dashArray) {
paint["line-dasharray"] = dashArray;
paint['line-dasharray'] = dashArray;
}
// Add layer
map.addLayer({
id: layerId,
type: "line",
type: 'line',
source: sourceId,
layout: {
"line-join": "round",
"line-cap": "round",
'line-join': 'round',
'line-cap': 'round'
},
paint,
paint
});
return () => {
@@ -118,12 +118,12 @@
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
if (source) {
source.setData({
type: "Feature",
type: 'Feature',
properties: {},
geometry: {
type: "LineString",
coordinates,
},
type: 'LineString',
coordinates
}
});
}
});
@@ -135,12 +135,12 @@
if (!loaded || !map || !map.getLayer(layerId)) return;
map.setPaintProperty(layerId, "line-color", color);
map.setPaintProperty(layerId, "line-width", width);
map.setPaintProperty(layerId, "line-opacity", opacity);
map.setPaintProperty(layerId, 'line-color', color);
map.setPaintProperty(layerId, 'line-width', width);
map.setPaintProperty(layerId, 'line-opacity', opacity);
if (dashArray) {
map.setPaintProperty(layerId, "line-dasharray", dashArray);
map.setPaintProperty(layerId, 'line-dasharray', dashArray);
}
// Move selected routes to top (when opacity is 1, it's selected)
@@ -164,22 +164,22 @@
onclick?.();
};
const handleMouseEnter = () => {
map.getCanvas().style.cursor = "pointer";
map.getCanvas().style.cursor = 'pointer';
onmouseenter?.();
};
const handleMouseLeave = () => {
map.getCanvas().style.cursor = "";
map.getCanvas().style.cursor = '';
onmouseleave?.();
};
map.on("click", layerId, handleClick);
map.on("mouseenter", layerId, handleMouseEnter);
map.on("mouseleave", layerId, handleMouseLeave);
map.on('click', layerId, handleClick);
map.on('mouseenter', layerId, handleMouseEnter);
map.on('mouseleave', layerId, handleMouseLeave);
return () => {
map.off("click", layerId, handleClick);
map.off("mouseenter", layerId, handleMouseEnter);
map.off("mouseleave", layerId, handleMouseLeave);
map.off('click', layerId, handleClick);
map.off('mouseenter', layerId, handleMouseEnter);
map.off('mouseleave', layerId, handleMouseLeave);
};
});
</script>
@@ -1,10 +1,10 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
import { cn } from "$lib/utils.js";
import { getContext } from 'svelte';
import MapLibreGL from 'maplibre-gl';
import { cn } from '$lib/utils.js';
interface Props {
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
class?: string;
}
@@ -15,7 +15,7 @@
getElement: () => HTMLDivElement | null;
getMap: () => MapLibreGL.Map | null;
isReady: () => boolean;
}>("marker");
}>('marker');
let wrapperElement: HTMLDivElement | null = $state(null);
let movedContent: Node[] = [];
@@ -45,7 +45,7 @@
<!-- Hidden wrapper that holds content until marker is ready -->
<div bind:this={wrapperElement} style="display: contents;">
<div class={cn("relative cursor-pointer", className)}>
<div class={cn('relative cursor-pointer', className)}>
{#if children}
{@render children()}
{:else}
@@ -1,24 +1,24 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { cn } from '$lib/utils.js';
interface Props {
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
class?: string;
position?: "top" | "bottom";
position?: 'top' | 'bottom';
}
let { children, class: className, position = "top" }: Props = $props();
let { children, class: className, position = 'top' }: Props = $props();
const positionClasses = {
top: "bottom-full mb-1",
bottom: "top-full mt-1",
top: 'bottom-full mb-1',
bottom: 'top-full mt-1'
};
</script>
<div
class={cn(
"absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
"text-foreground text-[10px] font-medium",
'absolute left-1/2 -translate-x-1/2 whitespace-nowrap',
'text-[10px] font-medium text-foreground',
positionClasses[position],
className
)}
@@ -1,15 +1,15 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
import { cn } from "$lib/utils.js";
import X from "@lucide/svelte/icons/x";
import { getContext } from 'svelte';
import MapLibreGL, { type PopupOptions } from 'maplibre-gl';
import { cn } from '$lib/utils.js';
import X from '@lucide/svelte/icons/x';
interface Props {
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
class?: string;
closeButton?: boolean;
offset?: PopupOptions["offset"];
anchor?: PopupOptions["anchor"];
offset?: PopupOptions['offset'];
anchor?: PopupOptions['anchor'];
closeOnClick?: boolean;
closeOnMove?: boolean;
focusAfterOpen?: boolean;
@@ -25,7 +25,7 @@
closeOnClick,
closeOnMove,
focusAfterOpen,
maxWidth,
maxWidth
}: Props = $props();
const markerCtx = getContext<{
@@ -35,7 +35,7 @@
isReady: () => boolean;
isDraggable?: () => boolean;
isDragging?: () => boolean;
}>("marker");
}>('marker');
let popup: MapLibreGL.Popup | null = null;
let wrapperElement: HTMLDivElement | null = $state(null);
@@ -49,13 +49,13 @@
if (!ready || !marker || !wrapperElement) return;
// Create popup container
const container = document.createElement("div");
const container = document.createElement('div');
// Build popup options
const popupOptions: PopupOptions = {
offset,
closeButton: false,
className: "maplibre-popup-transparent",
className: 'maplibre-popup-transparent'
};
if (anchor !== undefined) popupOptions.anchor = anchor;
@@ -74,7 +74,7 @@
if (maxWidth) {
popupInstance.setMaxWidth(maxWidth);
} else {
popupInstance.setMaxWidth("none");
popupInstance.setMaxWidth('none');
}
// Attach popup to marker
@@ -127,8 +127,8 @@
<div bind:this={wrapperElement} style="display: contents;">
<div
class={cn(
"bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md",
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
'relative max-w-62 rounded-md border bg-popover p-3 text-popover-foreground shadow-md',
'animate-in duration-200 ease-out fade-in-0 zoom-in-95',
className
)}
>
@@ -137,7 +137,7 @@
type="button"
onclick={handleClose}
aria-label="Close popup"
class="focus-visible:ring-ring hover:bg-muted text-foreground absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm transition-colors focus:outline-none focus-visible:ring-2"
class="absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm text-foreground transition-colors hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X class="size-3.5" />
</button>
@@ -1,13 +1,13 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
import { cn } from "$lib/utils.js";
import { getContext } from 'svelte';
import MapLibreGL, { type PopupOptions } from 'maplibre-gl';
import { cn } from '$lib/utils.js';
interface Props {
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
class?: string;
offset?: PopupOptions["offset"];
anchor?: PopupOptions["anchor"];
offset?: PopupOptions['offset'];
anchor?: PopupOptions['anchor'];
}
let { children, class: className, offset = 16, anchor }: Props = $props();
@@ -17,7 +17,7 @@
getElement: () => HTMLDivElement | null;
getMap: () => MapLibreGL.Map | null;
isReady: () => boolean;
}>("marker");
}>('marker');
let wrapperElement: HTMLDivElement | null = $state(null);
@@ -31,21 +31,21 @@
if (!ready || !marker || !markerElement || !map || !wrapperElement) return;
// Create popup container
const container = document.createElement("div");
const container = document.createElement('div');
// Build popup options
const popupOptions: PopupOptions = {
offset,
closeOnClick: true,
closeButton: false,
className: "maplibre-popup-transparent",
className: 'maplibre-popup-transparent'
};
if (anchor !== undefined) popupOptions.anchor = anchor;
// Create popup
const popupInstance = new MapLibreGL.Popup(popupOptions)
.setMaxWidth("none")
.setMaxWidth('none')
.setDOMContent(container);
// Move content to popup container
@@ -62,12 +62,12 @@
popupInstance.remove();
};
markerElement.addEventListener("mouseenter", handleMouseEnter);
markerElement.addEventListener("mouseleave", handleMouseLeave);
markerElement.addEventListener('mouseenter', handleMouseEnter);
markerElement.addEventListener('mouseleave', handleMouseLeave);
return () => {
markerElement.removeEventListener("mouseenter", handleMouseEnter);
markerElement.removeEventListener("mouseleave", handleMouseLeave);
markerElement.removeEventListener('mouseenter', handleMouseEnter);
markerElement.removeEventListener('mouseleave', handleMouseLeave);
// Move content back
while (container.firstChild) {
@@ -82,8 +82,8 @@
<div bind:this={wrapperElement} style="display: contents;">
<div
class={cn(
"bg-foreground text-background pointer-events-none rounded-md px-2 py-1 text-xs text-balance shadow-md",
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
'pointer-events-none rounded-md bg-foreground px-2 py-1 text-xs text-balance text-background shadow-md',
'animate-in duration-200 ease-out fade-in-0 zoom-in-95',
className
)}
>
+13 -13
View File
@@ -1,13 +1,13 @@
export { default as Map } from "./Map.svelte";
export type { MapViewport } from "./Map.svelte";
export { default as MapMarker } from "./MapMarker.svelte";
export { default as MarkerContent } from "./MarkerContent.svelte";
export { default as MarkerPopup } from "./MarkerPopup.svelte";
export { default as MarkerTooltip } from "./MarkerTooltip.svelte";
export { default as MarkerLabel } from "./MarkerLabel.svelte";
export { default as MapControls } from "./MapControls.svelte";
export { default as MapPopup } from "./MapPopup.svelte";
export { default as MapRoute } from "./MapRoute.svelte";
export { default as MapClusterLayer } from "./MapClusterLayer.svelte";
export { default as MapArc } from "./MapArc.svelte";
export type { MapArcDatum, MapArcEvent, MapArcProps } from "./MapArc.svelte";
export { default as Map } from './Map.svelte';
export type { MapViewport } from './Map.svelte';
export { default as MapMarker } from './MapMarker.svelte';
export { default as MarkerContent } from './MarkerContent.svelte';
export { default as MarkerPopup } from './MarkerPopup.svelte';
export { default as MarkerTooltip } from './MarkerTooltip.svelte';
export { default as MarkerLabel } from './MarkerLabel.svelte';
export { default as MapControls } from './MapControls.svelte';
export { default as MapPopup } from './MapPopup.svelte';
export { default as MapRoute } from './MapRoute.svelte';
export { default as MapClusterLayer } from './MapClusterLayer.svelte';
export { default as MapArc } from './MapArc.svelte';
export type { MapArcDatum, MapArcEvent, MapArcProps } from './MapArc.svelte';
+2 -2
View File
@@ -1,8 +1,8 @@
export type MapTheme = "light" | "dark";
export type MapTheme = 'light' | 'dark';
export function resolveMapTheme({
explicitTheme,
ambientTheme,
ambientTheme
}: {
explicitTheme?: MapTheme;
ambientTheme: MapTheme;
@@ -30,9 +30,19 @@
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLInputElement;
const allowedKeys = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
const allowedKeys = [
'Backspace',
'Delete',
'Tab',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Home',
'End'
];
if (allowedKeys.includes(e.key)) return;
if (e.key === '+') return; // allow + anywhere; normalisePhoneInput removes extras
if (e.key === '+') return; // allow + anywhere; normalisePhoneInput removes extras
if (!/^[0-9]$/.test(e.key)) {
e.preventDefault();
}
@@ -31,7 +31,7 @@
<button
type="button"
onclick={toggle}
class="inline underline cursor-pointer text-xs hover:text-amber-700"
class="inline cursor-pointer text-xs underline hover:text-amber-700"
>
{#if trigger}
{@render trigger()}
@@ -42,7 +42,7 @@
{#if open}
<div
class="absolute left-0 top-full z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
>
<a
href="/cancellation-policy"
+9 -9
View File
@@ -1,11 +1,11 @@
export const POLICY = {
FULL_REFUND_THRESHOLD_HOURS: 72,
PARTIAL_REFUND_THRESHOLD_HOURS: 24,
NO_SHOW_THRESHOLD_HOURS: 24,
DEPOSIT_ADVANCE_HOURS: 36,
LOYALTY_STAMP_REDEMPTION_COST: 10,
RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS: 72,
RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS: 24,
PROTECTED_DEPOSIT_MAX_PCT: 0.50,
REQUIRED_DEPOSIT_PCT: 0.20,
FULL_REFUND_THRESHOLD_HOURS: 72,
PARTIAL_REFUND_THRESHOLD_HOURS: 24,
NO_SHOW_THRESHOLD_HOURS: 24,
DEPOSIT_ADVANCE_HOURS: 36,
LOYALTY_STAMP_REDEMPTION_COST: 10,
RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS: 72,
RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS: 24,
PROTECTED_DEPOSIT_MAX_PCT: 0.5,
REQUIRED_DEPOSIT_PCT: 0.2
} as const;
+4 -4
View File
@@ -1,5 +1,5 @@
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
import { getContext } from 'svelte';
import MapLibreGL from 'maplibre-gl';
type MapContext = {
getMap: () => MapLibreGL.Map | null;
@@ -8,7 +8,7 @@ type MapContext = {
};
export function useMap() {
const mapCtx = getContext<MapContext>("map");
const mapCtx = getContext<MapContext>('map');
const map = $derived.by(() => mapCtx?.getMap() ?? null);
const isLoaded = $derived.by(() => mapCtx?.isLoaded() ?? false);
@@ -23,6 +23,6 @@ export function useMap() {
},
get isStyleReady() {
return isStyleReady;
},
}
};
}
+48 -42
View File
@@ -1,51 +1,57 @@
export type SavedCard = {
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
cardholder_name?: string;
is_default: boolean;
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
cardholder_name?: string;
is_default: boolean;
};
function createSavedCardsStore() {
let cards = $state<SavedCard[]>([]);
let loading = $state(false);
let loaded = $state(false);
let cards = $state<SavedCard[]>([]);
let loading = $state(false);
let loaded = $state(false);
async function fetch() {
if (loading) return;
loading = true;
try {
const headers: Record<string, string> = {};
const token = globalThis.localStorage?.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await globalThis.fetch('/api/user/payment-methods', { headers });
if (res.ok) {
cards = await res.json();
} else {
cards = [];
}
loaded = true;
} catch {
cards = [];
} finally {
loading = false;
}
}
async function fetch() {
if (loading) return;
loading = true;
try {
const headers: Record<string, string> = {};
const token = globalThis.localStorage?.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await globalThis.fetch('/api/user/payment-methods', { headers });
if (res.ok) {
cards = await res.json();
} else {
cards = [];
}
loaded = true;
} catch {
cards = [];
} finally {
loading = false;
}
}
function invalidate() {
loaded = false;
return fetch();
}
function invalidate() {
loaded = false;
return fetch();
}
return {
get cards() { return cards; },
get loading() { return loading; },
get loaded() { return loaded; },
fetch,
invalidate,
};
return {
get cards() {
return cards;
},
get loading() {
return loading;
},
get loaded() {
return loaded;
},
fetch,
invalidate
};
}
export const savedCardsStore = createSavedCardsStore();
export const savedCardsStore = createSavedCardsStore();
+8 -2
View File
@@ -94,8 +94,14 @@ export function calculateAge(dateOfBirth: string | undefined | null): number | n
const today = new Date();
// Get London date components to ensure correct DST-safe age calculation
const [dobY, dobM, dobD] = dob.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }).split('-').map(Number);
const [todayY, todayM, todayD] = today.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }).split('-').map(Number);
const [dobY, dobM, dobD] = dob
.toLocaleDateString('en-CA', { timeZone: 'Europe/London' })
.split('-')
.map(Number);
const [todayY, todayM, todayD] = today
.toLocaleDateString('en-CA', { timeZone: 'Europe/London' })
.split('-')
.map(Number);
let age = todayY - dobY;
const monthDiff = todayM - dobM;
+22 -22
View File
@@ -2,7 +2,7 @@
* Strips formatting characters from a phone input, preserving digits and leading +.
*/
export function normalisePhoneInput(value: string): string {
return value.replace(/[^\d+]/g, '').replace(/(?!^)\+/g, '');
return value.replace(/[^\d+]/g, '').replace(/(?!^)\+/g, '');
}
/**
@@ -11,8 +11,8 @@ export function normalisePhoneInput(value: string): string {
* Returns true if valid.
*/
export function isValidUKPhone(phone: string): boolean {
const clean = normalisePhoneInput(phone);
return /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/.test(clean);
const clean = normalisePhoneInput(phone);
return /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/.test(clean);
}
/**
@@ -20,20 +20,20 @@ export function isValidUKPhone(phone: string): boolean {
* e.g. 07700900000 07700 900000, +447700900000 +44 7700 900000
*/
export function formatPhoneDisplay(value: string): string {
const clean = normalisePhoneInput(value);
if (clean.startsWith('+44')) {
const digits = clean.slice(3).slice(0, 10); // max 10 digits after +44
if (digits.length <= 4) return '+44 ' + digits;
if (digits.length <= 8) return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4);
return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
}
if (clean.startsWith('0')) {
const digits = clean.slice(1).slice(0, 10); // max 10 digits after 0
if (digits.length <= 4) return '0' + digits;
if (digits.length <= 7) return '0' + digits.slice(0, 4) + ' ' + digits.slice(4);
return '0' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
}
return clean.slice(0, 15); // fallback limit
const clean = normalisePhoneInput(value);
if (clean.startsWith('+44')) {
const digits = clean.slice(3).slice(0, 10); // max 10 digits after +44
if (digits.length <= 4) return '+44 ' + digits;
if (digits.length <= 8) return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4);
return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
}
if (clean.startsWith('0')) {
const digits = clean.slice(1).slice(0, 10); // max 10 digits after 0
if (digits.length <= 4) return '0' + digits;
if (digits.length <= 7) return '0' + digits.slice(0, 4) + ' ' + digits.slice(4);
return '0' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
}
return clean.slice(0, 15); // fallback limit
}
/**
@@ -41,8 +41,8 @@ export function formatPhoneDisplay(value: string): string {
* or null if the input is not a valid UK number.
*/
export function toE164UK(phone: string): string | null {
const clean = normalisePhoneInput(phone);
if (!isValidUKPhone(clean)) return null;
if (clean.startsWith('0')) return '+44' + clean.slice(1);
return clean;
}
const clean = normalisePhoneInput(phone);
if (!isValidUKPhone(clean)) return null;
if (clean.startsWith('0')) return '+44' + clean.slice(1);
return clean;
}
+26 -11
View File
@@ -32,14 +32,14 @@ export function formatLocalDateTime(date: Date): string {
* "2026-06-15T09:00:00+00:00") from the backend and returns a Date
* whose getHours()/getMinutes() in the browser's local timezone
* reflect the local wall-clock time.
*
*
* The backend stores all times as UTC wall-clock values. This function
* uses the standard Date parser which correctly interprets the ISO
* string as UTC and applies the browser timezone for get*() accessors.
* Example: "2026-06-15T09:00:00Z" getHours() returns 10 in BST.
*/
export function parseWallClockDate(iso: string): Date {
return new Date(iso);
return new Date(iso);
}
/**
@@ -47,8 +47,8 @@ export function parseWallClockDate(iso: string): Date {
* Always shows the wall-clock time, NOT shifted by timezone.
*/
export function formatWallClockTime(iso: string): string {
const d = parseWallClockDate(iso);
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false });
const d = parseWallClockDate(iso);
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false });
}
/**
@@ -56,8 +56,13 @@ export function formatWallClockTime(iso: string): string {
* Always shows the wall-clock date, NOT shifted by timezone.
*/
export function formatWallClockDate(iso: string): string {
const d = parseWallClockDate(iso);
return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const d = parseWallClockDate(iso);
return d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
/**
@@ -67,9 +72,9 @@ export function formatWallClockDate(iso: string): string {
* London date. Avoids using browser-local time which can be wrong in that window.
*/
export function getLondonTodayCalendarDate(): CalendarDate {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
return new CalendarDate(y, m, d);
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
return new CalendarDate(y, m, d);
}
export interface TimeSlotGroup {
@@ -195,7 +200,12 @@ export function generateAvailableTimeSlots(
if (isToday) {
const now = new Date();
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const londonTimeStr = now.toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
const currentMinutes = londonHours * 60 + londonMinutes;
let minimumStart = Math.ceil((currentMinutes + 60) / 15) * 15;
@@ -240,7 +250,12 @@ export function generateGroupedTimeSlots(
const todayCal = getLondonTodayCalendarDate();
if (date.compare(todayCal) === 0) {
const now = new Date();
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const londonTimeStr = now.toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
const currentMinutes = londonHours * 60 + londonMinutes;
startTotalMinutes = Math.max(startTotalMinutes, Math.ceil((currentMinutes + 15) / 15) * 15);
+169 -117
View File
@@ -826,8 +826,8 @@
}
async function saveName() {
const newFirstName = editingFirstName ? firstNameInput.trim() : (userData?.firstName || '');
const newLastName = editingLastName ? lastNameInput.trim() : (userData?.lastName || '');
const newFirstName = editingFirstName ? firstNameInput.trim() : userData?.firstName || '';
const newLastName = editingLastName ? lastNameInput.trim() : userData?.lastName || '';
// Validate
if (!newFirstName || !newLastName) {
@@ -1554,78 +1554,114 @@
{/each}
{:else if userData}
<div class="grid gap-4 md:grid-cols-2">
<div>
<span class="text-sm font-medium text-gray-600">First Name</span>
{#if editingFirstName}
<div class="mt-1 space-y-2">
<Input
id="first-name"
bind:value={firstNameInput}
placeholder="Enter first name"
maxlength={50}
error={firstNameError}
/>
{#if firstNameError}
<p class="text-xs text-red-500">{firstNameError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={saveName} disabled={savingName || !firstNameInput.trim()}>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="outline" onclick={cancelEditName} disabled={savingName}>
Cancel
<div>
<span class="text-sm font-medium text-gray-600">First Name</span>
{#if editingFirstName}
<div class="mt-1 space-y-2">
<Input
id="first-name"
bind:value={firstNameInput}
placeholder="Enter first name"
maxlength={50}
error={firstNameError}
/>
{#if firstNameError}
<p class="text-xs text-red-500">{firstNameError}</p>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={saveName}
disabled={savingName || !firstNameInput.trim()}
>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onclick={cancelEditName}
disabled={savingName}
>
Cancel
</Button>
</div>
</div>
{:else}
<div
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
>
<span>{userData.firstName}</span>
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
</div>
{:else}
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
<span>{userData.firstName}</span>
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
<div>
<span class="text-sm font-medium text-gray-600">Last Name</span>
{#if editingLastName}
<div class="mt-1 space-y-2">
<Input
id="last-name"
bind:value={lastNameInput}
placeholder="Enter last name"
maxlength={50}
error={lastNameError}
/>
{#if lastNameError}
<p class="text-xs text-red-500">{lastNameError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={saveName} disabled={savingName || !lastNameInput.trim()}>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="outline" onclick={cancelEditName} disabled={savingName}>
Cancel
{/if}
</div>
<div>
<span class="text-sm font-medium text-gray-600">Last Name</span>
{#if editingLastName}
<div class="mt-1 space-y-2">
<Input
id="last-name"
bind:value={lastNameInput}
placeholder="Enter last name"
maxlength={50}
error={lastNameError}
/>
{#if lastNameError}
<p class="text-xs text-red-500">{lastNameError}</p>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={saveName}
disabled={savingName || !lastNameInput.trim()}
>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onclick={cancelEditName}
disabled={savingName}
>
Cancel
</Button>
</div>
</div>
{:else}
<div
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
>
<span>{userData.lastName}</span>
<Button size="sm" variant="ghost" onclick={startEditLastName}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
</div>
{:else}
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
<span>{userData.lastName}</span>
<Button size="sm" variant="ghost" onclick={startEditLastName}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
{/if}
</div>
<div>
<span class="text-sm font-medium text-gray-600">Email</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
@@ -1643,7 +1679,11 @@
placeholder="Enter phone number"
/>
<div class="flex gap-2">
<Button size="sm" onclick={savePhone} disabled={savingPhone || !isValidUKPhone(phoneInput)}>
<Button
size="sm"
onclick={savePhone}
disabled={savingPhone || !isValidUKPhone(phoneInput)}
>
{savingPhone ? 'Saving...' : 'Save'}
</Button>
<Button
@@ -1693,16 +1733,17 @@
</div>
{/if}
<div
class="rounded-xl border-2 border-fuchsia-200 bg-fuchsia-50 p-5 sm:p-6"
>
<div class="rounded-xl border-2 border-fuchsia-200 bg-fuchsia-50 p-5 sm:p-6">
{#if userData}
<div class="mb-5 text-center sm:text-left">
<h3 class="text-sm font-semibold text-gray-900">Loyalty Stamp Card</h3>
<p class="mt-0.5 text-xs text-gray-500">
{stamps < 10
? `Collect ${10 - stamps} more stamp${10 - stamps === 1 ? '' : 's'} to get 10% off your next booking.`
: (() => { const fc = Math.floor(stamps / 10); return `You have ${fc === 1 ? 'a' : fc} full stampcard${fc === 1 ? '' : 's'} ready to take advantage of at your next booking!`; })()}
: (() => {
const fc = Math.floor(stamps / 10);
return `You have ${fc === 1 ? 'a' : fc} full stampcard${fc === 1 ? '' : 's'} ready to take advantage of at your next booking!`;
})()}
</p>
</div>
@@ -1710,7 +1751,7 @@
{#each Array(10) as _, i}
{@const slotNum = i + 1}
{@const rot = ((slotNum * 37 + 13) % 7) - 3}
{#if slotNum <= (stamps > 0 ? (stamps % 10 || 10) : 0)}
{#if slotNum <= (stamps > 0 ? stamps % 10 || 10 : 0)}
<div
class="aspect-square transition-transform duration-200 hover:scale-110"
>
@@ -1947,17 +1988,15 @@
</div>
<div class="text-sm">Friends Referred</div>
</div>
<div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold">
£{(userData.referralSavings || 0).toFixed(2)}
<div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold">
£{(userData.referralSavings || 0).toFixed(2)}
</div>
<div class="text-sm">Total Saved</div>
</div>
<div class="text-sm">Total Saved</div>
</div>
</div>
<Card.Root
class="mt-6 border-amber-200/60 bg-amber-50"
>
<Card.Root class="mt-6 border-amber-200/60 bg-amber-50">
<Card.Content class="pt-4 md:pt-6">
<div class="space-y-2 text-sm text-amber-900">
<h4 class="font-semibold text-amber-800">How it works:</h4>
@@ -2163,13 +2202,13 @@
oninput={handleGiftCardInput}
class="font-mono"
/>
<Button
onclick={() => (showRedeemConfirm = true)}
disabled={redeemingGiftCard ||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
>
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button>
<Button
onclick={() => (showRedeemConfirm = true)}
disabled={redeemingGiftCard ||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
>
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
</Card.Content>
@@ -2180,42 +2219,40 @@
<AlertDialog.Header>
<AlertDialog.Title>Redeem Gift Card</AlertDialog.Title>
<AlertDialog.Description>
Claiming this gift card will add its remaining balance directly to your
account balance, which can be used toward future bookings.
Claiming this gift card will add its remaining balance directly to your account
balance, which can be used toward future bookings.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="px-6 py-4 space-y-3 text-sm text-muted-foreground">
<div class="rounded-lg border bg-amber-50/50 p-3 space-y-2">
<div class="space-y-3 px-6 py-4 text-sm text-muted-foreground">
<div class="space-y-2 rounded-lg border bg-amber-50/50 p-3">
<p>
<strong class="text-foreground">What happens when I claim?</strong>
</p>
<ul class="list-disc pl-4 space-y-1">
<ul class="list-disc space-y-1 pl-4">
<li>The gift card value is added to your account balance.</li>
<li>
Account balances do not expire, but gift card codes become invalid once
redeemed.
</li>
<li>
This action is final and cannot be reversed.
</li>
<li>This action is final and cannot be reversed.</li>
</ul>
</div>
<div class="rounded-lg border bg-blue-50/50 p-3 space-y-2">
<div class="space-y-2 rounded-lg border bg-blue-50/50 p-3">
<p>
<strong class="text-foreground">Legal &amp; GDPR Information</strong>
</p>
<ul class="list-disc pl-4 space-y-1">
<ul class="list-disc space-y-1 pl-4">
<li>
Your personal data (name, email, transaction history) is processed in
accordance with UK data protection law.
</li>
<li>
Financial records are retained for 7 years as required by HMRC, after
which personally identifiable information is anonymised.
Financial records are retained for 7 years as required by HMRC, after which
personally identifiable information is anonymised.
</li>
<li>
You can request a full copy of your data or deletion of your account
at any time via your account settings.
You can request a full copy of your data or deletion of your account at any
time via your account settings.
</li>
</ul>
</div>
@@ -2348,17 +2385,17 @@
</div>
{#if buyRecipientType === 'friend'}
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700"
>Friend's Email (Optional)</label
>
<EmailInput
id="recipient-email"
bind:value={buyRecipientEmail}
placeholder="friend@example.com (blank to send to yourself)"
class="mt-1"
/>
</div>
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700"
>Friend's Email (Optional)</label
>
<EmailInput
id="recipient-email"
bind:value={buyRecipientEmail}
placeholder="friend@example.com (blank to send to yourself)"
class="mt-1"
/>
</div>
{/if}
<div class="space-y-3 border-t pt-2">
@@ -2665,7 +2702,22 @@
<PolicyPopover>
{#snippet trigger()}
<Button variant="outline">
<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"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<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"
><path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/><polyline points="14 2 14 8 20 8" /><line
x1="16"
y1="13"
x2="8"
y2="13"
/><line x1="16" y1="17" x2="8" y2="17" /></svg
>
Cancellation & Deposit Policy
</Button>
{/snippet}
+15 -5
View File
@@ -71,14 +71,24 @@
<svelte:head>
<script>
(function() {
(function () {
try {
var token = localStorage.getItem('authToken');
if (!token) { window.location.replace('/login'); return; }
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
if (payload.role !== 'admin') { window.location.replace('/'); }
} catch(e) { window.location.replace('/login'); }
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
return;
}
if (payload.role !== 'admin') {
window.location.replace('/');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
</svelte:head>
@@ -331,7 +331,10 @@
function getNotificationSubtitle(n: Notification): string {
const parts = [formatRelative(n.created_at)];
if (n.user_name) {
parts.push(n.user_name); {/* TODO: add formerly name when previous name data is available */}
parts.push(n.user_name);
{
/* TODO: add formerly name when previous name data is available */
}
}
return parts.join(' — ');
}
@@ -347,14 +350,24 @@
<svelte:head>
<script>
(function() {
(function () {
try {
var token = localStorage.getItem('authToken');
if (!token) { window.location.replace('/login'); return; }
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
if (payload.role !== 'admin') { window.location.replace('/'); }
} catch(e) { window.location.replace('/login'); }
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
return;
}
if (payload.role !== 'admin') {
window.location.replace('/');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
</svelte:head>
@@ -509,7 +522,7 @@
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking?.id ?? ''} />
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} openBookingModal={openBookingModal} />
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
{#if showEditRequestModal && selectedEditRequest}
<EditRequestModal
+44 -15
View File
@@ -19,7 +19,11 @@
start_time: string;
status: string;
duration_minutes: number;
user?: { full_name: string; previous_first_name?: string | null; previous_last_name?: string | null };
user?: {
full_name: string;
previous_first_name?: string | null;
previous_last_name?: string | null;
};
services: BookingService[];
};
type TimeBlocker = {
@@ -108,7 +112,12 @@
* Intl.DateTimeFormat with Europe/London so the current-time blue line is
* positioned correctly regardless of the browser's system timezone. */
function getLondonNowMinutes(): number {
const timeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const timeStr = new Date().toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [h, m] = timeStr.split(':').map(Number);
return h * 60 + m;
}
@@ -365,7 +374,8 @@
return { minStart: 8, maxEnd: 18, hours: Array.from({ length: 11 }, (_, i) => i + 8) };
// Find earliest start and latest end across ALL data (hours, bookings, blockers)
let earliest = 24, latest = 0;
let earliest = 24,
latest = 0;
for (const day of getWeekDays(weekStart)) {
const dateStr = formatDate(day);
@@ -378,14 +388,14 @@
const ef = eh + (em || 0) / 60;
if (ef > latest) latest = ef;
}
for (const b of (bookingsByDate.get(dateStr) || [])) {
for (const b of bookingsByDate.get(dateStr) || []) {
const d = new SvelteDate(b.start_time);
const sf = d.getHours() + d.getMinutes() / 60;
if (sf < earliest) earliest = sf;
const ef = sf + b.duration_minutes / 60;
if (ef > latest) latest = ef;
}
for (const b of (blockersByDate.get(dateStr) || [])) {
for (const b of blockersByDate.get(dateStr) || []) {
const d = new SvelteDate(b.start_time);
const sf = d.getHours() + d.getMinutes() / 60;
if (sf < earliest) earliest = sf;
@@ -395,15 +405,16 @@
}
// No data: fall back to 8-18
if (earliest >= 24 || latest <= 0) return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
if (earliest >= 24 || latest <= 0)
return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
// Start: 30min buffer before earliest (to show the pre-booking gap)
// End: floor of latest (the row that CONTAINS the latest item, not the next row)
let minStart = Math.floor(earliest - 0.5);
let maxEnd = Math.floor(latest);
let maxEnd = Math.floor(latest);
minStart = Math.max(0, minStart);
maxEnd = Math.min(24, maxEnd);
maxEnd = Math.min(24, maxEnd);
const hours: number[] = [];
for (let h = minStart; h <= maxEnd; h++) hours.push(h);
@@ -432,14 +443,24 @@
<svelte:head>
<script>
(function() {
(function () {
try {
var token = localStorage.getItem('authToken');
if (!token) { window.location.replace('/login'); return; }
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
if (payload.role !== 'admin') { window.location.replace('/'); }
} catch(e) { window.location.replace('/login'); }
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
return;
}
if (payload.role !== 'admin') {
window.location.replace('/');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
</svelte:head>
@@ -612,7 +633,11 @@
{#if !hasOverlap}
<button
type="button"
aria-label="View booking for {formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}"
aria-label="View booking for {formatUserName(
b.user?.full_name || 'Guest',
b.user?.previous_first_name,
b.user?.previous_last_name
)}"
class="absolute inset-x-0.5 z-10 cursor-pointer overflow-hidden rounded border px-1.5 py-0.5 text-left text-xs transition-shadow hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500"
style="top: {topOffset}px; height: {heightPx}px; {bookingStyle(
b.status
@@ -628,7 +653,11 @@
style="background:{dotColor(b.status)}"
></div>
<span class="truncate font-medium"
>{formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}</span
>{formatUserName(
b.user?.full_name || 'Guest',
b.user?.previous_first_name,
b.user?.previous_last_name
)}</span
>
</div>
{#if heightPx > 36}
+189 -196
View File
@@ -28,12 +28,12 @@ Decompose `+page.svelte` into focused, testable components with clear data flow
**Single source of truth:** `BookingFlow.svelte`
* currentStep
* selectedServices
* selectedDate
* selectedTime
* customerInfo
* pricing / totals
- currentStep
- selectedServices
- selectedDate
- selectedTime
- customerInfo
- pricing / totals
Everything else receives props + dispatches events. No hidden global coupling. 🧠
@@ -45,14 +45,14 @@ Everything else receives props + dispatches events. No hidden global coupling.
**Role:** Orchestrator
* Holds booking state
* Validates transitions between steps
* Calls API + handles toasts
- Holds booking state
- Validates transitions between steps
- Calls API + handles toasts
**Props:** none
**Emits:** none
This is the only component allowed to know *everything*.
This is the only component allowed to know _everything_.
---
@@ -63,8 +63,8 @@ This is the only component allowed to know *everything*.
**Props**
```ts
currentStep: number
totalSteps: number
currentStep: number;
totalSteps: number;
```
Pure UI. Zero logic.
@@ -85,8 +85,8 @@ selected: Service[]
**Emits**
```ts
select(service)
deselect(service)
select(service);
deselect(service);
```
Internally renders multiple `ServiceCard`s.
@@ -100,8 +100,8 @@ Internally renders multiple `ServiceCard`s.
**Props**
```ts
service: Service
selected: boolean
service: Service;
selected: boolean;
```
No awareness of booking steps or pricing totals.
@@ -117,13 +117,13 @@ Wraps your existing `Calendar` usage.
**Props**
```ts
date: CalendarDate | undefined
date: CalendarDate | undefined;
```
**Emits**
```ts
change(date)
change(date);
```
---
@@ -143,7 +143,7 @@ availability: TimeSlot[]
**Emits**
```ts
select(time)
select(time);
```
---
@@ -155,13 +155,13 @@ select(time)
**Props**
```ts
value: CustomerInfo
value: CustomerInfo;
```
**Emits**
```ts
update(value)
update(value);
```
No submit button here. Forms shouldnt decide flow control.
@@ -193,17 +193,17 @@ Zero mutation. Snapshot only.
**Props**
```ts
canBack: boolean
canNext: boolean
isSubmitting: boolean
canBack: boolean;
canNext: boolean;
isSubmitting: boolean;
```
**Emits**
```ts
back()
next()
submit()
back();
next();
submit();
```
---
@@ -234,10 +234,10 @@ Use only if the flow must persist across routes or reloads. Otherwise keep it lo
## Result
* Smaller files
* Predictable data flow
* Each component explainable in one sentence
* Easier testing and future changes
- Smaller files
- Predictable data flow
- Each component explainable in one sentence
- Easier testing and future changes
Entropy reduced. ✂️🧩
@@ -249,64 +249,62 @@ We start by **wrapping the existing logic**, not rewriting it.
### 1. Create `BookingFlow.svelte`
Move *all* bookingrelated state and logic out of `+page.svelte` into this file.
Move _all_ bookingrelated state and logic out of `+page.svelte` into this file.
```svelte
<script lang="ts">
import StepIndicator from './StepIndicator.svelte';
import ServiceSelector from './ServiceSelector.svelte';
import DatePicker from './DatePicker.svelte';
import TimeSlotPicker from './TimeSlotPicker.svelte';
import CustomerDetailsForm from './CustomerDetailsForm.svelte';
import BookingSummary from './BookingSummary.svelte';
import BookingActions from './BookingActions.svelte';
import StepIndicator from './StepIndicator.svelte';
import ServiceSelector from './ServiceSelector.svelte';
import DatePicker from './DatePicker.svelte';
import TimeSlotPicker from './TimeSlotPicker.svelte';
import CustomerDetailsForm from './CustomerDetailsForm.svelte';
import BookingSummary from './BookingSummary.svelte';
import BookingActions from './BookingActions.svelte';
import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';
import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';
// --- State (copied verbatim from +page.svelte) ---
let currentStep = 1;
let selectedServices: Service[] = [];
let selectedDate;
let selectedTime: string | null = null;
let customerInfo: CustomerInfo = { /* unchanged */ };
// --- State (copied verbatim from +page.svelte) ---
let currentStep = 1;
let selectedServices: Service[] = [];
let selectedDate;
let selectedTime: string | null = null;
let customerInfo: CustomerInfo = {
/* unchanged */
};
// pricing, derived values, API calls stay here
// pricing, derived values, API calls stay here
</script>
<StepIndicator {currentStep} totalSteps={4} />
{#if currentStep === 1}
<ServiceSelector
services={services}
selected={selectedServices}
on:select={(e) => selectedServices.push(e.detail)}
on:deselect={(e) => selectedServices = selectedServices.filter(s => s.id !== e.detail.id)}
/>
<ServiceSelector
{services}
selected={selectedServices}
on:select={(e) => selectedServices.push(e.detail)}
on:deselect={(e) => (selectedServices = selectedServices.filter((s) => s.id !== e.detail.id))}
/>
{:else if currentStep === 2}
<DatePicker bind:date={selectedDate} />
<TimeSlotPicker
date={selectedDate}
availability={availability}
bind:selectedTime
/>
<DatePicker bind:date={selectedDate} />
<TimeSlotPicker date={selectedDate} {availability} bind:selectedTime />
{:else if currentStep === 3}
<CustomerDetailsForm bind:value={customerInfo} />
<CustomerDetailsForm bind:value={customerInfo} />
{:else if currentStep === 4}
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={customerInfo}
total={total}
/>
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={customerInfo}
{total}
/>
{/if}
<BookingActions
canBack={currentStep > 1}
canNext={currentStep < 4}
on:back={() => currentStep--}
on:next={() => currentStep++}
on:submit={submitBooking}
canBack={currentStep > 1}
canNext={currentStep < 4}
on:back={() => currentStep--}
on:next={() => currentStep++}
on:submit={submitBooking}
/>
```
@@ -318,7 +316,7 @@ Nothing clever yet. This is a **containerization step**, not a refactor.
```svelte
<script lang="ts">
import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
</script>
<BookingFlow />
@@ -326,9 +324,9 @@ Nothing clever yet. This is a **containerization step**, not a refactor.
At this point:
* Behaviour is identical
* No store introduced
* You have a single, explicit "brain"
- Behaviour is identical
- No store introduced
- You have a single, explicit "brain"
---
@@ -342,11 +340,11 @@ This is the safest cut: **pure UI, minimal state, zero flow control**.
In `ServiceSelector`, find the repeated markup that:
* Displays service name / price / duration
* Highlights selected state
* Handles click / toggle
- Displays service name / price / duration
- Highlights selected state
- Handles click / toggle
If it *renders one service*, it becomes a card.
If it _renders one service_, it becomes a card.
---
@@ -354,31 +352,32 @@ If it *renders one service*, it becomes a card.
```svelte
<script lang="ts">
import type { Service } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
import type { Service } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
export let service: Service;
export let selected = false;
export let service: Service;
export let selected = false;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
function toggle() {
dispatch(selected ? 'deselect' : 'select', service);
}
function toggle() {
dispatch(selected ? 'deselect' : 'select', service);
}
</script>
<button
class:selected
on:click={toggle}
>
<h3>{service.name}</h3>
<p>{service.duration} min</p>
<p>£{service.price}</p>
<button class:selected on:click={toggle}>
<h3>{service.name}</h3>
<p>{service.duration} min</p>
<p>£{service.price}</p>
</button>
<style>
button { /* existing styles */ }
.selected { /* existing selected styles */ }
button {
/* existing styles */
}
.selected {
/* existing selected styles */
}
</style>
```
@@ -390,26 +389,26 @@ No booking logic. No totals. No step awareness.
```svelte
<script lang="ts">
import ServiceCard from './ServiceCard.svelte';
import type { Service } from '$lib/types/booking';
import ServiceCard from './ServiceCard.svelte';
import type { Service } from '$lib/types/booking';
export let services: Service[] = [];
export let selected: Service[] = [];
export let services: Service[] = [];
export let selected: Service[] = [];
</script>
<div class="grid">
{#each services as service (service.id)}
<ServiceCard
{service}
selected={selected.some(s => s.id === service.id)}
on:select
on:deselect
/>
{/each}
{#each services as service (service.id)}
<ServiceCard
{service}
selected={selected.some((s) => s.id === service.id)}
on:select
on:deselect
/>
{/each}
</div>
```
Selection state still lives *above*. This is critical.
Selection state still lives _above_. This is critical.
---
@@ -417,9 +416,9 @@ Selection state still lives *above*. This is critical.
At this point:
* `ServiceCard` is dumb
* `ServiceSelector` coordinates cards
* `BookingFlow` owns truth
- `ServiceCard` is dumb
- `ServiceSelector` coordinates cards
- `BookingFlow` owns truth
If this feels boring, good. Boring code is stable code.
@@ -439,11 +438,11 @@ The rule here is strict:
In `BookingFlow`, locate:
* Name / email / phone inputs
* Validation messages
* `on:input` handlers
- Name / email / phone inputs
- Validation messages
- `on:input` handlers
Anything that mutates `customerInfo` belongs in the form *except* submission.
Anything that mutates `customerInfo` belongs in the form _except_ submission.
---
@@ -451,39 +450,39 @@ Anything that mutates `customerInfo` belongs in the form *except* submission.
```svelte
<script lang="ts">
import type { CustomerInfo } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
import type { CustomerInfo } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
export let value: CustomerInfo;
export let value: CustomerInfo;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
dispatch('update', { ...value, [key]: val });
}
function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
dispatch('update', { ...value, [key]: val });
}
</script>
<div class="space-y-4">
<input
type="text"
placeholder="Name"
value={value.name}
on:input={(e) => update('name', e.currentTarget.value)}
/>
<input
type="text"
placeholder="Name"
value={value.name}
on:input={(e) => update('name', e.currentTarget.value)}
/>
<input
type="email"
placeholder="Email"
value={value.email}
on:input={(e) => update('email', e.currentTarget.value)}
/>
<input
type="email"
placeholder="Email"
value={value.email}
on:input={(e) => update('email', e.currentTarget.value)}
/>
<input
type="tel"
placeholder="Phone"
value={value.phone}
on:input={(e) => update('phone', e.currentTarget.value)}
/>
<input
type="tel"
placeholder="Phone"
value={value.phone}
on:input={(e) => update('phone', e.currentTarget.value)}
/>
</div>
```
@@ -496,16 +495,13 @@ No submit button. No step logic. No API calls.
Replace inline inputs with:
```svelte
<CustomerDetailsForm
value={customerInfo}
on:update={(e) => customerInfo = e.detail}
/>
<CustomerDetailsForm value={customerInfo} on:update={(e) => (customerInfo = e.detail)} />
```
Validation still lives in `BookingFlow`:
* Can we go to the next step?
* Is submit enabled?
- Can we go to the next step?
- Is submit enabled?
---
@@ -513,11 +509,11 @@ Validation still lives in `BookingFlow`:
You should now observe:
* The form is reusable
* BookingFlow got smaller
* Step logic is easier to read
- The form is reusable
- BookingFlow got smaller
- Step logic is easier to read
If you *cant* explain where a rule lives in one sentence, its in the wrong place.
If you _cant_ explain where a rule lives in one sentence, its in the wrong place.
---
@@ -533,22 +529,19 @@ Rule of the step:
### 4.1 Extract `DatePicker.svelte`
This component selects *only* a date. No availability logic. No time awareness.
This component selects _only_ a date. No availability logic. No time awareness.
```svelte
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { CalendarDate } from '@internationalized/date';
import { createEventDispatcher } from 'svelte';
import type { CalendarDate } from '@internationalized/date';
export let date: CalendarDate | undefined;
export let date: CalendarDate | undefined;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
</script>
<Calendar
value={date}
on:change={(e) => dispatch('change', e.detail)}
/>
<Calendar value={date} on:change={(e) => dispatch('change', e.detail)} />
```
It emits intent. Thats it.
@@ -557,34 +550,34 @@ It emits intent. Thats it.
### 4.2 Extract `TimeSlotPicker.svelte`
Time slots depend on *inputs*, never globals.
Time slots depend on _inputs_, never globals.
```svelte
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { TimeSlot } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
import type { TimeSlot } from '$lib/types/booking';
export let date; // required
export let availability: TimeSlot[] = [];
export let selectedTime: string | null = null;
export let date; // required
export let availability: TimeSlot[] = [];
export let selectedTime: string | null = null;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
</script>
{#if !date}
<p class="text-muted">Select a date first</p>
<p class="text-muted">Select a date first</p>
{:else}
<div class="grid">
{#each availability as slot (slot.time)}
<button
class:selected={slot.time === selectedTime}
disabled={!slot.available}
on:click={() => dispatch('select', slot.time)}
>
{slot.time}
</button>
{/each}
</div>
<div class="grid">
{#each availability as slot (slot.time)}
<button
class:selected={slot.time === selectedTime}
disabled={!slot.available}
on:click={() => dispatch('select', slot.time)}
>
{slot.time}
</button>
{/each}
</div>
{/if}
```
@@ -598,27 +591,27 @@ Here is the **complete and correct wiring**, including guards and reset logic:
```svelte
<DatePicker
date={selectedDate}
on:change={(e) => {
const newDate = e.detail;
selectedDate = newDate;
date={selectedDate}
on:change={(e) => {
const newDate = e.detail;
selectedDate = newDate;
// changing date invalidates time
selectedTime = null;
// changing date invalidates time
selectedTime = null;
// fetch / recompute availability here
loadAvailability(newDate);
}}
// fetch / recompute availability here
loadAvailability(newDate);
}}
/>
<TimeSlotPicker
date={selectedDate}
availability={availability}
selectedTime={selectedTime}
on:select={(e) => {
selectedTime = e.detail;
}}
date={selectedDate}
{availability}
{selectedTime}
on:select={(e) => {
selectedTime = e.detail;
}}
/>
```
All temporal logic lives here. Children stay honest.
All temporal logic lives here. Children stay honest.
@@ -369,7 +369,9 @@
<span class="text-gray-600">VAT</span>
<span class="font-medium">{formatPence(paymentSummary.total_vat_amount)}</span>
</div>
<div class="mt-1 flex justify-between border-t border-gray-200 pt-1 text-sm font-semibold">
<div
class="mt-1 flex justify-between border-t border-gray-200 pt-1 text-sm font-semibold"
>
<span class="text-gray-800">Total paid</span>
<span class="text-gray-800">{formatPence(paymentSummary.paid_amount)}</span>
</div>
@@ -60,9 +60,9 @@
<p class="mb-3">
Where a deposit is required to secure your appointment, you must pay at least
<strong>20%</strong> of the total service cost before the 24-hour deadline prior to the
appointment. The 20% can be paid in a single payment or accumulated across multiple
payments — what matters is the total when the deadline passes. When deposit restrictions
are active on your account, appointments must be scheduled at least
appointment. The 20% can be paid in a single payment or accumulated across multiple payments
— what matters is the total when the deadline passes. When deposit restrictions are active
on your account, appointments must be scheduled at least
<strong>36 hours in advance</strong>.
</p>
<p class="mb-3">
@@ -80,9 +80,9 @@
</h2>
<p class="mb-3">
If a required deposit is not paid at least 24 hours before the appointment begins, the
booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes
vulnerable — if another customer books an overlapping time and pays, your original
booking is automatically evicted.
booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes vulnerable
— if another customer books an overlapping time and pays, your original booking is automatically
evicted.
</p>
<p class="mb-3">
While in this status, your appointment is <strong>not guaranteed</strong>. The system will
@@ -144,9 +144,9 @@
days).
</li>
<li>
<strong>Gift card payments</strong>: Refunded back to the original gift card. The
gift card's remaining balance is incremented and is immediately available for use.
Expired gift cards are non-refundable.
<strong>Gift card payments</strong>: Refunded back to the original gift card. The gift
card's remaining balance is incremented and is immediately available for use. Expired gift
cards are non-refundable.
</li>
<li>
<strong>Cash payments</strong>: Credited to your account balance, available for immediate
@@ -172,8 +172,8 @@
registered users).
</li>
<li>
<strong>Gift card payments</strong>: Refunded back to the original gift card. The
gift card's remaining balance is incremented. Expired gift cards are non-refundable.
<strong>Gift card payments</strong>: Refunded back to the original gift card. The gift
card's remaining balance is incremented. Expired gift cards are non-refundable.
</li>
<li>
<strong>Cash payments</strong>: Refunded in person at the salon. Please bring your receipt
@@ -192,8 +192,8 @@
<p class="mb-3">
Failing to attend a confirmed appointment without notifying us in advance constitutes a
"No-Show". Cancelling a pending or deposit-lapsed booking within 24 hours does
<strong>not</strong> count as a no-show — only confirmed bookings (where the slot was
secured with a payment) can incur no-show strikes.
<strong>not</strong> count as a no-show — only confirmed bookings (where the slot was secured
with a payment) can incur no-show strikes.
</p>
<p class="mb-3">
If your account accumulates <strong
@@ -206,8 +206,8 @@
Each completed booking with a payment reduces the required deposit count by one. Once the
count reaches zero, all prior no-show records within the 6-month window are forgiven and
your account returns to normal — no upfront deposits required — until a new no-show occurs.
The salon can also forgive individual no-shows at management's discretion, which
immediately removes them from the count.
The salon can also forgive individual no-shows at management's discretion, which immediately
removes them from the count.
</p>
</section>
+35 -20
View File
@@ -36,7 +36,9 @@
<div class="mx-auto grid max-w-[744px] gap-6 px-4 lg:grid-cols-2">
<div class="h-[376px]">
{#if loading}
<div class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6">
<div
class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6"
>
<div class="mb-4 flex justify-center">
<div class="h-24 w-24 rounded-full bg-gray-200"></div>
</div>
@@ -68,26 +70,39 @@
{/if}
</div>
<div class="map-card mx-auto h-[376px] w-full max-w-sm rounded-lg border-2 border-gray-200 bg-white overflow-hidden lg:mx-0 lg:max-w-none">
<div
class="map-card mx-auto h-[376px] w-full max-w-sm overflow-hidden rounded-lg border-2 border-gray-200 bg-white lg:mx-0 lg:max-w-none"
>
<Map theme="light" center={[-3.476464162450991, 56.0781854944036]} zoom={15}>
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>
<MarkerContent>
<div class="flex items-center justify-center rounded-full bg-primary p-2 text-primary-foreground shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</div>
</MarkerContent>
<MarkerPopup>
<div class="p-2 text-sm">
<p class="font-semibold">41 Pollock Walk</p>
<p class="text-muted-foreground">Dunfermline KY12 9DA</p>
</div>
</MarkerPopup>
</MapMarker>
<MapControls position="bottom-right" showZoom />
</Map>
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>
<MarkerContent>
<div
class="flex items-center justify-center rounded-full bg-primary p-2 text-primary-foreground shadow-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
</MarkerContent>
<MarkerPopup>
<div class="p-2 text-sm">
<p class="font-semibold">41 Pollock Walk</p>
<p class="text-muted-foreground">Dunfermline KY12 9DA</p>
</div>
</MarkerPopup>
</MapMarker>
<MapControls position="bottom-right" showZoom />
</Map>
</div>
</div>
</section>
+2 -2
View File
@@ -324,7 +324,7 @@
</Card.Header>
<Card.Content>
<div class="space-y-3">
{#each recentCheckouts as checkout, i (i)}
{#each recentCheckouts as checkout, i (i)}
<div class="rounded-lg border p-3">
<div class="mb-1 flex items-center justify-between">
<span class="font-medium">{checkout.customer}</span>
@@ -387,7 +387,7 @@
</Card.Header>
<Card.Content>
<div class="space-y-3">
{#each loyaltyToday as loyalty, i (i)}
{#each loyaltyToday as loyalty, i (i)}
<div class="rounded-lg border bg-gray-50 p-3">
<div class="mb-1 flex items-center justify-between">
<span class="font-medium">{loyalty.customer}</span>
+51 -40
View File
@@ -260,7 +260,12 @@
function fmtDate(dateStr: string): string {
if (!dateStr) return '—';
const d = new Date(dateStr);
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'Europe/London' });
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/London'
});
}
function fmtDateTime(dateStr: string): string {
@@ -330,29 +335,29 @@
{
label: 'Loyalty Stamps',
value: `${stats.totalLoyaltyStamps} total`,
subtitle: `${stats.stampsRedeemed} redeemed, ${data.user_profile.loyalty_stamps} remaining`,
subtitle: `${stats.stampsRedeemed} redeemed, ${data.user_profile.loyalty_stamps} remaining`
},
{
label: 'Discounts Received',
value: fmt(stats.totalDiscounts),
subtitle: `${data.booking_discounts?.length ?? 0} discount(s) applied`,
highlight: true,
highlight: true
},
{
label: 'Patch Tests',
value: `${stats.patchTests}`,
subtitle: 'completed',
subtitle: 'completed'
},
{
label: 'Edits or Reschedules',
value: `${stats.editRequests}`,
subtitle: 'submitted',
subtitle: 'submitted'
},
{
label: 'Saved Cards',
value: `${stats.savedCards}`,
subtitle: 'on file',
},
subtitle: 'on file'
}
];
const nonZero = candidates.filter((c) => {
@@ -382,9 +387,7 @@
const completedBookings = bookings.filter((b) => b.status === 'completed');
const cancelledBookings = bookings.filter(
(b) =>
b.status === 'client_cancelled' ||
b.status === 'we_cancelled' ||
b.status === 'no_show'
b.status === 'client_cancelled' || b.status === 'we_cancelled' || b.status === 'no_show'
);
const noShowBookings = bookings.filter((b) => b.status === 'no_show');
const pendingBookings = bookings.filter(
@@ -425,12 +428,8 @@
.map(([method, data]) => ({ method, ...data }))
.sort((a, b) => b.total - a.total);
const totalDiscounts = discounts.reduce(
(sum, d) => sum + Number(d.discount_amount), 0
);
const stampsRedeemed = redemptions.reduce(
(sum, r) => sum + Number(r.stamps_redeemed), 0
);
const totalDiscounts = discounts.reduce((sum, d) => sum + Number(d.discount_amount), 0);
const stampsRedeemed = redemptions.reduce((sum, r) => sum + Number(r.stamps_redeemed), 0);
return {
totalBookings: bookings.length,
@@ -441,9 +440,11 @@
totalSpent,
totalRefunded,
netSpent: totalSpent - totalRefunded,
avgBookingValue: completedBookings.length > 0
? completedBookings.reduce((s, b) => s + Number(b.total_price), 0) / completedBookings.length
: 0,
avgBookingValue:
completedBookings.length > 0
? completedBookings.reduce((s, b) => s + Number(b.total_price), 0) /
completedBookings.length
: 0,
memberSince: data.user_profile?.created_at ?? '',
totalLoyaltyStamps: (data.user_profile?.loyalty_stamps ?? 0) + stampsRedeemed,
stampsRedeemed,
@@ -455,7 +456,7 @@
patchTests: patchTests.length,
savedCards: (data.saved_cards ?? []).length,
referredBy: data.referrals?.referred_by?.referrer_name ?? null,
referredCount: data.referrals?.referred_users?.length ?? 0,
referredCount: data.referrals?.referred_users?.length ?? 0
};
}
@@ -566,9 +567,9 @@
</div>
<!-- Summary Stats -->
{#if computeBookingStats(gdprData)}
{@const stats = computeBookingStats(gdprData)!}
<h2 class="mb-3 text-xl font-bold">Summary</h2>
{#if computeBookingStats(gdprData)}
{@const stats = computeBookingStats(gdprData)!}
<h2 class="mb-3 text-xl font-bold">Summary</h2>
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border bg-card p-4">
<span class="text-xs text-gray-400">Member Since</span>
@@ -605,7 +606,9 @@
{#each stats.topServices as svc (svc.name)}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-fuchsia-100 text-xs font-semibold text-fuchsia-700">
<span
class="flex h-6 w-6 items-center justify-center rounded-full bg-fuchsia-100 text-xs font-semibold text-fuchsia-700"
>
{svc.count}
</span>
<span class="text-sm">{svc.name}</span>
@@ -636,7 +639,9 @@
{#each secondary as s (s.label)}
<div class="rounded-xl border bg-card p-4">
<span class="text-xs text-gray-400">{s.label}</span>
<p class="mt-1 text-lg font-semibold {s.highlight ? 'text-green-600' : ''}">{s.value}</p>
<p class="mt-1 text-lg font-semibold {s.highlight ? 'text-green-600' : ''}">
{s.value}
</p>
<p class="text-xs text-gray-400">{s.subtitle}</p>
</div>
{/each}
@@ -750,8 +755,8 @@
<table class="w-full text-left text-sm">
<thead
><tr class="border-b"
><th class="px-4 py-3 font-medium text-gray-500">Previous Name</th
><th class="px-4 py-3 font-medium text-gray-500">Changed At</th
><th class="px-4 py-3 font-medium text-gray-500">Previous Name</th><th
class="px-4 py-3 font-medium text-gray-500">Changed At</th
><th class="px-4 py-3 font-medium text-gray-500">Booking</th></tr
></thead
>
@@ -762,7 +767,9 @@
>{nh.previous_first_name} {nh.previous_last_name}</td
>
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(nh.changed_at)}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500">{nh.booking_id || '—'}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500"
>{nh.booking_id || '—'}</td
>
</tr>
{/each}
</tbody>
@@ -965,10 +972,11 @@
<table class="w-full text-left text-sm">
<thead
><tr class="border-b"
><th class="px-4 py-3 font-medium text-gray-500">Created</th
><th class="px-4 py-3 font-medium text-gray-500">Discount</th
><th class="px-4 py-3 font-medium text-gray-500">Status</th
><th class="px-4 py-3 font-medium text-gray-500">Used At</th></tr
><th class="px-4 py-3 font-medium text-gray-500">Created</th><th
class="px-4 py-3 font-medium text-gray-500">Discount</th
><th class="px-4 py-3 font-medium text-gray-500">Status</th><th
class="px-4 py-3 font-medium text-gray-500">Used At</th
></tr
></thead
>
<tbody>
@@ -984,7 +992,9 @@
>{rd.used ? 'Used' : 'Available'}</span
></td
>
<td class="px-4 py-3 whitespace-nowrap">{rd.used_at ? fmtDateTime(rd.used_at) : '—'}</td>
<td class="px-4 py-3 whitespace-nowrap"
>{rd.used_at ? fmtDateTime(rd.used_at) : '—'}</td
>
</tr>
{/each}
</tbody>
@@ -1014,10 +1024,10 @@
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(la.created_at)}</td>
<td class="px-4 py-3">{la.attempt_type.replace(/_/g, ' ')}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {la.success
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'}"
>{la.success ? 'Yes' : 'No'}</span
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {la.success
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'}">{la.success ? 'Yes' : 'No'}</span
>
</td>
</tr>
@@ -1049,9 +1059,10 @@
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rt.created_at)}</td>
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rt.expires_at)}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {rt.revoked
? 'bg-gray-100 text-gray-600'
: 'bg-green-100 text-green-700'}"
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {rt.revoked
? 'bg-gray-100 text-gray-600'
: 'bg-green-100 text-green-700'}"
>{rt.revoked ? 'Revoked' : 'Active'}</span
>
</td>
+48 -42
View File
@@ -224,21 +224,25 @@
}
// when password changes, re-compute strength
let passwordStrength = $derived(formData.password ? (() => {
const result = zxcvbn(formData.password);
// Add minimum length check (6 chars) to the score feedback
if (formData.password.length < 6) {
return {
...result,
score: Math.min(result.score, 0), // Force weak for too short
feedback: {
warning: 'Password must be at least 6 characters',
suggestions: ['Add more characters to meet the minimum length requirement']
}
};
}
return result;
})() : null);
let passwordStrength = $derived(
formData.password
? (() => {
const result = zxcvbn(formData.password);
// Add minimum length check (6 chars) to the score feedback
if (formData.password.length < 6) {
return {
...result,
score: Math.min(result.score, 0), // Force weak for too short
feedback: {
warning: 'Password must be at least 6 characters',
suggestions: ['Add more characters to meet the minimum length requirement']
}
};
}
return result;
})()
: null
);
// form completion check
let isFormComplete = $derived(
@@ -252,7 +256,8 @@
formData.confirmPassword &&
formData.password === formData.confirmPassword &&
passwordStrength &&
formData.password.length >= 6 && passwordStrength.score >= 2 &&
formData.password.length >= 6 &&
passwordStrength.score >= 2 &&
agreedToPolicy &&
!validationErrors.email &&
!validationErrors.phone &&
@@ -364,18 +369,18 @@
{#if !isLogin}
<!-- SECTION 1: LOGIN DETAILS -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">What we need to log you in</h3>
<h3 class="text-lg font-semibold">What we need to log you in</h3>
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<EmailInput
id="email"
bind:value={formData.email}
bind:error={validationErrors.email}
placeholder="john@example.com"
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<EmailInput
id="email"
bind:value={formData.email}
bind:error={validationErrors.email}
placeholder="john@example.com"
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="password" text="Password" />
@@ -486,8 +491,9 @@
type="date"
bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)}
max={new SvelteDate(new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16))
.toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
max={new SvelteDate(
new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16)
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
required
/>
{#if validationErrors.dateOfBirth}
@@ -518,20 +524,20 @@
</div>
{:else}
<!-- Login Fields (Simple inline layout) -->
<div class="space-y-4">
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<EmailInput
id="email"
bind:value={formData.email}
bind:error={validationErrors.email}
placeholder="john@example.com"
required
/>
</div>
<div class="space-y-4">
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<EmailInput
id="email"
bind:value={formData.email}
bind:error={validationErrors.email}
placeholder="john@example.com"
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="password" text="Password" />
<div class="space-y-2">
<RequiredLabel forId="password" text="Password" />
<Input
id="password"
type="password"
+80 -66
View File
@@ -347,14 +347,15 @@
const tagsParam = page.url.searchParams.get('tags');
const imgParam = page.url.searchParams.get('img');
selectedTag = (tagParam && tagParam.length <= 100) ? tagParam.slice(0, 100) : '';
selectedTags = tagsParam && tagsParam.length <= 500
? tagsParam
.split(',')
.map((t: string) => t.trim())
.filter(Boolean)
.slice(0, 20)
: [];
selectedTag = tagParam && tagParam.length <= 100 ? tagParam.slice(0, 100) : '';
selectedTags =
tagsParam && tagsParam.length <= 500
? tagsParam
.split(',')
.map((t: string) => t.trim())
.filter(Boolean)
.slice(0, 20)
: [];
if (selectedTag) {
searchQuery = selectedTag;
@@ -459,7 +460,7 @@
// Position button so its top edge is `offset` px below the image top edge.
// Same offset as the right edge (`right-2` / `sm:right-4`), so the circle
// sits equally inside both edges.
closeBtnRef.style.top = (imgTopRel + offset) + 'px';
closeBtnRef.style.top = imgTopRel + offset + 'px';
// FLIP from the old position if we have one
if (pendingBtnRect) {
@@ -468,10 +469,7 @@
pendingBtnRect = null;
if (Math.abs(dy) > 0.5) {
closeBtnRef.animate(
[
{ transform: `translateY(${dy}px)` },
{ transform: 'translateY(0)' }
],
[{ transform: `translateY(${dy}px)` }, { transform: 'translateY(0)' }],
{ duration: BTN_FLIP_MS, easing: BUTTON_EASE }
);
}
@@ -881,7 +879,13 @@
{/if}
{#if showModal}
<Dialog open={showModal} onOpenChange={(v) => { if (!v) closeModal(); else showModal = v; }}>
<Dialog
open={showModal}
onOpenChange={(v) => {
if (!v) closeModal();
else showModal = v;
}}
>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
<DialogContent
hideClose={true}
@@ -895,62 +899,72 @@
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
<div class="overflow-hidden rounded-sm">
<div
class="flex w-[300%]"
style="transform: translateX({trackOffset}); transition: {trackTransition}"
>
<div class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
{#if prevFullURLs}
<ImageVariant
urls={prevFullURLs}
type="full"
alt=""
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
/>
{/if}
</div>
<div bind:this={centerSlideRef} class="relative flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
{#if imageLoading && selectedThumbURLs}
<ImageVariant
urls={selectedThumbURLs}
type="thumb"
alt="Loading preview"
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
/>
<div class="absolute inset-0 flex items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
></div>
<div class="overflow-hidden rounded-sm">
<div
class="flex w-[300%]"
style="transform: translateX({trackOffset}); transition: {trackTransition}"
>
<div
class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
style="width: calc(100% / 3)"
>
{#if prevFullURLs}
<ImageVariant
urls={prevFullURLs}
type="full"
alt=""
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
/>
{/if}
</div>
{/if}
{#if selectedFullURLs}
<ImageVariant
urls={selectedFullURLs}
type="full"
alt="Portfolio full size"
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
? 'opacity-0'
: ''}"
onload={handleFullImageLoad}
/>
{/if}
</div>
<div
bind:this={centerSlideRef}
class="relative flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
style="width: calc(100% / 3)"
>
{#if imageLoading && selectedThumbURLs}
<ImageVariant
urls={selectedThumbURLs}
type="thumb"
alt="Loading preview"
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
/>
<div class="absolute inset-0 flex items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
></div>
</div>
{/if}
<div class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
{#if nextFullURLs}
<ImageVariant
urls={nextFullURLs}
type="full"
alt=""
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
/>
{/if}
{#if selectedFullURLs}
<ImageVariant
urls={selectedFullURLs}
type="full"
alt="Portfolio full size"
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
? 'opacity-0'
: ''}"
onload={handleFullImageLoad}
/>
{/if}
</div>
<div
class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
style="width: calc(100% / 3)"
>
{#if nextFullURLs}
<ImageVariant
urls={nextFullURLs}
type="full"
alt=""
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
/>
{/if}
</div>
</div>
</div>
</div>
</div>
<button
bind:this={closeBtnRef}
+9 -5
View File
@@ -159,9 +159,13 @@
<Card.Root class="border-dashed border-gray-200 bg-gray-50/50 shadow-sm">
<Card.Content class="p-6 text-center">
<p class="text-sm text-gray-600">
Need something different? Select the closest service and add a note, or contact us for a bespoke treatment.
Need something different? Select the closest service and add a note, or contact us for a
bespoke treatment.
</p>
<a href={resolve('/contact')} class="mt-1 inline-block text-sm font-medium text-blue-600 hover:underline">
<a
href={resolve('/contact')}
class="mt-1 inline-block text-sm font-medium text-blue-600 hover:underline"
>
Arrange a custom booking →
</a>
</Card.Content>
@@ -216,9 +220,9 @@
<Card.Content class="p-6 text-center md:p-8">
<h2 class="mb-4 text-lg font-semibold text-primary md:text-xl">Ready to Book?</h2>
<div class="flex flex-col gap-3 md:flex-row md:justify-center md:gap-4">
<Button href={resolve('/book')} class="px-6 py-3 md:px-8">Book Appointment</Button>
<Button
href={resolve('/contact')}
<Button href={resolve('/book')} class="px-6 py-3 md:px-8">Book Appointment</Button>
<Button
href={resolve('/contact')}
variant="outline"
class="border-primary/30 px-6 py-3 hover:bg-primary/10 md:px-8"
>
+23 -18
View File
@@ -201,12 +201,10 @@
</div>
</div>
</div>
{:else if pageState === 'unauthorized'}
<div class="mx-auto max-w-4xl px-4 py-16 text-center md:px-8">
<p class="text-gray-500">Please log in to view your schedule.</p>
</div>
{:else}
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
<div class="mb-10 text-center">
@@ -234,10 +232,11 @@
</div>
{/each}
</div>
{:else if bookings.length === 0}
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50/50 py-16 text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100"
>
<svg
class="h-8 w-8 text-gray-400"
fill="none"
@@ -253,10 +252,11 @@
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900">No Upcoming Appointments</h3>
<p class="mb-6 text-sm text-gray-500">You don't have any upcoming appointments scheduled.</p>
<p class="mb-6 text-sm text-gray-500">
You don't have any upcoming appointments scheduled.
</p>
<Button href={resolve('/book')}>Book an Appointment</Button>
</div>
{:else}
<div class="space-y-10">
{#each bookingGroups as group (group.key)}
@@ -271,15 +271,19 @@
<div class="space-y-4">
{#each group.bookings as booking (booking.id)}
{@const cfg = getConfig(booking.status)}
{@const serviceNames = booking.services?.map((s: BookingService) => s.service_name).filter(Boolean).join(', ') || ''}
{@const serviceNames =
booking.services
?.map((s: BookingService) => s.service_name)
.filter(Boolean)
.join(', ') || ''}
<div
in:fly={{ y: 12, duration: 300, delay: 50 }}
class="group/card relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:shadow-md"
>
<div class="absolute left-0 top-0 h-full w-1 {cfg.bar}"></div>
<div class="absolute top-0 left-0 h-full w-1 {cfg.bar}"></div>
<div class="pl-5 pr-5 pt-4 pb-4 md:pl-6 md:pr-6 md:pt-5 md:pb-5">
<div class="pt-4 pr-5 pb-4 pl-5 md:pt-5 md:pr-6 md:pb-5 md:pl-6">
<div class="mb-3 flex items-start justify-between gap-3 md:mb-3">
<div class="flex items-center gap-2">
<svg
@@ -304,11 +308,12 @@
{formatEndTime(booking.start_time, booking.duration_minutes || 0)}
</span>
{#if booking.duration_minutes}
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span>
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span
>
{/if}
</div>
<span
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-none {cfg.badge}"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs leading-none font-medium {cfg.badge}"
>
<span class="h-1.5 w-1.5 rounded-full {cfg.dot}"></span>
{cfg.label}
@@ -319,10 +324,14 @@
<div>
{#if booking.services && booking.services.length > 0}
<p class="font-medium text-gray-900">{serviceNames}</p>
<p class="font-medium text-gray-900">{serviceNames}</p>
{/if}
<div class="{booking.services && booking.services.length > 0 ? 'mt-3 md:mt-4' : ''} flex items-center justify-between">
<div
class="{booking.services && booking.services.length > 0
? 'mt-3 md:mt-4'
: ''} flex items-center justify-between"
>
{#if booking.total_amount}
<span class="text-lg font-bold text-gray-900">
£{booking.total_amount.toFixed(2)}
@@ -346,11 +355,7 @@
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
+20 -6
View File
@@ -80,14 +80,24 @@
<svelte:head>
<script>
(function() {
(function () {
try {
var token = localStorage.getItem('authToken');
if (!token) { window.location.replace('/login'); return; }
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
if (payload.role !== 'admin') { window.location.replace('/'); }
} catch(e) { window.location.replace('/login'); }
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
return;
}
if (payload.role !== 'admin') {
window.location.replace('/');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
</svelte:head>
@@ -130,7 +140,11 @@
</div>
<!-- Current/Next Appointment Card (Full Width) -->
<CurrentAppointment _openBookingModal={openBookingModal} {openEditBookingModal} {openUserModal} />
<CurrentAppointment
_openBookingModal={openBookingModal}
{openEditBookingModal}
{openUserModal}
/>
<!-- Quick Booking + Till Purchases Grid -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">