Close refund system and gate raw-PAN card entry
Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
|
||||
// zxcvbn-ts imports
|
||||
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
|
||||
@@ -29,7 +30,6 @@
|
||||
|
||||
// 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 { EmailInput } from '$lib/components/ui/email-input/index.js';
|
||||
@@ -153,12 +153,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let showAddCard = $state(false);
|
||||
let newCardNumber = $state('');
|
||||
let newCardExpiry = $state('');
|
||||
let newCardCVC = $state('');
|
||||
let addingCard = $state(false);
|
||||
|
||||
// =============== Gift Card State ===============
|
||||
let giftCardBalance = $state(0);
|
||||
let loadingBalance = $state(false);
|
||||
@@ -172,11 +166,6 @@
|
||||
let buyRecipientType = $state<'self' | 'friend'>('self');
|
||||
let buyRecipientEmail = $state('');
|
||||
let buySelectedCard = $state('');
|
||||
let buyShowNewCard = $state(false);
|
||||
let buyNewCardNumber = $state('');
|
||||
let buyNewCardExpiry = $state('');
|
||||
let buyNewCardCVC = $state('');
|
||||
let buySaveCard = $state(false);
|
||||
let buyingGiftCard = $state(false);
|
||||
let purchaseResultCode = $state<string | null>(null);
|
||||
|
||||
@@ -188,90 +177,18 @@
|
||||
let buyKeyedCard = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// Auto-select the default saved card only once, when cards first load.
|
||||
// Do NOT re-select when the user explicitly chooses "Use a new card"
|
||||
// (buySelectedCard === ''), otherwise the click is immediately overridden.
|
||||
if (savedCardsStore.cards.length > 0 && !buySelectedCard && !buyShowNewCard) {
|
||||
// Auto-select the default saved card when cards first load. A new card
|
||||
// cannot be entered online right now (see CardEntryUnavailable), so this
|
||||
// only ever needs to pick between saved cards.
|
||||
if (savedCardsStore.cards.length > 0 && !buySelectedCard) {
|
||||
const defaultCard =
|
||||
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
|
||||
buySelectedCard = defaultCard.id;
|
||||
}
|
||||
});
|
||||
|
||||
function parseExpiryParts(value: string): { month: number; year: number } | null {
|
||||
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
|
||||
const [monthStr, yearStr] = value.split('/');
|
||||
const month = parseInt(monthStr, 10);
|
||||
const year = 2000 + parseInt(yearStr, 10);
|
||||
if (month < 1 || month > 12) return null;
|
||||
return { month, year };
|
||||
}
|
||||
|
||||
// Derived validations for Add Saved Card form
|
||||
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
|
||||
const isNewCardExpiryInPast = $derived(
|
||||
newCardExpiryParts !== null &&
|
||||
(() => {
|
||||
const expiryYearMonth = newCardExpiryParts.year * 12 + newCardExpiryParts.month;
|
||||
const now = new SvelteDate();
|
||||
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
|
||||
return expiryYearMonth < currentYearMonth;
|
||||
})()
|
||||
);
|
||||
const isNewCardExpiryInvalidMonth = $derived(
|
||||
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
|
||||
);
|
||||
|
||||
const addCardError = $derived(
|
||||
newCardNumber.length > 0 && !isValidLuhn(newCardNumber)
|
||||
? 'Invalid card number'
|
||||
: isNewCardExpiryInvalidMonth
|
||||
? 'Invalid expiry month'
|
||||
: isNewCardExpiryInPast
|
||||
? 'This card has already expired'
|
||||
: newCardCVC.length > 0 && newCardCVC.length < 3
|
||||
? 'CVC must be at least 3 digits'
|
||||
: null
|
||||
);
|
||||
|
||||
const isAddCardValid = $derived(
|
||||
isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
|
||||
);
|
||||
|
||||
// Derived validations for Buy Gift Card form
|
||||
const buyNewCardExpiryParts = $derived(parseExpiryParts(buyNewCardExpiry));
|
||||
const isBuyNewCardExpiryInPast = $derived(
|
||||
buyNewCardExpiryParts !== null &&
|
||||
(() => {
|
||||
const expiryYearMonth = buyNewCardExpiryParts.year * 12 + buyNewCardExpiryParts.month;
|
||||
const now = new SvelteDate();
|
||||
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
|
||||
return expiryYearMonth < currentYearMonth;
|
||||
})()
|
||||
);
|
||||
const isBuyNewCardExpiryInvalidMonth = $derived(
|
||||
/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) && buyNewCardExpiryParts === null
|
||||
);
|
||||
|
||||
const buyCardError = $derived(
|
||||
buyNewCardNumber.length > 0 && !isValidLuhn(buyNewCardNumber)
|
||||
? 'Invalid card number'
|
||||
: isBuyNewCardExpiryInvalidMonth
|
||||
? 'Invalid expiry month'
|
||||
: isBuyNewCardExpiryInPast
|
||||
? 'This card has already expired'
|
||||
: buyNewCardCVC.length > 0 && buyNewCardCVC.length < 3
|
||||
? 'CVC must be at least 3 digits'
|
||||
: null
|
||||
);
|
||||
|
||||
const isBuyCardValid = $derived(
|
||||
buySelectedCard !== '' ||
|
||||
(isValidLuhn(buyNewCardNumber) &&
|
||||
buyNewCardExpiryParts !== null &&
|
||||
!isBuyNewCardExpiryInPast &&
|
||||
buyNewCardCVC.length >= 3)
|
||||
);
|
||||
// Derived validation for Buy Gift Card form
|
||||
const isBuyCardValid = $derived(buySelectedCard !== '');
|
||||
|
||||
async function fetchGiftCardBalance() {
|
||||
loadingBalance = true;
|
||||
@@ -318,36 +235,20 @@
|
||||
}
|
||||
|
||||
async function buyGiftCard() {
|
||||
if (!buySelectedCard) {
|
||||
toast.error('Please select a saved card');
|
||||
buyingGiftCard = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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 cardId = buySelectedCard;
|
||||
|
||||
// Cache the idempotency key per amount+card so a lost-response retry
|
||||
// reuses the same key (backend dedups) instead of double-charging.
|
||||
// Regenerate when the amount or card changes.
|
||||
const cardKey = cardId ?? newCardToken ?? '';
|
||||
const cardKey = cardId;
|
||||
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
||||
buyIdempotencyKey = generateIdempotencyKey();
|
||||
buyKeyedAmount = buyAmount;
|
||||
@@ -362,8 +263,6 @@
|
||||
recipient_type: buyRecipientType,
|
||||
recipient_email: buyRecipientEmail,
|
||||
card_id: cardId,
|
||||
new_card_token: newCardToken,
|
||||
save_card: saveCard,
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
});
|
||||
@@ -372,19 +271,10 @@
|
||||
const data = await res.json();
|
||||
toast.success('Gift card purchased successfully!');
|
||||
purchaseResultCode = data.code;
|
||||
buyNewCardNumber = '';
|
||||
buyNewCardExpiry = '';
|
||||
buyNewCardCVC = '';
|
||||
buyIdempotencyKey = '';
|
||||
buyKeyedAmount = 0;
|
||||
buyKeyedCard = '';
|
||||
await fetchGiftCardBalance();
|
||||
if (buySelectedCard === '') {
|
||||
await savedCardsStore.fetch();
|
||||
// If the new card was saved, return to saved-card selection so the
|
||||
// auto-select effect picks a default for the next purchase.
|
||||
buyShowNewCard = false;
|
||||
}
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(extractErrorMessage(errText) || 'Failed to purchase gift card');
|
||||
@@ -475,77 +365,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
return groups ? groups.join(' ') : digits;
|
||||
}
|
||||
|
||||
function formatExpiryDate(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').substring(0, 4);
|
||||
if (digits.length >= 3) {
|
||||
return digits.substring(0, 2) + '/' + digits.substring(2);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function generateIdempotencyKey(): string {
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof window !== 'undefined' && window.crypto) {
|
||||
@@ -564,58 +383,6 @@
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function addCard() {
|
||||
if (
|
||||
!isValidLuhn(newCardNumber) ||
|
||||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
|
||||
newCardCVC.length < 3
|
||||
) {
|
||||
toast.error('Please fill in all card details correctly');
|
||||
return;
|
||||
}
|
||||
const cardNum = newCardNumber.replace(/\s/g, '');
|
||||
const [monthStr, yearStr] = newCardExpiry.split('/');
|
||||
const month = parseInt(monthStr, 10);
|
||||
const year = 2000 + parseInt(yearStr, 10);
|
||||
if (month < 1 || month > 12) {
|
||||
toast.error('Invalid expiry month');
|
||||
return;
|
||||
}
|
||||
const expiryDate = new Date(year, month);
|
||||
if (expiryDate < new Date()) {
|
||||
toast.error('This card has already expired');
|
||||
return;
|
||||
}
|
||||
addingCard = true;
|
||||
try {
|
||||
const res = await apiFetch('/api/user/payment-methods', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
card_number: cardNum,
|
||||
expiry: newCardExpiry,
|
||||
cvc: newCardCVC
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Card added');
|
||||
showAddCard = false;
|
||||
newCardNumber = '';
|
||||
newCardExpiry = '';
|
||||
newCardCVC = '';
|
||||
savedCardsStore.invalidate();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(extractErrorMessage(errText) || 'Failed to add card');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('addCard error:', err);
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
addingCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotifPrefs() {
|
||||
try {
|
||||
const res = await apiFetch('/api/user/notification-preferences');
|
||||
@@ -2039,88 +1806,12 @@
|
||||
<Skeleton class="h-16 w-full" />
|
||||
<Skeleton class="h-16 w-full" />
|
||||
</div>
|
||||
{:else if showAddCard}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Add New Card</h4>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="account-cardNumber" class="text-sm font-medium text-gray-700"
|
||||
>Card Number</label
|
||||
>
|
||||
<Input
|
||||
id="account-cardNumber"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={newCardNumber}
|
||||
oninput={handleCardNumberInput}
|
||||
placeholder="1234 5678 9012 3456"
|
||||
maxlength={19}
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="account-cardExpiry" class="text-sm font-medium text-gray-700"
|
||||
>Expiry (MM/YY)</label
|
||||
>
|
||||
<Input
|
||||
id="account-cardExpiry"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={newCardExpiry}
|
||||
oninput={handleExpiryInput}
|
||||
placeholder="MM/YY"
|
||||
maxlength={5}
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="account-cardCVC" class="text-sm font-medium text-gray-700"
|
||||
>CVC</label
|
||||
>
|
||||
<Input
|
||||
id="account-cardCVC"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={newCardCVC}
|
||||
oninput={handleCvcInput}
|
||||
placeholder="123"
|
||||
maxlength={4}
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if addCardError}
|
||||
<div class="mt-1 text-xs font-semibold text-red-500">{addCardError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
showAddCard = false;
|
||||
newCardNumber = '';
|
||||
newCardExpiry = '';
|
||||
newCardCVC = '';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onclick={addCard}
|
||||
loading={addingCard}
|
||||
disabled={addingCard || !isAddCardValid}
|
||||
>
|
||||
Add Card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if savedCardsStore.cards.length === 0}
|
||||
<div class="py-8 text-center">
|
||||
<p class="text-gray-500">No saved cards yet</p>
|
||||
<Button class="mt-4" onclick={() => (showAddCard = true)}>Add a Card</Button>
|
||||
<div class="space-y-4 py-2">
|
||||
<p class="text-center text-gray-500">No saved cards yet</p>
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
@@ -2150,9 +1841,9 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button variant="outline" class="w-full" onclick={() => (showAddCard = true)}>
|
||||
+ Add a Card
|
||||
</Button>
|
||||
<CardEntryUnavailable
|
||||
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
@@ -2419,10 +2110,7 @@
|
||||
card.id
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
buySelectedCard = card.id;
|
||||
buyShowNewCard = false;
|
||||
}}
|
||||
onclick={() => (buySelectedCard = card.id)}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
@@ -2438,95 +2126,9 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
|
||||
''
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
buySelectedCard = '';
|
||||
buyShowNewCard = true;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
|
||||
>
|
||||
NEW
|
||||
</div>
|
||||
<span class="animate-pulse text-sm font-medium text-gray-700"
|
||||
>Use a new card</span
|
||||
>
|
||||
</div>
|
||||
{#if buySelectedCard === ''}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if buySelectedCard === ''}
|
||||
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
|
||||
<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="mt-1 h-8 bg-white text-xs"
|
||||
/>
|
||||
</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="mt-1 h-8 bg-white text-xs"
|
||||
/>
|
||||
</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="mt-1 h-8 bg-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if buyCardError}
|
||||
<div class="mt-1 text-[10px] font-semibold text-red-500">
|
||||
{buyCardError}
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user