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.
2348 lines
74 KiB
Svelte
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>
|