Implement P11: Square Web Payments SDK new-card tokenization
Re-enable new-card entry across all 8 flows via Square Web Payments SDK cnon: nonces (backend was already P11-ready): - Add square.ts SDK loader (env-gated on VITE_SQUARE_APPLICATION_ID/LOCATION_ID, sandbox vs prod URL auto-derived from app-ID prefix) + SquareCardInput.svelte (tokenize() via bind:this, onReady state, CardEntryUnavailable fallback) - CardSelection.svelte: replace newCardDisabled gate with new-card toggle + SquareCardInput; expose tokenize() for parent flows - Wire new-card mode into tip x3, booking payment (UserPaymentModal), deposit (BookingFlow incl. guest), Buy a Gift Card + Add a Card (account), and admin till online_square (GiftCardsManagement create/topup) - Retry-safe: each flow caches the one-shot nonce and reuses it on retry so the backend idempotency key dedups instead of re-tokenizing - Docs: README, Gap Backlog P11, Feature Catalog, Technical Manual, P11 plan
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
import { EmailInput } from '$lib/components/ui/email-input';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
@@ -152,6 +153,12 @@
|
||||
let cardMachineItemID = $state<string | null>(null);
|
||||
let processingMessage = $state('Processing payment...');
|
||||
|
||||
// Online card (Square Web Payments tokenization) state for the till.
|
||||
let onlineSquareAction = $state<'create' | 'topup' | null>(null);
|
||||
let onlineSquareCardReady = $state(false);
|
||||
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
let onlineSquareProcessing = $state(false);
|
||||
|
||||
// Idempotency Key
|
||||
let idempotencyKey = $state('');
|
||||
|
||||
@@ -450,6 +457,8 @@
|
||||
generateEmail = '';
|
||||
generateUserQuery = '';
|
||||
generateUsers = [];
|
||||
onlineSquareAction = null;
|
||||
onlineSquareProcessing = false;
|
||||
}
|
||||
|
||||
function resetTopUpModal() {
|
||||
@@ -463,6 +472,8 @@
|
||||
paymentResult = null;
|
||||
cardMachineItemID = null;
|
||||
idempotencyKey = '';
|
||||
onlineSquareAction = null;
|
||||
onlineSquareProcessing = false;
|
||||
}
|
||||
|
||||
// =============== Embedded Payment Handlers ===============
|
||||
@@ -594,6 +605,58 @@
|
||||
setModalStep(actionType, 'error');
|
||||
}
|
||||
|
||||
async function handleEmbeddedOnlineSquarePayment(actionType: 'create' | 'topup', gcId?: string) {
|
||||
if (!onlineSquareCardInput) return;
|
||||
onlineSquareProcessing = true;
|
||||
paymentError = '';
|
||||
try {
|
||||
let token: string;
|
||||
try {
|
||||
token = await onlineSquareCardInput.tokenize();
|
||||
} catch (err) {
|
||||
paymentError = err instanceof Error ? err.message : 'Card entry failed';
|
||||
setModalStep(actionType, 'error');
|
||||
return;
|
||||
}
|
||||
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
|
||||
const body: Record<string, unknown> = {
|
||||
item_type: 'gift_card',
|
||||
action: actionType,
|
||||
amount: amt,
|
||||
payment_method: 'online_square',
|
||||
card_token: token,
|
||||
idempotency_key: getIdempotencyKey()
|
||||
};
|
||||
if (gcId) body.gift_card_id = gcId;
|
||||
if (selectedCustomer) body.user_id = selectedCustomer.id;
|
||||
if (actionType === 'create' && generateType === 'account' && selectedCustomer)
|
||||
body.redeem_to_user_id = selectedCustomer.id;
|
||||
|
||||
const res = await apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
paymentResult = { ...data };
|
||||
setModalStep(actionType, 'success');
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
paymentError = await res.text();
|
||||
setModalStep(actionType, 'error');
|
||||
}
|
||||
} catch {
|
||||
paymentError = 'Network error processing online card payment';
|
||||
setModalStep(actionType, 'error');
|
||||
} finally {
|
||||
onlineSquareProcessing = false;
|
||||
onlineSquareAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmbeddedGiveawayTopUp(gcId: string) {
|
||||
topUpStep = 'processing';
|
||||
processingMessage = 'Processing on-the-house top-up...';
|
||||
@@ -1810,12 +1873,44 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
<div class="sm:col-span-2">
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
|
||||
/>
|
||||
</div>
|
||||
{#if isSquareConfigured()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'create')}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if onlineSquareAction === 'create'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
variant="outline"
|
||||
onclick={() => handleEmbeddedOnlineSquarePayment('create')}
|
||||
disabled={onlineSquareProcessing || !onlineSquareCardReady}
|
||||
loading={onlineSquareProcessing}
|
||||
>
|
||||
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Modal.Footer>
|
||||
<Button variant="ghost" onclick={() => (generateStep = 'amount_email')}>Back</Button>
|
||||
@@ -2049,12 +2144,45 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
<div class="sm:col-span-2">
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
|
||||
/>
|
||||
</div>
|
||||
{#if isSquareConfigured()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'topup')}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if onlineSquareAction === 'topup'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
variant="outline"
|
||||
onclick={() =>
|
||||
handleEmbeddedOnlineSquarePayment('topup', selectedCardId ?? undefined)}
|
||||
disabled={onlineSquareProcessing || !onlineSquareCardReady}
|
||||
loading={onlineSquareProcessing}
|
||||
>
|
||||
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Modal.Footer>
|
||||
<Button variant="ghost" onclick={() => (topUpStep = 'amount')}>Back</Button>
|
||||
|
||||
Reference in New Issue
Block a user