Files
Crussell/frontend/src/lib/components/admin/GiftCardsManagement.svelte
T
popertots 54a5b1024e Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors,
nitpicks), then close the post-implementation re-review items, then
align card-form typography and roll out the Square trust badge.

Backend - Square API alignment:
- tip_settings.allow_tipping nested under device_options (was top-level:
  terminal tips were silently lost in prod)
- CreateCardOnFile now accepts customerID and sends card.customer_id;
  saved-card (ccof:) charges forward square_customer_id as CustomerID
- New SquareClient methods GetPayment, CreateCustomer, CancelCheckout
- SCA verification_token accepted + forwarded in all charge paths
- ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout
  NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/
  ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts
  emails, ForceRefundPending hook

Backend - money safety:
- sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id
  instead of stranding them forever
- SweepStalePendingPayments reconciles at Square before failing (tri-state:
  leave pending on transport error, rescue completed, fail definitively)
- GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution;
  SweepStaleTerminalCheckouts covers terminal_checkouts table
- till gift-card clawback on definitive failure incl. retry path +
  INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT
- cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id))
- customer provisioning (lazy, save-only); one-off/guest mint no customer
- discount preview/apply unified in discounts.go (global-milestone visible
  in preview, N+1 eliminated, redemption counter preserved on failures)
- webhook event_id dedup; refund loop dedup; stale comment fixes
- test-isolation t.Cleanup on committed sweep tests

Frontend:
- SCA tokenizeWithVerification across all charge flows (amount as
  major-units decimal), 5-min token-expiry re-tokenize, verification_token
  in request bodies
- PaymentModal synchronous double-click + zero/negative-amount guards
- till online-card UI wired to /api/admin/till/sale
- policyPopover generalised; new /privacy-policy route; consent checkbox
  copy + Square privacy link
- Square card iframe styled to app typography (Inter 14px, oklch tokens);
  mock form md:text-sm parity
- 'Secure payment powered by Square' badge on all 8 card-payment flows

Schema/docs: terminal_checkouts + square_customer_id + per-user card
constraint in init-script.sql; README migrations; P14 plan + backlog +
Technical Manual updated.

Includes 39 modified/new test files; full backend suite (25 pkgs),
-race on payments+square, and frontend build are green.
2026-08-22 00:34:49 +01:00

2348 lines
74 KiB
Svelte

