From e5c6458ec737deb9364c53c5793522f00bc9ebd8 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 3 Aug 2026 14:57:46 +0100 Subject: [PATCH] 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. --- frontend/.env.example | 15 + .../lib/components/booking/BookingFlow.svelte | 23 +- .../components/payments/CardBrandIcon.svelte | 15 +- .../components/payments/PaymentModal.svelte | 5 +- .../lib/components/payments/TipPayment.svelte | 437 ++++++++++++++++++ frontend/src/routes/login/+page.svelte | 2 +- frontend/src/routes/pay-tip/[id]/+page.svelte | 381 +-------------- frontend/src/routes/terms/+page.svelte | 240 ++++++++++ frontend/src/routes/tip/+page.svelte | 388 +--------------- 9 files changed, 749 insertions(+), 757 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/lib/components/payments/TipPayment.svelte create mode 100644 frontend/src/routes/terms/+page.svelte diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..d2a55f0 --- /dev/null +++ b/frontend/.env.example @@ -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 diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index bbe7598..5114de2 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -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(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(); } }); diff --git a/frontend/src/lib/components/payments/CardBrandIcon.svelte b/frontend/src/lib/components/payments/CardBrandIcon.svelte index 70a1444..e62420c 100644 --- a/frontend/src/lib/components/payments/CardBrandIcon.svelte +++ b/frontend/src/lib/components/payments/CardBrandIcon.svelte @@ -1,6 +1,12 @@ + +{#if paymentState === 'success'} + + +
+ + + +
+

Thank you!

+

Your generosity is greatly appreciated.

+ + +
+
+{:else} + + + Your Appointment + + +
+ Date + {formatDate(booking.start_time)} +
+
+ Time + {formatTimeRange( + booking.start_time, + booking.services, + booking.duration_minutes ?? 0 + )} +
+
+ Subtotal + {formatPrice(subtotal)} +
+ {#if tipsPaid > 0} +
+ Tips + {formatPrice(tipsPaid)} +
+ {/if} + {#if booking.services && booking.services.length > 0} +
+
Services
+
+ {#each booking.services as service (service.service_id || service.booking_id)} +
+ {service.service_name} + {formatPrice(service.override_price ?? service.price)} +
+ {/each} +
+
+ {/if} +
+
+ + + + Choose Tip Amount + + +
+ {#each tipPercentages as tip (tip.pct)} + + {/each} +
+ +
+ +
+ £ + +
+
+
+
+ + + + +
+ Payment Method + + (cardSelectionValid = v)} + /> +
+
+
+ + {#if paymentState === 'error'} +
+

Payment failed. Please try again.

+ +
+ {/if} + + + +

Secure payment powered by Square

+{/if} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index bbdbf57..c855184 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -576,7 +576,7 @@ > and Privacy Policy; }; // State let booking = $state(null); let loading = $state(true); let error = $state(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([]); - let cardSelection = $state(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(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 = { - 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 @@

Show your appreciation for great service

- {#if paymentState === 'success'} - - -
- - - -
-

Thank you!

-

Your generosity is greatly appreciated.

- - -
-
- {:else} - - - Your Appointment - - -
- Date - {formatDate(booking.start_time)} -
-
- Time - {formatTimeRange( - booking.start_time, - booking.services, - booking.duration_minutes ?? 0 - )} -
-
- Paid - {formatPrice(booking.amount_paid ?? 0)} -
-
-
Services
-
- {#each booking.services as service (service.service_id || service.booking_id)} -
- {service.service_name} - {formatPrice(service.override_price ?? service.price)} -
- {/each} -
-
-
-
- - - - Choose Tip Amount - - -
- {#each tipPercentages as tip (tip.pct)} - - {/each} -
- -
- -
- £ - -
-
-
-
- - - - - Payment Method - - - (cardSelectionValid = v)} - /> - - - - {#if paymentState === 'error'} -
-

Payment failed. Please try again.

- -
- {/if} - - - -

Secure payment powered by Square

- {/if} + {/if} diff --git a/frontend/src/routes/terms/+page.svelte b/frontend/src/routes/terms/+page.svelte new file mode 100644 index 0000000..ff5f2ae --- /dev/null +++ b/frontend/src/routes/terms/+page.svelte @@ -0,0 +1,240 @@ + + + + Terms & Conditions + + + +
+
+

Terms & Conditions

+ + DRAFT — for review + +
+

Last updated: June 2026

+ + {#if format === 'pdf' && pdfNotice} +

+ Generating PDF… If the print dialog does not appear, use Ctrl+P / Cmd+P. +

+ {/if} + +
+
+

1. Introduction

+

+ These Terms & Conditions (“Terms”) govern your use of the Crussell booking + platform (“Platform”), accessible via our website and associated mobile + applications. +

+

+ 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. +

+
+

Business Details

+

Trading name: Crussell Salon

+

Registered address: Edinburgh, Scotland

+

Contact email: help@crussell.invalid

+

VAT: Not currently registered (threshold £90,000; will register when reached)

+
+
+ +
+

2. Account Creation & Deletion

+

2.1 Account Eligibility

+
    +
  • You must be at least 16 years old to create an account.
  • +
  • You must provide accurate, current contact information.
  • +
  • You are responsible for maintaining the security of your account.
  • +
+

2.2 Account Deletion

+

You may request account deletion at any time. Upon deletion:

+
    +
  • All personal data will be anonymized or deleted.
  • +
  • Booking history is retained for 7 years (HMRC requirement) then aggregated.
  • +
  • You will lose access to loyalty stamps, referral codes, and booking history.
  • +
+

+ If your account has a balance: your balance becomes dormant and is + transferred to our recovery registry. You will receive your + Account ID by email and can recover your balance at any time by providing it. + All other personal data is anonymized. +

+

+ Warning: 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). +

+

2.3 Inactive Account Policy

+

+ To comply with GDPR storage-limitation principles, inactive accounts are deleted: +

+
    +
  • No balance: after 2 years of inactivity.
  • +
  • + With balance: after 5 years of inactivity (Scottish prescriptive period). +
  • +
+

+ 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. +

+
+ +
+

3. Bookings & Appointments

+
    +
  • Bookings are subject to availability.
  • +
  • You will receive confirmation via email/SMS.
  • +
  • Some services require a deposit (typically 20–50% of the service cost).
  • +
+

Cancellations & rescheduling

+
    +
  • Client cancellation: at least 24 hours before the appointment.
  • +
  • Late cancellation (<24 hours): deposit may be forfeited.
  • +
  • No-show: deposit forfeited; may affect future booking eligibility.
  • +
  • Business cancellation: full refund or reschedule offered.
  • +
+

Deposits

+
    +
  • + Deposits are non-refundable if you cancel less than 24 hours before the appointment. +
  • +
  • Deposits are applied to your final bill.
  • +
  • If we cancel, the deposit is fully refunded.
  • +
+

Service changes

+
    +
  • We reserve the right to refuse service for health or safety reasons.
  • +
  • + Patch tests may be required for certain treatments (allergy records retained 7 years). +
  • +
  • Service prices may change; you will be notified before booking.
  • +
+
+ +
+

4. Payments & Fees

+

Payment methods

+
    +
  • Online card payments (via Square).
  • +
  • In-person card and cash payments.
  • +
  • Gift cards and account balance (from redeemed gift cards).
  • +
+

Payment processing

+
    +
  • Card payments are processed securely via Square.
  • +
  • We do not store full card details.
  • +
  • + Refunds are processed to the original payment method within 5–10 business days. +
  • +
  • + Saving a card for next time 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”. +
  • +
+

Split payments

+
    +
  • You may split payment across multiple methods (e.g. gift card + cash).
  • +
  • Each payment method is processed separately.
  • +
  • Refunds apply proportionally to each payment method.
  • +
+
+ +
+

+ 5. Gift Cards & Account Balances +

+

Gift card expiry

+
    +
  • Gift cards expire 24 months after last use (rolling expiry).
  • +
  • + “Last use” includes redemption, top-up, balance check, or any admin action. +
  • +
  • The expiry date is displayed on the gift card and in your account.
  • +
+

Account balances

+
    +
  • Once redeemed to your account, the balance does not expire.
  • +
  • However, your account may be deleted after 5 years of inactivity.
  • +
  • + If an account is deleted with a balance, the funds become dormant but recoverable with the + Account ID. +
  • +
+

+ VAT treatment: 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). +

+
+ +
+

Appendix: Statutory Timeframes

+
    +
  • + HMRC Corporation Tax records: 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. +
  • +
  • + Scottish Contract Claims prescriptive period: 5 years (Prescription and Limitation + (Scotland) Act 1973 s.6). Accounts with remaining balances must remain active for at least 5 + years. +
  • +
  • + GDPR Storage Limitation: 2 years of inactivity for accounts with no balance. +
  • +
+

+ Questions about these Terms? Please use our official + Contact Channels + to get in touch. +

+
+
+
diff --git a/frontend/src/routes/tip/+page.svelte b/frontend/src/routes/tip/+page.svelte index 93a33d3..2887db4 100644 --- a/frontend/src/routes/tip/+page.svelte +++ b/frontend/src/routes/tip/+page.svelte @@ -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(null); let booking = $state(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([]); - let cardSelection = $state(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(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 = { - 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 - } - } @@ -401,153 +180,6 @@

Show your appreciation for great service

- {#if paymentState === 'success'} - - -
- - - -
-

Thank you!

-

Your generosity is greatly appreciated.

- - -
-
- {:else} - - - Your last Appointment - - -
- Date - {formatDate(booking.start_time)} -
-
- Time - {formatTimeRange(booking.start_time, booking.duration_minutes ?? 0)} -
-
- Subtotal - {formatPrice(subtotal)} -
- {#if tipsPaid > 0} -
- Tips - {formatPrice(tipsPaid)} -
- {/if} - {#if booking.services && booking.services.length > 0} -
-
Services
-
- {#each booking.services as service (service.service_id || service.booking_id)} -
- {service.service_name} - {formatPrice(service.override_price ?? service.price)} -
- {/each} -
-
- {/if} -
-
- - - - Choose Tip Amount - - -
- {#each tipPercentages as tip (tip.pct)} - - {/each} -
- -
- -
- £ - -
-
-
-
- - - - -
- Payment Method - - (cardSelectionValid = v)} - /> -
-
-
- - {#if paymentState === 'error'} -
-

Payment failed. Please try again.

- -
- {/if} - - - -

Secure payment powered by Square

- {/if} + {/if}