Unify card selection UI via reusable CardSelection component; fix Buy a Gift Card new-card bug

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.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 28f0ddb328
commit 5a373a3b0b
3 changed files with 246 additions and 220 deletions
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
@@ -9,7 +8,7 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking } from '$lib/types/booking';
import type { UserSavedCard } from '$lib/types';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
@@ -41,112 +40,25 @@
// Card selection state
let paymentMethods = $state<UserSavedCard[]>([]);
let paymentMethodsLoading = $state(false);
let selectedPaymentMethod = $state<string | null>(null);
let showNewCardForm = $state(false);
let showCardList = $state(false);
let selectedCardId = $state('');
let cardSelectionValid = $state(false);
let stamps = $state(0);
let useLoyalty = $state(false);
// Auto-select first saved card when methods load
$effect(() => {
if (paymentMethods.length > 0 && !selectedPaymentMethod && !showNewCardForm) {
if (paymentMethods.length > 0 && !selectedCardId) {
const defaultCard = paymentMethods.find((m) => m.is_default) ?? paymentMethods[0];
selectedPaymentMethod = defaultCard.id;
selectedCardId = defaultCard.id;
}
});
// New card form fields
// New card form fields (bound into CardSelection)
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
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;
}
const cardFormValid = $derived(
isValidLuhn(newCardNumber) && expiryParts !== null && newCardCVC.length >= 3 && !isExpiryInPast
);
const cardSelected = $derived(
(selectedPaymentMethod !== null && paymentMethods.length > 0) ||
(showNewCardForm && cardFormValid) ||
(paymentMethods.length === 0 && cardFormValid)
);
const cardValidationError = $derived(
!cardSelected
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
? 'Please select a card'
: cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isExpiryInPast
? 'Expiry date in the past'
: cardExpiryTouched && !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
? '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
);
// Partial payment amount (in pounds, user enters)
let partialAmount = $state<string>('');
@@ -254,7 +166,7 @@
const payButtonDisabled = $derived(
status === 'processing' ||
!cardSelected ||
!cardSelectionValid ||
(paymentType === 'partial' && !partialAmountValid) ||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
);
@@ -383,10 +295,6 @@
}
}
function formatCardExpiry(month: number, year: number): string {
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
}
function sanitizeAmountInput(value: string): string {
// Remove all non-numeric chars except .
const cleaned = value.replace(/[^0-9.]/g, '');
@@ -442,8 +350,8 @@
let newCardToken: string | undefined;
let saveCard = false;
if (selectedPaymentMethod) {
cardId = selectedPaymentMethod;
if (selectedCardId) {
cardId = selectedCardId;
} else if (newCardNumber) {
newCardToken = newCardNumber;
saveCard = saveCardForFuture;
@@ -757,119 +665,18 @@
{#if status === 'idle' && authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else if paymentMethods.length > 0 && !showNewCardForm}
<!-- Saved card selected -->
<div class="space-y-3">
{#if selectedPaymentMethod}
{#each paymentMethods as method (method.id)}
{#if method.id === selectedPaymentMethod}
<div
class="flex items-center justify-between rounded-lg border border-input bg-fuchsia-100 p-3"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"
>
{method.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {method.last_4}</span>
<span class="ml-2 text-gray-500">
{formatCardExpiry(method.exp_month, method.exp_year)}
</span>
</div>
</div>
<span class="text-xs font-medium text-foreground">Selected</span>
</div>
{/if}
{/each}
{/if}
{#if canSaveCards}
<Button
variant="ghost"
size="sm"
class="text-sm text-gray-600"
onclick={() => (showCardList = !showCardList)}
>
{showCardList ? 'Hide other cards' : 'Use a different card'}
</Button>
{/if}
{#if showCardList}
<div class="space-y-2">
{#if canSaveCards}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 transition-colors hover:border-gray-300"
onclick={() => {
showNewCardForm = true;
showCardList = false;
selectedPaymentMethod = null;
}}
>
<div class="text-sm font-medium text-gray-700">Enter card details</div>
<svg
class="h-4 w-4 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
{/if}
{#each paymentMethods as method (method.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 {selectedPaymentMethod ===
method.id
? 'border-input bg-fuchsia-100'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
showCardList = false;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"
>
{method.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {method.last_4}</span>
<span class="ml-2 text-gray-500">
{formatCardExpiry(method.exp_month, method.exp_year)}
</span>
</div>
</div>
{#if selectedPaymentMethod === method.id}
<span class="text-xs font-medium text-foreground">Selected</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{:else}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
<CardSelection
cards={paymentMethods}
{canSaveCards}
bind:selectedCardId
bind:newCardNumber
bind:newCardExpiry
bind:newCardCVC
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
disabled={false}
onfieldblur={handleFieldBlur}
onfieldinput={handleFieldInput}
onValidityChange={(v) => (cardSelectionValid = v)}
/>
{/if}
<!-- Card validation error shown inline near card input -->
{#if cardValidationError}
<p class="text-sm text-red-600">{cardValidationError}</p>
{/if}
{/if}
{#if depositPolicyWarning}