<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { EmailInput } from '$lib/components/ui/email-input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
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';
interface GiftCard {
id: string;
total_funds_added: number;
amount_remaining: number;
created_by?: string;
created_at: string;
redeemed_at?: string;
redeemed_by?: string;
is_inventory?: boolean;
last_used_at?: string;
}
interface UserBalance {
user_id: string;
name: string;
email: string;
balance: number;
updated_at: string;
/* TODO: add previousFirstName/previousLastName when backend sends them */
}
interface GiftCardSummary {
total_unclaimed: number;
total_user_balances: number;
gift_cards: GiftCard[];
user_balances: UserBalance[];
}
const summary = $state<GiftCardSummary>({
total_unclaimed: 0,
total_user_balances: 0,
gift_cards: [],
user_balances: []
});
let cardQuery = $state('');
let cards = $state<GiftCard[]>([]);
let balances = $state<UserBalance[]>([]);
let totalBalanceRecords = $state(0);
let currentPage = $state(1);
let totalPages = $state(1);
let loadingSearch = $state(false);
let activeSection = $state<'cards' | 'balances' | 'expired' | 'expired_cards'>('cards');
let expiredBalances = $state<
Array<{
id: string;
account_id?: string;
original_balance: number;
expired_at: string;
claimed_at?: string;
claimed_by_admin?: string;
notes?: string;
}>
>([]);
let loadingExpired = $state(false);
let claimingId = $state<string | null>(null);
let loading = $state(true);
let showGenerateModal = $state(false);
let showTopUpModal = $state(false);
let showTransferModal = $state(false);
let creating = $state(false);
let transferring = $state(false);
let selectedCardId = $state<string | null>(null);
// Form inputs
let generateAmount = $state('');
let generateUserQuery = $state('');
let generateUsers = $state<
Array<{
id: string;
fullName: string;
email?: string;
phone?: string;
previousFirstName?: string | null;
previousLastName?: string | null;
}>
>([]);
let generateLoadingUsers = $state(false);
let topUpAmount = $state('');
let transferAmount = $state('');
let transferToCode = $state('');
// Redesigned generate modal states
let generateStep = $state<
| 'type'
| 'customer'
| 'amount_email'
| 'payment'
| 'cash_entry'
| 'processing'
| 'success'
| 'error'
>('type');
let generateType = $state<'code' | 'account' | 'stock'>('code');
// Page 2: Customer selection state
let generateCustomerTab = $state<'current' | 'member' | 'guest'>('current');
let currentCustomerInfo = $state<{
id: string;
name: string;
email?: string;
phone?: string;
previousFirstName?: string | null;
previousLastName?: string | null;
} | null>(null);
let loadingCurrentCustomer = $state(false);
// Selection from Page 2
let selectedCustomer = $state<{
id: string;
name: string;
email?: string;
previousFirstName?: string | null;
previousLastName?: string | null;
} | null>(null);
let isGuestSelected = $state(false);
// Page 3: Recipient email input
let generateEmail = $state('');
// Payment Processing States
let cashAmount = $state('');
let paymentError = $state('');
let paymentResult = $state<{
id: string;
giftcard_code?: string;
item_id?: string;
status?: string;
} | null>(null);
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('');
function getIdempotencyKey(): string {
if (!idempotencyKey) {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
array[6] = (array[6] & 0x0f) | 0x40;
array[8] = (array[8] & 0x3f) | 0x80;
idempotencyKey = [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
return idempotencyKey;
}
let topUpStep = $state<
'choice' | 'amount' | 'payment' | 'cash_entry' | 'processing' | 'success' | 'error'
>('choice');
let topUpMode = $state<'giveaway' | 'purchase'>('giveaway');
// Validation
const generateError = $derived(
generateAmount && (isNaN(Number(generateAmount)) || Number(generateAmount) <= 0)
? 'Must be a valid positive number'
: ''
);
const topUpError = $derived(
topUpAmount && (isNaN(Number(topUpAmount)) || Number(topUpAmount) <= 0)
? 'Must be a valid positive number'
: ''
);
const transferAmountError = $derived(
transferAmount && (isNaN(Number(transferAmount)) || Number(transferAmount) <= 0)
? 'Must be a valid positive number'
: ''
);
const transferCodeError = $derived(
transferToCode && transferToCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12
? 'Code must be exactly 12 characters'
: ''
);
const isAmountValid = $derived(
generateType === 'stock' ? true : !!(generateAmount && !generateError)
);
const isEmailValid = $derived.by(() => {
if (generateType !== 'code') return true;
const trimmed = generateEmail.trim();
if (isGuestSelected) {
// Mandatory for guest: non-empty & valid email format
return trimmed !== '' && trimmed.includes('@') && trimmed.includes('.');
}
// Optional for member: if non-empty, must be valid email format
if (trimmed === '') return true;
return trimmed.includes('@') && trimmed.includes('.');
});
const isGenerateValid = $derived(generateType === 'stock' ? true : isAmountValid && isEmailValid);
const isTopUpValid = $derived(topUpAmount && !topUpError);
const isTransferValid = $derived(
transferAmount && !transferAmountError && transferToCode && !transferCodeError
);
async function fetchGiftCards(page: number = 1, search: string = '') {
loading = true;
loadingSearch = true;
try {
const params = new SvelteURLSearchParams({
page: page.toString(),
per_page: '10'
});
if (search.trim()) params.append('q', search.trim());
const res = await apiFetch(`/api/admin/gift-cards?${params}`);
if (res.ok) {
const data = await res.json();
summary.total_unclaimed = data.total_unclaimed;
summary.total_user_balances = data.total_user_balances;
cards = data.gift_cards;
balances = data.user_balances;
totalBalanceRecords = data.ub_total ?? data.user_balances?.length ?? 0;
currentPage = data.page;
totalPages = data.totalPages;
} else {
toast.error('Failed to fetch gift cards');
}
} catch {
toast.error('Network error fetching gift cards');
} finally {
loading = false;
loadingSearch = false;
}
}
function searchCards() {
currentPage = 1;
fetchGiftCards(1, cardQuery);
}
async function fetchExpiredBalances() {
loadingExpired = true;
try {
const res = await apiFetch('/api/admin/gift-cards/expired-balances');
if (res.ok) {
const data = await res.json();
expiredBalances = data.expired_balances || [];
} else {
toast.error('Failed to fetch expired balances');
}
} catch {
toast.error('Network error fetching expired balances');
} finally {
loadingExpired = false;
}
}
async function claimExpiredBalance(balanceId: string) {
claimingId = balanceId;
try {
const res = await apiFetch('/api/admin/gift-cards/expired-balances/claim', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ balance_id: balanceId })
});
if (res.ok) {
toast.success('Balance claimed successfully');
await fetchExpiredBalances();
} else {
const err = await res.json();
toast.error(err.error || 'Failed to claim balance');
}
} catch {
toast.error('Network error claiming balance');
} finally {
claimingId = null;
}
}
function nextPage() {
if (currentPage < totalPages) {
fetchGiftCards(currentPage + 1, cardQuery);
}
}
function previousPage() {
if (currentPage > 1) {
fetchGiftCards(currentPage - 1, cardQuery);
}
}
async function fetchCurrentCustomerForGenerate() {
loadingCurrentCustomer = true;
currentCustomerInfo = null;
try {
const res = await apiFetch('/api/admin/today/current-next');
if (res.ok) {
const data = await res.json();
const appointment = data.current || data.next;
if (appointment?.user?.id) {
currentCustomerInfo = {
id: appointment.user.id,
name: appointment.user.full_name || appointment.user.name || 'Current Customer',
email: appointment.user.email,
phone: appointment.user.phone,
previousFirstName: appointment.user.previous_first_name,
previousLastName: appointment.user.previous_last_name
};
}
}
} catch {
// Ignore silently
} finally {
loadingCurrentCustomer = false;
}
}
function goToGeneratePayment() {
if (!isGenerateValid) return;
generateStep = 'payment';
}
async function searchGenerateCustomers() {
if (!generateUserQuery.trim()) return;
generateLoadingUsers = true;
try {
const res = await apiFetch(
`/api/admin/users?page=1&per_page=5&q=${encodeURIComponent(generateUserQuery)}`
);
if (res.ok) {
const data = await res.json();
const excludedRoles = ['admin', 'guest', 'affiliate'];
generateUsers = (data.users || []).filter(
(u: { account_role: string }) => !excludedRoles.includes(u.account_role)
);
}
} catch {
toast.error('Failed to search users');
} finally {
generateLoadingUsers = false;
}
}
async function generateInventoryCard() {
creating = true;
try {
const res = await apiFetch('/api/admin/gift-cards', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: 0,
is_inventory: true
})
});
if (res.ok) {
const data = await res.json();
toast.success(`Inventory card ${formatCardCode(data.id)} created`);
showGenerateModal = false;
resetGenerateModal();
await fetchGiftCards();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to create inventory card');
}
} catch {
toast.error('Network error');
} finally {
creating = false;
}
}
function goToTopUpPayment() {
if (!isTopUpValid) return;
if (topUpMode === 'giveaway') {
if (selectedCardId) {
handleEmbeddedGiveawayTopUp(selectedCardId);
}
} else {
topUpStep = 'payment';
}
}
async function transferCard() {
if (!isTransferValid || !selectedCardId) return;
transferring = true;
try {
const res = await apiFetch(`/api/admin/gift-cards/${selectedCardId}/transfer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
to_card_id: transferToCode,
amount: Number(transferAmount)
})
});
if (res.ok) {
toast.success('Balance transferred successfully');
showTransferModal = false;
transferAmount = '';
transferToCode = '';
await fetchGiftCards();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to transfer balance');
}
} catch {
toast.error('Network error transferring balance');
} finally {
transferring = false;
}
}
function resetGenerateModal() {
generateStep = 'type';
generateType = 'code';
generateCustomerTab = 'current';
currentCustomerInfo = null;
selectedCustomer = null;
isGuestSelected = false;
generateAmount = '';
generateEmail = '';
generateUserQuery = '';
generateUsers = [];
onlineSquareAction = null;
onlineSquareProcessing = false;
}
function resetTopUpModal() {
topUpStep = 'choice';
topUpMode = 'giveaway';
topUpAmount = '';
// Reset payment
cashAmount = '';
paymentError = '';
paymentResult = null;
cardMachineItemID = null;
idempotencyKey = '';
onlineSquareAction = null;
onlineSquareProcessing = false;
}
// =============== Embedded Payment Handlers ===============
function setModalStep(
actionType: 'create' | 'topup',
step: 'processing' | 'success' | 'error' | 'payment' | 'cash_entry'
) {
if (actionType === 'create') {
generateStep = step;
} else {
topUpStep = step;
}
}
async function handleEmbeddedCashPayment(actionType: 'create' | 'topup', gcId?: string) {
setModalStep(actionType, 'processing');
processingMessage = 'Processing cash payment...';
try {
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: actionType,
amount: amt,
payment_method: 'cash',
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 cash payment';
setModalStep(actionType, 'error');
}
}
async function handleEmbeddedCardMachinePayment(actionType: 'create' | 'topup', gcId?: string) {
setModalStep(actionType, 'processing');
processingMessage = 'Initiating card machine...';
try {
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: actionType,
amount: amt,
payment_method: 'card_machine',
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();
cardMachineItemID = data.item_id || null;
if (data.status === 'pending' && data.checkout_id) {
setModalStep(actionType, 'processing');
pollEmbeddedCheckout(data.checkout_id, amt, actionType);
} else {
paymentResult = { ...data };
setModalStep(actionType, 'success');
await fetchGiftCards();
}
} else {
paymentError = await res.text();
setModalStep(actionType, 'error');
}
} catch {
paymentError = 'Network error initiating card machine payment';
setModalStep(actionType, 'error');
}
}
async function pollEmbeddedCheckout(ckId: string, amt: number, actionType: 'create' | 'topup') {
const maxAttempts = 60;
let attempts = 0;
while (attempts < maxAttempts) {
if (!showGenerateModal && !showTopUpModal) {
return;
}
await new Promise((r) => setTimeout(r, 2000));
attempts++;
try {
const res = await apiFetch(`/api/admin/till/sale/checkout/${ckId}/status`);
if (res.ok) {
const data = await res.json();
if (data.status === 'COMPLETED') {
paymentResult = {
id: data.payment_id || ckId,
item_id: cardMachineItemID ?? undefined,
giftcard_code: data.giftcard_code || undefined
};
setModalStep(actionType, 'success');
await fetchGiftCards();
return;
}
}
} catch {
// Continue polling
}
}
paymentError = 'Card machine payment timed out. Please check Square dashboard.';
setModalStep(actionType, 'error');
}
async function handleEmbeddedOnlineSquarePayment(actionType: 'create' | 'topup', gcId?: string) {
if (!onlineSquareCardInput) return;
onlineSquareProcessing = true;
paymentError = '';
try {
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
let token: string;
let verificationToken: string | null;
try {
const contact = selectedCustomer
? {
givenName: selectedCustomer.name?.split(' ')[0],
email: selectedCustomer.email
}
: undefined;
const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
Math.round(amt * 100),
contact
);
token = tokenized.nonce;
verificationToken = tokenized.verificationToken;
} catch (err) {
paymentError = err instanceof Error ? err.message : 'Card entry failed';
setModalStep(actionType, 'error');
return;
}
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: actionType,
amount: amt,
payment_method: 'online_square',
card_token: token,
idempotency_key: getIdempotencyKey()
};
if (verificationToken) body.verification_token = verificationToken;
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...';
try {
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: 'topup',
amount: Number(topUpAmount),
payment_method: 'on_the_house',
gift_card_id: gcId,
idempotency_key: getIdempotencyKey()
};
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 };
topUpStep = 'success';
await fetchGiftCards();
} else {
paymentError = await res.text();
topUpStep = 'error';
}
} catch {
paymentError = 'Network error processing giveaway top-up';
topUpStep = 'error';
}
}
function handleCodeInput(e: Event) {
const target = e.target as HTMLInputElement;
let raw = target.value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
if (raw.length > 12) raw = raw.slice(0, 12);
let formatted = '';
if (raw.length > 0) formatted += raw.slice(0, 4);
if (raw.length > 4) formatted += '-' + raw.slice(4, 8);
if (raw.length > 8) formatted += '-' + raw.slice(8, 12);
transferToCode = formatted;
}
function formatCardCode(id: string): string {
if (id.length !== 12) return id;
return `${id.slice(0, 4)}-${id.slice(4, 8)}-${id.slice(8, 12)}`.toUpperCase();
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatDate(dateStr: string): string {
return new SvelteDate(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/London'
});
}
function getExpiryDate(lastUsedAt?: string): Date | null {
if (!lastUsedAt) return null;
const date = new SvelteDate(lastUsedAt);
date.setMonth(date.getMonth() + 24);
return date;
}
function isExpired(lastUsedAt?: string): boolean {
const expiry = getExpiryDate(lastUsedAt);
return expiry !== null && expiry < new SvelteDate();
}
// =============== Sorting ===============
type SortKey =
| 'code'
| 'added'
| 'remaining'
| 'created'
| 'status'
| 'name'
| 'email'
| 'balance'
| 'updated';
const cardSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'created', dir: 'desc' });
const balanceSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({
key: 'updated',
dir: 'desc'
});
function toggleCardSort(key: SortKey) {
if (cardSort.key === key) {
cardSort.dir = cardSort.dir === 'asc' ? 'desc' : 'asc';
} else {
cardSort.key = key;
cardSort.dir = 'asc';
}
}
function toggleBalanceSort(key: SortKey) {
if (balanceSort.key === key) {
balanceSort.dir = balanceSort.dir === 'asc' ? 'desc' : 'asc';
} else {
balanceSort.key = key;
balanceSort.dir = 'asc';
}
}
const sortedCards = $derived.by(() => {
let filtered = [...cards];
if (activeSection === 'cards') {
filtered = cards.filter((gc) => !isExpired(gc.last_used_at));
} else if (activeSection === 'expired_cards') {
filtered = cards.filter((gc) => isExpired(gc.last_used_at));
}
const sorted = [...filtered];
const { key, dir } = cardSort;
const mul = dir === 'asc' ? 1 : -1;
sorted.sort((a, b) => {
switch (key) {
case 'code':
return a.id.localeCompare(b.id) * mul;
case 'added':
return (a.total_funds_added - b.total_funds_added) * mul;
case 'remaining':
return (a.amount_remaining - b.amount_remaining) * mul;
case 'created':
return (
(new SvelteDate(a.created_at).getTime() - new SvelteDate(b.created_at).getTime()) * mul
);
case 'status': {
const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0;
const bVal = b.redeemed_by ? 2 : b.amount_remaining === 0 ? 1 : 0;
return (aVal - bVal) * mul;
}
default:
return 0;
}
});
return sorted;
});
const activeCardsCount = $derived(cards.filter((gc) => !isExpired(gc.last_used_at)).length);
const expiredCardsCount = $derived(cards.filter((gc) => isExpired(gc.last_used_at)).length);
const sortedBalances = $derived.by(() => {
const bals = [...balances];
const { key, dir } = balanceSort;
const mul = dir === 'asc' ? 1 : -1;
bals.sort((a, b) => {
switch (key) {
case 'name':
return a.name.localeCompare(b.name) * mul;
case 'email':
return a.email.localeCompare(b.email) * mul;
case 'balance':
return (a.balance - b.balance) * mul;
case 'updated':
return (
(new SvelteDate(a.updated_at).getTime() - new SvelteDate(b.updated_at).getTime()) * mul
);
default:
return 0;
}
});
return bals;
});
function sortArrow(key: SortKey, state: typeof cardSort): string {
if (state.key !== key) return '';
return state.dir === 'asc' ? ' \u25B2' : ' \u25BC';
}
$effect(() => {
if (authStore.currentToken) {
fetchGiftCards();
}
});
</script>
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<Card.Title>Gift Card Management</Card.Title>
<Card.Description>
Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred.
</Card.Description>
</div>
<Button onclick={() => (showGenerateModal = true)}>Generate Gift Card</Button>
</div>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
<div class="rounded-xl border bg-card p-4">
<div class="text-xs text-gray-400">Total Unclaimed</div>
<div class="mt-1 text-2xl font-bold text-card-foreground">
{loading ? '...' : formatCurrency(summary.total_unclaimed)}
</div>
</div>
<div class="rounded-xl border bg-card p-4">
<div class="text-xs text-gray-400">User Account Balances</div>
<div class="mt-1 text-2xl font-bold text-card-foreground">
{loading ? '...' : formatCurrency(summary.total_user_balances)}
</div>
</div>
<div class="rounded-xl border bg-card p-4 sm:col-span-2 md:col-span-1">
<div class="text-xs text-gray-400">Combined Liability</div>
<div class="mt-1 text-2xl font-bold text-card-foreground">
{loading ? '...' : formatCurrency(summary.total_unclaimed + summary.total_user_balances)}
</div>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-gray-200">
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSection === 'cards'
? 'border-primary font-semibold text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (activeSection = 'cards')}
>
Active Gift Cards ({activeCardsCount})
</button>
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSection ===
'expired_cards'
? 'border-primary font-semibold text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (activeSection = 'expired_cards')}
>
Expired Gift Cards ({expiredCardsCount})
</button>
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSection ===
'balances'
? 'border-primary font-semibold text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (activeSection = 'balances')}
>
Customer Account Balances ({totalBalanceRecords})
</button>
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSection ===
'expired'
? 'border-primary font-semibold text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => {
activeSection = 'expired';
if (expiredBalances.length === 0) fetchExpiredBalances();
}}
>
Expired Balances ({expiredBalances.length})
</button>
</div>
{#if activeSection === 'cards' || activeSection === 'expired_cards'}
<div class="mb-4 flex gap-2">
<Input
placeholder="Search by gift card code..."
bind:value={cardQuery}
onkeyup={(e) => {
if (e.key === 'Enter') searchCards();
}}
/>
<Button onclick={searchCards} disabled={loadingSearch}>
{loadingSearch ? 'Searching...' : 'Search'}
</Button>
</div>
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs tracking-wider text-gray-500 uppercase">
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('code')}
>
Card Code{sortArrow('code', cardSort)}
</th>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('added')}
>
Total Added{sortArrow('added', cardSort)}
</th>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('remaining')}
>
Remaining{sortArrow('remaining', cardSort)}
</th>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('created')}
>
Created On{sortArrow('created', cardSort)}
</th>
<th class="py-3 font-medium text-gray-500">Expiry</th>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('status')}
>
Status{sortArrow('status', cardSort)}
</th>
<th class="py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody class={sortedCards.length > 0 && (loading || loadingSearch) ? 'opacity-60' : ''}>
{#if sortedCards.length === 0 && !loading && !loadingSearch}
<tr>
<td colspan="8" class="py-8 text-center text-gray-500">
{activeSection === 'expired_cards'
? 'No expired gift cards found.'
: 'No gift cards generated yet. Click "Generate Gift Card" to create one.'}
</td>
</tr>
{:else if sortedCards.length > 0}
{#each sortedCards as gc (gc.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</td>
<td class="py-3 font-medium text-gray-600"
>{formatCurrency(gc.total_funds_added)}</td
>
<td class="py-3 font-semibold text-card-foreground"
>{formatCurrency(gc.amount_remaining)}</td
>
<td class="py-3 text-gray-600">{formatDate(gc.created_at)}</td>
<td class="py-3 text-gray-600">
{#if gc.last_used_at}
{formatDate(getExpiryDate(gc.last_used_at)!.toISOString())}
{:else}
<span class="text-gray-400 italic"></span>
{/if}
</td>
<td class="py-3">
{#if gc.redeemed_by}
<span
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700"
>
Claimed
</span>
{:else if gc.is_inventory}
<span
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700"
>
Inventory
</span>
{:else if isExpired(gc.last_used_at)}
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700"
>
Expired
</span>
{:else if gc.amount_remaining === 0}
<span
class="inline-flex items-center rounded-full border border-gray-200 bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"
>
Spent
</span>
{:else}
<span
class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600"
>
Active
</span>
{/if}
</td>
<td class="py-3 text-center">
<div class="flex items-center justify-center gap-2">
{#if !gc.redeemed_by}
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTopUpModal = true;
}}
>
Top Up
</Button>
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTransferModal = true;
}}
>
Transfer
</Button>
{:else}
<span class="text-xs text-gray-400 italic">No actions available</span>
{/if}
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Mobile view - Cards -->
<div class="grid gap-4 md:hidden">
{#if sortedCards.length === 0 && !loading && !loadingSearch}
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
{activeSection === 'expired_cards'
? 'No expired gift cards found.'
: 'No gift cards generated yet.'}
</div>
{:else if sortedCards.length > 0}
<div class={loading || loadingSearch ? 'opacity-60' : ''}>
{#each sortedCards as gc (gc.id)}
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</span>
{#if gc.redeemed_by}
<span
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700"
>
Claimed
</span>
{:else if gc.is_inventory}
<span
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700"
>
Inventory
</span>
{:else if isExpired(gc.last_used_at)}
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700"
>
Expired
</span>
{:else if gc.amount_remaining === 0}
<span
class="inline-flex items-center rounded-full border border-gray-200 bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
>
Spent
</span>
{:else}
<span
class="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700"
>
Active
</span>
{/if}
</div>
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
<div>
<span class="text-gray-400">Total Added:</span>
<span class="ml-1 font-semibold text-gray-700"
>{formatCurrency(gc.total_funds_added)}</span
>
</div>
<div>
<span class="text-gray-400">Remaining:</span>
<span class="ml-1 font-semibold text-gray-700"
>{formatCurrency(gc.amount_remaining)}</span
>
</div>
<div class="col-span-2">
<span class="text-gray-400">Created:</span>
<span class="ml-1 font-medium text-gray-700">{formatDate(gc.created_at)}</span>
</div>
{#if gc.last_used_at}
<div class="col-span-2">
<span class="text-gray-400">Expires:</span>
<span class="ml-1 font-medium text-gray-700"
>{formatDate(getExpiryDate(gc.last_used_at)!.toISOString())}</span
>
</div>
{/if}
</div>
<div class="flex justify-end gap-2 pt-1">
{#if !gc.redeemed_by}
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTopUpModal = true;
}}
class="flex-1"
>
Top Up
</Button>
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTransferModal = true;
}}
class="flex-1"
>
Transfer
</Button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if totalPages > 1}
<div class="mt-3 flex items-center justify-between border-t pt-3 text-sm">
<Button
variant="outline"
size="sm"
onclick={previousPage}
disabled={currentPage === 1 || loadingSearch}
>
Previous
</Button>
<span class="text-xs text-gray-600">Page {currentPage} of {totalPages}</span>
<Button
variant="outline"
size="sm"
onclick={nextPage}
disabled={currentPage === totalPages || loadingSearch}
>
Next
</Button>
</div>
{/if}
{:else if activeSection === 'balances'}
<!-- Desktop - Account Balances -->
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs tracking-wider text-gray-500 uppercase">
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleBalanceSort('name')}
>
Customer{sortArrow('name', balanceSort)}
</th>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleBalanceSort('email')}
>
Email{sortArrow('email', balanceSort)}
</th>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleBalanceSort('balance')}
>
Account Balance{sortArrow('balance', balanceSort)}
</th>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleBalanceSort('updated')}
>
Last Updated{sortArrow('updated', balanceSort)}
</th>
</tr>
</thead>
<tbody>
{#if loading}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-36" /></td>
<td class="py-3"><Skeleton class="h-4 w-44" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
</tr>
{:else if balances.length === 0}
<tr>
<td colspan="4" class="py-8 text-center text-gray-500">
No customers have redeemed gift cards yet.
</td>
</tr>
{:else}
{#each sortedBalances as ub (ub.user_id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium text-gray-900"
>{ub.name}<!-- TODO: add formerly name when previous name data is available --></td
>
<td class="py-3 text-gray-600">{ub.email}</td>
<td class="py-3 font-semibold text-primary">{formatCurrency(ub.balance)}</td>
<td class="py-3 text-gray-600">{formatDate(ub.updated_at)}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Mobile view - Balances -->
<div class="grid gap-4 md:hidden">
{#if loading}
{#each range(2) as i (i)}
<div class="space-y-3 rounded-lg border p-4">
<Skeleton class="h-4 w-36" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-24" />
</div>
{/each}
{:else if balances.length === 0}
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
No customers have redeemed gift cards yet.
</div>
{:else}
{#each sortedBalances as ub (ub.user_id)}
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900"
>{ub.name}<!-- TODO: add formerly name when previous name data is available --></span
>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
<div class="col-span-2">
<span class="text-gray-400">Email:</span>
<span class="ml-1 font-medium text-gray-700">{ub.email}</span>
</div>
<div>
<span class="text-gray-400">Balance:</span>
<span class="ml-1 font-bold text-primary">{formatCurrency(ub.balance)}</span>
</div>
<div>
<span class="text-gray-400">Updated:</span>
<span class="ml-1 font-medium text-gray-700">{formatDate(ub.updated_at)}</span>
</div>
</div>
</div>
{/each}
{/if}
</div>
{:else if activeSection === 'expired'}
<!-- Desktop view - Expired Balances -->
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs tracking-wider text-gray-500 uppercase">
<th class="py-3 font-medium">Account ID</th>
<th class="py-3 font-medium">Original Balance</th>
<th class="py-3 font-medium">Expired At</th>
<th class="py-3 font-medium">Status</th>
<th class="py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#if loadingExpired}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-24" /></td>
</tr>
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-24" /></td>
</tr>
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-24" /></td>
</tr>
{:else if expiredBalances.length === 0}
<tr>
<td colspan="5" class="py-8 text-center text-gray-500">
No expired balances found.
</td>
</tr>
{:else}
{#each expiredBalances as eb (eb.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-mono text-xs text-gray-600">{eb.account_id || 'N/A'}</td>
<td class="py-3 font-semibold text-primary"
>{formatCurrency(eb.original_balance)}</td
>
<td class="py-3 text-gray-600">{formatDate(eb.expired_at)}</td>
<td class="py-3">
{#if eb.claimed_at}
<span
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700"
>
Claimed
</span>
{:else}
<span
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700"
>
Unclaimed
</span>
{/if}
</td>
<td class="py-3 text-center">
{#if !eb.claimed_at}
<Button
size="sm"
onclick={() => claimExpiredBalance(eb.id)}
disabled={claimingId === eb.id}
>
{claimingId === eb.id ? 'Claiming...' : 'Mark Claimed'}
</Button>
{:else}
<span class="text-xs text-gray-500">
{formatDate(eb.claimed_at)}
</span>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Mobile view - Expired Balances -->
<div class="grid gap-4 md:hidden">
{#if loadingExpired}
{#each range(2) as i (i)}
<div class="space-y-3 rounded-lg border p-4">
<Skeleton class="h-4 w-36" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-24" />
</div>
{/each}
{:else if expiredBalances.length === 0}
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
No expired balances found.
</div>
{:else}
{#each expiredBalances as eb (eb.id)}
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-mono text-xs text-gray-600">{eb.account_id || 'N/A'}</span>
{#if eb.claimed_at}
<span
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700"
>
Claimed
</span>
{:else}
<span
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700"
>
Unclaimed
</span>
{/if}
</div>
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
<div>
<span class="text-gray-400">Balance:</span>
<span class="ml-1 font-bold text-primary"
>{formatCurrency(eb.original_balance)}</span
>
</div>
<div>
<span class="text-gray-400">Expired:</span>
<span class="ml-1 font-medium text-gray-700">{formatDate(eb.expired_at)}</span>
</div>
{#if eb.claimed_at}
<div class="col-span-2">
<span class="text-gray-400">Claimed:</span>
<span class="ml-1 font-medium text-gray-700">{formatDate(eb.claimed_at)}</span>
</div>
{/if}
</div>
{#if !eb.claimed_at}
<Button
size="sm"
class="w-full"
onclick={() => claimExpiredBalance(eb.id)}
disabled={claimingId === eb.id}
>
{claimingId === eb.id ? 'Claiming...' : 'Mark as Claimed'}
</Button>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Generate Gift Card Modal -->
<Modal.Root bind:open={showGenerateModal}>
<Modal.Content class="max-w-md">
<Modal.Header>
<Modal.Title>Generate Gift Card</Modal.Title>
<Modal.Description>
{#if generateStep === 'type'}
Select how the gift card will be issued and redeemed.
{:else if generateStep === 'customer'}
Select the customer for this gift card.
{:else if generateStep === 'amount_email'}
Enter the starting amount and delivery details.
{:else if generateStep === 'payment'}
Select the payment method.
{:else if generateStep === 'cash_entry'}
Enter cash amount received.
{/if}
</Modal.Description>
</Modal.Header>
{#if generateStep === 'type'}
<div class="space-y-3 py-4">
<p class="text-sm font-medium text-gray-700">How would you like to issue this gift card?</p>
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-fuchsia-50/50"
onclick={() => {
generateType = 'code';
selectedCustomer = null;
isGuestSelected = false;
generateStep = 'customer';
generateCustomerTab = 'current';
fetchCurrentCustomerForGenerate();
}}
>
<div class="font-semibold text-card-foreground">Generate Gift Code</div>
<div class="mt-1 text-xs text-muted-foreground">
Creates a 12-character code (not linked to an account at start). Treated as walk-in
guest payment-wise.
</div>
</button>
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-fuchsia-50/50"
onclick={() => {
generateType = 'account';
selectedCustomer = null;
isGuestSelected = false;
generateStep = 'customer';
generateCustomerTab = 'current';
fetchCurrentCustomerForGenerate();
}}
>
<div class="font-semibold text-card-foreground">Add to Customer Account</div>
<div class="mt-1 text-xs text-muted-foreground">
Issue directly to a registered customer account balance (members only).
</div>
</button>
<button
type="button"
class="w-full rounded-lg border border-amber-200 bg-amber-50/50 p-4 text-left transition-colors hover:bg-amber-50"
onclick={() => {
generateType = 'stock';
selectedCustomer = null;
isGuestSelected = false;
generateStep = 'amount_email';
}}
>
<div class="font-semibold text-amber-900">Blank Card for Stock</div>
<div class="mt-1 text-xs text-amber-700">
Create a blank card with £0 balance for shop stock — top up when sold.
</div>
</button>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (showGenerateModal = false)}>Cancel</Button>
</Modal.Footer>
{:else if generateStep === 'customer'}
<div class="space-y-4 py-4">
<div class="flex gap-6 border-b border-gray-200 text-sm">
<button
type="button"
class="pb-2 font-medium transition-colors {generateCustomerTab === 'current'
? 'border-b-2 border-primary font-semibold text-primary'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (generateCustomerTab = 'current')}
>
Current Customer
</button>
<button
type="button"
class="pb-2 font-medium transition-colors {generateCustomerTab === 'member'
? 'border-b-2 border-primary font-semibold text-primary'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (generateCustomerTab = 'member')}
>
Member Search
</button>
{#if generateType === 'code'}
<button
type="button"
class="pb-2 font-medium transition-colors {generateCustomerTab === 'guest'
? 'border-b-2 border-primary font-semibold text-primary'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (generateCustomerTab = 'guest')}
>
Guest / Non-Member
</button>
{/if}
</div>
{#if generateCustomerTab === 'current'}
<div class="space-y-3">
{#if loadingCurrentCustomer}
<div class="space-y-2 p-2">
<Skeleton class="h-12 w-full" />
</div>
{:else if currentCustomerInfo}
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between rounded-lg border p-4 text-left transition-colors {selectedCustomer?.id ===
currentCustomerInfo.id
? 'border-input bg-fuchsia-100 font-medium'
: 'border-gray-200 hover:bg-fuchsia-50/40'}"
onclick={() => {
if (currentCustomerInfo) {
selectedCustomer = {
id: currentCustomerInfo.id,
name: currentCustomerInfo.name,
email: currentCustomerInfo.email,
previousFirstName: currentCustomerInfo.previousFirstName,
previousLastName: currentCustomerInfo.previousLastName
};
isGuestSelected = false;
}
}}
>
<div>
<div class="text-sm font-semibold">
{formatUserName(
currentCustomerInfo.name,
currentCustomerInfo.previousFirstName,
currentCustomerInfo.previousLastName
)}
</div>
{#if currentCustomerInfo.email}
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
{/if}
</div>
{#if selectedCustomer?.id === currentCustomerInfo.id}
<svg class="h-5 w-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
{:else}
<div class="rounded-lg border border-dashed p-6 text-center text-sm text-gray-500">
No current or next appointment found today.
</div>
{/if}
</div>
{:else if generateCustomerTab === 'member'}
<div class="space-y-3">
<div class="flex gap-2">
<div class="relative flex-1">
<input
type="text"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Search by name, email or phone..."
bind:value={generateUserQuery}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchGenerateCustomers();
}
}}
/>
</div>
<Button onclick={searchGenerateCustomers} disabled={generateLoadingUsers}>
{generateLoadingUsers ? '...' : 'Search'}
</Button>
</div>
<div class="max-h-[220px] overflow-y-auto rounded-md border border-gray-200">
{#if generateUsers.length === 0 && !generateLoadingUsers}
<div class="flex items-center justify-center p-8 text-xs text-gray-500">
{generateUserQuery ? 'No members found.' : 'Search for a member above.'}
</div>
{:else if generateUsers.length > 0}
<ul class="divide-y divide-gray-200 {generateLoadingUsers ? 'opacity-60' : ''}">
{#each generateUsers.slice(0, 5) as user (user.id)}
<li>
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between px-4 py-3 text-left transition-colors {selectedCustomer?.id ===
user.id
? 'bg-fuchsia-100 font-medium'
: 'hover:bg-fuchsia-50/40'}"
onclick={() => {
selectedCustomer = {
id: user.id,
name: user.fullName,
email: user.email,
previousFirstName: user.previousFirstName,
previousLastName: user.previousLastName
};
isGuestSelected = false;
}}
>
<div>
<div class="text-sm font-semibold">
{formatUserName(
user.fullName,
user.previousFirstName,
user.previousLastName
)}
</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
{:else if user.email}
{user.email}
{:else}
No email info
{/if}
</div>
</div>
{#if selectedCustomer?.id === user.id}
<svg class="h-5 w-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else if generateCustomerTab === 'guest'}
<div class="space-y-3">
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between rounded-lg border p-4 text-left transition-colors {isGuestSelected
? 'border-input bg-fuchsia-100 font-medium'
: 'border-gray-200 hover:bg-fuchsia-50/40'}"
onclick={() => {
selectedCustomer = null;
isGuestSelected = true;
}}
>
<div>
<div class="text-sm font-semibold">Walk-in / Call-in Guest</div>
<div class="text-xs text-gray-500">
A temporary guest customer. A mandatory email address will be required.
</div>
</div>
{#if isGuestSelected}
<svg class="h-5 w-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</div>
{/if}
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (generateStep = 'type')}>Back</Button>
<Button
disabled={!selectedCustomer && !isGuestSelected}
onclick={() => (generateStep = 'amount_email')}
>
Next
</Button>
</Modal.Footer>
{:else if generateStep === 'amount_email'}
<div class="space-y-4 py-4">
{#if generateType === 'stock'}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="text-sm font-medium text-amber-900">£0 — Blank Card for Stock</p>
<p class="mt-1 text-xs text-amber-700">
Creates an empty gift card. No delivery details needed.
</p>
</div>
{:else}
<div class="rounded-lg border bg-gray-50 p-3 text-sm">
<span class="block text-xs font-semibold tracking-wider text-gray-400 uppercase"
>Customer</span
>
<p class="mt-0.5 font-medium text-gray-900">
{#if selectedCustomer}
{formatUserName(
selectedCustomer.name,
selectedCustomer.previousFirstName,
selectedCustomer.previousLastName
)} (Member)
{:else if isGuestSelected}
Walk-in Guest
{/if}
</p>
</div>
<div class="space-y-2">
<label for="generate-amount" class="text-sm font-medium">Starting Amount (£)</label>
<Input
id="generate-amount"
type="text"
inputmode="decimal"
placeholder="e.g. 50.00"
bind:value={generateAmount}
/>
{#if generateError}
<span class="text-xs font-medium text-red-500">{generateError}</span>
{/if}
</div>
{#if generateType === 'code'}
<div class="space-y-2">
<label for="generate-email" class="text-sm font-medium">
{#if isGuestSelected}
Email Address (Required)*
{:else}
Recipient Email Address (Optional)
{/if}
</label>
<EmailInput
id="generate-email"
bind:value={generateEmail}
placeholder="e.g. customer@example.com"
/>
{#if selectedCustomer?.email && !generateEmail.trim()}
<p class="text-xs text-gray-500 italic">Defaults to: {selectedCustomer.email}</p>
{/if}
</div>
{/if}
{/if}
</div>
<Modal.Footer>
<Button
variant="ghost"
onclick={() => {
if (generateType === 'stock') {
generateStep = 'type';
} else {
generateStep = 'customer';
}
}}>Back</Button
>
{#if generateType === 'stock'}
<Button onclick={generateInventoryCard} disabled={creating}>
{creating ? 'Creating...' : 'Create Inventory Card'}
</Button>
{:else}
<Button onclick={goToGeneratePayment} disabled={!isGenerateValid}>
Continue to Payment
</Button>
{/if}
</Modal.Footer>
{:else if generateStep === 'payment'}
<div class="space-y-4 py-4">
<div class="flex justify-between rounded-lg border bg-gray-50 p-4 text-sm">
<span class="text-base font-semibold text-gray-700">Total Amount</span>
<span class="text-xl font-bold text-gray-900"
>{formatCurrency(Number(generateAmount))}</span
>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => handleEmbeddedCardMachinePayment('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="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Card Machine
</button>
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => (generateStep = 'cash_entry')}
>
<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"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
Cash
</button>
{#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>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (generateStep = 'amount_email')}>Back</Button>
</Modal.Footer>
{:else if generateStep === 'cash_entry'}
<div class="space-y-4 py-4">
<div class="flex justify-between rounded-lg border bg-gray-50 p-4 text-sm">
<span class="text-base font-semibold text-gray-700">Total Amount</span>
<span class="text-xl font-bold text-gray-900"
>{formatCurrency(Number(generateAmount))}</span
>
</div>
<div class="space-y-2">
<label for="generate-cash-tendered" class="text-sm font-medium">Cash Received (£)</label>
<Input
id="generate-cash-tendered"
type="text"
inputmode="numeric"
placeholder="e.g. 50.00"
value={cashAmount}
oninput={(e) => (cashAmount = e.currentTarget.value)}
/>
</div>
{#if Number(cashAmount) > Number(generateAmount)}
<div class="rounded-md bg-green-50 p-3 text-xs text-green-800">
Change due: <span class="font-bold"
>{formatCurrency(Number(cashAmount) - Number(generateAmount))}</span
>
</div>
{/if}
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (generateStep = 'payment')}>Back</Button>
<Button
disabled={isNaN(Number(cashAmount)) || Number(cashAmount) < Number(generateAmount)}
onclick={() => handleEmbeddedCashPayment('create')}
>
Confirm Cash
</Button>
</Modal.Footer>
{:else if generateStep === 'processing'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-sm text-gray-600">{processingMessage}</p>
</div>
{:else if generateStep === 'success'}
<div class="flex flex-col items-center justify-center space-y-4 py-8 text-center">
<div
class="flex h-16 w-16 items-center justify-center rounded-full border border-green-200 bg-green-50"
>
<svg
class="h-8 w-8 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-gray-900">Payment Successful</h3>
{#if generateType === 'code'}
<p class="mt-1 text-xs text-gray-500">A copy of the code has been emailed.</p>
<div
class="mt-4 rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4 font-mono text-xl font-bold tracking-widest text-fuchsia-900 shadow-sm"
>
{formatCardCode(paymentResult?.giftcard_code || paymentResult?.item_id || '')}
</div>
<p class="mt-2 text-xs text-gray-400">
Write down or print this code for the customer.
</p>
{:else if generateType === 'account'}
<p class="mt-2 text-sm text-gray-600">
✓ Gift card added to {selectedCustomer?.name || 'account'}
</p>
<p class="mt-1 text-xs text-gray-400">Balance is available immediately.</p>
{/if}
</div>
<Button
onclick={() => {
showGenerateModal = false;
resetGenerateModal();
}}
class="mt-4 w-full">Done</Button
>
</div>
{:else if generateStep === 'error'}
<div class="space-y-4 py-4">
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<p class="font-semibold">Payment Failed</p>
<p class="mt-1 text-xs">{paymentError || 'An error occurred during transaction.'}</p>
</div>
<div class="flex gap-2">
<Button variant="ghost" onclick={() => (generateStep = 'payment')} class="flex-1"
>Try Again</Button
>
<Button
variant="outline"
onclick={() => {
showGenerateModal = false;
resetGenerateModal();
}}
class="flex-1">Close</Button
>
</div>
</div>
{/if}
</Modal.Content>
</Modal.Root>
<!-- Top Up Modal -->
<Modal.Root bind:open={showTopUpModal}>
<Modal.Content class="max-w-md">
<Modal.Header>
<Modal.Title>Top Up Gift Card</Modal.Title>
<Modal.Description>
{#if topUpStep === 'choice'}
Select how you would like to add funds to this card.
{:else if topUpStep === 'amount'}
Enter the amount to add to this card.
{:else if topUpStep === 'payment'}
Select the payment method.
{:else if topUpStep === 'cash_entry'}
Enter cash amount received.
{/if}
</Modal.Description>
</Modal.Header>
{#if topUpStep === 'choice'}
<div class="space-y-3 py-4">
<p class="text-sm text-gray-600">How would you like to add funds?</p>
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-fuchsia-50/50"
onclick={() => {
topUpMode = 'giveaway';
topUpStep = 'amount';
}}
>
<div class="font-semibold text-card-foreground">Giveaway (On the House)</div>
<div class="mt-1 text-sm text-muted-foreground">Free top-up, no payment needed</div>
</button>
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-fuchsia-50/50"
onclick={() => {
topUpMode = 'purchase';
topUpStep = 'amount';
}}
>
<div class="font-semibold text-card-foreground">Customer Purchase</div>
<div class="mt-1 text-sm text-muted-foreground">
Collect payment via cash or card machine
</div>
</button>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (showTopUpModal = false)}>Cancel</Button>
</Modal.Footer>
{:else if topUpStep === 'amount'}
<div class="space-y-4 py-4">
<div class="space-y-2">
<label for="topup-amount" class="text-sm font-medium">Amount to Add (£)</label>
<Input
id="topup-amount"
type="text"
inputmode="decimal"
placeholder="e.g. 20.00"
bind:value={topUpAmount}
/>
{#if topUpError}
<span class="text-xs font-medium text-red-500">{topUpError}</span>
{/if}
</div>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (topUpStep = 'choice')}>Back</Button>
<Button onclick={goToTopUpPayment} disabled={!isTopUpValid}>
{#if topUpMode === 'giveaway'}
Add Funds
{:else}
Continue to Payment
{/if}
</Button>
</Modal.Footer>
{:else if topUpStep === 'payment'}
<div class="space-y-4 py-4">
<div class="flex justify-between rounded-lg border bg-gray-50 p-4 text-sm">
<span class="text-base font-semibold text-gray-700">Total Top Up</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(Number(topUpAmount))}</span>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => handleEmbeddedCardMachinePayment('topup', selectedCardId ?? undefined)}
>
<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="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Card Machine
</button>
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => (topUpStep = 'cash_entry')}
>
<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"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
Cash
</button>
{#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>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (topUpStep = 'amount')}>Back</Button>
</Modal.Footer>
{:else if topUpStep === 'cash_entry'}
<div class="space-y-4 py-4">
<div class="flex justify-between rounded-lg border bg-gray-50 p-4 text-sm">
<span class="text-base font-semibold text-gray-700">Total Top Up</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(Number(topUpAmount))}</span>
</div>
<div class="space-y-2">
<label for="topup-cash-tendered" class="text-sm font-medium">Cash Received (£)</label>
<Input
id="topup-cash-tendered"
type="text"
inputmode="numeric"
placeholder="e.g. 50.00"
value={cashAmount}
oninput={(e) => (cashAmount = e.currentTarget.value)}
/>
</div>
{#if Number(cashAmount) > Number(topUpAmount)}
<div class="rounded-md bg-green-50 p-3 text-xs text-green-800">
Change due: <span class="font-bold"
>{formatCurrency(Number(cashAmount) - Number(topUpAmount))}</span
>
</div>
{/if}
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (topUpStep = 'payment')}>Back</Button>
<Button
disabled={isNaN(Number(cashAmount)) || Number(cashAmount) < Number(topUpAmount)}
onclick={() => handleEmbeddedCashPayment('topup', selectedCardId ?? undefined)}
>
Confirm Cash
</Button>
</Modal.Footer>
{:else if topUpStep === 'processing'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-sm text-gray-600">{processingMessage}</p>
</div>
{:else if topUpStep === 'success'}
<div class="flex flex-col items-center justify-center space-y-4 py-8 text-center">
<div
class="flex h-16 w-16 items-center justify-center rounded-full border border-green-200 bg-green-50"
>
<svg
class="h-8 w-8 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-gray-900">Top Up Successful</h3>
<p class="mt-2 text-sm text-gray-600">✓ Gift card topped up successfully!</p>
<p class="mt-1 text-xs text-gray-400">New balance is available immediately.</p>
</div>
<Button
onclick={() => {
showTopUpModal = false;
resetTopUpModal();
}}
class="mt-4 w-full">Done</Button
>
</div>
{:else if topUpStep === 'error'}
<div class="space-y-4 py-4">
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<p class="font-semibold">Top Up Failed</p>
<p class="mt-1 text-xs">{paymentError || 'An error occurred during transaction.'}</p>
</div>
<div class="flex gap-2">
<Button variant="ghost" onclick={() => (topUpStep = 'payment')} class="flex-1"
>Try Again</Button
>
<Button
variant="outline"
onclick={() => {
showTopUpModal = false;
resetTopUpModal();
}}
class="flex-1">Close</Button
>
</div>
</div>
{/if}
</Modal.Content>
</Modal.Root>
<!-- Transfer Modal -->
<Modal.Root bind:open={showTransferModal}>
<Modal.Content class="max-w-md">
<Modal.Header>
<Modal.Title>Transfer Balance</Modal.Title>
<Modal.Description
>Transfer funds from {formatCardCode(selectedCardId ?? '')} directly to another card.</Modal.Description
>
</Modal.Header>
<div class="space-y-4 py-4">
<div class="space-y-2">
<label for="transfer-code" class="text-sm font-medium">Destination Card Code</label>
<Input
id="transfer-code"
type="text"
placeholder="xxxx-xxxx-xxxx"
maxlength={14}
value={transferToCode}
oninput={handleCodeInput}
/>
{#if transferCodeError}
<span class="text-xs font-medium text-red-500">{transferCodeError}</span>
{/if}
</div>
<div class="space-y-2">
<label for="transfer-amount" class="text-sm font-medium">Amount to Transfer (£)</label>
<Input
id="transfer-amount"
type="text"
inputmode="decimal"
placeholder="e.g. 10.00"
bind:value={transferAmount}
/>
{#if transferAmountError}
<span class="text-xs font-medium text-red-500">{transferAmountError}</span>
{/if}
</div>
</div>
<Modal.Footer>
<Button variant="outline" onclick={() => (showTransferModal = false)}>Cancel</Button>
<Button onclick={transferCard} disabled={transferring || !isTransferValid}>
{transferring ? 'Transferring...' : 'Transfer Balance'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>