Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
947 lines
32 KiB
Svelte
947 lines
32 KiB
Svelte
<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 - £8
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="justify-start gap-2"
|
||
disabled={processing}
|
||
onclick={() => addItem('Nail Files (Pack)', 5)}
|
||
>
|
||
Nail Files - £5
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="justify-start gap-2"
|
||
disabled={processing}
|
||
onclick={() => addItem('Hand Cream', 6)}
|
||
>
|
||
Hand Cream - £6
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="justify-start gap-2"
|
||
disabled={processing}
|
||
onclick={() => addItem('Base Coat', 7)}
|
||
>
|
||
Base Coat - £7
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="justify-start gap-2"
|
||
disabled={processing}
|
||
onclick={() => addItem('Top Coat', 7)}
|
||
>
|
||
Top Coat - £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"
|
||
>£</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 (£{GIFT_CARD_MAX_AMOUNT})
|
||
</p>
|
||
{/if}
|
||
<p class="text-xs text-muted-foreground">
|
||
Gift card limit £{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)}
|
||
>
|
||
−
|
||
</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'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>
|