Three fresh reviews (money/security/dup-mod) cross-validated findings: - MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL - MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks - DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed' - HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert) - LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval) - Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID - Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race - SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence) 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
826 lines
27 KiB
Svelte
826 lines
27 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 { toast } from 'svelte-sonner';
|
||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||
import { apiFetch } from '$lib/utils/api';
|
||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||
import {
|
||
isSquareConfigured,
|
||
isTwoFactorVerificationGateFailure,
|
||
submitPaymentWithRetry
|
||
} from '$lib/square/square';
|
||
import { authStore } from '$lib/stores/auth.svelte';
|
||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||
|
||
type CartItem = {
|
||
id: string;
|
||
label: string;
|
||
price: number;
|
||
qty: number;
|
||
};
|
||
|
||
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | '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: '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 Map<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 === '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;
|
||
};
|
||
|
||
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)
|
||
);
|
||
})
|
||
);
|
||
|
||
// B6/B10: charging a customer's saved card via the till requires the
|
||
// customer's current 2FA verification code when the backend enforces the
|
||
// gate. The backend keys on the CARD OWNER (not the admin), so the input is
|
||
// surfaced whenever the gate is enforced — the operator relays the
|
||
// customer's code. Cash, card machine, and online (new-card nonce) payments
|
||
// are unaffected. Shared two-factor-code state (code, reveal, show/missing
|
||
// derivations, "Request a new code" handler) — see
|
||
// $lib/stores/twoFactorCode.svelte.ts. The admin always supplies the
|
||
// CUSTOMER's code — the admin's own 2FA flag is irrelevant to the backend
|
||
// gate, so `enabled` is always true.
|
||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||
enabled: () => true,
|
||
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card'
|
||
});
|
||
|
||
// 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 !== '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 === '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 formatCurrency(n: number): string {
|
||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
|
||
}
|
||
|
||
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 === '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>[] = [];
|
||
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 === 'saved_card') {
|
||
body.user_id = selectedCustomer?.id;
|
||
body.user_saved_card_id = selectedSavedCardId;
|
||
// B6/B10: the backend requires the CARD OWNER's current 2FA
|
||
// verification code when the gate is enforced.
|
||
if (twoFactor.showInput) body.verification_code = twoFactor.code;
|
||
} 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);
|
||
}
|
||
}
|
||
|
||
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();
|
||
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();
|
||
twoFactor.setCode('');
|
||
twoFactor.reveal = false;
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||
// code, brute-force lockout) is recoverable — keep the code populated
|
||
// and reveal the input so the sale can be retried with a fresh code.
|
||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||
paymentError = msg;
|
||
toast.error(msg);
|
||
} finally {
|
||
isProcessingPaymentSync = false;
|
||
processing = 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 text-sm"
|
||
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 h-9 w-9 min-w-9 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 h-9 w-9 min-w-9 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 h-9 w-9 min-w-9 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="rounded-lg border py-3 text-sm font-medium transition-colors 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 === '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 {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">
|
||
This card may require bank app confirmation to complete. Ensure the customer has
|
||
their phone ready.
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- B6/B10: saved-card till charges require the customer's
|
||
current 2FA verification code when the backend enforces
|
||
the gate. -->
|
||
<TwoFactorCodeInput
|
||
bind:code={twoFactor.code}
|
||
showInput={twoFactor.showInput}
|
||
enabled={true}
|
||
/>
|
||
{#if twoFactor.showInput}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="w-full"
|
||
loading={twoFactor.requesting}
|
||
disabled={twoFactor.requesting}
|
||
onclick={twoFactor.requestNewCode}
|
||
>
|
||
Request a new code
|
||
</Button>
|
||
{/if}
|
||
</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}
|
||
|
||
<Button
|
||
class="mt-3 w-full"
|
||
onclick={chargeCart}
|
||
loading={processing}
|
||
disabled={!canCharge ||
|
||
processing ||
|
||
twoFactor.missing ||
|
||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||
>
|
||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||
</Button>
|
||
<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>
|