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:
@@ -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>
|
||||
@@ -43,6 +43,32 @@
|
||||
let paymentResult = $state<PaymentResult | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let customerBalance = $state(0);
|
||||
let loadingCustomerBalance = $state(false);
|
||||
let giftCardPaymentAmount = $state('');
|
||||
|
||||
async function fetchCustomerGiftCardBalance() {
|
||||
if (!booking.user_id) return;
|
||||
loadingCustomerBalance = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${booking.user_id}/giftcard-balance`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
customerBalance = data.balance;
|
||||
giftCardPaymentAmount = Math.min(data.balance, totalDue).toFixed(2);
|
||||
if (data.balance > 0) {
|
||||
useAccountBalance = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loadingCustomerBalance = false;
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceOverride = {
|
||||
price: string;
|
||||
originalPrice: number;
|
||||
@@ -51,6 +77,7 @@
|
||||
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
|
||||
|
||||
$effect(() => {
|
||||
fetchCustomerGiftCardBalance();
|
||||
const services = booking.services ?? [];
|
||||
const overrides: Record<string, ServiceOverride> = {};
|
||||
for (const s of services) {
|
||||
@@ -355,42 +382,105 @@
|
||||
}
|
||||
|
||||
let giftCardId = $state('');
|
||||
let useAccountBalance = $state(false);
|
||||
|
||||
function formatAndPreserveCursor(
|
||||
input: HTMLInputElement,
|
||||
formatter: (val: string) => string,
|
||||
charRegex: RegExp = /\d/
|
||||
): string {
|
||||
const rawValue = input.value;
|
||||
const oldSelectionStart = input.selectionStart || 0;
|
||||
|
||||
let charsBeforeCursor = 0;
|
||||
for (let i = 0; i < oldSelectionStart; i++) {
|
||||
if (charRegex.test(rawValue[i])) {
|
||||
charsBeforeCursor++;
|
||||
}
|
||||
}
|
||||
|
||||
const formatted = formatter(rawValue);
|
||||
input.value = formatted;
|
||||
|
||||
let newSelectionStart = 0;
|
||||
let charsFound = 0;
|
||||
for (let i = 0; i < formatted.length; i++) {
|
||||
if (charsFound === charsBeforeCursor) {
|
||||
break;
|
||||
}
|
||||
if (charRegex.test(formatted[i])) {
|
||||
charsFound++;
|
||||
}
|
||||
newSelectionStart++;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
input.setSelectionRange(newSelectionStart, newSelectionStart);
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function formatGiftCardId(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').substring(0, 12);
|
||||
const groups = digits.match(/.{1,4}/g);
|
||||
return groups ? groups.join(' ') : digits;
|
||||
let raw = 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);
|
||||
return formatted.toUpperCase();
|
||||
}
|
||||
|
||||
function handleGiftCardInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
giftCardId = formatGiftCardId(input.value);
|
||||
const formatted = formatAndPreserveCursor(input, formatGiftCardId, /[a-zA-Z0-9]/);
|
||||
giftCardId = formatted;
|
||||
}
|
||||
|
||||
let giftCardValid = $derived(giftCardId.replace(/\s/g, '').length === 12);
|
||||
let giftCardValid = $derived(
|
||||
useAccountBalance || giftCardId.replace(/-/g, '').length === 12
|
||||
);
|
||||
|
||||
async function handleGiftCardPayment() {
|
||||
if (!giftCardValid) {
|
||||
toast.error('Please enter a valid 12-digit gift card ID');
|
||||
toast.error('Please enter a valid 12-character gift card code');
|
||||
return;
|
||||
}
|
||||
|
||||
let payAmountCents = Math.round(totalDue * 100);
|
||||
if (useAccountBalance) {
|
||||
const parsedAmt = parseFloat(giftCardPaymentAmount);
|
||||
if (isNaN(parsedAmt) || parsedAmt <= 0) {
|
||||
toast.error('Please enter a valid payment amount');
|
||||
return;
|
||||
}
|
||||
if (parsedAmt > customerBalance) {
|
||||
toast.error('Payment amount exceeds available balance');
|
||||
return;
|
||||
}
|
||||
payAmountCents = Math.round(parsedAmt * 100);
|
||||
}
|
||||
|
||||
status = 'gift-confirming';
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const body: any = {
|
||||
amount: payAmountCents,
|
||||
payment_type: 'full',
|
||||
payment_method: 'giftcard'
|
||||
};
|
||||
if (!useAccountBalance) {
|
||||
body.gift_card_id = giftCardId.replace(/-/g, '');
|
||||
}
|
||||
|
||||
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: 1000,
|
||||
payment_type: 'full',
|
||||
payment_method: 'giftcard',
|
||||
gift_card_id: giftCardId.replace(/\s/g, '')
|
||||
})
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -749,27 +839,70 @@
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card ID </label>
|
||||
<Input
|
||||
id="gift-card-id"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
tabindex={-1}
|
||||
value={giftCardId}
|
||||
oninput={handleGiftCardInput}
|
||||
placeholder="XXXX XXXX XXXX"
|
||||
maxlength={14}
|
||||
class="mt-1 font-mono text-lg tracking-widest"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Enter the 12-digit ID printed on the gift card</p>
|
||||
</div>
|
||||
{#if booking.user_id && customerBalance > 0}
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Source</span>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {useAccountBalance
|
||||
? 'border-fuchsia-600 bg-fuchsia-50 text-fuchsia-900 font-semibold'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => useAccountBalance = true}
|
||||
>
|
||||
Account Balance ({formatCurrency(customerBalance)})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {!useAccountBalance
|
||||
? 'border-fuchsia-600 bg-fuchsia-50 text-fuchsia-900 font-semibold'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => useAccountBalance = false}
|
||||
>
|
||||
Physical Gift Card Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if useAccountBalance}
|
||||
<div>
|
||||
<label for="giftcard-amount" class="text-sm font-medium text-gray-700">Amount to pay with Balance (£)</label>
|
||||
<Input
|
||||
id="giftcard-amount"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
value={giftCardPaymentAmount}
|
||||
oninput={(e) => giftCardPaymentAmount = (e.target as HTMLInputElement).value}
|
||||
class="mt-1 font-mono text-lg"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance can be used.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card Code </label>
|
||||
<Input
|
||||
id="gift-card-id"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
tabindex={-1}
|
||||
value={giftCardId}
|
||||
oninput={handleGiftCardInput}
|
||||
placeholder="XXXX-XXXX-XXXX"
|
||||
maxlength={14}
|
||||
class="mt-1 font-mono text-lg tracking-widest"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Enter the 12-character code printed on the gift card</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleGiftCardPayment}
|
||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
||||
class="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||
disabled={!giftCardValid}
|
||||
>
|
||||
Apply Gift Card
|
||||
|
||||
@@ -51,6 +51,43 @@
|
||||
let newCardCVC = $state('');
|
||||
let saveCardForFuture = $state(false);
|
||||
|
||||
function formatAndPreserveCursor(
|
||||
input: HTMLInputElement,
|
||||
formatter: (val: string) => string,
|
||||
charRegex: RegExp = /\d/
|
||||
): string {
|
||||
const rawValue = input.value;
|
||||
const oldSelectionStart = input.selectionStart || 0;
|
||||
|
||||
let charsBeforeCursor = 0;
|
||||
for (let i = 0; i < oldSelectionStart; i++) {
|
||||
if (charRegex.test(rawValue[i])) {
|
||||
charsBeforeCursor++;
|
||||
}
|
||||
}
|
||||
|
||||
const formatted = formatter(rawValue);
|
||||
input.value = formatted;
|
||||
|
||||
let newSelectionStart = 0;
|
||||
let charsFound = 0;
|
||||
for (let i = 0; i < formatted.length; i++) {
|
||||
if (charsFound === charsBeforeCursor) {
|
||||
break;
|
||||
}
|
||||
if (charRegex.test(formatted[i])) {
|
||||
charsFound++;
|
||||
}
|
||||
newSelectionStart++;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
input.setSelectionRange(newSelectionStart, newSelectionStart);
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function formatCardNumber(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').substring(0, 16);
|
||||
const groups = digits.match(/.{1,4}/g);
|
||||
@@ -59,7 +96,8 @@
|
||||
|
||||
function handleCardNumberInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
newCardNumber = formatCardNumber(input.value);
|
||||
const formatted = formatAndPreserveCursor(input, formatCardNumber);
|
||||
newCardNumber = formatted;
|
||||
}
|
||||
|
||||
function formatExpiryDate(value: string): string {
|
||||
@@ -72,12 +110,14 @@
|
||||
|
||||
function handleExpiryInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
newCardExpiry = formatExpiryDate(input.value);
|
||||
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
|
||||
newCardExpiry = formatted;
|
||||
}
|
||||
|
||||
function handleCvcInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
newCardCVC = input.value.replace(/\D/g, '').substring(0, 4);
|
||||
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
|
||||
newCardCVC = formatted;
|
||||
}
|
||||
|
||||
function parseExpiryParts(value: string): { month: number; year: number } | null {
|
||||
@@ -101,8 +141,24 @@
|
||||
|
||||
let hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
|
||||
|
||||
function isValidLuhn(cardNumber: string): boolean {
|
||||
const s = cardNumber.replace(/\D/g, '');
|
||||
let sum = 0;
|
||||
let alternate = false;
|
||||
for (let i = s.length - 1; i >= 0; i--) {
|
||||
let n = parseInt(s[i], 10);
|
||||
if (alternate) {
|
||||
n *= 2;
|
||||
if (n > 9) n -= 9;
|
||||
}
|
||||
sum += n;
|
||||
alternate = !alternate;
|
||||
}
|
||||
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
|
||||
}
|
||||
|
||||
let cardFormValid = $derived(
|
||||
newCardNumber.replace(/\s/g, '').length >= 13 &&
|
||||
isValidLuhn(newCardNumber) &&
|
||||
expiryParts !== null &&
|
||||
newCardCVC.length >= 3 &&
|
||||
!isExpiryInPast
|
||||
@@ -118,8 +174,8 @@
|
||||
!cardSelected
|
||||
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
|
||||
? 'Please select a card'
|
||||
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
|
||||
? 'Card number too short'
|
||||
: !isValidLuhn(newCardNumber) && newCardNumber.length > 0
|
||||
? 'Invalid card number'
|
||||
: hasInvalidMonth
|
||||
? 'Invalid expiry month'
|
||||
: isExpiryInPast
|
||||
|
||||
Reference in New Issue
Block a user