Fix frontend payment flows: BookingFlow fetch loop, shared TipPayment, card icons, terms route
Fix the P0 infinite refetch in BookingFlow (payment-methods fetched once via a guard flag, was looping on empty saved-card arrays and DoS-ing the rate limiter). Extract the shared TipPayment component so tip and pay-tip routes no longer drift; reconcile formatTimeRange override_duration_minutes and subtotal/tipsPaid. CardBrandIcon gains the correct Square enum keys (DISCOVER_DINERS, CHINA_UNIONPAY). PaymentModal reads card_last4. Login links resolve to the new /terms and /privacy-policy routes. Add frontend/.env.example.
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Shared tip-payment UI used by /tip and /pay-tip/[id]. The routes resolve
|
||||
// the booking (most-recent past booking vs. booking by URL id) and hand it
|
||||
// here; everything else — tip selection, CardSelection wiring, nonce + SCA
|
||||
// verification caching, idempotency-key derivation, submitTip, success and
|
||||
// error handling — lives in ONE place so the two pages can't diverge.
|
||||
type BookingService = {
|
||||
service_id: string;
|
||||
booking_id: string;
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
override_price?: number;
|
||||
override_duration_minutes?: number;
|
||||
};
|
||||
|
||||
type Payment = {
|
||||
id: string;
|
||||
payment_type: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
services: BookingService[];
|
||||
total_amount: number;
|
||||
duration_minutes: number;
|
||||
payments?: Payment[];
|
||||
};
|
||||
|
||||
const { booking }: { booking: Booking } = $props();
|
||||
|
||||
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 — delegated to CardSelection.svelte (saved-card list,
|
||||
// "Use a new card" toggle, SquareCardInput tokenization, consent checkbox).
|
||||
let savedCards = $state<SavedCard[]>([]);
|
||||
let cardSelection = $state<CardSelection | null>(null);
|
||||
let selectedCardId = $state('');
|
||||
let cardSelectionValid = $state(false);
|
||||
let saveCard = $state(false);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let tipNonce = $state('');
|
||||
// Cached SCA verification token paired with tipNonce (both one-shot, reused
|
||||
// together on retry). The verification token is amount-bound, so changing
|
||||
// the tip invalidates the cached pair.
|
||||
let tipVerificationToken = $state('');
|
||||
let tipTokenAmount = $state(0);
|
||||
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
|
||||
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||
// on late retries and re-tokenized instead of rejected by Square.
|
||||
let tipTokenizedAt = $state(0);
|
||||
|
||||
const canSaveCards = $derived(
|
||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||
);
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
|
||||
let selectedTip = $state<number | null>(null);
|
||||
let customTip = $state('');
|
||||
const tipAmount = $derived(
|
||||
selectedTip !== null ? selectedTip : customTip ? parseFloat(customTip) || 0 : 0
|
||||
);
|
||||
|
||||
// Sum of completed tip payments already made against this booking. Both
|
||||
// routes' responses carry `payments` (the booking-detail endpoint), so this
|
||||
// is computed identically everywhere.
|
||||
const tipsPaid = $derived(
|
||||
booking.payments
|
||||
?.filter((p) => p.status === 'completed' && p.payment_type === 'tip')
|
||||
.reduce((sum, p) => sum + p.amount, 0) ?? 0
|
||||
);
|
||||
|
||||
const subtotal = $derived(booking.total_amount ?? 0);
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
if (subtotal <= 0) return [];
|
||||
return [
|
||||
{ pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 }
|
||||
];
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Honours per-service override_duration_minutes when computing the end time,
|
||||
// falling back to the booking's duration_minutes (reconciled from the
|
||||
// pay-tip route, which is the correct behaviour when durations are edited).
|
||||
function formatTimeRange(
|
||||
startStr: string,
|
||||
services: BookingService[],
|
||||
fallbackDuration: number
|
||||
): string {
|
||||
const start = new SvelteDate(startStr);
|
||||
const totalMinutes =
|
||||
services?.reduce(
|
||||
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
|
||||
0
|
||||
) ??
|
||||
fallbackDuration ??
|
||||
0;
|
||||
const end = new SvelteDate(start.getTime() + totalMinutes * 60000);
|
||||
|
||||
const formatOpt: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
};
|
||||
|
||||
return `${start.toLocaleTimeString('en-GB', formatOpt)} – ${end.toLocaleTimeString('en-GB', formatOpt)}`;
|
||||
}
|
||||
|
||||
function formatPrice(pounds: number): string {
|
||||
return `£${pounds.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function selectTip(amount: number) {
|
||||
selectedTip = amount;
|
||||
customTip = '';
|
||||
}
|
||||
|
||||
function handleCustomTipInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const cleaned = input.value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
customTip = sanitized;
|
||||
}
|
||||
selectedTip = null;
|
||||
}
|
||||
|
||||
async function loadSavedCards() {
|
||||
if (savedCardsStore.loaded) {
|
||||
savedCards = savedCardsStore.cards;
|
||||
if (savedCards.length > 0 && !selectedCardId) {
|
||||
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await savedCardsStore.fetch();
|
||||
savedCards = savedCardsStore.cards;
|
||||
if (savedCards.length > 0 && !selectedCardId) {
|
||||
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
|
||||
}
|
||||
} catch {
|
||||
// ignore — user can enter new card
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSavedCards();
|
||||
});
|
||||
|
||||
async function submitTip() {
|
||||
if (tipAmount <= 0) {
|
||||
toast.error('Please select a tip amount');
|
||||
return;
|
||||
}
|
||||
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
if (selectedCardId) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (cardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||
// verification token on retry (tokenization is one-shot; the backend
|
||||
// idempotency key dedups). The verification token is amount-bound, so
|
||||
// a changed tip amount forces a fresh tokenization.
|
||||
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
Math.round(tipAmount * 100),
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
}
|
||||
);
|
||||
tipNonce = tokenized.nonce;
|
||||
tipVerificationToken = tokenized.verificationToken ?? '';
|
||||
tipTokenAmount = tipAmount;
|
||||
tipTokenizedAt = Date.now();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
newCardToken = tipNonce;
|
||||
verificationToken = tipVerificationToken || undefined;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
paymentState = 'processing';
|
||||
|
||||
try {
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
|
||||
tipIdempotencyKey = crypto.randomUUID();
|
||||
tipKeyedAmount = tipAmount;
|
||||
}
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
const body: Record<string, unknown> = {
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
}
|
||||
|
||||
paymentState = 'success';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
toast.success('Thank you for your tip!');
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
const errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function retryPayment() {
|
||||
paymentState = 'idle';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if paymentState === 'success'}
|
||||
<Card.Root>
|
||||
<Card.Content class="py-8 text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8 text-green-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">Thank you!</h2>
|
||||
<p class="mt-2 text-gray-600">Your generosity is greatly appreciated.</p>
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<Button class="mt-6" onclick={() => goto('/')}>Go Home</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Your Appointment</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Date</span>
|
||||
<span class="font-medium">{formatDate(booking.start_time)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Time</span>
|
||||
<span class="font-medium"
|
||||
>{formatTimeRange(
|
||||
booking.start_time,
|
||||
booking.services,
|
||||
booking.duration_minutes ?? 0
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Subtotal</span>
|
||||
<span class="font-medium">{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
{#if tipsPaid > 0}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Tips</span>
|
||||
<span class="font-medium">{formatPrice(tipsPaid)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if booking.services && booking.services.length > 0}
|
||||
<div class="border-t pt-3">
|
||||
<div class="text-sm text-gray-500">Services</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each booking.services as service (service.service_id || service.booking_id)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700">{service.service_name}</span>
|
||||
<span class="text-gray-500"
|
||||
>{formatPrice(service.override_price ?? service.price)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Tip Amount</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each tipPercentages as tip (tip.pct)}
|
||||
<button
|
||||
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTip ===
|
||||
tip.amount
|
||||
? 'bg-fuchsia-100'
|
||||
: ''}"
|
||||
onclick={() => selectTip(tip.amount)}
|
||||
type="button"
|
||||
>
|
||||
<div>{formatPrice(tip.amount)}</div>
|
||||
<div class="text-xs font-normal text-gray-500">{tip.pct}%</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="custom-tip" class="text-sm font-medium text-gray-700"
|
||||
>Or enter custom amount</label
|
||||
>
|
||||
<div class="relative mt-1">
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
|
||||
<Input
|
||||
id="custom-tip"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
class="pl-7"
|
||||
value={customTip}
|
||||
oninput={handleCustomTipInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Payment Method -->
|
||||
<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
|
||||
>
|
||||
|
||||
<CardSelection
|
||||
bind:this={cardSelection}
|
||||
cards={savedCards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId
|
||||
bind:saveCard
|
||||
onValidityChange={(v) => (cardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if paymentState === 'error'}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p class="text-red-700">Payment failed. Please try again.</p>
|
||||
<Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
size="lg"
|
||||
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing'}
|
||||
loading={paymentState === 'processing'}
|
||||
onclick={submitTip}
|
||||
>
|
||||
{paymentState === 'processing' ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
|
||||
</Button>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user