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>
@@ -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
+517 -12
View File
@@ -23,6 +23,7 @@
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
@@ -137,6 +138,10 @@
});
if (res.ok) {
savedCards = await res.json();
if (savedCards.length > 0 && !buySelectedCard) {
const defaultCard = savedCards.find((c) => c.is_default) || savedCards[0];
buySelectedCard = defaultCard.id;
}
}
} catch {
toast.error('Failed to load saved cards');
@@ -168,6 +173,214 @@
let newCardCVC = $state('');
let addingCard = $state(false);
// =============== Gift Card State ===============
let giftCardBalance = $state(0);
let loadingBalance = $state(false);
let giftCardCode = $state('');
let redeemingGiftCard = $state(false);
// Buy Gift Card State
let buyAmount = $state<10 | 20 | 50>(10);
let buyRecipientType = $state<'self' | 'friend'>('self');
let buyRecipientEmail = $state('');
let buySelectedCard = $state('');
let buyNewCardNumber = $state('');
let buyNewCardExpiry = $state('');
let buyNewCardCVC = $state('');
let buySaveCard = $state(false);
let buyingGiftCard = $state(false);
let purchaseResultCode = $state<string | null>(null);
async function fetchGiftCardBalance() {
loadingBalance = true;
try {
const res = await fetch('/api/user/giftcards/balance', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
const data = await res.json();
giftCardBalance = data.balance;
}
} catch {
// ignore
} finally {
loadingBalance = false;
}
}
async function redeemGiftCard() {
if (giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12) {
toast.error('Invalid gift card code format');
return;
}
redeemingGiftCard = true;
try {
const res = await fetch('/api/user/giftcards/redeem', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({ code: giftCardCode })
});
if (res.ok) {
const data = await res.json();
toast.success(`Success! Redeemed ${formatCurrency(data.amount_redeemed)} to your balance.`);
giftCardCode = '';
await fetchGiftCardBalance();
} else {
const errText = await res.text();
toast.error(errText || 'Failed to redeem gift card');
}
} catch {
toast.error('Network error');
} finally {
redeemingGiftCard = false;
}
}
async function buyGiftCard() {
buyingGiftCard = true;
try {
let cardId: string | undefined;
let newCardToken: string | undefined;
let saveCard = false;
if (buySelectedCard) {
cardId = buySelectedCard;
} else if (buyNewCardNumber) {
if (!isValidLuhn(buyNewCardNumber) || !/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) || buyNewCardCVC.length < 3) {
toast.error('Please enter valid credit card details');
buyingGiftCard = false;
return;
}
newCardToken = buyNewCardNumber;
saveCard = buySaveCard;
} else {
toast.error('Please select or enter card details');
buyingGiftCard = false;
return;
}
const idempotencyKey = crypto.randomUUID();
const res = await fetch('/api/user/giftcards/buy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
amount: buyAmount * 100, // cents
recipient_type: buyRecipientType,
recipient_email: buyRecipientEmail,
card_id: cardId,
new_card_token: newCardToken,
save_card: saveCard,
idempotency_key: idempotencyKey
})
});
if (res.ok) {
const data = await res.json();
toast.success('Gift card purchased successfully!');
purchaseResultCode = data.code;
buyNewCardNumber = '';
buyNewCardExpiry = '';
buyNewCardCVC = '';
await fetchGiftCardBalance();
if (buySelectedCard === '') {
await fetchSavedCards();
}
} else {
const errText = await res.text();
toast.error(errText || 'Failed to purchase gift card');
}
} catch {
toast.error('Network error');
} finally {
buyingGiftCard = 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 handleGiftCardInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) => {
let raw = val.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
if (raw.length > 12) raw = raw.slice(0, 12);
let clean = '';
if (raw.length > 0) clean += raw.slice(0, 4);
if (raw.length > 4) clean += '-' + raw.slice(4, 8);
if (raw.length > 8) clean += '-' + raw.slice(8, 12);
return clean;
}, /[a-zA-Z0-9]/);
giftCardCode = formatted;
}
$effect(() => {
if (savedCards.length === 0 && buySelectedCard !== '') {
buySelectedCard = '';
}
});
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
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;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
@@ -182,9 +395,45 @@
return digits;
}
function handleCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatCardNumber);
newCardNumber = formatted;
}
function handleExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
newCardExpiry = formatted;
}
function handleCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
newCardCVC = formatted;
}
function handleBuyCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatCardNumber);
buyNewCardNumber = formatted;
}
// Uses custom formatter with MM/YY slash and preserves cursor position
function handleBuyExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
buyNewCardExpiry = formatted;
}
function handleBuyCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
buyNewCardCVC = formatted;
}
async function addCard() {
const cardNum = newCardNumber.replace(/\s/g, '');
if (cardNum.length < 13 || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || newCardCVC.length < 3) {
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || newCardCVC.length < 3) {
toast.error('Please fill in all card details correctly');
return;
}
@@ -883,6 +1132,7 @@
onclick={(_) => {
activeTab = 'cards';
fetchSavedCards();
fetchGiftCardBalance();
}}
>
<svg
@@ -1387,6 +1637,267 @@
</Card.Content>
</Card.Root>
{:else if activeTab === 'cards'}
<!-- Gift Card Balance & Redemption -->
<div class="grid gap-6 md:grid-cols-2 mb-6">
<!-- Redeem Gift Card -->
<Card.Root class="border-fuchsia-100 bg-white">
<Card.Header>
<Card.Title class="text-fuchsia-900 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
</svg>
Redeem Gift Card
</Card.Title>
<Card.Description>Redeem a gift card directly to your account balance.</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="rounded-lg border border-fuchsia-50 bg-fuchsia-50 p-4 flex justify-between items-center">
<div>
<div class="text-xs font-semibold text-fuchsia-600 uppercase tracking-wider">Your Balance</div>
<div class="mt-1 text-2xl font-bold text-fuchsia-950">
{loadingBalance ? '...' : formatCurrency(giftCardBalance)}
</div>
</div>
<div class="text-3xl text-fuchsia-300">💰</div>
</div>
<div class="space-y-2">
<label for="redeem-code" class="text-sm font-medium text-gray-700">Enter Gift Card Code</label>
<div class="flex gap-2">
<Input
id="redeem-code"
type="text"
placeholder="xxxx-xxxx-xxxx"
maxlength={14}
value={giftCardCode}
oninput={handleGiftCardInput}
class="font-mono"
/>
<Button
onclick={redeemGiftCard}
disabled={redeemingGiftCard || giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
>
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Buy Gift Card -->
<Card.Root class="border-pink-100 bg-white">
<Card.Header>
<Card.Title class="text-pink-900 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<rect x="2" y="5" width="20" height="14" rx="2" ry="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Buy a Gift Card
</Card.Title>
<Card.Description>Purchase a gift card online for yourself or a friend.</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if purchaseResultCode}
<div class="rounded-lg border border-green-100 bg-green-50 p-4 space-y-3">
<div class="text-sm font-medium text-green-800 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Purchase Successful!
</div>
{#if buyRecipientType === 'self'}
<p class="text-xs text-green-700">
Your purchase of <strong>{formatCurrency(buyAmount)}</strong> has been automatically added to your account balance!
</p>
{:else}
<p class="text-xs text-green-700">
Here is your gift card code:
</p>
<div class="text-center py-2 bg-white rounded border border-green-200 font-mono font-bold text-lg tracking-wider text-green-800">
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] text-green-600 italic">
Please save this code! It has been emailed to the recipient.
</p>
{/if}
<Button size="sm" variant="outline" onclick={() => purchaseResultCode = null} class="w-full">
Buy Another Card
</Button>
</div>
{:else}
<!-- Amount preset selector -->
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select Value</span>
<div class="grid grid-cols-3 gap-2">
{#each [10, 20, 50] as amount}
<button
type="button"
class="rounded-lg border py-2.5 text-center text-sm font-semibold transition-colors {buyAmount === amount
? 'border-pink-600 bg-pink-50 text-pink-900'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyAmount = amount as 10 | 20 | 50}
>
{formatCurrency(amount)}
</button>
{/each}
</div>
</div>
<!-- Recipient toggle -->
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Recipient</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 {buyRecipientType === 'self'
? 'border-pink-600 bg-pink-50 text-pink-900'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyRecipientType = 'self'}
>
For Myself (Auto-Redeem)
</button>
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType === 'friend'
? 'border-pink-600 bg-pink-50 text-pink-900'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyRecipientType = 'friend'}
>
For a Friend (Gift Code)
</button>
</div>
</div>
{#if buyRecipientType === 'friend'}
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700">Friend's Email (Optional)</label>
<Input
id="recipient-email"
type="email"
placeholder="friend@example.com (blank to send to yourself)"
bind:value={buyRecipientEmail}
class="mt-1"
/>
</div>
{/if}
<!-- Payment fields -->
<div class="space-y-3 pt-2 border-t">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Payment Method</span>
{#if savedCards.length > 0}
<div class="space-y-2">
{#each savedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === card.id
? 'border-pink-600 bg-pink-50'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = card.id;
}}
>
<div class="flex items-center gap-3">
<div class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium uppercase text-gray-700">
{card.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-gray-400 text-xs">
Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
</span>
</div>
</div>
{#if buySelectedCard === card.id}
<span class="text-xs font-semibold text-pink-700">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === ''
? 'border-pink-600 bg-pink-50'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = '';
}}
>
<div class="flex items-center gap-3">
<div class="flex h-8 min-w-12 items-center justify-center rounded border-dashed border border-gray-300 text-xs font-medium text-gray-400">
NEW
</div>
<span class="text-sm font-medium text-gray-700 animate-pulse">Use a new card</span>
</div>
{#if buySelectedCard === ''}
<span class="text-xs font-semibold text-pink-700">Selected</span>
{/if}
</button>
</div>
{/if}
{#if buySelectedCard === ''}
<div class="space-y-3 bg-gray-50 p-3 rounded-lg border">
<div>
<label for="buy-card-num" class="text-xs font-medium text-gray-600">Card Number</label>
<Input
id="buy-card-num"
type="text"
inputmode="numeric"
placeholder="1234 5678 9012 3456"
value={buyNewCardNumber}
oninput={handleBuyCardNumberInput}
maxlength={19}
class="h-8 text-xs mt-1 bg-white"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="buy-card-exp" class="text-xs font-medium text-gray-600">Expiry (MM/YY)</label>
<Input
id="buy-card-exp"
type="text"
inputmode="numeric"
placeholder="MM/YY"
value={buyNewCardExpiry}
oninput={handleBuyExpiryInput}
maxlength={5}
class="h-8 text-xs mt-1 bg-white"
/>
</div>
<div>
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600">CVC</label>
<Input
id="buy-card-cvc"
type="text"
inputmode="numeric"
placeholder="123"
value={buyNewCardCVC}
oninput={handleBuyCvcInput}
maxlength={4}
class="h-8 text-xs mt-1 bg-white"
/>
</div>
</div>
<div class="flex items-center gap-2 pt-1">
<Checkbox id="buy-save-card" bind:checked={buySaveCard} />
<label for="buy-save-card" class="text-[10px] text-gray-500">Save card for future purchases</label>
</div>
</div>
{/if}
</div>
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard || (!buySelectedCard && !buyNewCardNumber)}
class="w-full bg-pink-600 hover:bg-pink-700 text-white mt-2"
>
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
</Button>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Saved Cards -->
<Card.Root>
<Card.Header>
@@ -1413,8 +1924,7 @@
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={(e) =>
(newCardNumber = formatCardNumber((e.target as HTMLInputElement).value))}
oninput={handleCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
@@ -1430,10 +1940,7 @@
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={(e) =>
(newCardExpiry = formatExpiryDate(
(e.target as HTMLInputElement).value
))}
oninput={handleExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
@@ -1448,10 +1955,7 @@
type="text"
inputmode="numeric"
value={newCardCVC}
oninput={(e) =>
(newCardCVC = (e.target as HTMLInputElement).value
.replace(/\D/g, '')
.substring(0, 4))}
oninput={handleCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
@@ -1775,6 +2279,7 @@
onclick={(_) => {
activeTab = 'cards';
fetchSavedCards();
fetchGiftCardBalance();
}}
>
<svg
+2
View File
@@ -14,6 +14,7 @@
import ServicesManagement from '$lib/components/admin/ServicesManagement.svelte';
import PatchTestsManagement from '$lib/components/admin/PatchTestsManagement.svelte';
import DiscountsManagement from '$lib/components/admin/DiscountsManagement.svelte';
import GiftCardsManagement from '$lib/components/admin/GiftCardsManagement.svelte';
import UserModal from '$lib/components/admin/UserModal.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
@@ -301,6 +302,7 @@
<ServicesManagement />
<PatchTestsManagement />
<DiscountsManagement />
<GiftCardsManagement />
</div>
<!-- Modals -->