fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking
Money-safety: - Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation - Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged - CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard) - Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse - Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID GDPR / security: - Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010 - square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2 - Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel - Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit Frontend: - Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen) - Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh - Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy S3: - Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific) Tests/docs: - 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
This commit is contained in:
@@ -774,7 +774,7 @@
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content
|
||||
class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
>
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -557,7 +557,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content
|
||||
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
>
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1072,7 +1072,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
{/if}
|
||||
|
||||
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
|
||||
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
|
||||
<Modal.Content class="max-w-[calc(100%-2rem)]">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Cancel Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
@@ -1176,7 +1176,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto">
|
||||
<Modal.Content class="max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Leave a Tip</Modal.Title>
|
||||
<Modal.Description>Show your appreciation for great service</Modal.Description>
|
||||
|
||||
@@ -379,7 +379,7 @@
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content
|
||||
class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-lg md:max-w-2xl"
|
||||
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-lg md:max-w-2xl"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Approve Booking</Modal.Title>
|
||||
@@ -704,7 +704,7 @@
|
||||
|
||||
<!-- Decline Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showDeclineConfirm}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Decline this booking?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content
|
||||
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||
>
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -815,7 +815,7 @@
|
||||
|
||||
{#if selectedBooking && showCancelModal}
|
||||
<Modal.Root bind:open={showCancelModal}>
|
||||
<Modal.Content class="!z-[80] max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<Modal.Content class="max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Cancel Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
|
||||
@@ -651,7 +651,7 @@
|
||||
|
||||
<!-- Remove Service Confirmation -->
|
||||
<AlertDialog.Root bind:open={showRemoveConfirm}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Remove Service?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
|
||||
<!-- Deny Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showDenyConfirm}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Deny this change request?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
redeemed_by?: string;
|
||||
is_inventory?: boolean;
|
||||
last_used_at?: string;
|
||||
payment_id?: string;
|
||||
cancellable?: boolean;
|
||||
cancellation_reason?: string;
|
||||
}
|
||||
|
||||
interface UserBalance {
|
||||
@@ -85,6 +88,11 @@
|
||||
|
||||
let selectedCardId = $state<string | null>(null);
|
||||
|
||||
// Cancellation (14-day cooling-off right) state
|
||||
let showCancelCardModal = $state(false);
|
||||
let cancellingCard = $state(false);
|
||||
let cancelTargetCard = $state<GiftCard | null>(null);
|
||||
|
||||
// Form inputs
|
||||
let generateAmount = $state('');
|
||||
let generateUserQuery = $state('');
|
||||
@@ -193,12 +201,16 @@
|
||||
const generateError = $derived(
|
||||
generateAmount && (isNaN(Number(generateAmount)) || Number(generateAmount) <= 0)
|
||||
? 'Must be a valid positive number'
|
||||
: ''
|
||||
: generateAmount && Number(generateAmount) > 250
|
||||
? 'Amount cannot exceed £250 per transaction'
|
||||
: ''
|
||||
);
|
||||
const topUpError = $derived(
|
||||
topUpAmount && (isNaN(Number(topUpAmount)) || Number(topUpAmount) <= 0)
|
||||
? 'Must be a valid positive number'
|
||||
: ''
|
||||
: topUpAmount && Number(topUpAmount) > 250
|
||||
? 'Amount cannot exceed £250 per transaction'
|
||||
: ''
|
||||
);
|
||||
const transferAmountError = $derived(
|
||||
transferAmount && (isNaN(Number(transferAmount)) || Number(transferAmount) <= 0)
|
||||
@@ -249,7 +261,18 @@
|
||||
const data = await res.json();
|
||||
summary.total_unclaimed = data.total_unclaimed;
|
||||
summary.total_user_balances = data.total_user_balances;
|
||||
cards = data.gift_cards;
|
||||
cards = (data.gift_cards || []).map(
|
||||
(
|
||||
gc: GiftCard & {
|
||||
cancellable?: boolean;
|
||||
cancellation_reason?: string;
|
||||
}
|
||||
) => ({
|
||||
...gc,
|
||||
cancellable: gc.cancellable === true,
|
||||
cancellation_reason: gc.cancellation_reason || ''
|
||||
})
|
||||
);
|
||||
balances = data.user_balances;
|
||||
totalBalanceRecords = data.ub_total ?? data.user_balances?.length ?? 0;
|
||||
currentPage = data.page;
|
||||
@@ -447,6 +470,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openCancelCardModal(gc: GiftCard) {
|
||||
cancelTargetCard = gc;
|
||||
showCancelCardModal = true;
|
||||
}
|
||||
|
||||
async function cancelGiftCard() {
|
||||
if (!cancelTargetCard) return;
|
||||
cancellingCard = true;
|
||||
try {
|
||||
const body: Record<string, unknown> = { code: cancelTargetCard.id };
|
||||
if (cancelTargetCard.payment_id) body.payment_id = cancelTargetCard.payment_id;
|
||||
const res = await apiFetch('/api/admin/gift-cards/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Gift card cancelled — unspent balance refunded');
|
||||
showCancelCardModal = false;
|
||||
cancelTargetCard = null;
|
||||
await fetchGiftCards();
|
||||
await fetchExpiredBalances();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(extractErrorMessage(errText) || 'Failed to cancel gift card');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error cancelling gift card');
|
||||
} finally {
|
||||
cancellingCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetGenerateModal() {
|
||||
generateStep = 'type';
|
||||
generateType = 'code';
|
||||
@@ -520,7 +578,7 @@
|
||||
setModalStep(actionType, 'success');
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
paymentError = extractErrorMessage(await res.text());
|
||||
setModalStep(actionType, 'error');
|
||||
}
|
||||
} catch {
|
||||
@@ -565,7 +623,7 @@
|
||||
await fetchGiftCards();
|
||||
}
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
paymentError = extractErrorMessage(await res.text());
|
||||
setModalStep(actionType, 'error');
|
||||
}
|
||||
} catch {
|
||||
@@ -661,7 +719,7 @@
|
||||
setModalStep(actionType, 'success');
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
paymentError = extractErrorMessage(await res.text());
|
||||
setModalStep(actionType, 'error');
|
||||
}
|
||||
} catch {
|
||||
@@ -699,7 +757,7 @@
|
||||
topUpStep = 'success';
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
paymentError = extractErrorMessage(await res.text());
|
||||
topUpStep = 'error';
|
||||
}
|
||||
} catch {
|
||||
@@ -866,7 +924,10 @@
|
||||
Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button onclick={() => (showGenerateModal = true)}>Generate Gift Card</Button>
|
||||
<div class="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Button onclick={() => (showGenerateModal = true)}>Generate Gift Card</Button>
|
||||
<p class="text-xs text-gray-500">Admin gift-card value is limited to £5,000 per day.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
@@ -939,6 +1000,14 @@
|
||||
</div>
|
||||
|
||||
{#if activeSection === 'cards' || activeSection === 'expired_cards'}
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800"
|
||||
>
|
||||
Gift cards purchased online by customers can be cancelled by the customer themselves from
|
||||
their account within 14 days of purchase (UK cooling-off right). Cancellations are made by
|
||||
the customer; no action is needed here.
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex gap-2">
|
||||
<Input
|
||||
placeholder="Search by gift card code..."
|
||||
@@ -1073,7 +1142,23 @@
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
{:else}
|
||||
{/if}
|
||||
{#if gc.cancellable}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openCancelCardModal(gc)}
|
||||
>
|
||||
Cancel & refund
|
||||
</Button>
|
||||
{:else if gc.cancellation_reason}
|
||||
<span
|
||||
class="max-w-[12rem] truncate text-xs text-gray-400 italic"
|
||||
title={gc.cancellation_reason}
|
||||
>
|
||||
{gc.cancellation_reason}
|
||||
</span>
|
||||
{:else if gc.redeemed_by}
|
||||
<span class="text-xs text-gray-400 italic">No actions available</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1157,7 +1242,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<div class="flex flex-wrap justify-end gap-2 pt-1">
|
||||
{#if !gc.redeemed_by}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1182,6 +1267,23 @@
|
||||
Transfer
|
||||
</Button>
|
||||
{/if}
|
||||
{#if gc.cancellable}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openCancelCardModal(gc)}
|
||||
class="flex-1"
|
||||
>
|
||||
Cancel & refund
|
||||
</Button>
|
||||
{:else if gc.cancellation_reason}
|
||||
<span
|
||||
class="max-w-full truncate text-xs text-gray-400 italic"
|
||||
title={gc.cancellation_reason}
|
||||
>
|
||||
{gc.cancellation_reason}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1796,6 +1898,7 @@
|
||||
placeholder="e.g. 50.00"
|
||||
bind:value={generateAmount}
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Max £250 per transaction.</p>
|
||||
{#if generateError}
|
||||
<span class="text-xs font-medium text-red-500">{generateError}</span>
|
||||
{/if}
|
||||
@@ -1946,7 +2049,7 @@
|
||||
<Input
|
||||
id="generate-cash-tendered"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
inputmode="decimal"
|
||||
placeholder="e.g. 50.00"
|
||||
value={cashAmount}
|
||||
oninput={(e) => (cashAmount = e.currentTarget.value)}
|
||||
@@ -2103,6 +2206,7 @@
|
||||
placeholder="e.g. 20.00"
|
||||
bind:value={topUpAmount}
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Max £250 per transaction.</p>
|
||||
{#if topUpError}
|
||||
<span class="text-xs font-medium text-red-500">{topUpError}</span>
|
||||
{/if}
|
||||
@@ -2217,7 +2321,7 @@
|
||||
<Input
|
||||
id="topup-cash-tendered"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
inputmode="decimal"
|
||||
placeholder="e.g. 50.00"
|
||||
value={cashAmount}
|
||||
oninput={(e) => (cashAmount = e.currentTarget.value)}
|
||||
@@ -2348,3 +2452,44 @@
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Cancel & Refund Modal -->
|
||||
<Modal.Root bind:open={showCancelCardModal}>
|
||||
<Modal.Content class="max-w-md">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Cancel & Refund Gift Card</Modal.Title>
|
||||
<Modal.Description>
|
||||
Cancel gift card {formatCardCode(cancelTargetCard?.id ?? '')} and refund the remaining balance.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<p class="font-semibold">14-day statutory cancellation right</p>
|
||||
<p class="mt-1 text-xs text-amber-700">
|
||||
The unspent balance will be refunded to the original payment method. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if cancelTargetCard}
|
||||
<div class="flex justify-between rounded-lg border bg-gray-50 p-4 text-sm">
|
||||
<span class="text-base font-semibold text-gray-700">Remaining Balance</span>
|
||||
<span class="text-xl font-bold text-gray-900"
|
||||
>{formatCurrency(cancelTargetCard.amount_remaining)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => (showCancelCardModal = false)}>Keep Card</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={cancelGiftCard}
|
||||
disabled={cancellingCard || !cancelTargetCard}
|
||||
>
|
||||
{cancellingCard ? 'Cancelling...' : 'Cancel & Refund'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
|
||||
<!-- Delete Exception Confirmation -->
|
||||
<AlertDialog.Root bind:open={showDeleteExceptionAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete exception group?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -968,7 +968,7 @@
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Confirm Upload</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="!z-[60] sm:max-w-[425px]">
|
||||
<Modal.Content class="sm:max-w-[425px]">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Record Patch Test</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
@@ -427,7 +427,7 @@
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-4xl">
|
||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-4xl">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Reschedule Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
let giftCardAmount = $state('25');
|
||||
let showGiftCardInput = $state(false);
|
||||
|
||||
// Gift card funding is capped at £250 per transaction (the backend enforces
|
||||
// the same limit) — mirror the cap client-side so the till cannot queue an
|
||||
// oversized gift card.
|
||||
const GIFT_CARD_MAX_AMOUNT = 250;
|
||||
|
||||
let paymentMethod = $state<TillPaymentMethod>('cash');
|
||||
let onlineSquareCardReady = $state(false);
|
||||
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
@@ -194,7 +199,19 @@
|
||||
// The backend till sale API currently only accepts item_type 'gift_card', so
|
||||
// retail items cannot be charged yet — gate the Charge button to gift-card-only carts.
|
||||
const hasRetailItems = $derived(cart.some((i) => i.label !== 'Gift Card'));
|
||||
const canCharge = $derived(cart.length > 0 && !hasRetailItems && subtotal > 0);
|
||||
const canCharge = $derived(
|
||||
cart.length > 0 &&
|
||||
!hasRetailItems &&
|
||||
subtotal > 0 &&
|
||||
!cart.some((i) => i.price > GIFT_CARD_MAX_AMOUNT)
|
||||
);
|
||||
|
||||
// Client-side parity with the per-transaction gift card cap: the amount
|
||||
// typed into the gift-card input must not exceed £250.
|
||||
const parsedGiftCardAmount = $derived(parseFloat(giftCardAmount));
|
||||
const giftCardAmountTooHigh = $derived(
|
||||
!isNaN(parsedGiftCardAmount) && parsedGiftCardAmount > GIFT_CARD_MAX_AMOUNT
|
||||
);
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
|
||||
@@ -212,6 +229,9 @@
|
||||
function addGiftCard() {
|
||||
const amt = parseFloat(giftCardAmount);
|
||||
if (isNaN(amt) || amt <= 0) return;
|
||||
// The Add button is disabled via `giftCardAmountTooHigh`, but the
|
||||
// input's Enter key bypasses that — reject here too (defense in depth).
|
||||
if (amt > GIFT_CARD_MAX_AMOUNT) return;
|
||||
addItem('Gift Card', amt);
|
||||
giftCardAmount = '25';
|
||||
showGiftCardInput = false;
|
||||
@@ -255,6 +275,10 @@
|
||||
toast.error('Cart is empty');
|
||||
return;
|
||||
}
|
||||
if (cart.some((item) => item.price > GIFT_CARD_MAX_AMOUNT)) {
|
||||
toast.error(`Gift card amount exceeds maximum (£${GIFT_CARD_MAX_AMOUNT})`);
|
||||
return;
|
||||
}
|
||||
if (hasRetailItems) {
|
||||
toast.error(
|
||||
'Retail items cannot be charged yet — the till API currently supports gift card sales only'
|
||||
@@ -390,28 +414,40 @@
|
||||
</Button>
|
||||
<div class="relative">
|
||||
{#if showGiftCardInput}
|
||||
<div class="flex gap-1">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute top-1/2 left-2 -translate-y-1/2 text-xs text-gray-400"
|
||||
>£</span
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute top-1/2 left-2 -translate-y-1/2 text-xs text-gray-400"
|
||||
>£</span
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={giftCardAmount}
|
||||
max={GIFT_CARD_MAX_AMOUNT}
|
||||
class="h-9 pl-5 text-sm"
|
||||
disabled={processing}
|
||||
error={giftCardAmountTooHigh ? 'Gift card amount exceeds maximum' : ''}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addGiftCard();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={addGiftCard}
|
||||
class="h-9 px-2 text-xs"
|
||||
disabled={processing || giftCardAmountTooHigh}>Add</Button
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={giftCardAmount}
|
||||
class="h-9 pl-5 text-sm"
|
||||
disabled={processing}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addGiftCard();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={addGiftCard}
|
||||
class="h-9 px-2 text-xs"
|
||||
disabled={processing}>Add</Button
|
||||
{#if giftCardAmountTooHigh}
|
||||
<p class="text-xs text-red-700"
|
||||
>Gift card amount exceeds maximum (£{GIFT_CARD_MAX_AMOUNT})</p
|
||||
>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground"
|
||||
>Gift card limit £{GIFT_CARD_MAX_AMOUNT} per transaction</p
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -556,7 +592,7 @@
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="flex h-9 w-9 min-w-9 items-center justify-center rounded border text-base text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => updateQty(item.id, -1)}
|
||||
>
|
||||
@@ -565,7 +601,7 @@
|
||||
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="flex h-9 w-9 min-w-9 items-center justify-center rounded border text-base text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => updateQty(item.id, 1)}
|
||||
>
|
||||
@@ -577,12 +613,12 @@
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove item"
|
||||
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="ml-1 flex h-9 w-9 min-w-9 items-center justify-center rounded text-base text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => removeItem(item.id)}
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -610,14 +646,14 @@
|
||||
>Payment Method</span
|
||||
>
|
||||
<div
|
||||
class="mt-2 grid gap-2 {availablePaymentMethods.length > 3
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-3'}"
|
||||
class="mt-2 grid grid-cols-2 gap-2 {availablePaymentMethods.length > 3
|
||||
? ''
|
||||
: 'sm:grid-cols-3'}"
|
||||
>
|
||||
{#each availablePaymentMethods as m (m.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
|
||||
class="rounded-lg border py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
|
||||
m.key
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
|
||||
@@ -956,7 +956,7 @@
|
||||
</Modal.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showDeleteAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -721,7 +721,7 @@
|
||||
|
||||
{#if selectedUser}
|
||||
<AlertDialog.Root bind:open={showRemove2FAConfirm}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Remove two-factor authentication?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -1996,10 +1996,17 @@
|
||||
|
||||
{#if loadingAvailableHours}
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex w-56 items-center justify-center border-l p-6"
|
||||
class="absolute inset-y-0 right-0 hidden w-56 items-center justify-center border-l p-6 md:flex"
|
||||
>
|
||||
<p class="text-sm text-gray-500">Loading times...</p>
|
||||
</div>
|
||||
<!-- md:hidden twin: time slots render in-flow below the calendar on mobile -->
|
||||
<div class="flex items-center justify-center gap-2 border-t p-6 md:hidden">
|
||||
<div
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-amber-600"
|
||||
></div>
|
||||
<p class="text-sm text-gray-500">Loading available hours...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TimeSlotPicker
|
||||
date={selectedDate}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
// Without this the stable isDateUnavailable reference may cause stale availability
|
||||
// after workingHours/availableHours are updated (e.g. after month data loading).
|
||||
isDateUnavailable={(d) => isDateUnavailable(d)}
|
||||
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
|
||||
class="bg-transparent p-0 [--cell-size:--spacing(11)] data-unavailable:line-through data-unavailable:opacity-100 max-sm:[--cell-size:--spacing(10)] md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
|
||||
weekdayFormat="short"
|
||||
{minValue}
|
||||
{maxValue}
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
<input
|
||||
id={consentId}
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary accent-primary"
|
||||
class="mt-0.5 h-5 w-5 rounded border-gray-300 text-primary accent-primary"
|
||||
bind:checked={saveCard}
|
||||
/>
|
||||
<span>
|
||||
|
||||
@@ -781,7 +781,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<Dialog.Content class="max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
@@ -800,20 +800,20 @@
|
||||
<div class="mb-2 text-sm font-medium">
|
||||
{service.service_name || 'Unknown Service'}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="text-xs text-gray-500">£</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex={-1}
|
||||
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
class="flex h-10 w-24 min-w-0 rounded-md border border-input bg-background px-2 py-1 text-base ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none md:text-sm"
|
||||
value={serviceOverrides[service.service_id]?.price ??
|
||||
service.price?.toFixed(2) ??
|
||||
'0.00'}
|
||||
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
|
||||
/>
|
||||
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
|
||||
<span class="text-xs text-amber-600">
|
||||
<span class="min-w-0 text-xs text-amber-600">
|
||||
(was £{serviceOverrides[service.service_id].originalPrice.toFixed(2)})
|
||||
</span>
|
||||
{/if}
|
||||
@@ -1301,7 +1301,7 @@
|
||||
oninput={handleGiftCardInput}
|
||||
placeholder="XXXX-XXXX-XXXX"
|
||||
maxlength={14}
|
||||
class="mt-1 font-mono text-lg tracking-widest"
|
||||
class="mt-1 font-mono text-base tracking-wide"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Enter the 12-character code printed on the gift card
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
const cardStyle: SquareCardClassSelectors = {
|
||||
input: {
|
||||
fontSize: '14px', // md:text-sm (text-base md:text-sm — desktop size)
|
||||
// Square caps fontSize at 16px exactly. 16px also matches the shared
|
||||
// Input's mobile size (text-base md:text-sm in ui/input/input.svelte),
|
||||
// which stops iOS Safari from auto-zooming the cross-origin iframe on
|
||||
// focus (any field under 16px triggers the zoom the page can't undo).
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
fontWeight: '400',
|
||||
color: 'oklch(0.129 0.042 264.695)', // --foreground
|
||||
@@ -278,5 +282,11 @@
|
||||
message="The secure card form failed to load. Please try again or use a saved card."
|
||||
/>
|
||||
{:else}
|
||||
<div id={uniqueId} bind:this={containerEl}></div>
|
||||
<div id={uniqueId} bind:this={containerEl} class="min-h-[120px]">
|
||||
{#if !ready}
|
||||
<!-- Keep the layout height stable while card.attach() resolves so the
|
||||
Pay button below doesn't jump mid-checkout on mobile. -->
|
||||
<div class="flex h-9 items-center text-sm text-gray-400">Loading secure card form...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md">
|
||||
<Dialog.Content class="max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
|
||||
{#if booking.id}
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
</Modal.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showDeleteAlert}>
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
||||
<AlertDialog.Description
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
|
||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import { nextZIndex } from '../dialog/zindex.js';
|
||||
|
||||
// Same dynamic z-index stack as the base dialog — see dialog-content.svelte.
|
||||
// Claimed lazily in open order: 0 until the Content's data-state becomes
|
||||
// "open" (see the $effect below), never at component instantiation.
|
||||
let z = $state(0);
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -11,16 +17,68 @@
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
|
||||
} = $props();
|
||||
|
||||
// Strip any z-index utility (including `!important` overrides) from the
|
||||
// consumer's class — the dynamic stack is authoritative (see dialog-content).
|
||||
function stripZIndexClasses(classes: unknown): string {
|
||||
return typeof classes === 'string'
|
||||
? classes.replace(/\s*!?z-(?:\[[^\]]+\]|\d+|auto)/g, '')
|
||||
: '';
|
||||
}
|
||||
|
||||
// Claim the z-index when the dialog OPENS (see dialog-content.svelte for the
|
||||
// full rationale): bits-ui keeps the Content mounted with data-state=
|
||||
// "closed" from page load, so claiming at instantiation would pre-claim
|
||||
// stack slots in template order and break 3-level / reverse nesting. Watch
|
||||
// the Content element's `data-state` attribute and claim the next slot the
|
||||
// moment it becomes "open" (or immediately if it already is — e.g. a
|
||||
// conditionally mounted `{#if}` alert dialog).
|
||||
//
|
||||
// The claim is NOT guarded by `z === 0` — bits-ui keeps the Content Svelte
|
||||
// component alive for the page lifetime (only the portal node is detached
|
||||
// on close), so a once-claimed `z` would freeze the stack in first-open
|
||||
// order on reopen. Track the last observed data-state in a plain local and
|
||||
// claim a FRESH slot on every closed→open transition. The effect reads
|
||||
// only `ref` (never `z`), so it re-runs solely when the ref is (re)assigned.
|
||||
$effect(() => {
|
||||
const element = ref;
|
||||
if (!element) return;
|
||||
|
||||
// Last data-state observed on this element — a plain local, invisible
|
||||
// to reactivity on purpose (see dialog-content.svelte).
|
||||
let lastState: string | null = null;
|
||||
|
||||
const claim = () => {
|
||||
const state = element.getAttribute('data-state');
|
||||
// Exactly one claim per closed→open transition — initial open and
|
||||
// every reopen — never on open→open, and not re-fired if the
|
||||
// observer delivers the same transition more than once.
|
||||
if (state === 'open' && lastState !== 'open') {
|
||||
z = nextZIndex();
|
||||
}
|
||||
lastState = state;
|
||||
};
|
||||
|
||||
// The dialog may already be open the first time the effect runs (the
|
||||
// ref is assigned after the element mounts) — claim immediately so the
|
||||
// initial state is captured as the lastState baseline.
|
||||
claim();
|
||||
|
||||
const observer = new MutationObserver(claim);
|
||||
observer.observe(element, { attributes: true, attributeFilter: ['data-state'] });
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogOverlay style={z > 0 ? `z-index: ${z}` : undefined} />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="alert-dialog-content"
|
||||
style={z > 0 ? `z-index: ${z}` : undefined}
|
||||
class={cn(
|
||||
'mm:max-w-lg fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
'mm:max-w-lg fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
stripZIndexClasses(className)
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="alert-dialog-overlay"
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import * as Dialog from './index.js';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import { nextZIndex } from './zindex.js';
|
||||
|
||||
// Claimed z-index slot for this Content. 0 until the dialog first opens; the
|
||||
// slot is claimed lazily in OPEN ORDER — the moment data-state flips to
|
||||
// "open" (see the $effect below), never at component instantiation. bits-ui
|
||||
// keeps every Content on the page mounted (data-state="closed") from load
|
||||
// time, so claiming at instantiation would burn stack slots in template
|
||||
// order and break 3-level / reverse nesting. Portals all mount into
|
||||
// document.body as siblings, so a higher z-index is the only thing
|
||||
// separating them.
|
||||
let z = $state(0);
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -17,27 +28,95 @@
|
||||
children: Snippet;
|
||||
hideClose?: boolean;
|
||||
} = $props();
|
||||
|
||||
// The dynamic stack is authoritative: strip any z-index utility (including
|
||||
// `!important` overrides like `!z-[60]`) consumers pass via `class`. A plain
|
||||
// inline style loses to `!important` classes in the cascade, so the tokens
|
||||
// must be removed rather than merely overridden.
|
||||
function stripZIndexClasses(classes: unknown): string {
|
||||
return typeof classes === 'string'
|
||||
? classes.replace(/\s*!?z-(?:\[[^\]]+\]|\d+|auto)/g, '')
|
||||
: '';
|
||||
}
|
||||
|
||||
// Claim the z-index when the dialog OPENS, not when this component mounts.
|
||||
// Watch the Content element's `data-state` attribute with a MutationObserver:
|
||||
// the moment it flips to "open" (or is already "open" when the ref first
|
||||
// lands — e.g. a conditionally mounted `{#if open}` dialog), claim the next
|
||||
// slot. Stacking therefore follows OPEN ORDER, so the most recently opened
|
||||
// dialog — at any nesting depth — always paints on top. The observer is
|
||||
// torn down with the effect.
|
||||
//
|
||||
// The claim is NOT guarded by `z === 0`: bits-ui keeps every Content Svelte
|
||||
// component alive for the page lifetime (the portal node is detached on
|
||||
// close, but the component — and its `z` — survive), so a once-claimed `z`
|
||||
// would freeze the stack in first-open order on reopen. Instead we track
|
||||
// the last observed data-state in a plain local and claim a FRESH slot on
|
||||
// every closed→open transition. The effect must not read `z` (that would
|
||||
// re-run it when a slot is claimed, tearing down the observer and resetting
|
||||
// the baseline); it reads only `ref`, so it re-runs solely when the ref is
|
||||
// (re)assigned — and assignment to `z` inside the effect is not a
|
||||
// dependency.
|
||||
$effect(() => {
|
||||
const element = ref;
|
||||
if (!element) return;
|
||||
|
||||
// Last data-state observed on this element. A plain local (not `$state`):
|
||||
// bookkeeping for the claim logic, deliberately invisible to reactivity.
|
||||
let lastState: string | null = null;
|
||||
|
||||
const claim = () => {
|
||||
const state = element.getAttribute('data-state');
|
||||
// Exactly one claim per closed→open transition — the initial open
|
||||
// and every reopen — never on open→open, and not re-fired if the
|
||||
// observer delivers the same transition more than once.
|
||||
if (state === 'open' && lastState !== 'open') {
|
||||
z = nextZIndex();
|
||||
}
|
||||
lastState = state;
|
||||
};
|
||||
|
||||
// The dialog may already be open the first time the effect runs (the
|
||||
// ref is assigned after the element mounts) — claim immediately so the
|
||||
// initial state is captured as the lastState baseline.
|
||||
claim();
|
||||
|
||||
const observer = new MutationObserver(claim);
|
||||
observer.observe(element, { attributes: true, attributeFilter: ['data-state'] });
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay
|
||||
class={typeof className === 'string' && /(!?z-\[[^\]]+\]|!?z-\d+)/.test(className)
|
||||
? className.match(/(!?z-\[[^\]]+\]|!?z-\d+)/)?.[0]
|
||||
: ''}
|
||||
/>
|
||||
<Dialog.Overlay style={z > 0 ? `z-index: ${z}` : undefined} />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
style={z > 0 ? `z-index: ${z}` : undefined}
|
||||
class={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
|
||||
className
|
||||
// Mobile-first: on <sm the dialog is a bottom sheet pinned to the
|
||||
// bottom edge (max-h uses dvh so it shrinks above the iOS keyboard
|
||||
// instead of trapping the primary action underneath it), with
|
||||
// safe-area padding so the last row clears the home indicator.
|
||||
// Desktop (sm+) keeps the original centered dialog via the sm:
|
||||
// overrides below — media-query utilities emit after the base ones,
|
||||
// so the center/re-align wins on larger screens.
|
||||
//
|
||||
// max-h is split into a plain `max-h-[90vh]` (shared by every
|
||||
// consumer's identical non-breakpoint override, so twMerge dedupes
|
||||
// to the same value on sm+) plus a `max-sm:`-scoped dvh value. The
|
||||
// media-query variant is a different twMerge group than the plain
|
||||
// utility, so it survives consumer `max-h-*` overrides that would
|
||||
// otherwise wipe the keyboard-safe mobile height.
|
||||
'fixed bottom-0 left-0 right-0 grid w-full max-w-none translate-x-0 translate-y-0 gap-4 rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-h-[90vh] max-sm:max-h-[calc(100dvh-4rem)] overflow-y-auto sm:top-[50%] sm:left-[50%] sm:right-auto sm:bottom-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
|
||||
stripZIndexClasses(className)
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if !hideClose}
|
||||
<DialogPrimitive.Close
|
||||
class="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
class="absolute top-4 right-4 flex h-10 w-10 items-center justify-center rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
class={cn('flex flex-col gap-2 pr-10 text-center sm:text-left', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Module-level z-index stack for nested dialogs.
|
||||
*
|
||||
* Every dialog/alert-dialog Content mounts its portal into `document.body`, so
|
||||
* sibling portals stack purely by z-index — DOM order across separate portals
|
||||
* is irrelevant. A static `z-50` on every dialog meant a modal opened from
|
||||
* inside another modal rendered *behind* it. Instead, each open dialog claims
|
||||
* the next slot in the stack (50, 60, 70, …), so arbitrarily deep nesting
|
||||
* always paints newest-on-top.
|
||||
*
|
||||
* The first dialog is exactly 50 to preserve its historic relationship with the
|
||||
* fixed navbar (also `z-50`): the dialog portal is appended to the body after
|
||||
* the navbar, so equal z-index + later DOM order keeps the dialog above it.
|
||||
* Each subsequent open dialog climbs by 10, leaving headroom for portal
|
||||
* internals at the same level.
|
||||
*
|
||||
* Plain module state (no `$state`) on purpose — this is a dumb counter, not
|
||||
* reactive UI state, and `$state` would add reactivity overhead per mount.
|
||||
*/
|
||||
let top = 40;
|
||||
|
||||
/** Claim the next z-index for a newly opened dialog (50, 60, 70, …). */
|
||||
export function nextZIndex(): number {
|
||||
top += 10;
|
||||
// Guard against absurd growth: an extremely long-lived page (days of open
|
||||
// dialogs, or a session that opens thousands of them) could otherwise push
|
||||
// the stack past the 32-bit int range and silently break z-index. Wrap back
|
||||
// to the base so the stack can never overflow.
|
||||
if (top > 2147483640) {
|
||||
top = 50;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
/** The current top of the stack (the last claimed z-index). */
|
||||
export function currentZIndex(): number {
|
||||
return top;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the stack to its base. Called once from the root layout on mount so
|
||||
* hot reloads / repeated test runs don't let the counter climb forever.
|
||||
*/
|
||||
export function resetZIndexStack(): void {
|
||||
top = 40;
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30',
|
||||
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 md:h-9 md:min-h-9',
|
||||
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
@@ -42,7 +42,7 @@
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
|
||||
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:h-9 md:min-h-9 md:text-sm dark:bg-input/30',
|
||||
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
|
||||
Reference in New Issue
Block a user