Files
Crussell/frontend/src/lib/components/admin/TillPurchases.svelte
T

947 lines
32 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { generateUUID } from '$lib/utils/uuid';
import { formatCurrency } from '$lib/utils/format';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
isSquareConfigured,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
SCA_REFUSAL_MESSAGE_TILL,
shouldShowSCARefusal,
submitPaymentWithRetry,
tokenizeSavedCardWithVerification,
PAYMENT_METHOD_SAVED_CARD,
type SavedCardVerificationResult
} from '$lib/square/square';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
type CartItem = {
id: string;
label: string;
price: number;
qty: number;
};
type TillPaymentMethod =
'cash' | 'card_machine' | 'online_square' | typeof PAYMENT_METHOD_SAVED_CARD;
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
{ key: 'cash', label: 'Cash' },
{ key: 'card_machine', label: 'Card Machine' },
{ key: 'online_square', label: 'Online Card' },
{ key: PAYMENT_METHOD_SAVED_CARD, label: 'Saved Card' }
];
let cart = $state<CartItem[]>([]);
let giftCardAmount = $state('25');
let showGiftCardInput = $state(false);
// Gift card funding is capped at £250 per transaction (the backend enforces
// the same limit) — mirror the cap client-side so the till cannot queue an
// oversized gift card.
const GIFT_CARD_MAX_AMOUNT = 250;
let paymentMethod = $state<TillPaymentMethod>('cash');
let onlineSquareCardReady = $state(false);
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
let processing = $state(false);
let paymentError = $state<string | null>(null);
// Synchronous double-click guard (see BookingFlow) — Svelte 5 reactivity is
// async, so `processing` may not reach the button before a fast second click.
let isProcessingPaymentSync = false;
// Idempotency keys are cached per cart line (item id, quantity index, price,
// payment method) so a lost-response retry of the SAME cart reuses the keys:
// the backend re-attempts the charge with the stored key, Square dedups, and
// the customer is not charged twice. A changed cart/amount/payment method
// yields a different composite key, so genuinely new sales get fresh keys.
// Mirrors the BookingFlow/PaymentModal/TipPayment per-charge caching pattern.
let idempotencyKeys = new SvelteMap<string, string>();
function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
// saved_card charges also key on the selected card id so switching to a
// different card (or back to another method) yields fresh keys.
const composite = `${item.id}:${qtyIndex}:${item.price}:${paymentMethod}:${paymentMethod === PAYMENT_METHOD_SAVED_CARD ? (selectedSavedCardId ?? '') : ''}`;
let key = idempotencyKeys.get(composite);
if (!key) {
key = generateUUID();
idempotencyKeys.set(composite, key);
}
return key;
}
// ---------- Customer picker (saved-card payments) ----------
type TillCustomer = {
id: string;
name: string;
email?: string;
};
// Fields mirror the backend SavedCard shape (payment-methods endpoint).
type SavedCard = {
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
cardholder_name?: string;
// Square's card-on-file id (`ccof:...`), needed to run the saved-card SCA
// challenge (tokenizeSavedCardWithVerification).
square_card_id?: string;
};
let customerQuery = $state('');
let customerResults = $state<TillCustomer[]>([]);
let loadingCustomers = $state(false);
let showCustomerResults = $state(false);
let selectedCustomer = $state<TillCustomer | null>(null);
let savedCards = $state<SavedCard[]>([]);
let loadingSavedCards = $state(false);
let savedCardsError = $state<string | null>(null);
let selectedSavedCardId = $state<string | null>(null);
// Square convention: a card is valid through the end of its exp_month/exp_year.
const validCards = $derived(
savedCards.filter((card) => {
const now = new Date();
return (
card.exp_year > now.getFullYear() ||
(card.exp_year === now.getFullYear() && card.exp_month >= now.getMonth() + 1)
);
})
);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the saved-card 3DS challenge is open and the CUSTOMER must
// approve it in their banking app — drives the "waiting for approval" panel.
let awaitingSCA = $state(false);
// The saved-card option is hidden outright unless a customer is selected
// AND has at least one currently-valid card on file.
const showSavedCardOption = $derived(selectedCustomer !== null && validCards.length > 0);
const availablePaymentMethods = $derived(
PAYMENT_METHODS.filter((m) => m.key !== PAYMENT_METHOD_SAVED_CARD || showSavedCardOption)
);
// If the saved-card option disappears (customer cleared, no valid cards, or
// a card expires mid-session) fall back to cash instead of leaving the till
// on an unrenderable method.
$effect(() => {
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD && !showSavedCardOption) {
paymentMethod = 'cash';
selectedSavedCardId = null;
}
});
async function searchCustomers() {
if (!customerQuery.trim()) return;
loadingCustomers = true;
try {
const res = await apiFetch(
`/api/admin/users?page=1&per_page=5&q=${encodeURIComponent(customerQuery.trim())}`
);
if (res.ok) {
const data = await res.json();
const excludedRoles = ['admin', 'guest', 'affiliate'];
customerResults = (data.users || [])
.filter((u: { account_role: string }) => !excludedRoles.includes(u.account_role))
.map((u: { id: string; fullName: string; email?: string }) => ({
id: u.id,
name: u.fullName || 'Customer',
email: u.email
}));
showCustomerResults = true;
}
} catch {
toast.error('Failed to search customers');
} finally {
loadingCustomers = false;
}
}
function selectCustomer(customer: TillCustomer) {
selectedCustomer = customer;
customerQuery = '';
customerResults = [];
showCustomerResults = false;
fetchSavedCards(customer.id);
}
function clearSelectedCustomer() {
selectedCustomer = null;
savedCards = [];
savedCardsError = null;
selectedSavedCardId = null;
customerResults = [];
showCustomerResults = false;
}
async function fetchSavedCards(userId: string) {
loadingSavedCards = true;
savedCards = [];
savedCardsError = null;
selectedSavedCardId = null;
try {
const res = await apiFetch(`/api/admin/users/${userId}/payment-methods`);
if (res.ok) {
savedCards = await res.json();
} else {
savedCardsError = 'Failed to load saved cards';
}
} catch {
savedCardsError = 'Failed to load saved cards';
} finally {
loadingSavedCards = false;
}
}
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
// The backend till sale API currently only accepts item_type 'gift_card', so
// retail items cannot be charged yet — gate the Charge button to gift-card-only carts.
const hasRetailItems = $derived(cart.some((i) => i.label !== 'Gift Card'));
const canCharge = $derived(
cart.length > 0 &&
!hasRetailItems &&
subtotal > 0 &&
!cart.some((i) => i.price > GIFT_CARD_MAX_AMOUNT)
);
// Client-side parity with the per-transaction gift card cap: the amount
// typed into the gift-card input must not exceed £250.
const parsedGiftCardAmount = $derived(parseFloat(giftCardAmount));
const giftCardAmountTooHigh = $derived(
!isNaN(parsedGiftCardAmount) && parsedGiftCardAmount > GIFT_CARD_MAX_AMOUNT
);
function addItem(label: string, price: number) {
const existing = cart.find((i) => i.label === label);
if (existing) {
existing.qty++;
} else {
cart = [...cart, { id: generateUUID(), label, price, qty: 1 }];
}
}
function addGiftCard() {
const amt = parseFloat(giftCardAmount);
if (isNaN(amt) || amt <= 0) return;
// The Add button is disabled via `giftCardAmountTooHigh`, but the
// input's Enter key bypasses that — reject here too (defense in depth).
if (amt > GIFT_CARD_MAX_AMOUNT) return;
addItem('Gift Card', amt);
giftCardAmount = '25';
showGiftCardInput = false;
}
function removeItem(id: string) {
cart = cart.filter((i) => i.id !== id);
}
function updateQty(id: string, delta: number) {
cart = cart
.map((i) => {
if (i.id !== id) return i;
const next = i.qty + delta;
return next <= 0 ? null : { ...i, qty: next };
})
.filter((i): i is CartItem => i !== null);
}
/** Polls a card-machine checkout until it completes (mirrors the gift-card management flow). */
async function pollTillCheckout(checkoutId: string): Promise<void> {
const maxAttempts = 60;
for (let attempts = 0; attempts < maxAttempts; attempts++) {
await new Promise((r) => setTimeout(r, 2000));
try {
const res = await apiFetch(`/api/admin/till/sale/checkout/${checkoutId}/status`);
if (res.ok) {
const data = await res.json();
if (data.status === 'COMPLETED') return;
}
} catch {
// Keep polling — a transient network error is not fatal.
}
}
throw new Error('Card machine payment timed out. Please check the Square dashboard.');
}
async function chargeCart() {
if (isProcessingPaymentSync) return;
if (cart.length === 0) {
toast.error('Cart is empty');
return;
}
if (cart.some((item) => item.price > GIFT_CARD_MAX_AMOUNT)) {
toast.error(`Gift card amount exceeds maximum (£${GIFT_CARD_MAX_AMOUNT})`);
return;
}
if (hasRetailItems) {
toast.error(
'Retail items cannot be charged yet — the till API currently supports gift card sales only'
);
return;
}
if (
paymentMethod === PAYMENT_METHOD_SAVED_CARD &&
(!selectedCustomer || !selectedSavedCardId)
) {
toast.error('Select a customer and a saved card before charging');
return;
}
isProcessingPaymentSync = true;
processing = true;
paymentError = null;
let responseStatus = 0;
try {
// One sale per cart line × quantity — each till sale funds its own
// gift card (the backend only accepts item_type 'gift_card').
const saleBodies: Record<string, unknown>[] = [];
// M10: proactive saved-card (ccof) SCA. Run the client-side challenge
// for every sale line BEFORE the first charge so a naked ccof till
// charge is never sent to the backend (mirrors PaymentModal/UserPaymentModal
// running SCA at charge init). Each line binds its token to its own
// amount. 'challenge-cancelled'/'sca-failed' abort the whole sale
// (retryable); 'sca-unavailable' aborts before any charge and surfaces
// the C6 refusal notice (no 2FA fallback).
let scaAborted = false;
awaitingSCA = true;
try {
for (const item of cart) {
for (let i = 0; i < item.qty; i++) {
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: 'create',
amount: item.price,
payment_method: paymentMethod,
idempotency_key: idempotencyKeyFor(item, i)
};
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) {
body.user_id = selectedCustomer?.id;
body.user_saved_card_id = selectedSavedCardId;
const squareCardId = savedCards.find(
(c) => c.id === selectedSavedCardId
)?.square_card_id;
const sca = await runSavedCardSCAProactively({
// The till body carries the amount in POUNDS (the
// backend multiplies by 100); the SCA challenge binds
// to pence, so convert for the challenge.
amountPence: Math.round(item.price * 100),
squareCardId: squareCardId ?? '',
buyer: { email: selectedCustomer?.email },
onOutcome: (o) => (lastSCAOutcome = o)
});
if (sca.outcome === 'challenge-cancelled' || sca.outcome === 'sca-failed') {
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
}
if (sca.outcome === 'sca-unavailable') {
// C6: SCA genuinely can't run — abort the whole sale
// BEFORE any charge is submitted; the refusal notice
// is shown above the Charge button (no 2FA fallback).
scaAborted = true;
break;
}
// C1: the SCA tokenize-result token is the charge SOURCE
// (new_card_token) alongside the saved-card ref — never
// the legacy verification_token.
if (sca.verificationToken) body.new_card_token = sca.verificationToken;
} else if (paymentMethod === 'online_square') {
if (!onlineSquareCardInput) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
// SCA verification amount must match the sale amount (pence).
const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
Math.round(item.price * 100)
);
body.card_token = tokenized.nonce;
if (tokenized.verificationToken) {
body.verification_token = tokenized.verificationToken;
}
}
saleBodies.push(body);
}
if (scaAborted) break;
}
} finally {
awaitingSCA = false;
}
if (scaAborted) return;
for (const body of saleBodies) {
const res = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
);
if (!res.ok) {
responseStatus = res.status;
const errText = await res.text();
// Saved-card (ccof) SCA: the backend returns 402 +
// `verification_required` when Square requires buyer verification
// and no verification_token was supplied. Run the client-side 3DS
// challenge and retry the SAME sale line with the fresh token and
// its SAME cached idempotency key. runTillSavedCardSCA throws to
// stop the whole sale on any non-verified outcome.
if (
paymentMethod === PAYMENT_METHOD_SAVED_CARD &&
isVerificationRequiredSignal(responseStatus, errText)
) {
await runTillSavedCardSCA(body);
continue;
}
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
const data = await res.json();
if (paymentMethod === 'card_machine' && data.status === 'pending' && data.checkout_id) {
await pollTillCheckout(data.checkout_id as string);
}
}
toast.success('Sale complete');
cart = [];
idempotencyKeys.clear();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Sale failed';
// C6: a sca-unavailable refusal is communicated by the refusal dialog
// above the Charge button — don't duplicate it in the error panel.
if (shouldShowSCARefusal(lastSCAOutcome)) {
paymentError = null;
return;
}
paymentError = msg;
toast.error(msg);
} finally {
isProcessingPaymentSync = false;
processing = false;
}
}
/**
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
* with the verification-required signal (the proactive token was stale or
* expired between tokenize and charge — the first attempt now always runs
* proactive SCA, so this is the defensive path). The CUSTOMER approves the
* 3DS challenge in their banking app; the operator's screen shows the waiting
* state. 'verified' retries the SAME sale line with the fresh tokenize-result
* token as new_card_token and its SAME cached idempotency key (never
* regenerated here); 'sca-unavailable' refuses the sale (C6 — the refusal
* dialog is driven by lastSCAOutcome); 'challenge-cancelled' / 'sca-failed'
* keep the pending row retryable (the idempotency key stays cached). Throws
* to stop the whole sale on any non-verified outcome.
*/
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
// The till body carries the amount in POUNDS (the backend multiplies by
// 100); the SCA challenge binds to pence, so convert for the challenge.
const amountPence = Math.round((Number(body.amount) || 0) * 100);
awaitingSCA = true;
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
email: selectedCustomer?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
// C1: the SCA tokenize-result token is the charge SOURCE.
new_card_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
const err = new Error(extractErrorMessage(errText) || 'Till sale failed');
(err as { bodyText?: string }).bodyText = errText;
throw err;
}
return;
}
if (result.outcome === 'sca-unavailable') {
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
}
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
} finally {
awaitingSCA = false;
}
}
</script>
<div class="rounded-xl border bg-card">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
<h3 class="text-base font-semibold">Till Sales</h3>
</div>
<div class="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Cuticle Oil', 8)}
>
Cuticle Oil - &pound;8
</Button>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Nail Files (Pack)', 5)}
>
Nail Files - &pound;5
</Button>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Hand Cream', 6)}
>
Hand Cream - &pound;6
</Button>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Base Coat', 7)}
>
Base Coat - &pound;7
</Button>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Top Coat', 7)}
>
Top Coat - &pound;7
</Button>
<div class="relative">
{#if showGiftCardInput}
<div class="flex flex-col gap-1">
<div class="flex gap-1">
<div class="relative flex-1">
<span class="absolute top-1/2 left-2 -translate-y-1/2 text-xs text-gray-400"
>&pound;</span
>
<Input
type="text"
inputmode="decimal"
bind:value={giftCardAmount}
max={GIFT_CARD_MAX_AMOUNT}
class="h-9 pl-5"
disabled={processing}
error={giftCardAmountTooHigh ? 'Gift card amount exceeds maximum' : ''}
onkeydown={(e) => {
if (e.key === 'Enter') addGiftCard();
}}
/>
</div>
<Button
size="sm"
variant="outline"
onclick={addGiftCard}
class="h-9 px-2 text-xs"
disabled={processing || giftCardAmountTooHigh}>Add</Button
>
</div>
{#if giftCardAmountTooHigh}
<p class="text-xs text-red-700">
Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})
</p>
{/if}
<p class="text-xs text-muted-foreground">
Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction
</p>
</div>
{:else}
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2"
disabled={processing}
onclick={() => (showGiftCardInput = true)}
>
Gift Card
</Button>
{/if}
</div>
</div>
<div class="border-b border-gray-200 px-4 py-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Customer (saved card payments)</span
>
{#if selectedCustomer}
<div
class="mt-2 flex items-center justify-between gap-2 rounded-md border border-gray-200 bg-gray-50/50 p-3"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-card-foreground">{selectedCustomer.name}</p>
{#if selectedCustomer.email}
<p class="truncate text-xs text-muted-foreground">{selectedCustomer.email}</p>
{/if}
{#if savedCardsError}
<p class="mt-1 text-xs text-red-700">{savedCardsError}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
{#if loadingSavedCards}
<span class="text-xs text-muted-foreground">Loading cards...</span>
{:else if !savedCardsError}
<span class="text-xs text-muted-foreground">
{validCards.length} valid card{validCards.length === 1 ? '' : 's'}
</span>
{/if}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
disabled={processing}
onclick={clearSelectedCustomer}
>
Clear
</Button>
</div>
</div>
{:else}
<div class="relative mt-2">
<div class="flex gap-2">
<div class="relative flex-1">
<Input
type="text"
placeholder="Search by name, email or phone..."
bind:value={customerQuery}
disabled={processing}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchCustomers();
} else if (e.key === 'Escape') {
showCustomerResults = false;
}
}}
onfocus={() => (showCustomerResults = true)}
onblur={() => (showCustomerResults = false)}
/>
</div>
<Button
size="sm"
variant="outline"
class="h-9"
disabled={processing || !customerQuery.trim()}
onclick={searchCustomers}
>
{loadingCustomers ? '...' : 'Search'}
</Button>
</div>
{#if showCustomerResults}
<div
class="absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-background shadow-lg"
>
{#if loadingCustomers}
<div class="flex justify-center p-6">
<div
class="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-primary"
></div>
</div>
{:else if customerResults.length === 0}
<div class="p-4 text-center text-xs text-gray-500">
{customerQuery.trim()
? 'No customers found.'
: 'Type a name, email or phone to search.'}
</div>
{:else}
<ul class="max-h-56 divide-y divide-gray-200 overflow-y-auto">
{#each customerResults as user (user.id)}
<li>
<button
type="button"
class="flex w-full flex-col items-start px-4 py-3 text-left transition-colors hover:bg-fuchsia-50/40"
onmousedown={(e) => e.preventDefault()}
onclick={() => selectCustomer(user)}
>
<span class="text-sm font-semibold text-card-foreground">{user.name}</span>
{#if user.email}
<span class="text-xs text-gray-500">{user.email}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<Separator />
<div class="px-4 py-3">
{#if cart.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">
Tap items above to add them to the sale.
</p>
{:else}
<div class="max-h-48 space-y-1 overflow-y-auto">
{#each cart as item (item.id)}
<div class="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<div class="min-w-0 flex-1">
<span class="font-medium text-card-foreground">{item.label}</span>
<span class="ml-2 text-xs text-muted-foreground"
>{formatCurrency(item.price)} each</span
>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="flex min-h-11 min-w-11 items-center justify-center rounded border text-base text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
disabled={processing}
onclick={() => updateQty(item.id, -1)}
>
&minus;
</button>
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
<button
type="button"
class="flex min-h-11 min-w-11 items-center justify-center rounded border text-base text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
disabled={processing}
onclick={() => updateQty(item.id, 1)}
>
+
</button>
<span class="w-14 text-right text-sm font-semibold tabular-nums"
>{formatCurrency(item.price * item.qty)}</span
>
<button
type="button"
aria-label="Remove item"
class="ml-1 flex min-h-11 min-w-11 items-center justify-center rounded text-base text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
disabled={processing}
onclick={() => removeItem(item.id)}
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/each}
</div>
<Separator class="my-3" />
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">
{itemCount} item{itemCount !== 1 ? 's' : ''}
</span>
<span class="text-lg font-bold tabular-nums">{formatCurrency(subtotal)}</span>
</div>
<div class="mt-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Payment Method</span
>
<div
class="mt-2 grid grid-cols-2 gap-2 {availablePaymentMethods.length > 3
? ''
: 'sm:grid-cols-3'}"
>
{#each availablePaymentMethods as m (m.key)}
<button
type="button"
class="min-h-11 rounded-lg border py-3 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
m.key
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
disabled={processing}
onclick={() => (paymentMethod = m.key)}
>
{m.label}
</button>
{/each}
</div>
</div>
{#if paymentMethod === 'online_square'}
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
{#if isSquareConfigured()}
<SquareCardInput
bind:this={onlineSquareCardInput}
onReady={(r) => (onlineSquareCardReady = r)}
disabled={processing}
/>
{:else}
<p class="text-xs text-gray-500">
Online card entry is unavailable — Square is not configured.
</p>
{/if}
</div>
{/if}
{#if paymentMethod === PAYMENT_METHOD_SAVED_CARD}
<div class="mt-3 space-y-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
{#if loadingSavedCards}
<div class="flex justify-center py-6">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
</div>
{:else if savedCardsError}
<p class="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-800">
{savedCardsError}
</p>
{:else}
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Select a Saved Card</span
>
<div class="space-y-2">
{#each validCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedSavedCardId ===
card.id
? 'border-input bg-fuchsia-100'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (selectedSavedCardId = card.id)}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg
class="h-5 w-5 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<span class="font-medium text-gray-900">{card.brand} ••••{card.last_4}</span>
</div>
<span class="text-xs text-gray-500"
>{String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
</div>
</button>
{/each}
</div>
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-xs text-amber-800">
Your card issuer will ask you to approve this payment in your banking app.
</p>
</div>
{/if}
<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
sca-unavailable outcome: the charge cannot complete and the
customer must pay online later (no 2FA code fallback). -->
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(lastSCAOutcome)}
message={SCA_REFUSAL_MESSAGE_TILL}
onOk={() => {
lastSCAOutcome = '';
paymentError = null;
}}
/>
</div>
{/if}
{#if hasRetailItems}
<p class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
Retail items can&apos;t be charged yet — the till API currently supports gift card sales
only. Remove retail items to complete this sale.
</p>
{/if}
{#if paymentError}
<p class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-800">
{paymentError}
</p>
{/if}
{#if awaitingSCA}
<div
class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="mt-3 text-sm font-medium text-gray-700">
Waiting for customer to approve in their banking app…
</p>
<p class="mt-1 text-xs text-muted-foreground">
The customer may need to approve this payment in their banking app
</p>
</div>
{:else}
<Button
class="mt-3 min-h-11 w-full active:bg-primary/85 active:shadow-none"
onclick={chargeCart}
loading={processing}
disabled={!canCharge ||
processing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
{/if}
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
<p class="mt-1 text-xs text-muted-foreground">
Gift card sales are processed through the till; retail items require manual recording for
now.
</p>
{/if}
</div>
</div>