Add giftcard management
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import TillPaymentModal from '$lib/components/payments/TillPaymentModal.svelte';
|
||||
|
||||
interface GiftCard {
|
||||
id: string;
|
||||
@@ -17,18 +18,30 @@
|
||||
redeemed_by?: string;
|
||||
}
|
||||
|
||||
interface UserBalance {
|
||||
user_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
balance: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface GiftCardSummary {
|
||||
total_unclaimed: number;
|
||||
total_user_balances: number;
|
||||
gift_cards: GiftCard[];
|
||||
user_balances: UserBalance[];
|
||||
}
|
||||
|
||||
let summary = $state<GiftCardSummary>({
|
||||
total_unclaimed: 0,
|
||||
total_user_balances: 0,
|
||||
gift_cards: []
|
||||
gift_cards: [],
|
||||
user_balances: []
|
||||
});
|
||||
|
||||
let activeSection = $state<'cards' | 'balances'>('cards');
|
||||
|
||||
let loading = $state(true);
|
||||
let showGenerateModal = $state(false);
|
||||
let showTopUpModal = $state(false);
|
||||
@@ -72,6 +85,18 @@
|
||||
let isTopUpValid = $derived(topUpAmount && !topUpError);
|
||||
let isTransferValid = $derived(transferAmount && !transferAmountError && transferToCode && !transferCodeError);
|
||||
|
||||
// Choice flow state
|
||||
let generateStep = $state<'choice' | 'amount'>('choice');
|
||||
let generateMode = $state<'giveaway' | 'purchase'>('giveaway');
|
||||
let topUpStep = $state<'choice' | 'amount'>('choice');
|
||||
let topUpMode = $state<'giveaway' | 'purchase'>('giveaway');
|
||||
|
||||
// TillPayment state
|
||||
let showTillPayment = $state(false);
|
||||
let tillAmount = $state(0);
|
||||
let tillAction = $state<'create' | 'topup'>('create');
|
||||
let tillGiftCardId = $state<string | undefined>(undefined);
|
||||
|
||||
async function fetchGiftCards() {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -92,6 +117,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function goToGeneratePayment() {
|
||||
if (!isGenerateValid) return;
|
||||
tillAmount = Number(generateAmount);
|
||||
tillAction = 'create';
|
||||
tillGiftCardId = undefined;
|
||||
showTillPayment = true;
|
||||
}
|
||||
|
||||
async function generateGiftCard() {
|
||||
if (!isGenerateValid) return;
|
||||
creating = true;
|
||||
@@ -108,7 +141,7 @@
|
||||
const data = await res.json();
|
||||
toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`);
|
||||
showGenerateModal = false;
|
||||
generateAmount = '';
|
||||
resetGenerateModal();
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
@@ -121,6 +154,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function goToTopUpPayment() {
|
||||
if (!isTopUpValid) return;
|
||||
tillAmount = Number(topUpAmount);
|
||||
tillAction = 'topup';
|
||||
tillGiftCardId = selectedCardId ?? undefined;
|
||||
showTillPayment = true;
|
||||
}
|
||||
|
||||
async function topUpCard() {
|
||||
if (!isTopUpValid || !selectedCardId) return;
|
||||
toppingUp = true;
|
||||
@@ -136,7 +177,7 @@
|
||||
if (res.ok) {
|
||||
toast.success('Gift card topped up successfully');
|
||||
showTopUpModal = false;
|
||||
topUpAmount = '';
|
||||
resetTopUpModal();
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
@@ -181,6 +222,77 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetGenerateModal() {
|
||||
generateStep = 'choice';
|
||||
generateMode = 'giveaway';
|
||||
generateAmount = '';
|
||||
}
|
||||
|
||||
function resetTopUpModal() {
|
||||
topUpStep = 'choice';
|
||||
topUpMode = 'giveaway';
|
||||
topUpAmount = '';
|
||||
}
|
||||
|
||||
async function handleTillPaymentComplete(result: { id: string; total_amount: number; payment_method: string; status: string }) {
|
||||
if (tillAction === 'create') {
|
||||
if (!isGenerateValid) return;
|
||||
creating = true;
|
||||
try {
|
||||
const res = await fetch('/api/admin/gift-cards', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({ amount: Number(generateAmount) })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`);
|
||||
showTillPayment = false;
|
||||
showGenerateModal = false;
|
||||
resetGenerateModal();
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(errText || 'Failed to generate gift card');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Network error generating gift card');
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
} else {
|
||||
if (!isTopUpValid || !selectedCardId) return;
|
||||
toppingUp = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/gift-cards/${selectedCardId}/topup`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({ amount: Number(topUpAmount) })
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Gift card topped up successfully');
|
||||
showTillPayment = false;
|
||||
showTopUpModal = false;
|
||||
resetTopUpModal();
|
||||
await fetchGiftCards();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(errText || 'Failed to top up gift card');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Network error topping up gift card');
|
||||
} finally {
|
||||
toppingUp = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCodeInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let raw = target.value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
@@ -209,6 +321,72 @@
|
||||
});
|
||||
}
|
||||
|
||||
// =============== Sorting ===============
|
||||
type SortKey = 'code' | 'added' | 'remaining' | 'created' | 'status' | 'name' | 'email' | 'balance' | 'updated';
|
||||
|
||||
let cardSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'created', dir: 'desc' });
|
||||
let 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';
|
||||
}
|
||||
}
|
||||
|
||||
let sortedCards = $derived.by(() => {
|
||||
const cards = [...summary.gift_cards];
|
||||
const { key, dir } = cardSort;
|
||||
const mul = dir === 'asc' ? 1 : -1;
|
||||
cards.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 Date(a.created_at).getTime() - new Date(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 cards;
|
||||
});
|
||||
|
||||
let sortedBalances = $derived.by(() => {
|
||||
const bals = [...summary.user_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 Date(a.updated_at).getTime() - new Date(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();
|
||||
@@ -225,18 +403,7 @@
|
||||
Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button onclick={() => showGenerateModal = true} class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
<Button onclick={() => showGenerateModal = true}>
|
||||
Generate Gift Card
|
||||
</Button>
|
||||
</div>
|
||||
@@ -244,194 +411,312 @@
|
||||
|
||||
<Card.Content class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-fuchsia-100 bg-fuchsia-50 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-fuchsia-600">Total Unclaimed</div>
|
||||
<div class="mt-1 text-2xl font-bold text-fuchsia-950">
|
||||
<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-lg border border-pink-100 bg-pink-50 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-pink-600">User Account Balances</div>
|
||||
<div class="mt-1 text-2xl font-bold text-pink-950">
|
||||
<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-lg border border-purple-100 bg-purple-50 p-4 sm:col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-purple-600">Combined Liability</div>
|
||||
<div class="mt-1 text-2xl font-bold text-purple-950">
|
||||
<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>
|
||||
|
||||
<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 text-gray-500 uppercase tracking-wider">
|
||||
<th class="py-3 font-medium">Card Code</th>
|
||||
<th class="py-3 font-medium text-right">Total Added</th>
|
||||
<th class="py-3 font-medium text-right">Remaining</th>
|
||||
<th class="py-3 font-medium">Created On</th>
|
||||
<th class="py-3 font-medium">Status</th>
|
||||
<th class="py-3 text-center font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if loading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<tr class="border-b">
|
||||
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
|
||||
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td>
|
||||
<td class="py-3 text-right"><Skeleton class="ml-auto 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>
|
||||
{/each}
|
||||
{:else if summary.gift_cards.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 text-center text-gray-500">
|
||||
No gift cards generated yet. Click "Generate Gift Card" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each summary.gift_cards 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 text-right font-medium text-gray-600">{formatCurrency(gc.total_funds_added)}</td>
|
||||
<td class="py-3 text-right font-semibold {gc.amount_remaining > 0 ? 'text-fuchsia-700' : 'text-gray-400'}">
|
||||
{formatCurrency(gc.amount_remaining)}
|
||||
</td>
|
||||
<td class="py-3 text-gray-600">{formatDate(gc.created_at)}</td>
|
||||
<td class="py-3">
|
||||
{#if gc.redeemed_by}
|
||||
<span class="inline-flex items-center rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
|
||||
Claimed
|
||||
</span>
|
||||
{:else if gc.amount_remaining === 0}
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
|
||||
Spent
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-fuchsia-50 px-2.5 py-0.5 text-xs font-medium text-fuchsia-700 border border-fuchsia-200">
|
||||
Active
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
{#if !gc.redeemed_by && gc.amount_remaining > 0}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
selectedCardId = gc.id;
|
||||
showTopUpModal = true;
|
||||
}}
|
||||
class="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
|
||||
>
|
||||
Top Up
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
selectedCardId = gc.id;
|
||||
showTransferModal = true;
|
||||
}}
|
||||
class="border-pink-200 text-pink-700 hover:bg-pink-50"
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400 italic">No actions available</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors border-b-2 {activeSection === 'cards'
|
||||
? 'border-primary text-primary font-semibold'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => activeSection = 'cards'}
|
||||
>
|
||||
Physical Gift Cards ({summary.gift_cards.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors border-b-2 {activeSection === 'balances'
|
||||
? 'border-primary text-primary font-semibold'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => activeSection = 'balances'}
|
||||
>
|
||||
Customer Account Balances ({summary.user_balances.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile view -->
|
||||
<div class="grid gap-4 md:hidden">
|
||||
{#if loading}
|
||||
{#each Array(2) as _, i (i)}
|
||||
<div class="rounded-lg border p-4 space-y-3">
|
||||
<Skeleton class="h-4 w-32" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-24" />
|
||||
{#if activeSection === 'cards'}
|
||||
<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 text-gray-500 uppercase tracking-wider">
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('code')}>
|
||||
Card Code{sortArrow('code', cardSort)}
|
||||
</th>
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('added')}>
|
||||
Total Added{sortArrow('added', cardSort)}
|
||||
</th>
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('remaining')}>
|
||||
Remaining{sortArrow('remaining', cardSort)}
|
||||
</th>
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('created')}>
|
||||
Created On{sortArrow('created', cardSort)}
|
||||
</th>
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('status')}>
|
||||
Status{sortArrow('status', cardSort)}
|
||||
</th>
|
||||
<th class="py-3 font-medium text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if loading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<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-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>
|
||||
{/each}
|
||||
{:else if summary.gift_cards.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 text-center text-gray-500">
|
||||
No gift cards generated yet. Click "Generate Gift Card" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#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">
|
||||
{#if gc.redeemed_by}
|
||||
<span class="inline-flex items-center rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
|
||||
Claimed
|
||||
</span>
|
||||
{:else if gc.amount_remaining === 0}
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
|
||||
Spent
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
|
||||
Active
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
{#if !gc.redeemed_by && gc.amount_remaining > 0}
|
||||
<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 loading}
|
||||
{#each Array(2) as _, i (i)}
|
||||
<div class="rounded-lg border p-4 space-y-3">
|
||||
<Skeleton class="h-4 w-32" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-24" />
|
||||
</div>
|
||||
{/each}
|
||||
{:else if summary.gift_cards.length === 0}
|
||||
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
|
||||
No gift cards generated yet.
|
||||
</div>
|
||||
{/each}
|
||||
{:else if summary.gift_cards.length === 0}
|
||||
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
|
||||
No gift cards generated yet.
|
||||
</div>
|
||||
{:else}
|
||||
{#each summary.gift_cards as gc (gc.id)}
|
||||
<div class="rounded-lg border p-4 space-y-3 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 bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 border border-green-200">
|
||||
Claimed
|
||||
</span>
|
||||
{:else if gc.amount_remaining === 0}
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
|
||||
Spent
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-700 border border-fuchsia-200">
|
||||
Active
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
|
||||
<div>
|
||||
<span class="text-gray-400">Total Added:</span>
|
||||
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.total_funds_added)}</span>
|
||||
{:else}
|
||||
{#each sortedCards as gc (gc.id)}
|
||||
<div class="rounded-lg border p-4 space-y-3 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 bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 border border-green-200">
|
||||
Claimed
|
||||
</span>
|
||||
{:else if gc.amount_remaining === 0}
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
|
||||
Spent
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700 border border-blue-200">
|
||||
Active
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Remaining:</span>
|
||||
<span class="font-bold text-fuchsia-800 ml-1">{formatCurrency(gc.amount_remaining)}</span>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
|
||||
<div>
|
||||
<span class="text-gray-400">Total Added:</span>
|
||||
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.total_funds_added)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Remaining:</span>
|
||||
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.amount_remaining)}</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-gray-400">Created:</span>
|
||||
<span class="font-medium text-gray-700 ml-1">{formatDate(gc.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-gray-400">Created:</span>
|
||||
<span class="font-medium text-gray-700 ml-1">{formatDate(gc.created_at)}</span>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
{#if !gc.redeemed_by && gc.amount_remaining > 0}
|
||||
<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>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
{#if !gc.redeemed_by && gc.amount_remaining > 0}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
selectedCardId = gc.id;
|
||||
showTopUpModal = true;
|
||||
}}
|
||||
class="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50 flex-1"
|
||||
>
|
||||
Top Up
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
selectedCardId = gc.id;
|
||||
showTransferModal = true;
|
||||
}}
|
||||
class="border-pink-200 text-pink-700 hover:bg-pink-50 flex-1"
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{: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 text-gray-500 uppercase tracking-wider">
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('name')}>
|
||||
Customer{sortArrow('name', balanceSort)}
|
||||
</th>
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('email')}>
|
||||
Email{sortArrow('email', balanceSort)}
|
||||
</th>
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('balance')}>
|
||||
Account Balance{sortArrow('balance', balanceSort)}
|
||||
</th>
|
||||
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('updated')}>
|
||||
Last Updated{sortArrow('updated', balanceSort)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if loading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<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>
|
||||
{/each}
|
||||
{:else if summary.user_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}</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 Array(2) as _, i (i)}
|
||||
<div class="rounded-lg border p-4 space-y-3">
|
||||
<Skeleton class="h-4 w-36" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-24" />
|
||||
</div>
|
||||
{/each}
|
||||
{:else if summary.user_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>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#each sortedBalances as ub (ub.user_id)}
|
||||
<div class="rounded-lg border p-4 space-y-3 hover:bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900">{ub.name}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
|
||||
<div class="col-span-2">
|
||||
<span class="text-gray-400">Email:</span>
|
||||
<span class="font-medium text-gray-700 ml-1">{ub.email}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Balance:</span>
|
||||
<span class="font-bold text-primary ml-1">{formatCurrency(ub.balance)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Updated:</span>
|
||||
<span class="font-medium text-gray-700 ml-1">{formatDate(ub.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -443,32 +728,65 @@
|
||||
<Modal.Description>Generate a new gift card code with a starting balance.</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<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 text-red-500 font-medium">{generateError}</span>
|
||||
{/if}
|
||||
{#if generateStep === 'choice'}
|
||||
<div class="space-y-3 py-4">
|
||||
<p class="text-sm text-gray-600">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-gray-50"
|
||||
onclick={() => { generateMode = 'giveaway'; generateStep = 'amount'; }}
|
||||
>
|
||||
<div class="font-semibold text-card-foreground">Giveaway (On the House)</div>
|
||||
<div class="mt-1 text-sm text-muted-foreground">Free promotional card, 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-gray-50"
|
||||
onclick={() => { generateMode = 'purchase'; generateStep = '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={() => showGenerateModal = false}>Cancel</Button>
|
||||
</Modal.Footer>
|
||||
{:else}
|
||||
<div class="space-y-4 py-4">
|
||||
<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 text-red-500 font-medium">{generateError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => showGenerateModal = false}>Cancel</Button>
|
||||
<Button
|
||||
onclick={generateGiftCard}
|
||||
disabled={creating || !isGenerateValid}
|
||||
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
|
||||
>
|
||||
{creating ? 'Generating...' : 'Generate Card'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
<Modal.Footer>
|
||||
<Button variant="ghost" onclick={() => generateStep = 'choice'}>Back</Button>
|
||||
{#if generateMode === 'giveaway'}
|
||||
<Button
|
||||
onclick={generateGiftCard}
|
||||
disabled={creating || !isGenerateValid}
|
||||
>
|
||||
{creating ? 'Generating...' : 'Generate Card'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
onclick={goToGeneratePayment}
|
||||
disabled={!isGenerateValid}
|
||||
>
|
||||
Continue to Payment
|
||||
</Button>
|
||||
{/if}
|
||||
</Modal.Footer>
|
||||
{/if}
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
@@ -480,32 +798,65 @@
|
||||
<Modal.Description>Add additional funds to active, unclaimed gift card {formatCardCode(selectedCardId ?? '')}.</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<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 text-red-500 font-medium">{topUpError}</span>
|
||||
{/if}
|
||||
{#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-gray-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-gray-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}
|
||||
<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 text-red-500 font-medium">{topUpError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => showTopUpModal = false}>Cancel</Button>
|
||||
<Button
|
||||
onclick={topUpCard}
|
||||
disabled={toppingUp || !isTopUpValid}
|
||||
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
|
||||
>
|
||||
{toppingUp ? 'Topping Up...' : 'Add Funds'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
<Modal.Footer>
|
||||
<Button variant="ghost" onclick={() => topUpStep = 'choice'}>Back</Button>
|
||||
{#if topUpMode === 'giveaway'}
|
||||
<Button
|
||||
onclick={topUpCard}
|
||||
disabled={toppingUp || !isTopUpValid}
|
||||
>
|
||||
{toppingUp ? 'Topping Up...' : 'Add Funds'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
onclick={goToTopUpPayment}
|
||||
disabled={!isTopUpValid}
|
||||
>
|
||||
Continue to Payment
|
||||
</Button>
|
||||
{/if}
|
||||
</Modal.Footer>
|
||||
{/if}
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
@@ -552,10 +903,20 @@
|
||||
<Button
|
||||
onclick={transferCard}
|
||||
disabled={transferring || !isTransferValid}
|
||||
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
|
||||
>
|
||||
{transferring ? 'Transferring...' : 'Transfer Balance'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</Modal.Root>
|
||||
|
||||
{#if showTillPayment}
|
||||
<TillPaymentModal
|
||||
amount={tillAmount}
|
||||
itemType="gift_card"
|
||||
action={tillAction}
|
||||
giftCardId={tillGiftCardId}
|
||||
onClose={() => { showTillPayment = false; }}
|
||||
onComplete={handleTillPaymentComplete}
|
||||
/>
|
||||
{/if}
|
||||
@@ -24,6 +24,8 @@
|
||||
| 'cash-confirming'
|
||||
| 'gift-entering'
|
||||
| 'gift-confirming'
|
||||
| 'saved-card-selecting'
|
||||
| 'saved-card-processing'
|
||||
| 'success'
|
||||
| 'error';
|
||||
|
||||
@@ -35,7 +37,7 @@
|
||||
amount: number;
|
||||
};
|
||||
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | null;
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | 'savedcard' | null;
|
||||
|
||||
let status = $state<PaymentStatus>('idle');
|
||||
let selectedMethod = $state<PaymentMethod>(null);
|
||||
@@ -46,6 +48,8 @@
|
||||
let customerBalance = $state(0);
|
||||
let loadingCustomerBalance = $state(false);
|
||||
let giftCardPaymentAmount = $state('');
|
||||
let savedCardList = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
|
||||
let loadingSavedCardList = $state(false);
|
||||
|
||||
async function fetchCustomerGiftCardBalance() {
|
||||
if (!booking.user_id) return;
|
||||
@@ -69,6 +73,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSavedCardList() {
|
||||
if (!booking.user_id) return;
|
||||
loadingSavedCardList = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
savedCardList = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loadingSavedCardList = false;
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceOverride = {
|
||||
price: string;
|
||||
originalPrice: number;
|
||||
@@ -78,6 +99,7 @@
|
||||
|
||||
$effect(() => {
|
||||
fetchCustomerGiftCardBalance();
|
||||
fetchSavedCardList();
|
||||
const services = booking.services ?? [];
|
||||
const overrides: Record<string, ServiceOverride> = {};
|
||||
for (const s of services) {
|
||||
@@ -504,6 +526,77 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Saved cards
|
||||
let savedCards = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
|
||||
let loadingSavedCards = $state(false);
|
||||
let selectedSavedCardId = $state<string | null>(null);
|
||||
|
||||
async function fetchSavedCards() {
|
||||
if (!booking.user_id) return;
|
||||
loadingSavedCards = true;
|
||||
savedCards = [];
|
||||
selectedSavedCardId = null;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
savedCards = await res.json();
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load saved cards');
|
||||
} finally {
|
||||
loadingSavedCards = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavedCardPayment() {
|
||||
if (!selectedSavedCardId) {
|
||||
toast.error('Please select a saved card');
|
||||
return;
|
||||
}
|
||||
|
||||
status = 'saved-card-processing';
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: Math.round(totalDue * 100),
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
throw new Error(errData || 'Failed to process saved card payment');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
status = 'success';
|
||||
paymentResult = {
|
||||
checkout_id: data.checkout_id || data.id || '',
|
||||
status: 'COMPLETED',
|
||||
card_brand: data.card_brand,
|
||||
last4: data.card_last4,
|
||||
amount: data.amount
|
||||
};
|
||||
toast.success('Saved card payment successful');
|
||||
onComplete(paymentResult);
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
error = err instanceof Error ? err.message : 'Failed to process saved card payment';
|
||||
toast.error(error ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (selectedMethod === 'cash') {
|
||||
cashAmount = totalDue.toFixed(2);
|
||||
@@ -512,6 +605,9 @@
|
||||
if (selectedMethod === 'giftcard') {
|
||||
giftCardId = '';
|
||||
}
|
||||
if (selectedMethod === 'savedcard') {
|
||||
fetchSavedCards();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -618,7 +714,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div class="grid grid-cols-2 gap-3 {savedCardList.length > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3'}">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
|
||||
@@ -665,6 +761,32 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if savedCardList.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
|
||||
'savedcard'
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => {
|
||||
selectedMethod = 'savedcard';
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8"
|
||||
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" />
|
||||
<path d="M6 10h12" />
|
||||
<path d="M6 14h6" />
|
||||
</svg>
|
||||
Saved Card
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
|
||||
@@ -693,7 +815,19 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sm:hidden">
|
||||
<div class="sm:hidden flex flex-wrap gap-3">
|
||||
{#if savedCardList.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-600 underline hover:text-gray-900"
|
||||
onclick={() => {
|
||||
selectedMethod = 'savedcard';
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
>
|
||||
Pay with Saved Card
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-600 underline hover:text-gray-900"
|
||||
@@ -916,6 +1050,78 @@
|
||||
></div>
|
||||
<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
|
||||
</div>
|
||||
{:else if status === 'saved-card-selecting'}
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
||||
<span class="text-base font-semibold text-gray-700">Total Due</span>
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
||||
</div>
|
||||
|
||||
{#if loadingSavedCards}
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-fuchsia-600"></div>
|
||||
</div>
|
||||
{:else if savedCards.length === 0}
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
|
||||
<p class="text-sm text-gray-600">No saved cards found for this customer.</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Add a card via Square Dashboard or use another payment method.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select a Saved Card</span>
|
||||
{#each savedCards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId === card.id
|
||||
? 'border-fuchsia-600 bg-fuchsia-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => selectedSavedCardId = card.id}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{card.card_brand} ••••{card.card_last4}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{card.card_expiry}</span>
|
||||
</div>
|
||||
{#if card.cardholder_name}
|
||||
<div class="mt-1 text-xs text-gray-500">{card.cardholder_name}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-xs text-amber-800">This card may require bank app confirmation to complete. Ensure the customer has their phone ready.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleSavedCardPayment}
|
||||
class="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||
disabled={!selectedSavedCardId}
|
||||
>
|
||||
Charge Saved Card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'saved-card-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-green-600"
|
||||
></div>
|
||||
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
|
||||
</div>
|
||||
{:else if status === 'error' && error}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3">
|
||||
|
||||
Reference in New Issue
Block a user