Create CardSelection.svelte reusable component encapsulating the standard saved-card list + 'Use a new card' + CardInput pattern with blur-based validation (Luhn, expiry, CVC) — identical to the tip flows and account page. Refactor UserPaymentModal (Make a Payment submodal) to use CardSelection: - Removed its bespoke 'Use a different card' expand/collapse UI and inline validation derivations (parseExpiryParts, isValidLuhn, touched state) - Bound selectedCardId + new card fields to the component - payButtonDisabled now driven by component's onValidityChange callback - Removed now-unused CardInput import, SvelteDate import, formatCardExpiry Fix account 'Buy a Gift Card' bug: 'Use a new card' click did nothing because the auto-select effect immediately re-set buySelectedCard back to the default card. Added buyShowNewCard flag so the effect only auto-selects on initial load; reset after successful new-card purchase so the next purchase re-defaults.
203 lines
6.0 KiB
Svelte
203 lines
6.0 KiB
Svelte
<script lang="ts">
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
import CardInput from './CardInput.svelte';
|
|
import CardBrandIcon from './CardBrandIcon.svelte';
|
|
|
|
export interface SelectableCard {
|
|
id: string;
|
|
brand: string;
|
|
last_4: string;
|
|
exp_month: number;
|
|
exp_year: number;
|
|
is_default?: boolean;
|
|
}
|
|
|
|
let {
|
|
cards = [],
|
|
canSaveCards = false,
|
|
selectedCardId = $bindable(''),
|
|
newCardNumber = $bindable(''),
|
|
newCardExpiry = $bindable(''),
|
|
newCardCVC = $bindable(''),
|
|
saveCard = $bindable(false),
|
|
onValidityChange = (_valid: boolean) => {}
|
|
}: {
|
|
cards?: SelectableCard[];
|
|
canSaveCards?: boolean;
|
|
selectedCardId?: string;
|
|
newCardNumber?: string;
|
|
newCardExpiry?: string;
|
|
newCardCVC?: string;
|
|
saveCard?: boolean;
|
|
onValidityChange?: (valid: boolean) => void;
|
|
} = $props();
|
|
|
|
// Internal — whether the "use a new card" form is shown.
|
|
// When no saved cards exist the form shows by default.
|
|
let showNewCardForm = $state(false);
|
|
|
|
// Blur-based validation state (matches the pattern used across all card flows)
|
|
let cardNumberTouched = $state(false);
|
|
let cardExpiryTouched = $state(false);
|
|
let cardCVCTouched = $state(false);
|
|
|
|
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 };
|
|
}
|
|
|
|
const expiryParts = $derived(parseExpiryParts(newCardExpiry));
|
|
|
|
const isExpiryInPast = $derived(
|
|
expiryParts !== null &&
|
|
(() => {
|
|
const expiryYearMonth = expiryParts.year * 12 + expiryParts.month;
|
|
const now = new SvelteDate();
|
|
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
|
|
return expiryYearMonth < currentYearMonth;
|
|
})()
|
|
);
|
|
|
|
const 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;
|
|
}
|
|
|
|
function handleFieldBlur(field: string) {
|
|
if (field === 'cardNumber') cardNumberTouched = true;
|
|
else if (field === 'cardExpiry') cardExpiryTouched = true;
|
|
else if (field === 'cardCVC') cardCVCTouched = true;
|
|
}
|
|
|
|
function handleFieldInput(field: string) {
|
|
if (field === 'cardNumber') cardNumberTouched = false;
|
|
else if (field === 'cardExpiry') cardExpiryTouched = false;
|
|
else if (field === 'cardCVC') cardCVCTouched = false;
|
|
}
|
|
|
|
// Exposed derived state — same semantics as the other card flows.
|
|
const cardError = $derived(
|
|
showNewCardForm || cards.length === 0
|
|
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
|
|
? 'Invalid card number'
|
|
: cardExpiryTouched && hasInvalidMonth
|
|
? 'Invalid expiry month'
|
|
: cardExpiryTouched && isExpiryInPast
|
|
? 'This card has expired'
|
|
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
|
|
? 'Enter expiry as MM/YY'
|
|
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
|
|
? 'Enter your CVC number'
|
|
: isValidLuhn(newCardNumber) &&
|
|
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
|
|
newCardCVC.length >= 3
|
|
? null
|
|
: newCardNumber.length === 0 &&
|
|
newCardExpiry.length === 0 &&
|
|
newCardCVC.length === 0
|
|
? null
|
|
: 'Please complete all card fields'
|
|
: null
|
|
);
|
|
|
|
const isCardValid = $derived(
|
|
(selectedCardId !== '' && cards.length > 0) ||
|
|
(isValidLuhn(newCardNumber) &&
|
|
expiryParts !== null &&
|
|
!isExpiryInPast &&
|
|
newCardCVC.length >= 3)
|
|
);
|
|
|
|
$effect(() => {
|
|
onValidityChange(isCardValid);
|
|
});
|
|
</script>
|
|
|
|
{#if cards.length > 0}
|
|
<div class="space-y-2">
|
|
{#each cards as card (card.id)}
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
|
card.id && !showNewCardForm
|
|
? 'border-input bg-accent'
|
|
: 'border-gray-200 hover:bg-gray-50'}"
|
|
onclick={() => {
|
|
selectedCardId = card.id;
|
|
showNewCardForm = false;
|
|
}}
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<CardBrandIcon brand={card.brand} />
|
|
<div class="text-sm">
|
|
<span class="font-mono">**** {card.last_4}</span>
|
|
<span class="ml-2 text-xs text-gray-400"
|
|
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
{#if selectedCardId === card.id && !showNewCardForm}
|
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
|
|
? 'border-input bg-accent'
|
|
: 'border-gray-200 hover:bg-gray-50'}"
|
|
onclick={() => {
|
|
selectedCardId = '';
|
|
showNewCardForm = 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 showNewCardForm}
|
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showNewCardForm || cards.length === 0}
|
|
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
|
|
<CardInput
|
|
bind:cardNumber={newCardNumber}
|
|
bind:cardExpiry={newCardExpiry}
|
|
bind:cardCVC={newCardCVC}
|
|
bind:saveCard
|
|
showSaveCard={canSaveCards}
|
|
onfieldblur={handleFieldBlur}
|
|
onfieldinput={handleFieldInput}
|
|
/>
|
|
{#if cardError}
|
|
<p class="mt-1 text-xs font-semibold text-red-500">{cardError}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|