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
@@ -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"