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:
@@ -95,6 +95,12 @@
|
||||
}>
|
||||
>([]);
|
||||
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 paymentCardSelection = $state<CardSelection | null>(null);
|
||||
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(() => {
|
||||
if (currentStep === finalStep && depositRequired && authStore.isAuthenticated) {
|
||||
if (
|
||||
currentStep === finalStep &&
|
||||
depositRequired &&
|
||||
authStore.isAuthenticated &&
|
||||
!paymentMethodsFetched
|
||||
) {
|
||||
paymentMethodsFetched = true;
|
||||
fetchPaymentMethods();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
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> = {
|
||||
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>`,
|
||||
|
||||
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>`,
|
||||
|
||||
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>`,
|
||||
|
||||
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_UNION_PAY: unionPaySvg,
|
||||
UNIONPAY: unionPaySvg,
|
||||
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>`,
|
||||
|
||||
|
||||
@@ -332,7 +332,10 @@
|
||||
checkout_id: checkoutId!,
|
||||
status: data.status,
|
||||
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
|
||||
};
|
||||
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}
|
||||
@@ -576,7 +576,7 @@
|
||||
>
|
||||
and
|
||||
<a
|
||||
href="/privacy"
|
||||
href="/privacy-policy"
|
||||
class="font-semibold text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer external">Privacy Policy</a
|
||||
|
||||
@@ -4,15 +4,10 @@
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
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 { apiFetch } from '$lib/utils/api';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import TipPayment from '$lib/components/payments/TipPayment.svelte';
|
||||
|
||||
// Types
|
||||
type Service = {
|
||||
@@ -33,106 +28,25 @@
|
||||
total_amount: number;
|
||||
amount_paid: number;
|
||||
duration_minutes: number;
|
||||
payments?: Array<{
|
||||
id: string;
|
||||
payment_type: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// State
|
||||
let booking = $state<Booking | null>(null);
|
||||
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 — 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
|
||||
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
|
||||
async function fetchBookingData() {
|
||||
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
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
@@ -317,7 +97,6 @@
|
||||
}
|
||||
|
||||
pageState = 'authorized';
|
||||
loadSavedCards();
|
||||
if (bookingId) {
|
||||
fetchBookingData();
|
||||
}
|
||||
@@ -384,146 +163,6 @@
|
||||
<p class="mt-1 text-gray-600">Show your appreciation for great service</p>
|
||||
</div>
|
||||
|
||||
{#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">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}
|
||||
<TipPayment {booking} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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 & 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 & 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 — 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… 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 & Conditions (“Terms”) govern your use of the Crussell booking
|
||||
platform (“Platform”), 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 £90,000; will register when reached)</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">2. Account Creation & 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 & 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–50% of the service cost).</li>
|
||||
</ul>
|
||||
<p class="mb-2 font-medium text-gray-800">Cancellations & 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 (<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 & 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–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 “save this card”.
|
||||
</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 & 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>
|
||||
“Last use” 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>
|
||||
@@ -4,14 +4,10 @@
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
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 { Input } from '$lib/components/ui/input';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import TipPayment from '$lib/components/payments/TipPayment.svelte';
|
||||
|
||||
type BookingService = {
|
||||
service_id: string;
|
||||
@@ -23,15 +19,6 @@
|
||||
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;
|
||||
@@ -40,207 +27,19 @@
|
||||
total_amount: number;
|
||||
amount_paid: 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 error = $state<string | 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(() => {
|
||||
if (!browser) return;
|
||||
@@ -261,7 +60,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
loadSavedCards();
|
||||
fetchMostRecentBooking();
|
||||
});
|
||||
|
||||
@@ -318,25 +116,6 @@
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -401,153 +180,6 @@
|
||||
<p class="mt-1 text-gray-600">Show your appreciation for great service</p>
|
||||
</div>
|
||||
|
||||
{#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 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}
|
||||
<TipPayment {booking} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user