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:
2026-08-22 00:34:50 +01:00
parent 9111258461
commit 78e6d00dc5
89 changed files with 7702 additions and 853 deletions
@@ -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"
>&pound;</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"
>&pound;</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 (&pound;{GIFT_CARD_MAX_AMOUNT})</p
>
{/if}
<p class="text-xs text-muted-foreground"
>Gift card limit &pound;{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 -1
View File
@@ -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} />
+217 -13
View File
@@ -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 &amp; 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
+2 -2
View File
@@ -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">
+2 -2
View File
@@ -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 &mdash; includes
allergies, skin sensitivities, and access needs)
</li>
<li>Allergy and patch test records (health data &mdash; 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 &amp; Safety Notes</h3>
<p class="mb-3">
Notes about your appointments &mdash; colour and preference, lateness, and any allergies,
skin sensitivities, or access needs you tell us about &mdash; 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) &mdash; 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 &sect;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 &amp; 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 &amp; 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 &mdash; 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>
+26 -10
View File
@@ -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 &amp; 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&ndash;50% of the service cost).</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Cancellations &amp; 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>
&ldquo;Last use&rdquo; 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">
+1 -1
View File
@@ -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">