Fix payment review round: till integrity, HTTP client tests, concurrency tests, card-selection consolidation
Addresses the payment review (all 10 blocking + 2 minor findings): Till money-integrity (CreateTillSale): - Add pg_advisory_lock on the idempotency key (concurrent same-key double-funding race) - Guard amount on pending-reuse retry (mirrors tip/gift-card guards) - Explicitly complete the row for cash/on_the_house pending-reuse - Reject method-switch on a live card-machine checkout (double-charge guard) - 3 regression tests (amount-mismatch, cash-completes-row, method-switch) BookingFlow: - Fetch saved cards at the deposit step (was dead code) - Charge the server-computed deposit_amount, not the client estimate HTTP client tests (was untested): doJSON error parsing, refund sentinel classification, payment/refund/card wire shapes, checkout polling states, list-refunds pagination + 20-page guard, sha256 card idempotency key Concurrency regression tests: real two-goroutine races for BuyGiftCard, tip, and booking-payment locks asserting exactly-one record each Frontend: - Fix CRIT-1: zero-saved-card users blocked (all flows now handle it) - Consolidate tip/deposit/Buy-Gift-Card card UI onto CardSelection - Explicit save-card consent checkbox (was silent/inconsistent) - Fix stale saved-card field names in BookingFlow (last4 -> last_4) - Unique instance ids (crypto.randomUUID) in CardSelection/SquareCardInput - UserPaymentModal: keep card form mounted on error + Try Again button Health/docs: /api/health reports square state (mock/ok, was not_implemented), close P1 backlog, correct stale webhook and env-var claims
This commit is contained in:
@@ -15,10 +15,7 @@
|
||||
import { computeBalanceDue } from '$lib/utils/booking';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -147,13 +144,13 @@
|
||||
let tipIdempotencyKey = $state('');
|
||||
let tipKeyedAmount = $state(0);
|
||||
|
||||
// Card selection state for tips
|
||||
// Card selection for tips — delegated to CardSelection.svelte.
|
||||
let tipSavedCards = $state<SavedCard[]>([]);
|
||||
let tipLoadingCards = $state(false);
|
||||
let tipSelectedCardId = $state<string | null>(null);
|
||||
let tipShowNewCardForm = $state(false);
|
||||
let tipSquareCardReady = $state(false);
|
||||
let tipSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
let tipCardSelection = $state<CardSelection | null>(null);
|
||||
let tipSelectedCardId = $state('');
|
||||
let tipCardSelectionValid = $state(false);
|
||||
let tipSaveCard = $state(false);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let tipNonce = $state('');
|
||||
@@ -162,9 +159,7 @@
|
||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||
);
|
||||
|
||||
const isTipCardValid = $derived(
|
||||
tipSelectedCardId !== null || (tipShowNewCardForm && tipSquareCardReady)
|
||||
);
|
||||
const isTipCardValid = $derived(tipCardSelectionValid);
|
||||
|
||||
const tipPresets = $derived(
|
||||
selectedBooking
|
||||
@@ -233,11 +228,11 @@
|
||||
let newCardToken: string | undefined;
|
||||
if (tipSelectedCardId) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (tipShowNewCardForm && tipSquareCardInput) {
|
||||
} else if (tipCardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!tipNonce) {
|
||||
try {
|
||||
tipNonce = await tipSquareCardInput.tokenize();
|
||||
tipNonce = await tipCardSelection.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
@@ -260,7 +255,7 @@
|
||||
amount: Math.round(tipAmount * 100),
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: tipSaveCard } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
|
||||
@@ -1112,8 +1107,8 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
customTipInput = '';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipSelectedCardId = null;
|
||||
tipShowNewCardForm = false;
|
||||
tipSelectedCardId = '';
|
||||
tipSaveCard = false;
|
||||
tipNonce = '';
|
||||
}
|
||||
}}
|
||||
@@ -1169,79 +1164,15 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
|
||||
{#if tipLoadingCards}
|
||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||
{:else if tipSavedCards.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each tipSavedCards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId ===
|
||||
card.id && !tipShowNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
tipSelectedCardId = card.id;
|
||||
tipShowNewCardForm = false;
|
||||
tipNonce = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {card.last_4}</span>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if tipSelectedCardId === card.id && !tipShowNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
tipSelectedCardId = null;
|
||||
tipNonce = '';
|
||||
tipShowNewCardForm = !tipShowNewCardForm;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
|
||||
>
|
||||
NEW
|
||||
</div>
|
||||
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
|
||||
</div>
|
||||
{#if tipShowNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={tipSquareCardInput}
|
||||
onReady={(r) => (tipSquareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if tipSavedCards.length > 0 && tipShowNewCardForm}
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={tipSquareCardInput}
|
||||
onReady={(r) => (tipSquareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
<CardSelection
|
||||
bind:this={tipCardSelection}
|
||||
cards={tipSavedCards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={tipSelectedCardId}
|
||||
bind:saveCard={tipSaveCard}
|
||||
onValidityChange={(v) => (tipCardSelectionValid = v)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,9 +36,7 @@
|
||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
|
||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
@@ -84,16 +82,24 @@
|
||||
// =============== Payment State ===============
|
||||
let userDepositsRequired = $state<number>(0);
|
||||
let hasActiveBooking = $state<boolean>(false);
|
||||
// Saved cards, in the API shape (last_4/exp_month/exp_year) — consumed by
|
||||
// CardSelection.svelte which renders the list, new-card toggle, and consent.
|
||||
let paymentMethods = $state<
|
||||
Array<{ id: string; brand: string; last4: string; expiry_month: number; expiry_year: number }>
|
||||
Array<{
|
||||
id: string;
|
||||
brand: string;
|
||||
last_4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
is_default?: boolean;
|
||||
}>
|
||||
>([]);
|
||||
let paymentMethodsLoading = $state(false);
|
||||
let selectedPaymentMethod = $state<string | null>(null);
|
||||
let selectedPaymentMethod = $state('');
|
||||
let paymentCardSelection = $state<CardSelection | null>(null);
|
||||
let paymentCardSelectionValid = $state(false);
|
||||
let depositSaveCard = $state(false);
|
||||
let isProcessingPayment = $state(false);
|
||||
// New-card mode (Square Web Payments tokenization)
|
||||
let showNewCardForm = $state(false);
|
||||
let squareCardReady = $state(false);
|
||||
let squareCardInput = $state<SquareCardInput | null>(null);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let depositNonce = $state('');
|
||||
@@ -106,15 +112,11 @@
|
||||
// Payment flow state
|
||||
let depositPaid = $state(false);
|
||||
|
||||
// New-card form is active when toggled, or implicitly when there is no saved
|
||||
// card to pick (guest flow / no saved cards yet).
|
||||
const newCardMode = $derived(
|
||||
showNewCardForm || !authStore.isAuthenticated || paymentMethods.length === 0
|
||||
const canSaveCards = $derived(
|
||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||
);
|
||||
|
||||
const depositCardFormValid = $derived(
|
||||
selectedPaymentMethod !== null || (newCardMode && squareCardReady)
|
||||
);
|
||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||
|
||||
// VAT registration status from public business info (via shared store)
|
||||
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
|
||||
@@ -275,7 +277,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function processPayment(amount: number) {
|
||||
async function processPayment(_amount: number) {
|
||||
// Synchronous double-click guard — set BEFORE any await so a rapid second
|
||||
// click is rejected immediately, even before the reactive `disabled` has
|
||||
// propagated to the button.
|
||||
@@ -287,11 +289,11 @@
|
||||
let newCardToken: string | undefined;
|
||||
if (selectedPaymentMethod) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (newCardMode && squareCardInput) {
|
||||
} else if (paymentCardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!depositNonce) {
|
||||
try {
|
||||
depositNonce = await squareCardInput.tokenize();
|
||||
depositNonce = await paymentCardSelection.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
@@ -308,11 +310,19 @@
|
||||
return;
|
||||
}
|
||||
const bookingId = confirmedBooking.id;
|
||||
const amountCents = Math.round(amount * 100);
|
||||
// Charge the SERVER-computed deposit: the booking response carries the
|
||||
// authoritative deposit_amount (20% of the server-side total, which
|
||||
// accounts for discounts / admin adjustments). The client-side
|
||||
// getTotalPrice()*0.2 estimate can diverge and under-charge.
|
||||
const depositAmount =
|
||||
confirmedBooking.deposit_amount && confirmedBooking.deposit_amount > 0
|
||||
? confirmedBooking.deposit_amount
|
||||
: _amount;
|
||||
const amountCents = Math.round(depositAmount * 100);
|
||||
|
||||
// Cache the idempotency key per amount+card so a lost-response retry
|
||||
// reuses it (backend dedups) instead of double-charging.
|
||||
const cardKey = selectedPaymentMethod ?? `new:${newCardToken ?? ''}`;
|
||||
const cardKey = selectedPaymentMethod || `new:${newCardToken ?? ''}`;
|
||||
if (
|
||||
!depositIdempotencyKey ||
|
||||
depositKeyedAmount !== amountCents ||
|
||||
@@ -328,7 +338,7 @@
|
||||
amount: amountCents,
|
||||
idempotency_key: depositIdempotencyKey,
|
||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {})
|
||||
};
|
||||
|
||||
paymentAttempted = true;
|
||||
@@ -348,6 +358,7 @@
|
||||
depositKeyedAmount = 0;
|
||||
depositKeyedCard = '';
|
||||
depositNonce = '';
|
||||
depositSaveCard = false;
|
||||
// Immutable update — avoid mutating the existing object so
|
||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
|
||||
@@ -355,8 +366,8 @@
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
deposit_paid: true,
|
||||
amount_paid: (confirmedBooking.amount_paid || 0) + amount,
|
||||
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - amount)
|
||||
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
|
||||
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
|
||||
};
|
||||
toast.success('Payment successful!');
|
||||
} else {
|
||||
@@ -382,8 +393,21 @@
|
||||
let depositKeyedAmount = $state(0);
|
||||
let depositKeyedCard = $state('');
|
||||
|
||||
function formatCardExpiry(month: number, year: number): string {
|
||||
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
|
||||
async function fetchPaymentMethods() {
|
||||
if (paymentMethodsLoading || paymentMethods.length > 0) return;
|
||||
if (!authStore.isAuthenticated) return;
|
||||
paymentMethodsLoading = true;
|
||||
try {
|
||||
const response = await apiFetch('/api/user/payment-methods');
|
||||
if (response.ok) {
|
||||
// CardSelection auto-selects the default card once cards load.
|
||||
paymentMethods = await response.json();
|
||||
}
|
||||
} catch {
|
||||
// non-fatal — the new-card form remains available
|
||||
} finally {
|
||||
paymentMethodsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user deposit and active booking status when step 1 is reached
|
||||
@@ -394,6 +418,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch saved cards when the deposit payment step is shown.
|
||||
$effect(() => {
|
||||
if (currentStep === finalStep && depositRequired && authStore.isAuthenticated) {
|
||||
fetchPaymentMethods();
|
||||
}
|
||||
});
|
||||
|
||||
// =============== Slot Reservation System ===============
|
||||
let _reservationId = $state<string | null>(null);
|
||||
let reservationExpiresAt = $state<Date | null>(null);
|
||||
@@ -2296,88 +2327,28 @@
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="mb-6 py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||
{:else if paymentMethods.length > 0}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
|
||||
<div class="space-y-3">
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
||||
method.id && !showNewCardForm
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
|
||||
>
|
||||
{method.brand}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {method.last4}</span>
|
||||
<span class="ml-2 text-gray-500">
|
||||
{formatCardExpiry(method.expiry_month, method.expiry_year)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPaymentMethod === method.id && !showNewCardForm
|
||||
? 'default'
|
||||
: 'outline'}
|
||||
onclick={() => {
|
||||
selectedPaymentMethod = method.id;
|
||||
showNewCardForm = false;
|
||||
depositNonce = '';
|
||||
}}
|
||||
>
|
||||
{selectedPaymentMethod === method.id && !showNewCardForm
|
||||
? 'Selected'
|
||||
: 'Use this card'}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showNewCardForm ? 'default' : 'outline'}
|
||||
onclick={() => {
|
||||
selectedPaymentMethod = null;
|
||||
depositNonce = '';
|
||||
showNewCardForm = !showNewCardForm;
|
||||
}}
|
||||
>
|
||||
Use a new card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if newCardMode}
|
||||
<div class="mb-6">
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={squareCardInput}
|
||||
onReady={(r) => (squareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
||||
/>
|
||||
{/if}
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={paymentMethods}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={squareCardInput}
|
||||
onReady={(r) => (squareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please contact the salon to pay by another method."
|
||||
/>
|
||||
{/if}
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -15,13 +15,15 @@
|
||||
|
||||
let {
|
||||
cards = [],
|
||||
canSaveCards: _canSaveCards = false,
|
||||
canSaveCards = false,
|
||||
selectedCardId = $bindable(''),
|
||||
saveCard = $bindable(false),
|
||||
onValidityChange = (_valid: boolean) => {}
|
||||
}: {
|
||||
cards?: SelectableCard[];
|
||||
canSaveCards?: boolean;
|
||||
selectedCardId?: string;
|
||||
saveCard?: boolean;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -30,6 +32,10 @@
|
||||
let showNewCardForm = $state(false);
|
||||
let squareCardReady = $state(false);
|
||||
let squareCardInput = $state<SquareCardInput | null>(null);
|
||||
// Unique per instance: a plain counter would be instance-scoped in Svelte 5
|
||||
// (every instance restarting at 0), so two mounted CardSelection instances
|
||||
// would collide on the same checkbox id. Pure SPA, so no SSR concern.
|
||||
const consentId = `save-card-consent-${crypto.randomUUID()}`;
|
||||
|
||||
// Auto-select the default saved card when cards first load. Guarded by
|
||||
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
|
||||
@@ -125,5 +131,20 @@
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
|
||||
{#if canSaveCards && squareCardReady}
|
||||
<label
|
||||
class="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-600"
|
||||
for={consentId}
|
||||
>
|
||||
<input
|
||||
id={consentId}
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary accent-primary"
|
||||
bind:checked={saveCard}
|
||||
/>
|
||||
<span>Save this card for next time</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
||||
import { getSquarePayments, isSquareConfigured } from '$lib/square/square';
|
||||
|
||||
// Module-level counter keeps the element id stable across server/client render
|
||||
// (a Math.random() id would mismatch during hydration).
|
||||
let squareCardIdCounter = 0;
|
||||
|
||||
interface Props {
|
||||
/** Disable the form while a payment is processing. */
|
||||
disabled?: boolean;
|
||||
@@ -21,7 +17,10 @@
|
||||
let ready = $state(false);
|
||||
let initError = $state<string | null>(null);
|
||||
|
||||
let uniqueId = $state(`square-card-${squareCardIdCounter++}`);
|
||||
// Unique per instance so two mounted card forms never share an element id
|
||||
// (e.g. the deposit step + the pay-early modal on the same page). This app
|
||||
// is a pure SPA (no SSR/hydration), so a random id cannot mismatch.
|
||||
let uniqueId = $state(`square-card-${crypto.randomUUID()}`);
|
||||
|
||||
async function init() {
|
||||
if (!isSquareConfigured()) {
|
||||
|
||||
@@ -23,7 +23,17 @@
|
||||
defaultPaymentType?: 'full' | 'partial' | 'deposit';
|
||||
}
|
||||
|
||||
const { booking, onClose, onComplete, canSaveCards = true, defaultPaymentType }: Props = $props();
|
||||
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);
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
|
||||
|
||||
@@ -394,7 +404,7 @@
|
||||
amount: amountCents,
|
||||
payment_type: paymentType,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: canSaveCards } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
})
|
||||
});
|
||||
@@ -687,8 +697,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Selection (only when idle) -->
|
||||
{#if status === 'idle' && authStore.isAuthenticated}
|
||||
<!-- Card Selection (mounted while idle OR error so a declined card
|
||||
can be retried/swapped without closing the modal) -->
|
||||
{#if (status === 'idle' || status === 'error') && authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||
{:else}
|
||||
@@ -697,6 +708,7 @@
|
||||
cards={paymentMethods}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId
|
||||
bind:saveCard
|
||||
onValidityChange={(v) => (cardSelectionValid = v)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -849,6 +861,17 @@
|
||||
{#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}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
@@ -205,9 +206,9 @@
|
||||
let buySelectedCard = $state('');
|
||||
let buyingGiftCard = $state(false);
|
||||
let purchaseResultCode = $state<string | null>(null);
|
||||
let buyShowNewCardForm = $state(false);
|
||||
let buySquareCardReady = $state(false);
|
||||
let buySquareCardInput = $state<SquareCardInput | null>(null);
|
||||
let buyCardSelection = $state<CardSelection | null>(null);
|
||||
let buyCardSelectionValid = $state(false);
|
||||
let buySaveCard = $state(false);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let buyNonce = $state('');
|
||||
@@ -219,21 +220,8 @@
|
||||
let buyKeyedAmount = $state(0);
|
||||
let buyKeyedCard = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// Auto-select the default saved card when cards first load. When the
|
||||
// "Use a new card" toggle is open, selectedCardId is cleared so the
|
||||
// effect doesn't override the user's choice.
|
||||
if (savedCardsStore.cards.length > 0 && !buySelectedCard && !buyShowNewCardForm) {
|
||||
const defaultCard =
|
||||
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
|
||||
buySelectedCard = defaultCard.id;
|
||||
}
|
||||
});
|
||||
|
||||
// Derived validation for Buy Gift Card form
|
||||
const isBuyCardValid = $derived(
|
||||
buySelectedCard !== '' || (buyShowNewCardForm && buySquareCardReady)
|
||||
);
|
||||
// Derived validation for Buy Gift Card form — delegated to CardSelection.
|
||||
const isBuyCardValid = $derived(buyCardSelectionValid);
|
||||
|
||||
async function fetchGiftCardBalance() {
|
||||
loadingBalance = true;
|
||||
@@ -283,11 +271,11 @@
|
||||
let newCardToken: string | undefined;
|
||||
if (buySelectedCard) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (buyShowNewCardForm && buySquareCardInput) {
|
||||
} else if (buyCardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!buyNonce) {
|
||||
try {
|
||||
buyNonce = await buySquareCardInput.tokenize();
|
||||
buyNonce = await buyCardSelection.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
buyingGiftCard = false;
|
||||
@@ -308,7 +296,7 @@
|
||||
// Cache the idempotency key per amount+card so a lost-response retry
|
||||
// reuses the same key (backend dedups) instead of double-charging.
|
||||
// Regenerate when the amount or card changes.
|
||||
const cardKey = cardId ?? `new:${newCardToken ?? ''}`;
|
||||
const cardKey = cardId || `new:${newCardToken ?? ''}`;
|
||||
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
||||
buyIdempotencyKey = generateIdempotencyKey();
|
||||
buyKeyedAmount = buyAmount;
|
||||
@@ -323,7 +311,7 @@
|
||||
recipient_type: buyRecipientType,
|
||||
recipient_email: buyRecipientEmail,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
});
|
||||
@@ -2179,73 +2167,14 @@
|
||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||||
>Payment Method</span
|
||||
>
|
||||
{#if savedCardsStore.cards.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each savedCardsStore.cards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
|
||||
card.id && !buyShowNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
buySelectedCard = card.id;
|
||||
buyShowNewCardForm = false;
|
||||
buyNonce = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {card.last_4}</span>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if buySelectedCard === card.id && !buyShowNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buyShowNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
buySelectedCard = '';
|
||||
buyNonce = '';
|
||||
buyShowNewCardForm = !buyShowNewCardForm;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
|
||||
>
|
||||
NEW
|
||||
</div>
|
||||
<span class="animate-pulse text-sm font-medium text-gray-700"
|
||||
>Use a new card</span
|
||||
>
|
||||
</div>
|
||||
{#if buyShowNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if buyShowNewCardForm || savedCardsStore.cards.length === 0}
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={buySquareCardInput}
|
||||
onReady={(r) => (buySquareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
{/if}
|
||||
<CardSelection
|
||||
bind:this={buyCardSelection}
|
||||
cards={savedCardsStore.cards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={buySelectedCard}
|
||||
bind:saveCard={buySaveCard}
|
||||
onValidityChange={(v) => (buyCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -11,10 +11,7 @@
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
|
||||
// Types
|
||||
@@ -52,17 +49,21 @@
|
||||
let tipKeyedAmount = $state(0);
|
||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
|
||||
|
||||
// Card selection state
|
||||
// Card selection — delegated to CardSelection.svelte.
|
||||
let savedCards = $state<SavedCard[]>([]);
|
||||
let selectedCardId = $state<string | null>(null);
|
||||
let showNewCardForm = $state(false);
|
||||
let squareCardReady = $state(false);
|
||||
let squareCardInput = $state<SquareCardInput | null>(null);
|
||||
let cardSelection = $state<CardSelection | null>(null);
|
||||
let selectedCardId = $state('');
|
||||
let cardSelectionValid = $state(false);
|
||||
let saveCard = $state(false);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let tipNonce = $state('');
|
||||
|
||||
const isCardValid = $derived(selectedCardId !== null || (showNewCardForm && squareCardReady));
|
||||
const canSaveCards = $derived(
|
||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||
);
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
|
||||
// Tip selection state
|
||||
let selectedTip = $state<number | null>(null);
|
||||
@@ -204,11 +205,11 @@
|
||||
let newCardToken: string | undefined;
|
||||
if (selectedCardId) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (showNewCardForm && squareCardInput) {
|
||||
} else if (cardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!tipNonce) {
|
||||
try {
|
||||
tipNonce = await squareCardInput.tokenize();
|
||||
tipNonce = await cardSelection.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
@@ -232,7 +233,7 @@
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
|
||||
@@ -465,89 +466,14 @@
|
||||
<Card.Title>Payment Method</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if savedCards.length > 0}
|
||||
<div class="space-y-3">
|
||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
|
||||
Payment Method
|
||||
</span>
|
||||
<div class="space-y-2">
|
||||
{#each savedCards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||
card.id && !showNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
selectedCardId = card.id;
|
||||
showNewCardForm = false;
|
||||
tipNonce = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {card.last_4}</span>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedCardId === card.id && !showNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
selectedCardId = null;
|
||||
tipNonce = '';
|
||||
showNewCardForm = !showNewCardForm;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
|
||||
>
|
||||
NEW
|
||||
</div>
|
||||
<span class="animate-pulse text-sm font-medium text-gray-700"
|
||||
>Use a new card</span
|
||||
>
|
||||
</div>
|
||||
{#if showNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
|
||||
Payment Method
|
||||
</span>
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={squareCardInput}
|
||||
onReady={(r) => (squareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if savedCards.length > 0 && showNewCardForm}
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput bind:this={squareCardInput} onReady={(r) => (squareCardReady = r)} />
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
{/if}
|
||||
<CardSelection
|
||||
bind:this={cardSelection}
|
||||
cards={savedCards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId
|
||||
bind:saveCard
|
||||
onValidityChange={(v) => (cardSelectionValid = v)}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -58,17 +55,22 @@
|
||||
let tipIdempotencyKey = $state('');
|
||||
let tipKeyedAmount = $state(0);
|
||||
|
||||
// Card selection state (same pattern as UserPaymentModal)
|
||||
// Card selection — delegated to CardSelection.svelte (saved-card list,
|
||||
// "Use a new card" toggle, SquareCardInput tokenization, consent checkbox).
|
||||
let savedCards = $state<SavedCard[]>([]);
|
||||
let selectedCardId = $state<string | null>(null);
|
||||
let showNewCardForm = $state(false);
|
||||
let squareCardReady = $state(false);
|
||||
let squareCardInput = $state<SquareCardInput | null>(null);
|
||||
let cardSelection = $state<CardSelection | null>(null);
|
||||
let selectedCardId = $state('');
|
||||
let cardSelectionValid = $state(false);
|
||||
let saveCard = $state(false);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let tipNonce = $state('');
|
||||
|
||||
const isCardValid = $derived(selectedCardId !== null || (showNewCardForm && squareCardReady));
|
||||
const canSaveCards = $derived(
|
||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||
);
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
|
||||
let selectedTip = $state<number | null>(null);
|
||||
let customTip = $state('');
|
||||
@@ -153,11 +155,11 @@
|
||||
let newCardToken: string | undefined;
|
||||
if (selectedCardId) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (showNewCardForm && squareCardInput) {
|
||||
} else if (cardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!tipNonce) {
|
||||
try {
|
||||
tipNonce = await squareCardInput.tokenize();
|
||||
tipNonce = await cardSelection.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
@@ -181,7 +183,7 @@
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
@@ -487,73 +489,14 @@
|
||||
>Payment Method</span
|
||||
>
|
||||
|
||||
{#if savedCards.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each savedCards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||
card.id && !showNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
selectedCardId = card.id;
|
||||
showNewCardForm = false;
|
||||
tipNonce = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {card.last_4}</span>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedCardId === card.id && !showNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
selectedCardId = null;
|
||||
tipNonce = '';
|
||||
showNewCardForm = !showNewCardForm;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
|
||||
>
|
||||
NEW
|
||||
</div>
|
||||
<span class="animate-pulse text-sm font-medium text-gray-700"
|
||||
>Use a new card</span
|
||||
>
|
||||
</div>
|
||||
{#if showNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNewCardForm || savedCards.length === 0}
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={squareCardInput}
|
||||
onReady={(r) => (squareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
{/if}
|
||||
<CardSelection
|
||||
bind:this={cardSelection}
|
||||
cards={savedCards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId
|
||||
bind:saveCard
|
||||
onValidityChange={(v) => (cardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
Reference in New Issue
Block a user