Files
Crussell/frontend/src/lib/components/payments/UserPaymentModal.svelte
T
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00

1341 lines
48 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking } from '$lib/types/booking';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationOutcome,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
const LOYALTY_DISCOUNT_RATE = 0.1;
interface Props {
booking: Booking;
onClose: () => void;
onComplete: () => void;
canSaveCards?: boolean;
defaultPaymentType?: 'full' | 'partial' | 'deposit';
}
const {
booking,
onClose,
onComplete,
canSaveCards = false,
defaultPaymentType
}: Props = $props();
// Explicit consent: whether the new card is saved for next time. Toggled by
// the checkbox inside CardSelection; defaults to false (opt-in).
let saveCard = $state(false);
// B6/B10: saved-card charges (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge (card.tokenize with
// verificationDetails) is in flight — the challenge is out-of-band (the
// buyer approves in their banking app), so the form shows a waiting panel
// and blocks modal close until it resolves.
let waitingForSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
});
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
let status = $state<PaymentStatus>('idle');
let error = $state<string | null>(null);
// Cached idempotency key per payment attempt (amount + type + card): reused
// on retry so a lost-response retry dedups instead of double-charging,
// regenerated when any of those change. Matches the tip-flow pattern.
let payIdempotencyKey = $state('');
let payKeyedAmount = $state(0);
let payKeyedType = $state('');
let payKeyedCard = $state('');
// Cached nonce for the new-card form: tokenization is one-shot, so a retry
// reuses this token instead of re-tokenizing (backend idempotency dedups).
let newCardNonce = $state('');
// Cached SCA verification token paired with newCardNonce (both one-shot,
// reused together on retry). The verification token is amount-bound, so a
// changed payment amount invalidates the cached pair.
let newCardVerificationToken = $state('');
let newCardTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let newCardTokenizedAt = $state(0);
// Save intent at tokenization time: the SCA verification token is bound to
// CHARGE vs CHARGE_AND_STORE, so toggling the save-card checkbox after a
// tokenize must force a fresh tokenization rather than reuse a token minted
// with the wrong intent.
let newCardTokenizedForSaveCard = $state(false);
let paymentResult = $state<{
id: string;
amount: number;
card_brand?: string;
card_last4?: string;
payment_type: string;
} | null>(null);
// Card selection state — cards and loading live in the shared savedCards
// store so all payment surfaces fetch /api/user/payment-methods identically.
let selectedCardId = $state('');
let cardSelectionValid = $state(false);
let cardSelection = $state<CardSelection | null>(null);
let stamps = $state(0);
let useLoyalty = $state(false);
// Partial payment amount (in pounds, user enters)
let partialAmount = $state<string>('');
let lastValidPartialAmount = $state<string>('');
// Campaign discount preview — fetched on mount to show eligible discounts
let discountPreview = $state<{
eligible: boolean;
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
original_total: number;
discounted_total: number;
} | null>(null);
// Derived values
const depositOutstanding = $derived(booking.deposit_required && !booking.deposit_paid);
// Auto-select a sensible default payment type based on the booking's deposit
// state. The backend will split the charge into deposit + non-deposit records
// when appropriate, so this choice mainly controls the button label and amount.
const defaultType = $derived(defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full'));
// eslint-disable-next-line svelte/prefer-writable-derived
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
$effect(() => {
paymentType = defaultType as 'full' | 'partial' | 'deposit';
});
// Payment lock state
let lockTimer = $state(-1);
let lockAcquired = $state(false);
let lockInterval: ReturnType<typeof setInterval> | null = null;
let countdownInterval: ReturnType<typeof setInterval> | null = null;
const servicesSubtotal = $derived(
(booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0)
);
const discountSum = $derived(
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
);
const totalPaid = $derived(
booking.payments
?.filter((p) => p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0) || 0
);
// Tip-excluding remaining balance in pounds, mirroring the backend's
// GetBookingRemainingBalancePence (see backend/handlers/payments/service.go):
// total completed non-tip payments + completed refunds, clamped to the
// booking total and floored at 0. The backend rejects an unconfirmed
// pre-start overpayment when req.Amount > this value, and records the excess
// (req.Amount remainingPence) as a tip once confirmed — so the
// overflow-confirmation prompt shows exactly that. A completed TIP must not
// reduce what the customer can still pay for the booking itself (gratuity,
// not booking credit), so this deliberately differs from `totalPaid` (which
// includes tips and drives the scenario labels / "Amount Paid" row).
const remainingBalance = $derived.by(() => {
const total = booking.total_amount ?? 0;
const paid = (booking.payments ?? [])
.filter((p) => p.status === 'completed' && p.payment_type !== 'tip')
.reduce((sum, p) => sum + p.amount, 0);
const refunded = (booking.refunds ?? [])
.filter((r) => r.status === 'completed')
.reduce((sum, r) => sum + r.amount, 0);
return Math.max(0, Math.min(total - paid + refunded, total));
});
const remainingBalancePence = $derived(Math.round(remainingBalance * 100));
// Overpayment confirmation. The backend rejects a payment that exceeds the
// booking's remaining balance unless the request carries
// `confirm_overflow_tip: true` — a tip is gratuity for service already
// rendered. This guard now applies both before AND after the appointment
// has started (B12), so the prompt must fire in both states — it is keyed
// purely off the backend's `overflow_tip_confirmation_required` error code,
// never off booking state. It typically fires on STALE booking data
// (multi-tab, admin-changed totals, refunds that reopened capacity) where
// the user would otherwise be stuck with an unresolvable 400. On the guard
// firing, the rejected request (amount, type, cached card tokens) is parked
// here and a Confirm/Cancel prompt is shown; Confirm resends the SAME
// request with the flag, Cancel returns to the amount-editing form.
let overflowConfirm = $state<{
amountPence: number;
paymentType: string;
overflowPence: number;
// Actual amount the backend will charge. For deposits the backend
// charges req.Amount minus the eligible campaign credit (the frontend
// sends deposits RAW), so this can differ from amountPence.
chargePence?: number;
cardId?: string;
newCardToken?: string;
verificationToken?: string;
} | null>(null);
const loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
booking.total_amount > 0 &&
booking.amount_paid === 0
);
const loyaltyDiscount = $derived(
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
);
// Deposit policy warning text — dynamic based on booking state
const expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
const depositPolicyWarning = $derived<string | null>(
{
get text(): string | null {
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
if (booking.deposit_required) {
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
}
if (totalPaid > 0 || booking.amount_due > 0) {
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
}
return null;
}
}.text
);
const isScenarioA = $derived(depositOutstanding);
const isScenarioB = $derived(!depositOutstanding && totalPaid < booking.total_amount);
const partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
const partialAmountValid = $derived(
paymentType === 'partial' &&
partialAmount !== '' &&
!isNaN(partialAmountNum) &&
partialAmountNum > 0 &&
partialAmountNum <= remainingBalance &&
/^\d+(\.\d{0,2})?$/.test(partialAmount)
);
const partialValidationError = $derived(
paymentType === 'partial' && !partialAmountValid
? partialAmount === ''
? 'Enter an amount'
: !/^\d+(\.\d{0,2})?$/.test(partialAmount)
? 'Invalid amount format'
: partialAmountNum <= 0
? 'Amount must be greater than 0'
: partialAmountNum > remainingBalance
? 'Amount exceeds balance'
: 'Invalid amount'
: null
);
const payButtonDisabled = $derived(
status === 'processing' ||
!cardSelectionValid ||
(paymentType === 'partial' && !partialAmountValid) ||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
function formatTimer(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function clearLockIntervals() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
if (lockInterval) {
clearInterval(lockInterval);
lockInterval = null;
}
}
async function acquireLock() {
try {
const response = await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'POST'
});
if (response.ok) {
lockAcquired = true;
lockTimer = 300;
}
} catch (_err) {
console.error('Failed to acquire payment lock:', _err);
}
}
async function releaseLock() {
lockAcquired = false;
clearLockIntervals();
try {
await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'DELETE'
});
} catch (_err) {
console.error('Failed to release payment lock:', _err);
}
}
function startCountdown() {
countdownInterval = setInterval(() => {
lockTimer = Math.max(0, lockTimer - 1);
}, 1000);
}
function startRenewal() {
lockInterval = setInterval(async () => {
try {
const response = await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'POST'
});
if (response.ok) {
lockTimer = 300;
lockAcquired = true;
}
} catch (_err) {
console.error('Failed to renew payment lock:', _err);
}
}, 60000);
}
// Cached payment methods come from the shared savedCards store (single
// fetch of /api/user/payment-methods), so the account, booking and tip
// surfaces can't drift on the API shape or the loading semantics.
async function loadSavedCards() {
if (!authStore.isAuthenticated) return;
await savedCardsStore.fetch();
}
async function fetchLoyaltyData() {
if (!authStore.isAuthenticated) return;
try {
const response = await apiFetch('/api/user/loyalty');
if (response.ok) {
const data = await response.json();
stamps = data.stamps ?? 0;
}
} catch (_err) {
console.error('Failed to fetch loyalty data:', _err);
}
}
function handlePartialAmountInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeDecimalInput(input.value);
// Only update if the sanitized value passes the regex (max 2 decimal places)
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
partialAmount = sanitized;
lastValidPartialAmount = sanitized;
} else {
// Reject input with more than 2 decimal places — revert to last valid
partialAmount = lastValidPartialAmount;
input.value = lastValidPartialAmount;
}
}
async function makePayment(paymentType: string, amountPence: number) {
status = 'processing';
error = null;
// Apply loyalty redemption before payment
if (useLoyalty) {
try {
const redemptionResponse = await apiFetch(`/api/bookings/${booking.id}/apply-redemption`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!redemptionResponse.ok) {
const errData = await redemptionResponse.text();
throw new Error(extractErrorMessage(errData) || 'Failed to apply loyalty discount');
}
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Failed to apply loyalty discount';
error = msg;
toast.error(msg);
return;
}
}
let cardId: string | undefined;
let newCardToken: string | undefined;
let verificationToken: string | undefined;
// Saved-card charge: the card is used directly — B6/B10 requires the
// customer's current 2FA verification code (collected in the charge form)
// when the backend enforces the gate, but never blocks the selection.
if (selectedCardId) {
cardId = selectedCardId;
} else if (cardSelection) {
// New-card mode: tokenize once per attempt WITH SCA verification, then
// reuse the cached nonce + verification token on retry (tokenization
// is one-shot; the backend idempotency key dedups). The verification
// token is amount-bound, so a changed amount forces a fresh
// tokenization.
if (
!newCardNonce ||
newCardTokenizedForSaveCard !== saveCard ||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountPence)
) {
try {
const tokenized = await cardSelection.tokenizeWithVerification(
amountPence,
{
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
},
saveCard
);
newCardNonce = tokenized.nonce;
newCardVerificationToken = tokenized.verificationToken ?? '';
newCardTokenAmount = amountPence;
newCardTokenizedAt = Date.now();
newCardTokenizedForSaveCard = saveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it (the 2FA
// input only surfaces while SCA genuinely cannot authorise).
lastSCAOutcome = 'verified';
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
error = msg;
toast.error(msg);
return;
}
}
newCardToken = newCardNonce;
verificationToken = newCardVerificationToken || undefined;
} else {
status = 'error';
error = 'Please select a payment method';
toast.error('Please select a payment method');
return;
}
// Cache the idempotency key per amount+type+card so a lost-response
// retry reuses it (backend dedups) instead of double-charging. The
// new-card identity is a STABLE sentinel, NOT the cnon: nonce: the
// nonce is one-shot and cleared on a failed charge, so keying on it
// would regenerate the key on retry and a network-timeout retry (where
// the charge actually landed) would double-charge. The nonce changes
// per tokenize but represents the same logical card intent.
const cardKey = cardId ?? 'new-card';
if (
!payIdempotencyKey ||
payKeyedAmount !== amountPence ||
payKeyedType !== paymentType ||
payKeyedCard !== cardKey
) {
payIdempotencyKey = generateUUID();
payKeyedAmount = amountPence;
payKeyedType = paymentType;
payKeyedCard = cardKey;
}
// Proactive saved-card (ccof) SCA: run the client-side challenge BEFORE
// the first charge attempt so it carries a fresh verification_token —
// a naked ccof is never sent to the backend. Only 'sca-unavailable'
// proceeds token-less (the 2FA gate is the fallback); a cancelled/failed
// challenge does NOT charge — the user taps Pay again to re-run it, and
// the cached idempotency key above is never regenerated across the
// challenge-then-charge. waitingForSCA drives the "approve in your
// banking app" panel and blocks modal close while the challenge is open.
if (cardId && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
}
await submitBookingPayment(
paymentType,
amountPence,
cardId,
newCardToken,
verificationToken,
false
);
}
// Submits a booking-payment request and processes the outcome. Shared by
// the initial attempt and the overflow-tip confirm resend so both use the
// exact same success/error handling. `confirmOverflowTip` adds the backend's
// opt-in flag for a pre-start overpayment; the resend reuses the SAME
// cached nonce/verification token/idempotency key as the rejected attempt
// (the guard fired before any Square call, so the tokens are unconsumed and
// the key is still the correct dedup identity for this amount+type+card).
async function submitBookingPayment(
paymentType: string,
amountPence: number,
cardId: string | undefined,
newCardToken: string | undefined,
verificationToken: string | undefined,
confirmOverflowTip: boolean
): Promise<void> {
let responseStatus = 0;
try {
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: amountPence,
payment_type: paymentType,
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
idempotency_key: payIdempotencyKey
})
})
);
if (!response.ok) {
responseStatus = response.status;
const errData = await response.text();
// Pre-start overpayment on stale booking data: park the rejected
// request (amount, type, cached tokens) and surface the
// Confirm/Cancel prompt instead of a dead-end 400. The cached
// nonce + SCA verification token + idempotency key are NOT
// cleared here — the confirm resend is the same logical charge.
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) {
// The backend's overflow guard compares against the DISCOUNTED
// remaining (remaining + eligible campaign credit), and for a
// DEPOSIT it charges req.Amount the campaign credit (the
// frontend sends deposits raw). Both the displayed overflow
// and the amount actually charged must therefore account for
// the eligible campaign discount on the deposit path.
const depositDiscountPence =
paymentType === 'deposit' ? campaignDiscountPence(discountPreview) : 0;
overflowConfirm = {
amountPence,
paymentType,
overflowPence: Math.max(0, amountPence - remainingBalancePence - depositDiscountPence),
chargePence: Math.max(0, amountPence - depositDiscountPence),
cardId,
newCardToken,
verificationToken
};
status = 'idle';
return;
}
// Defensive fallback: a `verification_required` 402 on a
// saved-card charge should no longer happen — the first attempt
// always carried a proactive verification_token or demoted to
// 2FA (sca-unavailable). If it still occurs (e.g. a stale token
// got consumed/expired between tokenize and charge), surface the
// guidance and let the user retry — never re-run SCA silently
// mid-flow. The catch below maps this signal to
// VERIFICATION_REQUIRED_MESSAGE.
const err = new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
(err as { bodyText?: string }).bodyText = errData;
throw err;
}
const data = await response.json();
// Payment is synchronous (completed immediately)
status = 'success';
overflowConfirm = null;
payIdempotencyKey = '';
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
newCardNonce = '';
newCardVerificationToken = '';
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
twoFactor.setCode('');
twoFactor.reveal = false;
// The backend skips the Square charge entirely when an eligible
// campaign discount covers the whole deposit
// (`deposit_covered_by_discount` — a £0 charge is invalid at
// Square). Report it as a completed, nothing-to-pay deposit.
paymentResult = {
id: data.id ?? '',
amount: data.amount ?? 0,
card_brand: data.card_brand,
card_last4: data.card_last4,
payment_type: data.payment_type ?? 'deposit'
};
toast.success(
data.deposit_covered_by_discount
? 'Deposit covered by your discount — nothing to pay'
: 'Payment successful'
);
savedCardsStore.invalidate();
onComplete();
releaseLock();
} catch (_err) {
status = 'error';
overflowConfirm = null;
let msg = _err instanceof Error ? _err.message : 'Payment declined';
// A 402 with the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance. A plain
// decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
error = msg;
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
// A definitive charge failure consumes the nonce + SCA verification
// token — clear the cached pair so retries re-tokenize fresh. The
// idempotency key stays for network-timeout dedup. CardSelection stays
// mounted on error, so this also stops a user who corrects their card
// digits from resubmitting the old nonce for the new card.
newCardNonce = '';
newCardVerificationToken = '';
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the charge
// did NOT land — Square's idempotency key would otherwise reject a
// retry that re-runs SCA and mints a fresh token (the key's purpose
// is dedup on AMBIGUOUS 503 retries, where the charge may have
// landed). Regenerate the key on 402 so the next Pay click gets a
// fresh key + fresh pending row. Keep it on 503/network (ambiguous)
// and on challenge-cancelled/sca-failed (no charge was attempted).
if (responseStatus === 402) {
payIdempotencyKey = '';
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
}
releaseLock();
}
}
/**
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first charge
* attempt (never after a 402). Square's card.tokenize(verificationDetails,
* squareCardId) determines UP FRONT whether buyer verification is required
* and returns a fresh verification_token bound to the exact amount:
* - 'verified' → the caller charges with the returned token (the first
* attempt carries it — a naked ccof is never sent);
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
* to the only available gate and the caller proceeds WITHOUT a token;
* - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge:
* the pending row stays retryable and the user taps Pay again to re-run
* the challenge.
*/
async function runSavedCardSCAProactively(
amountPence: number,
cardId: string
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
});
} catch (_err) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
lastSCAOutcome = result.outcome;
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
}
// Confirm the overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
// both pre-start and post-start overflows (B12).
async function confirmOverflowPayment() {
const pending = overflowConfirm;
if (!pending || status === 'processing') return;
status = 'processing';
error = null;
await submitBookingPayment(
pending.paymentType,
pending.amountPence,
pending.cardId,
pending.newCardToken,
pending.verificationToken,
true
);
}
// Revert to the amount-editing form. The cached nonce/tokens/idempotency key
// stay: a resubmit with the SAME amount+type+card reuses them (no charge was
// made — the guard fired before Square), and a changed amount forces a fresh
// tokenization + key.
function cancelOverflowConfirmation() {
overflowConfirm = null;
status = 'idle';
}
function handlePayDeposit() {
const depositPence = booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100);
makePayment('deposit', depositPence);
}
function handlePayFull() {
const fullPence = Math.round(booking.amount_due * 100);
const discountedPence = Math.max(
0,
fullPence - campaignDiscountPence(discountPreview) - loyaltyDiscount
);
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
makePayment(paymentType, discountedPence);
}
function handlePayPartial() {
if (!partialAmountValid) {
toast.error('Please enter a valid amount');
return;
}
makePayment('partial', Math.round(partialAmountNum * 100));
}
function handleClose() {
releaseLock();
onClose();
}
// Fetch payment methods + loyalty on mount if authenticated
$effect(() => {
if (authStore.isAuthenticated) {
loadSavedCards();
fetchLoyaltyData();
}
});
// Cleanup on unmount
$effect(() => {
return () => {
if (countdownInterval) clearInterval(countdownInterval);
if (lockInterval) clearInterval(lockInterval);
};
});
onMount(async () => {
if (booking.status === 'pending_release') {
acquireLock();
startCountdown();
startRenewal();
}
// Fetch eligible campaign discounts
try {
const resp = await apiFetch(`/api/bookings/${booking.id}/discount-preview`);
if (resp.ok) {
discountPreview = await resp.json();
}
} catch (_err) {
console.error('Failed to fetch discount preview:', _err);
}
});
onDestroy(() => {
if (booking.status === 'pending_release' && lockAcquired) {
releaseLock();
}
});
</script>
<Dialog.Root
open={true}
onOpenChange={(open) => {
if (open) return;
// ESC/overlay while a payment is in flight (the charge, or the
// out-of-band SCA challenge the buyer must approve in their banking
// app) must NOT close the modal — the charge may still land, and
// closing mid-challenge strands it. Block close until it resolves.
if (status === 'processing' || waitingForSCA) return;
// ESC while the overflow-confirm prompt is showing must dismiss the
// prompt (back to the amount-editing form) instead of closing the whole
// modal — the payment was rejected by the guard and the user needs to
// confirm or adjust, not lose the flow entirely.
if (overflowConfirm) {
cancelOverflowConfirmation();
return;
}
handleClose();
}}
>
<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}
<div class="mt-1 text-sm text-gray-500">Booking ID: {booking.id}</div>
{/if}
</Dialog.Header>
{#if overflowConfirm}
<div class="space-y-4">
<!-- Overpayment confirmation: the backend rejected the payment because
the booking's remaining balance has changed since it was loaded
(stale data). The excess over the remaining balance will be
recorded as a tip once confirmed. Shared markup with the
booking-flow deposit step (OverflowTipConfirm) so the two surfaces
can't drift. -->
<OverflowTipConfirm
overflowPence={overflowConfirm.overflowPence}
discountNote={overflowConfirm.paymentType === 'deposit' &&
overflowConfirm.chargePence !== undefined
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
: undefined}
loading={status === 'processing'}
onConfirm={confirmOverflowPayment}
onCancel={cancelOverflowConfirmation}
/>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
<Button
variant="ghost"
onclick={handleClose}
class="w-full"
disabled={status === 'processing'}
>
Close
</Button>
</div>
{:else if status === 'idle' || status === 'processing' || status === 'error'}
<div class="space-y-4">
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
<div
class="flex items-center gap-2 rounded-md border p-3 text-sm {lockTimer <= 60
? 'border-amber-200 bg-amber-50 text-amber-800'
: 'border-blue-200 bg-blue-50 text-blue-800'}"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<span
>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment
processing</span
>
</div>
{:else if booking.status === 'pending_release' && (lockTimer === 0 || !lockAcquired)}
<div
class="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<span>Slot no longer secured — please close and retry</span>
</div>
{/if}
<!-- Out-of-band SCA challenge: the buyer approves in their banking
app while this panel shows. -->
{#if waitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
<!-- Service Breakdown -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
<div class="space-y-2">
{#each booking.services ?? [] as service, index (index)}
<div class="flex justify-between text-sm">
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
<span class="font-medium">
{service.override_price
? formatCurrency(Math.round(service.override_price * 100))
: service.price
? formatCurrency(Math.round(service.price * 100))
: '-'}
</span>
</div>
{/each}
</div>
</div>
<!-- Loyalty Redemption Checkbox -->
{#if loyaltyEligible}
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4">
<div class="mb-2 text-sm font-semibold text-fuchsia-800">Available Savings</div>
<div class="flex items-start gap-3">
<Checkbox
id="use-loyalty"
bind:checked={useLoyalty}
disabled={status === 'processing'}
/>
<label for="use-loyalty" class="cursor-pointer select-none">
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
</div>
</label>
</div>
</div>
{/if}
<!-- Applied Discounts -->
{#if booking.discounts && booking.discounts.length > 0}
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
<svg
class="h-4 w-4 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"
></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
Applied Discounts
</div>
{#if servicesSubtotal > 0}
<span
class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
>
{((discountSum / servicesSubtotal) * 100).toFixed(0)}% Off Total
</span>
{/if}
</div>
<div class="space-y-2 text-sm">
{#each booking.discounts as d (d.id)}
<div class="flex items-center justify-between text-gray-600">
<div class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
<span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
{:else if d.campaign_name}
{d.campaign_name}
{:else}
Promo Campaign ({d.discount_percent}% Off)
{/if}
</span>
</div>
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span
>
</div>
{/each}
</div>
</div>
{/if}
<!-- Financial Summary -->
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span>
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
>
</div>
{#if discountPreview?.eligible}
{#each discountPreview.discounts as d (d.name)}
<div class="flex justify-between text-sm">
<span class="text-gray-600">{d.name}</span>
<span class="font-medium text-green-700"
>-{formatCurrency(Math.round(d.amount * 100))}</span
>
</div>
{/each}
{/if}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
</div>
{#if useLoyalty && loyaltyDiscount > 0}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 pt-2">
<span class="font-semibold text-gray-900">Amount Remaining</span>
<span class="text-lg font-bold text-red-600">
{formatCurrency(
Math.max(
0,
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
)
)}
</span>
</div>
</div>
<!-- Card Selection. Mounted for idle/error so a declined card can be
retried/swapped without closing the modal, AND for processing:
makePayment() sets status='processing' FIRST, then awaits the
loyalty redemption and tokenizeWithVerification. Svelte 5 flushes
on the next microtask after the await yields, so a status gate
that unmounts CardSelection mid-payment would null the bind:this
ref AND destroy the Square iframe mid-tokenization, making the
new-card path fail with "Please select a payment method" /
"Card entry failed". Staying mounted keeps both alive for the
full duration of makePayment. -->
{#if authStore.isAuthenticated}
{#if savedCardsStore.loading}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else}
<CardSelection
bind:this={cardSelection}
cards={savedCardsStore.cards}
{canSaveCards}
bind:selectedCardId
bind:saveCard
onValidityChange={(v) => (cardSelectionValid = v)}
/>
{/if}
{/if}
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
{#if depositPolicyWarning}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<p class="font-semibold text-amber-900">Cancellation &amp; Deposit Policy</p>
<p class="mt-1">{depositPolicyWarning}</p>
<PolicyPopover>
{#snippet trigger()}
<span class="mt-1 inline-block underline">Full cancellation policy →</span>
{/snippet}
</PolicyPopover>
</div>
{/if}
<!-- Scenario A: Deposit needed -->
{#if isScenarioA}
<div class="space-y-3">
<!-- Payment type radio buttons -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'deposit'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'deposit')}
>
Pay Deposit
</button>
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'full'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'full')}
>
Pay in Full
</button>
</div>
<!-- Pay button -->
<Button
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled || twoFactor.missing}
>
{#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(
depositChargePence(
booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100),
campaignDiscountPence(discountPreview)
)
)})
{:else}
Pay {formatCurrency(
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountPence(discountPreview) -
(useLoyalty ? loyaltyDiscount : 0)
)
)}
{/if}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
<!-- Scenario B: Partial or full payment -->
{#if isScenarioB}
<div class="space-y-3">
<!-- Payment type radio buttons -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'full'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'full')}
>
Pay in Full
</button>
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'partial'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'partial')}
>
Pay Part
</button>
</div>
<!-- Partial amount input (only when partial selected) -->
{#if paymentType === 'partial'}
<div>
<label for="partial-amount" class="text-sm font-medium text-gray-700">
Partial Payment Amount
</label>
<div class="relative mt-1">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="partial-amount"
type="text"
inputmode="decimal"
step="0.01"
placeholder="0.00"
value={partialAmount}
oninput={handlePartialAmountInput}
class="pl-7"
disabled={status !== 'idle'}
/>
</div>
{#if partialValidationError}
<p class="mt-1 text-sm text-red-600">{partialValidationError}</p>
{/if}
</div>
{/if}
<!-- Pay button -->
<Button
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled || twoFactor.missing}
>
{#if paymentType === 'partial'}
Pay {partialAmountValid
? formatCurrency(Math.round(partialAmountNum * 100))
: 'Part'}
{:else}
Pay {formatCurrency(
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountPence(discountPreview) -
(useLoyalty ? loyaltyDiscount : 0)
)
)}
{/if}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
{#if status === 'error' && error}
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
<Button
variant="outline"
size="sm"
class="mt-3 w-full"
onclick={() => {
status = 'idle';
error = null;
}}
>
Try Again
</Button>
</div>
{/if}
<!-- Close button -->
<Button
variant="ghost"
onclick={handleClose}
class="w-full"
disabled={status === 'processing'}
>
Close
</Button>
</div>
{:else if status === 'success' && paymentResult}
<!-- Success State -->
<div class="space-y-4">
<div class="flex flex-col items-center justify-center py-4">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-green-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900">Payment Successful</h3>
</div>
<!-- Receipt -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Amount</span>
<span class="font-semibold text-gray-900">
{formatCurrency(paymentResult.amount / 100)}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Type</span>
<span class="font-medium text-gray-900 capitalize">
{paymentResult.payment_type}
</span>
</div>
{#if paymentResult.card_brand}
<div class="flex justify-between">
<span class="text-sm text-gray-600">Card</span>
<span class="font-medium text-gray-900">
{paymentResult.card_brand} ****{paymentResult.card_last4}
</span>
</div>
{/if}
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span class="font-medium text-green-600">Completed</span>
</div>
</div>
</div>
<Button onclick={handleClose} class="w-full">Done</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>