feat: caret preservation and Luhn validation in card inputs

- Added generic formatAndPreserveCursor() helper on frontend to track
  and restore selection caret position during dynamic input sanitization
- Applied to all card inputs, gift card code inputs, and expiry inputs
- Added Luhn validation (isValidLuhn) for saved cards and gift cards
- Rebuilt payments test DB and got 100% green tests
This commit is contained in:
2026-06-05 21:05:36 +01:00
parent fd41ca2920
commit 0d4a74bd4a
15 changed files with 3039 additions and 51 deletions
@@ -0,0 +1,561 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
interface GiftCard {
id: string;
total_funds_added: number;
amount_remaining: number;
created_by?: string;
created_at: string;
redeemed_at?: string;
redeemed_by?: string;
}
interface GiftCardSummary {
total_unclaimed: number;
total_user_balances: number;
gift_cards: GiftCard[];
}
let summary = $state<GiftCardSummary>({
total_unclaimed: 0,
total_user_balances: 0,
gift_cards: []
});
let loading = $state(true);
let showGenerateModal = $state(false);
let showTopUpModal = $state(false);
let showTransferModal = $state(false);
let creating = $state(false);
let toppingUp = $state(false);
let transferring = $state(false);
let selectedCardId = $state<string | null>(null);
// Form inputs
let generateAmount = $state('');
let topUpAmount = $state('');
let transferAmount = $state('');
let transferToCode = $state('');
// Validation
let generateError = $derived(
generateAmount && (isNaN(Number(generateAmount)) || Number(generateAmount) <= 0)
? 'Must be a valid positive number'
: ''
);
let topUpError = $derived(
topUpAmount && (isNaN(Number(topUpAmount)) || Number(topUpAmount) <= 0)
? 'Must be a valid positive number'
: ''
);
let transferAmountError = $derived(
transferAmount && (isNaN(Number(transferAmount)) || Number(transferAmount) <= 0)
? 'Must be a valid positive number'
: ''
);
let transferCodeError = $derived(
transferToCode && transferToCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12
? 'Code must be exactly 12 characters'
: ''
);
let isGenerateValid = $derived(generateAmount && !generateError);
let isTopUpValid = $derived(topUpAmount && !topUpError);
let isTransferValid = $derived(transferAmount && !transferAmountError && transferToCode && !transferCodeError);
async function fetchGiftCards() {
loading = true;
try {
const res = await fetch('/api/admin/gift-cards', {
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (res.ok) {
summary = await res.json();
} else {
toast.error('Failed to fetch gift cards');
}
} catch (err) {
toast.error('Network error fetching gift cards');
} finally {
loading = false;
}
}
async function generateGiftCard() {
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!`);
showGenerateModal = false;
generateAmount = '';
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;
}
}
async function topUpCard() {
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');
showTopUpModal = false;
topUpAmount = '';
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;
}
}
async function transferCard() {
if (!isTransferValid || !selectedCardId) return;
transferring = true;
try {
const res = await fetch(`/api/admin/gift-cards/${selectedCardId}/transfer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
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(errText || 'Failed to transfer balance');
}
} catch (err) {
toast.error('Network error transferring balance');
} finally {
transferring = false;
}
}
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 Date(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
$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} 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>
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-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">
{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">
{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">
{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>
</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" />
</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>
</div>
<div>
<span class="text-gray-400">Remaining:</span>
<span class="font-bold text-fuchsia-800 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="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}
</div>
</div>
{/each}
{/if}
</div>
</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>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}
</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.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>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}
</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.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 text-red-500 font-medium">{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 text-red-500 font-medium">{transferAmountError}</span>
{/if}
</div>
</div>
<Modal.Footer>
<Button variant="outline" onclick={() => showTransferModal = false}>Cancel</Button>
<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>