style: apply prettier formatting to frontend
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user