style: apply prettier formatting to frontend
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@import "shadcn-svelte/tailwind.css";
|
||||
@import 'shadcn-svelte/tailwind.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
.maplibregl-popup-content {
|
||||
@apply bg-transparent! shadow-none! p-0! rounded-none!;
|
||||
@apply rounded-none! bg-transparent! p-0! shadow-none!;
|
||||
}
|
||||
.maplibregl-popup-tip {
|
||||
@apply hidden!;
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
import * as Label from '$lib/components/ui/label';
|
||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import type { Booking, BookingDiscount, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||
import type {
|
||||
Booking,
|
||||
BookingDiscount,
|
||||
Service,
|
||||
WorkingHoursDay,
|
||||
AvailableHoursDay
|
||||
} from '$lib/types/booking';
|
||||
import {
|
||||
extractBookedSlots,
|
||||
getLunchProtectionForSlots,
|
||||
@@ -70,7 +76,11 @@
|
||||
// ─── Date constants ─────────────────────────────────────
|
||||
const todayCalendarDate = getLondonTodayCalendarDate();
|
||||
const minDate = todayCalendarDate;
|
||||
const maxDate = new SvelteDate(todayCalendarDate.year, todayCalendarDate.month - 1, todayCalendarDate.day);
|
||||
const maxDate = new SvelteDate(
|
||||
todayCalendarDate.year,
|
||||
todayCalendarDate.month - 1,
|
||||
todayCalendarDate.day
|
||||
);
|
||||
maxDate.setMonth(todayCalendarDate.month - 1 + 6);
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
@@ -82,9 +92,7 @@
|
||||
let hoursUntilAppointment = $derived(
|
||||
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||
);
|
||||
let hasPayments = $derived(
|
||||
(booking.amount_paid ?? 0) > 0
|
||||
);
|
||||
let hasPayments = $derived((booking.amount_paid ?? 0) > 0);
|
||||
let noticePeriodBlocked = $derived(
|
||||
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
|
||||
);
|
||||
@@ -218,7 +226,12 @@
|
||||
|
||||
if (isToday) {
|
||||
const now = new Date();
|
||||
const londonTime = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const londonTime = now.toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const [londonHours, londonMinutes] = londonTime.split(':').map(Number);
|
||||
const currentMin = londonHours * 60 + londonMinutes;
|
||||
startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15);
|
||||
@@ -268,7 +281,12 @@
|
||||
const isToday = date.compare(todayCal) === 0;
|
||||
if (isToday) {
|
||||
const now = new Date();
|
||||
const londonTime = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const londonTime = now.toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const [londonHours, londonMinutes] = londonTime.split(':').map(Number);
|
||||
const currentMin = londonHours * 60 + londonMinutes;
|
||||
startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15);
|
||||
@@ -466,11 +484,7 @@
|
||||
}
|
||||
|
||||
const tomorrowCal = getLondonTodayCalendarDate();
|
||||
newDate = new CalendarDate(
|
||||
tomorrowCal.year,
|
||||
tomorrowCal.month,
|
||||
tomorrowCal.day + 1
|
||||
);
|
||||
newDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, tomorrowCal.day + 1);
|
||||
if (!userNavigatedCalendar) {
|
||||
placeholderDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
|
||||
}
|
||||
@@ -774,8 +788,13 @@
|
||||
<p class="font-medium text-amber-900">Cannot Reschedule Online</p>
|
||||
<p class="mt-1">{noticeBlockedMessage}</p>
|
||||
<p class="mt-1">
|
||||
<a href="/contact" target="_blank" class="underline">Contact us</a> to discuss options,
|
||||
or <button type="button" onclick={() => open = false} class="inline underline cursor-pointer">cancel this booking</button>
|
||||
<a href="/contact" target="_blank" class="underline">Contact us</a> to discuss
|
||||
options, or
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = false)}
|
||||
class="inline cursor-pointer underline">cancel this booking</button
|
||||
>
|
||||
and rebook — note that cancellation fees may apply based on our
|
||||
<PolicyPopover>
|
||||
{#snippet trigger()}
|
||||
@@ -788,8 +807,8 @@
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<p class="font-medium text-amber-900">Rescheduling Within 24h</p>
|
||||
<p class="mt-1">
|
||||
Rescheduling within 24h counts as a no-show towards your deposit obligations. Two no-shows
|
||||
within 6 months will require deposits on future bookings.
|
||||
Rescheduling within 24h counts as a no-show towards your deposit obligations. Two
|
||||
no-shows within 6 months will require deposits on future bookings.
|
||||
<PolicyPopover>
|
||||
{#snippet trigger()}
|
||||
<span class="underline">Full policy</span>
|
||||
@@ -800,14 +819,12 @@
|
||||
{/if}
|
||||
|
||||
{#if booking.discounts && booking.discounts.length > 0}
|
||||
<div
|
||||
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
|
||||
>
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<p class="font-medium text-amber-900">Discounts Applied to This Booking</p>
|
||||
<p class="mt-1">
|
||||
Your booking has £{discountTotal.toFixed(2)} in savings from loyalty stamps or
|
||||
promotional offers. A time change requires admin approval. If denied, you can cancel
|
||||
(standard refund policy applies) and rebook at full price.
|
||||
Your booking has £{discountTotal.toFixed(2)} in savings from loyalty stamps or promotional
|
||||
offers. A time change requires admin approval. If denied, you can cancel (standard refund
|
||||
policy applies) and rebook at full price.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -914,7 +931,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="no-scrollbar mt-2 flex max-h-40 min-h-25 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
|
||||
class="mt-2 no-scrollbar flex max-h-40 min-h-25 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
|
||||
>
|
||||
<div class="grid justify-center gap-2 text-sm text-gray-600">
|
||||
{newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
||||
import { computeBalanceDue } from '$lib/utils/booking';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
||||
import { computeBalanceDue } from '$lib/utils/booking';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
bookingId: string;
|
||||
@@ -108,11 +108,14 @@ import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
|
||||
let hoursUntilAppointment = $derived(
|
||||
selectedBooking
|
||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) /
|
||||
(1000 * 60 * 60)
|
||||
: Infinity
|
||||
);
|
||||
let protectedDeposit = $derived(
|
||||
selectedBooking ? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT) : 0
|
||||
selectedBooking
|
||||
? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT)
|
||||
: 0
|
||||
);
|
||||
let estimatedRefund = $derived(
|
||||
hoursUntilAppointment > POLICY.FULL_REFUND_THRESHOLD_HOURS
|
||||
@@ -262,26 +265,43 @@ import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
});
|
||||
|
||||
function printReceipt() {
|
||||
if (!selectedBooking) { toast.error('No booking data to print'); return; }
|
||||
if (!selectedBooking) {
|
||||
toast.error('No booking data to print');
|
||||
return;
|
||||
}
|
||||
const pw = window.open('', '_blank');
|
||||
if (!pw) { toast.error('Please allow pop-ups to print the receipt'); return; }
|
||||
if (!pw) {
|
||||
toast.error('Please allow pop-ups to print the receipt');
|
||||
return;
|
||||
}
|
||||
|
||||
const biz = businessSettings;
|
||||
const paidPayments = selectedBooking.payments?.filter((p) => p.status === 'completed' && p.payment_method !== 'discount') ?? [];
|
||||
const discountPayments = selectedBooking.payments?.filter((p) => p.payment_method === 'discount' && p.status === 'completed') ?? [];
|
||||
const paidPayments =
|
||||
selectedBooking.payments?.filter(
|
||||
(p) => p.status === 'completed' && p.payment_method !== 'discount'
|
||||
) ?? [];
|
||||
const discountPayments =
|
||||
selectedBooking.payments?.filter(
|
||||
(p) => p.payment_method === 'discount' && p.status === 'completed'
|
||||
) ?? [];
|
||||
const refunds = selectedBooking.refunds?.filter((r) => r.status === 'completed') ?? [];
|
||||
const grossTotal = paidPayments.reduce((s, p) => s + p.amount, 0);
|
||||
|
||||
const esc = (str: string | null | undefined): string => {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
};
|
||||
|
||||
const fmt = (val: number | null | undefined, fallback = '\u2014'): string => {
|
||||
return val != null ? '\u00a3' + val.toFixed(2) : fallback;
|
||||
};
|
||||
|
||||
pw.document.write(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Receipt</title>
|
||||
pw.document
|
||||
.write(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Receipt</title>
|
||||
<style>
|
||||
@page { margin: 12mm; }
|
||||
* { color: #000 !important; background: transparent !important; }
|
||||
@@ -313,14 +333,14 @@ import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
<h2>Services</h2>
|
||||
<table>
|
||||
<tr><th>Service</th><th style="text-align:right">Price</th></tr>
|
||||
${(selectedBooking.services ?? []).map(s => `<tr><td>${esc(s.service_name)}${s.duration_minutes ? ' (' + s.duration_minutes + ' min)' : ''}</td><td style="text-align:right">\u00a3${(s.price ?? 0).toFixed(2)}</td></tr>`).join('')}
|
||||
${(selectedBooking.services ?? []).map((s) => `<tr><td>${esc(s.service_name)}${s.duration_minutes ? ' (' + s.duration_minutes + ' min)' : ''}</td><td style="text-align:right">\u00a3${(s.price ?? 0).toFixed(2)}</td></tr>`).join('')}
|
||||
</table>
|
||||
<h2>Payments</h2>
|
||||
<table>
|
||||
<tr><th>Type</th><th>Method</th><th style="text-align:right">Net</th><th style="text-align:right">VAT</th><th style="text-align:right">Gross</th></tr>
|
||||
${paidPayments.map(p => `<tr><td style="text-transform:capitalize">${p.payment_type}</td><td style="text-transform:capitalize">${p.payment_method ?? '\u2014'}</td><td style="text-align:right">${fmt(p.net_amount)}</td><td style="text-align:right">${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}</td><td style="text-align:right">\u00a3${p.amount.toFixed(2)}</td></tr>`).join('')}
|
||||
${discountPayments.map(d => `<tr><td>Discount</td><td>\u2014</td><td style="text-align:right;color:#059669">-\u00a3${Math.abs(d.amount).toFixed(2)}</td><td style="text-align:right;color:#059669">\u2014</td><td style="text-align:right;color:#059669">\u2014</td></tr>`).join('')}
|
||||
${refunds.map(r => `<tr><td>Refund</td><td>\u2014</td><td style="text-align:right;color:#dc2626">-\u00a3${r.amount.toFixed(2)}</td><td style="text-align:right;color:#dc2626">\u2014</td><td style="text-align:right;color:#dc2626">\u2014</td></tr>`).join('')}
|
||||
${paidPayments.map((p) => `<tr><td style="text-transform:capitalize">${p.payment_type}</td><td style="text-transform:capitalize">${p.payment_method ?? '\u2014'}</td><td style="text-align:right">${fmt(p.net_amount)}</td><td style="text-align:right">${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}</td><td style="text-align:right">\u00a3${p.amount.toFixed(2)}</td></tr>`).join('')}
|
||||
${discountPayments.map((d) => `<tr><td>Discount</td><td>\u2014</td><td style="text-align:right;color:#059669">-\u00a3${Math.abs(d.amount).toFixed(2)}</td><td style="text-align:right;color:#059669">\u2014</td><td style="text-align:right;color:#059669">\u2014</td></tr>`).join('')}
|
||||
${refunds.map((r) => `<tr><td>Refund</td><td>\u2014</td><td style="text-align:right;color:#dc2626">-\u00a3${r.amount.toFixed(2)}</td><td style="text-align:right;color:#dc2626">\u2014</td><td style="text-align:right;color:#dc2626">\u2014</td></tr>`).join('')}
|
||||
${hasVAT ? `<tr class="total"><td colspan="2"></td><td style="text-align:right">\u00a3${totalNet.toFixed(2)}</td><td style="text-align:right">\u00a3${totalVAT.toFixed(2)}</td><td style="text-align:right">\u00a3${(totalNet + totalVAT).toFixed(2)}</td></tr>` : ''}
|
||||
<tr class="total"><td colspan="4" style="text-align:right">Total Paid</td><td style="text-align:right">\u00a3${grossTotal.toFixed(2)}</td></tr>
|
||||
</table>
|
||||
@@ -377,14 +397,21 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
}
|
||||
}
|
||||
|
||||
function getPaymentName(payment: Payment, index: number, payments: Payment[] | undefined, discounts: BookingDiscount[] | undefined): string {
|
||||
function getPaymentName(
|
||||
payment: Payment,
|
||||
index: number,
|
||||
payments: Payment[] | undefined,
|
||||
discounts: BookingDiscount[] | undefined
|
||||
): string {
|
||||
if (payment.payment_method === 'online_square') return 'Online Card';
|
||||
if (payment.payment_method === 'in_person_card') return 'Card Machine';
|
||||
if (payment.payment_method === 'cash') return 'Cash';
|
||||
if (payment.payment_method === 'giftcard') return 'Gift Card';
|
||||
if (payment.payment_method === 'discount') {
|
||||
const discountPaymentsBefore = (payments ?? []).slice(0, index).filter(p => p.payment_method === 'discount').length;
|
||||
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
|
||||
const discountPaymentsBefore = (payments ?? [])
|
||||
.slice(0, index)
|
||||
.filter((p) => p.payment_method === 'discount').length;
|
||||
const discountList = (discounts ?? []).filter((d) => d.discount_amount > 0.01);
|
||||
if (discountList[discountPaymentsBefore]) {
|
||||
const d = discountList[discountPaymentsBefore];
|
||||
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
|
||||
@@ -505,8 +532,6 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{#if selectedBooking.services && selectedBooking.services.length > 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
@@ -553,22 +578,20 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</span>
|
||||
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
|
||||
<span class="text-gray-500">
|
||||
• Due: {parseWallClockDate(selectedBooking.deposit_deadline).toLocaleDateString(
|
||||
'en-GB',
|
||||
{
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}
|
||||
)} at {parseWallClockDate(selectedBooking.deposit_deadline).toLocaleTimeString(
|
||||
'en-GB',
|
||||
{
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}
|
||||
)}
|
||||
• Due: {parseWallClockDate(
|
||||
selectedBooking.deposit_deadline
|
||||
).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})} at {parseWallClockDate(
|
||||
selectedBooking.deposit_deadline
|
||||
).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -582,7 +605,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</div>
|
||||
|
||||
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
|
||||
<div class="border-y border-fuchsia-100 bg-fuchsia-50/20 py-2 my-2 space-y-1 rounded-md px-2">
|
||||
<div
|
||||
class="my-2 space-y-1 rounded-md border-y border-fuchsia-100 bg-fuchsia-50/20 px-2 py-2"
|
||||
>
|
||||
{#each selectedBooking.discounts as d}
|
||||
<div class="flex items-center justify-between text-xs text-fuchsia-800">
|
||||
<span class="flex items-center gap-1.5">
|
||||
@@ -603,12 +628,17 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</div>
|
||||
<div class="flex items-center justify-between font-medium text-gray-900">
|
||||
<span class="text-sm">Net Total</span>
|
||||
<span>£{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)}</span>
|
||||
<span
|
||||
>£{(
|
||||
selectedBooking.total_amount -
|
||||
selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)
|
||||
).toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasVAT}
|
||||
<div class="border-t border-gray-200 pt-2 mt-2">
|
||||
<div class="mt-2 border-t border-gray-200 pt-2">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Net amount (excl. VAT)</span>
|
||||
<span class="font-medium text-gray-700">£{totalNet.toFixed(2)}</span>
|
||||
@@ -623,7 +653,10 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
|
||||
<span class="font-semibold text-green-700">
|
||||
£{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}
|
||||
£{(selectedBooking.payments ?? [])
|
||||
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
|
||||
.reduce((sum, p) => sum + p.amount, 0)
|
||||
.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -659,8 +692,13 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900"
|
||||
>{getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)}</span
|
||||
>
|
||||
>{getPaymentName(
|
||||
payment,
|
||||
index,
|
||||
selectedBooking.payments,
|
||||
selectedBooking.discounts
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{payment.status === 'completed'
|
||||
@@ -688,9 +726,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
|
||||
{#if payment.vat_amount}
|
||||
<div>
|
||||
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount.toFixed(
|
||||
2
|
||||
)}
|
||||
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount.toFixed(2)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -712,7 +748,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-red-700">Refund</span>
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
|
||||
>
|
||||
{refund.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -762,16 +800,28 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</div>
|
||||
|
||||
{#if pendingEditRequest}
|
||||
{@const timeChanged = pendingEditRequest.original.start_time && pendingEditRequest.proposed?.start_time && pendingEditRequest.original.start_time !== pendingEditRequest.proposed.start_time}
|
||||
{@const servicesChanged = pendingEditRequest.proposed?.services?.length && JSON.stringify(pendingEditRequest.original.services?.map(s => s.name)) !== JSON.stringify(pendingEditRequest.proposed.services?.map(s => s.name))}
|
||||
{@const originalNames = (pendingEditRequest.original.services ?? []).map(s => s.name)}
|
||||
{@const proposedNames = (pendingEditRequest.proposed.services ?? []).map(s => s.name)}
|
||||
{@const addedServices = proposedNames.filter(n => !originalNames.includes(n))}
|
||||
{@const removedServices = originalNames.filter(n => !proposedNames.includes(n))}
|
||||
{@const timeChanged =
|
||||
pendingEditRequest.original.start_time &&
|
||||
pendingEditRequest.proposed?.start_time &&
|
||||
pendingEditRequest.original.start_time !== pendingEditRequest.proposed.start_time}
|
||||
{@const servicesChanged =
|
||||
pendingEditRequest.proposed?.services?.length &&
|
||||
JSON.stringify(pendingEditRequest.original.services?.map((s) => s.name)) !==
|
||||
JSON.stringify(pendingEditRequest.proposed.services?.map((s) => s.name))}
|
||||
{@const originalNames = (pendingEditRequest.original.services ?? []).map((s) => s.name)}
|
||||
{@const proposedNames = (pendingEditRequest.proposed.services ?? []).map((s) => s.name)}
|
||||
{@const addedServices = proposedNames.filter((n) => !originalNames.includes(n))}
|
||||
{@const removedServices = originalNames.filter((n) => !proposedNames.includes(n))}
|
||||
{#if timeChanged || servicesChanged}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
@@ -779,20 +829,38 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<p class="font-medium">Awaiting admin approval</p>
|
||||
<p class="mt-0.5 text-amber-700">
|
||||
{#if timeChanged}
|
||||
Reschedule requested from {parseWallClockDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {parseWallClockDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
Reschedule requested from {parseWallClockDate(
|
||||
pendingEditRequest.original.start_time!
|
||||
).toLocaleString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} to {parseWallClockDate(
|
||||
pendingEditRequest.proposed.start_time!
|
||||
).toLocaleString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
{/if}
|
||||
</p>
|
||||
{#if addedServices.length > 0}
|
||||
<p class="mt-1 text-amber-700">
|
||||
<span class="font-medium text-green-700">Services added:</span> {addedServices.join(', ')}
|
||||
<span class="font-medium text-green-700">Services added:</span>
|
||||
{addedServices.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if removedServices.length > 0}
|
||||
<p class="mt-0.5 text-amber-700">
|
||||
<span class="font-medium text-red-600">Services removed:</span> {removedServices.join(', ')}
|
||||
<span class="font-medium text-red-600">Services removed:</span>
|
||||
{removedServices.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-amber-500">We'll let you know once it's been reviewed</p>
|
||||
<p class="mt-1 text-xs text-amber-500">
|
||||
We'll let you know once it's been reviewed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -839,7 +907,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if hasPendingEditRequest && !depositOutstanding}
|
||||
<div class="flex-1 rounded-md border border-dashed border-gray-200 bg-gray-50/50 px-3 py-2 text-center text-xs text-gray-400">
|
||||
<div
|
||||
class="flex-1 rounded-md border border-dashed border-gray-200 bg-gray-50/50 px-3 py-2 text-center text-xs text-gray-400"
|
||||
>
|
||||
Payments paused while awaiting approval
|
||||
</div>
|
||||
{/if}
|
||||
@@ -882,31 +952,34 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2 rounded-md border p-3 text-sm {hoursUntilAppt < POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
|
||||
class="mt-2 rounded-md border p-3 text-sm {hoursUntilAppt <
|
||||
POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
|
||||
? 'border-red-200 bg-red-50 text-red-800'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'}"
|
||||
>
|
||||
{#if hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS}
|
||||
<p class="font-medium text-green-800">Full Refund</p>
|
||||
<p class="mt-1">
|
||||
You have given over {POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. You will receive a
|
||||
<strong>full refund</strong> of <strong>£{totalPaid.toFixed(2)}</strong>.
|
||||
Nothing will be deducted.
|
||||
You have given over {POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. You will receive
|
||||
a
|
||||
<strong>full refund</strong> of <strong>£{totalPaid.toFixed(2)}</strong>. Nothing
|
||||
will be deducted.
|
||||
</p>
|
||||
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
|
||||
<p class="font-medium">Partial Refund</p>
|
||||
<p class="mt-1">
|
||||
Based on your notice period ({hoursUntilAppt}h), up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of the total
|
||||
(£{protectedDeposit.toFixed(2)}) is treated as a protected deposit and will be
|
||||
retained. The remaining <strong>£{estimatedRefund.toFixed(2)}</strong> will be refunded.
|
||||
Based on your notice period ({hoursUntilAppt}h), up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT *
|
||||
100}% of the total (£{protectedDeposit.toFixed(2)}) is treated as a protected
|
||||
deposit and will be retained. The remaining
|
||||
<strong>£{estimatedRefund.toFixed(2)}</strong> will be refunded.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="font-semibold text-red-900">Cancelling With No Refund</p>
|
||||
<p class="mt-1">
|
||||
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. The full amount you have paid
|
||||
(<strong>£{totalPaid.toFixed(2)}</strong>) will be retained to cover the lost slot.
|
||||
It will also count as a <strong>no-show</strong> toward your booking history
|
||||
(2 no-shows within 6 months would require deposits on future bookings).
|
||||
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. The full
|
||||
amount you have paid (<strong>£{totalPaid.toFixed(2)}</strong>) will be retained to
|
||||
cover the lost slot. It will also count as a <strong>no-show</strong> toward your booking
|
||||
history (2 no-shows within 6 months would require deposits on future bookings).
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs">
|
||||
@@ -919,32 +992,26 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
for full details.
|
||||
</p>
|
||||
</div>
|
||||
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
|
||||
<p>Are you sure you want to cancel this booking?</p>
|
||||
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
|
||||
<p class="font-medium">Clean Cancellation</p>
|
||||
<p class="mt-1">
|
||||
This booking has no payments and is being cancelled with
|
||||
{hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS ? 'plenty of' : 'sufficient'} notice.
|
||||
It will be removed completely and will not appear in your booking history.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
|
||||
<p>
|
||||
Are you sure you want to cancel this booking?
|
||||
<p>Are you sure you want to cancel this booking?</p>
|
||||
<div class="mt-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<p class="font-semibold text-red-900">Short Notice Cancellation</p>
|
||||
<p class="mt-1">
|
||||
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. Cancelling
|
||||
now counts as a
|
||||
<strong>no-show</strong> (2 no-shows within 6 months will require deposits on future bookings).
|
||||
</p>
|
||||
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
|
||||
<p class="font-medium">Clean Cancellation</p>
|
||||
<p class="mt-1">
|
||||
This booking has no payments and is being cancelled with
|
||||
{hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS ? 'plenty of' : 'sufficient'} notice.
|
||||
It will be removed completely and will not appear in your booking history.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>
|
||||
Are you sure you want to cancel this booking?
|
||||
</p>
|
||||
<div class="mt-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<p class="font-semibold text-red-900">Short Notice Cancellation</p>
|
||||
<p class="mt-1">
|
||||
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. Cancelling now counts as a
|
||||
<strong>no-show</strong> (2 no-shows within 6 months will require deposits on
|
||||
future bookings).
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -379,7 +379,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-lg md:max-w-2xl">
|
||||
<Modal.Content
|
||||
class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-lg md:max-w-2xl"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Approve Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
@@ -413,7 +415,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
{#if oldest?.id === ob.id}
|
||||
<span class="text-amber-600" title="Booked first">★</span>
|
||||
{/if}
|
||||
{formatUserName(ob.user?.full_name || 'Unknown', ob.user?.previous_first_name, ob.user?.previous_last_name)}
|
||||
{formatUserName(
|
||||
ob.user?.full_name || 'Unknown',
|
||||
ob.user?.previous_first_name,
|
||||
ob.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{parseWallClockDate(ob.start_time).toLocaleDateString('en-GB', {
|
||||
@@ -497,7 +503,13 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold">{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{formatUserName(
|
||||
booking.user?.full_name || '—',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -517,9 +529,8 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Phone</div>
|
||||
{#if booking.user?.phone}
|
||||
<a
|
||||
href="tel:{booking.user.phone}"
|
||||
class="font-medium text-blue-600 hover:underline">{booking.user.phone}</a
|
||||
<a href="tel:{booking.user.phone}" class="font-medium text-blue-600 hover:underline"
|
||||
>{booking.user.phone}</a
|
||||
>
|
||||
{:else}
|
||||
<div class="font-medium">—</div>
|
||||
@@ -627,7 +638,8 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
min="1"
|
||||
step="1"
|
||||
value={serviceOverrides[service.service_id].duration}
|
||||
oninput={(e) => handleDurationInput(service.service_id, e.currentTarget.value)}
|
||||
oninput={(e) =>
|
||||
handleDurationInput(service.service_id, e.currentTarget.value)}
|
||||
class="no-spin w-full"
|
||||
placeholder={service.duration_minutes?.toString() || '60'}
|
||||
/>
|
||||
@@ -653,7 +665,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<p class="font-medium">Applied Discounts</p>
|
||||
{#each booking.discounts as d}
|
||||
<p class="mt-1">
|
||||
- {d.discount_source === 'loyalty' ? 'Loyalty Stamp Card' : d.discount_source === 'referral' ? 'Referral Discount' : d.campaign_name || 'Promo Campaign'}
|
||||
- {d.discount_source === 'loyalty'
|
||||
? 'Loyalty Stamp Card'
|
||||
: d.discount_source === 'referral'
|
||||
? 'Referral Discount'
|
||||
: d.campaign_name || 'Promo Campaign'}
|
||||
({d.discount_percent}% off): -£{d.discount_amount.toFixed(2)}
|
||||
</p>
|
||||
{/each}
|
||||
@@ -704,4 +720,3 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
|
||||
@@ -676,9 +676,7 @@
|
||||
const startTimeISO = formatLocalDateTime(localDate);
|
||||
|
||||
const serviceIds = selectedServices.filter((s) => !s.is_custom).map((s) => s.id);
|
||||
const customServiceIds = selectedServices
|
||||
.filter((s) => s.is_custom)
|
||||
.map((s) => s.id);
|
||||
const customServiceIds = selectedServices.filter((s) => s.is_custom).map((s) => s.id);
|
||||
|
||||
// Build service overrides payload
|
||||
const overrides = [];
|
||||
@@ -1007,7 +1005,13 @@
|
||||
start_time: string;
|
||||
service_ids: string[];
|
||||
custom_service_ids: string[];
|
||||
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
|
||||
service_overrides:
|
||||
| Array<{
|
||||
service_id: string;
|
||||
override_price: number | null;
|
||||
override_duration_minutes: number | null;
|
||||
}>
|
||||
| undefined;
|
||||
notes: string | null;
|
||||
out_of_hours: boolean;
|
||||
} = {
|
||||
@@ -1657,7 +1661,9 @@
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-green-800">
|
||||
Slot reserved until {parseWallClockDate(reservationExpiresAt.toISOString()).toLocaleTimeString([], {
|
||||
Slot reserved until {parseWallClockDate(
|
||||
reservationExpiresAt.toISOString()
|
||||
).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
|
||||
@@ -45,11 +45,14 @@
|
||||
let balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0);
|
||||
let hoursUntilAppt = $derived(
|
||||
selectedBooking
|
||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) /
|
||||
(1000 * 60 * 60)
|
||||
: Infinity
|
||||
);
|
||||
let protectedDeposit = $derived(
|
||||
selectedBooking ? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT) : 0
|
||||
selectedBooking
|
||||
? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT)
|
||||
: 0
|
||||
);
|
||||
let estimatedRefund = $derived(
|
||||
forgiveFeesCancel
|
||||
@@ -216,14 +219,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getPaymentName(payment: Payment, index: number, payments: Payment[] | undefined, discounts: BookingDiscount[] | undefined): string {
|
||||
function getPaymentName(
|
||||
payment: Payment,
|
||||
index: number,
|
||||
payments: Payment[] | undefined,
|
||||
discounts: BookingDiscount[] | undefined
|
||||
): string {
|
||||
if (payment.payment_method === 'online_square') return 'Online Card';
|
||||
if (payment.payment_method === 'in_person_card') return 'Card Machine';
|
||||
if (payment.payment_method === 'cash') return 'Cash';
|
||||
if (payment.payment_method === 'giftcard') return 'Gift Card';
|
||||
if (payment.payment_method === 'discount') {
|
||||
const discountPaymentsBefore = (payments ?? []).slice(0, index).filter(p => p.payment_method === 'discount').length;
|
||||
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
|
||||
const discountPaymentsBefore = (payments ?? [])
|
||||
.slice(0, index)
|
||||
.filter((p) => p.payment_method === 'discount').length;
|
||||
const discountList = (discounts ?? []).filter((d) => d.discount_amount > 0.01);
|
||||
if (discountList[discountPaymentsBefore]) {
|
||||
const d = discountList[discountPaymentsBefore];
|
||||
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
|
||||
@@ -232,7 +242,9 @@
|
||||
}
|
||||
return 'Discount';
|
||||
}
|
||||
return (payment.payment_method as string).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return (payment.payment_method as string)
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -323,8 +335,17 @@
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-800"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-1 h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Out-of-hours
|
||||
</span>
|
||||
@@ -418,12 +439,22 @@
|
||||
{#if selectedBooking.user?.profile_pic_url}
|
||||
<img
|
||||
src={selectedBooking.user.profile_pic_url}
|
||||
alt={formatUserName(selectedBooking.user.full_name, selectedBooking.user.previous_first_name, selectedBooking.user.previous_last_name)}
|
||||
alt={formatUserName(
|
||||
selectedBooking.user.full_name,
|
||||
selectedBooking.user.previous_first_name,
|
||||
selectedBooking.user.previous_last_name
|
||||
)}
|
||||
class="h-16 w-16 rounded-full object-cover ring-4 ring-blue-200"
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="text-lg font-semibold">{formatUserName(selectedBooking.user?.full_name || '—', selectedBooking.user?.previous_first_name, selectedBooking.user?.previous_last_name)}</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{formatUserName(
|
||||
selectedBooking.user?.full_name || '—',
|
||||
selectedBooking.user?.previous_first_name,
|
||||
selectedBooking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
{#if selectedBooking.user?.date_of_birth}
|
||||
<div class="text-sm text-gray-500">
|
||||
{calculateAge(selectedBooking.user.date_of_birth)} years old
|
||||
@@ -541,7 +572,9 @@
|
||||
</div>
|
||||
|
||||
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
|
||||
<div class="border-y border-fuchsia-100 bg-fuchsia-50/20 py-2 my-2 space-y-1 rounded-md px-2">
|
||||
<div
|
||||
class="my-2 space-y-1 rounded-md border-y border-fuchsia-100 bg-fuchsia-50/20 px-2 py-2"
|
||||
>
|
||||
{#each selectedBooking.discounts as d}
|
||||
<div class="flex items-center justify-between text-xs text-fuchsia-800">
|
||||
<span class="flex items-center gap-1.5">
|
||||
@@ -562,14 +595,22 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between font-medium text-gray-900">
|
||||
<span class="text-sm">Net Total</span>
|
||||
<span>£{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)}</span>
|
||||
<span
|
||||
>£{(
|
||||
selectedBooking.total_amount -
|
||||
selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)
|
||||
).toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
|
||||
<span class="font-semibold text-green-700">
|
||||
£{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}
|
||||
£{(selectedBooking.payments ?? [])
|
||||
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
|
||||
.reduce((sum, p) => sum + p.amount, 0)
|
||||
.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -583,9 +624,7 @@
|
||||
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
|
||||
<span class="font-medium text-gray-900">Balance Due</span>
|
||||
<span
|
||||
class="text-lg font-bold {balanceDue > 0.01
|
||||
? 'text-red-600'
|
||||
: 'text-green-600'}"
|
||||
class="text-lg font-bold {balanceDue > 0.01 ? 'text-red-600' : 'text-green-600'}"
|
||||
>
|
||||
£{balanceDue.toFixed(2)}
|
||||
</span>
|
||||
@@ -606,7 +645,12 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">
|
||||
{getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)}
|
||||
{getPaymentName(
|
||||
payment,
|
||||
index,
|
||||
selectedBooking.payments,
|
||||
selectedBooking.discounts
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
@@ -645,9 +689,8 @@
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
|
||||
<div>
|
||||
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(
|
||||
2
|
||||
) || '0.00'}
|
||||
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(2) ||
|
||||
'0.00'}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -668,7 +711,9 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-red-700">Refund</span>
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
|
||||
>
|
||||
{refund.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -751,71 +796,131 @@
|
||||
{@const apptHours = Math.round(hoursUntilAppt)}
|
||||
{#if totalPaid > 0}
|
||||
<p>
|
||||
This booking has payments totalling <span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
|
||||
This booking has payments totalling <span class="font-semibold"
|
||||
>£{totalPaid.toFixed(2)}</span
|
||||
>.
|
||||
</p>
|
||||
{:else}
|
||||
<p>Are you sure you want to cancel this booking?</p>
|
||||
{/if}
|
||||
|
||||
{#if totalPaid > 0}
|
||||
<div class="mt-3 rounded-md border p-3 text-sm {apptHours < 24
|
||||
? 'border-red-200 bg-red-50 text-red-800'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'}">
|
||||
<div
|
||||
class="mt-3 rounded-md border p-3 text-sm {apptHours < 24
|
||||
? 'border-red-200 bg-red-50 text-red-800'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'}"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{apptHours > POLICY.FULL_REFUND_THRESHOLD_HOURS ? `Full Refund — Over ${POLICY.FULL_REFUND_THRESHOLD_HOURS}h notice` : apptHours >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS ? `Partial Refund — ${apptHours}h notice` : `No Refund — Under ${POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}h notice`}
|
||||
{apptHours > POLICY.FULL_REFUND_THRESHOLD_HOURS
|
||||
? `Full Refund — Over ${POLICY.FULL_REFUND_THRESHOLD_HOURS}h notice`
|
||||
: apptHours >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
|
||||
? `Partial Refund — ${apptHours}h notice`
|
||||
: `No Refund — Under ${POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}h notice`}
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
{apptHours > POLICY.FULL_REFUND_THRESHOLD_HOURS ? `You've given over ${POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. £${totalPaid.toFixed(2)} will be refunded in full.` : apptHours >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS ? `You've paid £${totalPaid.toFixed(2)}. Up to ${POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of the total (£${(selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT).toFixed(2)}) counts as protected deposit. £${protectedDeposit.toFixed(2)} will be retained and £${estimatedRefund.toFixed(2)} will be refunded.` : `Under ${POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}h notice. The full £${totalPaid.toFixed(2)} is retained to cover the lost slot.`}
|
||||
{apptHours > POLICY.FULL_REFUND_THRESHOLD_HOURS
|
||||
? `You've given over ${POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. £${totalPaid.toFixed(2)} will be refunded in full.`
|
||||
: apptHours >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
|
||||
? `You've paid £${totalPaid.toFixed(2)}. Up to ${POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of the total (£${(selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT).toFixed(2)}) counts as protected deposit. £${protectedDeposit.toFixed(2)} will be retained and £${estimatedRefund.toFixed(2)} will be refunded.`
|
||||
: `Under ${POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}h notice. The full £${totalPaid.toFixed(2)} is retained to cover the lost slot.`}
|
||||
</p>
|
||||
<details class="mt-1 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||
<p class="mt-1">
|
||||
{apptHours > 72 ? "Over 72 hours' notice means no deposit protection applies — a full refund is given regardless." : apptHours >= 24 ? "Between 24–72 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 24–72 hours' notice, up to 50% of the total (" +
|
||||
'£' +
|
||||
(selectedBooking.total_amount * 0.5).toFixed(2) +
|
||||
') is treated as a protected deposit to cover the lost slot. The remaining balance above that is refunded.'
|
||||
: "Under 24 hours' notice, the full amount paid (" +
|
||||
'£' +
|
||||
totalPaid.toFixed(2) +
|
||||
') is retained. This also counts as a no-show toward deposit obligations.'}
|
||||
</p>
|
||||
</details>
|
||||
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveFeesCancel} />
|
||||
<span class="text-xs">Forgive fees — refund the <strong>full</strong> amount paid</span>
|
||||
<span class="text-xs"
|
||||
>Forgive fees — refund the <strong>full</strong> amount paid</span
|
||||
>
|
||||
</label>
|
||||
<details class="ml-6 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||
<p class="mt-1">"We've applied a full refund to this booking as a goodwill gesture. No deposit protection will be applied."</p>
|
||||
<summary class="cursor-pointer hover:text-gray-700"
|
||||
>What happens with forgiveness</summary
|
||||
>
|
||||
<p class="mt-1">
|
||||
"We've applied a full refund to this booking as a goodwill gesture. No deposit
|
||||
protection will be applied."
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<p class="mt-3 font-medium">No-Show Record</p>
|
||||
<p class="mt-1">This cancellation counts as a no-show toward deposit obligations.</p>
|
||||
<details class="mt-1 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||
<p class="mt-1">"This cancellation will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings to secure your appointment."</p>
|
||||
<p class="mt-1">
|
||||
"This cancellation will count as a no-show toward your booking history. Two
|
||||
no-shows within 6 months would require a deposit on future bookings to secure your
|
||||
appointment."
|
||||
</p>
|
||||
</details>
|
||||
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveNoShowCancel} />
|
||||
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
|
||||
<span class="text-xs"
|
||||
>Forgive no-show — this cancellation will <strong>not</strong> count toward deposit
|
||||
obligations</span
|
||||
>
|
||||
</label>
|
||||
<details class="ml-6 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||
<p class="mt-1">"We've waived the no-show record for this cancellation so your deposit obligations remain unaffected."</p>
|
||||
<summary class="cursor-pointer hover:text-gray-700"
|
||||
>What happens with forgiveness</summary
|
||||
>
|
||||
<p class="mt-1">
|
||||
"We've waived the no-show record for this cancellation so your deposit obligations
|
||||
remain unaffected."
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
{:else if apptHours < 24}
|
||||
<div class="mt-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<p class="font-semibold text-red-900">No-Show Warning — {apptHours}h before appointment</p>
|
||||
<p class="mt-1">This booking has no payments but is under 24 hours' notice. Cancelling counts as a no-show toward deposit obligations.</p>
|
||||
<p class="font-semibold text-red-900">
|
||||
No-Show Warning — {apptHours}h before appointment
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
This booking has no payments but is under 24 hours' notice. Cancelling counts as a
|
||||
no-show toward deposit obligations.
|
||||
</p>
|
||||
<details class="mt-1 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||
<p class="mt-1">"This cancellation will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings to secure your appointment."</p>
|
||||
<p class="mt-1">
|
||||
"This cancellation will count as a no-show toward your booking history. Two
|
||||
no-shows within 6 months would require a deposit on future bookings to secure your
|
||||
appointment."
|
||||
</p>
|
||||
</details>
|
||||
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveNoShowCancel} />
|
||||
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
|
||||
<span class="text-xs"
|
||||
>Forgive no-show — this cancellation will <strong>not</strong> count toward deposit
|
||||
obligations</span
|
||||
>
|
||||
</label>
|
||||
<details class="ml-6 mt-1 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||
<p class="mt-1">"We've waived the no-show record for this cancellation. Your deposit obligations will not be affected."</p>
|
||||
<details class="mt-1 ml-6 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700"
|
||||
>What happens with forgiveness</summary
|
||||
>
|
||||
<p class="mt-1">
|
||||
"We've waived the no-show record for this cancellation. Your deposit obligations
|
||||
will not be affected."
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
|
||||
<div
|
||||
class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800"
|
||||
>
|
||||
<p class="font-medium">Clean Cancellation</p>
|
||||
<p class="mt-1">This booking has no payments. The booking will be removed cleanly.</p>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +284,13 @@
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<span>• {formatUserName(b.user?.full_name || 'Unknown User', b.user?.previous_first_name, b.user?.previous_last_name)}</span>
|
||||
<span
|
||||
>• {formatUserName(
|
||||
b.user?.full_name || 'Unknown User',
|
||||
b.user?.previous_first_name,
|
||||
b.user?.previous_last_name
|
||||
)}</span
|
||||
>
|
||||
<span>
|
||||
- {formatServices(b.services)}
|
||||
</span>
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
// UK VAT numbers: GB + 9 digits (standard) or GB + 12 digits (branch)
|
||||
if (trimmed.length !== 11 && trimmed.length !== 14) return 'Must be GB followed by 9 or 12 digits';
|
||||
if (trimmed.length !== 11 && trimmed.length !== 14)
|
||||
return 'Must be GB followed by 9 or 12 digits';
|
||||
if (!trimmed.startsWith('GB')) return 'Must start with GB';
|
||||
const digits = trimmed.slice(2);
|
||||
if (!/^\d+$/.test(digits)) return 'Must contain only digits after GB';
|
||||
@@ -156,13 +157,16 @@
|
||||
function validateField(field: string, inputValue?: unknown) {
|
||||
const val = inputValue ?? form[field as keyof typeof form];
|
||||
if (field === 'business_name') formErrors.business_name = validateBusinessName(val);
|
||||
else if (field === 'business_address') formErrors.business_address = validateBusinessAddress(val);
|
||||
else if (field === 'business_address')
|
||||
formErrors.business_address = validateBusinessAddress(val);
|
||||
else if (field === 'business_phone') formErrors.business_phone = validatePhone(val);
|
||||
else if (field === 'business_email') formErrors.business_email = validateEmail(val);
|
||||
else if (field === 'website_url') formErrors.website_url = validateWebsiteUrl(val);
|
||||
else if (field === 'vat_registration_number') formErrors.vat_registration_number = validateVatNumber(val);
|
||||
else if (field === 'vat_registration_number')
|
||||
formErrors.vat_registration_number = validateVatNumber(val);
|
||||
else if (field === 'default_vat_rate') formErrors.default_vat_rate = validateVatRate(val);
|
||||
else if (field === 'gift_card_expiry_months') formErrors.gift_card_expiry_months = validateGiftCardExpiry(val);
|
||||
else if (field === 'gift_card_expiry_months')
|
||||
formErrors.gift_card_expiry_months = validateGiftCardExpiry(val);
|
||||
else if (field === 'voucher_type') formErrors.voucher_type = validateVoucherType(val);
|
||||
}
|
||||
|
||||
@@ -192,7 +196,9 @@
|
||||
let normalisedEmail = form.business_email?.trim() ?? null;
|
||||
if (normalisedEmail) normalisedEmail = normalizeEmail(normalisedEmail);
|
||||
|
||||
const normalisedPhone = form.business_phone ? toE164UK(form.business_phone) ?? normalisePhoneInput(form.business_phone) : null;
|
||||
const normalisedPhone = form.business_phone
|
||||
? (toE164UK(form.business_phone) ?? normalisePhoneInput(form.business_phone))
|
||||
: null;
|
||||
|
||||
const normalisedForm = {
|
||||
...form,
|
||||
@@ -205,9 +211,16 @@
|
||||
};
|
||||
|
||||
const fields: (keyof BusinessSettings)[] = [
|
||||
'business_name', 'business_address', 'business_phone', 'business_email',
|
||||
'vat_registration_number', 'is_vat_registered', 'default_vat_rate',
|
||||
'website_url', 'gift_card_expiry_months', 'voucher_type'
|
||||
'business_name',
|
||||
'business_address',
|
||||
'business_phone',
|
||||
'business_email',
|
||||
'vat_registration_number',
|
||||
'is_vat_registered',
|
||||
'default_vat_rate',
|
||||
'website_url',
|
||||
'gift_card_expiry_months',
|
||||
'voucher_type'
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
@@ -235,7 +248,7 @@
|
||||
|
||||
if (res.ok) {
|
||||
const wasVatEnabled = settings?.is_vat_registered;
|
||||
const updated = await res.json() as BusinessSettings;
|
||||
const updated = (await res.json()) as BusinessSettings;
|
||||
settings = updated;
|
||||
if (!wasVatEnabled && updated.is_vat_registered) {
|
||||
toast.success('VAT enabled — past payments and till sales updated retroactively');
|
||||
@@ -268,9 +281,7 @@
|
||||
Business details, VAT configuration, and gift card defaults.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button onclick={openEditModal} disabled={loading || !settings}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
<Button onclick={openEditModal} disabled={loading || !settings}>Edit Settings</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
@@ -292,7 +303,9 @@
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Business Information -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Business Information</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
Business Information
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">Business Name</span>
|
||||
@@ -332,15 +345,17 @@
|
||||
|
||||
<!-- VAT Configuration -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">VAT & Currency</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
VAT & Currency
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">VAT Registered</span>
|
||||
<p class="text-sm font-medium">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.is_vat_registered
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-gray-50 text-gray-600 border border-gray-200'}"
|
||||
? 'border border-green-200 bg-green-50 text-green-700'
|
||||
: 'border border-gray-200 bg-gray-50 text-gray-600'}"
|
||||
>
|
||||
{settings.is_vat_registered ? 'Registered' : 'Not Registered'}
|
||||
</span>
|
||||
@@ -363,7 +378,9 @@
|
||||
|
||||
<!-- Gift Card Configuration -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Gift Cards</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
Gift Cards
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">Expiry Period</span>
|
||||
@@ -373,9 +390,10 @@
|
||||
<span class="text-xs text-muted-foreground">Voucher Type</span>
|
||||
<p class="text-sm font-medium">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.voucher_type === 'MPV'
|
||||
? 'bg-blue-50 text-blue-700 border border-blue-200'
|
||||
: 'bg-purple-50 text-purple-700 border border-purple-200'}"
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.voucher_type ===
|
||||
'MPV'
|
||||
? 'border border-blue-200 bg-blue-50 text-blue-700'
|
||||
: 'border border-purple-200 bg-purple-50 text-purple-700'}"
|
||||
>
|
||||
{settings.voucher_type === 'MPV' ? 'MPV (0% VAT)' : 'SPV (20% VAT)'}
|
||||
</span>
|
||||
@@ -390,7 +408,7 @@
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<Modal.Root bind:open={showEditModal}>
|
||||
<Modal.Content class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Edit Business Settings</Modal.Title>
|
||||
<Modal.Description>
|
||||
@@ -398,10 +416,20 @@
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<form class="space-y-6 py-4" onsubmit={(e) => { e.preventDefault(); saveSettings(); }}>
|
||||
<form
|
||||
class="space-y-6 py-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveSettings();
|
||||
}}
|
||||
>
|
||||
<!-- Business Information Section -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">Business Information</h4>
|
||||
<h4
|
||||
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>
|
||||
Business Information
|
||||
</h4>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="business_name">Business Name</Label.Root>
|
||||
@@ -493,7 +521,11 @@
|
||||
|
||||
<!-- VAT & Currency Section -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">VAT & Currency</h4>
|
||||
<h4
|
||||
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>
|
||||
VAT & Currency
|
||||
</h4>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
@@ -502,18 +534,26 @@
|
||||
Enable if your business is registered for UK VAT
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox id="is_vat_registered" checked={form.is_vat_registered ?? false} onCheckedChange={(v: boolean) => {
|
||||
form.is_vat_registered = v;
|
||||
if (!v) {
|
||||
form.vat_registration_number = null;
|
||||
form.default_vat_rate = 20.00;
|
||||
formErrors.vat_registration_number = '';
|
||||
formErrors.default_vat_rate = '';
|
||||
}
|
||||
}} />
|
||||
<Checkbox
|
||||
id="is_vat_registered"
|
||||
checked={form.is_vat_registered ?? false}
|
||||
onCheckedChange={(v: boolean) => {
|
||||
form.is_vat_registered = v;
|
||||
if (!v) {
|
||||
form.vat_registration_number = null;
|
||||
form.default_vat_rate = 20.0;
|
||||
formErrors.vat_registration_number = '';
|
||||
formErrors.default_vat_rate = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 {!form.is_vat_registered ? 'opacity-50 pointer-events-none' : ''}">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 {!form.is_vat_registered
|
||||
? 'pointer-events-none opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="vat_registration_number">VAT Registration Number</Label.Root>
|
||||
<Input
|
||||
@@ -557,7 +597,11 @@
|
||||
|
||||
<!-- Gift Card Section -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">Gift Card Configuration</h4>
|
||||
<h4
|
||||
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>
|
||||
Gift Card Configuration
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
@@ -579,7 +623,7 @@
|
||||
<Label.Root for="voucher_type">Voucher Type</Label.Root>
|
||||
<select
|
||||
id="voucher_type"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
bind:value={form.voucher_type}
|
||||
onchange={() => validateField('voucher_type')}
|
||||
>
|
||||
|
||||
@@ -89,16 +89,18 @@
|
||||
function validateField(field: string) {
|
||||
if (field === 'name') serviceErrors.name = validateName(newService.name);
|
||||
if (field === 'price') serviceErrors.price = validatePrice(newService.price);
|
||||
if (field === 'duration_minutes') serviceErrors.duration_minutes = validateDuration(newService.duration_minutes);
|
||||
if (field === 'minimum_age_required') serviceErrors.minimum_age_required = validateMinimumAge(newService.minimum_age_required);
|
||||
if (field === 'duration_minutes')
|
||||
serviceErrors.duration_minutes = validateDuration(newService.duration_minutes);
|
||||
if (field === 'minimum_age_required')
|
||||
serviceErrors.minimum_age_required = validateMinimumAge(newService.minimum_age_required);
|
||||
}
|
||||
|
||||
let isFormValid = $derived(
|
||||
(newService.name ?? '').trim() !== '' &&
|
||||
!serviceErrors.name &&
|
||||
!serviceErrors.price &&
|
||||
!serviceErrors.duration_minutes &&
|
||||
!serviceErrors.minimum_age_required
|
||||
!serviceErrors.name &&
|
||||
!serviceErrors.price &&
|
||||
!serviceErrors.duration_minutes &&
|
||||
!serviceErrors.minimum_age_required
|
||||
);
|
||||
|
||||
async function fetchServices() {
|
||||
@@ -165,7 +167,12 @@
|
||||
}
|
||||
|
||||
async function promoteService(id: string, name: string) {
|
||||
if (!confirm(`Promote "${name}" to a regular catalog service? This will migrate all booking references.`)) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Promote "${name}" to a regular catalog service? This will migrate all booking references.`
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/custom-services/${id}/promote`, {
|
||||
method: 'POST',
|
||||
@@ -208,7 +215,13 @@
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
newService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
newService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
serviceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
}
|
||||
|
||||
@@ -226,8 +239,20 @@
|
||||
One-off services for bridal parties, special requests, and custom bookings.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button onclick={() => { resetForm(); showCreateModal = true; }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<Button
|
||||
onclick={() => {
|
||||
resetForm();
|
||||
showCreateModal = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -241,10 +266,21 @@
|
||||
<Input
|
||||
placeholder="Search custom services..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { page = 1; fetchServices(); } }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
page = 1;
|
||||
fetchServices();
|
||||
}
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" onclick={() => { page = 1; fetchServices(); }}>Search</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
page = 1;
|
||||
fetchServices();
|
||||
}}>Search</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -278,7 +314,9 @@
|
||||
<td class="py-3 font-medium">{service.name}</td>
|
||||
<td class="py-3 text-gray-600">
|
||||
{#if service.description}
|
||||
<div class="line-clamp-1" title={service.description}>{service.description}</div>
|
||||
<div class="line-clamp-1" title={service.description}>
|
||||
{service.description}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
@@ -286,14 +324,24 @@
|
||||
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
|
||||
<td class="py-3 text-right">{service.duration_minutes} min</td>
|
||||
<td class="py-3 text-right">
|
||||
{service.usage_count}×{service.last_used_at ? ` (last: ${new Date(service.last_used_at).toLocaleDateString('en-GB', { timeZone: 'Europe/London' })})` : ''}
|
||||
{service.usage_count}×{service.last_used_at
|
||||
? ` (last: ${new Date(service.last_used_at).toLocaleDateString('en-GB', { timeZone: 'Europe/London' })})`
|
||||
: ''}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => promoteService(service.id, service.name)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => promoteService(service.id, service.name)}
|
||||
>
|
||||
Promote
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onclick={() => deleteService(service.id)}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => deleteService(service.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
@@ -320,8 +368,18 @@
|
||||
<div><span class="font-medium">Duration:</span> {service.duration_minutes} min</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" class="flex-1" onclick={() => promoteService(service.id, service.name)}>Promote</Button>
|
||||
<Button variant="destructive" size="sm" class="flex-1" onclick={() => deleteService(service.id)}>Delete</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
onclick={() => promoteService(service.id, service.name)}>Promote</Button
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
onclick={() => deleteService(service.id)}>Delete</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,8 +392,24 @@
|
||||
Page {page} of {Math.ceil(total / perPage)} ({total} total)
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => { page--; fetchServices(); }}>Previous</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= Math.ceil(total / perPage)} onclick={() => { page++; fetchServices(); }}>Next</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onclick={() => {
|
||||
page--;
|
||||
fetchServices();
|
||||
}}>Previous</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= Math.ceil(total / perPage)}
|
||||
onclick={() => {
|
||||
page++;
|
||||
fetchServices();
|
||||
}}>Next</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -408,11 +482,17 @@
|
||||
id="cs-duration"
|
||||
bind:value={newService.duration_minutes}
|
||||
onchange={() => validateField('duration_minutes')}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.duration_minutes ? 'border-red-500' : ''}"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {serviceErrors.duration_minutes
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{#each durationOptions as mins (mins)}
|
||||
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
|
||||
<option value={mins}
|
||||
>{mins} min{mins >= 60
|
||||
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{#if serviceErrors.duration_minutes}
|
||||
@@ -443,7 +523,14 @@
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onclick={() => { showCreateModal = false; resetForm(); }} disabled={creating}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showCreateModal = false;
|
||||
resetForm();
|
||||
}}
|
||||
disabled={creating}>Cancel</Button
|
||||
>
|
||||
<Button onclick={createService} disabled={creating || !isFormValid}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
|
||||
@@ -166,11 +166,7 @@
|
||||
if (form.campaign_type === 'time_based') {
|
||||
if (!form.start_date) errors.start_date = 'Required';
|
||||
if (!form.end_date) errors.end_date = 'Required';
|
||||
if (
|
||||
form.start_date &&
|
||||
form.end_date &&
|
||||
new Date(form.end_date) < new Date(form.start_date)
|
||||
) {
|
||||
if (form.start_date && form.end_date && new Date(form.end_date) < new Date(form.start_date)) {
|
||||
errors.end_date = 'Must be after start';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -422,7 +422,13 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Name</div>
|
||||
<div class="font-medium">{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
|
||||
<div class="font-medium">
|
||||
{formatUserName(
|
||||
booking.user?.full_name || '—',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
interface ServiceItem {
|
||||
id: string;
|
||||
@@ -185,7 +185,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
|
||||
<Modal.Description>
|
||||
Review the requested changes to {formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}'s booking.
|
||||
Review the requested changes to {formatUserName(
|
||||
editRequest.user.full_name,
|
||||
editRequest.user.previous_first_name,
|
||||
editRequest.user.previous_last_name
|
||||
)}'s booking.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
@@ -198,14 +202,22 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Name</div>
|
||||
<div class="font-medium">{formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}</div>
|
||||
<div class="font-medium">
|
||||
{formatUserName(
|
||||
editRequest.user.full_name,
|
||||
editRequest.user.previous_first_name,
|
||||
editRequest.user.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Phone</div>
|
||||
<div class="font-medium">
|
||||
{#if editRequest.user.phone}
|
||||
<a href="tel:{editRequest.user.phone}" class="text-blue-600 hover:underline">{editRequest.user.phone}</a>
|
||||
<a href="tel:{editRequest.user.phone}" class="text-blue-600 hover:underline"
|
||||
>{editRequest.user.phone}</a
|
||||
>
|
||||
{:else}—{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +225,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
<div class="font-medium break-all">
|
||||
{#if editRequest.user.email}
|
||||
<a href="mailto:{editRequest.user.email}" class="text-blue-600 hover:underline">{editRequest.user.email}</a>
|
||||
<a href="mailto:{editRequest.user.email}" class="text-blue-600 hover:underline"
|
||||
>{editRequest.user.email}</a
|
||||
>
|
||||
{:else}—{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,125 +236,125 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
</div>
|
||||
|
||||
{#if isTimeChanged()}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Date & Time Change
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Before</div>
|
||||
<div class="font-medium">
|
||||
{formatDateLine1(editRequest.original.start_time)}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Date & Time Change
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Before</div>
|
||||
<div class="font-medium">
|
||||
{formatDateLine1(editRequest.original.start_time)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(
|
||||
editRequest.original.start_time,
|
||||
getDuration(editRequest.original.services)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(
|
||||
editRequest.original.start_time,
|
||||
getDuration(editRequest.original.services)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">After</div>
|
||||
<div class="font-medium text-emerald-700">
|
||||
{formatDateLine1(editRequest.proposed.start_time!)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(
|
||||
editRequest.proposed.start_time!,
|
||||
getDuration(editRequest.proposed.services)
|
||||
)}
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">After</div>
|
||||
<div class="font-medium text-emerald-700">
|
||||
{formatDateLine1(editRequest.proposed.start_time!)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(
|
||||
editRequest.proposed.start_time!,
|
||||
getDuration(editRequest.proposed.services)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if areServicesChanged()}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Services Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.original.services as service (service.id)}
|
||||
{#if serviceDiff.removed.some((s) => s.id === service.id)}
|
||||
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-red-600">−</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-red-700 line-through">
|
||||
{service.name}
|
||||
</div>
|
||||
<div class="text-xs text-red-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Services Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.original.services as service (service.id)}
|
||||
{#if serviceDiff.removed.some((s) => s.id === service.id)}
|
||||
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-red-600">−</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-red-700 line-through">
|
||||
{service.name}
|
||||
</div>
|
||||
<div class="text-xs text-red-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.proposed.services as service (service.id)}
|
||||
{#if serviceDiff.added.some((s) => s.id === service.id)}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
|
||||
>
|
||||
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
|
||||
<div class="text-xs text-emerald-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.proposed.services as service (service.id)}
|
||||
{#if serviceDiff.added.some((s) => s.id === service.id)}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
|
||||
>
|
||||
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
|
||||
<div class="text-xs text-emerald-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editRequest.proposed.notes !== editRequest.original.notes}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Booking Notes Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="text-sm">{editRequest.original.notes || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="text-sm">{editRequest.proposed.notes}</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Booking Notes Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="text-sm">{editRequest.original.notes || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="text-sm">{editRequest.proposed.notes}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Request Notes -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,8 +42,14 @@
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => { URL.revokeObjectURL(url); resolve(true); };
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(false); };
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
@@ -123,16 +129,20 @@
|
||||
/** Encode ImageData to AVIF via Web Worker. */
|
||||
function encodeAvif(imageData: ImageData, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/avif-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
const worker = new Worker(new URL('$lib/workers/avif-encoder.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
worker.onmessage = (
|
||||
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
|
||||
) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/avif' }));
|
||||
};
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.onerror = (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
};
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
@@ -140,16 +150,20 @@
|
||||
/** Encode ImageData to WebP via Web Worker. */
|
||||
function encodeWebp(imageData: ImageData, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/webp-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
const worker = new Worker(new URL('$lib/workers/webp-encoder.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
worker.onmessage = (
|
||||
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
|
||||
) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/webp' }));
|
||||
};
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.onerror = (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
};
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
@@ -157,16 +171,20 @@
|
||||
/** Encode ImageData to JPEG via Web Worker. */
|
||||
function encodeJpeg(imageData: ImageData, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/jpeg-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
const worker = new Worker(new URL('$lib/workers/jpeg-encoder.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
worker.onmessage = (
|
||||
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
|
||||
) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/jpeg' }));
|
||||
};
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.onerror = (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
};
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
@@ -174,18 +192,26 @@
|
||||
/** Encode ImageData to JPEG XL via Web Worker. Returns null if unavailable. */
|
||||
function encodeJxl(imageData: ImageData, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/jxl-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
const timeout = setTimeout(() => { worker.terminate(); resolve(null); }, 10000);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
const worker = new Worker(new URL('$lib/workers/jxl-encoder.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
worker.terminate();
|
||||
resolve(null);
|
||||
}, 10000);
|
||||
worker.onmessage = (
|
||||
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
|
||||
) => {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
if (e.data.error) return resolve(null);
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/jxl' }));
|
||||
};
|
||||
worker.onerror = () => { clearTimeout(timeout); worker.terminate(); resolve(null); };
|
||||
worker.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
resolve(null);
|
||||
};
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
@@ -610,7 +636,9 @@
|
||||
<div class="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<Card.Title>Drop or Select Files</Card.Title>
|
||||
<Card.Description>HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file</Card.Description>
|
||||
<Card.Description
|
||||
>HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file</Card.Description
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
@@ -634,7 +662,9 @@
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-gray-700">Drop images here or click to browse</p>
|
||||
<p class="mt-1 text-xs text-gray-500">HEIC, AVIF, WebP, PNG, JPEG, GIF & more — max 20MB per file</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
HEIC, AVIF, WebP, PNG, JPEG, GIF & more — max 20MB per file
|
||||
</p>
|
||||
</div>
|
||||
</FileDropZone>
|
||||
|
||||
@@ -688,11 +718,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium" class:text-gray-900={!oversized} class:text-red-900={oversized}>
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
class:text-gray-900={!oversized}
|
||||
class:text-red-900={oversized}
|
||||
>
|
||||
{f.name}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs" class:text-gray-500={!oversized} class:text-red-600={oversized}>
|
||||
<p
|
||||
class="text-xs"
|
||||
class:text-gray-500={!oversized}
|
||||
class:text-red-600={oversized}
|
||||
>
|
||||
{formatFileSize(f.size)}
|
||||
{#if oversized}
|
||||
— exceeds 20MB limit
|
||||
@@ -740,10 +778,10 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Results Section -->
|
||||
<!-- Upload Results Section -->
|
||||
{#if uploadResults.length > 0}
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="mb-4 font-semibold text-gray-900">Upload Results</h3>
|
||||
@@ -895,7 +933,10 @@
|
||||
<!-- Action Buttons -->
|
||||
<div class="border-t pt-6">
|
||||
<div class="flex justify-end">
|
||||
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading || hasOversizedFiles}>
|
||||
<Button
|
||||
onclick={uploadOneOrMany}
|
||||
disabled={!uploadFiles.length || uploading || hasOversizedFiles}
|
||||
>
|
||||
{#if uploading}
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
|
||||
@@ -68,9 +68,7 @@
|
||||
let hoursUntilAppointment = $derived(
|
||||
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||
);
|
||||
let hasPayments = $derived(
|
||||
(booking.amount_paid ?? 0) > 0
|
||||
);
|
||||
let hasPayments = $derived((booking.amount_paid ?? 0) > 0);
|
||||
let showNoticeWarning = $derived(
|
||||
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
|
||||
);
|
||||
@@ -439,30 +437,52 @@
|
||||
<p class="mt-1">Rescheduling may forfeit deposit protection on payments made.</p>
|
||||
<details class="mt-1 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||
<p class="mt-1">"Rescheduling within {Math.round(hoursUntilAppointment)}h of the original time with payments present means deposit protection applies — up to 50% of the total (up to £{(booking.total_amount * 0.5).toFixed(2)}) could be retained depending on notice period."</p>
|
||||
<p class="mt-1">
|
||||
"Rescheduling within {Math.round(hoursUntilAppointment)}h of the original time with
|
||||
payments present means deposit protection applies — up to 50% of the total (up to £{(
|
||||
booking.total_amount * 0.5
|
||||
).toFixed(2)}) could be retained depending on notice period."
|
||||
</p>
|
||||
</details>
|
||||
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveFees} />
|
||||
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span>
|
||||
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span
|
||||
>
|
||||
</label>
|
||||
<details class="ml-6 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||
<p class="mt-1">"We've waived deposit protection on this reschedule as a goodwill gesture. The full amount moves to the new appointment instead of having up to 50% retained as deposit."</p>
|
||||
<summary class="cursor-pointer hover:text-gray-700"
|
||||
>What happens with forgiveness</summary
|
||||
>
|
||||
<p class="mt-1">
|
||||
"We've waived deposit protection on this reschedule as a goodwill gesture. The full
|
||||
amount moves to the new appointment instead of having up to 50% retained as
|
||||
deposit."
|
||||
</p>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<p class="mt-1">This time change counts as a no-show toward deposit obligations.</p>
|
||||
<details class="mt-1 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||
<p class="mt-1">"This time change will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings."</p>
|
||||
<p class="mt-1">
|
||||
"This time change will count as a no-show toward your booking history. Two no-shows
|
||||
within 6 months would require a deposit on future bookings."
|
||||
</p>
|
||||
</details>
|
||||
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveNoShow} />
|
||||
<span class="text-xs">Forgive no-show — this reschedule will <strong>not</strong> count toward deposit obligations</span>
|
||||
<span class="text-xs"
|
||||
>Forgive no-show — this reschedule will <strong>not</strong> count toward deposit obligations</span
|
||||
>
|
||||
</label>
|
||||
<details class="ml-6 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||
<p class="mt-1">"We've waived the no-show record for this reschedule so your deposit obligations are unaffected."</p>
|
||||
<summary class="cursor-pointer hover:text-gray-700"
|
||||
>What happens with forgiveness</summary
|
||||
>
|
||||
<p class="mt-1">
|
||||
"We've waived the no-show record for this reschedule so your deposit obligations are
|
||||
unaffected."
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
@@ -489,9 +509,12 @@
|
||||
})}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)} · {booking.services
|
||||
?.map((s) => s.service_name)
|
||||
.join(', ') || 'No services'} · {bookingDuration} min
|
||||
{formatUserName(
|
||||
booking.user?.full_name || 'Unknown',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)} · {booking.services?.map((s) => s.service_name).join(', ') || 'No services'} · {bookingDuration}
|
||||
min
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -570,11 +570,17 @@
|
||||
id="service-duration"
|
||||
bind:value={newService.duration_minutes}
|
||||
onblur={validateDurationField}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.duration_minutes ? 'border-red-500' : ''}"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {serviceErrors.duration_minutes
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
>
|
||||
<option value={0}>Select...</option>
|
||||
{#each durationOptions as mins (mins)}
|
||||
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
|
||||
<option value={mins}
|
||||
>{mins} min{mins >= 60
|
||||
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{#if serviceErrors.duration_minutes}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
|
||||
function addItem(label: string, price: number) {
|
||||
const existing = cart.find(i => i.label === label);
|
||||
const existing = cart.find((i) => i.label === label);
|
||||
if (existing) {
|
||||
existing.qty++;
|
||||
} else {
|
||||
@@ -40,15 +40,17 @@
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
cart = cart.filter(i => i.id !== id);
|
||||
cart = cart.filter((i) => i.id !== id);
|
||||
}
|
||||
|
||||
function updateQty(id: string, delta: number) {
|
||||
cart = cart.map(i => {
|
||||
if (i.id !== id) return i;
|
||||
const next = i.qty + delta;
|
||||
return next <= 0 ? null : { ...i, qty: next };
|
||||
}).filter((i): i is CartItem => i !== null);
|
||||
cart = cart
|
||||
.map((i) => {
|
||||
if (i.id !== id) return i;
|
||||
const next = i.qty + delta;
|
||||
return next <= 0 ? null : { ...i, qty: next };
|
||||
})
|
||||
.filter((i): i is CartItem => i !== null);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -58,38 +60,74 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Cuticle Oil', 8)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Cuticle Oil', 8)}
|
||||
>
|
||||
Cuticle Oil - £8
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Nail Files (Pack)', 5)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Nail Files (Pack)', 5)}
|
||||
>
|
||||
Nail Files - £5
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Hand Cream', 6)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Hand Cream', 6)}
|
||||
>
|
||||
Hand Cream - £6
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Base Coat', 7)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Base Coat', 7)}
|
||||
>
|
||||
Base Coat - £7
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Top Coat', 7)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Top Coat', 7)}
|
||||
>
|
||||
Top Coat - £7
|
||||
</Button>
|
||||
<div class="relative">
|
||||
{#if showGiftCardInput}
|
||||
<div class="flex gap-1">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">£</span>
|
||||
<span class="absolute top-1/2 left-2 -translate-y-1/2 text-xs text-gray-400"
|
||||
>£</span
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={giftCardAmount}
|
||||
class="h-9 pl-5 text-sm"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addGiftCard(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addGiftCard();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs">Add</Button>
|
||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs"
|
||||
>Add</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" size="sm" class="w-full justify-start gap-2" onclick={() => showGiftCardInput = true}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2"
|
||||
onclick={() => (showGiftCardInput = true)}
|
||||
>
|
||||
Gift Card
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -100,16 +138,20 @@
|
||||
|
||||
<div class="px-4 py-3">
|
||||
{#if cart.length === 0}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Tap items above to add them to the sale.</p>
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||
Tap items above to add them to the sale.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto">
|
||||
{#each cart as item (item.id)}
|
||||
<div class="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="font-medium text-card-foreground">{item.label}</span>
|
||||
<span class="ml-2 text-xs text-muted-foreground">{formatCurrency(item.price)} each</span>
|
||||
<span class="ml-2 text-xs text-muted-foreground"
|
||||
>{formatCurrency(item.price)} each</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
|
||||
@@ -125,14 +167,22 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span class="w-14 text-right text-sm font-semibold tabular-nums">{formatCurrency(item.price * item.qty)}</span>
|
||||
<span class="w-14 text-right text-sm font-semibold tabular-nums"
|
||||
>{formatCurrency(item.price * item.qty)}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove item"
|
||||
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600"
|
||||
onclick={() => removeItem(item.id)}
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -153,7 +203,9 @@
|
||||
<Button class="mt-3 w-full" disabled>
|
||||
Charge {formatCurrency(subtotal)}
|
||||
</Button>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Payment flow and backend integration coming soon.</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Payment flow and backend integration coming soon.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -719,7 +719,9 @@
|
||||
size="sm"
|
||||
class="flex-1 text-xs"
|
||||
onclick={() => {
|
||||
newStartDate = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
newStartDate = new Date().toLocaleDateString('en-CA', {
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
onStartDateChange();
|
||||
}}
|
||||
>
|
||||
@@ -731,7 +733,9 @@
|
||||
size="sm"
|
||||
class="flex-1 text-xs"
|
||||
onclick={() => {
|
||||
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', {
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
onStartDateChange();
|
||||
}}
|
||||
>
|
||||
@@ -860,7 +864,13 @@
|
||||
{#each overlappingBookings as booking (booking.id)}
|
||||
<div class="rounded-md border border-amber-200 bg-white p-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
|
||||
<div class="text-sm font-medium">
|
||||
{formatUserName(
|
||||
booking.user?.full_name || 'Unknown',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PatchTestModal from './PatchTestModal.svelte';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PatchTestModal from './PatchTestModal.svelte';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -252,7 +252,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
const data = await res.json();
|
||||
giftCardBalance = data.balance;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -301,15 +303,21 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
{#if selectedUser}
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Personal Information -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Personal Information
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Full Name</div>
|
||||
<div class="font-medium">{formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Personal Information
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Full Name</div>
|
||||
<div class="font-medium">
|
||||
{formatUserName(
|
||||
selectedUser.fullName,
|
||||
selectedUser.previousFirstName,
|
||||
selectedUser.previousLastName
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
<div class="font-medium break-words" title={selectedUser.email}>
|
||||
@@ -637,7 +645,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<PatchTestModal
|
||||
bind:open={showPatchTestModal}
|
||||
userId={selectedUser.id}
|
||||
userName={formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}
|
||||
userName={formatUserName(
|
||||
selectedUser.fullName,
|
||||
selectedUser.previousFirstName,
|
||||
selectedUser.previousLastName
|
||||
)}
|
||||
onPatchTestAdded={() => {
|
||||
fetchUserDetails();
|
||||
}}
|
||||
|
||||
@@ -192,7 +192,9 @@
|
||||
{#each users as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||
<div>
|
||||
<div class="font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
|
||||
<div class="font-medium">
|
||||
{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{user.email || '—'} • {user.phone || '—'}
|
||||
</div>
|
||||
|
||||
@@ -167,7 +167,9 @@
|
||||
|
||||
// --- Walk-in lunch protection ---
|
||||
// Always block the first 60 minutes of the suggested lunch window from walk-in availability
|
||||
const dayStartMinutesVal = Math.min(...todayData.slots.map((s) => timeToMinutes(s.startTime)));
|
||||
const dayStartMinutesVal = Math.min(
|
||||
...todayData.slots.map((s) => timeToMinutes(s.startTime))
|
||||
);
|
||||
const dayEndMinutesVal = Math.max(...todayData.slots.map((s) => timeToMinutes(s.endTime)));
|
||||
const dayStartTimeVal = minutesToTime(dayStartMinutesVal);
|
||||
const dayEndTimeVal = minutesToTime(dayEndMinutesVal);
|
||||
|
||||
@@ -52,7 +52,15 @@
|
||||
let userType = $state<'member' | 'guest'>('member');
|
||||
let userQuery = $state('');
|
||||
let users = $state<
|
||||
Array<{ id: string; full_name: string; email?: string; phone?: string; account_role: string; previous_first_name?: string | null; previous_last_name?: string | null }>
|
||||
Array<{
|
||||
id: string;
|
||||
full_name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
account_role: string;
|
||||
previous_first_name?: string | null;
|
||||
previous_last_name?: string | null;
|
||||
}>
|
||||
>([]);
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let guestName = $state('');
|
||||
@@ -70,7 +78,13 @@
|
||||
let customSearchQuery = $state('');
|
||||
let loadingCustomServices = $state(false);
|
||||
let showCustomCreateForm = $state(false);
|
||||
let newCustomService = $state({ name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' });
|
||||
let newCustomService = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
});
|
||||
let creatingCustomService = $state(false);
|
||||
let customServiceErrors = $state<Record<string, string>>({});
|
||||
|
||||
@@ -112,16 +126,22 @@
|
||||
}
|
||||
let isCustomFormValid = $derived(
|
||||
(newCustomService.name ?? '').trim() !== '' &&
|
||||
!customServiceErrors.name &&
|
||||
!customServiceErrors.price &&
|
||||
!customServiceErrors.duration_minutes &&
|
||||
!customServiceErrors.minimum_age_required
|
||||
!customServiceErrors.name &&
|
||||
!customServiceErrors.price &&
|
||||
!customServiceErrors.duration_minutes &&
|
||||
!customServiceErrors.minimum_age_required
|
||||
);
|
||||
|
||||
function toggleCustomForm(show: boolean) {
|
||||
showCustomCreateForm = show;
|
||||
if (show) {
|
||||
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
newCustomService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
}
|
||||
}
|
||||
@@ -388,7 +408,13 @@
|
||||
}
|
||||
};
|
||||
showCustomCreateForm = false;
|
||||
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
newCustomService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
customServiceErrors = { name: '', price: '', duration_minutes: '' };
|
||||
toast.success('Custom service created and added');
|
||||
} else {
|
||||
@@ -467,7 +493,12 @@
|
||||
} else {
|
||||
// Fallback: Calculate immediate start time (rounded to next 15 min)
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const londonTimeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const londonTimeStr = new Date().toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
const [h, min] = londonTimeStr.split(':').map(Number);
|
||||
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
|
||||
@@ -502,13 +533,19 @@
|
||||
start_time: string;
|
||||
service_ids: string[];
|
||||
custom_service_ids: string[];
|
||||
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
|
||||
service_overrides:
|
||||
| Array<{
|
||||
service_id: string;
|
||||
override_price: number | null;
|
||||
override_duration_minutes: number | null;
|
||||
}>
|
||||
| undefined;
|
||||
notes: string | null;
|
||||
} = {
|
||||
user_id: finalUserId,
|
||||
start_time: dateTimeStr,
|
||||
service_ids: selectedServices.filter(s => !s.is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter(s => s.is_custom).map((s) => s.id),
|
||||
service_ids: selectedServices.filter((s) => !s.is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter((s) => s.is_custom).map((s) => s.id),
|
||||
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||
notes: notes.trim() || null
|
||||
};
|
||||
@@ -720,7 +757,13 @@
|
||||
onclick={() => (selectedUserId = user.id)}
|
||||
>
|
||||
<div>
|
||||
<div class="text-base font-medium">{formatUserName(user.full_name, user.previous_first_name, user.previous_last_name)}</div>
|
||||
<div class="text-base font-medium">
|
||||
{formatUserName(
|
||||
user.full_name,
|
||||
user.previous_first_name,
|
||||
user.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{#if user.email && user.phone}
|
||||
{user.email} • {user.phone}
|
||||
@@ -828,7 +871,9 @@
|
||||
<Input
|
||||
placeholder="Search existing custom services..."
|
||||
bind:value={customSearchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') fetchCustomServices(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') fetchCustomServices();
|
||||
}}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={fetchCustomServices}>Search</Button>
|
||||
@@ -861,12 +906,23 @@
|
||||
}}
|
||||
>
|
||||
<span class="font-medium">{cs.name}</span>
|
||||
<span class="text-gray-500">{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0 ? ` (${cs.usage_count}×)` : ''}</span>
|
||||
<span class="text-gray-500"
|
||||
>{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0
|
||||
? ` (${cs.usage_count}×)`
|
||||
: ''}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={() => { showCustomCreateForm = true; }} class="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
showCustomCreateForm = true;
|
||||
}}
|
||||
class="w-full"
|
||||
>
|
||||
+ Create new custom service
|
||||
</Button>
|
||||
</div>
|
||||
@@ -877,8 +933,10 @@
|
||||
<Input
|
||||
id="walkin-cs-name"
|
||||
bind:value={newCustomService.name}
|
||||
oninput={() => customServiceErrors.name = validateCsName(newCustomService.name)}
|
||||
onblur={() => customServiceErrors.name = validateCsName(newCustomService.name)}
|
||||
oninput={() =>
|
||||
(customServiceErrors.name = validateCsName(newCustomService.name))}
|
||||
onblur={() =>
|
||||
(customServiceErrors.name = validateCsName(newCustomService.name))}
|
||||
placeholder="e.g., Bridal Party French Tips"
|
||||
class={customServiceErrors.name ? 'border-red-500' : ''}
|
||||
/>
|
||||
@@ -904,8 +962,10 @@
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={newCustomService.price}
|
||||
oninput={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
|
||||
onblur={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
|
||||
oninput={() =>
|
||||
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
|
||||
onblur={() =>
|
||||
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
|
||||
placeholder="0.00"
|
||||
class={customServiceErrors.price ? 'border-red-500' : ''}
|
||||
/>
|
||||
@@ -918,12 +978,21 @@
|
||||
<select
|
||||
id="walkin-cs-dur"
|
||||
bind:value={newCustomService.duration_minutes}
|
||||
onchange={() => customServiceErrors.duration_minutes = validateCsDuration(newCustomService.duration_minutes)}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {customServiceErrors.duration_minutes ? 'border-red-500' : ''}"
|
||||
onchange={() =>
|
||||
(customServiceErrors.duration_minutes = validateCsDuration(
|
||||
newCustomService.duration_minutes
|
||||
))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {customServiceErrors.duration_minutes
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{#each durationOptions as mins (mins)}
|
||||
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
|
||||
<option value={mins}
|
||||
>{mins} min{mins >= 60
|
||||
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{#if customServiceErrors.duration_minutes}
|
||||
@@ -941,8 +1010,13 @@
|
||||
max="100"
|
||||
placeholder="0"
|
||||
bind:value={newCustomService.minimum_age_required}
|
||||
oninput={() => customServiceErrors.minimum_age_required = validateCsMinimumAge(newCustomService.minimum_age_required)}
|
||||
class="w-full {customServiceErrors.minimum_age_required ? 'border-red-500' : ''}"
|
||||
oninput={() =>
|
||||
(customServiceErrors.minimum_age_required = validateCsMinimumAge(
|
||||
newCustomService.minimum_age_required
|
||||
))}
|
||||
class="w-full {customServiceErrors.minimum_age_required
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
/>
|
||||
{#if customServiceErrors.minimum_age_required}
|
||||
<p class="text-xs text-red-600">{customServiceErrors.minimum_age_required}</p>
|
||||
@@ -950,7 +1024,11 @@
|
||||
<p class="text-xs text-gray-500">0 for no age restriction</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={createCustomService} disabled={creatingCustomService || !isCustomFormValid}>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={createCustomService}
|
||||
disabled={creatingCustomService || !isCustomFormValid}
|
||||
>
|
||||
{creatingCustomService ? 'Creating...' : 'Save & Add'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => toggleCustomForm(false)}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,14 @@
|
||||
outOfHours?: boolean;
|
||||
}
|
||||
|
||||
let { selectedDate, selectedTime, endTime, duration, protection, outOfHours = false }: Props = $props();
|
||||
let {
|
||||
selectedDate,
|
||||
selectedTime,
|
||||
endTime,
|
||||
duration,
|
||||
protection,
|
||||
outOfHours = false
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Separator class="my-4" />
|
||||
@@ -78,7 +85,8 @@
|
||||
{/if}
|
||||
<div
|
||||
class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 {protection?.showWarning ||
|
||||
protection?.isBlocked || outOfHours
|
||||
protection?.isBlocked ||
|
||||
outOfHours
|
||||
? 'mt-3'
|
||||
: ''}"
|
||||
>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="no-scrollbar inset-y-0 right-0 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
|
||||
class="inset-y-0 right-0 no-scrollbar flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
|
||||
>
|
||||
{#if groupedTimeSlots.length > 0}
|
||||
{#if formattedDate}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="no-scrollbar inset-y-0 right-0 flex max-h-72 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-t-0 md:border-l"
|
||||
class="inset-y-0 right-0 no-scrollbar flex max-h-72 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-t-0 md:border-l"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
{#each timeSlots as time (time)}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
{#if showSaveCard}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="saveCard" bind:checked={saveCard} disabled={disabled} />
|
||||
<Checkbox id="saveCard" bind:checked={saveCard} {disabled} />
|
||||
<Label for="saveCard" class="text-sm font-normal">Save card for next time</Label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -64,7 +64,15 @@
|
||||
let customerBalance = $state(0);
|
||||
let loadingCustomerBalance = $state(false);
|
||||
let giftCardPaymentAmount = $state('');
|
||||
let savedCardList = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
|
||||
let savedCardList = $state<
|
||||
Array<{
|
||||
id: string;
|
||||
card_brand: string;
|
||||
card_last4: string;
|
||||
card_expiry: string;
|
||||
cardholder_name?: string;
|
||||
}>
|
||||
>([]);
|
||||
let loadingSavedCardList = $state(false);
|
||||
|
||||
async function fetchCustomerGiftCardBalance() {
|
||||
@@ -196,7 +204,9 @@
|
||||
}
|
||||
|
||||
let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0));
|
||||
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
|
||||
let discountSum = $derived(
|
||||
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
|
||||
);
|
||||
let netTotal = $derived(Math.max(0, subtotal - discountSum));
|
||||
|
||||
let tipPercentages = $derived.by(() => {
|
||||
@@ -501,9 +511,7 @@
|
||||
giftCardId = formatted;
|
||||
}
|
||||
|
||||
let giftCardValid = $derived(
|
||||
useAccountBalance || giftCardId.replace(/-/g, '').length === 12
|
||||
);
|
||||
let giftCardValid = $derived(useAccountBalance || giftCardId.replace(/-/g, '').length === 12);
|
||||
|
||||
async function handleGiftCardPayment() {
|
||||
if (!giftCardValid) {
|
||||
@@ -573,7 +581,15 @@
|
||||
}
|
||||
|
||||
// Saved cards
|
||||
let savedCards = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
|
||||
let savedCards = $state<
|
||||
Array<{
|
||||
id: string;
|
||||
card_brand: string;
|
||||
card_last4: string;
|
||||
card_expiry: string;
|
||||
cardholder_name?: string;
|
||||
}>
|
||||
>([]);
|
||||
let loadingSavedCards = $state(false);
|
||||
let selectedSavedCardId = $state<string | null>(null);
|
||||
|
||||
@@ -660,10 +676,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
open={true}
|
||||
onOpenChange={(open) => !open && handleClose()}
|
||||
>
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
||||
@@ -688,7 +701,7 @@
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex={-1}
|
||||
tabindex={-1}
|
||||
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
value={serviceOverrides[service.service_id]?.price ??
|
||||
service.price?.toFixed(2) ??
|
||||
@@ -717,7 +730,10 @@
|
||||
<label for="use-loyalty-admin" class="cursor-pointer select-none">
|
||||
<div class="text-sm font-medium text-fuchsia-900">Use Loyalty Stamp Card</div>
|
||||
<div class="mt-0.5 text-xs text-fuchsia-700">
|
||||
{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available · {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
|
||||
· {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
|
||||
Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE)
|
||||
)})
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -727,14 +743,24 @@
|
||||
{#if booking.discounts && booking.discounts.length > 0}
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-gray-800 flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
||||
<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"
|
||||
></path>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
||||
</svg>
|
||||
Applied Discounts
|
||||
</div>
|
||||
<span class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600">
|
||||
<span
|
||||
class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
|
||||
>
|
||||
{((discountSum / subtotal) * 100).toFixed(0)}% Off Total
|
||||
</span>
|
||||
</div>
|
||||
@@ -753,18 +779,23 @@
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span>
|
||||
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between items-center rounded-md border border-gray-200 bg-white p-4">
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md border border-gray-200 bg-white p-4"
|
||||
>
|
||||
<span class="text-base font-semibold text-gray-700">Total</span>
|
||||
<div class="flex items-baseline gap-2.5">
|
||||
{#if discountSum > 0.01}
|
||||
<span class="text-sm font-medium text-gray-400 line-through">{formatCurrency(subtotal)}</span>
|
||||
<span class="text-sm font-medium text-gray-400 line-through"
|
||||
>{formatCurrency(subtotal)}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
||||
</div>
|
||||
@@ -781,7 +812,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 {savedCardList.length > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3'}">
|
||||
<div
|
||||
class="grid grid-cols-2 gap-3 {savedCardList.length > 0
|
||||
? 'sm:grid-cols-4'
|
||||
: 'sm:grid-cols-3'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
|
||||
@@ -882,7 +917,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sm:hidden flex flex-wrap gap-3">
|
||||
<div class="flex flex-wrap gap-3 sm:hidden">
|
||||
{#if savedCardList.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
@@ -940,7 +975,7 @@
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex={-1}
|
||||
tabindex={-1}
|
||||
placeholder="Custom tip amount"
|
||||
value={customTipAmount}
|
||||
oninput={handleCustomTipInput}
|
||||
@@ -962,9 +997,7 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button onclick={handleCardPayment} class="flex-1">
|
||||
Charge Card
|
||||
</Button>
|
||||
<Button onclick={handleCardPayment} class="flex-1">Charge Card</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'card-processing' || status === 'card-polling'}
|
||||
@@ -990,7 +1023,7 @@
|
||||
id="cash-amount"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex={-1}
|
||||
tabindex={-1}
|
||||
value={cashAmount}
|
||||
oninput={handleCashInput}
|
||||
class="pl-7 text-lg font-semibold"
|
||||
@@ -1017,11 +1050,7 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleCashPayment}
|
||||
class="flex-1"
|
||||
disabled={cashAmountNum < totalDue}
|
||||
>
|
||||
<Button onclick={handleCashPayment} class="flex-1" disabled={cashAmountNum < totalDue}>
|
||||
Confirm Cash
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1042,14 +1071,16 @@
|
||||
|
||||
{#if (booking.user_id ?? booking.user?.id) && customerBalance > 0}
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Source</span>
|
||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||||
>Source</span
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {useAccountBalance
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => useAccountBalance = true}
|
||||
onclick={() => (useAccountBalance = true)}
|
||||
>
|
||||
Account Balance ({formatCurrency(customerBalance)})
|
||||
</button>
|
||||
@@ -1058,7 +1089,7 @@
|
||||
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {!useAccountBalance
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => useAccountBalance = false}
|
||||
onclick={() => (useAccountBalance = false)}
|
||||
>
|
||||
Physical Gift Card Code
|
||||
</button>
|
||||
@@ -1068,32 +1099,39 @@
|
||||
|
||||
{#if useAccountBalance}
|
||||
<div>
|
||||
<label for="giftcard-amount" class="text-sm font-medium text-gray-700">Amount to pay with Balance (£)</label>
|
||||
<label for="giftcard-amount" class="text-sm font-medium text-gray-700"
|
||||
>Amount to pay with Balance (£)</label
|
||||
>
|
||||
<div class="mt-1 flex gap-2">
|
||||
<Input
|
||||
id="giftcard-amount"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
value={giftCardPaymentAmount}
|
||||
oninput={(e) => giftCardPaymentAmount = (e.target as HTMLInputElement).value}
|
||||
oninput={(e) => (giftCardPaymentAmount = (e.target as HTMLInputElement).value)}
|
||||
class="flex-1 font-mono text-lg"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => { giftCardPaymentAmount = Math.min(customerBalance, totalDue).toFixed(2); }}
|
||||
onclick={() => {
|
||||
giftCardPaymentAmount = Math.min(customerBalance, totalDue).toFixed(2);
|
||||
}}
|
||||
class="shrink-0 text-xs"
|
||||
>
|
||||
Full Balance
|
||||
</Button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance can be used.
|
||||
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance
|
||||
can be used.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card Code </label>
|
||||
<label for="gift-card-id" class="text-sm font-medium text-gray-700">
|
||||
Gift Card Code
|
||||
</label>
|
||||
<Input
|
||||
id="gift-card-id"
|
||||
type="text"
|
||||
@@ -1105,17 +1143,15 @@
|
||||
maxlength={14}
|
||||
class="mt-1 font-mono text-lg tracking-widest"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Enter the 12-character code printed on the gift card</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Enter the 12-character code printed on the gift card
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleGiftCardPayment}
|
||||
class="flex-1"
|
||||
disabled={!giftCardValid}
|
||||
>
|
||||
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid}>
|
||||
Apply Gift Card
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1136,31 +1172,46 @@
|
||||
|
||||
{#if loadingSavedCards}
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"></div>
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
</div>
|
||||
{:else if savedCards.length === 0}
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
|
||||
<p class="text-sm text-gray-600">No saved cards found for this customer.</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Add a card via Square Dashboard or use another payment method.</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Add a card via Square Dashboard or use another payment method.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select a Saved Card</span>
|
||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||||
>Select a Saved Card</span
|
||||
>
|
||||
{#each savedCards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId === card.id
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId ===
|
||||
card.id
|
||||
? 'border-input bg-fuchsia-100'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => selectedSavedCardId = card.id}
|
||||
onclick={() => (selectedSavedCardId = card.id)}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{card.card_brand} ••••{card.card_last4}</span>
|
||||
<span class="font-medium text-gray-900"
|
||||
>{card.card_brand} ••••{card.card_last4}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{card.card_expiry}</span>
|
||||
</div>
|
||||
@@ -1171,23 +1222,28 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-xs text-amber-800">This card may require bank app confirmation to complete. Ensure the customer has their phone ready.</p>
|
||||
<p class="text-xs text-amber-800">
|
||||
This card may require bank app confirmation to complete. Ensure the customer has their
|
||||
phone ready.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleSavedCardPayment}
|
||||
class="flex-1"
|
||||
disabled={!selectedSavedCardId}
|
||||
>
|
||||
<Button onclick={handleSavedCardPayment} class="flex-1" disabled={!selectedSavedCardId}>
|
||||
Charge Saved Card
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,18 @@
|
||||
onComplete: (result: TillSaleResult) => void;
|
||||
}
|
||||
|
||||
let { amount, itemType, action, giftCardId = undefined, userId = undefined, isGuestMode = false, guestEmail = undefined, delivery: deliveryProp = undefined, onClose, onComplete }: Props = $props();
|
||||
let {
|
||||
amount,
|
||||
itemType,
|
||||
action,
|
||||
giftCardId = undefined,
|
||||
userId = undefined,
|
||||
isGuestMode = false,
|
||||
guestEmail = undefined,
|
||||
delivery: deliveryProp = undefined,
|
||||
onClose,
|
||||
onComplete
|
||||
}: Props = $props();
|
||||
|
||||
type TillSaleResult = {
|
||||
id: string;
|
||||
@@ -74,7 +85,12 @@
|
||||
}
|
||||
|
||||
// Selected customer info
|
||||
let selectedCustomer = $state<{ id: string; name: string; previousFirstName?: string | null; previousLastName?: string | null } | null>(null);
|
||||
let selectedCustomer = $state<{
|
||||
id: string;
|
||||
name: string;
|
||||
previousFirstName?: string | null;
|
||||
previousLastName?: string | null;
|
||||
} | null>(null);
|
||||
let isGuest = $state(false);
|
||||
|
||||
// Delivery choice — how the gift card value is given to the customer
|
||||
@@ -111,7 +127,16 @@
|
||||
|
||||
// Customer Selection - Search
|
||||
let userQuery = $state('');
|
||||
let users = $state<Array<{ id: string; fullName: string; email?: string; phone?: string; previousFirstName?: string | null; previousLastName?: string | null }>>([]);
|
||||
let users = $state<
|
||||
Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
previousFirstName?: string | null;
|
||||
previousLastName?: string | null;
|
||||
}>
|
||||
>([]);
|
||||
let loadingUsers = $state(false);
|
||||
let currentPage = $state(1);
|
||||
let totalPages = $state(1);
|
||||
@@ -318,9 +343,14 @@
|
||||
|
||||
function selectCurrentCustomer() {
|
||||
if (!currentCustomerInfo) return;
|
||||
selectedCustomer = { id: currentCustomerInfo.id, name: currentCustomerInfo.name, previousFirstName: currentCustomerInfo.previousFirstName, previousLastName: currentCustomerInfo.previousLastName };
|
||||
selectedCustomer = {
|
||||
id: currentCustomerInfo.id,
|
||||
name: currentCustomerInfo.name,
|
||||
previousFirstName: currentCustomerInfo.previousFirstName,
|
||||
previousLastName: currentCustomerInfo.previousLastName
|
||||
};
|
||||
isGuest = currentCustomerInfo.email?.endsWith('@guest.invalid') || false;
|
||||
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
|
||||
delivery = action === 'topup' || isGuest ? 'code' : 'account';
|
||||
step = 'payment-selection';
|
||||
}
|
||||
|
||||
@@ -395,10 +425,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function selectCustomer(user: { id: string; fullName: string; email?: string; previousFirstName?: string | null; previousLastName?: string | null }) {
|
||||
selectedCustomer = { id: user.id, name: user.fullName, previousFirstName: user.previousFirstName, previousLastName: user.previousLastName };
|
||||
function selectCustomer(user: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
previousFirstName?: string | null;
|
||||
previousLastName?: string | null;
|
||||
}) {
|
||||
selectedCustomer = {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
previousFirstName: user.previousFirstName,
|
||||
previousLastName: user.previousLastName
|
||||
};
|
||||
isGuest = user.email?.endsWith('@guest.invalid') || false;
|
||||
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
|
||||
delivery = action === 'topup' || isGuest ? 'code' : 'account';
|
||||
step = 'payment-selection';
|
||||
}
|
||||
|
||||
@@ -782,7 +823,13 @@
|
||||
{currentCustomerInfo.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{formatUserName(currentCustomerInfo.name, currentCustomerInfo.previousFirstName, currentCustomerInfo.previousLastName)}</div>
|
||||
<div class="text-sm font-medium">
|
||||
{formatUserName(
|
||||
currentCustomerInfo.name,
|
||||
currentCustomerInfo.previousFirstName,
|
||||
currentCustomerInfo.previousLastName
|
||||
)}
|
||||
</div>
|
||||
{#if currentCustomerInfo.email}
|
||||
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
|
||||
{/if}
|
||||
@@ -864,7 +911,13 @@
|
||||
onclick={() => selectCustomer(userItem)}
|
||||
>
|
||||
<div>
|
||||
<div class="text-base font-medium">{formatUserName(userItem.fullName, userItem.previousFirstName, userItem.previousLastName)}</div>
|
||||
<div class="text-base font-medium">
|
||||
{formatUserName(
|
||||
userItem.fullName,
|
||||
userItem.previousFirstName,
|
||||
userItem.previousLastName
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{userItem.email}{#if userItem.email && userItem.phone}
|
||||
·
|
||||
@@ -921,7 +974,11 @@
|
||||
<Dialog.Description>
|
||||
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
|
||||
{#if selectedCustomer}
|
||||
— {formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)}
|
||||
— {formatUserName(
|
||||
selectedCustomer.name,
|
||||
selectedCustomer.previousFirstName,
|
||||
selectedCustomer.previousLastName
|
||||
)}
|
||||
{/if}.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
@@ -934,11 +991,15 @@
|
||||
|
||||
{#if action === 'create' && !isGuest}
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block text-muted-foreground">Fulfillment Method</span>
|
||||
<span
|
||||
class="block text-xs font-semibold tracking-wider text-gray-500 text-muted-foreground uppercase"
|
||||
>Fulfillment Method</span
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery === 'account'
|
||||
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery ===
|
||||
'account'
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'}"
|
||||
onclick={() => (delivery = 'account')}
|
||||
@@ -947,7 +1008,8 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery === 'code'
|
||||
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery ===
|
||||
'code'
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'}"
|
||||
onclick={() => (delivery = 'code')}
|
||||
@@ -1119,7 +1181,11 @@
|
||||
<Dialog.Description>
|
||||
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
|
||||
{#if selectedCustomer}
|
||||
— {formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)}
|
||||
— {formatUserName(
|
||||
selectedCustomer.name,
|
||||
selectedCustomer.previousFirstName,
|
||||
selectedCustomer.previousLastName
|
||||
)}
|
||||
{/if}.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -125,7 +125,9 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
function handleCvcInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
|
||||
const formatted = formatAndPreserveCursor(input, (val) =>
|
||||
val.replace(/\D/g, '').substring(0, 4)
|
||||
);
|
||||
newCardCVC = formatted;
|
||||
}
|
||||
|
||||
@@ -167,10 +169,7 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
}
|
||||
|
||||
let cardFormValid = $derived(
|
||||
isValidLuhn(newCardNumber) &&
|
||||
expiryParts !== null &&
|
||||
newCardCVC.length >= 3 &&
|
||||
!isExpiryInPast
|
||||
isValidLuhn(newCardNumber) && expiryParts !== null && newCardCVC.length >= 3 && !isExpiryInPast
|
||||
);
|
||||
|
||||
let cardSelected = $derived(
|
||||
@@ -217,9 +216,7 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
// Auto-select a sensible default payment type based on the booking's deposit
|
||||
// state. The backend will split the charge into deposit + non-deposit records
|
||||
// when appropriate, so this choice mainly controls the button label and amount.
|
||||
let defaultType = $derived(
|
||||
defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full')
|
||||
);
|
||||
let defaultType = $derived(defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full'));
|
||||
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
|
||||
$effect(() => {
|
||||
paymentType = defaultType as 'full' | 'partial' | 'deposit';
|
||||
@@ -233,8 +230,12 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
let lockInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
let servicesSubtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0));
|
||||
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
|
||||
let servicesSubtotal = $derived(
|
||||
(booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0)
|
||||
);
|
||||
let discountSum = $derived(
|
||||
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
|
||||
);
|
||||
|
||||
let totalPaid = $derived(
|
||||
booking.payments
|
||||
@@ -257,18 +258,20 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// Deposit policy warning text — dynamic based on booking state
|
||||
let expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
|
||||
let depositPolicyWarning = $derived<string | null>({
|
||||
get text(): string | null {
|
||||
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
|
||||
if (booking.deposit_required) {
|
||||
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
|
||||
let depositPolicyWarning = $derived<string | null>(
|
||||
{
|
||||
get text(): string | null {
|
||||
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
|
||||
if (booking.deposit_required) {
|
||||
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
|
||||
}
|
||||
if (totalPaid > 0 || booking.amount_due > 0) {
|
||||
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (totalPaid > 0 || booking.amount_due > 0) {
|
||||
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}.text);
|
||||
}.text
|
||||
);
|
||||
|
||||
let isScenarioA = $derived(depositOutstanding);
|
||||
|
||||
@@ -494,24 +497,20 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
// Apply loyalty redemption before payment
|
||||
if (useLoyalty) {
|
||||
try {
|
||||
const redemptionResponse = await fetch(
|
||||
`/api/bookings/${booking.id}/apply-redemption`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
const redemptionResponse = await fetch(`/api/bookings/${booking.id}/apply-redemption`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
);
|
||||
});
|
||||
if (!redemptionResponse.ok) {
|
||||
const errData = await redemptionResponse.text();
|
||||
throw new Error(errData || 'Failed to apply loyalty discount');
|
||||
}
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
const msg =
|
||||
err instanceof Error ? err.message : 'Failed to apply loyalty discount';
|
||||
const msg = err instanceof Error ? err.message : 'Failed to apply loyalty discount';
|
||||
error = msg;
|
||||
toast.error(msg);
|
||||
return;
|
||||
@@ -669,7 +668,7 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
{#if status === 'idle' || status === 'processing' || status === 'error'}
|
||||
<div class="space-y-4">
|
||||
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
|
||||
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
|
||||
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md border p-3 text-sm {lockTimer <= 60
|
||||
@@ -686,7 +685,10 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0110 0v4" />
|
||||
</svg>
|
||||
<span>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment processing</span>
|
||||
<span
|
||||
>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment
|
||||
processing</span
|
||||
>
|
||||
</div>
|
||||
{:else if booking.status === 'pending_release' && (lockTimer === 0 || !lockAcquired)}
|
||||
<div
|
||||
@@ -737,7 +739,8 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
<label for="use-loyalty" class="cursor-pointer select-none">
|
||||
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
|
||||
<div class="mt-0.5 text-xs text-fuchsia-700">
|
||||
{stamps} stamps available · {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
|
||||
{stamps} stamps available · {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
|
||||
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -749,14 +752,24 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
|
||||
<svg class="h-4 w-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"
|
||||
></path>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
||||
</svg>
|
||||
Applied Discounts
|
||||
</div>
|
||||
{#if servicesSubtotal > 0}
|
||||
<span class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600">
|
||||
<span
|
||||
class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
|
||||
>
|
||||
{((discountSum / servicesSubtotal) * 100).toFixed(0)}% Off Total
|
||||
</span>
|
||||
{/if}
|
||||
@@ -776,7 +789,8 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span>
|
||||
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -790,30 +804,37 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
|
||||
>
|
||||
</div>
|
||||
{#if discountPreview?.eligible}
|
||||
{#each discountPreview.discounts as d}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">{d.name}</span>
|
||||
<span class="font-medium text-green-700">-{formatCurrency(Math.round(d.amount * 100))}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Amount Paid</span>
|
||||
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
|
||||
</div>
|
||||
{#if useLoyalty && loyaltyDiscount > 0}
|
||||
{#if discountPreview?.eligible}
|
||||
{#each discountPreview.discounts as d}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">{d.name}</span>
|
||||
<span class="font-medium text-green-700"
|
||||
>-{formatCurrency(Math.round(d.amount * 100))}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
|
||||
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
|
||||
<span class="text-gray-600">Amount Paid</span>
|
||||
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
|
||||
</div>
|
||||
{#if useLoyalty && loyaltyDiscount > 0}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
|
||||
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between border-t border-gray-200 pt-2">
|
||||
<span class="font-semibold text-gray-900">Amount Remaining</span>
|
||||
<span class="text-lg font-bold text-red-600">
|
||||
{formatCurrency(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between border-t border-gray-200 pt-2">
|
||||
<span class="font-semibold text-gray-900">Amount Remaining</span>
|
||||
<span class="text-lg font-bold text-red-600">
|
||||
{formatCurrency(Math.max(0, Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Selection (only when idle) -->
|
||||
@@ -986,7 +1007,14 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
: Math.round(booking.total_amount * 0.2 * 100)
|
||||
)})
|
||||
{:else}
|
||||
Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))}
|
||||
Pay {formatCurrency(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountCents() -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
)}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1036,7 +1064,7 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
value={partialAmount}
|
||||
oninput={handlePartialAmountInput}
|
||||
class="pl-7"
|
||||
disabled={status !== 'idle'}
|
||||
disabled={status !== 'idle'}
|
||||
/>
|
||||
</div>
|
||||
{#if partialValidationError}
|
||||
@@ -1057,7 +1085,14 @@ import { SvelteDate } from 'svelte/reactivity';
|
||||
? formatCurrency(Math.round(partialAmountNum * 100))
|
||||
: 'Part'}
|
||||
{:else}
|
||||
Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))}
|
||||
Pay {formatCurrency(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountCents() -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
)}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -400,8 +400,16 @@
|
||||
<div class="space-y-5">
|
||||
<!-- Conditional messages -->
|
||||
{#if minutesUntilClosing >= 75}
|
||||
<div class="flex items-start gap-2.5 rounded-md border border-teal-200 bg-teal-50/60 p-3 text-sm text-teal-800">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<div
|
||||
class="flex items-start gap-2.5 rounded-md border border-teal-200 bg-teal-50/60 p-3 text-sm text-teal-800"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
@@ -412,14 +420,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if minutesUntilClosing >= 30}
|
||||
<div class="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50/60 p-3 text-sm text-amber-800">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<div
|
||||
class="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50/60 p-3 text-sm text-amber-800"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium">Walk-ins closing soon</p>
|
||||
<p class="mt-0.5 text-amber-700">There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings</p>
|
||||
<p class="mt-0.5 text-amber-700">
|
||||
There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -438,17 +456,17 @@
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
|
||||
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||
£{summary.total_payments_today.toFixed(2)}
|
||||
<!-- Stats grid -->
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
|
||||
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||
£{summary.total_payments_today.toFixed(2)}
|
||||
</div>
|
||||
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
|
||||
</div>
|
||||
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||
<div class="text-xs font-medium text-gray-500">Tips</div>
|
||||
@@ -575,13 +593,13 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
|
||||
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||
£{weekSummary.total_payments_today.toFixed(2)}
|
||||
</div>
|
||||
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
|
||||
</div>
|
||||
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
|
||||
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||
£{weekSummary.total_payments_today.toFixed(2)}
|
||||
</div>
|
||||
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||
<div class="text-xs font-medium text-gray-500">Tips</div>
|
||||
@@ -655,7 +673,11 @@
|
||||
{#if activeAppointment.user?.profile_pic_url}
|
||||
<img
|
||||
src={activeAppointment.user.profile_pic_url}
|
||||
alt={formatUserName(activeAppointment.user.full_name, activeAppointment.user.previous_first_name, activeAppointment.user.previous_last_name)}
|
||||
alt={formatUserName(
|
||||
activeAppointment.user.full_name,
|
||||
activeAppointment.user.previous_first_name,
|
||||
activeAppointment.user.previous_last_name
|
||||
)}
|
||||
class="h-14 w-14 shrink-0 rounded-full object-cover ring-2 ring-blue-200 sm:h-16 sm:w-16 sm:ring-4 md:h-20 md:w-20"
|
||||
/>
|
||||
{:else}
|
||||
@@ -670,11 +692,15 @@
|
||||
{initials}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-lg font-semibold">
|
||||
{formatUserName(activeAppointment.user?.full_name || 'Guest', activeAppointment.user?.previous_first_name, activeAppointment.user?.previous_last_name)}
|
||||
</div>
|
||||
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-lg font-semibold">
|
||||
{formatUserName(
|
||||
activeAppointment.user?.full_name || 'Guest',
|
||||
activeAppointment.user?.previous_first_name,
|
||||
activeAppointment.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
|
||||
{#if activeAppointment.user}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -364,7 +364,13 @@
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{formatUserName(approval.user_name || 'Guest', approval.previous_first_name, approval.previous_last_name)}</div>
|
||||
<div class="font-medium">
|
||||
{formatUserName(
|
||||
approval.user_name || 'Guest',
|
||||
approval.previous_first_name,
|
||||
approval.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
{approval.services.join(', ')}
|
||||
</div>
|
||||
@@ -409,7 +415,13 @@
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{formatUserName(er.user?.full_name || 'Unknown', er.user?.previous_first_name, er.user?.previous_last_name)}</span>
|
||||
<span class="font-medium"
|
||||
>{formatUserName(
|
||||
er.user?.full_name || 'Unknown',
|
||||
er.user?.previous_first_name,
|
||||
er.user?.previous_last_name
|
||||
)}</span
|
||||
>
|
||||
<span class="text-xs font-medium text-amber-600">Edit/Reschedule</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
|
||||
@@ -57,7 +57,14 @@
|
||||
start_time: string;
|
||||
duration_minutes: number;
|
||||
status: string;
|
||||
user: { id: string; full_name: string; email: string | null; phone: string | null; previous_first_name?: string | null; previous_last_name?: string | null } | null;
|
||||
user: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
previous_first_name?: string | null;
|
||||
previous_last_name?: string | null;
|
||||
} | null;
|
||||
services: string[];
|
||||
};
|
||||
|
||||
@@ -271,7 +278,11 @@
|
||||
data: { label: string };
|
||||
};
|
||||
|
||||
type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem | ClosingTimeTimelineItem;
|
||||
type TimelineItem =
|
||||
| AppointmentTimelineItem
|
||||
| BlockerTimelineItem
|
||||
| LunchTimelineItem
|
||||
| ClosingTimeTimelineItem;
|
||||
|
||||
let timeline = $derived.by(() => {
|
||||
const items: TimelineItem[] = [];
|
||||
@@ -753,7 +764,11 @@
|
||||
class="block max-w-full truncate font-medium hover:text-blue-600 hover:underline"
|
||||
onclick={() => openUserModal(item.data.user_id)}
|
||||
>
|
||||
{formatUserName(item.data.user_name, item.data.previous_first_name, item.data.previous_last_name)}
|
||||
{formatUserName(
|
||||
item.data.user_name,
|
||||
item.data.previous_first_name,
|
||||
item.data.previous_last_name
|
||||
)}
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
|
||||
<span class="truncate">{(item.data.services ?? []).join(', ')}</span>
|
||||
@@ -877,9 +892,7 @@
|
||||
</div>
|
||||
{:else if item.type === 'closing'}
|
||||
<div class="flex items-center gap-3 border-t border-gray-100 px-1 pt-3">
|
||||
<div
|
||||
class="text-xs font-medium text-gray-400"
|
||||
>
|
||||
<div class="text-xs font-medium text-gray-400">
|
||||
{item.data.label}
|
||||
</div>
|
||||
<div class="h-px flex-1 bg-gray-100"></div>
|
||||
@@ -1008,7 +1021,13 @@
|
||||
{#each overlappingBookings as booking (booking.id)}
|
||||
<div class="rounded-md border border-amber-200 bg-white p-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
|
||||
<div class="text-sm font-medium">
|
||||
{formatUserName(
|
||||
booking.user?.full_name || 'Unknown',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
text: string;
|
||||
maxChars?: number;
|
||||
threshold?: number; // when to start showing counter
|
||||
};
|
||||
let { text, maxChars = 1000000, threshold = 750000 }: Props = $props();
|
||||
|
||||
const graphemeCount = $derived.by(() => {
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
return [...segmenter.segment(text)].length;
|
||||
});
|
||||
|
||||
const shouldShow = $derived(graphemeCount > threshold);
|
||||
const remaining = $derived(maxChars - graphemeCount);
|
||||
const color = $derived(
|
||||
graphemeCount > 950000 ? 'text-red-600' :
|
||||
graphemeCount > 800000 ? 'text-yellow-600' :
|
||||
'text-green-600'
|
||||
);
|
||||
type Props = {
|
||||
text: string;
|
||||
maxChars?: number;
|
||||
threshold?: number; // when to start showing counter
|
||||
};
|
||||
let { text, maxChars = 1000000, threshold = 750000 }: Props = $props();
|
||||
|
||||
const graphemeCount = $derived.by(() => {
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
return [...segmenter.segment(text)].length;
|
||||
});
|
||||
|
||||
const shouldShow = $derived(graphemeCount > threshold);
|
||||
const remaining = $derived(maxChars - graphemeCount);
|
||||
const color = $derived(
|
||||
graphemeCount > 950000
|
||||
? 'text-red-600'
|
||||
: graphemeCount > 800000
|
||||
? 'text-yellow-600'
|
||||
: 'text-green-600'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if shouldShow}
|
||||
<p class="text-xs {color}">
|
||||
{remaining.toLocaleString()} characters remaining ({graphemeCount.toLocaleString()} / {maxChars.toLocaleString()})
|
||||
</p>
|
||||
<p class="text-xs {color}">
|
||||
{remaining.toLocaleString()} characters remaining ({graphemeCount.toLocaleString()} / {maxChars.toLocaleString()})
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -12,16 +12,22 @@
|
||||
jxl?: string;
|
||||
}
|
||||
|
||||
let { urls, type = "thumb", alt = "", class: className = "", ...imgProps }: {
|
||||
let {
|
||||
urls,
|
||||
type = 'thumb',
|
||||
alt = '',
|
||||
class: className = '',
|
||||
...imgProps
|
||||
}: {
|
||||
urls: ThumbURLs | FullURLs;
|
||||
type?: "thumb" | "full";
|
||||
type?: 'thumb' | 'full';
|
||||
alt?: string;
|
||||
class?: string;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
|
||||
const fallbackSrc = $derived(urls.jpg || urls.webp || urls.avif || "");
|
||||
const hasJxl = $derived(type === "full" && "jxl" in urls && (urls as FullURLs).jxl);
|
||||
const fallbackSrc = $derived(urls.jpg || urls.webp || urls.avif || '');
|
||||
const hasJxl = $derived(type === 'full' && 'jxl' in urls && (urls as FullURLs).jxl);
|
||||
</script>
|
||||
|
||||
<picture>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
const display = $derived(variantMap[status] ?? variantMap.draft);
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {display.variantClass}">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {display.variantClass}"
|
||||
>
|
||||
{display.label}
|
||||
</span>
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay class={typeof className === 'string' && /(!?z-\[[^\]]+\]|!?z-\d+)/.test(className) ? className.match(/(!?z-\[[^\]]+\]|!?z-\d+)/)?.[0] : ''} />
|
||||
<Dialog.Overlay
|
||||
class={typeof className === 'string' && /(!?z-\[[^\]]+\]|!?z-\d+)/.test(className)
|
||||
? className.match(/(!?z-\[[^\]]+\]|!?z-\d+)/)?.[0]
|
||||
: ''}
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
let ref = $state<HTMLElement | null>(null);
|
||||
</script>
|
||||
|
||||
<Button.Root bind:ref={ref} type="submit" {...restProps} />
|
||||
<Button.Root bind:ref type="submit" {...restProps} />
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, setContext, untrack } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import MapLibreGL from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { browser } from "$app/environment";
|
||||
import { resolveMapTheme } from "./theme";
|
||||
import { onMount, onDestroy, setContext, untrack } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import MapLibreGL from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { browser } from '$app/environment';
|
||||
import { resolveMapTheme } from './theme';
|
||||
|
||||
const theme = writable<"light" | "dark">("light");
|
||||
const theme = writable<'light' | 'dark'>('light');
|
||||
|
||||
// Check document class for theme (works with next-themes, etc.)
|
||||
function getDocumentTheme(): "light" | "dark" | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
if (document.documentElement.classList.contains("dark")) return "dark";
|
||||
if (document.documentElement.classList.contains("light")) return "light";
|
||||
function getDocumentTheme(): 'light' | 'dark' | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
if (document.documentElement.classList.contains('dark')) return 'dark';
|
||||
if (document.documentElement.classList.contains('light')) return 'light';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get system preference
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
let tailwindTheme: "light" | "dark" = $state("light");
|
||||
let tailwindTheme: 'light' | 'dark' = $state('light');
|
||||
|
||||
type MapStyleOption = string | MapLibreGL.StyleSpecification;
|
||||
|
||||
@@ -39,17 +39,17 @@
|
||||
};
|
||||
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
styles?: {
|
||||
light?: MapStyleOption;
|
||||
dark?: MapStyleOption;
|
||||
};
|
||||
theme?: "light" | "dark";
|
||||
theme?: 'light' | 'dark';
|
||||
/** Map projection type. Use `{ type: "globe" }` for 3D globe view. */
|
||||
projection?: MapLibreGL.ProjectionSpecification;
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
options?: Omit<MapLibreGL.MapOptions, "container" | "style">;
|
||||
options?: Omit<MapLibreGL.MapOptions, 'container' | 'style'>;
|
||||
/**
|
||||
* Bindable reference to the underlying MapLibre map instance.
|
||||
* Useful for calling map methods imperatively from the parent.
|
||||
@@ -74,8 +74,8 @@
|
||||
}
|
||||
|
||||
const defaultStyles = {
|
||||
dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
||||
light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
|
||||
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -89,7 +89,7 @@
|
||||
map = $bindable(null),
|
||||
viewport,
|
||||
onviewportchange,
|
||||
onstyleloaded,
|
||||
onstyleloaded
|
||||
}: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
@@ -111,25 +111,25 @@
|
||||
center: [c.lng, c.lat],
|
||||
zoom: mapInstance.getZoom(),
|
||||
bearing: mapInstance.getBearing(),
|
||||
pitch: mapInstance.getPitch(),
|
||||
pitch: mapInstance.getPitch()
|
||||
};
|
||||
}
|
||||
|
||||
const mapStyles = $derived({
|
||||
dark: styles?.dark ?? defaultStyles.dark,
|
||||
light: styles?.light ?? defaultStyles.light,
|
||||
light: styles?.light ?? defaultStyles.light
|
||||
});
|
||||
|
||||
const resolvedTheme = $derived(resolveMapTheme({ explicitTheme, ambientTheme: tailwindTheme }));
|
||||
|
||||
const currentStyle = $derived(resolvedTheme === "light" ? mapStyles.light : mapStyles.dark);
|
||||
const currentStyle = $derived(resolvedTheme === 'light' ? mapStyles.light : mapStyles.dark);
|
||||
|
||||
const isReady = $derived(isMounted && isLoaded && isStyleLoaded);
|
||||
|
||||
setContext("map", {
|
||||
setContext('map', {
|
||||
getMap: () => map,
|
||||
isLoaded: () => hasInitiallyLoaded,
|
||||
isStyleReady: () => isReady,
|
||||
isStyleReady: () => isReady
|
||||
});
|
||||
|
||||
function clearStyleTimeout() {
|
||||
@@ -165,22 +165,22 @@
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
// Also watch for system preference changes
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleSystemChange = (e: MediaQueryListEvent) => {
|
||||
// Only use system preference if no document class is set
|
||||
if (!getDocumentTheme()) {
|
||||
tailwindTheme = e.matches ? "dark" : "light";
|
||||
tailwindTheme = e.matches ? 'dark' : 'light';
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleSystemChange);
|
||||
mediaQuery.addEventListener('change', handleSystemChange);
|
||||
|
||||
onDestroy(() => {
|
||||
observer.disconnect();
|
||||
mediaQuery.removeEventListener("change", handleSystemChange);
|
||||
mediaQuery.removeEventListener('change', handleSystemChange);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,13 +190,13 @@
|
||||
fadeDuration: 0,
|
||||
renderWorldCopies: false,
|
||||
attributionControl: {
|
||||
compact: true,
|
||||
compact: true
|
||||
},
|
||||
center: viewport?.center ?? center,
|
||||
zoom: viewport?.zoom ?? zoom,
|
||||
bearing: viewport?.bearing ?? 0,
|
||||
pitch: viewport?.pitch ?? 0,
|
||||
...options,
|
||||
...options
|
||||
});
|
||||
|
||||
const styleDataHandler = () => {
|
||||
@@ -229,18 +229,18 @@
|
||||
onviewportchange?.(getViewport(mapInstance));
|
||||
};
|
||||
|
||||
mapInstance.on("load", loadHandler);
|
||||
mapInstance.on("styledata", styleDataHandler);
|
||||
mapInstance.on("move", handleMove);
|
||||
mapInstance.on('load', loadHandler);
|
||||
mapInstance.on('styledata', styleDataHandler);
|
||||
mapInstance.on('move', handleMove);
|
||||
|
||||
mapInstance.on("dragstart", () => (isInteracting = true));
|
||||
mapInstance.on("dragend", () => (isInteracting = false));
|
||||
mapInstance.on("zoomstart", () => (isInteracting = true));
|
||||
mapInstance.on("zoomend", () => (isInteracting = false));
|
||||
mapInstance.on("rotatestart", () => (isInteracting = true));
|
||||
mapInstance.on("rotateend", () => (isInteracting = false));
|
||||
mapInstance.on("pitchstart", () => (isInteracting = true));
|
||||
mapInstance.on("pitchend", () => (isInteracting = false));
|
||||
mapInstance.on('dragstart', () => (isInteracting = true));
|
||||
mapInstance.on('dragend', () => (isInteracting = false));
|
||||
mapInstance.on('zoomstart', () => (isInteracting = true));
|
||||
mapInstance.on('zoomend', () => (isInteracting = false));
|
||||
mapInstance.on('rotatestart', () => (isInteracting = true));
|
||||
mapInstance.on('rotateend', () => (isInteracting = false));
|
||||
mapInstance.on('pitchstart', () => (isInteracting = true));
|
||||
mapInstance.on('pitchend', () => (isInteracting = false));
|
||||
|
||||
map = mapInstance;
|
||||
});
|
||||
@@ -255,7 +255,7 @@
|
||||
center: viewport.center ?? current.center,
|
||||
zoom: viewport.zoom ?? current.zoom,
|
||||
bearing: viewport.bearing ?? current.bearing,
|
||||
pitch: viewport.pitch ?? current.pitch,
|
||||
pitch: viewport.pitch ?? current.pitch
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -269,7 +269,7 @@
|
||||
}
|
||||
|
||||
internalUpdate = true;
|
||||
map!.once("moveend", () => {
|
||||
map!.once('moveend', () => {
|
||||
internalUpdate = false;
|
||||
});
|
||||
map.jumpTo(next);
|
||||
@@ -291,12 +291,12 @@
|
||||
isStyleLoaded = false;
|
||||
map!.setStyle(style, { diff: true });
|
||||
|
||||
map!.once("styledata", () => {
|
||||
map!.once('styledata', () => {
|
||||
map!.jumpTo({
|
||||
center: currCenter,
|
||||
zoom: currZoom,
|
||||
bearing: currBearing,
|
||||
pitch: currPitch,
|
||||
pitch: currPitch
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -330,12 +330,12 @@
|
||||
{#if !isReady}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="flex gap-1">
|
||||
<span class="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full"></span>
|
||||
<span class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60"></span>
|
||||
<span
|
||||
class="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:150ms]"
|
||||
class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60 [animation-delay:150ms]"
|
||||
></span>
|
||||
<span
|
||||
class="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:300ms]"
|
||||
class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60 [animation-delay:300ms]"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" module>
|
||||
import type MapLibreGL from "maplibre-gl";
|
||||
import type MapLibreGL from 'maplibre-gl';
|
||||
|
||||
export type MapArcDatum = {
|
||||
/** Unique identifier for this arc. Required for hover state tracking. */
|
||||
@@ -17,8 +17,8 @@
|
||||
originalEvent: MapLibreGL.MapMouseEvent;
|
||||
};
|
||||
|
||||
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
||||
type MapArcLineLayout = NonNullable<MapLibreGL.LineLayerSpecification["layout"]>;
|
||||
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification['paint']>;
|
||||
type MapArcLineLayout = NonNullable<MapLibreGL.LineLayerSpecification['layout']>;
|
||||
|
||||
export type MapArcProps<T extends MapArcDatum = MapArcDatum> = {
|
||||
/** Array of arcs to render. Each arc must have a unique `id`. */
|
||||
@@ -50,7 +50,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T extends MapArcDatum = MapArcDatum">
|
||||
import { useMap } from "$lib/hooks/use-map.svelte.js";
|
||||
import { useMap } from '$lib/hooks/use-map.svelte.js';
|
||||
|
||||
let {
|
||||
data,
|
||||
@@ -63,18 +63,18 @@
|
||||
onclick,
|
||||
onhover,
|
||||
interactive = true,
|
||||
beforeId,
|
||||
beforeId
|
||||
}: MapArcProps<T> = $props();
|
||||
|
||||
const DEFAULT_PAINT: NonNullable<MapLibreGL.LineLayerSpecification["paint"]> = {
|
||||
"line-color": "#4285F4",
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.85,
|
||||
const DEFAULT_PAINT: NonNullable<MapLibreGL.LineLayerSpecification['paint']> = {
|
||||
'line-color': '#4285F4',
|
||||
'line-width': 2,
|
||||
'line-opacity': 0.85
|
||||
};
|
||||
|
||||
const DEFAULT_LAYOUT: NonNullable<MapLibreGL.LineLayerSpecification["layout"]> = {
|
||||
"line-join": "round",
|
||||
"line-cap": "round",
|
||||
const DEFAULT_LAYOUT: NonNullable<MapLibreGL.LineLayerSpecification['layout']> = {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
};
|
||||
|
||||
const ARC_HIT_MIN_WIDTH = 12;
|
||||
@@ -123,9 +123,9 @@
|
||||
}
|
||||
|
||||
function mergeArcPaint(
|
||||
base: NonNullable<MapLibreGL.LineLayerSpecification["paint"]>,
|
||||
hover: NonNullable<MapLibreGL.LineLayerSpecification["paint"]> | undefined
|
||||
): NonNullable<MapLibreGL.LineLayerSpecification["paint"]> {
|
||||
base: NonNullable<MapLibreGL.LineLayerSpecification['paint']>,
|
||||
hover: NonNullable<MapLibreGL.LineLayerSpecification['paint']> | undefined
|
||||
): NonNullable<MapLibreGL.LineLayerSpecification['paint']> {
|
||||
if (!hover) return base;
|
||||
const merged: Record<string, unknown> = { ...base };
|
||||
for (const [key, hoverValue] of Object.entries(hover)) {
|
||||
@@ -134,32 +134,32 @@
|
||||
merged[key] =
|
||||
baseValue === undefined
|
||||
? hoverValue
|
||||
: ["case", ["boolean", ["feature-state", "hover"], false], hoverValue, baseValue];
|
||||
: ['case', ['boolean', ['feature-state', 'hover'], false], hoverValue, baseValue];
|
||||
}
|
||||
return merged as NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
||||
return merged as NonNullable<MapLibreGL.LineLayerSpecification['paint']>;
|
||||
}
|
||||
|
||||
const geoJSON = $derived.by<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => ({
|
||||
type: "FeatureCollection",
|
||||
type: 'FeatureCollection',
|
||||
features: data.map((arc) => {
|
||||
const { from, to, id: arcId, ...properties } = arc;
|
||||
return {
|
||||
id: typeof arcId === "number" ? arcId : undefined,
|
||||
type: "Feature" as const,
|
||||
id: typeof arcId === 'number' ? arcId : undefined,
|
||||
type: 'Feature' as const,
|
||||
properties: { ...properties, _arc_id: String(arcId) },
|
||||
geometry: {
|
||||
type: "LineString" as const,
|
||||
coordinates: buildArcCoordinates(from, to, curvature, samples),
|
||||
},
|
||||
type: 'LineString' as const,
|
||||
coordinates: buildArcCoordinates(from, to, curvature, samples)
|
||||
}
|
||||
};
|
||||
}),
|
||||
})
|
||||
}));
|
||||
|
||||
const mergedPaint = $derived(mergeArcPaint({ ...DEFAULT_PAINT, ...paint }, hoverPaint));
|
||||
const mergedLayout = $derived({ ...DEFAULT_LAYOUT, ...layout });
|
||||
const hitWidth = $derived(() => {
|
||||
const w = paint?.["line-width"] ?? DEFAULT_PAINT["line-width"];
|
||||
const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH;
|
||||
const w = paint?.['line-width'] ?? DEFAULT_PAINT['line-width'];
|
||||
const base = typeof w === 'number' ? w : ARC_HIT_MIN_WIDTH;
|
||||
return Math.max((base as number) + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
|
||||
});
|
||||
|
||||
@@ -178,18 +178,18 @@
|
||||
|
||||
if (!map.getSource(currentSourceId)) {
|
||||
map.addSource(currentSourceId, {
|
||||
type: "geojson",
|
||||
type: 'geojson',
|
||||
data: geoJSON,
|
||||
promoteId: "_arc_id",
|
||||
promoteId: '_arc_id'
|
||||
});
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: currentLayerId,
|
||||
type: "line",
|
||||
type: 'line',
|
||||
source: currentSourceId,
|
||||
layout: mergedLayout,
|
||||
paint: mergedPaint,
|
||||
paint: mergedPaint
|
||||
},
|
||||
beforeId
|
||||
);
|
||||
@@ -198,10 +198,10 @@
|
||||
map.addLayer(
|
||||
{
|
||||
id: currentHitLayerId,
|
||||
type: "line",
|
||||
type: 'line',
|
||||
source: currentSourceId,
|
||||
layout: mergedLayout,
|
||||
paint: { "line-color": "transparent", "line-width": hitWidth() },
|
||||
paint: { 'line-color': 'transparent', 'line-width': hitWidth() }
|
||||
},
|
||||
beforeId
|
||||
);
|
||||
@@ -267,7 +267,7 @@
|
||||
if (arcId) {
|
||||
map.setFeatureState({ source: sourceId, id: arcId }, { hover: true });
|
||||
hoveredArcId = arcId;
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
|
||||
if (onhover) {
|
||||
const arc = getArcById(arcId);
|
||||
@@ -276,7 +276,7 @@
|
||||
}
|
||||
}
|
||||
} else {
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.getCanvas().style.cursor = '';
|
||||
if (onhover) onhover(null);
|
||||
}
|
||||
};
|
||||
@@ -286,18 +286,18 @@
|
||||
map.setFeatureState({ source: sourceId, id: hoveredArcId }, { hover: false });
|
||||
hoveredArcId = null;
|
||||
}
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.getCanvas().style.cursor = '';
|
||||
if (onhover) onhover(null);
|
||||
};
|
||||
|
||||
map.on("click", targetLayer, handleClick);
|
||||
map.on("mousemove", targetLayer, handleMouseMove);
|
||||
map.on("mouseleave", targetLayer, handleMouseLeave);
|
||||
map.on('click', targetLayer, handleClick);
|
||||
map.on('mousemove', targetLayer, handleMouseMove);
|
||||
map.on('mouseleave', targetLayer, handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
map.off("click", targetLayer, handleClick);
|
||||
map.off("mousemove", targetLayer, handleMouseMove);
|
||||
map.off("mouseleave", targetLayer, handleMouseLeave);
|
||||
map.off('click', targetLayer, handleClick);
|
||||
map.off('mousemove', targetLayer, handleMouseMove);
|
||||
map.off('mouseleave', targetLayer, handleMouseLeave);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" generics="P extends GeoJSON.GeoJsonProperties">
|
||||
import { getContext } from "svelte";
|
||||
import MapLibreGL from "maplibre-gl";
|
||||
import { getContext } from 'svelte';
|
||||
import MapLibreGL from 'maplibre-gl';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
|
||||
interface Props {
|
||||
@@ -29,17 +29,17 @@
|
||||
data,
|
||||
clusterMaxZoom = 14,
|
||||
clusterRadius = 50,
|
||||
clusterColors = ["#22c55e", "#eab308", "#ef4444"],
|
||||
clusterColors = ['#22c55e', '#eab308', '#ef4444'],
|
||||
clusterThresholds = [100, 750],
|
||||
pointColor = "#3b82f6",
|
||||
pointColor = '#3b82f6',
|
||||
onpointclick,
|
||||
onclusterclick,
|
||||
onclusterclick
|
||||
}: Props = $props();
|
||||
|
||||
const mapCtx = getContext<{
|
||||
getMap: () => MapLibreGL.Map | null;
|
||||
isStyleReady: () => boolean;
|
||||
}>("map");
|
||||
}>('map');
|
||||
|
||||
const id = generateUUID();
|
||||
const sourceId = $derived(`cluster-source-${id}`);
|
||||
@@ -66,72 +66,72 @@
|
||||
|
||||
// Add clustered GeoJSON source
|
||||
map.addSource(sourceId, {
|
||||
type: "geojson",
|
||||
type: 'geojson',
|
||||
data,
|
||||
cluster: true,
|
||||
clusterMaxZoom,
|
||||
clusterRadius,
|
||||
clusterRadius
|
||||
});
|
||||
|
||||
// Add cluster circles layer
|
||||
map.addLayer({
|
||||
id: clusterLayerId,
|
||||
type: "circle",
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
filter: ["has", "point_count"],
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
"circle-color": [
|
||||
"step",
|
||||
["get", "point_count"],
|
||||
'circle-color': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
clusterColors[0],
|
||||
clusterThresholds[0],
|
||||
clusterColors[1],
|
||||
clusterThresholds[1],
|
||||
clusterColors[2],
|
||||
clusterColors[2]
|
||||
],
|
||||
"circle-radius": [
|
||||
"step",
|
||||
["get", "point_count"],
|
||||
'circle-radius': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
20,
|
||||
clusterThresholds[0],
|
||||
30,
|
||||
clusterThresholds[1],
|
||||
40,
|
||||
40
|
||||
],
|
||||
"circle-stroke-width": 1,
|
||||
"circle-stroke-color": "#fff",
|
||||
"circle-opacity": 0.85,
|
||||
},
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#fff',
|
||||
'circle-opacity': 0.85
|
||||
}
|
||||
});
|
||||
|
||||
// Add cluster count text layer
|
||||
map.addLayer({
|
||||
id: clusterCountLayerId,
|
||||
type: "symbol",
|
||||
type: 'symbol',
|
||||
source: sourceId,
|
||||
filter: ["has", "point_count"],
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
"text-field": "{point_count_abbreviated}",
|
||||
"text-font": ["Open Sans"],
|
||||
"text-size": 12,
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['Open Sans'],
|
||||
'text-size': 12
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#fff",
|
||||
},
|
||||
'text-color': '#fff'
|
||||
}
|
||||
});
|
||||
|
||||
// Add unclustered point layer
|
||||
map.addLayer({
|
||||
id: unclusteredLayerId,
|
||||
type: "circle",
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
"circle-color": pointColor,
|
||||
"circle-radius": 5,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
'circle-color': pointColor,
|
||||
'circle-radius': 5,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fff'
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -151,7 +151,7 @@
|
||||
const map = mapCtx.getMap();
|
||||
const loaded = mapCtx.isStyleReady();
|
||||
|
||||
if (!loaded || !map || typeof data === "string") return;
|
||||
if (!loaded || !map || typeof data === 'string') return;
|
||||
|
||||
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
@@ -168,29 +168,29 @@
|
||||
|
||||
// Update cluster layer colors and sizes
|
||||
if (map.getLayer(clusterLayerId)) {
|
||||
map.setPaintProperty(clusterLayerId, "circle-color", [
|
||||
"step",
|
||||
["get", "point_count"],
|
||||
map.setPaintProperty(clusterLayerId, 'circle-color', [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
clusterColors[0],
|
||||
clusterThresholds[0],
|
||||
clusterColors[1],
|
||||
clusterThresholds[1],
|
||||
clusterColors[2],
|
||||
clusterColors[2]
|
||||
]);
|
||||
map.setPaintProperty(clusterLayerId, "circle-radius", [
|
||||
"step",
|
||||
["get", "point_count"],
|
||||
map.setPaintProperty(clusterLayerId, 'circle-radius', [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
20,
|
||||
clusterThresholds[0],
|
||||
30,
|
||||
clusterThresholds[1],
|
||||
40,
|
||||
40
|
||||
]);
|
||||
}
|
||||
|
||||
// Update unclustered point layer color
|
||||
if (map.getLayer(unclusteredLayerId)) {
|
||||
map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor);
|
||||
map.setPaintProperty(unclusteredLayerId, 'circle-color', pointColor);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
}
|
||||
) => {
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: [clusterLayerId],
|
||||
layers: [clusterLayerId]
|
||||
});
|
||||
if (!features.length) return;
|
||||
|
||||
@@ -225,7 +225,7 @@
|
||||
const zoom = await source.getClusterExpansionZoom(clusterId);
|
||||
map.easeTo({
|
||||
center: coordinates,
|
||||
zoom,
|
||||
zoom
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -241,7 +241,7 @@
|
||||
const feature = e.features[0];
|
||||
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
|
||||
// Handle world copies
|
||||
@@ -254,34 +254,34 @@
|
||||
|
||||
// Cursor style handlers
|
||||
const handleMouseEnterCluster = () => {
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
const handleMouseLeaveCluster = () => {
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.getCanvas().style.cursor = '';
|
||||
};
|
||||
const handleMouseEnterPoint = () => {
|
||||
if (onpointclick) {
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
}
|
||||
};
|
||||
const handleMouseLeavePoint = () => {
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.getCanvas().style.cursor = '';
|
||||
};
|
||||
|
||||
map.on("click", clusterLayerId, handleClusterClick);
|
||||
map.on("click", unclusteredLayerId, handlePointClick);
|
||||
map.on("mouseenter", clusterLayerId, handleMouseEnterCluster);
|
||||
map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster);
|
||||
map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
|
||||
map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
|
||||
map.on('click', clusterLayerId, handleClusterClick);
|
||||
map.on('click', unclusteredLayerId, handlePointClick);
|
||||
map.on('mouseenter', clusterLayerId, handleMouseEnterCluster);
|
||||
map.on('mouseleave', clusterLayerId, handleMouseLeaveCluster);
|
||||
map.on('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
|
||||
map.on('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
|
||||
|
||||
return () => {
|
||||
map.off("click", clusterLayerId, handleClusterClick);
|
||||
map.off("click", unclusteredLayerId, handlePointClick);
|
||||
map.off("mouseenter", clusterLayerId, handleMouseEnterCluster);
|
||||
map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster);
|
||||
map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
|
||||
map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
|
||||
map.off('click', clusterLayerId, handleClusterClick);
|
||||
map.off('click', unclusteredLayerId, handlePointClick);
|
||||
map.off('mouseenter', clusterLayerId, handleMouseEnterCluster);
|
||||
map.off('mouseleave', clusterLayerId, handleMouseLeaveCluster);
|
||||
map.off('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
|
||||
map.off('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import MapLibreGL from "maplibre-gl";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import Minus from "@lucide/svelte/icons/minus";
|
||||
import Locate from "@lucide/svelte/icons/locate";
|
||||
import Maximize from "@lucide/svelte/icons/maximize";
|
||||
import Loader2 from "@lucide/svelte/icons/loader-2";
|
||||
import { getContext } from 'svelte';
|
||||
import MapLibreGL from 'maplibre-gl';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Minus from '@lucide/svelte/icons/minus';
|
||||
import Locate from '@lucide/svelte/icons/locate';
|
||||
import Maximize from '@lucide/svelte/icons/maximize';
|
||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||
|
||||
interface Props {
|
||||
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
showZoom?: boolean;
|
||||
showCompass?: boolean;
|
||||
showLocate?: boolean;
|
||||
@@ -19,29 +19,29 @@
|
||||
}
|
||||
|
||||
let {
|
||||
position = "bottom-right",
|
||||
position = 'bottom-right',
|
||||
showZoom = true,
|
||||
showCompass = false,
|
||||
showLocate = false,
|
||||
showFullscreen = false,
|
||||
class: className,
|
||||
onlocate,
|
||||
onlocate
|
||||
}: Props = $props();
|
||||
|
||||
const mapCtx = getContext<{
|
||||
getMap: () => MapLibreGL.Map | null;
|
||||
isLoaded: () => boolean;
|
||||
}>("map");
|
||||
}>('map');
|
||||
|
||||
let waitingForLocation = $state(false);
|
||||
let compassElement: SVGSVGElement | null = $state(null);
|
||||
const loaded = $derived(mapCtx.isLoaded());
|
||||
|
||||
const positionClasses = {
|
||||
"top-left": "top-2 left-2",
|
||||
"top-right": "top-2 right-2",
|
||||
"bottom-left": "bottom-2 left-2",
|
||||
"bottom-right": "bottom-10 right-2",
|
||||
'top-left': 'top-2 left-2',
|
||||
'top-right': 'top-2 right-2',
|
||||
'bottom-left': 'bottom-2 left-2',
|
||||
'bottom-right': 'bottom-10 right-2'
|
||||
};
|
||||
|
||||
// Update compass rotation
|
||||
@@ -57,13 +57,13 @@
|
||||
compassElement.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
|
||||
};
|
||||
|
||||
map.on("rotate", updateRotation);
|
||||
map.on("pitch", updateRotation);
|
||||
map.on('rotate', updateRotation);
|
||||
map.on('pitch', updateRotation);
|
||||
updateRotation();
|
||||
|
||||
return () => {
|
||||
map.off("rotate", updateRotation);
|
||||
map.off("pitch", updateRotation);
|
||||
map.off('rotate', updateRotation);
|
||||
map.off('pitch', updateRotation);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -88,23 +88,23 @@
|
||||
|
||||
waitingForLocation = true;
|
||||
|
||||
if ("geolocation" in navigator) {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const coords = {
|
||||
longitude: position.coords.longitude,
|
||||
latitude: position.coords.latitude,
|
||||
latitude: position.coords.latitude
|
||||
};
|
||||
map.flyTo({
|
||||
center: [coords.longitude, coords.latitude],
|
||||
zoom: 14,
|
||||
duration: 1500,
|
||||
duration: 1500
|
||||
});
|
||||
onlocate?.(coords);
|
||||
waitingForLocation = false;
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error getting location:", error);
|
||||
console.error('Error getting location:', error);
|
||||
waitingForLocation = false;
|
||||
}
|
||||
);
|
||||
@@ -125,16 +125,16 @@
|
||||
</script>
|
||||
|
||||
{#if loaded}
|
||||
<div class={cn("absolute z-10 flex flex-col gap-1.5", positionClasses[position], className)}>
|
||||
<div class={cn('absolute z-10 flex flex-col gap-1.5', positionClasses[position], className)}>
|
||||
{#if showZoom}
|
||||
<div
|
||||
class="border-border bg-background [&>button:not(:last-child)]:border-border flex flex-col overflow-hidden rounded-md border shadow-sm [&>button:not(:last-child)]:border-b"
|
||||
class="flex flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm [&>button:not(:last-child)]:border-b [&>button:not(:last-child)]:border-border"
|
||||
>
|
||||
<button
|
||||
onclick={handleZoomIn}
|
||||
aria-label="Zoom in"
|
||||
type="button"
|
||||
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
|
||||
class="flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</button>
|
||||
@@ -142,7 +142,7 @@
|
||||
onclick={handleZoomOut}
|
||||
aria-label="Zoom out"
|
||||
type="button"
|
||||
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
|
||||
class="flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
|
||||
>
|
||||
<Minus class="size-4" />
|
||||
</button>
|
||||
@@ -151,13 +151,13 @@
|
||||
|
||||
{#if showCompass}
|
||||
<div
|
||||
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
|
||||
class="flex flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm"
|
||||
>
|
||||
<button
|
||||
onclick={handleResetBearing}
|
||||
aria-label="Reset bearing to north"
|
||||
type="button"
|
||||
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
|
||||
class="flex size-8 items-center justify-center transition-all hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
|
||||
>
|
||||
<svg
|
||||
bind:this={compassElement}
|
||||
@@ -176,13 +176,13 @@
|
||||
|
||||
{#if showLocate}
|
||||
<div
|
||||
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
|
||||
class="flex flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm"
|
||||
>
|
||||
<button
|
||||
onclick={handleLocate}
|
||||
aria-label="Find my location"
|
||||
type="button"
|
||||
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
|
||||
class="flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
|
||||
disabled={waitingForLocation}
|
||||
>
|
||||
{#if waitingForLocation}
|
||||
@@ -196,13 +196,13 @@
|
||||
|
||||
{#if showFullscreen}
|
||||
<div
|
||||
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
|
||||
class="flex flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm"
|
||||
>
|
||||
<button
|
||||
onclick={handleFullscreen}
|
||||
aria-label="Toggle fullscreen"
|
||||
type="button"
|
||||
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
|
||||
class="flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-accent/40"
|
||||
>
|
||||
<Maximize class="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { getContext, setContext, untrack } from "svelte";
|
||||
import MapLibreGL, { type MarkerOptions } from "maplibre-gl";
|
||||
import { getContext, setContext, untrack } from 'svelte';
|
||||
import MapLibreGL, { type MarkerOptions } from 'maplibre-gl';
|
||||
|
||||
type Anchor =
|
||||
| "center"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "left"
|
||||
| "right"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
| 'center'
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
interface Props {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
children?: import("svelte").Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
onmouseenter?: (e: MouseEvent) => void;
|
||||
onmouseleave?: (e: MouseEvent) => void;
|
||||
@@ -25,10 +25,10 @@
|
||||
ondragend?: (lngLat: { lng: number; lat: number }) => void;
|
||||
draggable?: boolean;
|
||||
anchor?: Anchor;
|
||||
offset?: MarkerOptions["offset"];
|
||||
offset?: MarkerOptions['offset'];
|
||||
rotation?: number;
|
||||
pitchAlignment?: MarkerOptions["pitchAlignment"];
|
||||
rotationAlignment?: MarkerOptions["rotationAlignment"];
|
||||
pitchAlignment?: MarkerOptions['pitchAlignment'];
|
||||
rotationAlignment?: MarkerOptions['rotationAlignment'];
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -42,17 +42,17 @@
|
||||
ondrag,
|
||||
ondragend,
|
||||
draggable = false,
|
||||
anchor = "center",
|
||||
anchor = 'center',
|
||||
offset,
|
||||
rotation,
|
||||
pitchAlignment,
|
||||
rotationAlignment,
|
||||
rotationAlignment
|
||||
}: Props = $props();
|
||||
|
||||
const mapCtx = getContext<{
|
||||
getMap: () => MapLibreGL.Map | null;
|
||||
isLoaded: () => boolean;
|
||||
}>("map");
|
||||
}>('map');
|
||||
|
||||
let marker: MapLibreGL.Marker | null = $state(null);
|
||||
let markerElement: HTMLDivElement | null = $state(null);
|
||||
@@ -60,13 +60,13 @@
|
||||
let isDragging = $state(false);
|
||||
|
||||
// Provide marker context for child components
|
||||
setContext("marker", {
|
||||
setContext('marker', {
|
||||
getMarker: () => marker,
|
||||
getElement: () => markerElement,
|
||||
getMap: () => mapCtx.getMap(),
|
||||
isReady: () => isReady,
|
||||
isDraggable: () => draggable,
|
||||
isDragging: () => isDragging,
|
||||
isDragging: () => isDragging
|
||||
});
|
||||
|
||||
// Create marker when map is ready
|
||||
@@ -80,8 +80,8 @@
|
||||
const lng = untrack(() => longitude);
|
||||
const lat = untrack(() => latitude);
|
||||
if (
|
||||
typeof lng !== "number" ||
|
||||
typeof lat !== "number" ||
|
||||
typeof lng !== 'number' ||
|
||||
typeof lat !== 'number' ||
|
||||
Number.isNaN(lng) ||
|
||||
Number.isNaN(lat)
|
||||
) {
|
||||
@@ -89,15 +89,15 @@
|
||||
}
|
||||
|
||||
// Create container element programmatically
|
||||
const container = document.createElement("div");
|
||||
container.className = "cursor-pointer";
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cursor-pointer';
|
||||
markerElement = container;
|
||||
|
||||
// Build marker options
|
||||
const markerOptions: MarkerOptions = {
|
||||
element: container,
|
||||
draggable,
|
||||
anchor,
|
||||
anchor
|
||||
};
|
||||
|
||||
if (offset !== undefined) markerOptions.offset = offset;
|
||||
@@ -111,10 +111,10 @@
|
||||
marker = markerInstance;
|
||||
|
||||
// Mouse event listeners on the container
|
||||
if (onclick) container.addEventListener("click", onclick);
|
||||
if (onmouseenter) container.addEventListener("mouseenter", onmouseenter);
|
||||
if (onclick) container.addEventListener('click', onclick);
|
||||
if (onmouseenter) container.addEventListener('mouseenter', onmouseenter);
|
||||
if (onmouseleave) {
|
||||
container.addEventListener("mouseleave", (e) => {
|
||||
container.addEventListener('mouseleave', (e) => {
|
||||
if (!isDragging) onmouseleave(e);
|
||||
});
|
||||
}
|
||||
@@ -136,23 +136,23 @@
|
||||
};
|
||||
|
||||
if (draggable) {
|
||||
markerInstance.on("dragstart", handleDragStart);
|
||||
markerInstance.on("drag", handleDrag);
|
||||
markerInstance.on("dragend", handleDragEnd);
|
||||
markerInstance.on('dragstart', handleDragStart);
|
||||
markerInstance.on('drag', handleDrag);
|
||||
markerInstance.on('dragend', handleDragEnd);
|
||||
}
|
||||
|
||||
isReady = true;
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (onclick) container.removeEventListener("click", onclick);
|
||||
if (onmouseenter) container.removeEventListener("mouseenter", onmouseenter);
|
||||
if (onmouseleave) container.removeEventListener("mouseleave", onmouseleave);
|
||||
if (onclick) container.removeEventListener('click', onclick);
|
||||
if (onmouseenter) container.removeEventListener('mouseenter', onmouseenter);
|
||||
if (onmouseleave) container.removeEventListener('mouseleave', onmouseleave);
|
||||
|
||||
if (draggable) {
|
||||
markerInstance.off("dragstart", handleDragStart);
|
||||
markerInstance.off("drag", handleDrag);
|
||||
markerInstance.off("dragend", handleDragEnd);
|
||||
markerInstance.off('dragstart', handleDragStart);
|
||||
markerInstance.off('drag', handleDrag);
|
||||
markerInstance.off('dragend', handleDragEnd);
|
||||
}
|
||||
|
||||
markerInstance.remove();
|
||||
@@ -166,8 +166,8 @@
|
||||
$effect(() => {
|
||||
if (
|
||||
marker &&
|
||||
typeof longitude === "number" &&
|
||||
typeof latitude === "number" &&
|
||||
typeof longitude === 'number' &&
|
||||
typeof latitude === 'number' &&
|
||||
!Number.isNaN(longitude) &&
|
||||
!Number.isNaN(latitude)
|
||||
) {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import { getContext } from 'svelte';
|
||||
import MapLibreGL, { type PopupOptions } from 'maplibre-gl';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
children?: import("svelte").Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
class?: string;
|
||||
closeButton?: boolean;
|
||||
onclose?: () => void;
|
||||
offset?: PopupOptions["offset"];
|
||||
anchor?: PopupOptions["anchor"];
|
||||
offset?: PopupOptions['offset'];
|
||||
anchor?: PopupOptions['anchor'];
|
||||
closeOnClick?: boolean;
|
||||
closeOnMove?: boolean;
|
||||
focusAfterOpen?: boolean;
|
||||
@@ -31,18 +31,18 @@
|
||||
closeOnClick,
|
||||
closeOnMove,
|
||||
focusAfterOpen,
|
||||
maxWidth,
|
||||
maxWidth
|
||||
}: Props = $props();
|
||||
|
||||
const mapCtx = getContext<{
|
||||
getMap: () => MapLibreGL.Map | null;
|
||||
isLoaded: () => boolean;
|
||||
}>("map");
|
||||
}>('map');
|
||||
|
||||
const markerCtx =
|
||||
getContext<{
|
||||
isDraggable?: () => boolean;
|
||||
}>("marker") || {};
|
||||
}>('marker') || {};
|
||||
|
||||
let popup: MapLibreGL.Popup | null = null;
|
||||
let wrapperElement: HTMLDivElement | null = $state(null);
|
||||
@@ -56,8 +56,8 @@
|
||||
|
||||
// Validate coordinates
|
||||
if (
|
||||
typeof longitude !== "number" ||
|
||||
typeof latitude !== "number" ||
|
||||
typeof longitude !== 'number' ||
|
||||
typeof latitude !== 'number' ||
|
||||
Number.isNaN(longitude) ||
|
||||
Number.isNaN(latitude)
|
||||
) {
|
||||
@@ -65,13 +65,13 @@
|
||||
}
|
||||
|
||||
// Create popup container
|
||||
const container = document.createElement("div");
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Build popup options
|
||||
const popupOptions: PopupOptions = {
|
||||
offset,
|
||||
closeButton: false,
|
||||
className: "maplibre-popup-transparent",
|
||||
className: 'maplibre-popup-transparent'
|
||||
};
|
||||
|
||||
// If marker is draggable, preserve popup state during movement
|
||||
@@ -93,14 +93,14 @@
|
||||
if (maxWidth) {
|
||||
popupInstance.setMaxWidth(maxWidth);
|
||||
} else {
|
||||
popupInstance.setMaxWidth("none");
|
||||
popupInstance.setMaxWidth('none');
|
||||
}
|
||||
|
||||
popup = popupInstance;
|
||||
|
||||
// Handle close event
|
||||
const handleClose = () => onclose?.();
|
||||
popupInstance.on("close", handleClose);
|
||||
popupInstance.on('close', handleClose);
|
||||
|
||||
// Move content to popup container
|
||||
while (wrapperElement.firstChild) {
|
||||
@@ -108,7 +108,7 @@
|
||||
}
|
||||
|
||||
return () => {
|
||||
popupInstance.off("close", handleClose);
|
||||
popupInstance.off('close', handleClose);
|
||||
|
||||
// Move content back
|
||||
while (container.firstChild) {
|
||||
@@ -126,8 +126,8 @@
|
||||
$effect(() => {
|
||||
if (
|
||||
popup &&
|
||||
typeof longitude === "number" &&
|
||||
typeof latitude === "number" &&
|
||||
typeof longitude === 'number' &&
|
||||
typeof latitude === 'number' &&
|
||||
!Number.isNaN(longitude) &&
|
||||
!Number.isNaN(latitude)
|
||||
) {
|
||||
@@ -144,8 +144,8 @@
|
||||
<div bind:this={wrapperElement} style="display: contents;">
|
||||
<div
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
|
||||
'relative max-w-62 rounded-md border bg-popover p-3 text-popover-foreground shadow-md',
|
||||
'animate-in duration-200 ease-out fade-in-0 zoom-in-95',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -154,7 +154,7 @@
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
aria-label="Close popup"
|
||||
class="focus-visible:ring-ring hover:bg-muted text-foreground absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm transition-colors focus:outline-none focus-visible:ring-2"
|
||||
class="absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm text-foreground transition-colors hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import MapLibreGL from "maplibre-gl";
|
||||
import { getContext } from 'svelte';
|
||||
import MapLibreGL from 'maplibre-gl';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
|
||||
interface Props {
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
let {
|
||||
coordinates,
|
||||
color = "#4285F4",
|
||||
color = '#4285F4',
|
||||
width = 3,
|
||||
opacity = 0.8,
|
||||
dashArray,
|
||||
@@ -36,13 +36,13 @@
|
||||
onmouseenter,
|
||||
onmouseleave,
|
||||
interactive = true,
|
||||
id = generateUUID(),
|
||||
id = generateUUID()
|
||||
}: Props = $props();
|
||||
|
||||
const mapCtx = getContext<{
|
||||
getMap: () => MapLibreGL.Map | null;
|
||||
isStyleReady: () => boolean;
|
||||
}>("map");
|
||||
}>('map');
|
||||
|
||||
const sourceId = $derived(`route-source-${id}`);
|
||||
const layerId = $derived(`route-layer-${id}`);
|
||||
@@ -60,42 +60,42 @@
|
||||
|
||||
// Add source
|
||||
map.addSource(sourceId, {
|
||||
type: "geojson",
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: "Feature",
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates,
|
||||
},
|
||||
},
|
||||
type: 'LineString',
|
||||
coordinates
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Build paint options with transition definitions
|
||||
// Use default values here - they'll be updated by the paint property effect
|
||||
const paint: MapLibreGL.LineLayerSpecification['paint'] = {
|
||||
"line-color": "#94a3b8", // Start with gray (unselected color)
|
||||
"line-width": 5, // Start with unselected width
|
||||
"line-opacity": 0.6, // Start with unselected opacity
|
||||
"line-color-transition": { duration: 300, delay: 0 },
|
||||
"line-width-transition": { duration: 300, delay: 0 },
|
||||
"line-opacity-transition": { duration: 300, delay: 0 },
|
||||
'line-color': '#94a3b8', // Start with gray (unselected color)
|
||||
'line-width': 5, // Start with unselected width
|
||||
'line-opacity': 0.6, // Start with unselected opacity
|
||||
'line-color-transition': { duration: 300, delay: 0 },
|
||||
'line-width-transition': { duration: 300, delay: 0 },
|
||||
'line-opacity-transition': { duration: 300, delay: 0 }
|
||||
};
|
||||
|
||||
if (dashArray) {
|
||||
paint["line-dasharray"] = dashArray;
|
||||
paint['line-dasharray'] = dashArray;
|
||||
}
|
||||
|
||||
// Add layer
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: "line",
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
layout: {
|
||||
"line-join": "round",
|
||||
"line-cap": "round",
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint,
|
||||
paint
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -118,12 +118,12 @@
|
||||
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData({
|
||||
type: "Feature",
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates,
|
||||
},
|
||||
type: 'LineString',
|
||||
coordinates
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -135,12 +135,12 @@
|
||||
|
||||
if (!loaded || !map || !map.getLayer(layerId)) return;
|
||||
|
||||
map.setPaintProperty(layerId, "line-color", color);
|
||||
map.setPaintProperty(layerId, "line-width", width);
|
||||
map.setPaintProperty(layerId, "line-opacity", opacity);
|
||||
map.setPaintProperty(layerId, 'line-color', color);
|
||||
map.setPaintProperty(layerId, 'line-width', width);
|
||||
map.setPaintProperty(layerId, 'line-opacity', opacity);
|
||||
|
||||
if (dashArray) {
|
||||
map.setPaintProperty(layerId, "line-dasharray", dashArray);
|
||||
map.setPaintProperty(layerId, 'line-dasharray', dashArray);
|
||||
}
|
||||
|
||||
// Move selected routes to top (when opacity is 1, it's selected)
|
||||
@@ -164,22 +164,22 @@
|
||||
onclick?.();
|
||||
};
|
||||
const handleMouseEnter = () => {
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
onmouseenter?.();
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.getCanvas().style.cursor = '';
|
||||
onmouseleave?.();
|
||||
};
|
||||
|
||||
map.on("click", layerId, handleClick);
|
||||
map.on("mouseenter", layerId, handleMouseEnter);
|
||||
map.on("mouseleave", layerId, handleMouseLeave);
|
||||
map.on('click', layerId, handleClick);
|
||||
map.on('mouseenter', layerId, handleMouseEnter);
|
||||
map.on('mouseleave', layerId, handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
map.off("click", layerId, handleClick);
|
||||
map.off("mouseenter", layerId, handleMouseEnter);
|
||||
map.off("mouseleave", layerId, handleMouseLeave);
|
||||
map.off('click', layerId, handleClick);
|
||||
map.off('mouseenter', layerId, handleMouseEnter);
|
||||
map.off('mouseleave', layerId, handleMouseLeave);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import MapLibreGL from "maplibre-gl";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { getContext } from 'svelte';
|
||||
import MapLibreGL from 'maplibre-gl';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
getElement: () => HTMLDivElement | null;
|
||||
getMap: () => MapLibreGL.Map | null;
|
||||
isReady: () => boolean;
|
||||
}>("marker");
|
||||
}>('marker');
|
||||
|
||||
let wrapperElement: HTMLDivElement | null = $state(null);
|
||||
let movedContent: Node[] = [];
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<!-- Hidden wrapper that holds content until marker is ready -->
|
||||
<div bind:this={wrapperElement} style="display: contents;">
|
||||
<div class={cn("relative cursor-pointer", className)}>
|
||||
<div class={cn('relative cursor-pointer', className)}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
class?: string;
|
||||
position?: "top" | "bottom";
|
||||
position?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
let { children, class: className, position = "top" }: Props = $props();
|
||||
let { children, class: className, position = 'top' }: Props = $props();
|
||||
|
||||
const positionClasses = {
|
||||
top: "bottom-full mb-1",
|
||||
bottom: "top-full mt-1",
|
||||
top: 'bottom-full mb-1',
|
||||
bottom: 'top-full mt-1'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
|
||||
"text-foreground text-[10px] font-medium",
|
||||
'absolute left-1/2 -translate-x-1/2 whitespace-nowrap',
|
||||
'text-[10px] font-medium text-foreground',
|
||||
positionClasses[position],
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import { getContext } from 'svelte';
|
||||
import MapLibreGL, { type PopupOptions } from 'maplibre-gl';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
class?: string;
|
||||
closeButton?: boolean;
|
||||
offset?: PopupOptions["offset"];
|
||||
anchor?: PopupOptions["anchor"];
|
||||
offset?: PopupOptions['offset'];
|
||||
anchor?: PopupOptions['anchor'];
|
||||
closeOnClick?: boolean;
|
||||
closeOnMove?: boolean;
|
||||
focusAfterOpen?: boolean;
|
||||
@@ -25,7 +25,7 @@
|
||||
closeOnClick,
|
||||
closeOnMove,
|
||||
focusAfterOpen,
|
||||
maxWidth,
|
||||
maxWidth
|
||||
}: Props = $props();
|
||||
|
||||
const markerCtx = getContext<{
|
||||
@@ -35,7 +35,7 @@
|
||||
isReady: () => boolean;
|
||||
isDraggable?: () => boolean;
|
||||
isDragging?: () => boolean;
|
||||
}>("marker");
|
||||
}>('marker');
|
||||
|
||||
let popup: MapLibreGL.Popup | null = null;
|
||||
let wrapperElement: HTMLDivElement | null = $state(null);
|
||||
@@ -49,13 +49,13 @@
|
||||
if (!ready || !marker || !wrapperElement) return;
|
||||
|
||||
// Create popup container
|
||||
const container = document.createElement("div");
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Build popup options
|
||||
const popupOptions: PopupOptions = {
|
||||
offset,
|
||||
closeButton: false,
|
||||
className: "maplibre-popup-transparent",
|
||||
className: 'maplibre-popup-transparent'
|
||||
};
|
||||
|
||||
if (anchor !== undefined) popupOptions.anchor = anchor;
|
||||
@@ -74,7 +74,7 @@
|
||||
if (maxWidth) {
|
||||
popupInstance.setMaxWidth(maxWidth);
|
||||
} else {
|
||||
popupInstance.setMaxWidth("none");
|
||||
popupInstance.setMaxWidth('none');
|
||||
}
|
||||
|
||||
// Attach popup to marker
|
||||
@@ -127,8 +127,8 @@
|
||||
<div bind:this={wrapperElement} style="display: contents;">
|
||||
<div
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
|
||||
'relative max-w-62 rounded-md border bg-popover p-3 text-popover-foreground shadow-md',
|
||||
'animate-in duration-200 ease-out fade-in-0 zoom-in-95',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -137,7 +137,7 @@
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
aria-label="Close popup"
|
||||
class="focus-visible:ring-ring hover:bg-muted text-foreground absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm transition-colors focus:outline-none focus-visible:ring-2"
|
||||
class="absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm text-foreground transition-colors hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { getContext } from 'svelte';
|
||||
import MapLibreGL, { type PopupOptions } from 'maplibre-gl';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
class?: string;
|
||||
offset?: PopupOptions["offset"];
|
||||
anchor?: PopupOptions["anchor"];
|
||||
offset?: PopupOptions['offset'];
|
||||
anchor?: PopupOptions['anchor'];
|
||||
}
|
||||
|
||||
let { children, class: className, offset = 16, anchor }: Props = $props();
|
||||
@@ -17,7 +17,7 @@
|
||||
getElement: () => HTMLDivElement | null;
|
||||
getMap: () => MapLibreGL.Map | null;
|
||||
isReady: () => boolean;
|
||||
}>("marker");
|
||||
}>('marker');
|
||||
|
||||
let wrapperElement: HTMLDivElement | null = $state(null);
|
||||
|
||||
@@ -31,21 +31,21 @@
|
||||
if (!ready || !marker || !markerElement || !map || !wrapperElement) return;
|
||||
|
||||
// Create popup container
|
||||
const container = document.createElement("div");
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Build popup options
|
||||
const popupOptions: PopupOptions = {
|
||||
offset,
|
||||
closeOnClick: true,
|
||||
closeButton: false,
|
||||
className: "maplibre-popup-transparent",
|
||||
className: 'maplibre-popup-transparent'
|
||||
};
|
||||
|
||||
if (anchor !== undefined) popupOptions.anchor = anchor;
|
||||
|
||||
// Create popup
|
||||
const popupInstance = new MapLibreGL.Popup(popupOptions)
|
||||
.setMaxWidth("none")
|
||||
.setMaxWidth('none')
|
||||
.setDOMContent(container);
|
||||
|
||||
// Move content to popup container
|
||||
@@ -62,12 +62,12 @@
|
||||
popupInstance.remove();
|
||||
};
|
||||
|
||||
markerElement.addEventListener("mouseenter", handleMouseEnter);
|
||||
markerElement.addEventListener("mouseleave", handleMouseLeave);
|
||||
markerElement.addEventListener('mouseenter', handleMouseEnter);
|
||||
markerElement.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
markerElement.removeEventListener("mouseenter", handleMouseEnter);
|
||||
markerElement.removeEventListener("mouseleave", handleMouseLeave);
|
||||
markerElement.removeEventListener('mouseenter', handleMouseEnter);
|
||||
markerElement.removeEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
// Move content back
|
||||
while (container.firstChild) {
|
||||
@@ -82,8 +82,8 @@
|
||||
<div bind:this={wrapperElement} style="display: contents;">
|
||||
<div
|
||||
class={cn(
|
||||
"bg-foreground text-background pointer-events-none rounded-md px-2 py-1 text-xs text-balance shadow-md",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
|
||||
'pointer-events-none rounded-md bg-foreground px-2 py-1 text-xs text-balance text-background shadow-md',
|
||||
'animate-in duration-200 ease-out fade-in-0 zoom-in-95',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export { default as Map } from "./Map.svelte";
|
||||
export type { MapViewport } from "./Map.svelte";
|
||||
export { default as MapMarker } from "./MapMarker.svelte";
|
||||
export { default as MarkerContent } from "./MarkerContent.svelte";
|
||||
export { default as MarkerPopup } from "./MarkerPopup.svelte";
|
||||
export { default as MarkerTooltip } from "./MarkerTooltip.svelte";
|
||||
export { default as MarkerLabel } from "./MarkerLabel.svelte";
|
||||
export { default as MapControls } from "./MapControls.svelte";
|
||||
export { default as MapPopup } from "./MapPopup.svelte";
|
||||
export { default as MapRoute } from "./MapRoute.svelte";
|
||||
export { default as MapClusterLayer } from "./MapClusterLayer.svelte";
|
||||
export { default as MapArc } from "./MapArc.svelte";
|
||||
export type { MapArcDatum, MapArcEvent, MapArcProps } from "./MapArc.svelte";
|
||||
export { default as Map } from './Map.svelte';
|
||||
export type { MapViewport } from './Map.svelte';
|
||||
export { default as MapMarker } from './MapMarker.svelte';
|
||||
export { default as MarkerContent } from './MarkerContent.svelte';
|
||||
export { default as MarkerPopup } from './MarkerPopup.svelte';
|
||||
export { default as MarkerTooltip } from './MarkerTooltip.svelte';
|
||||
export { default as MarkerLabel } from './MarkerLabel.svelte';
|
||||
export { default as MapControls } from './MapControls.svelte';
|
||||
export { default as MapPopup } from './MapPopup.svelte';
|
||||
export { default as MapRoute } from './MapRoute.svelte';
|
||||
export { default as MapClusterLayer } from './MapClusterLayer.svelte';
|
||||
export { default as MapArc } from './MapArc.svelte';
|
||||
export type { MapArcDatum, MapArcEvent, MapArcProps } from './MapArc.svelte';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export type MapTheme = "light" | "dark";
|
||||
export type MapTheme = 'light' | 'dark';
|
||||
|
||||
export function resolveMapTheme({
|
||||
explicitTheme,
|
||||
ambientTheme,
|
||||
ambientTheme
|
||||
}: {
|
||||
explicitTheme?: MapTheme;
|
||||
ambientTheme: MapTheme;
|
||||
|
||||
@@ -30,9 +30,19 @@
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const allowedKeys = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
||||
const allowedKeys = [
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'Tab',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Home',
|
||||
'End'
|
||||
];
|
||||
if (allowedKeys.includes(e.key)) return;
|
||||
if (e.key === '+') return; // allow + anywhere; normalisePhoneInput removes extras
|
||||
if (e.key === '+') return; // allow + anywhere; normalisePhoneInput removes extras
|
||||
if (!/^[0-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
class="inline underline cursor-pointer text-xs hover:text-amber-700"
|
||||
class="inline cursor-pointer text-xs underline hover:text-amber-700"
|
||||
>
|
||||
{#if trigger}
|
||||
{@render trigger()}
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="absolute left-0 top-full z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
|
||||
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
|
||||
>
|
||||
<a
|
||||
href="/cancellation-policy"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export const POLICY = {
|
||||
FULL_REFUND_THRESHOLD_HOURS: 72,
|
||||
PARTIAL_REFUND_THRESHOLD_HOURS: 24,
|
||||
NO_SHOW_THRESHOLD_HOURS: 24,
|
||||
DEPOSIT_ADVANCE_HOURS: 36,
|
||||
LOYALTY_STAMP_REDEMPTION_COST: 10,
|
||||
RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS: 72,
|
||||
RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS: 24,
|
||||
PROTECTED_DEPOSIT_MAX_PCT: 0.50,
|
||||
REQUIRED_DEPOSIT_PCT: 0.20,
|
||||
FULL_REFUND_THRESHOLD_HOURS: 72,
|
||||
PARTIAL_REFUND_THRESHOLD_HOURS: 24,
|
||||
NO_SHOW_THRESHOLD_HOURS: 24,
|
||||
DEPOSIT_ADVANCE_HOURS: 36,
|
||||
LOYALTY_STAMP_REDEMPTION_COST: 10,
|
||||
RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS: 72,
|
||||
RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS: 24,
|
||||
PROTECTED_DEPOSIT_MAX_PCT: 0.5,
|
||||
REQUIRED_DEPOSIT_PCT: 0.2
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getContext } from "svelte";
|
||||
import MapLibreGL from "maplibre-gl";
|
||||
import { getContext } from 'svelte';
|
||||
import MapLibreGL from 'maplibre-gl';
|
||||
|
||||
type MapContext = {
|
||||
getMap: () => MapLibreGL.Map | null;
|
||||
@@ -8,7 +8,7 @@ type MapContext = {
|
||||
};
|
||||
|
||||
export function useMap() {
|
||||
const mapCtx = getContext<MapContext>("map");
|
||||
const mapCtx = getContext<MapContext>('map');
|
||||
|
||||
const map = $derived.by(() => mapCtx?.getMap() ?? null);
|
||||
const isLoaded = $derived.by(() => mapCtx?.isLoaded() ?? false);
|
||||
@@ -23,6 +23,6 @@ export function useMap() {
|
||||
},
|
||||
get isStyleReady() {
|
||||
return isStyleReady;
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,51 +1,57 @@
|
||||
export type SavedCard = {
|
||||
id: string;
|
||||
brand: string;
|
||||
last_4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
cardholder_name?: string;
|
||||
is_default: boolean;
|
||||
id: string;
|
||||
brand: string;
|
||||
last_4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
cardholder_name?: string;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
function createSavedCardsStore() {
|
||||
let cards = $state<SavedCard[]>([]);
|
||||
let loading = $state(false);
|
||||
let loaded = $state(false);
|
||||
let cards = $state<SavedCard[]>([]);
|
||||
let loading = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
async function fetch() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = globalThis.localStorage?.getItem('authToken');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await globalThis.fetch('/api/user/payment-methods', { headers });
|
||||
if (res.ok) {
|
||||
cards = await res.json();
|
||||
} else {
|
||||
cards = [];
|
||||
}
|
||||
loaded = true;
|
||||
} catch {
|
||||
cards = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function fetch() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = globalThis.localStorage?.getItem('authToken');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await globalThis.fetch('/api/user/payment-methods', { headers });
|
||||
if (res.ok) {
|
||||
cards = await res.json();
|
||||
} else {
|
||||
cards = [];
|
||||
}
|
||||
loaded = true;
|
||||
} catch {
|
||||
cards = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
loaded = false;
|
||||
return fetch();
|
||||
}
|
||||
function invalidate() {
|
||||
loaded = false;
|
||||
return fetch();
|
||||
}
|
||||
|
||||
return {
|
||||
get cards() { return cards; },
|
||||
get loading() { return loading; },
|
||||
get loaded() { return loaded; },
|
||||
fetch,
|
||||
invalidate,
|
||||
};
|
||||
return {
|
||||
get cards() {
|
||||
return cards;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
},
|
||||
fetch,
|
||||
invalidate
|
||||
};
|
||||
}
|
||||
|
||||
export const savedCardsStore = createSavedCardsStore();
|
||||
export const savedCardsStore = createSavedCardsStore();
|
||||
|
||||
@@ -94,8 +94,14 @@ export function calculateAge(dateOfBirth: string | undefined | null): number | n
|
||||
const today = new Date();
|
||||
|
||||
// Get London date components to ensure correct DST-safe age calculation
|
||||
const [dobY, dobM, dobD] = dob.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }).split('-').map(Number);
|
||||
const [todayY, todayM, todayD] = today.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }).split('-').map(Number);
|
||||
const [dobY, dobM, dobD] = dob
|
||||
.toLocaleDateString('en-CA', { timeZone: 'Europe/London' })
|
||||
.split('-')
|
||||
.map(Number);
|
||||
const [todayY, todayM, todayD] = today
|
||||
.toLocaleDateString('en-CA', { timeZone: 'Europe/London' })
|
||||
.split('-')
|
||||
.map(Number);
|
||||
|
||||
let age = todayY - dobY;
|
||||
const monthDiff = todayM - dobM;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Strips formatting characters from a phone input, preserving digits and leading +.
|
||||
*/
|
||||
export function normalisePhoneInput(value: string): string {
|
||||
return value.replace(/[^\d+]/g, '').replace(/(?!^)\+/g, '');
|
||||
return value.replace(/[^\d+]/g, '').replace(/(?!^)\+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11,8 +11,8 @@ export function normalisePhoneInput(value: string): string {
|
||||
* Returns true if valid.
|
||||
*/
|
||||
export function isValidUKPhone(phone: string): boolean {
|
||||
const clean = normalisePhoneInput(phone);
|
||||
return /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/.test(clean);
|
||||
const clean = normalisePhoneInput(phone);
|
||||
return /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/.test(clean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,20 +20,20 @@ export function isValidUKPhone(phone: string): boolean {
|
||||
* e.g. 07700900000 → 07700 900000, +447700900000 → +44 7700 900000
|
||||
*/
|
||||
export function formatPhoneDisplay(value: string): string {
|
||||
const clean = normalisePhoneInput(value);
|
||||
if (clean.startsWith('+44')) {
|
||||
const digits = clean.slice(3).slice(0, 10); // max 10 digits after +44
|
||||
if (digits.length <= 4) return '+44 ' + digits;
|
||||
if (digits.length <= 8) return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4);
|
||||
return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
|
||||
}
|
||||
if (clean.startsWith('0')) {
|
||||
const digits = clean.slice(1).slice(0, 10); // max 10 digits after 0
|
||||
if (digits.length <= 4) return '0' + digits;
|
||||
if (digits.length <= 7) return '0' + digits.slice(0, 4) + ' ' + digits.slice(4);
|
||||
return '0' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
|
||||
}
|
||||
return clean.slice(0, 15); // fallback limit
|
||||
const clean = normalisePhoneInput(value);
|
||||
if (clean.startsWith('+44')) {
|
||||
const digits = clean.slice(3).slice(0, 10); // max 10 digits after +44
|
||||
if (digits.length <= 4) return '+44 ' + digits;
|
||||
if (digits.length <= 8) return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4);
|
||||
return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
|
||||
}
|
||||
if (clean.startsWith('0')) {
|
||||
const digits = clean.slice(1).slice(0, 10); // max 10 digits after 0
|
||||
if (digits.length <= 4) return '0' + digits;
|
||||
if (digits.length <= 7) return '0' + digits.slice(0, 4) + ' ' + digits.slice(4);
|
||||
return '0' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
|
||||
}
|
||||
return clean.slice(0, 15); // fallback limit
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,8 +41,8 @@ export function formatPhoneDisplay(value: string): string {
|
||||
* or null if the input is not a valid UK number.
|
||||
*/
|
||||
export function toE164UK(phone: string): string | null {
|
||||
const clean = normalisePhoneInput(phone);
|
||||
if (!isValidUKPhone(clean)) return null;
|
||||
if (clean.startsWith('0')) return '+44' + clean.slice(1);
|
||||
return clean;
|
||||
}
|
||||
const clean = normalisePhoneInput(phone);
|
||||
if (!isValidUKPhone(clean)) return null;
|
||||
if (clean.startsWith('0')) return '+44' + clean.slice(1);
|
||||
return clean;
|
||||
}
|
||||
|
||||
@@ -32,14 +32,14 @@ export function formatLocalDateTime(date: Date): string {
|
||||
* "2026-06-15T09:00:00+00:00") from the backend and returns a Date
|
||||
* whose getHours()/getMinutes() in the browser's local timezone
|
||||
* reflect the local wall-clock time.
|
||||
*
|
||||
*
|
||||
* The backend stores all times as UTC wall-clock values. This function
|
||||
* uses the standard Date parser which correctly interprets the ISO
|
||||
* string as UTC and applies the browser timezone for get*() accessors.
|
||||
* Example: "2026-06-15T09:00:00Z" → getHours() returns 10 in BST.
|
||||
*/
|
||||
export function parseWallClockDate(iso: string): Date {
|
||||
return new Date(iso);
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +47,8 @@ export function parseWallClockDate(iso: string): Date {
|
||||
* Always shows the wall-clock time, NOT shifted by timezone.
|
||||
*/
|
||||
export function formatWallClockTime(iso: string): string {
|
||||
const d = parseWallClockDate(iso);
|
||||
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const d = parseWallClockDate(iso);
|
||||
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,8 +56,13 @@ export function formatWallClockTime(iso: string): string {
|
||||
* Always shows the wall-clock date, NOT shifted by timezone.
|
||||
*/
|
||||
export function formatWallClockDate(iso: string): string {
|
||||
const d = parseWallClockDate(iso);
|
||||
return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const d = parseWallClockDate(iso);
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,9 +72,9 @@ export function formatWallClockDate(iso: string): string {
|
||||
* London date. Avoids using browser-local time which can be wrong in that window.
|
||||
*/
|
||||
export function getLondonTodayCalendarDate(): CalendarDate {
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
return new CalendarDate(y, m, d);
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
return new CalendarDate(y, m, d);
|
||||
}
|
||||
|
||||
export interface TimeSlotGroup {
|
||||
@@ -195,7 +200,12 @@ export function generateAvailableTimeSlots(
|
||||
|
||||
if (isToday) {
|
||||
const now = new Date();
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
||||
const currentMinutes = londonHours * 60 + londonMinutes;
|
||||
let minimumStart = Math.ceil((currentMinutes + 60) / 15) * 15;
|
||||
@@ -240,7 +250,12 @@ export function generateGroupedTimeSlots(
|
||||
const todayCal = getLondonTodayCalendarDate();
|
||||
if (date.compare(todayCal) === 0) {
|
||||
const now = new Date();
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
||||
const currentMinutes = londonHours * 60 + londonMinutes;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, Math.ceil((currentMinutes + 15) / 15) * 15);
|
||||
|
||||
@@ -826,8 +826,8 @@
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
const newFirstName = editingFirstName ? firstNameInput.trim() : (userData?.firstName || '');
|
||||
const newLastName = editingLastName ? lastNameInput.trim() : (userData?.lastName || '');
|
||||
const newFirstName = editingFirstName ? firstNameInput.trim() : userData?.firstName || '';
|
||||
const newLastName = editingLastName ? lastNameInput.trim() : userData?.lastName || '';
|
||||
|
||||
// Validate
|
||||
if (!newFirstName || !newLastName) {
|
||||
@@ -1554,78 +1554,114 @@
|
||||
{/each}
|
||||
{:else if userData}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">First Name</span>
|
||||
{#if editingFirstName}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="first-name"
|
||||
bind:value={firstNameInput}
|
||||
placeholder="Enter first name"
|
||||
maxlength={50}
|
||||
error={firstNameError}
|
||||
/>
|
||||
{#if firstNameError}
|
||||
<p class="text-xs text-red-500">{firstNameError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={saveName} disabled={savingName || !firstNameInput.trim()}>
|
||||
{savingName ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={cancelEditName} disabled={savingName}>
|
||||
Cancel
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">First Name</span>
|
||||
{#if editingFirstName}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="first-name"
|
||||
bind:value={firstNameInput}
|
||||
placeholder="Enter first name"
|
||||
maxlength={50}
|
||||
error={firstNameError}
|
||||
/>
|
||||
{#if firstNameError}
|
||||
<p class="text-xs text-red-500">{firstNameError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={saveName}
|
||||
disabled={savingName || !firstNameInput.trim()}
|
||||
>
|
||||
{savingName ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={cancelEditName}
|
||||
disabled={savingName}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
|
||||
>
|
||||
<span>{userData.firstName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
<span>{userData.firstName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">Last Name</span>
|
||||
{#if editingLastName}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="last-name"
|
||||
bind:value={lastNameInput}
|
||||
placeholder="Enter last name"
|
||||
maxlength={50}
|
||||
error={lastNameError}
|
||||
/>
|
||||
{#if lastNameError}
|
||||
<p class="text-xs text-red-500">{lastNameError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={saveName} disabled={savingName || !lastNameInput.trim()}>
|
||||
{savingName ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={cancelEditName} disabled={savingName}>
|
||||
Cancel
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">Last Name</span>
|
||||
{#if editingLastName}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="last-name"
|
||||
bind:value={lastNameInput}
|
||||
placeholder="Enter last name"
|
||||
maxlength={50}
|
||||
error={lastNameError}
|
||||
/>
|
||||
{#if lastNameError}
|
||||
<p class="text-xs text-red-500">{lastNameError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={saveName}
|
||||
disabled={savingName || !lastNameInput.trim()}
|
||||
>
|
||||
{savingName ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={cancelEditName}
|
||||
disabled={savingName}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
|
||||
>
|
||||
<span>{userData.lastName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditLastName}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
<span>{userData.lastName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditLastName}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">Email</span>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
@@ -1643,7 +1679,11 @@
|
||||
placeholder="Enter phone number"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={savePhone} disabled={savingPhone || !isValidUKPhone(phoneInput)}>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={savePhone}
|
||||
disabled={savingPhone || !isValidUKPhone(phoneInput)}
|
||||
>
|
||||
{savingPhone ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1693,16 +1733,17 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="rounded-xl border-2 border-fuchsia-200 bg-fuchsia-50 p-5 sm:p-6"
|
||||
>
|
||||
<div class="rounded-xl border-2 border-fuchsia-200 bg-fuchsia-50 p-5 sm:p-6">
|
||||
{#if userData}
|
||||
<div class="mb-5 text-center sm:text-left">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Loyalty Stamp Card</h3>
|
||||
<p class="mt-0.5 text-xs text-gray-500">
|
||||
{stamps < 10
|
||||
? `Collect ${10 - stamps} more stamp${10 - stamps === 1 ? '' : 's'} to get 10% off your next booking.`
|
||||
: (() => { const fc = Math.floor(stamps / 10); return `You have ${fc === 1 ? 'a' : fc} full stampcard${fc === 1 ? '' : 's'} ready to take advantage of at your next booking!`; })()}
|
||||
: (() => {
|
||||
const fc = Math.floor(stamps / 10);
|
||||
return `You have ${fc === 1 ? 'a' : fc} full stampcard${fc === 1 ? '' : 's'} ready to take advantage of at your next booking!`;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1710,7 +1751,7 @@
|
||||
{#each Array(10) as _, i}
|
||||
{@const slotNum = i + 1}
|
||||
{@const rot = ((slotNum * 37 + 13) % 7) - 3}
|
||||
{#if slotNum <= (stamps > 0 ? (stamps % 10 || 10) : 0)}
|
||||
{#if slotNum <= (stamps > 0 ? stamps % 10 || 10 : 0)}
|
||||
<div
|
||||
class="aspect-square transition-transform duration-200 hover:scale-110"
|
||||
>
|
||||
@@ -1947,17 +1988,15 @@
|
||||
</div>
|
||||
<div class="text-sm">Friends Referred</div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4 text-center">
|
||||
<div class="text-3xl font-bold">
|
||||
£{(userData.referralSavings || 0).toFixed(2)}
|
||||
<div class="rounded-lg border p-4 text-center">
|
||||
<div class="text-3xl font-bold">
|
||||
£{(userData.referralSavings || 0).toFixed(2)}
|
||||
</div>
|
||||
<div class="text-sm">Total Saved</div>
|
||||
</div>
|
||||
<div class="text-sm">Total Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Root
|
||||
class="mt-6 border-amber-200/60 bg-amber-50"
|
||||
>
|
||||
<Card.Root class="mt-6 border-amber-200/60 bg-amber-50">
|
||||
<Card.Content class="pt-4 md:pt-6">
|
||||
<div class="space-y-2 text-sm text-amber-900">
|
||||
<h4 class="font-semibold text-amber-800">How it works:</h4>
|
||||
@@ -2163,13 +2202,13 @@
|
||||
oninput={handleGiftCardInput}
|
||||
class="font-mono"
|
||||
/>
|
||||
<Button
|
||||
onclick={() => (showRedeemConfirm = true)}
|
||||
disabled={redeemingGiftCard ||
|
||||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
|
||||
>
|
||||
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => (showRedeemConfirm = true)}
|
||||
disabled={redeemingGiftCard ||
|
||||
giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
|
||||
>
|
||||
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
@@ -2180,42 +2219,40 @@
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Redeem Gift Card</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Claiming this gift card will add its remaining balance directly to your
|
||||
account balance, which can be used toward future bookings.
|
||||
Claiming this gift card will add its remaining balance directly to your account
|
||||
balance, which can be used toward future bookings.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<div class="px-6 py-4 space-y-3 text-sm text-muted-foreground">
|
||||
<div class="rounded-lg border bg-amber-50/50 p-3 space-y-2">
|
||||
<div class="space-y-3 px-6 py-4 text-sm text-muted-foreground">
|
||||
<div class="space-y-2 rounded-lg border bg-amber-50/50 p-3">
|
||||
<p>
|
||||
<strong class="text-foreground">What happens when I claim?</strong>
|
||||
</p>
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<ul class="list-disc space-y-1 pl-4">
|
||||
<li>The gift card value is added to your account balance.</li>
|
||||
<li>
|
||||
Account balances do not expire, but gift card codes become invalid once
|
||||
redeemed.
|
||||
</li>
|
||||
<li>
|
||||
This action is final and cannot be reversed.
|
||||
</li>
|
||||
<li>This action is final and cannot be reversed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-blue-50/50 p-3 space-y-2">
|
||||
<div class="space-y-2 rounded-lg border bg-blue-50/50 p-3">
|
||||
<p>
|
||||
<strong class="text-foreground">Legal & GDPR Information</strong>
|
||||
</p>
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<ul class="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
Your personal data (name, email, transaction history) is processed in
|
||||
accordance with UK data protection law.
|
||||
</li>
|
||||
<li>
|
||||
Financial records are retained for 7 years as required by HMRC, after
|
||||
which personally identifiable information is anonymised.
|
||||
Financial records are retained for 7 years as required by HMRC, after which
|
||||
personally identifiable information is anonymised.
|
||||
</li>
|
||||
<li>
|
||||
You can request a full copy of your data or deletion of your account
|
||||
at any time via your account settings.
|
||||
You can request a full copy of your data or deletion of your account at any
|
||||
time via your account settings.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -2348,17 +2385,17 @@
|
||||
</div>
|
||||
|
||||
{#if buyRecipientType === 'friend'}
|
||||
<div class="space-y-2">
|
||||
<label for="recipient-email" class="text-sm font-medium text-gray-700"
|
||||
>Friend's Email (Optional)</label
|
||||
>
|
||||
<EmailInput
|
||||
id="recipient-email"
|
||||
bind:value={buyRecipientEmail}
|
||||
placeholder="friend@example.com (blank to send to yourself)"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="recipient-email" class="text-sm font-medium text-gray-700"
|
||||
>Friend's Email (Optional)</label
|
||||
>
|
||||
<EmailInput
|
||||
id="recipient-email"
|
||||
bind:value={buyRecipientEmail}
|
||||
placeholder="friend@example.com (blank to send to yourself)"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3 border-t pt-2">
|
||||
@@ -2665,7 +2702,22 @@
|
||||
<PolicyPopover>
|
||||
{#snippet trigger()}
|
||||
<Button variant="outline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/><polyline points="14 2 14 8 20 8" /><line
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="8"
|
||||
y2="13"
|
||||
/><line x1="16" y1="17" x2="8" y2="17" /></svg
|
||||
>
|
||||
Cancellation & Deposit Policy
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -71,14 +71,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
try {
|
||||
var token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.replace('/login'); return; }
|
||||
if (!token) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
|
||||
if (payload.role !== 'admin') { window.location.replace('/'); }
|
||||
} catch(e) { window.location.replace('/login'); }
|
||||
if (payload.exp * 1000 <= Date.now()) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (payload.role !== 'admin') {
|
||||
window.location.replace('/');
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</svelte:head>
|
||||
|
||||
@@ -331,7 +331,10 @@
|
||||
function getNotificationSubtitle(n: Notification): string {
|
||||
const parts = [formatRelative(n.created_at)];
|
||||
if (n.user_name) {
|
||||
parts.push(n.user_name); {/* TODO: add formerly name when previous name data is available */}
|
||||
parts.push(n.user_name);
|
||||
{
|
||||
/* TODO: add formerly name when previous name data is available */
|
||||
}
|
||||
}
|
||||
return parts.join(' — ');
|
||||
}
|
||||
@@ -347,14 +350,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
try {
|
||||
var token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.replace('/login'); return; }
|
||||
if (!token) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
|
||||
if (payload.role !== 'admin') { window.location.replace('/'); }
|
||||
} catch(e) { window.location.replace('/login'); }
|
||||
if (payload.exp * 1000 <= Date.now()) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (payload.role !== 'admin') {
|
||||
window.location.replace('/');
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</svelte:head>
|
||||
@@ -509,7 +522,7 @@
|
||||
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking?.id ?? ''} />
|
||||
|
||||
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} openBookingModal={openBookingModal} />
|
||||
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
|
||||
|
||||
{#if showEditRequestModal && selectedEditRequest}
|
||||
<EditRequestModal
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
start_time: string;
|
||||
status: string;
|
||||
duration_minutes: number;
|
||||
user?: { full_name: string; previous_first_name?: string | null; previous_last_name?: string | null };
|
||||
user?: {
|
||||
full_name: string;
|
||||
previous_first_name?: string | null;
|
||||
previous_last_name?: string | null;
|
||||
};
|
||||
services: BookingService[];
|
||||
};
|
||||
type TimeBlocker = {
|
||||
@@ -108,7 +112,12 @@
|
||||
* Intl.DateTimeFormat with Europe/London so the current-time blue line is
|
||||
* positioned correctly regardless of the browser's system timezone. */
|
||||
function getLondonNowMinutes(): number {
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const [h, m] = timeStr.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
@@ -365,7 +374,8 @@
|
||||
return { minStart: 8, maxEnd: 18, hours: Array.from({ length: 11 }, (_, i) => i + 8) };
|
||||
|
||||
// Find earliest start and latest end across ALL data (hours, bookings, blockers)
|
||||
let earliest = 24, latest = 0;
|
||||
let earliest = 24,
|
||||
latest = 0;
|
||||
|
||||
for (const day of getWeekDays(weekStart)) {
|
||||
const dateStr = formatDate(day);
|
||||
@@ -378,14 +388,14 @@
|
||||
const ef = eh + (em || 0) / 60;
|
||||
if (ef > latest) latest = ef;
|
||||
}
|
||||
for (const b of (bookingsByDate.get(dateStr) || [])) {
|
||||
for (const b of bookingsByDate.get(dateStr) || []) {
|
||||
const d = new SvelteDate(b.start_time);
|
||||
const sf = d.getHours() + d.getMinutes() / 60;
|
||||
if (sf < earliest) earliest = sf;
|
||||
const ef = sf + b.duration_minutes / 60;
|
||||
if (ef > latest) latest = ef;
|
||||
}
|
||||
for (const b of (blockersByDate.get(dateStr) || [])) {
|
||||
for (const b of blockersByDate.get(dateStr) || []) {
|
||||
const d = new SvelteDate(b.start_time);
|
||||
const sf = d.getHours() + d.getMinutes() / 60;
|
||||
if (sf < earliest) earliest = sf;
|
||||
@@ -395,15 +405,16 @@
|
||||
}
|
||||
|
||||
// No data: fall back to 8-18
|
||||
if (earliest >= 24 || latest <= 0) return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
|
||||
if (earliest >= 24 || latest <= 0)
|
||||
return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
|
||||
|
||||
// Start: 30min buffer before earliest (to show the pre-booking gap)
|
||||
// End: floor of latest (the row that CONTAINS the latest item, not the next row)
|
||||
let minStart = Math.floor(earliest - 0.5);
|
||||
let maxEnd = Math.floor(latest);
|
||||
let maxEnd = Math.floor(latest);
|
||||
|
||||
minStart = Math.max(0, minStart);
|
||||
maxEnd = Math.min(24, maxEnd);
|
||||
maxEnd = Math.min(24, maxEnd);
|
||||
|
||||
const hours: number[] = [];
|
||||
for (let h = minStart; h <= maxEnd; h++) hours.push(h);
|
||||
@@ -432,14 +443,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
try {
|
||||
var token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.replace('/login'); return; }
|
||||
if (!token) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
|
||||
if (payload.role !== 'admin') { window.location.replace('/'); }
|
||||
} catch(e) { window.location.replace('/login'); }
|
||||
if (payload.exp * 1000 <= Date.now()) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (payload.role !== 'admin') {
|
||||
window.location.replace('/');
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</svelte:head>
|
||||
@@ -612,7 +633,11 @@
|
||||
{#if !hasOverlap}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View booking for {formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}"
|
||||
aria-label="View booking for {formatUserName(
|
||||
b.user?.full_name || 'Guest',
|
||||
b.user?.previous_first_name,
|
||||
b.user?.previous_last_name
|
||||
)}"
|
||||
class="absolute inset-x-0.5 z-10 cursor-pointer overflow-hidden rounded border px-1.5 py-0.5 text-left text-xs transition-shadow hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500"
|
||||
style="top: {topOffset}px; height: {heightPx}px; {bookingStyle(
|
||||
b.status
|
||||
@@ -628,7 +653,11 @@
|
||||
style="background:{dotColor(b.status)}"
|
||||
></div>
|
||||
<span class="truncate font-medium"
|
||||
>{formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}</span
|
||||
>{formatUserName(
|
||||
b.user?.full_name || 'Guest',
|
||||
b.user?.previous_first_name,
|
||||
b.user?.previous_last_name
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
{#if heightPx > 36}
|
||||
|
||||
+189
-196
@@ -28,12 +28,12 @@ Decompose `+page.svelte` into focused, testable components with clear data flow
|
||||
|
||||
**Single source of truth:** `BookingFlow.svelte`
|
||||
|
||||
* currentStep
|
||||
* selectedServices
|
||||
* selectedDate
|
||||
* selectedTime
|
||||
* customerInfo
|
||||
* pricing / totals
|
||||
- currentStep
|
||||
- selectedServices
|
||||
- selectedDate
|
||||
- selectedTime
|
||||
- customerInfo
|
||||
- pricing / totals
|
||||
|
||||
Everything else receives props + dispatches events. No hidden global coupling. 🧠
|
||||
|
||||
@@ -45,14 +45,14 @@ Everything else receives props + dispatches events. No hidden global coupling.
|
||||
|
||||
**Role:** Orchestrator
|
||||
|
||||
* Holds booking state
|
||||
* Validates transitions between steps
|
||||
* Calls API + handles toasts
|
||||
- Holds booking state
|
||||
- Validates transitions between steps
|
||||
- Calls API + handles toasts
|
||||
|
||||
**Props:** none
|
||||
**Emits:** none
|
||||
|
||||
This is the only component allowed to know *everything*.
|
||||
This is the only component allowed to know _everything_.
|
||||
|
||||
---
|
||||
|
||||
@@ -63,8 +63,8 @@ This is the only component allowed to know *everything*.
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
```
|
||||
|
||||
Pure UI. Zero logic.
|
||||
@@ -85,8 +85,8 @@ selected: Service[]
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
select(service)
|
||||
deselect(service)
|
||||
select(service);
|
||||
deselect(service);
|
||||
```
|
||||
|
||||
Internally renders multiple `ServiceCard`s.
|
||||
@@ -100,8 +100,8 @@ Internally renders multiple `ServiceCard`s.
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
service: Service
|
||||
selected: boolean
|
||||
service: Service;
|
||||
selected: boolean;
|
||||
```
|
||||
|
||||
No awareness of booking steps or pricing totals.
|
||||
@@ -117,13 +117,13 @@ Wraps your existing `Calendar` usage.
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
date: CalendarDate | undefined
|
||||
date: CalendarDate | undefined;
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
change(date)
|
||||
change(date);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -143,7 +143,7 @@ availability: TimeSlot[]
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
select(time)
|
||||
select(time);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -155,13 +155,13 @@ select(time)
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
value: CustomerInfo
|
||||
value: CustomerInfo;
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
update(value)
|
||||
update(value);
|
||||
```
|
||||
|
||||
No submit button here. Forms shouldn’t decide flow control.
|
||||
@@ -193,17 +193,17 @@ Zero mutation. Snapshot only.
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
canBack: boolean
|
||||
canNext: boolean
|
||||
isSubmitting: boolean
|
||||
canBack: boolean;
|
||||
canNext: boolean;
|
||||
isSubmitting: boolean;
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
back()
|
||||
next()
|
||||
submit()
|
||||
back();
|
||||
next();
|
||||
submit();
|
||||
```
|
||||
|
||||
---
|
||||
@@ -234,10 +234,10 @@ Use only if the flow must persist across routes or reloads. Otherwise keep it lo
|
||||
|
||||
## Result
|
||||
|
||||
* Smaller files
|
||||
* Predictable data flow
|
||||
* Each component explainable in one sentence
|
||||
* Easier testing and future changes
|
||||
- Smaller files
|
||||
- Predictable data flow
|
||||
- Each component explainable in one sentence
|
||||
- Easier testing and future changes
|
||||
|
||||
Entropy reduced. ✂️🧩
|
||||
|
||||
@@ -249,64 +249,62 @@ We start by **wrapping the existing logic**, not rewriting it.
|
||||
|
||||
### 1. Create `BookingFlow.svelte`
|
||||
|
||||
Move *all* booking‑related state and logic out of `+page.svelte` into this file.
|
||||
Move _all_ booking‑related state and logic out of `+page.svelte` into this file.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import StepIndicator from './StepIndicator.svelte';
|
||||
import ServiceSelector from './ServiceSelector.svelte';
|
||||
import DatePicker from './DatePicker.svelte';
|
||||
import TimeSlotPicker from './TimeSlotPicker.svelte';
|
||||
import CustomerDetailsForm from './CustomerDetailsForm.svelte';
|
||||
import BookingSummary from './BookingSummary.svelte';
|
||||
import BookingActions from './BookingActions.svelte';
|
||||
import StepIndicator from './StepIndicator.svelte';
|
||||
import ServiceSelector from './ServiceSelector.svelte';
|
||||
import DatePicker from './DatePicker.svelte';
|
||||
import TimeSlotPicker from './TimeSlotPicker.svelte';
|
||||
import CustomerDetailsForm from './CustomerDetailsForm.svelte';
|
||||
import BookingSummary from './BookingSummary.svelte';
|
||||
import BookingActions from './BookingActions.svelte';
|
||||
|
||||
import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';
|
||||
import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';
|
||||
|
||||
// --- State (copied verbatim from +page.svelte) ---
|
||||
let currentStep = 1;
|
||||
let selectedServices: Service[] = [];
|
||||
let selectedDate;
|
||||
let selectedTime: string | null = null;
|
||||
let customerInfo: CustomerInfo = { /* unchanged */ };
|
||||
// --- State (copied verbatim from +page.svelte) ---
|
||||
let currentStep = 1;
|
||||
let selectedServices: Service[] = [];
|
||||
let selectedDate;
|
||||
let selectedTime: string | null = null;
|
||||
let customerInfo: CustomerInfo = {
|
||||
/* unchanged */
|
||||
};
|
||||
|
||||
// pricing, derived values, API calls stay here
|
||||
// pricing, derived values, API calls stay here
|
||||
</script>
|
||||
|
||||
<StepIndicator {currentStep} totalSteps={4} />
|
||||
|
||||
{#if currentStep === 1}
|
||||
<ServiceSelector
|
||||
services={services}
|
||||
selected={selectedServices}
|
||||
on:select={(e) => selectedServices.push(e.detail)}
|
||||
on:deselect={(e) => selectedServices = selectedServices.filter(s => s.id !== e.detail.id)}
|
||||
/>
|
||||
<ServiceSelector
|
||||
{services}
|
||||
selected={selectedServices}
|
||||
on:select={(e) => selectedServices.push(e.detail)}
|
||||
on:deselect={(e) => (selectedServices = selectedServices.filter((s) => s.id !== e.detail.id))}
|
||||
/>
|
||||
{:else if currentStep === 2}
|
||||
<DatePicker bind:date={selectedDate} />
|
||||
<TimeSlotPicker
|
||||
date={selectedDate}
|
||||
availability={availability}
|
||||
bind:selectedTime
|
||||
/>
|
||||
<DatePicker bind:date={selectedDate} />
|
||||
<TimeSlotPicker date={selectedDate} {availability} bind:selectedTime />
|
||||
{:else if currentStep === 3}
|
||||
<CustomerDetailsForm bind:value={customerInfo} />
|
||||
<CustomerDetailsForm bind:value={customerInfo} />
|
||||
{:else if currentStep === 4}
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={customerInfo}
|
||||
total={total}
|
||||
/>
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={customerInfo}
|
||||
{total}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<BookingActions
|
||||
canBack={currentStep > 1}
|
||||
canNext={currentStep < 4}
|
||||
on:back={() => currentStep--}
|
||||
on:next={() => currentStep++}
|
||||
on:submit={submitBooking}
|
||||
canBack={currentStep > 1}
|
||||
canNext={currentStep < 4}
|
||||
on:back={() => currentStep--}
|
||||
on:next={() => currentStep++}
|
||||
on:submit={submitBooking}
|
||||
/>
|
||||
```
|
||||
|
||||
@@ -318,7 +316,7 @@ Nothing clever yet. This is a **containerization step**, not a refactor.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
|
||||
import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
|
||||
</script>
|
||||
|
||||
<BookingFlow />
|
||||
@@ -326,9 +324,9 @@ Nothing clever yet. This is a **containerization step**, not a refactor.
|
||||
|
||||
At this point:
|
||||
|
||||
* Behaviour is identical
|
||||
* No store introduced
|
||||
* You have a single, explicit "brain"
|
||||
- Behaviour is identical
|
||||
- No store introduced
|
||||
- You have a single, explicit "brain"
|
||||
|
||||
---
|
||||
|
||||
@@ -342,11 +340,11 @@ This is the safest cut: **pure UI, minimal state, zero flow control**.
|
||||
|
||||
In `ServiceSelector`, find the repeated markup that:
|
||||
|
||||
* Displays service name / price / duration
|
||||
* Highlights selected state
|
||||
* Handles click / toggle
|
||||
- Displays service name / price / duration
|
||||
- Highlights selected state
|
||||
- Handles click / toggle
|
||||
|
||||
If it *renders one service*, it becomes a card.
|
||||
If it _renders one service_, it becomes a card.
|
||||
|
||||
---
|
||||
|
||||
@@ -354,31 +352,32 @@ If it *renders one service*, it becomes a card.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Service } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Service } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let service: Service;
|
||||
export let selected = false;
|
||||
export let service: Service;
|
||||
export let selected = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function toggle() {
|
||||
dispatch(selected ? 'deselect' : 'select', service);
|
||||
}
|
||||
function toggle() {
|
||||
dispatch(selected ? 'deselect' : 'select', service);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class:selected
|
||||
on:click={toggle}
|
||||
>
|
||||
<h3>{service.name}</h3>
|
||||
<p>{service.duration} min</p>
|
||||
<p>£{service.price}</p>
|
||||
<button class:selected on:click={toggle}>
|
||||
<h3>{service.name}</h3>
|
||||
<p>{service.duration} min</p>
|
||||
<p>£{service.price}</p>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button { /* existing styles */ }
|
||||
.selected { /* existing selected styles */ }
|
||||
button {
|
||||
/* existing styles */
|
||||
}
|
||||
.selected {
|
||||
/* existing selected styles */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
@@ -390,26 +389,26 @@ No booking logic. No totals. No step awareness.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import ServiceCard from './ServiceCard.svelte';
|
||||
import type { Service } from '$lib/types/booking';
|
||||
import ServiceCard from './ServiceCard.svelte';
|
||||
import type { Service } from '$lib/types/booking';
|
||||
|
||||
export let services: Service[] = [];
|
||||
export let selected: Service[] = [];
|
||||
export let services: Service[] = [];
|
||||
export let selected: Service[] = [];
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
{#each services as service (service.id)}
|
||||
<ServiceCard
|
||||
{service}
|
||||
selected={selected.some(s => s.id === service.id)}
|
||||
on:select
|
||||
on:deselect
|
||||
/>
|
||||
{/each}
|
||||
{#each services as service (service.id)}
|
||||
<ServiceCard
|
||||
{service}
|
||||
selected={selected.some((s) => s.id === service.id)}
|
||||
on:select
|
||||
on:deselect
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
```
|
||||
|
||||
Selection state still lives *above*. This is critical.
|
||||
Selection state still lives _above_. This is critical.
|
||||
|
||||
---
|
||||
|
||||
@@ -417,9 +416,9 @@ Selection state still lives *above*. This is critical.
|
||||
|
||||
At this point:
|
||||
|
||||
* `ServiceCard` is dumb
|
||||
* `ServiceSelector` coordinates cards
|
||||
* `BookingFlow` owns truth
|
||||
- `ServiceCard` is dumb
|
||||
- `ServiceSelector` coordinates cards
|
||||
- `BookingFlow` owns truth
|
||||
|
||||
If this feels boring, good. Boring code is stable code.
|
||||
|
||||
@@ -439,11 +438,11 @@ The rule here is strict:
|
||||
|
||||
In `BookingFlow`, locate:
|
||||
|
||||
* Name / email / phone inputs
|
||||
* Validation messages
|
||||
* `on:input` handlers
|
||||
- Name / email / phone inputs
|
||||
- Validation messages
|
||||
- `on:input` handlers
|
||||
|
||||
Anything that mutates `customerInfo` belongs in the form *except* submission.
|
||||
Anything that mutates `customerInfo` belongs in the form _except_ submission.
|
||||
|
||||
---
|
||||
|
||||
@@ -451,39 +450,39 @@ Anything that mutates `customerInfo` belongs in the form *except* submission.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { CustomerInfo } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { CustomerInfo } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let value: CustomerInfo;
|
||||
export let value: CustomerInfo;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
|
||||
dispatch('update', { ...value, [key]: val });
|
||||
}
|
||||
function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
|
||||
dispatch('update', { ...value, [key]: val });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={value.name}
|
||||
on:input={(e) => update('name', e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={value.name}
|
||||
on:input={(e) => update('name', e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={value.email}
|
||||
on:input={(e) => update('email', e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={value.email}
|
||||
on:input={(e) => update('email', e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Phone"
|
||||
value={value.phone}
|
||||
on:input={(e) => update('phone', e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Phone"
|
||||
value={value.phone}
|
||||
on:input={(e) => update('phone', e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -496,16 +495,13 @@ No submit button. No step logic. No API calls.
|
||||
Replace inline inputs with:
|
||||
|
||||
```svelte
|
||||
<CustomerDetailsForm
|
||||
value={customerInfo}
|
||||
on:update={(e) => customerInfo = e.detail}
|
||||
/>
|
||||
<CustomerDetailsForm value={customerInfo} on:update={(e) => (customerInfo = e.detail)} />
|
||||
```
|
||||
|
||||
Validation still lives in `BookingFlow`:
|
||||
|
||||
* Can we go to the next step?
|
||||
* Is submit enabled?
|
||||
- Can we go to the next step?
|
||||
- Is submit enabled?
|
||||
|
||||
---
|
||||
|
||||
@@ -513,11 +509,11 @@ Validation still lives in `BookingFlow`:
|
||||
|
||||
You should now observe:
|
||||
|
||||
* The form is reusable
|
||||
* BookingFlow got smaller
|
||||
* Step logic is easier to read
|
||||
- The form is reusable
|
||||
- BookingFlow got smaller
|
||||
- Step logic is easier to read
|
||||
|
||||
If you *can’t* explain where a rule lives in one sentence, it’s in the wrong place.
|
||||
If you _can’t_ explain where a rule lives in one sentence, it’s in the wrong place.
|
||||
|
||||
---
|
||||
|
||||
@@ -533,22 +529,19 @@ Rule of the step:
|
||||
|
||||
### 4.1 Extract `DatePicker.svelte`
|
||||
|
||||
This component selects *only* a date. No availability logic. No time awareness.
|
||||
This component selects _only_ a date. No availability logic. No time awareness.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { CalendarDate } from '@internationalized/date';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { CalendarDate } from '@internationalized/date';
|
||||
|
||||
export let date: CalendarDate | undefined;
|
||||
export let date: CalendarDate | undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<Calendar
|
||||
value={date}
|
||||
on:change={(e) => dispatch('change', e.detail)}
|
||||
/>
|
||||
<Calendar value={date} on:change={(e) => dispatch('change', e.detail)} />
|
||||
```
|
||||
|
||||
It emits intent. That’s it.
|
||||
@@ -557,34 +550,34 @@ It emits intent. That’s it.
|
||||
|
||||
### 4.2 Extract `TimeSlotPicker.svelte`
|
||||
|
||||
Time slots depend on *inputs*, never globals.
|
||||
Time slots depend on _inputs_, never globals.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { TimeSlot } from '$lib/types/booking';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { TimeSlot } from '$lib/types/booking';
|
||||
|
||||
export let date; // required
|
||||
export let availability: TimeSlot[] = [];
|
||||
export let selectedTime: string | null = null;
|
||||
export let date; // required
|
||||
export let availability: TimeSlot[] = [];
|
||||
export let selectedTime: string | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
{#if !date}
|
||||
<p class="text-muted">Select a date first</p>
|
||||
<p class="text-muted">Select a date first</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each availability as slot (slot.time)}
|
||||
<button
|
||||
class:selected={slot.time === selectedTime}
|
||||
disabled={!slot.available}
|
||||
on:click={() => dispatch('select', slot.time)}
|
||||
>
|
||||
{slot.time}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid">
|
||||
{#each availability as slot (slot.time)}
|
||||
<button
|
||||
class:selected={slot.time === selectedTime}
|
||||
disabled={!slot.available}
|
||||
on:click={() => dispatch('select', slot.time)}
|
||||
>
|
||||
{slot.time}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
@@ -598,27 +591,27 @@ Here is the **complete and correct wiring**, including guards and reset logic:
|
||||
|
||||
```svelte
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
on:change={(e) => {
|
||||
const newDate = e.detail;
|
||||
selectedDate = newDate;
|
||||
date={selectedDate}
|
||||
on:change={(e) => {
|
||||
const newDate = e.detail;
|
||||
selectedDate = newDate;
|
||||
|
||||
// changing date invalidates time
|
||||
selectedTime = null;
|
||||
// changing date invalidates time
|
||||
selectedTime = null;
|
||||
|
||||
// fetch / recompute availability here
|
||||
loadAvailability(newDate);
|
||||
}}
|
||||
// fetch / recompute availability here
|
||||
loadAvailability(newDate);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TimeSlotPicker
|
||||
date={selectedDate}
|
||||
availability={availability}
|
||||
selectedTime={selectedTime}
|
||||
on:select={(e) => {
|
||||
selectedTime = e.detail;
|
||||
}}
|
||||
date={selectedDate}
|
||||
{availability}
|
||||
{selectedTime}
|
||||
on:select={(e) => {
|
||||
selectedTime = e.detail;
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
All temporal logic lives here. Children stay honest.
|
||||
All temporal logic lives here. Children stay honest.
|
||||
|
||||
@@ -369,7 +369,9 @@
|
||||
<span class="text-gray-600">VAT</span>
|
||||
<span class="font-medium">{formatPence(paymentSummary.total_vat_amount)}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between border-t border-gray-200 pt-1 text-sm font-semibold">
|
||||
<div
|
||||
class="mt-1 flex justify-between border-t border-gray-200 pt-1 text-sm font-semibold"
|
||||
>
|
||||
<span class="text-gray-800">Total paid</span>
|
||||
<span class="text-gray-800">{formatPence(paymentSummary.paid_amount)}</span>
|
||||
</div>
|
||||
|
||||
@@ -60,9 +60,9 @@
|
||||
<p class="mb-3">
|
||||
Where a deposit is required to secure your appointment, you must pay at least
|
||||
<strong>20%</strong> of the total service cost before the 24-hour deadline prior to the
|
||||
appointment. The 20% can be paid in a single payment or accumulated across multiple
|
||||
payments — what matters is the total when the deadline passes. When deposit restrictions
|
||||
are active on your account, appointments must be scheduled at least
|
||||
appointment. The 20% can be paid in a single payment or accumulated across multiple payments
|
||||
— what matters is the total when the deadline passes. When deposit restrictions are active
|
||||
on your account, appointments must be scheduled at least
|
||||
<strong>36 hours in advance</strong>.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
@@ -80,9 +80,9 @@
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
If a required deposit is not paid at least 24 hours before the appointment begins, the
|
||||
booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes
|
||||
vulnerable — if another customer books an overlapping time and pays, your original
|
||||
booking is automatically evicted.
|
||||
booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes vulnerable
|
||||
— if another customer books an overlapping time and pays, your original booking is automatically
|
||||
evicted.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
While in this status, your appointment is <strong>not guaranteed</strong>. The system will
|
||||
@@ -144,9 +144,9 @@
|
||||
days).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The
|
||||
gift card's remaining balance is incremented and is immediately available for use.
|
||||
Expired gift cards are non-refundable.
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The gift
|
||||
card's remaining balance is incremented and is immediately available for use. Expired gift
|
||||
cards are non-refundable.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cash payments</strong>: Credited to your account balance, available for immediate
|
||||
@@ -172,8 +172,8 @@
|
||||
registered users).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The
|
||||
gift card's remaining balance is incremented. Expired gift cards are non-refundable.
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The gift
|
||||
card's remaining balance is incremented. Expired gift cards are non-refundable.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cash payments</strong>: Refunded in person at the salon. Please bring your receipt
|
||||
@@ -192,8 +192,8 @@
|
||||
<p class="mb-3">
|
||||
Failing to attend a confirmed appointment without notifying us in advance constitutes a
|
||||
"No-Show". Cancelling a pending or deposit-lapsed booking within 24 hours does
|
||||
<strong>not</strong> count as a no-show — only confirmed bookings (where the slot was
|
||||
secured with a payment) can incur no-show strikes.
|
||||
<strong>not</strong> count as a no-show — only confirmed bookings (where the slot was secured
|
||||
with a payment) can incur no-show strikes.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
If your account accumulates <strong
|
||||
@@ -206,8 +206,8 @@
|
||||
Each completed booking with a payment reduces the required deposit count by one. Once the
|
||||
count reaches zero, all prior no-show records within the 6-month window are forgiven and
|
||||
your account returns to normal — no upfront deposits required — until a new no-show occurs.
|
||||
The salon can also forgive individual no-shows at management's discretion, which
|
||||
immediately removes them from the count.
|
||||
The salon can also forgive individual no-shows at management's discretion, which immediately
|
||||
removes them from the count.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
<div class="mx-auto grid max-w-[744px] gap-6 px-4 lg:grid-cols-2">
|
||||
<div class="h-[376px]">
|
||||
{#if loading}
|
||||
<div class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6">
|
||||
<div
|
||||
class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6"
|
||||
>
|
||||
<div class="mb-4 flex justify-center">
|
||||
<div class="h-24 w-24 rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
@@ -68,26 +70,39 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="map-card mx-auto h-[376px] w-full max-w-sm rounded-lg border-2 border-gray-200 bg-white overflow-hidden lg:mx-0 lg:max-w-none">
|
||||
<div
|
||||
class="map-card mx-auto h-[376px] w-full max-w-sm overflow-hidden rounded-lg border-2 border-gray-200 bg-white lg:mx-0 lg:max-w-none"
|
||||
>
|
||||
<Map theme="light" center={[-3.476464162450991, 56.0781854944036]} zoom={15}>
|
||||
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>
|
||||
<MarkerContent>
|
||||
<div class="flex items-center justify-center rounded-full bg-primary p-2 text-primary-foreground shadow-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
</MarkerContent>
|
||||
<MarkerPopup>
|
||||
<div class="p-2 text-sm">
|
||||
<p class="font-semibold">41 Pollock Walk</p>
|
||||
<p class="text-muted-foreground">Dunfermline KY12 9DA</p>
|
||||
</div>
|
||||
</MarkerPopup>
|
||||
</MapMarker>
|
||||
<MapControls position="bottom-right" showZoom />
|
||||
</Map>
|
||||
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>
|
||||
<MarkerContent>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full bg-primary p-2 text-primary-foreground shadow-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</MarkerContent>
|
||||
<MarkerPopup>
|
||||
<div class="p-2 text-sm">
|
||||
<p class="font-semibold">41 Pollock Walk</p>
|
||||
<p class="text-muted-foreground">Dunfermline KY12 9DA</p>
|
||||
</div>
|
||||
</MarkerPopup>
|
||||
</MapMarker>
|
||||
<MapControls position="bottom-right" showZoom />
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="space-y-3">
|
||||
{#each recentCheckouts as checkout, i (i)}
|
||||
{#each recentCheckouts as checkout, i (i)}
|
||||
<div class="rounded-lg border p-3">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="font-medium">{checkout.customer}</span>
|
||||
@@ -387,7 +387,7 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="space-y-3">
|
||||
{#each loyaltyToday as loyalty, i (i)}
|
||||
{#each loyaltyToday as loyalty, i (i)}
|
||||
<div class="rounded-lg border bg-gray-50 p-3">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="font-medium">{loyalty.customer}</span>
|
||||
|
||||
@@ -260,7 +260,12 @@
|
||||
function fmtDate(dateStr: string): string {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'Europe/London' });
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDateTime(dateStr: string): string {
|
||||
@@ -330,29 +335,29 @@
|
||||
{
|
||||
label: 'Loyalty Stamps',
|
||||
value: `${stats.totalLoyaltyStamps} total`,
|
||||
subtitle: `${stats.stampsRedeemed} redeemed, ${data.user_profile.loyalty_stamps} remaining`,
|
||||
subtitle: `${stats.stampsRedeemed} redeemed, ${data.user_profile.loyalty_stamps} remaining`
|
||||
},
|
||||
{
|
||||
label: 'Discounts Received',
|
||||
value: fmt(stats.totalDiscounts),
|
||||
subtitle: `${data.booking_discounts?.length ?? 0} discount(s) applied`,
|
||||
highlight: true,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Patch Tests',
|
||||
value: `${stats.patchTests}`,
|
||||
subtitle: 'completed',
|
||||
subtitle: 'completed'
|
||||
},
|
||||
{
|
||||
label: 'Edits or Reschedules',
|
||||
value: `${stats.editRequests}`,
|
||||
subtitle: 'submitted',
|
||||
subtitle: 'submitted'
|
||||
},
|
||||
{
|
||||
label: 'Saved Cards',
|
||||
value: `${stats.savedCards}`,
|
||||
subtitle: 'on file',
|
||||
},
|
||||
subtitle: 'on file'
|
||||
}
|
||||
];
|
||||
|
||||
const nonZero = candidates.filter((c) => {
|
||||
@@ -382,9 +387,7 @@
|
||||
const completedBookings = bookings.filter((b) => b.status === 'completed');
|
||||
const cancelledBookings = bookings.filter(
|
||||
(b) =>
|
||||
b.status === 'client_cancelled' ||
|
||||
b.status === 'we_cancelled' ||
|
||||
b.status === 'no_show'
|
||||
b.status === 'client_cancelled' || b.status === 'we_cancelled' || b.status === 'no_show'
|
||||
);
|
||||
const noShowBookings = bookings.filter((b) => b.status === 'no_show');
|
||||
const pendingBookings = bookings.filter(
|
||||
@@ -425,12 +428,8 @@
|
||||
.map(([method, data]) => ({ method, ...data }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const totalDiscounts = discounts.reduce(
|
||||
(sum, d) => sum + Number(d.discount_amount), 0
|
||||
);
|
||||
const stampsRedeemed = redemptions.reduce(
|
||||
(sum, r) => sum + Number(r.stamps_redeemed), 0
|
||||
);
|
||||
const totalDiscounts = discounts.reduce((sum, d) => sum + Number(d.discount_amount), 0);
|
||||
const stampsRedeemed = redemptions.reduce((sum, r) => sum + Number(r.stamps_redeemed), 0);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
@@ -441,9 +440,11 @@
|
||||
totalSpent,
|
||||
totalRefunded,
|
||||
netSpent: totalSpent - totalRefunded,
|
||||
avgBookingValue: completedBookings.length > 0
|
||||
? completedBookings.reduce((s, b) => s + Number(b.total_price), 0) / completedBookings.length
|
||||
: 0,
|
||||
avgBookingValue:
|
||||
completedBookings.length > 0
|
||||
? completedBookings.reduce((s, b) => s + Number(b.total_price), 0) /
|
||||
completedBookings.length
|
||||
: 0,
|
||||
memberSince: data.user_profile?.created_at ?? '',
|
||||
totalLoyaltyStamps: (data.user_profile?.loyalty_stamps ?? 0) + stampsRedeemed,
|
||||
stampsRedeemed,
|
||||
@@ -455,7 +456,7 @@
|
||||
patchTests: patchTests.length,
|
||||
savedCards: (data.saved_cards ?? []).length,
|
||||
referredBy: data.referrals?.referred_by?.referrer_name ?? null,
|
||||
referredCount: data.referrals?.referred_users?.length ?? 0,
|
||||
referredCount: data.referrals?.referred_users?.length ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -566,9 +567,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
{#if computeBookingStats(gdprData)}
|
||||
{@const stats = computeBookingStats(gdprData)!}
|
||||
<h2 class="mb-3 text-xl font-bold">Summary</h2>
|
||||
{#if computeBookingStats(gdprData)}
|
||||
{@const stats = computeBookingStats(gdprData)!}
|
||||
<h2 class="mb-3 text-xl font-bold">Summary</h2>
|
||||
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">Member Since</span>
|
||||
@@ -605,7 +606,9 @@
|
||||
{#each stats.topServices as svc (svc.name)}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-fuchsia-100 text-xs font-semibold text-fuchsia-700">
|
||||
<span
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-fuchsia-100 text-xs font-semibold text-fuchsia-700"
|
||||
>
|
||||
{svc.count}
|
||||
</span>
|
||||
<span class="text-sm">{svc.name}</span>
|
||||
@@ -636,7 +639,9 @@
|
||||
{#each secondary as s (s.label)}
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">{s.label}</span>
|
||||
<p class="mt-1 text-lg font-semibold {s.highlight ? 'text-green-600' : ''}">{s.value}</p>
|
||||
<p class="mt-1 text-lg font-semibold {s.highlight ? 'text-green-600' : ''}">
|
||||
{s.value}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">{s.subtitle}</p>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -750,8 +755,8 @@
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
><tr class="border-b"
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Previous Name</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Changed At</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Previous Name</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Changed At</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Booking</th></tr
|
||||
></thead
|
||||
>
|
||||
@@ -762,7 +767,9 @@
|
||||
>{nh.previous_first_name} {nh.previous_last_name}</td
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(nh.changed_at)}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500">{nh.booking_id || '—'}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500"
|
||||
>{nh.booking_id || '—'}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -965,10 +972,11 @@
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
><tr class="border-b"
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Created</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Discount</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Status</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Used At</th></tr
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Created</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Discount</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Status</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Used At</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
@@ -984,7 +992,9 @@
|
||||
>{rd.used ? 'Used' : 'Available'}</span
|
||||
></td
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{rd.used_at ? fmtDateTime(rd.used_at) : '—'}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap"
|
||||
>{rd.used_at ? fmtDateTime(rd.used_at) : '—'}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -1014,10 +1024,10 @@
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(la.created_at)}</td>
|
||||
<td class="px-4 py-3">{la.attempt_type.replace(/_/g, ' ')}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {la.success
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}"
|
||||
>{la.success ? 'Yes' : 'No'}</span
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {la.success
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}">{la.success ? 'Yes' : 'No'}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1049,9 +1059,10 @@
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rt.created_at)}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rt.expires_at)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {rt.revoked
|
||||
? 'bg-gray-100 text-gray-600'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {rt.revoked
|
||||
? 'bg-gray-100 text-gray-600'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
>{rt.revoked ? 'Revoked' : 'Active'}</span
|
||||
>
|
||||
</td>
|
||||
|
||||
@@ -224,21 +224,25 @@
|
||||
}
|
||||
|
||||
// when password changes, re-compute strength
|
||||
let passwordStrength = $derived(formData.password ? (() => {
|
||||
const result = zxcvbn(formData.password);
|
||||
// Add minimum length check (6 chars) to the score feedback
|
||||
if (formData.password.length < 6) {
|
||||
return {
|
||||
...result,
|
||||
score: Math.min(result.score, 0), // Force weak for too short
|
||||
feedback: {
|
||||
warning: 'Password must be at least 6 characters',
|
||||
suggestions: ['Add more characters to meet the minimum length requirement']
|
||||
}
|
||||
};
|
||||
}
|
||||
return result;
|
||||
})() : null);
|
||||
let passwordStrength = $derived(
|
||||
formData.password
|
||||
? (() => {
|
||||
const result = zxcvbn(formData.password);
|
||||
// Add minimum length check (6 chars) to the score feedback
|
||||
if (formData.password.length < 6) {
|
||||
return {
|
||||
...result,
|
||||
score: Math.min(result.score, 0), // Force weak for too short
|
||||
feedback: {
|
||||
warning: 'Password must be at least 6 characters',
|
||||
suggestions: ['Add more characters to meet the minimum length requirement']
|
||||
}
|
||||
};
|
||||
}
|
||||
return result;
|
||||
})()
|
||||
: null
|
||||
);
|
||||
|
||||
// form completion check
|
||||
let isFormComplete = $derived(
|
||||
@@ -252,7 +256,8 @@
|
||||
formData.confirmPassword &&
|
||||
formData.password === formData.confirmPassword &&
|
||||
passwordStrength &&
|
||||
formData.password.length >= 6 && passwordStrength.score >= 2 &&
|
||||
formData.password.length >= 6 &&
|
||||
passwordStrength.score >= 2 &&
|
||||
agreedToPolicy &&
|
||||
!validationErrors.email &&
|
||||
!validationErrors.phone &&
|
||||
@@ -364,18 +369,18 @@
|
||||
{#if !isLogin}
|
||||
<!-- SECTION 1: LOGIN DETAILS -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold">What we need to log you in</h3>
|
||||
<h3 class="text-lg font-semibold">What we need to log you in</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="email" text="Email" />
|
||||
<EmailInput
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
bind:error={validationErrors.email}
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="email" text="Email" />
|
||||
<EmailInput
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
bind:error={validationErrors.email}
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="password" text="Password" />
|
||||
@@ -486,8 +491,9 @@
|
||||
type="date"
|
||||
bind:value={formData.dateOfBirth}
|
||||
onblur={() => validateAge(formData.dateOfBirth)}
|
||||
max={new SvelteDate(new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16))
|
||||
.toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
|
||||
max={new SvelteDate(
|
||||
new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16)
|
||||
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
|
||||
required
|
||||
/>
|
||||
{#if validationErrors.dateOfBirth}
|
||||
@@ -518,20 +524,20 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Login Fields (Simple inline layout) -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="email" text="Email" />
|
||||
<EmailInput
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
bind:error={validationErrors.email}
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="email" text="Email" />
|
||||
<EmailInput
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
bind:error={validationErrors.email}
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="password" text="Password" />
|
||||
<div class="space-y-2">
|
||||
<RequiredLabel forId="password" text="Password" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
|
||||
@@ -347,14 +347,15 @@
|
||||
const tagsParam = page.url.searchParams.get('tags');
|
||||
const imgParam = page.url.searchParams.get('img');
|
||||
|
||||
selectedTag = (tagParam && tagParam.length <= 100) ? tagParam.slice(0, 100) : '';
|
||||
selectedTags = tagsParam && tagsParam.length <= 500
|
||||
? tagsParam
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
selectedTag = tagParam && tagParam.length <= 100 ? tagParam.slice(0, 100) : '';
|
||||
selectedTags =
|
||||
tagsParam && tagsParam.length <= 500
|
||||
? tagsParam
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
|
||||
if (selectedTag) {
|
||||
searchQuery = selectedTag;
|
||||
@@ -459,7 +460,7 @@
|
||||
// Position button so its top edge is `offset` px below the image top edge.
|
||||
// Same offset as the right edge (`right-2` / `sm:right-4`), so the circle
|
||||
// sits equally inside both edges.
|
||||
closeBtnRef.style.top = (imgTopRel + offset) + 'px';
|
||||
closeBtnRef.style.top = imgTopRel + offset + 'px';
|
||||
|
||||
// FLIP from the old position if we have one
|
||||
if (pendingBtnRect) {
|
||||
@@ -468,10 +469,7 @@
|
||||
pendingBtnRect = null;
|
||||
if (Math.abs(dy) > 0.5) {
|
||||
closeBtnRef.animate(
|
||||
[
|
||||
{ transform: `translateY(${dy}px)` },
|
||||
{ transform: 'translateY(0)' }
|
||||
],
|
||||
[{ transform: `translateY(${dy}px)` }, { transform: 'translateY(0)' }],
|
||||
{ duration: BTN_FLIP_MS, easing: BUTTON_EASE }
|
||||
);
|
||||
}
|
||||
@@ -881,7 +879,13 @@
|
||||
{/if}
|
||||
|
||||
{#if showModal}
|
||||
<Dialog open={showModal} onOpenChange={(v) => { if (!v) closeModal(); else showModal = v; }}>
|
||||
<Dialog
|
||||
open={showModal}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) closeModal();
|
||||
else showModal = v;
|
||||
}}
|
||||
>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
|
||||
<DialogContent
|
||||
hideClose={true}
|
||||
@@ -895,62 +899,72 @@
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
<div class="overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="flex w-[300%]"
|
||||
style="transform: translateX({trackOffset}); transition: {trackTransition}"
|
||||
>
|
||||
<div class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
|
||||
{#if prevFullURLs}
|
||||
<ImageVariant
|
||||
urls={prevFullURLs}
|
||||
type="full"
|
||||
alt=""
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div bind:this={centerSlideRef} class="relative flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
|
||||
{#if imageLoading && selectedThumbURLs}
|
||||
<ImageVariant
|
||||
urls={selectedThumbURLs}
|
||||
type="thumb"
|
||||
alt="Loading preview"
|
||||
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
|
||||
></div>
|
||||
<div class="overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="flex w-[300%]"
|
||||
style="transform: translateX({trackOffset}); transition: {trackTransition}"
|
||||
>
|
||||
<div
|
||||
class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
|
||||
style="width: calc(100% / 3)"
|
||||
>
|
||||
{#if prevFullURLs}
|
||||
<ImageVariant
|
||||
urls={prevFullURLs}
|
||||
type="full"
|
||||
alt=""
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedFullURLs}
|
||||
<ImageVariant
|
||||
urls={selectedFullURLs}
|
||||
type="full"
|
||||
alt="Portfolio full size"
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
|
||||
? 'opacity-0'
|
||||
: ''}"
|
||||
onload={handleFullImageLoad}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
bind:this={centerSlideRef}
|
||||
class="relative flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
|
||||
style="width: calc(100% / 3)"
|
||||
>
|
||||
{#if imageLoading && selectedThumbURLs}
|
||||
<ImageVariant
|
||||
urls={selectedThumbURLs}
|
||||
type="thumb"
|
||||
alt="Loading preview"
|
||||
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
|
||||
{#if nextFullURLs}
|
||||
<ImageVariant
|
||||
urls={nextFullURLs}
|
||||
type="full"
|
||||
alt=""
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
{/if}
|
||||
{#if selectedFullURLs}
|
||||
<ImageVariant
|
||||
urls={selectedFullURLs}
|
||||
type="full"
|
||||
alt="Portfolio full size"
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
|
||||
? 'opacity-0'
|
||||
: ''}"
|
||||
onload={handleFullImageLoad}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
|
||||
style="width: calc(100% / 3)"
|
||||
>
|
||||
{#if nextFullURLs}
|
||||
<ImageVariant
|
||||
urls={nextFullURLs}
|
||||
type="full"
|
||||
alt=""
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
bind:this={closeBtnRef}
|
||||
|
||||
@@ -159,9 +159,13 @@
|
||||
<Card.Root class="border-dashed border-gray-200 bg-gray-50/50 shadow-sm">
|
||||
<Card.Content class="p-6 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Need something different? Select the closest service and add a note, or contact us for a bespoke treatment.
|
||||
Need something different? Select the closest service and add a note, or contact us for a
|
||||
bespoke treatment.
|
||||
</p>
|
||||
<a href={resolve('/contact')} class="mt-1 inline-block text-sm font-medium text-blue-600 hover:underline">
|
||||
<a
|
||||
href={resolve('/contact')}
|
||||
class="mt-1 inline-block text-sm font-medium text-blue-600 hover:underline"
|
||||
>
|
||||
Arrange a custom booking →
|
||||
</a>
|
||||
</Card.Content>
|
||||
@@ -216,9 +220,9 @@
|
||||
<Card.Content class="p-6 text-center md:p-8">
|
||||
<h2 class="mb-4 text-lg font-semibold text-primary md:text-xl">Ready to Book?</h2>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-center md:gap-4">
|
||||
<Button href={resolve('/book')} class="px-6 py-3 md:px-8">Book Appointment</Button>
|
||||
<Button
|
||||
href={resolve('/contact')}
|
||||
<Button href={resolve('/book')} class="px-6 py-3 md:px-8">Book Appointment</Button>
|
||||
<Button
|
||||
href={resolve('/contact')}
|
||||
variant="outline"
|
||||
class="border-primary/30 px-6 py-3 hover:bg-primary/10 md:px-8"
|
||||
>
|
||||
|
||||
@@ -201,12 +201,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if pageState === 'unauthorized'}
|
||||
<div class="mx-auto max-w-4xl px-4 py-16 text-center md:px-8">
|
||||
<p class="text-gray-500">Please log in to view your schedule.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
|
||||
<div class="mb-10 text-center">
|
||||
@@ -234,10 +232,11 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if bookings.length === 0}
|
||||
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50/50 py-16 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8 text-gray-400"
|
||||
fill="none"
|
||||
@@ -253,10 +252,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-1 text-lg font-semibold text-gray-900">No Upcoming Appointments</h3>
|
||||
<p class="mb-6 text-sm text-gray-500">You don't have any upcoming appointments scheduled.</p>
|
||||
<p class="mb-6 text-sm text-gray-500">
|
||||
You don't have any upcoming appointments scheduled.
|
||||
</p>
|
||||
<Button href={resolve('/book')}>Book an Appointment</Button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="space-y-10">
|
||||
{#each bookingGroups as group (group.key)}
|
||||
@@ -271,15 +271,19 @@
|
||||
<div class="space-y-4">
|
||||
{#each group.bookings as booking (booking.id)}
|
||||
{@const cfg = getConfig(booking.status)}
|
||||
{@const serviceNames = booking.services?.map((s: BookingService) => s.service_name).filter(Boolean).join(', ') || ''}
|
||||
{@const serviceNames =
|
||||
booking.services
|
||||
?.map((s: BookingService) => s.service_name)
|
||||
.filter(Boolean)
|
||||
.join(', ') || ''}
|
||||
|
||||
<div
|
||||
in:fly={{ y: 12, duration: 300, delay: 50 }}
|
||||
class="group/card relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<div class="absolute left-0 top-0 h-full w-1 {cfg.bar}"></div>
|
||||
<div class="absolute top-0 left-0 h-full w-1 {cfg.bar}"></div>
|
||||
|
||||
<div class="pl-5 pr-5 pt-4 pb-4 md:pl-6 md:pr-6 md:pt-5 md:pb-5">
|
||||
<div class="pt-4 pr-5 pb-4 pl-5 md:pt-5 md:pr-6 md:pb-5 md:pl-6">
|
||||
<div class="mb-3 flex items-start justify-between gap-3 md:mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
@@ -304,11 +308,12 @@
|
||||
{formatEndTime(booking.start_time, booking.duration_minutes || 0)}
|
||||
</span>
|
||||
{#if booking.duration_minutes}
|
||||
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span>
|
||||
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-none {cfg.badge}"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs leading-none font-medium {cfg.badge}"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full {cfg.dot}"></span>
|
||||
{cfg.label}
|
||||
@@ -319,10 +324,14 @@
|
||||
|
||||
<div>
|
||||
{#if booking.services && booking.services.length > 0}
|
||||
<p class="font-medium text-gray-900">{serviceNames}</p>
|
||||
<p class="font-medium text-gray-900">{serviceNames}</p>
|
||||
{/if}
|
||||
|
||||
<div class="{booking.services && booking.services.length > 0 ? 'mt-3 md:mt-4' : ''} flex items-center justify-between">
|
||||
<div
|
||||
class="{booking.services && booking.services.length > 0
|
||||
? 'mt-3 md:mt-4'
|
||||
: ''} flex items-center justify-between"
|
||||
>
|
||||
{#if booking.total_amount}
|
||||
<span class="text-lg font-bold text-gray-900">
|
||||
£{booking.total_amount.toFixed(2)}
|
||||
@@ -346,11 +355,7 @@
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -80,14 +80,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
try {
|
||||
var token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.replace('/login'); return; }
|
||||
if (!token) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 <= Date.now()) { window.location.replace('/login'); return; }
|
||||
if (payload.role !== 'admin') { window.location.replace('/'); }
|
||||
} catch(e) { window.location.replace('/login'); }
|
||||
if (payload.exp * 1000 <= Date.now()) {
|
||||
window.location.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (payload.role !== 'admin') {
|
||||
window.location.replace('/');
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</svelte:head>
|
||||
@@ -130,7 +140,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Current/Next Appointment Card (Full Width) -->
|
||||
<CurrentAppointment _openBookingModal={openBookingModal} {openEditBookingModal} {openUserModal} />
|
||||
<CurrentAppointment
|
||||
_openBookingModal={openBookingModal}
|
||||
{openEditBookingModal}
|
||||
{openUserModal}
|
||||
/>
|
||||
|
||||
<!-- Quick Booking + Till Purchases Grid -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
||||
|
||||
Reference in New Issue
Block a user