Fix tip amount-change false dedup, wire BuyerEmail everywhere, clear ESLint errors

Money-moving fixes:
- Tip idempotency key regenerates when the tip amount changes after a failed
  attempt (all 3 tip flows). Cached key still reused on same-amount retry
  (dedup intact) and cleared on success/modal reset. Prevents silent
  under-charge when a user retries at a different amount.
- Till replay path returns actual till_sales.status (may be 'pending') instead
  of hardcoded 'completed' — no more misreported successful charge.
- BuyerEmail wired for CreateBookingPayment, gift card purchases, and till
  sales (saved_card + online_square), matching the tip flow. Email lookup
  errors logged, non-fatal.
- Till buyer-email errors now logged (was silently swallowed).
- on_the_house till top-up uses cached getIdempotencyKey() for retry-safe dedup
  (was fresh crypto.randomUUID()).

Test/validation fixes:
- Add TestPaymentFromSquare_* unit tests (else-branch + nil card details),
  build tag relaxed to 'test' so they run in the standard dev suite.
- Add TestValidateCardInfo table test (7 cases: both/either/neither/empty).
- Add TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed regression test.
- Remove dead mock pre-registration in TestTipPayment_WithSavedCard.
- Correct misleading till regression-test comment.

ESLint cleanup (12 errors -> 0):
- Remove unused loadingCards in tip + pay-tip pages (dead assignments in
  loadSavedCards).
- Scoped eslint-disable for {@html} in CardBrandIcon (hardcoded brand SVGs).
- Remove dead confirmSaveDefaultHours + unused rescheduleVersion prop in
  WeeklySchedule (and its parent pass-through).
