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