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:
2026-08-22 00:34:49 +01:00
parent 738f6b6a51
commit e5c6458ec7
9 changed files with 749 additions and 757 deletions
+15
View File
@@ -0,0 +1,15 @@
VITE_BACKEND_URL=http://localhost:8080
# Square Web Payments SDK — local dev runs the built-in frontend mock:
# VITE_SQUARE_ENVIRONMENT=mock makes SquareCardInput render a plain HTML card
# form and tokenize() return the deterministic cnon: tokens the backend dev
# mock (SQUARE_ENVIRONMENT=mock) accepts — a full end-to-end walkthrough with
# zero real Square credentials. Card data stays in local component state;
# the backend still only ever receives cnon: tokens.
#
# For real sandbox/production testing, uncomment the two IDs below and set
# VITE_SQUARE_ENVIRONMENT=sandbox|production. NEVER set 'mock' in a deployed
# (non-local) build.
# VITE_SQUARE_APPLICATION_ID=sandbox-sq0idb-xxxx
# VITE_SQUARE_LOCATION_ID=xxxx
VITE_SQUARE_ENVIRONMENT=mock
@@ -95,6 +95,12 @@
}> }>
>([]); >([]);
let paymentMethodsLoading = $state(false); let paymentMethodsLoading = $state(false);
// Once-per-payment-step fetch guard. Without it, the effect below re-runs on
// every state change and re-assigns a FRESH paymentMethods array (even an
// empty one), which re-triggers the effect → infinite refetch loop when the
// user has zero saved cards. Set synchronously BEFORE the await so re-entry
// is impossible even while the request is in flight.
let paymentMethodsFetched = $state(false);
let selectedPaymentMethod = $state(''); let selectedPaymentMethod = $state('');
let paymentCardSelection = $state<CardSelection | null>(null); let paymentCardSelection = $state<CardSelection | null>(null);
let paymentCardSelectionValid = $state(false); let paymentCardSelectionValid = $state(false);
@@ -448,9 +454,22 @@
} }
}); });
// Fetch saved cards when the deposit payment step is shown. // Fetch saved cards when the deposit payment step is shown. The
// paymentMethodsFetched guard makes this run exactly ONCE per mount: the
// effect body re-runs on unrelated state changes, but the guard short-
// circuits before fetchPaymentMethods() can read/write any tracked state,
// so no fetch can be triggered by the empty-array assignment (the root
// cause of the infinite refetch loop for users with zero saved cards).
// Once-per-mount is acceptable — a user who navigates away and back keeps
// the already-loaded card list.
$effect(() => { $effect(() => {
if (currentStep === finalStep && depositRequired && authStore.isAuthenticated) { if (
currentStep === finalStep &&
depositRequired &&
authStore.isAuthenticated &&
!paymentMethodsFetched
) {
paymentMethodsFetched = true;
fetchPaymentMethods(); fetchPaymentMethods();
} }
}); });
@@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
let { brand = '' }: { brand?: string } = $props(); let { brand = '' }: { brand?: string } = $props();
// Square's card_brand enum uses DISCOVER_DINERS and CHINA_UNIONPAY — the
// historical DINERS_CLUB / UNIONPAY / CHINA_UNION_PAY keys are kept as
// aliases in case anything else in the app passes those spellings.
const dinersClubSvg = `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#004A98"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="9">DC</text></svg>`;
const unionPaySvg = `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#D7001E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">UNION</text></svg>`;
const brandSvgs: Record<string, string> = { const brandSvgs: Record<string, string> = {
VISA: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#1A1F71"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="11">VISA</text></svg>`, VISA: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#1A1F71"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="11">VISA</text></svg>`,
@@ -10,15 +16,16 @@
DISCOVER: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#000"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">DISCOVER</text></svg>`, DISCOVER: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#000"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">DISCOVER</text></svg>`,
DINERS_CLUB: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#004A98"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="9">DC</text></svg>`, DINERS_CLUB: dinersClubSvg,
DISCOVER_DINERS: dinersClubSvg,
JCB: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#0D4A2E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="10">JCB</text></svg>`, JCB: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#0D4A2E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="10">JCB</text></svg>`,
SQUARE_GIFT_CARD: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#E8F5E9"/><rect x="1" y="1" width="58" height="22" rx="2" stroke="#4CAF50" stroke-width="0.5" stroke-dasharray="2 1"/><text x="30" y="16" text-anchor="middle" fill="#2E7D32" font-family="Arial, sans-serif" font-weight="bold" font-size="7">GIFT</text></svg>`, SQUARE_GIFT_CARD: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#E8F5E9"/><rect x="1" y="1" width="58" height="22" rx="2" stroke="#4CAF50" stroke-width="0.5" stroke-dasharray="2 1"/><text x="30" y="16" text-anchor="middle" fill="#2E7D32" font-family="Arial, sans-serif" font-weight="bold" font-size="7">GIFT</text></svg>`,
CHINA_UNION_PAY: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#D7001E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">UNION</text></svg>`, CHINA_UNION_PAY: unionPaySvg,
UNIONPAY: unionPaySvg,
UNIONPAY: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#D7001E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">UNION</text></svg>`, CHINA_UNIONPAY: unionPaySvg,
INTERAC: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#074CA1"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="8">INTERAC</text></svg>`, INTERAC: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#074CA1"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="8">INTERAC</text></svg>`,
@@ -332,7 +332,10 @@
checkout_id: checkoutId!, checkout_id: checkoutId!,
status: data.status, status: data.status,
card_brand: data.card_brand, card_brand: data.card_brand,
last4: data.last4, // The API serializes the last-four as card_last4 (see
// PaymentStatusResponse); the success screen reads
// paymentResult.last4, so keep the local field name.
last4: data.card_last4,
amount: data.amount amount: data.amount
}; };
toast.success('Payment successful'); toast.success('Payment successful');
@@ -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}
+1 -1
View File
@@ -576,7 +576,7 @@
> >
and and
<a <a
href="/privacy" href="/privacy-policy"
class="font-semibold text-primary hover:underline" class="font-semibold text-primary hover:underline"
target="_blank" target="_blank"
rel="noopener noreferrer external">Privacy Policy</a rel="noopener noreferrer external">Privacy Policy</a
+10 -371
View File
@@ -4,15 +4,10 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
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 { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
import CardSelection from '$lib/components/payments/CardSelection.svelte'; import TipPayment from '$lib/components/payments/TipPayment.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
// Types // Types
type Service = { type Service = {
@@ -33,106 +28,25 @@
total_amount: number; total_amount: number;
amount_paid: number; amount_paid: number;
duration_minutes: number; duration_minutes: number;
payments?: Array<{
id: string;
payment_type: string;
payment_method: string;
status: string;
amount: number;
created_at: string;
}>;
}; };
// State // State
let booking = $state<Booking | null>(null); let booking = $state<Booking | null>(null);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); 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'); let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
// Card selection — delegated to CardSelection.svelte.
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);
// Tip selection state
let selectedTip = $state<number | null>(null);
let customTip = $state('');
const tipAmount = $derived(
selectedTip !== null ? selectedTip : customTip ? parseFloat(customTip) || 0 : 0
);
// Get booking ID from URL // Get booking ID from URL
const bookingId = $derived($page.params.id); const bookingId = $derived($page.params.id);
const tipPercentages = $derived.by(() => {
const total = booking?.total_amount ?? 0;
if (total <= 0) return [];
return [
{ pct: 10, amount: Math.round(total * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(total * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(total * 0.2 * 100) / 100 }
];
});
// Format functions
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
return date.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function formatTimeRange(
startStr: string,
services: Service[],
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)}`;
}
// Fetch booking data // Fetch booking data
async function fetchBookingData() { async function fetchBookingData() {
loading = true; loading = true;
@@ -159,140 +73,6 @@
} }
} }
// Load saved cards
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
}
}
// Handle tip selection
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;
}
// Submit tip payment
async function submitTip() {
if (!booking) return;
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/${bookingId}/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);
}
}
// Reset and retry
function retryPayment() {
paymentState = 'idle';
}
// Auth check + fetch // Auth check + fetch
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
@@ -317,7 +97,6 @@
} }
pageState = 'authorized'; pageState = 'authorized';
loadSavedCards();
if (bookingId) { if (bookingId) {
fetchBookingData(); fetchBookingData();
} }
@@ -384,146 +163,6 @@
<p class="mt-1 text-gray-600">Show your appreciation for great service</p> <p class="mt-1 text-gray-600">Show your appreciation for great service</p>
</div> </div>
{#if paymentState === 'success'} <TipPayment {booking} />
<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">Paid</span>
<span class="font-medium">{formatPrice(booking.amount_paid ?? 0)}</span>
</div>
<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>
</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.Header>
<Card.Title>Payment Method</Card.Title>
</Card.Header>
<Card.Content>
<CardSelection
bind:this={cardSelection}
cards={savedCards}
{canSaveCards}
bind:selectedCardId
bind:saveCard
onValidityChange={(v) => (cardSelectionValid = v)}
/>
</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}
{/if} {/if}
</div> </div>
+240
View File
@@ -0,0 +1,240 @@
<script lang="ts">
import { page } from '$app/stores';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
let format = $state('html');
let pdfNotice = $state(true);
onMount(() => {
format = $page.url.searchParams.get('format') || 'html';
if (format === 'pdf') {
// Strip ?format=pdf from the URL so a refresh doesn't re-trigger the print dialog.
const clean = window.location.pathname + window.location.hash;
history.replaceState(null, '', clean);
// Open the print dialog once the page is rendered.
// The notice and draft badges are removed before print so they won't appear in the PDF.
setTimeout(() => {
pdfNotice = false;
// Small delay so Svelte can remove the element before the print engine snapshots.
setTimeout(() => window.print(), 50);
}, 100);
}
});
</script>
<svelte:head>
<title>Terms &amp; Conditions</title>
<style>
@media print {
:global(nav),
:global(.no-print) {
display: none !important;
}
:global(body) {
padding-top: 0 !important;
}
}
</style>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8 text-gray-900">
<div class="mb-2 flex items-center gap-3">
<h1 class="border-b border-gray-200 pb-4 text-2xl font-bold">Terms &amp; Conditions</h1>
<span
class="no-print shrink-0 rounded-full border border-amber-300 bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-800"
>
DRAFT &mdash; for review
</span>
</div>
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: June 2026</p>
{#if format === 'pdf' && pdfNotice}
<p class="mb-6 rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 italic">
Generating PDF&hellip; If the print dialog does not appear, use Ctrl+P / Cmd+P.
</p>
{/if}
<div class="space-y-8 text-sm leading-relaxed text-gray-700">
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">1. Introduction</h2>
<p class="mb-3">
These Terms &amp; Conditions (&ldquo;Terms&rdquo;) govern your use of the Crussell booking
platform (&ldquo;Platform&rdquo;), accessible via our website and associated mobile
applications.
</p>
<p class="mb-3">
By creating an account or making a booking through our Platform, you agree to be bound by
these Terms. If you do not agree, please do not use our services.
</p>
<div class="rounded-md border border-gray-200 bg-gray-50/50 p-4 text-xs text-gray-600">
<p class="font-semibold text-gray-900">Business Details</p>
<p class="mt-1">Trading name: Crussell Salon</p>
<p>Registered address: Edinburgh, Scotland</p>
<p>Contact email: help@crussell.invalid</p>
<p>VAT: Not currently registered (threshold &pound;90,000; will register when reached)</p>
</div>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">2. Account Creation &amp; Deletion</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.1 Account Eligibility</h3>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>You must be at least 16 years old to create an account.</li>
<li>You must provide accurate, current contact information.</li>
<li>You are responsible for maintaining the security of your account.</li>
</ul>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.2 Account Deletion</h3>
<p class="mb-2">You may request account deletion at any time. Upon deletion:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>All personal data will be anonymized or deleted.</li>
<li>Booking history is retained for 7 years (HMRC requirement) then aggregated.</li>
<li>You will lose access to loyalty stamps, referral codes, and booking history.</li>
</ul>
<p class="mb-3">
<strong>If your account has a balance:</strong> your balance becomes dormant and is
transferred to our recovery registry. You will receive your
<strong>Account ID</strong> by email and can recover your balance at any time by providing it.
All other personal data is anonymized.
</p>
<p class="mb-3">
<strong>Warning:</strong> account deletion is permanent. You will lose all booking history, treatment
notes, allergy and patch test records, loyalty stamps and referral codes, and access to your account
balance (unless you retain your Account ID).
</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Inactive Account Policy</h3>
<p class="mb-2">
To comply with GDPR storage-limitation principles, inactive accounts are deleted:
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li><strong>No balance:</strong> after 2 years of inactivity.</li>
<li>
<strong>With balance:</strong> after 5 years of inactivity (Scottish prescriptive period).
</li>
</ul>
<p class="mb-3">
Warning emails are sent before deletion (18 months and 23 months for no-balance accounts; 4
years and 59 months for accounts with a balance). All warning emails include your Account ID
for future balance recovery.
</p>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Bookings &amp; Appointments</h2>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Bookings are subject to availability.</li>
<li>You will receive confirmation via email/SMS.</li>
<li>Some services require a deposit (typically 20&ndash;50% of the service cost).</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Cancellations &amp; rescheduling</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li><strong>Client cancellation:</strong> at least 24 hours before the appointment.</li>
<li><strong>Late cancellation (&lt;24 hours):</strong> deposit may be forfeited.</li>
<li><strong>No-show:</strong> deposit forfeited; may affect future booking eligibility.</li>
<li><strong>Business cancellation:</strong> full refund or reschedule offered.</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Deposits</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
Deposits are non-refundable if you cancel less than 24 hours before the appointment.
</li>
<li>Deposits are applied to your final bill.</li>
<li>If we cancel, the deposit is fully refunded.</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Service changes</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>We reserve the right to refuse service for health or safety reasons.</li>
<li>
Patch tests may be required for certain treatments (allergy records retained 7 years).
</li>
<li>Service prices may change; you will be notified before booking.</li>
</ul>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">4. Payments &amp; Fees</h2>
<p class="mb-2 font-medium text-gray-800">Payment methods</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Online card payments (via Square).</li>
<li>In-person card and cash payments.</li>
<li>Gift cards and account balance (from redeemed gift cards).</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Payment processing</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Card payments are processed securely via Square.</li>
<li>We do not store full card details.</li>
<li>
Refunds are processed to the original payment method within 5&ndash;10 business days.
</li>
<li>
<strong>Saving a card for next time</strong> stores a tokenised reference with our payment provider,
Square. You can remove saved cards at any time from your account. Cards are only stored when
you explicitly tick &ldquo;save this card&rdquo;.
</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Split payments</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>You may split payment across multiple methods (e.g. gift card + cash).</li>
<li>Each payment method is processed separately.</li>
<li>Refunds apply proportionally to each payment method.</li>
</ul>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
5. Gift Cards &amp; Account Balances
</h2>
<p class="mb-2 font-medium text-gray-800">Gift card expiry</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Gift cards expire 24 months after last use (rolling expiry).</li>
<li>
&ldquo;Last use&rdquo; includes redemption, top-up, balance check, or any admin action.
</li>
<li>The expiry date is displayed on the gift card and in your account.</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Account balances</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Once redeemed to your account, the balance does not expire.</li>
<li>However, your account may be deleted after 5 years of inactivity.</li>
<li>
If an account is deleted with a balance, the funds become dormant but recoverable with the
Account ID.
</li>
</ul>
<p class="mb-3">
<strong>VAT treatment:</strong> gift cards are Single-Purpose Vouchers (SPVs) under UK VAT law.
VAT is charged at the point of gift card purchase, not at redemption. When you pay with gift card
balance, no additional VAT is charged (it has already been paid).
</p>
</section>
<section class="border-t border-gray-200 pt-6">
<h2 class="mb-3 text-base font-semibold text-gray-900">Appendix: Statutory Timeframes</h2>
<ul class="mb-4 list-disc space-y-1 pl-5">
<li>
<strong>HMRC Corporation Tax records:</strong> 6 years from the end of the financial year (HMRC
CH14600 / Companies Act 2006 s.388). Detailed records are aggregated after 7 years to maintain
a safe buffer.
</li>
<li>
<strong>Scottish Contract Claims prescriptive period:</strong> 5 years (Prescription and Limitation
(Scotland) Act 1973 s.6). Accounts with remaining balances must remain active for at least 5
years.
</li>
<li>
<strong>GDPR Storage Limitation:</strong> 2 years of inactivity for accounts with no balance.
</li>
</ul>
<p class="text-xs text-gray-500">
Questions about these Terms? Please use our official
<a
href={resolve('/contact')}
class="font-medium text-blue-600 underline hover:text-blue-800">Contact Channels</a
>
to get in touch.
</p>
</section>
</div>
</div>
+10 -378
View File
@@ -4,14 +4,10 @@
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';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
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 { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import TipPayment from '$lib/components/payments/TipPayment.svelte';
type BookingService = { type BookingService = {
service_id: string; service_id: string;
@@ -23,15 +19,6 @@
override_duration_minutes?: number; override_duration_minutes?: number;
}; };
type Payment = {
id: string;
payment_type: string;
payment_method: string;
status: string;
amount: number;
created_at: string;
};
type Booking = { type Booking = {
id: string; id: string;
start_time: string; start_time: string;
@@ -40,207 +27,19 @@
total_amount: number; total_amount: number;
amount_paid: number; amount_paid: number;
duration_minutes: number; duration_minutes: number;
payments?: Payment[]; payments?: Array<{
id: string;
payment_type: string;
payment_method: string;
status: string;
amount: number;
created_at: string;
}>;
}; };
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let booking = $state<Booking | null>(null); 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 — 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
);
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'
});
}
function formatTimeRange(startStr: string, durationMinutes: number): string {
const start = new SvelteDate(startStr);
const end = new SvelteDate(start.getTime() + durationMinutes * 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 submitTip() {
if (!booking) return;
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';
}
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
@@ -261,7 +60,6 @@
return; return;
} }
loadSavedCards();
fetchMostRecentBooking(); fetchMostRecentBooking();
}); });
@@ -318,25 +116,6 @@
loading = false; loading = false;
} }
} }
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
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -401,153 +180,6 @@
<p class="mt-1 text-gray-600">Show your appreciation for great service</p> <p class="mt-1 text-gray-600">Show your appreciation for great service</p>
</div> </div>
{#if paymentState === 'success'} <TipPayment {booking} />
<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 last 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.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}
{/if} {/if}
</div> </div>