fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking
Money-safety: - Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation - Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged - CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard) - Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse - Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID GDPR / security: - Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010 - square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2 - Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel - Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit Frontend: - Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen) - Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh - Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy S3: - Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific) Tests/docs: - 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
This commit is contained in:
@@ -774,7 +774,7 @@
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content
|
||||
class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
>
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -557,7 +557,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content
|
||||
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
>
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1072,7 +1072,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
{/if}
|
||||
|
||||
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
|
||||
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
|
||||
<Modal.Content class="max-w-[calc(100%-2rem)]">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Cancel Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
@@ -1176,7 +1176,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto">
|
||||
<Modal.Content class="max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Leave a Tip</Modal.Title>
|
||||
<Modal.Description>Show your appreciation for great service</Modal.Description>
|
||||
|
||||
@@ -379,7 +379,7 @@
|
||||
|
||||
<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"
|
||||
class="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>
|
||||
@@ -704,7 +704,7 @@
|
||||
|
||||
<!-- Decline Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showDeclineConfirm}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Decline this booking?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content
|
||||
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
>
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -815,7 +815,7 @@
|
||||
|
||||
{#if selectedBooking && showCancelModal}
|
||||
<Modal.Root bind:open={showCancelModal}>
|
||||
<Modal.Content class="!z-[80] max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<Modal.Content class="max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Cancel Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
|
||||
@@ -651,7 +651,7 @@
|
||||
|
||||
<!-- Remove Service Confirmation -->
|
||||
<AlertDialog.Root bind:open={showRemoveConfirm}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Remove Service?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
|
||||
<!-- Deny Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showDenyConfirm}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Deny this change request?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
redeemed_by?: string;
|
||||
is_inventory?: boolean;
|
||||
last_used_at?: string;
|
||||
payment_id?: string;
|
||||
cancellable?: boolean;
|
||||
cancellation_reason?: string;
|
||||
}
|
||||
|
||||
interface UserBalance {
|
||||
@@ -85,6 +88,11 @@
|
||||
|
||||
let selectedCardId = $state<string | null>(null);
|
||||
|
||||
// Cancellation (14-day cooling-off right) state
|
||||
let showCancelCardModal = $state(false);
|
||||
let cancellingCard = $state(false);
|
||||
let cancelTargetCard = $state<GiftCard | null>(null);
|
||||
|
||||
// Form inputs
|
||||
let generateAmount = $state('');
|
||||
let generateUserQuery = $state('');
|
||||
@@ -193,12 +201,16 @@
|
||||
const generateError = $derived(
|
||||
generateAmount && (isNaN(Number(generateAmount)) || Number(generateAmount) <= 0)
|
||||
? 'Must be a valid positive number'
|
||||
: ''
|
||||
: generateAmount && Number(generateAmount) > 250
|
||||
? 'Amount cannot exceed £250 per transaction'
|
||||
: ''
|
||||
);
|
||||
const topUpError = $derived(
|
||||
topUpAmount && (isNaN(Number(topUpAmount)) || Number(topUpAmount) <= 0)
|
||||
? 'Must be a valid positive number'
|
||||
: ''
|
||||
: topUpAmount && Number(topUpAmount) > 250
|
||||
? 'Amount cannot exceed £250 per transaction'
|
||||
: ''
|
||||
);
|
||||
const transferAmountError = $derived(
|
||||
transferAmount && (isNaN(Number(transferAmount)) || Number(transferAmount) <= 0)
|
||||
@@ -249,7 +261,18 @@
|
||||
const data = await res.json();
|
||||
summary.total_unclaimed = data.total_unclaimed;
|
||||
summary.total_user_balances = data.total_user_balances;
|
||||
cards = data.gift_cards;
|
||||
cards = (data.gift_cards || []).map(
|
||||
(
|
||||
gc: GiftCard & {
|
||||
cancellable?: boolean;
|
||||
cancellation_reason?: string;
|
||||
}
|
||||
) => ({
|
||||
...gc,
|
||||
cancellable: gc.cancellable === true,
|
||||
cancellation_reason: gc.cancellation_reason || ''
|
||||
})
|
||||
);
|
||||
balances = data.user_balances;
|
||||
totalBalanceRecords = data.ub_total ?? data.user_balances?.length ?? 0;
|
||||
currentPage = data.page;
|
||||
@@ -447,6 +470,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openCancelCardModal(gc: GiftCard) {
|
||||
cancelTargetCard = gc;
|
||||
showCancelCardModal = true;
|
||||
}
|
||||
|
||||
async function cancelGiftCard() {
|
||||
if (!cancelTargetCard) return;
|
||||
cancellingCard = true;
|
||||
try {
|
||||
const body: Record<string, unknown> = { code: cancelTargetCard.id };
|
||||
if (cancelTargetCard.payment_id) body.payment_id = cancelTargetCard.payment_id;
|
||||
const res = await apiFetch('/api/admin/gift-cards/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Gift card cancelled — unspent balance refunded');
|
||||
showCancelCardModal = false;
|
||||
cancelTargetCard = null;
|
||||
await fetchGiftCards();
|
||||
await fetchExpiredBalances();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(extractErrorMessage(errText) || 'Failed to cancel gift card');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error cancelling gift card');
|
||||
} finally {
|
||||
cancellingCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetGenerateModal() {
|
||||
generateStep = 'type';
|
||||
generateType = 'code';
|
||||
@@ -520,7 +578,7 @@
|
||||
setModalStep(actionType, 'success');
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
paymentError = extractErrorMessage(await res.text());
|
||||
setModalStep(actionType, 'error');
|
||||
}
|
||||
} catch {
|
||||
@@ -565,7 +623,7 @@
|
||||
await fetchGiftCards();
|
||||
}
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
paymentError = extractErrorMessage(await res.text());
|
||||
setModalStep(actionType, 'error');
|
||||
}
|
||||
} catch {
|
||||
@@ -661,7 +719,7 @@
|
||||
setModalStep(actionType, 'success');
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
paymentError = extractErrorMessage(await res.text());
|
||||
setModalStep(actionType, 'error');
|
||||
}
|
||||
} catch {
|
||||
@@ -699,7 +757,7 @@
|
||||
topUpStep = 'success';
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
paymentError = extractErrorMessage(await res.text());
|
||||
topUpStep = 'error';
|
||||
}
|
||||
} catch {
|
||||
@@ -866,7 +924,10 @@
|
||||
Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button onclick={() => (showGenerateModal = true)}>Generate Gift Card</Button>
|
||||
<div class="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Button onclick={() => (showGenerateModal = true)}>Generate Gift Card</Button>
|
||||
<p class="text-xs text-gray-500">Admin gift-card value is limited to £5,000 per day.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
@@ -939,6 +1000,14 @@
|
||||
</div>
|
||||
|
||||
{#if activeSection === 'cards' || activeSection === 'expired_cards'}
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800"
|
||||
>
|
||||
Gift cards purchased online by customers can be cancelled by the customer themselves from
|
||||
their account within 14 days of purchase (UK cooling-off right). Cancellations are made by
|
||||
the customer; no action is needed here.
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex gap-2">
|
||||
<Input
|
||||
placeholder="Search by gift card code..."
|
||||
@@ -1073,7 +1142,23 @@
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
{:else}
|
||||
{/if}
|
||||
{#if gc.cancellable}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openCancelCardModal(gc)}
|
||||
>
|
||||
Cancel & refund
|
||||
</Button>
|
||||
{:else if gc.cancellation_reason}
|
||||
<span
|
||||
class="max-w-[12rem] truncate text-xs text-gray-400 italic"
|
||||
title={gc.cancellation_reason}
|
||||
>
|
||||
{gc.cancellation_reason}
|
||||
</span>
|
||||
{:else if gc.redeemed_by}
|
||||
<span class="text-xs text-gray-400 italic">No actions available</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1157,7 +1242,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<div class="flex flex-wrap justify-end gap-2 pt-1">
|
||||
{#if !gc.redeemed_by}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1182,6 +1267,23 @@
|
||||
Transfer
|
||||
</Button>
|
||||
{/if}
|
||||
{#if gc.cancellable}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openCancelCardModal(gc)}
|
||||
class="flex-1"
|
||||
>
|
||||
Cancel & refund
|
||||
</Button>
|
||||
{:else if gc.cancellation_reason}
|
||||
<span
|
||||
class="max-w-full truncate text-xs text-gray-400 italic"
|
||||
title={gc.cancellation_reason}
|
||||
>
|
||||
{gc.cancellation_reason}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1796,6 +1898,7 @@
|
||||
placeholder="e.g. 50.00"
|
||||
bind:value={generateAmount}
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Max £250 per transaction.</p>
|
||||
{#if generateError}
|
||||
<span class="text-xs font-medium text-red-500">{generateError}</span>
|
||||
{/if}
|
||||
@@ -1946,7 +2049,7 @@
|
||||
<Input
|
||||
id="generate-cash-tendered"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
inputmode="decimal"
|
||||
placeholder="e.g. 50.00"
|
||||
value={cashAmount}
|
||||
oninput={(e) => (cashAmount = e.currentTarget.value)}
|
||||
@@ -2103,6 +2206,7 @@
|
||||
placeholder="e.g. 20.00"
|
||||
bind:value={topUpAmount}
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Max £250 per transaction.</p>
|
||||
{#if topUpError}
|
||||
<span class="text-xs font-medium text-red-500">{topUpError}</span>
|
||||
{/if}
|
||||
@@ -2217,7 +2321,7 @@
|
||||
<Input
|
||||
id="topup-cash-tendered"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
inputmode="decimal"
|
||||
placeholder="e.g. 50.00"
|
||||
value={cashAmount}
|
||||
oninput={(e) => (cashAmount = e.currentTarget.value)}
|
||||
@@ -2348,3 +2452,44 @@
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Cancel & Refund Modal -->
|
||||
<Modal.Root bind:open={showCancelCardModal}>
|
||||
<Modal.Content class="max-w-md">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Cancel & Refund Gift Card</Modal.Title>
|
||||
<Modal.Description>
|
||||
Cancel gift card {formatCardCode(cancelTargetCard?.id ?? '')} and refund the remaining balance.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<p class="font-semibold">14-day statutory cancellation right</p>
|
||||
<p class="mt-1 text-xs text-amber-700">
|
||||
The unspent balance will be refunded to the original payment method. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if cancelTargetCard}
|
||||
<div class="flex justify-between rounded-lg border bg-gray-50 p-4 text-sm">
|
||||
<span class="text-base font-semibold text-gray-700">Remaining Balance</span>
|
||||
<span class="text-xl font-bold text-gray-900"
|
||||
>{formatCurrency(cancelTargetCard.amount_remaining)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => (showCancelCardModal = false)}>Keep Card</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={cancelGiftCard}
|
||||
disabled={cancellingCard || !cancelTargetCard}
|
||||
>
|
||||
{cancellingCard ? 'Cancelling...' : 'Cancel & Refund'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
|
||||
<!-- Delete Exception Confirmation -->
|
||||
<AlertDialog.Root bind:open={showDeleteExceptionAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete exception group?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -968,7 +968,7 @@
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Confirm Upload</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="!z-[60] sm:max-w-[425px]">
|
||||
<Modal.Content class="sm:max-w-[425px]">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Record Patch Test</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
@@ -427,7 +427,7 @@
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-4xl">
|
||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-4xl">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Reschedule Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
let giftCardAmount = $state('25');
|
||||
let showGiftCardInput = $state(false);
|
||||
|
||||
// Gift card funding is capped at £250 per transaction (the backend enforces
|
||||
// the same limit) — mirror the cap client-side so the till cannot queue an
|
||||
// oversized gift card.
|
||||
const GIFT_CARD_MAX_AMOUNT = 250;
|
||||
|
||||
let paymentMethod = $state<TillPaymentMethod>('cash');
|
||||
let onlineSquareCardReady = $state(false);
|
||||
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
@@ -194,7 +199,19 @@
|
||||
// The backend till sale API currently only accepts item_type 'gift_card', so
|
||||
// retail items cannot be charged yet — gate the Charge button to gift-card-only carts.
|
||||
const hasRetailItems = $derived(cart.some((i) => i.label !== 'Gift Card'));
|
||||
const canCharge = $derived(cart.length > 0 && !hasRetailItems && subtotal > 0);
|
||||
const canCharge = $derived(
|
||||
cart.length > 0 &&
|
||||
!hasRetailItems &&
|
||||
subtotal > 0 &&
|
||||
!cart.some((i) => i.price > GIFT_CARD_MAX_AMOUNT)
|
||||
);
|
||||
|
||||
// Client-side parity with the per-transaction gift card cap: the amount
|
||||
// typed into the gift-card input must not exceed £250.
|
||||
const parsedGiftCardAmount = $derived(parseFloat(giftCardAmount));
|
||||
const giftCardAmountTooHigh = $derived(
|
||||
!isNaN(parsedGiftCardAmount) && parsedGiftCardAmount > GIFT_CARD_MAX_AMOUNT
|
||||
);
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
|
||||
@@ -212,6 +229,9 @@
|
||||
function addGiftCard() {
|
||||
const amt = parseFloat(giftCardAmount);
|
||||
if (isNaN(amt) || amt <= 0) return;
|
||||
// The Add button is disabled via `giftCardAmountTooHigh`, but the
|
||||
// input's Enter key bypasses that — reject here too (defense in depth).
|
||||
if (amt > GIFT_CARD_MAX_AMOUNT) return;
|
||||
addItem('Gift Card', amt);
|
||||
giftCardAmount = '25';
|
||||
showGiftCardInput = false;
|
||||
@@ -255,6 +275,10 @@
|
||||
toast.error('Cart is empty');
|
||||
return;
|
||||
}
|
||||
if (cart.some((item) => item.price > GIFT_CARD_MAX_AMOUNT)) {
|
||||
toast.error(`Gift card amount exceeds maximum (£${GIFT_CARD_MAX_AMOUNT})`);
|
||||
return;
|
||||
}
|
||||
if (hasRetailItems) {
|
||||
toast.error(
|
||||
'Retail items cannot be charged yet — the till API currently supports gift card sales only'
|
||||
@@ -390,28 +414,40 @@
|
||||
</Button>
|
||||
<div class="relative">
|
||||
{#if showGiftCardInput}
|
||||
<div class="flex gap-1">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute top-1/2 left-2 -translate-y-1/2 text-xs text-gray-400"
|
||||
>£</span
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<div class="relative flex-1">
|
||||
<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}
|
||||
max={GIFT_CARD_MAX_AMOUNT}
|
||||
class="h-9 pl-5 text-sm"
|
||||
disabled={processing}
|
||||
error={giftCardAmountTooHigh ? 'Gift card amount exceeds maximum' : ''}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addGiftCard();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={addGiftCard}
|
||||
class="h-9 px-2 text-xs"
|
||||
disabled={processing || giftCardAmountTooHigh}>Add</Button
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={giftCardAmount}
|
||||
class="h-9 pl-5 text-sm"
|
||||
disabled={processing}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addGiftCard();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={addGiftCard}
|
||||
class="h-9 px-2 text-xs"
|
||||
disabled={processing}>Add</Button
|
||||
{#if giftCardAmountTooHigh}
|
||||
<p class="text-xs text-red-700"
|
||||
>Gift card amount exceeds maximum (£{GIFT_CARD_MAX_AMOUNT})</p
|
||||
>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground"
|
||||
>Gift card limit £{GIFT_CARD_MAX_AMOUNT} per transaction</p
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -556,7 +592,7 @@
|
||||
<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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="flex h-9 w-9 min-w-9 items-center justify-center rounded border text-base text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => updateQty(item.id, -1)}
|
||||
>
|
||||
@@ -565,7 +601,7 @@
|
||||
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="flex h-9 w-9 min-w-9 items-center justify-center rounded border text-base text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => updateQty(item.id, 1)}
|
||||
>
|
||||
@@ -577,12 +613,12 @@
|
||||
<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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="ml-1 flex h-9 w-9 min-w-9 items-center justify-center rounded text-base text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => removeItem(item.id)}
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -610,14 +646,14 @@
|
||||
>Payment Method</span
|
||||
>
|
||||
<div
|
||||
class="mt-2 grid gap-2 {availablePaymentMethods.length > 3
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-3'}"
|
||||
class="mt-2 grid grid-cols-2 gap-2 {availablePaymentMethods.length > 3
|
||||
? ''
|
||||
: 'sm:grid-cols-3'}"
|
||||
>
|
||||
{#each availablePaymentMethods as m (m.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
|
||||
class="rounded-lg border py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
|
||||
m.key
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
|
||||
@@ -956,7 +956,7 @@
|
||||
</Modal.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showDeleteAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -721,7 +721,7 @@
|
||||
|
||||
{#if selectedUser}
|
||||
<AlertDialog.Root bind:open={showRemove2FAConfirm}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Remove two-factor authentication?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -1996,10 +1996,17 @@
|
||||
|
||||
{#if loadingAvailableHours}
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex w-56 items-center justify-center border-l p-6"
|
||||
class="absolute inset-y-0 right-0 hidden w-56 items-center justify-center border-l p-6 md:flex"
|
||||
>
|
||||
<p class="text-sm text-gray-500">Loading times...</p>
|
||||
</div>
|
||||
<!-- md:hidden twin: time slots render in-flow below the calendar on mobile -->
|
||||
<div class="flex items-center justify-center gap-2 border-t p-6 md:hidden">
|
||||
<div
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-amber-600"
|
||||
></div>
|
||||
<p class="text-sm text-gray-500">Loading available hours...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TimeSlotPicker
|
||||
date={selectedDate}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
// Without this the stable isDateUnavailable reference may cause stale availability
|
||||
// after workingHours/availableHours are updated (e.g. after month data loading).
|
||||
isDateUnavailable={(d) => isDateUnavailable(d)}
|
||||
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
|
||||
class="bg-transparent p-0 [--cell-size:--spacing(11)] data-unavailable:line-through data-unavailable:opacity-100 max-sm:[--cell-size:--spacing(10)] md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
|
||||
weekdayFormat="short"
|
||||
{minValue}
|
||||
{maxValue}
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
<input
|
||||
id={consentId}
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary accent-primary"
|
||||
class="mt-0.5 h-5 w-5 rounded border-gray-300 text-primary accent-primary"
|
||||
bind:checked={saveCard}
|
||||
/>
|
||||
<span>
|
||||
|
||||
@@ -781,7 +781,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<Dialog.Content class="max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
@@ -800,20 +800,20 @@
|
||||
<div class="mb-2 text-sm font-medium">
|
||||
{service.service_name || 'Unknown Service'}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="text-xs text-gray-500">£</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex={-1}
|
||||
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
class="flex h-10 w-24 min-w-0 rounded-md border border-input bg-background px-2 py-1 text-base ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none md:text-sm"
|
||||
value={serviceOverrides[service.service_id]?.price ??
|
||||
service.price?.toFixed(2) ??
|
||||
'0.00'}
|
||||
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
|
||||
/>
|
||||
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
|
||||
<span class="text-xs text-amber-600">
|
||||
<span class="min-w-0 text-xs text-amber-600">
|
||||
(was £{serviceOverrides[service.service_id].originalPrice.toFixed(2)})
|
||||
</span>
|
||||
{/if}
|
||||
@@ -1301,7 +1301,7 @@
|
||||
oninput={handleGiftCardInput}
|
||||
placeholder="XXXX-XXXX-XXXX"
|
||||
maxlength={14}
|
||||
class="mt-1 font-mono text-lg tracking-widest"
|
||||
class="mt-1 font-mono text-base tracking-wide"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Enter the 12-character code printed on the gift card
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
const cardStyle: SquareCardClassSelectors = {
|
||||
input: {
|
||||
fontSize: '14px', // md:text-sm (text-base md:text-sm — desktop size)
|
||||
// Square caps fontSize at 16px exactly. 16px also matches the shared
|
||||
// Input's mobile size (text-base md:text-sm in ui/input/input.svelte),
|
||||
// which stops iOS Safari from auto-zooming the cross-origin iframe on
|
||||
// focus (any field under 16px triggers the zoom the page can't undo).
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
fontWeight: '400',
|
||||
color: 'oklch(0.129 0.042 264.695)', // --foreground
|
||||
@@ -278,5 +282,11 @@
|
||||
message="The secure card form failed to load. Please try again or use a saved card."
|
||||
/>
|
||||
{:else}
|
||||
<div id={uniqueId} bind:this={containerEl}></div>
|
||||
<div id={uniqueId} bind:this={containerEl} class="min-h-[120px]">
|
||||
{#if !ready}
|
||||
<!-- Keep the layout height stable while card.attach() resolves so the
|
||||
Pay button below doesn't jump mid-checkout on mobile. -->
|
||||
<div class="flex h-9 items-center text-sm text-gray-400">Loading secure card form...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md">
|
||||
<Dialog.Content class="max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
|
||||
{#if booking.id}
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
</Modal.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showDeleteAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
||||
<AlertDialog.Description
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
|
||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import { nextZIndex } from '../dialog/zindex.js';
|
||||
|
||||
// Same dynamic z-index stack as the base dialog — see dialog-content.svelte.
|
||||
// Claimed lazily in open order: 0 until the Content's data-state becomes
|
||||
// "open" (see the $effect below), never at component instantiation.
|
||||
let z = $state(0);
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -11,16 +17,68 @@
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
|
||||
} = $props();
|
||||
|
||||
// Strip any z-index utility (including `!important` overrides) from the
|
||||
// consumer's class — the dynamic stack is authoritative (see dialog-content).
|
||||
function stripZIndexClasses(classes: unknown): string {
|
||||
return typeof classes === 'string'
|
||||
? classes.replace(/\s*!?z-(?:\[[^\]]+\]|\d+|auto)/g, '')
|
||||
: '';
|
||||
}
|
||||
|
||||
// Claim the z-index when the dialog OPENS (see dialog-content.svelte for the
|
||||
// full rationale): bits-ui keeps the Content mounted with data-state=
|
||||
// "closed" from page load, so claiming at instantiation would pre-claim
|
||||
// stack slots in template order and break 3-level / reverse nesting. Watch
|
||||
// the Content element's `data-state` attribute and claim the next slot the
|
||||
// moment it becomes "open" (or immediately if it already is — e.g. a
|
||||
// conditionally mounted `{#if}` alert dialog).
|
||||
//
|
||||
// The claim is NOT guarded by `z === 0` — bits-ui keeps the Content Svelte
|
||||
// component alive for the page lifetime (only the portal node is detached
|
||||
// on close), so a once-claimed `z` would freeze the stack in first-open
|
||||
// order on reopen. Track the last observed data-state in a plain local and
|
||||
// claim a FRESH slot on every closed→open transition. The effect reads
|
||||
// only `ref` (never `z`), so it re-runs solely when the ref is (re)assigned.
|
||||
$effect(() => {
|
||||
const element = ref;
|
||||
if (!element) return;
|
||||
|
||||
// Last data-state observed on this element — a plain local, invisible
|
||||
// to reactivity on purpose (see dialog-content.svelte).
|
||||
let lastState: string | null = null;
|
||||
|
||||
const claim = () => {
|
||||
const state = element.getAttribute('data-state');
|
||||
// Exactly one claim per closed→open transition — initial open and
|
||||
// every reopen — never on open→open, and not re-fired if the
|
||||
// observer delivers the same transition more than once.
|
||||
if (state === 'open' && lastState !== 'open') {
|
||||
z = nextZIndex();
|
||||
}
|
||||
lastState = state;
|
||||
};
|
||||
|
||||
// The dialog may already be open the first time the effect runs (the
|
||||
// ref is assigned after the element mounts) — claim immediately so the
|
||||
// initial state is captured as the lastState baseline.
|
||||
claim();
|
||||
|
||||
const observer = new MutationObserver(claim);
|
||||
observer.observe(element, { attributes: true, attributeFilter: ['data-state'] });
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogOverlay style={z > 0 ? `z-index: ${z}` : undefined} />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="alert-dialog-content"
|
||||
style={z > 0 ? `z-index: ${z}` : undefined}
|
||||
class={cn(
|
||||
'mm:max-w-lg fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
'mm:max-w-lg fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
stripZIndexClasses(className)
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="alert-dialog-overlay"
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import * as Dialog from './index.js';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import { nextZIndex } from './zindex.js';
|
||||
|
||||
// Claimed z-index slot for this Content. 0 until the dialog first opens; the
|
||||
// slot is claimed lazily in OPEN ORDER — the moment data-state flips to
|
||||
// "open" (see the $effect below), never at component instantiation. bits-ui
|
||||
// keeps every Content on the page mounted (data-state="closed") from load
|
||||
// time, so claiming at instantiation would burn stack slots in template
|
||||
// order and break 3-level / reverse nesting. Portals all mount into
|
||||
// document.body as siblings, so a higher z-index is the only thing
|
||||
// separating them.
|
||||
let z = $state(0);
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -17,27 +28,95 @@
|
||||
children: Snippet;
|
||||
hideClose?: boolean;
|
||||
} = $props();
|
||||
|
||||
// The dynamic stack is authoritative: strip any z-index utility (including
|
||||
// `!important` overrides like `!z-[60]`) consumers pass via `class`. A plain
|
||||
// inline style loses to `!important` classes in the cascade, so the tokens
|
||||
// must be removed rather than merely overridden.
|
||||
function stripZIndexClasses(classes: unknown): string {
|
||||
return typeof classes === 'string'
|
||||
? classes.replace(/\s*!?z-(?:\[[^\]]+\]|\d+|auto)/g, '')
|
||||
: '';
|
||||
}
|
||||
|
||||
// Claim the z-index when the dialog OPENS, not when this component mounts.
|
||||
// Watch the Content element's `data-state` attribute with a MutationObserver:
|
||||
// the moment it flips to "open" (or is already "open" when the ref first
|
||||
// lands — e.g. a conditionally mounted `{#if open}` dialog), claim the next
|
||||
// slot. Stacking therefore follows OPEN ORDER, so the most recently opened
|
||||
// dialog — at any nesting depth — always paints on top. The observer is
|
||||
// torn down with the effect.
|
||||
//
|
||||
// The claim is NOT guarded by `z === 0`: bits-ui keeps every Content Svelte
|
||||
// component alive for the page lifetime (the portal node is detached on
|
||||
// close, but the component — and its `z` — survive), so a once-claimed `z`
|
||||
// would freeze the stack in first-open order on reopen. Instead we track
|
||||
// the last observed data-state in a plain local and claim a FRESH slot on
|
||||
// every closed→open transition. The effect must not read `z` (that would
|
||||
// re-run it when a slot is claimed, tearing down the observer and resetting
|
||||
// the baseline); it reads only `ref`, so it re-runs solely when the ref is
|
||||
// (re)assigned — and assignment to `z` inside the effect is not a
|
||||
// dependency.
|
||||
$effect(() => {
|
||||
const element = ref;
|
||||
if (!element) return;
|
||||
|
||||
// Last data-state observed on this element. A plain local (not `$state`):
|
||||
// bookkeeping for the claim logic, deliberately invisible to reactivity.
|
||||
let lastState: string | null = null;
|
||||
|
||||
const claim = () => {
|
||||
const state = element.getAttribute('data-state');
|
||||
// Exactly one claim per closed→open transition — the initial open
|
||||
// and every reopen — never on open→open, and not re-fired if the
|
||||
// observer delivers the same transition more than once.
|
||||
if (state === 'open' && lastState !== 'open') {
|
||||
z = nextZIndex();
|
||||
}
|
||||
lastState = state;
|
||||
};
|
||||
|
||||
// The dialog may already be open the first time the effect runs (the
|
||||
// ref is assigned after the element mounts) — claim immediately so the
|
||||
// initial state is captured as the lastState baseline.
|
||||
claim();
|
||||
|
||||
const observer = new MutationObserver(claim);
|
||||
observer.observe(element, { attributes: true, attributeFilter: ['data-state'] });
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay
|
||||
class={typeof className === 'string' && /(!?z-\[[^\]]+\]|!?z-\d+)/.test(className)
|
||||
? className.match(/(!?z-\[[^\]]+\]|!?z-\d+)/)?.[0]
|
||||
: ''}
|
||||
/>
|
||||
<Dialog.Overlay style={z > 0 ? `z-index: ${z}` : undefined} />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
style={z > 0 ? `z-index: ${z}` : undefined}
|
||||
class={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
|
||||
className
|
||||
// Mobile-first: on <sm the dialog is a bottom sheet pinned to the
|
||||
// bottom edge (max-h uses dvh so it shrinks above the iOS keyboard
|
||||
// instead of trapping the primary action underneath it), with
|
||||
// safe-area padding so the last row clears the home indicator.
|
||||
// Desktop (sm+) keeps the original centered dialog via the sm:
|
||||
// overrides below — media-query utilities emit after the base ones,
|
||||
// so the center/re-align wins on larger screens.
|
||||
//
|
||||
// max-h is split into a plain `max-h-[90vh]` (shared by every
|
||||
// consumer's identical non-breakpoint override, so twMerge dedupes
|
||||
// to the same value on sm+) plus a `max-sm:`-scoped dvh value. The
|
||||
// media-query variant is a different twMerge group than the plain
|
||||
// utility, so it survives consumer `max-h-*` overrides that would
|
||||
// otherwise wipe the keyboard-safe mobile height.
|
||||
'fixed bottom-0 left-0 right-0 grid w-full max-w-none translate-x-0 translate-y-0 gap-4 rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-h-[90vh] max-sm:max-h-[calc(100dvh-4rem)] overflow-y-auto sm:top-[50%] sm:left-[50%] sm:right-auto sm:bottom-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
|
||||
stripZIndexClasses(className)
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if !hideClose}
|
||||
<DialogPrimitive.Close
|
||||
class="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
class="absolute top-4 right-4 flex h-10 w-10 items-center justify-center rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
class={cn('flex flex-col gap-2 pr-10 text-center sm:text-left', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Module-level z-index stack for nested dialogs.
|
||||
*
|
||||
* Every dialog/alert-dialog Content mounts its portal into `document.body`, so
|
||||
* sibling portals stack purely by z-index — DOM order across separate portals
|
||||
* is irrelevant. A static `z-50` on every dialog meant a modal opened from
|
||||
* inside another modal rendered *behind* it. Instead, each open dialog claims
|
||||
* the next slot in the stack (50, 60, 70, …), so arbitrarily deep nesting
|
||||
* always paints newest-on-top.
|
||||
*
|
||||
* The first dialog is exactly 50 to preserve its historic relationship with the
|
||||
* fixed navbar (also `z-50`): the dialog portal is appended to the body after
|
||||
* the navbar, so equal z-index + later DOM order keeps the dialog above it.
|
||||
* Each subsequent open dialog climbs by 10, leaving headroom for portal
|
||||
* internals at the same level.
|
||||
*
|
||||
* Plain module state (no `$state`) on purpose — this is a dumb counter, not
|
||||
* reactive UI state, and `$state` would add reactivity overhead per mount.
|
||||
*/
|
||||
let top = 40;
|
||||
|
||||
/** Claim the next z-index for a newly opened dialog (50, 60, 70, …). */
|
||||
export function nextZIndex(): number {
|
||||
top += 10;
|
||||
// Guard against absurd growth: an extremely long-lived page (days of open
|
||||
// dialogs, or a session that opens thousands of them) could otherwise push
|
||||
// the stack past the 32-bit int range and silently break z-index. Wrap back
|
||||
// to the base so the stack can never overflow.
|
||||
if (top > 2147483640) {
|
||||
top = 50;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
/** The current top of the stack (the last claimed z-index). */
|
||||
export function currentZIndex(): number {
|
||||
return top;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the stack to its base. Called once from the root layout on mount so
|
||||
* hot reloads / repeated test runs don't let the counter climb forever.
|
||||
*/
|
||||
export function resetZIndexStack(): void {
|
||||
top = 40;
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30',
|
||||
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 md:h-9 md:min-h-9',
|
||||
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
@@ -42,7 +42,7 @@
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
|
||||
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:h-9 md:min-h-9 md:text-sm dark:bg-input/30',
|
||||
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Toaster } from '$lib/components/ui/sonner/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { resetZIndexStack } from '$lib/components/ui/dialog/zindex.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
@@ -29,6 +30,9 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Start the dialog z-index stack fresh per full page load so hot reloads
|
||||
// / repeated runs don't let the counter climb forever (see zindex.ts).
|
||||
resetZIndexStack();
|
||||
updateToasterPosition();
|
||||
window.addEventListener('resize', updateToasterPosition);
|
||||
return () => {
|
||||
@@ -66,7 +70,7 @@
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<div class="flex min-h-screen supports-[height:100dvh]:min-h-dvh flex-col">
|
||||
<NavBar />
|
||||
<Toaster position={toasterPosition} />
|
||||
|
||||
|
||||
@@ -239,6 +239,16 @@
|
||||
// with the wrong intent.
|
||||
let buyTokenizedForSaveCard = $state(false);
|
||||
|
||||
// Client-side mirror of the £500/day online purchase cap. The backend is
|
||||
// authoritative — this counter only reflects confirmed purchases made in
|
||||
// this session, so a user is told they've hit the cap instead of being
|
||||
// silently rejected on the next attempt. It is not persisted, so it resets
|
||||
// on page reload; any rejection the counter can't foresee still surfaces
|
||||
// through the backend's error toast.
|
||||
const DAILY_GIFT_CARD_BUY_LIMIT = 500;
|
||||
let buyDailyTotal = $state(0);
|
||||
const buyLimitReached = $derived(buyDailyTotal >= DAILY_GIFT_CARD_BUY_LIMIT);
|
||||
|
||||
// Cached idempotency key: generated once per purchase attempt, reused on
|
||||
// retry (so a lost-response retry dedups instead of double-charging),
|
||||
// cleared on success. Reset when the amount or payment method changes.
|
||||
@@ -246,6 +256,25 @@
|
||||
let buyKeyedAmount = $state(0);
|
||||
let buyKeyedCard = $state('');
|
||||
|
||||
// My Gift Cards — online purchases within the 14-day cooling-off window
|
||||
// (Consumer Contracts Regulations 2013), each with the rolling expiry date
|
||||
// shown (T&C: "the expiry date is displayed in your account").
|
||||
let myGiftCards = $state<
|
||||
Array<{
|
||||
code: string;
|
||||
amount: number;
|
||||
purchased_at: string;
|
||||
expiry_date?: string;
|
||||
cancellable: boolean;
|
||||
cancellation_reason?: string;
|
||||
payment_id?: string;
|
||||
}>
|
||||
>([]);
|
||||
let loadingMyGiftCards = $state(false);
|
||||
let cancellingCode = $state<string | null>(null);
|
||||
let cardToCancel = $state<(typeof myGiftCards)[number] | null>(null);
|
||||
let showCancelConfirm = $state(false);
|
||||
|
||||
// Derived validation for Buy Gift Card form — delegated to CardSelection.
|
||||
const isBuyCardValid = $derived(buyCardSelectionValid);
|
||||
|
||||
@@ -264,6 +293,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMyGiftCards() {
|
||||
loadingMyGiftCards = true;
|
||||
try {
|
||||
const res = await apiFetch('/api/user/giftcards');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
myGiftCards = data.gift_cards || [];
|
||||
}
|
||||
} catch {
|
||||
// ignore — the section renders with the empty state
|
||||
} finally {
|
||||
loadingMyGiftCards = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelGiftCard() {
|
||||
if (!cardToCancel) return;
|
||||
cancellingCode = cardToCancel.code;
|
||||
try {
|
||||
const res = await apiFetch('/api/user/giftcards/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: cardToCancel.code,
|
||||
...(cardToCancel.payment_id ? { payment_id: cardToCancel.payment_id } : {})
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
toast.success(data.message || 'Gift card cancelled and refunded');
|
||||
cardToCancel = null;
|
||||
await fetchMyGiftCards();
|
||||
await fetchGiftCardBalance();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(extractErrorMessage(errText) || 'Failed to cancel gift card');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error cancelling gift card');
|
||||
} finally {
|
||||
cancellingCode = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemGiftCard() {
|
||||
if (isRedeemingSync) return;
|
||||
isRedeemingSync = true;
|
||||
@@ -286,6 +359,10 @@
|
||||
);
|
||||
giftCardCode = '';
|
||||
await fetchGiftCardBalance();
|
||||
// The redeemed card is consumed — refresh the cancellable list so
|
||||
// it no longer shows a stale "Cancel & refund" row (mirror of the
|
||||
// buy path which refreshes both balance and list).
|
||||
await fetchMyGiftCards();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(extractErrorMessage(errText) || 'Failed to redeem gift card');
|
||||
@@ -387,6 +464,9 @@
|
||||
const data = await res.json();
|
||||
toast.success('Gift card purchased successfully!');
|
||||
purchaseResultCode = data.code;
|
||||
// Track confirmed purchases toward the £500/day cap (the backend
|
||||
// is authoritative; this only feeds the client-side nudge).
|
||||
buyDailyTotal += buyAmount;
|
||||
buyIdempotencyKey = '';
|
||||
buyKeyedAmount = 0;
|
||||
buyKeyedCard = '';
|
||||
@@ -504,6 +584,15 @@
|
||||
);
|
||||
}
|
||||
|
||||
function formatShortDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
}
|
||||
|
||||
function generateIdempotencyKey(): string {
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof window !== 'undefined' && window.crypto) {
|
||||
@@ -552,7 +641,6 @@
|
||||
let twoFAMethod = $state<'email' | 'sms'>('email');
|
||||
let twoFACode = $state('');
|
||||
let twoFASetupPending = $state(false);
|
||||
let twoFADevCode = $state('');
|
||||
let twoFASettingUp = $state(false);
|
||||
let twoFAVerifying = $state(false);
|
||||
let twoFADisabling = $state(false);
|
||||
@@ -560,7 +648,6 @@
|
||||
let showDisableTwoFADialog = $state(false);
|
||||
let showDisableCodeEntry = $state(false);
|
||||
let twoFADisableCode = $state('');
|
||||
let twoFASendingDisableCode = $state(false);
|
||||
let twoFADisableConfirming = $state(false);
|
||||
|
||||
// Locally-selected 2FA state, behaving as a radio group: none, email, or sms —
|
||||
@@ -601,9 +688,6 @@
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// Dev-only: unenforced environments return the code so the loose
|
||||
// fake flow is usable without reading backend logs.
|
||||
twoFADevCode = data.code ?? '';
|
||||
twoFASetupPending = true;
|
||||
twoFACode = '';
|
||||
toast.success(data.message ?? 'Verification code sent');
|
||||
@@ -629,7 +713,6 @@
|
||||
if (res.ok) {
|
||||
twoFASetupPending = false;
|
||||
twoFACode = '';
|
||||
twoFADevCode = '';
|
||||
await authStore.refreshProfile();
|
||||
toast.success('Two-factor authentication enabled');
|
||||
} else {
|
||||
@@ -692,7 +775,6 @@
|
||||
// disabled. Mint one first (the backend mints + delivers it), then show
|
||||
// the code-entry step.
|
||||
async function sendDisableCode() {
|
||||
twoFASendingDisableCode = true;
|
||||
try {
|
||||
const res = await apiFetch('/api/user/2fa/disable/code', {
|
||||
method: 'POST',
|
||||
@@ -708,8 +790,6 @@
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
twoFASendingDisableCode = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1230,6 +1310,7 @@
|
||||
fetchPastBookings();
|
||||
fetchNotifPrefs();
|
||||
fetchGdprExportStatus();
|
||||
fetchMyGiftCards();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1517,6 +1598,7 @@
|
||||
savedCardsStore.fetch();
|
||||
savedCardsStore.invalidate();
|
||||
fetchGiftCardBalance();
|
||||
fetchMyGiftCards();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
@@ -2407,6 +2489,14 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
Gift-card purchases are limited to £500 per day.
|
||||
</p>
|
||||
{#if buyLimitReached}
|
||||
<p class="text-xs font-semibold text-amber-600">
|
||||
You've reached today's £500 gift-card purchase limit.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@@ -2467,10 +2557,16 @@
|
||||
|
||||
<Button
|
||||
onclick={buyGiftCard}
|
||||
disabled={buyingGiftCard || !isBuyCardValid}
|
||||
disabled={buyingGiftCard ||
|
||||
!isBuyCardValid ||
|
||||
buyDailyTotal + buyAmount > DAILY_GIFT_CARD_BUY_LIMIT}
|
||||
class="mt-2 w-full"
|
||||
>
|
||||
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
|
||||
{buyingGiftCard
|
||||
? 'Processing Payment...'
|
||||
: buyLimitReached
|
||||
? 'Daily purchase limit reached'
|
||||
: `Pay ${formatCurrency(buyAmount)}`}
|
||||
</Button>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">
|
||||
Secure payment powered by Square
|
||||
@@ -2479,6 +2575,109 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- My Gift Cards (14-day cooling-off cancellation) -->
|
||||
<Card.Root class="mt-6">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
My Gift Cards
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
Gift cards you purchased online. Under UK law you have 14 days from purchase to cancel
|
||||
and receive a full refund to your original payment method.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loadingMyGiftCards}
|
||||
<Skeleton class="h-16 w-full" />
|
||||
{:else if myGiftCards.length === 0}
|
||||
<p class="py-2 text-center text-gray-500">
|
||||
No online gift card purchases yet. Buy a gift card above to get started.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each myGiftCards as gc (gc.code)}
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="font-mono text-sm font-bold text-gray-900"
|
||||
>{formatCardCode(gc.code)}</div
|
||||
>
|
||||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
<span>Value: <strong>{formatCurrency(gc.amount)}</strong></span>
|
||||
<span>Purchased: {formatShortDate(gc.purchased_at)}</span>
|
||||
{#if gc.expiry_date}
|
||||
<span>Expires: {formatShortDate(gc.expiry_date)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if gc.cancellable}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
cardToCancel = gc;
|
||||
showCancelConfirm = true;
|
||||
}}
|
||||
disabled={cancellingCode === gc.code}
|
||||
>
|
||||
{cancellingCode === gc.code ? 'Cancelling...' : 'Cancel & refund'}
|
||||
</Button>
|
||||
{:else if gc.cancellation_reason}
|
||||
<span class="text-xs text-gray-500 italic">{gc.cancellation_reason}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showCancelConfirm}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Cancel this gift card?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
{cardToCancel
|
||||
? `You will be refunded ${formatCurrency(cardToCancel.amount)} to the payment method you used to buy it, and the gift card will no longer be usable.`
|
||||
: ''}
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<div class="space-y-3 px-6 py-4 text-sm text-muted-foreground">
|
||||
<div class="space-y-2 rounded-lg border bg-blue-50/50 p-3">
|
||||
<p>
|
||||
<strong class="text-foreground">14-day cooling-off period</strong>
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-4">
|
||||
<li>You may cancel an online gift-card purchase within 14 days of buying it.</li>
|
||||
<li>The full amount is refunded to your original payment method.</li>
|
||||
<li>Once cancelled, the gift card cannot be used or redeemed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Keep gift card</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={cancelGiftCard}
|
||||
>Confirm cancellation & refund</AlertDialog.Action
|
||||
>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
{:else if activeTab === 'admin'}
|
||||
<!-- Admin Settings -->
|
||||
<Card.Root>
|
||||
@@ -2718,6 +2917,8 @@
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
maxlength={6}
|
||||
placeholder="6-digit code"
|
||||
bind:value={twoFACode}
|
||||
@@ -2737,6 +2938,8 @@
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
maxlength={6}
|
||||
placeholder="6-digit code"
|
||||
bind:value={twoFADisableCode}
|
||||
@@ -2927,6 +3130,7 @@
|
||||
savedCardsStore.fetch();
|
||||
savedCardsStore.invalidate();
|
||||
fetchGiftCardBalance();
|
||||
fetchMyGiftCards();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
@@ -2970,7 +3174,7 @@
|
||||
<!-- Password Change Modal -->
|
||||
{#if showPasswordModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<Card.Root class="w-full max-w-md">
|
||||
<Card.Root class="w-full max-w-md max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<Card.Header>
|
||||
<Card.Title>Change Password</Card.Title>
|
||||
<Card.Description>Enter your current and new password</Card.Description>
|
||||
@@ -3063,7 +3267,7 @@
|
||||
|
||||
<!-- Delete Account Alert -->
|
||||
<AlertDialog.Root bind:open={showDeleteAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Account?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -161,9 +161,10 @@
|
||||
days).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card. The gift
|
||||
card's remaining balance is incremented and is immediately available for use. Expired gift
|
||||
cards are non-refundable.
|
||||
<strong>Gift card payments</strong>: Refunded back to the original gift card (or, if
|
||||
you paid from your account balance, back to that balance). The gift card's remaining
|
||||
balance is incremented and is immediately available for use. Expired gift cards are
|
||||
non-refundable.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cash payments</strong>: Credited to your account balance, available for immediate
|
||||
@@ -245,6 +246,19 @@
|
||||
standard 14-day statutory cancellation "cooling-off" period under the Consumer Contracts
|
||||
Regulations 2013 does not apply to online bookings scheduled for a specific date or time.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
That exclusion does not apply to gift cards: online gift-card purchases may be
|
||||
cancelled within 14 days for a refund to the original payment method under the
|
||||
Consumer Contracts Regulations 2013. If the card has been partly used, the amount
|
||||
already spent on salon services is not refundable, and the remaining unspent balance
|
||||
is refunded to the original payment method; the card is then cancelled. A card that
|
||||
has been redeemed to an account balance or fully spent cannot be cancelled.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
Where a partly-used card is cancelled, the card is cancelled automatically when the
|
||||
refund is issued, so the remaining balance cannot then be spent. See our Gift Card
|
||||
Terms for the full position.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
If you believe your statutory consumer rights have not been met, you can get free,
|
||||
impartial advice from
|
||||
|
||||
@@ -238,11 +238,11 @@
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'}"
|
||||
></div>
|
||||
<div class="flex-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium">{apt.customer}</div>
|
||||
<div class="text-sm text-gray-600">{apt.service} • {apt.duration} min</div>
|
||||
</div>
|
||||
<Badge class={getStatusColor(apt.status)}>
|
||||
<Badge class={`${getStatusColor(apt.status)} shrink whitespace-normal`}>
|
||||
{apt.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Button size="sm" variant="outline">View</Button>
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
<title>Leave a Tip - Crussell</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto min-h-screen px-4 py-8 sm:max-w-md md:py-12">
|
||||
<div class="mx-auto min-h-screen supports-[height:100dvh]:min-h-dvh px-4 py-8 sm:max-w-md md:py-12">
|
||||
{#if loading || pageState === 'loading'}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -895,10 +895,10 @@
|
||||
else showModal = v;
|
||||
}}
|
||||
>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
|
||||
<DialogOverlay class="fixed inset-0 bg-black/80 backdrop-blur-sm" />
|
||||
<DialogContent
|
||||
hideClose={true}
|
||||
class="fixed top-1/2 left-1/2 z-50 max-h-[95vh] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 border-0 bg-transparent p-0 shadow-none focus:outline-none sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
class="fixed top-1/2 left-1/2 max-h-[95vh] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 border-0 bg-transparent p-0 shadow-none focus:outline-none sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
>
|
||||
<div
|
||||
bind:this={modalContentRef}
|
||||
|
||||
@@ -95,10 +95,32 @@
|
||||
<p class="mb-2 font-medium text-gray-800">Booking Information:</p>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li>Appointment dates, times, services</li>
|
||||
<li>Treatment notes and preferences</li>
|
||||
<li>
|
||||
Treatment notes and preferences (one health-and-safety record — includes
|
||||
allergies, skin sensitivities, and access needs)
|
||||
</li>
|
||||
<li>Allergy and patch test records (health data — special category)</li>
|
||||
<li>Payment history and transaction records</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">Treatment & Safety Notes</h3>
|
||||
<p class="mb-3">
|
||||
Notes about your appointments — colour and preference, lateness, and any allergies,
|
||||
skin sensitivities, or access needs you tell us about — are kept as one
|
||||
health-and-safety record.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
Information about allergies and access needs is health data (special category) under the UK
|
||||
GDPR; we record it so we can treat you safely and make reasonable adjustments (Equality Act
|
||||
2010).
|
||||
</p>
|
||||
<p class="mb-3">These notes are seen only by the salon owner and are never shared or exported.</p>
|
||||
<p class="mb-4">
|
||||
On account deletion the rest of your record is erased or anonymized, and your notes are
|
||||
retained in a form that cannot be traced back to you. We keep them so we can still make safe
|
||||
adjustments if you return, and to defend any future legal claim, for example around an
|
||||
allergic reaction.
|
||||
</p>
|
||||
<p class="mb-2 font-medium text-gray-800">Financial Data:</p>
|
||||
<ul class="mb-4 list-disc space-y-1 pl-5">
|
||||
<li>Gift card codes and balances</li>
|
||||
@@ -165,8 +187,9 @@
|
||||
</ul>
|
||||
<p class="mb-3">
|
||||
<strong>Legal basis:</strong> UK GDPR Article 9(2)(a) — Explicit consent<br />
|
||||
<strong>Retention:</strong> 7 years (insurance requirement) or account deletion (whichever
|
||||
is later)
|
||||
<strong>Retention:</strong> 7 years (insurance requirement); patch-test records are kept
|
||||
unlinked to you if your account is deleted, and allergy/access information held in your
|
||||
treatment notes is retained de-identified (see §3.1).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -217,6 +240,15 @@
|
||||
<td class="px-3 py-2">7 years</td>
|
||||
<td class="px-3 py-2">Insurance requirement</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-3 py-2">Treatment & safety notes (incl. allergy/access information)</td>
|
||||
<td class="px-3 py-2">
|
||||
Retained after account deletion in de-identified form while they may be needed for
|
||||
safety adjustments or legal-claims defence; the rest of the account record is
|
||||
erased at deletion
|
||||
</td>
|
||||
<td class="px-3 py-2">Legitimate interest (safety & legal-claims defence)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-3 py-2">Dormant balances</td>
|
||||
<td class="px-3 py-2">Indefinite (Account ID only)</td>
|
||||
@@ -230,16 +262,30 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-3 mb-3">
|
||||
Data-retention consent is <strong>opt-in</strong>: it is never pre-ticked or assumed, and
|
||||
defaults to unchecked. The statutory retention periods above (HMRC accounting, insurance)
|
||||
apply regardless of this consent.
|
||||
</p>
|
||||
|
||||
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Deletion Process</h3>
|
||||
<p class="mb-2 font-medium text-gray-800">Account deletion (your request):</p>
|
||||
<ol class="mb-3 list-decimal space-y-1 pl-5">
|
||||
<li>You confirm deletion (warning about data loss).</li>
|
||||
<li>If balance exists, transferred to dormant balance system.</li>
|
||||
<li>Account ID sent to you via email.</li>
|
||||
<li>
|
||||
If a balance exists it is retained on your anonymised account record. The dormant-balance
|
||||
recovery registry is populated only when inactive accounts are cleaned up automatically,
|
||||
not at your own deletion request — contact us if you believe a balance is missing.
|
||||
</li>
|
||||
<li>Your Account ID will be sent to you by email once email delivery is available.</li>
|
||||
<li>Personal data anonymized (name, email, phone replaced with placeholders).</li>
|
||||
<li>Two-factor authentication setup is removed.</li>
|
||||
<li>Financial records retained 7 years (HMRC) then aggregated.</li>
|
||||
<li>Allergy records retained 7 years (insurance) then deleted.</li>
|
||||
<li>
|
||||
Treatment and safety notes retained in de-identified form per the policy above (rest of
|
||||
the account record erased).
|
||||
</li>
|
||||
</ol>
|
||||
<p class="mb-3">
|
||||
<strong>Saved cards:</strong> Deleting your account also removes your saved-card
|
||||
@@ -249,7 +295,11 @@
|
||||
</p>
|
||||
<p class="mb-2 font-medium text-gray-800">Inactive account deletion (automatic):</p>
|
||||
<ol class="mb-3 list-decimal space-y-1 pl-5">
|
||||
<li>Warning emails sent at 18/23 months (no balance) or 4/59 months (with balance).</li>
|
||||
<li>
|
||||
Warnings are scheduled at 18/23 months (no balance) or 4/59 months (with balance). Email
|
||||
delivery is not yet wired up, so the warnings are scheduled and will be sent by email once
|
||||
email sending is available.
|
||||
</li>
|
||||
<li>If no activity, account deleted as above.</li>
|
||||
<li>Dormant balance recoverable with Account ID.</li>
|
||||
</ol>
|
||||
|
||||
@@ -96,13 +96,15 @@
|
||||
<p class="mb-3">
|
||||
<strong>If your account has a balance:</strong> your balance becomes dormant and is
|
||||
transferred to our recovery registry. You will receive your
|
||||
<strong>Account ID</strong> by email and can recover your balance at any time by providing it.
|
||||
<strong>Account ID</strong> by email (once email delivery is available) and can recover your
|
||||
balance at any time by providing it.
|
||||
All other personal data is anonymized.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
<strong>Warning:</strong> account deletion is permanent. You will lose all booking history, treatment
|
||||
notes, allergy and patch test records, loyalty stamps and referral codes, and access to your account
|
||||
balance (unless you retain your Account ID).
|
||||
<strong>Warning:</strong> account deletion is permanent. You will lose access to your account,
|
||||
your booking history, loyalty stamps and referral codes, and your account balance (unless you
|
||||
retain your Account ID). Treatment and safety notes are retained after deletion in a form
|
||||
that cannot be traced back to you, and financial records are retained for 7 years (HMRC).
|
||||
</p>
|
||||
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Inactive Account Policy</h3>
|
||||
<p class="mb-2">
|
||||
@@ -115,9 +117,10 @@
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mb-3">
|
||||
Warning emails are sent before deletion (18 months and 23 months for no-balance accounts; 4
|
||||
years and 59 months for accounts with a balance). All warning emails include your Account ID
|
||||
for future balance recovery.
|
||||
Warnings are scheduled before deletion (18 months and 23 months for no-balance accounts; 4
|
||||
years and 59 months for accounts with a balance). The warnings include your Account ID for
|
||||
future balance recovery. Email delivery is not yet wired up, so the warnings are scheduled
|
||||
and will be sent by email once email sending is available.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -125,7 +128,7 @@
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Bookings & Appointments</h2>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li>Bookings are subject to availability.</li>
|
||||
<li>You will receive confirmation via email/SMS.</li>
|
||||
<li>You will receive confirmation on-screen and in your account (via email/SMS once email delivery is available).</li>
|
||||
<li>Some services require a deposit (typically 20–50% of the service cost).</li>
|
||||
</ul>
|
||||
<p class="mb-2 font-medium text-gray-800">Cancellations & rescheduling</p>
|
||||
@@ -188,11 +191,17 @@
|
||||
</h2>
|
||||
<p class="mb-2 font-medium text-gray-800">Gift card expiry</p>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li>Gift cards expire 24 months after last use (rolling expiry).</li>
|
||||
<li>
|
||||
Gift cards expire after the expiry period set in the salon's business settings (24 months
|
||||
after last use by default, and never less than 12 months).
|
||||
</li>
|
||||
<li>
|
||||
“Last use” includes redemption, top-up, balance check, or any admin action.
|
||||
</li>
|
||||
<li>The expiry date is displayed on the gift card and in your account.</li>
|
||||
<li>
|
||||
The expiry date is stored on the gift card's record and is displayed in your account
|
||||
alongside the gift card.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mb-2 font-medium text-gray-800">Account balances</p>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
@@ -208,6 +217,13 @@
|
||||
VAT is charged at the point of gift card purchase, not at redemption. When you pay with gift card
|
||||
balance, no additional VAT is charged (it has already been paid).
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase
|
||||
within 14 days for a refund to the original payment method. If the card has been partly used
|
||||
on salon services, only the unspent balance is refunded and the card is then cancelled. A card
|
||||
that has been redeemed to an account balance or fully spent cannot be cancelled. See our Gift
|
||||
Card Terms for the full position.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-gray-200 pt-6">
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
<title>Leave a Tip - Crussell</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto min-h-screen px-4 py-8 sm:max-w-md md:py-12">
|
||||
<div class="mx-auto min-h-screen supports-[height:100dvh]:min-h-dvh px-4 py-8 sm:max-w-md md:py-12">
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
|
||||
Reference in New Issue
Block a user