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:
Vendored
+4
@@ -14,6 +14,10 @@ declare global {
|
||||
__walkInModalCountdownInterval?: ReturnType<typeof setInterval>;
|
||||
__bookingCreateCountdownInterval?: ReturnType<typeof setInterval>;
|
||||
__bookingFlowCountdownInterval?: ReturnType<typeof setInterval> | null;
|
||||
/** Square Web Payments SDK global, present after square.js loads. */
|
||||
Square?: {
|
||||
payments: (appId: string, locationId: string) => unknown;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
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 { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -149,12 +151,20 @@
|
||||
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);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let tipNonce = $state('');
|
||||
|
||||
const canSaveCards = $derived(
|
||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||
);
|
||||
|
||||
const isTipCardValid = $derived(tipSelectedCardId !== null);
|
||||
const isTipCardValid = $derived(
|
||||
tipSelectedCardId !== null || (tipShowNewCardForm && tipSquareCardReady)
|
||||
);
|
||||
|
||||
const tipPresets = $derived(
|
||||
selectedBooking
|
||||
@@ -220,11 +230,21 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (tipSavedCards.length === 0) {
|
||||
toast.error('Please add a saved card or contact the salon to pay by another method');
|
||||
return;
|
||||
}
|
||||
if (!tipSelectedCardId) {
|
||||
let newCardToken: string | undefined;
|
||||
if (tipSelectedCardId) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (tipShowNewCardForm && tipSquareCardInput) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!tipNonce) {
|
||||
try {
|
||||
tipNonce = await tipSquareCardInput.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
newCardToken = tipNonce;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
@@ -239,7 +259,8 @@
|
||||
const body: Record<string, unknown> = {
|
||||
amount: Math.round(tipAmount * 100),
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
card_id: tipSelectedCardId
|
||||
...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
|
||||
@@ -254,6 +275,7 @@
|
||||
toast.success('Thank you for your tip!');
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipNonce = '';
|
||||
showTipModal = false;
|
||||
fetchBookingDetails();
|
||||
} catch (err) {
|
||||
@@ -1090,6 +1112,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
customTipInput = '';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipSelectedCardId = null;
|
||||
tipShowNewCardForm = false;
|
||||
tipNonce = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1150,10 +1175,14 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId ===
|
||||
card.id
|
||||
card.id && !tipShowNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => (tipSelectedCardId = card.id)}
|
||||
onclick={() => {
|
||||
tipSelectedCardId = card.id;
|
||||
tipShowNewCardForm = false;
|
||||
tipNonce = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
@@ -1164,16 +1193,55 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if tipSelectedCardId === card.id}
|
||||
{#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}
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
||||
/>
|
||||
{#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}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
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 PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
@@ -88,6 +90,13 @@
|
||||
let paymentMethodsLoading = $state(false);
|
||||
let selectedPaymentMethod = $state<string | null>(null);
|
||||
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('');
|
||||
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
|
||||
// on the next microtask), so `isProcessingPayment` may not propagate to the
|
||||
// button's `disabled` binding before a fast second click fires. This non-
|
||||
@@ -97,7 +106,15 @@
|
||||
// Payment flow state
|
||||
let depositPaid = $state(false);
|
||||
|
||||
const depositCardFormValid = $derived(selectedPaymentMethod !== null);
|
||||
// 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 depositCardFormValid = $derived(
|
||||
selectedPaymentMethod !== null || (newCardMode && squareCardReady)
|
||||
);
|
||||
|
||||
// VAT registration status from public business info (via shared store)
|
||||
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
|
||||
@@ -267,8 +284,22 @@
|
||||
isProcessingPayment = true;
|
||||
paymentAttempted = false;
|
||||
try {
|
||||
if (!selectedPaymentMethod) {
|
||||
toast.error('Please select a saved card');
|
||||
let newCardToken: string | undefined;
|
||||
if (selectedPaymentMethod) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (newCardMode && squareCardInput) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!depositNonce) {
|
||||
try {
|
||||
depositNonce = await squareCardInput.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
newCardToken = depositNonce;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
await submitAndProceed();
|
||||
@@ -281,7 +312,7 @@
|
||||
|
||||
// Cache the idempotency key per amount+card so a lost-response retry
|
||||
// reuses it (backend dedups) instead of double-charging.
|
||||
const cardKey = selectedPaymentMethod;
|
||||
const cardKey = selectedPaymentMethod ?? `new:${newCardToken ?? ''}`;
|
||||
if (
|
||||
!depositIdempotencyKey ||
|
||||
depositKeyedAmount !== amountCents ||
|
||||
@@ -296,7 +327,8 @@
|
||||
payment_type: 'deposit',
|
||||
amount: amountCents,
|
||||
idempotency_key: depositIdempotencyKey,
|
||||
card_id: selectedPaymentMethod
|
||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||
};
|
||||
|
||||
paymentAttempted = true;
|
||||
@@ -315,6 +347,7 @@
|
||||
depositIdempotencyKey = '';
|
||||
depositKeyedAmount = 0;
|
||||
depositKeyedCard = '';
|
||||
depositNonce = '';
|
||||
// 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
|
||||
@@ -2270,7 +2303,7 @@
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
||||
method.id
|
||||
method.id && !showNewCardForm
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
@@ -2289,27 +2322,62 @@
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
|
||||
onclick={() => (selectedPaymentMethod = method.id)}
|
||||
variant={selectedPaymentMethod === method.id && !showNewCardForm
|
||||
? 'default'
|
||||
: 'outline'}
|
||||
onclick={() => {
|
||||
selectedPaymentMethod = method.id;
|
||||
showNewCardForm = false;
|
||||
depositNonce = '';
|
||||
}}
|
||||
>
|
||||
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
|
||||
{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>
|
||||
{:else}
|
||||
{/if}
|
||||
|
||||
{#if newCardMode}
|
||||
<div class="mb-6">
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
||||
/>
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please contact the salon to pay by another method."
|
||||
/>
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import CardBrandIcon from './CardBrandIcon.svelte';
|
||||
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
||||
import SquareCardInput from './SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
|
||||
export interface SelectableCard {
|
||||
id: string;
|
||||
@@ -14,20 +16,20 @@
|
||||
let {
|
||||
cards = [],
|
||||
canSaveCards: _canSaveCards = false,
|
||||
newCardDisabled = false,
|
||||
selectedCardId = $bindable(''),
|
||||
onValidityChange = (_valid: boolean) => {}
|
||||
}: {
|
||||
cards?: SelectableCard[];
|
||||
canSaveCards?: boolean;
|
||||
newCardDisabled?: boolean;
|
||||
selectedCardId?: string;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
} = $props();
|
||||
|
||||
// Internal — whether the "use a new card" form is shown.
|
||||
// When no saved cards exist the form shows by default.
|
||||
// Whether the "use a new card" form is shown. When no saved cards exist the
|
||||
// form shows by default; otherwise it is toggled by the "Use a new card" row.
|
||||
let showNewCardForm = $state(false);
|
||||
let squareCardReady = $state(false);
|
||||
let squareCardInput = $state<SquareCardInput | null>(null);
|
||||
|
||||
// Auto-select the default saved card when cards first load. Guarded by
|
||||
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
|
||||
@@ -40,12 +42,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Exposed derived state — same semantics as the other card flows.
|
||||
const isCardValid = $derived(selectedCardId !== '' && cards.length > 0);
|
||||
// When no saved cards exist the new-card form shows by default (no toggle).
|
||||
const newCardMode = $derived(showNewCardForm || cards.length === 0);
|
||||
|
||||
// Exposed derived state — valid when a saved card is selected OR the new-card
|
||||
// Square form is ready to tokenize.
|
||||
const isCardValid = $derived(
|
||||
(selectedCardId !== '' && cards.length > 0) || (newCardMode && squareCardReady)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
onValidityChange(isCardValid);
|
||||
});
|
||||
|
||||
/** Tokenizes the new-card form. Returns the cnon:xxx nonce; throws on error. */
|
||||
export async function tokenize(): Promise<string> {
|
||||
if (!newCardMode || !squareCardInput) {
|
||||
throw new Error('No new card form is open');
|
||||
}
|
||||
return squareCardInput.tokenize();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if cards.length > 0}
|
||||
@@ -77,35 +93,37 @@
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if !newCardDisabled}
|
||||
<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 = '';
|
||||
showNewCardForm = true;
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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 = '';
|
||||
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>
|
||||
{#if showNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<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 newCardDisabled}
|
||||
{#if cards.length === 0}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
{#if newCardMode}
|
||||
<div class="mt-3">
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput bind:this={squareCardInput} onReady={(r) => (squareCardReady = r)} />
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
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;
|
||||
/** Called when the card form finishes (re)initializing; true = ready to tokenize. */
|
||||
onReady?: (ready: boolean) => void;
|
||||
}
|
||||
|
||||
let { disabled = false, onReady = () => {} }: Props = $props();
|
||||
|
||||
let containerEl = $state<HTMLDivElement | null>(null);
|
||||
let cardInstance: unknown | null = null;
|
||||
let ready = $state(false);
|
||||
let initError = $state<string | null>(null);
|
||||
|
||||
let uniqueId = $state(`square-card-${squareCardIdCounter++}`);
|
||||
|
||||
async function init() {
|
||||
if (!isSquareConfigured()) {
|
||||
initError = 'not_configured';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payments = (await getSquarePayments()) as {
|
||||
card: () => Promise<{
|
||||
attach: (selector: string) => Promise<void>;
|
||||
tokenize: () => Promise<{
|
||||
status: string;
|
||||
token?: string;
|
||||
errors?: Array<{ message?: string; code?: string }>;
|
||||
}>;
|
||||
destroy: () => void;
|
||||
}>;
|
||||
};
|
||||
const card = await payments.card();
|
||||
await card.attach(`#${uniqueId}`);
|
||||
cardInstance = card;
|
||||
ready = true;
|
||||
initError = null;
|
||||
} catch (err) {
|
||||
console.error('Square card form init failed:', err);
|
||||
initError = 'init_failed';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
const card = cardInstance as { destroy?: () => void } | null;
|
||||
card?.destroy?.();
|
||||
cardInstance = null;
|
||||
ready = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
onReady(ready && !disabled);
|
||||
});
|
||||
|
||||
/** Tokenizes the entered card. Returns the cnon:xxx nonce, throws with a user-facing message. */
|
||||
export async function tokenize(): Promise<string> {
|
||||
const card = cardInstance as {
|
||||
tokenize: () => Promise<{
|
||||
status: string;
|
||||
token?: string;
|
||||
errors?: Array<{ message?: string; code?: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
if (!card) {
|
||||
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||
}
|
||||
const result = await card.tokenize();
|
||||
if (result.status === 'OK' && result.token) {
|
||||
return result.token;
|
||||
}
|
||||
const detail =
|
||||
result.errors
|
||||
?.map((e) => e.message || e.code)
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Card details are incomplete';
|
||||
throw new Error(detail);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if initError === 'not_configured'}
|
||||
<CardEntryUnavailable />
|
||||
{:else if initError === 'init_failed'}
|
||||
<CardEntryUnavailable
|
||||
message="The secure card form failed to load. Please try again or use a saved card."
|
||||
/>
|
||||
{:else}
|
||||
<div id={uniqueId} bind:this={containerEl}></div>
|
||||
{/if}
|
||||
@@ -37,6 +37,9 @@
|
||||
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('');
|
||||
let paymentResult = $state<{
|
||||
id: string;
|
||||
amount: number;
|
||||
@@ -50,6 +53,7 @@
|
||||
let paymentMethodsLoading = $state(false);
|
||||
let selectedCardId = $state('');
|
||||
let cardSelectionValid = $state(false);
|
||||
let cardSelection = $state<CardSelection | null>(null);
|
||||
|
||||
let stamps = $state(0);
|
||||
let useLoyalty = $state(false);
|
||||
@@ -341,19 +345,35 @@
|
||||
}
|
||||
|
||||
let cardId: string | undefined;
|
||||
let newCardToken: string | undefined;
|
||||
|
||||
if (selectedCardId) {
|
||||
cardId = selectedCardId;
|
||||
} else if (cardSelection) {
|
||||
// New-card mode: tokenize once per attempt, then reuse the cached nonce
|
||||
// on retry (tokenization is one-shot; the backend idempotency key dedups).
|
||||
if (!newCardNonce) {
|
||||
try {
|
||||
newCardNonce = await cardSelection.tokenize();
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
|
||||
error = msg;
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
newCardToken = newCardNonce;
|
||||
} else {
|
||||
status = 'error';
|
||||
error = 'Please select a saved card';
|
||||
toast.error('Please select a saved card');
|
||||
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.
|
||||
const cardKey = cardId ?? '';
|
||||
const cardKey = cardId ?? `new:${newCardToken ?? ''}`;
|
||||
if (
|
||||
!payIdempotencyKey ||
|
||||
payKeyedAmount !== amountCents ||
|
||||
@@ -373,7 +393,8 @@
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
payment_type: paymentType,
|
||||
card_id: cardId,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: canSaveCards } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
})
|
||||
});
|
||||
@@ -390,6 +411,7 @@
|
||||
payKeyedAmount = 0;
|
||||
payKeyedType = '';
|
||||
payKeyedCard = '';
|
||||
newCardNonce = '';
|
||||
paymentResult = {
|
||||
id: data.id,
|
||||
amount: data.amount,
|
||||
@@ -671,9 +693,9 @@
|
||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||
{:else}
|
||||
<CardSelection
|
||||
bind:this={cardSelection}
|
||||
cards={paymentMethods}
|
||||
{canSaveCards}
|
||||
newCardDisabled
|
||||
bind:selectedCardId
|
||||
onValidityChange={(v) => (cardSelectionValid = v)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Env vars (frontend build-time, public — safe for the browser):
|
||||
// VITE_SQUARE_APPLICATION_ID Square Web Payments application ID (client-side public)
|
||||
// VITE_SQUARE_LOCATION_ID Square location ID
|
||||
// VITE_SQUARE_ENVIRONMENT 'sandbox' | 'production' (optional; auto-derived from
|
||||
// the application ID prefix when omitted)
|
||||
|
||||
const APP_ID = (import.meta.env.VITE_SQUARE_APPLICATION_ID as string | undefined) ?? '';
|
||||
const LOCATION_ID = (import.meta.env.VITE_SQUARE_LOCATION_ID as string | undefined) ?? '';
|
||||
|
||||
export interface SquareConfig {
|
||||
appId: string;
|
||||
locationId: string;
|
||||
}
|
||||
|
||||
/** True when both Square application + location IDs are configured at build time. */
|
||||
export function isSquareConfigured(): boolean {
|
||||
return APP_ID !== '' && LOCATION_ID !== '';
|
||||
}
|
||||
|
||||
export function getSquareConfig(): SquareConfig | null {
|
||||
if (!isSquareConfigured()) return null;
|
||||
return { appId: APP_ID, locationId: LOCATION_ID };
|
||||
}
|
||||
|
||||
function sdkUrl(): string {
|
||||
const env = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? '';
|
||||
const isSandbox = env === 'sandbox' || (APP_ID !== '' && APP_ID.startsWith('sandbox-'));
|
||||
return isSandbox
|
||||
? 'https://sandbox.web.squarecdn.com/v1/square.js'
|
||||
: 'https://web.squarecdn.com/v1/square.js';
|
||||
}
|
||||
|
||||
let sdkPromise: Promise<unknown> | null = null;
|
||||
|
||||
/**
|
||||
* Loads the Square.js script once and resolves with the global `Square` object.
|
||||
* The promise is cached so concurrent card forms share a single script load.
|
||||
*/
|
||||
export function loadSquareSdk(): Promise<unknown> {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('Square SDK requires a browser environment'));
|
||||
}
|
||||
const win = window as unknown as { Square?: unknown };
|
||||
if (win.Square) {
|
||||
return Promise.resolve(win.Square);
|
||||
}
|
||||
if (sdkPromise) return sdkPromise;
|
||||
|
||||
sdkPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = sdkUrl();
|
||||
script.async = true;
|
||||
script.dataset.squareSdk = 'true';
|
||||
script.onload = () => {
|
||||
if (win.Square) {
|
||||
resolve(win.Square);
|
||||
} else {
|
||||
reject(new Error('Square.js loaded but the Square global is missing'));
|
||||
}
|
||||
};
|
||||
script.onerror = () => {
|
||||
sdkPromise = null;
|
||||
reject(new Error('Failed to load Square Web Payments SDK'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return sdkPromise;
|
||||
}
|
||||
|
||||
/** Returns `Square.payments(appId, locationId)` once the SDK is loaded. */
|
||||
export async function getSquarePayments(): Promise<unknown> {
|
||||
const config = getSquareConfig();
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
'Square is not configured — set VITE_SQUARE_APPLICATION_ID and VITE_SQUARE_LOCATION_ID'
|
||||
);
|
||||
}
|
||||
const Square = (await loadSquareSdk()) as {
|
||||
payments: (appId: string, locationId: string) => unknown;
|
||||
};
|
||||
return Square.payments(config.appId, config.locationId);
|
||||
}
|
||||
@@ -5,13 +5,15 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
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 { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
|
||||
// zxcvbn-ts imports
|
||||
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
|
||||
@@ -135,6 +137,41 @@
|
||||
let cardToDelete = $state<SavedCard | null>(null);
|
||||
let showDeleteCardDialog = $state(false);
|
||||
|
||||
// Add-a-Card (Square Web Payments tokenization)
|
||||
let addCardSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
let addCardReady = $state(false);
|
||||
let addingCard = $state(false);
|
||||
|
||||
async function addCard() {
|
||||
if (!addCardSquareCardInput) return;
|
||||
let token: string;
|
||||
try {
|
||||
token = await addCardSquareCardInput.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
}
|
||||
addingCard = true;
|
||||
try {
|
||||
const res = await apiFetch('/api/user/payment-methods', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_token: token })
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Card saved');
|
||||
await savedCardsStore.invalidate();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(extractErrorMessage(errText) || 'Failed to add card');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
addingCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCard(card: SavedCard) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/user/payment-methods/${card.id}`, {
|
||||
@@ -168,6 +205,12 @@
|
||||
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);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let buyNonce = $state('');
|
||||
|
||||
// Cached idempotency key: generated once per purchase attempt, reused on
|
||||
// retry (so a lost-response retry dedups instead of double-charging),
|
||||
@@ -177,10 +220,10 @@
|
||||
let buyKeyedCard = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// Auto-select the default saved card when cards first load. A new card
|
||||
// cannot be entered online right now (see CardEntryUnavailable), so this
|
||||
// only ever needs to pick between saved cards.
|
||||
if (savedCardsStore.cards.length > 0 && !buySelectedCard) {
|
||||
// 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;
|
||||
@@ -188,7 +231,9 @@
|
||||
});
|
||||
|
||||
// Derived validation for Buy Gift Card form
|
||||
const isBuyCardValid = $derived(buySelectedCard !== '');
|
||||
const isBuyCardValid = $derived(
|
||||
buySelectedCard !== '' || (buyShowNewCardForm && buySquareCardReady)
|
||||
);
|
||||
|
||||
async function fetchGiftCardBalance() {
|
||||
loadingBalance = true;
|
||||
@@ -235,8 +280,23 @@
|
||||
}
|
||||
|
||||
async function buyGiftCard() {
|
||||
if (!buySelectedCard) {
|
||||
toast.error('Please select a saved card');
|
||||
let newCardToken: string | undefined;
|
||||
if (buySelectedCard) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (buyShowNewCardForm && buySquareCardInput) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!buyNonce) {
|
||||
try {
|
||||
buyNonce = await buySquareCardInput.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
buyingGiftCard = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
newCardToken = buyNonce;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
buyingGiftCard = false;
|
||||
return;
|
||||
}
|
||||
@@ -248,7 +308,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;
|
||||
const cardKey = cardId ?? `new:${newCardToken ?? ''}`;
|
||||
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
||||
buyIdempotencyKey = generateIdempotencyKey();
|
||||
buyKeyedAmount = buyAmount;
|
||||
@@ -262,7 +322,8 @@
|
||||
amount: buyAmount * 100, // cents
|
||||
recipient_type: buyRecipientType,
|
||||
recipient_email: buyRecipientEmail,
|
||||
card_id: cardId,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {}),
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
});
|
||||
@@ -274,6 +335,7 @@
|
||||
buyIdempotencyKey = '';
|
||||
buyKeyedAmount = 0;
|
||||
buyKeyedCard = '';
|
||||
buyNonce = '';
|
||||
await fetchGiftCardBalance();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
@@ -1806,44 +1868,60 @@
|
||||
<Skeleton class="h-16 w-full" />
|
||||
<Skeleton class="h-16 w-full" />
|
||||
</div>
|
||||
{:else if savedCardsStore.cards.length === 0}
|
||||
<div class="space-y-4 py-2">
|
||||
<p class="text-center text-gray-500">No saved cards yet</p>
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each savedCardsStore.cards as card (card.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
**** {card.last_4}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
|
||||
{#if savedCardsStore.cards.length === 0}
|
||||
<p class="py-2 text-center text-gray-500">No saved cards yet</p>
|
||||
{:else}
|
||||
{#each savedCardsStore.cards as card (card.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
**** {card.last_4}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onclick={() => {
|
||||
cardToDelete = card;
|
||||
showDeleteCardDialog = true;
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="border-t pt-4">
|
||||
<div class="mb-3 text-sm font-medium text-gray-700">Add a new card</div>
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={addCardSquareCardInput}
|
||||
onReady={(r) => (addCardReady = r)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onclick={() => {
|
||||
cardToDelete = card;
|
||||
showDeleteCardDialog = true;
|
||||
}}
|
||||
class="mt-3 w-full"
|
||||
onclick={addCard}
|
||||
disabled={addingCard || !addCardReady}
|
||||
loading={addingCard}
|
||||
>
|
||||
Remove
|
||||
{addingCard ? 'Adding...' : 'Add Card'}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
@@ -2107,10 +2185,14 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
|
||||
card.id
|
||||
card.id && !buyShowNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => (buySelectedCard = card.id)}
|
||||
onclick={() => {
|
||||
buySelectedCard = card.id;
|
||||
buyShowNewCardForm = false;
|
||||
buyNonce = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
@@ -2121,14 +2203,48 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if buySelectedCard === card.id}
|
||||
{#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>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
|
||||
{#if buyShowNewCardForm || savedCardsStore.cards.length === 0}
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={buySquareCardInput}
|
||||
onReady={(r) => (buySquareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
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 { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
|
||||
// Types
|
||||
@@ -53,8 +55,14 @@
|
||||
// Card selection state
|
||||
let savedCards = $state<SavedCard[]>([]);
|
||||
let selectedCardId = $state<string | null>(null);
|
||||
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 tipNonce = $state('');
|
||||
|
||||
const isCardValid = $derived(selectedCardId !== null);
|
||||
const isCardValid = $derived(selectedCardId !== null || (showNewCardForm && squareCardReady));
|
||||
|
||||
// Tip selection state
|
||||
let selectedTip = $state<number | null>(null);
|
||||
@@ -193,11 +201,21 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedCards.length === 0) {
|
||||
toast.error('Please add a saved card or contact the salon to pay by another method');
|
||||
return;
|
||||
}
|
||||
if (!selectedCardId) {
|
||||
let newCardToken: string | undefined;
|
||||
if (selectedCardId) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (showNewCardForm && squareCardInput) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!tipNonce) {
|
||||
try {
|
||||
tipNonce = await squareCardInput.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
newCardToken = tipNonce;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
@@ -213,7 +231,8 @@
|
||||
const body: Record<string, unknown> = {
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
card_id: selectedCardId
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
|
||||
@@ -230,6 +249,7 @@
|
||||
paymentState = 'success';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipNonce = '';
|
||||
toast.success('Thank you for your tip!');
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
@@ -455,10 +475,14 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||
card.id
|
||||
card.id && !showNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => (selectedCardId = card.id)}
|
||||
onclick={() => {
|
||||
selectedCardId = card.id;
|
||||
showNewCardForm = false;
|
||||
tipNonce = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
@@ -469,11 +493,36 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedCardId === card.id}
|
||||
{#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}
|
||||
@@ -481,11 +530,24 @@
|
||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
|
||||
Payment Method
|
||||
</span>
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
||||
/>
|
||||
{#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}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
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 { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -59,8 +61,14 @@
|
||||
// Card selection state (same pattern as UserPaymentModal)
|
||||
let savedCards = $state<SavedCard[]>([]);
|
||||
let selectedCardId = $state<string | null>(null);
|
||||
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 tipNonce = $state('');
|
||||
|
||||
const isCardValid = $derived(selectedCardId !== null);
|
||||
const isCardValid = $derived(selectedCardId !== null || (showNewCardForm && squareCardReady));
|
||||
|
||||
let selectedTip = $state<number | null>(null);
|
||||
let customTip = $state('');
|
||||
@@ -142,11 +150,21 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedCards.length === 0) {
|
||||
toast.error('Please add a saved card or contact the salon to pay by another method');
|
||||
return;
|
||||
}
|
||||
if (!selectedCardId) {
|
||||
let newCardToken: string | undefined;
|
||||
if (selectedCardId) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (showNewCardForm && squareCardInput) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
||||
if (!tipNonce) {
|
||||
try {
|
||||
tipNonce = await squareCardInput.tokenize();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
newCardToken = tipNonce;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +180,8 @@
|
||||
const body: Record<string, unknown> = {
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
card_id: selectedCardId
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
@@ -179,6 +198,7 @@
|
||||
paymentState = 'success';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipNonce = '';
|
||||
toast.success('Thank you for your tip!');
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
@@ -473,10 +493,14 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||
card.id
|
||||
card.id && !showNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => (selectedCardId = card.id)}
|
||||
onclick={() => {
|
||||
selectedCardId = card.id;
|
||||
showNewCardForm = false;
|
||||
tipNonce = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
@@ -487,16 +511,48 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedCardId === card.id}
|
||||
{#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>
|
||||
{:else}
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showNewCardForm || savedCards.length === 0}
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={squareCardInput}
|
||||
onReady={(r) => (squareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
Reference in New Issue
Block a user