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:
@@ -0,0 +1,202 @@
|
|||||||
|
<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}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
@@ -9,7 +8,7 @@
|
|||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import type { Booking } from '$lib/types/booking';
|
import type { Booking } from '$lib/types/booking';
|
||||||
import type { UserSavedCard } from '$lib/types';
|
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 PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
@@ -41,112 +40,25 @@
|
|||||||
// Card selection state
|
// Card selection state
|
||||||
let paymentMethods = $state<UserSavedCard[]>([]);
|
let paymentMethods = $state<UserSavedCard[]>([]);
|
||||||
let paymentMethodsLoading = $state(false);
|
let paymentMethodsLoading = $state(false);
|
||||||
let selectedPaymentMethod = $state<string | null>(null);
|
let selectedCardId = $state('');
|
||||||
let showNewCardForm = $state(false);
|
let cardSelectionValid = $state(false);
|
||||||
let showCardList = $state(false);
|
|
||||||
|
|
||||||
let stamps = $state(0);
|
let stamps = $state(0);
|
||||||
let useLoyalty = $state(false);
|
let useLoyalty = $state(false);
|
||||||
|
|
||||||
// Auto-select first saved card when methods load
|
// Auto-select first saved card when methods load
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (paymentMethods.length > 0 && !selectedPaymentMethod && !showNewCardForm) {
|
if (paymentMethods.length > 0 && !selectedCardId) {
|
||||||
const defaultCard = paymentMethods.find((m) => m.is_default) ?? paymentMethods[0];
|
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 newCardNumber = $state('');
|
||||||
let newCardExpiry = $state('');
|
let newCardExpiry = $state('');
|
||||||
let newCardCVC = $state('');
|
let newCardCVC = $state('');
|
||||||
let saveCardForFuture = $state(false);
|
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)
|
// Partial payment amount (in pounds, user enters)
|
||||||
let partialAmount = $state<string>('');
|
let partialAmount = $state<string>('');
|
||||||
@@ -254,7 +166,7 @@
|
|||||||
|
|
||||||
const payButtonDisabled = $derived(
|
const payButtonDisabled = $derived(
|
||||||
status === 'processing' ||
|
status === 'processing' ||
|
||||||
!cardSelected ||
|
!cardSelectionValid ||
|
||||||
(paymentType === 'partial' && !partialAmountValid) ||
|
(paymentType === 'partial' && !partialAmountValid) ||
|
||||||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
|
(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 {
|
function sanitizeAmountInput(value: string): string {
|
||||||
// Remove all non-numeric chars except .
|
// Remove all non-numeric chars except .
|
||||||
const cleaned = value.replace(/[^0-9.]/g, '');
|
const cleaned = value.replace(/[^0-9.]/g, '');
|
||||||
@@ -442,8 +350,8 @@
|
|||||||
let newCardToken: string | undefined;
|
let newCardToken: string | undefined;
|
||||||
let saveCard = false;
|
let saveCard = false;
|
||||||
|
|
||||||
if (selectedPaymentMethod) {
|
if (selectedCardId) {
|
||||||
cardId = selectedPaymentMethod;
|
cardId = selectedCardId;
|
||||||
} else if (newCardNumber) {
|
} else if (newCardNumber) {
|
||||||
newCardToken = newCardNumber;
|
newCardToken = newCardNumber;
|
||||||
saveCard = saveCardForFuture;
|
saveCard = saveCardForFuture;
|
||||||
@@ -757,119 +665,18 @@
|
|||||||
{#if status === 'idle' && authStore.isAuthenticated}
|
{#if status === 'idle' && authStore.isAuthenticated}
|
||||||
{#if paymentMethodsLoading}
|
{#if paymentMethodsLoading}
|
||||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
<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}
|
{:else}
|
||||||
<CardInput
|
<CardSelection
|
||||||
bind:cardNumber={newCardNumber}
|
cards={paymentMethods}
|
||||||
bind:cardExpiry={newCardExpiry}
|
{canSaveCards}
|
||||||
bind:cardCVC={newCardCVC}
|
bind:selectedCardId
|
||||||
|
bind:newCardNumber
|
||||||
|
bind:newCardExpiry
|
||||||
|
bind:newCardCVC
|
||||||
bind:saveCard={saveCardForFuture}
|
bind:saveCard={saveCardForFuture}
|
||||||
showSaveCard={canSaveCards}
|
onValidityChange={(v) => (cardSelectionValid = v)}
|
||||||
disabled={false}
|
|
||||||
onfieldblur={handleFieldBlur}
|
|
||||||
onfieldinput={handleFieldInput}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Card validation error shown inline near card input -->
|
|
||||||
{#if cardValidationError}
|
|
||||||
<p class="text-sm text-red-600">{cardValidationError}</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if depositPolicyWarning}
|
{#if depositPolicyWarning}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||||
@@ -172,6 +172,7 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
let buyRecipientType = $state<'self' | 'friend'>('self');
|
let buyRecipientType = $state<'self' | 'friend'>('self');
|
||||||
let buyRecipientEmail = $state('');
|
let buyRecipientEmail = $state('');
|
||||||
let buySelectedCard = $state('');
|
let buySelectedCard = $state('');
|
||||||
|
let buyShowNewCard = $state(false);
|
||||||
let buyNewCardNumber = $state('');
|
let buyNewCardNumber = $state('');
|
||||||
let buyNewCardExpiry = $state('');
|
let buyNewCardExpiry = $state('');
|
||||||
let buyNewCardCVC = $state('');
|
let buyNewCardCVC = $state('');
|
||||||
@@ -180,7 +181,10 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
let purchaseResultCode = $state<string | null>(null);
|
let purchaseResultCode = $state<string | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (savedCardsStore.cards.length > 0 && !buySelectedCard) {
|
// 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) {
|
||||||
const defaultCard =
|
const defaultCard =
|
||||||
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
|
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
|
||||||
buySelectedCard = defaultCard.id;
|
buySelectedCard = defaultCard.id;
|
||||||
@@ -359,6 +363,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
await fetchGiftCardBalance();
|
await fetchGiftCardBalance();
|
||||||
if (buySelectedCard === '') {
|
if (buySelectedCard === '') {
|
||||||
await savedCardsStore.fetch();
|
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 {
|
} else {
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
@@ -769,7 +776,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
await fetchUserData();
|
await fetchUserData();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to update phone number', { id: loadingToast });
|
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to update phone number', {
|
||||||
|
id: loadingToast
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating phone:', err);
|
console.error('Error updating phone:', err);
|
||||||
@@ -874,7 +883,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
await fetchUserData();
|
await fetchUserData();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to update name', { id: loadingToast });
|
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to update name', {
|
||||||
|
id: loadingToast
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating name:', err);
|
console.error('Error updating name:', err);
|
||||||
@@ -1160,7 +1171,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
passwordData = { current: '', new: '', confirm: '' };
|
passwordData = { current: '', new: '', confirm: '' };
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to change password', { id: loadingToast });
|
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to change password', {
|
||||||
|
id: loadingToast
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error changing password:', err);
|
console.error('Error changing password:', err);
|
||||||
@@ -1206,7 +1219,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
goto('/');
|
goto('/');
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to delete account', { id: loadingToast });
|
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to delete account', {
|
||||||
|
id: loadingToast
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting account:', err);
|
console.error('Error deleting account:', err);
|
||||||
@@ -2388,6 +2403,7 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
buySelectedCard = card.id;
|
buySelectedCard = card.id;
|
||||||
|
buyShowNewCard = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -2412,6 +2428,7 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
buySelectedCard = '';
|
buySelectedCard = '';
|
||||||
|
buyShowNewCard = true;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user