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

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