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:
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user