- Replace new Date() with SvelteDate in WeeklySchedule + BusinessHours.
- Fix each-block key in BusinessHours skeleton loader.
- Use void expression for reactivity-tracker reads in effects.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent a4441b6acf
commit 28f0ddb328
15 changed files with 330 additions and 143 deletions
@@ -16,7 +16,7 @@
import { parseWallClockDate } from '$lib/utils/timeSlots';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
interface Props {
open: boolean;
@@ -138,6 +138,13 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
let customTipInput = $state('');
let tipProcessing = $state(false);
// Cached idempotency key: generated once per tip attempt, reused on retry
// (so a network-timeout retry dedupes instead of double-charging), cleared on
// success. Reset when the tip amount changes so an amount change after a
// failed attempt gets a fresh key instead of a false dedup (under-charge).
let tipIdempotencyKey = $state('');
let tipKeyedAmount = $state(0);
// Card selection state for tips
let tipSavedCards = $state<SavedCard[]>([]);
let tipLoadingCards = $state(false);
@@ -205,7 +212,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
return expiryYearMonth < currentYearMonth;
})()
);
const hasTipNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null);
const hasTipNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null
);
const tipNewCardError = $derived(
tipShowNewCard || tipSavedCards.length === 0
@@ -215,13 +224,19 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
? 'Invalid expiry month'
: tipCardExpiryTouched && isTipNewCardExpiryPast
? 'This card has expired'
: tipCardExpiryTouched && tipNewCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(tipNewCardExpiry)
: tipCardExpiryTouched &&
tipNewCardExpiry.length > 0 &&
!/^\d{2}\/\d{2}$/.test(tipNewCardExpiry)
? 'Enter expiry as MM/YY'
: tipCVCTouched && tipNewCardCVC.length < 3 && tipNewCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(tipNewCardNumber) && /^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardCVC.length >= 3
: isValidLuhn(tipNewCardNumber) &&
/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) &&
tipNewCardCVC.length >= 3
? null
: tipNewCardNumber.length === 0 && tipNewCardExpiry.length === 0 && tipNewCardCVC.length === 0
: tipNewCardNumber.length === 0 &&
tipNewCardExpiry.length === 0 &&
tipNewCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
@@ -312,7 +327,12 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
// Validate card details for new card payments
if (tipShowNewCard || tipSavedCards.length === 0) {
if (!isValidLuhn(tipNewCardNumber) || !/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) || isTipNewCardExpiryPast || tipNewCardCVC.length < 3) {
if (
!isValidLuhn(tipNewCardNumber) ||
!/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) ||
isTipNewCardExpiryPast ||
tipNewCardCVC.length < 3
) {
tipProcessing = false;
toast.error(tipNewCardError || 'Please enter valid credit card details');
return;
@@ -320,7 +340,14 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
}
try {
const body: Record<string, unknown> = { amount: Math.round(tipAmount * 100) };
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
tipKeyedAmount = tipAmount;
}
const body: Record<string, unknown> = {
amount: Math.round(tipAmount * 100),
idempotency_key: tipIdempotencyKey
};
if (tipShowNewCard || tipSavedCards.length === 0) {
body.new_card_token = tipNewCardNumber.replace(/\s/g, '');
@@ -339,6 +366,8 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
throw new Error(errorText || 'Tip payment failed');
}
toast.success('Thank you for your tip!');
tipIdempotencyKey = '';
tipKeyedAmount = 0;
showTipModal = false;
fetchBookingDetails();
} catch (err) {
@@ -1173,6 +1202,8 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
tipAmount = 0;
selectedTipPreset = null;
customTipInput = '';
tipIdempotencyKey = '';
tipKeyedAmount = 0;
}
}}
>
@@ -1221,7 +1252,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<!-- Card Selection for Tip -->
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">Payment Method</span>
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Payment Method</span
>
{#if tipLoadingCards}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
@@ -1230,11 +1263,17 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
{#each tipSavedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId === card.id && !tipShowNewCard ? 'border-input bg-accent' : 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { tipSelectedCardId = card.id; tipShowNewCard = false; }}
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId ===
card.id && !tipShowNewCard
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
tipSelectedCardId = card.id;
tipShowNewCard = false;
}}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<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"
@@ -1249,8 +1288,13 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCard ? 'border-input bg-accent' : 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { tipSelectedCardId = null; tipShowNewCard = true; }}
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCard
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
tipSelectedCardId = null;
tipShowNewCard = true;
}}
>
<div class="flex items-center gap-3">
<div
@@ -694,7 +694,7 @@
amount: Number(topUpAmount),
payment_method: 'on_the_house',
gift_card_id: gcId,
idempotency_key: 'till-on-the-house-' + gcId + '-' + Date.now()
idempotency_key: getIdempotencyKey()
};
const res = await apiFetch('/api/admin/till/sale', {
@@ -376,10 +376,10 @@
// Check for conflicts whenever weekStarts, hours, or rescheduleVersion change
$effect(() => {
// Track these reactive values so the effect re-runs when they change
// Read these reactive values so the effect re-runs when they change
const weekStarts = exceptionDraft.weekStarts;
const hours = exceptionDraft.hours;
const rv = rescheduleVersion;
void exceptionDraft.hours;
void rescheduleVersion;
// Avoid triggering check on initial empty state
if (weekStarts.length > 0 && showExceptionModal) {
checkConflictingBookings();
@@ -747,7 +747,12 @@
? ''
: 's'} with the proposed hours
</span>
<Button variant="outline" size="sm" class="h-6 text-xs ml-auto" onclick={checkConflictingBookings}>
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
onclick={checkConflictingBookings}
>
Refresh
</Button>
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { browser } from '$app/environment';
@@ -57,15 +58,9 @@
};
openUserModal?: (userId: string) => void;
openBookingModal?: (bookingId: string) => void;
rescheduleVersion?: number;
}
let {
defaultHours: defaultHoursProp,
openUserModal,
openBookingModal,
rescheduleVersion = 0
}: Props = $props();
let { defaultHours: defaultHoursProp, openUserModal, openBookingModal }: Props = $props();
// =============== State ===============
let defaultHours = $state<WorkingHourRow[]>([]);
@@ -201,46 +196,6 @@
}
/** Saves the default hours draft after confirmation. */
async function confirmSaveDefaultHours() {
savingHours = true;
const loadingToast = toast.loading('Saving default hours...');
try {
// Map snake_case to camelCase for API
const payload = defaultHoursDraft.map((hour) => ({
weekday: hour.weekday,
startTime: hour.start_time,
endTime: hour.end_time,
isOpen: hour.is_open
}));
const response = await apiFetch('/api/scheduling/default-hours', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
// Update the main state from the draft state if successful
defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft));
showDefaultHoursModal = false;
toast.success('Default hours saved successfully!', { id: loadingToast });
} else if (response.status === 401 || response.status === 403) {
toast.error('Unauthorized. Please log in again.', { id: loadingToast });
} else {
const text = await response.text();
toast.error('Failed to save: ' + extractErrorMessage(text), { id: loadingToast });
}
} catch (err) {
console.error('save default hours', err);
toast.error('Network error saving hours', { id: loadingToast });
} finally {
savingHours = false;
}
}
async function checkConflictingBookings() {
const proposedHours = defaultHoursDraft.map((h) => ({
weekday: h.weekday,
@@ -274,7 +229,7 @@
}
function getDefaultEffectiveDate(): string {
const d = new Date();
const d = new SvelteDate();
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
}
@@ -394,8 +349,8 @@
// Trigger conflict check when the modal opens or hours/date change
$effect(() => {
const open = showDefaultHoursModal;
const hours = defaultHoursDraft;
const date = effectiveDate;
void defaultHoursDraft;
void effectiveDate;
if (open) {
checkConflictingBookings();
}
@@ -922,7 +877,12 @@
? ''
: 's'}
</span>
<Button variant="outline" size="sm" class="h-6 text-xs ml-auto" onclick={checkConflictingBookings}>
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
onclick={checkConflictingBookings}
>
Refresh
</Button>
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { onMount, onDestroy } from 'svelte';
import { formatTime } from '$lib/utils/timeSlots';
@@ -57,7 +58,10 @@
// may include microsecond precision ("17:00:00.000000") from
// PostgreSQL's ::text cast, while staged hours are plain "17:00".
const norm = (t: string) => t.replace(/\.\d+$/, '').replace(/^(\d{2}:\d{2}).*$/, '$1');
return norm(h.startTime) !== norm(current.startTime) || norm(h.endTime) !== norm(current.endTime);
return (
norm(h.startTime) !== norm(current.startTime) ||
norm(h.endTime) !== norm(current.endTime)
);
})
: []
);
@@ -77,7 +81,7 @@
const today = getLondonDate();
const dayOfWeek = today.getDay(); // 0=Sun
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(today);
const monday = new SvelteDate(today);
monday.setDate(today.getDate() + offset);
return fmtDate(monday);
}
@@ -254,7 +258,7 @@
{#if loading}
<div class="animate-pulse space-y-3">
{#each { length: 7 } as _}
{#each Array.from({ length: 7 }) as _, i (i)}
<div class="flex justify-between">
<div class="h-4 w-20 rounded bg-gray-200"></div>
<div class="h-4 w-24 rounded bg-gray-200"></div>
@@ -308,7 +312,9 @@
{#if scheduledChange && scheduledChangeDays.length > 0}
<hr class="my-2 border-gray-200" />
<p class="mb-2 text-center text-xs font-medium text-amber-600">
{scheduledChangeDays.length === 7 ? 'Opening hours will change from' : 'These opening hours will change from'}
{scheduledChangeDays.length === 7
? 'Opening hours will change from'
: 'These opening hours will change from'}
{new Date(scheduledChange.effective_date + 'T00:00:00').toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
@@ -31,9 +31,16 @@
</script>
{#if showSvg}
<div class="flex h-8 min-w-12 items-center justify-center rounded" role="img" aria-label={brand}>{@html normalizedSvg}</div>
<div class="flex h-8 min-w-12 items-center justify-center rounded" role="img" aria-label={brand}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- SVG strings are hardcoded constants, never user input -->
{@html normalizedSvg}
</div>
{:else}
<div class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium text-gray-700 uppercase" role="img" aria-label={brand}>
<div
class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium text-gray-700 uppercase"
role="img"
aria-label={brand}
>
{brand}
</div>
{/if}
+1 -1
View File
@@ -380,7 +380,7 @@
<div class="space-y-4 p-4">
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} />
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
<WeeklySchedule {defaultHours} {openUserModal} {openBookingModal} {rescheduleVersion} />
<WeeklySchedule {defaultHours} {openUserModal} {openBookingModal} />
</div>
{/if}
</div>
+40 -18
View File
@@ -12,7 +12,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
// Types
@@ -41,11 +41,17 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
let loading = $state(true);
let error = $state<string | null>(null);
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
// Cached idempotency key: generated once per payment attempt, reused on retry
// (so a network-timeout retry dedupes instead of double-charging), cleared on
// success. Reset when the tip amount changes so an amount change after a
// failed attempt gets a fresh key instead of a false dedup (under-charge).
let tipIdempotencyKey = $state('');
let tipKeyedAmount = $state(0);
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
// Card selection state
let savedCards = $state<SavedCard[]>([]);
let loadingCards = $state(false);
let selectedCardId = $state<string | null>(null);
let showNewCardForm = $state(false);
@@ -118,7 +124,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
return expiryYearMonth < currentYearMonth;
})()
);
const hasNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null);
const hasNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const newCardError = $derived(
showNewCardForm || savedCards.length === 0
@@ -132,9 +140,13 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
? '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
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
: newCardNumber.length === 0 &&
newCardExpiry.length === 0 &&
newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
@@ -232,21 +244,18 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
if (savedCardsStore.loaded) {
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
}
return;
}
loadingCards = true;
try {
await savedCardsStore.fetch();
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
}
} catch {
// ignore
} finally {
loadingCards = false;
}
}
@@ -295,7 +304,12 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
// Validate card details for new card payments
if (showNewCardForm || savedCards.length === 0) {
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || isNewCardExpiryPast || newCardCVC.length < 3) {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
isNewCardExpiryPast ||
newCardCVC.length < 3
) {
paymentState = 'idle';
toast.error(newCardError || 'Please enter valid credit card details');
return;
@@ -303,8 +317,15 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
}
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
tipKeyedAmount = tipAmount;
}
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = { amount: amountInPence };
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey
};
if (showNewCardForm || savedCards.length === 0) {
body.new_card_token = newCardNumber.replace(/\s/g, '');
@@ -325,6 +346,8 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
}
paymentState = 'success';
tipIdempotencyKey = '';
tipKeyedAmount = 0;
toast.success('Thank you for your tip!');
} catch (err) {
paymentState = 'error';
@@ -542,16 +565,15 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
<Card.Content>
{#if savedCards.length > 0}
<div class="space-y-3">
<span
class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
Payment Method
</span>
<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 {selectedCardId === card.id && !showNewCardForm
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={() => {
@@ -559,8 +581,8 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
showNewCardForm = false;
}}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<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"
+57 -21
View File
@@ -7,7 +7,7 @@
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -49,9 +49,15 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
let booking = $state<Booking | null>(null);
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
// Cached idempotency key: generated once per payment attempt, reused on retry
// (so a network-timeout retry dedupes instead of double-charging), cleared on
// success. Reset when the tip amount changes so an amount change after a
// failed attempt gets a fresh key instead of a false dedup (under-charge).
let tipIdempotencyKey = $state('');
let tipKeyedAmount = $state(0);
// Card selection state (same pattern as UserPaymentModal)
let savedCards = $state<SavedCard[]>([]);
let loadingCards = $state(false);
let selectedCardId = $state<string | null>(null);
let showNewCardForm = $state(false);
@@ -144,7 +150,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
return expiryYearMonth < currentYearMonth;
})()
);
const hasNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null);
const hasNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const newCardError = $derived(
showNewCardForm || savedCards.length === 0
@@ -158,12 +166,16 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
? '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
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
: newCardNumber.length === 0 &&
newCardExpiry.length === 0 &&
newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
: null
);
const isCardValid = $derived(
@@ -244,7 +256,12 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
// Validate card details for new card payments
if (showNewCardForm || savedCards.length === 0) {
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || isNewCardExpiryPast || newCardCVC.length < 3) {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
isNewCardExpiryPast ||
newCardCVC.length < 3
) {
paymentState = 'idle';
toast.error(newCardError || 'Please enter valid credit card details');
return;
@@ -252,8 +269,15 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
}
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
tipKeyedAmount = tipAmount;
}
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = { amount: amountInPence };
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey
};
if (showNewCardForm || savedCards.length === 0) {
body.new_card_token = newCardNumber.replace(/\s/g, '');
@@ -274,6 +298,8 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
}
paymentState = 'success';
tipIdempotencyKey = '';
tipKeyedAmount = 0;
toast.success('Thank you for your tip!');
} catch (err) {
paymentState = 'error';
@@ -367,21 +393,18 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
if (savedCardsStore.loaded) {
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
}
return;
}
loadingCards = true;
try {
await savedCardsStore.fetch();
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
}
} catch {
// ignore — user can enter new card
} finally {
loadingCards = false;
}
}
</script>
@@ -561,23 +584,31 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
<Card.Root class="mb-6">
<Card.Content class="space-y-4">
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">Payment Method</span>
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>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 {selectedCardId === card.id && !showNewCardForm
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; }}
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<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>
<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}
@@ -591,7 +622,10 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
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 = null; showNewCardForm = true; }}
onclick={() => {
selectedCardId = null;
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
@@ -599,7 +633,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
<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>