Backend: - Fix refund idempotency key: clock.Now() → deterministic (pr.ID + amount) - Fix ValidateCardInfo: enforce mutual exclusivity, handle empty strings symmetrically - Fix paymentFromSquare brand fallback (remove dead SourceType fallback) - Fix URL encoding: PathEscape → QueryEscape for customer_id query param - Fix BuyerEmail: log warning on DB error instead of silent discard - Fix idempotency key in createCardOnFileHTTP: time.Now() → deterministic hex hash - Add BuyerEmail to CreateTipPayment Square request - Move realBaseURL from shared file to square_dev.go (only used in dev) - Add TestTipPayment_WithSavedCard test (card_id path coverage) - Fix AMEX brand in mock (AMEX → AMERICAN_EXPRESS, fix test) Frontend: - Fix off-by-month expiry bug in ALL 8 files using year-month arithmetic (parseExpiryParts returns 1-indexed, SvelteDate expects 0-indexed) Files: tip/+page, pay-tip/[id], UserBookingModal, UserPaymentModal, BookingFlow, account/+page (add card + buy gift card sections) - Remove card_expiry/card_cvc from tip request bodies (backend has no fields) Docs: - Mark P9 (placeholder tokens) as completed, add P11 (Square Web Payments SDK) - Mark T13 (rune arithmetic) as completed
657 lines
20 KiB
Svelte
657 lines
20 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { browser } from '$app/environment';
|
||
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 CardInput from '$lib/components/payments/CardInput.svelte';
|
||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||
|
||
// Types
|
||
type Service = {
|
||
service_id: string;
|
||
booking_id: string;
|
||
service_name: string;
|
||
price: number;
|
||
duration_minutes: number;
|
||
override_price?: number;
|
||
override_duration_minutes?: number;
|
||
};
|
||
|
||
type Booking = {
|
||
id: string;
|
||
start_time: string;
|
||
status: string;
|
||
services: Service[];
|
||
total_amount: number;
|
||
amount_paid: number;
|
||
duration_minutes: number;
|
||
};
|
||
|
||
// 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');
|
||
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
|
||
|
||
// Card selection state
|
||
let savedCards = $state<SavedCard[]>([]);
|
||
let loadingCards = $state(false);
|
||
let selectedCardId = $state<string | null>(null);
|
||
let showNewCardForm = $state(false);
|
||
|
||
// New card form state
|
||
let newCardNumber = $state('');
|
||
let newCardExpiry = $state('');
|
||
let newCardCVC = $state('');
|
||
let saveCardForFuture = $state(false);
|
||
let cardNumberTouched = $state(false);
|
||
let cardExpiryTouched = $state(false);
|
||
let cardCVCTouched = $state(false);
|
||
|
||
// 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 }
|
||
];
|
||
});
|
||
|
||
const canSaveCards = $derived(
|
||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||
);
|
||
|
||
// Card validation (matching UserPaymentModal pattern)
|
||
function isValidLuhn(cardNumber: string): boolean {
|
||
const s = cardNumber.replace(/\D/g, '');
|
||
let sum = 0;
|
||
let alternate = false;
|
||
for (let i = s.length - 1; i >= 0; i--) {
|
||
let n = parseInt(s[i], 10);
|
||
if (alternate) {
|
||
n *= 2;
|
||
if (n > 9) n -= 9;
|
||
}
|
||
sum += n;
|
||
alternate = !alternate;
|
||
}
|
||
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
|
||
}
|
||
|
||
function parseExpiryParts(value: string): { month: number; year: number } | null {
|
||
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
|
||
const [monthStr, yearStr] = value.split('/');
|
||
const month = parseInt(monthStr, 10);
|
||
const year = 2000 + parseInt(yearStr, 10);
|
||
if (month < 1 || month > 12) return null;
|
||
return { month, year };
|
||
}
|
||
|
||
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
|
||
const isNewCardExpiryPast = $derived(
|
||
newCardExpiryParts !== null &&
|
||
(() => {
|
||
const expiryYearMonth = newCardExpiryParts.year * 12 + newCardExpiryParts.month;
|
||
const now = new SvelteDate();
|
||
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
|
||
return expiryYearMonth < currentYearMonth;
|
||
})()
|
||
);
|
||
const hasNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null);
|
||
|
||
const newCardError = $derived(
|
||
showNewCardForm || savedCards.length === 0
|
||
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
|
||
? 'Invalid card number'
|
||
: cardExpiryTouched && hasNewCardInvalidMonth
|
||
? 'Invalid expiry month'
|
||
: cardExpiryTouched && isNewCardExpiryPast
|
||
? 'This card has expired'
|
||
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
|
||
? 'Enter expiry as MM/YY'
|
||
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
|
||
? 'Enter your CVC number'
|
||
: isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
|
||
? null
|
||
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
|
||
? null
|
||
: 'Please complete all card fields'
|
||
: null
|
||
);
|
||
|
||
const isCardValid = $derived(
|
||
selectedCardId !== null ||
|
||
(isValidLuhn(newCardNumber) &&
|
||
newCardExpiryParts !== null &&
|
||
!isNewCardExpiryPast &&
|
||
newCardCVC.length >= 3)
|
||
);
|
||
|
||
function handleFieldBlur(field: string) {
|
||
if (field === 'cardNumber') cardNumberTouched = true;
|
||
else if (field === 'cardExpiry') cardExpiryTouched = true;
|
||
else if (field === 'cardCVC') cardCVCTouched = true;
|
||
}
|
||
|
||
function handleFieldInput(field: string) {
|
||
if (field === 'cardNumber') cardNumberTouched = false;
|
||
else if (field === 'cardExpiry') cardExpiryTouched = false;
|
||
else if (field === 'cardCVC') cardCVCTouched = false;
|
||
}
|
||
const onfieldblur = handleFieldBlur;
|
||
const onfieldinput = handleFieldInput;
|
||
|
||
// 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;
|
||
error = null;
|
||
|
||
try {
|
||
const response = await apiFetch(`/api/bookings/${bookingId}`);
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
throw new Error('Authentication required');
|
||
}
|
||
if (response.status === 404) {
|
||
throw new Error('Booking not found');
|
||
}
|
||
throw new Error('Failed to load booking');
|
||
}
|
||
|
||
booking = await response.json();
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : 'An error occurred';
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
loadingCards = true;
|
||
try {
|
||
await savedCardsStore.fetch();
|
||
savedCards = savedCardsStore.cards;
|
||
if (savedCards.length > 0 && !selectedCardId) {
|
||
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
loadingCards = false;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
if (savedCards.length > 0 && !selectedCardId && !showNewCardForm) {
|
||
toast.error('Please select a payment method');
|
||
return;
|
||
}
|
||
if ((showNewCardForm || savedCards.length === 0) && !newCardNumber.replace(/\s/g, '')) {
|
||
toast.error('Please enter your card number');
|
||
return;
|
||
}
|
||
|
||
paymentState = 'processing';
|
||
|
||
// Validate card details for new card payments
|
||
if (showNewCardForm || savedCards.length === 0) {
|
||
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || isNewCardExpiryPast || newCardCVC.length < 3) {
|
||
paymentState = 'idle';
|
||
toast.error(newCardError || 'Please enter valid credit card details');
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const amountInPence = Math.round(tipAmount * 100);
|
||
const body: Record<string, unknown> = { amount: amountInPence };
|
||
|
||
if (showNewCardForm || savedCards.length === 0) {
|
||
body.new_card_token = newCardNumber.replace(/\s/g, '');
|
||
body.save_card = saveCardForFuture;
|
||
} else {
|
||
body.card_id = selectedCardId;
|
||
}
|
||
|
||
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';
|
||
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;
|
||
|
||
if (authStore.isLoading) {
|
||
pageState = 'loading';
|
||
return;
|
||
}
|
||
|
||
if (!authStore.isAuthenticated) {
|
||
pageState = 'unauthorized';
|
||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||
goto('/login', { replaceState: true });
|
||
return;
|
||
}
|
||
|
||
if (authStore.currentUser?.role === 'admin') {
|
||
pageState = 'admin';
|
||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||
goto('/admin', { replaceState: true });
|
||
return;
|
||
}
|
||
|
||
pageState = 'authorized';
|
||
loadSavedCards();
|
||
if (bookingId) {
|
||
fetchBookingData();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<script>
|
||
(function () {
|
||
// Pre-hydration auth guard: reads localStorage directly because the Svelte
|
||
// authStore hasn't initialized yet at this point (async+$state). This runs
|
||
// synchronously before any rendering, preventing a flash of protected content.
|
||
// The authStore handles post-hydration auth.
|
||
try {
|
||
var token = localStorage.getItem('authToken');
|
||
if (!token) {
|
||
window.location.replace('/login');
|
||
return;
|
||
}
|
||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||
if (payload.exp * 1000 <= Date.now()) {
|
||
window.location.replace('/login');
|
||
}
|
||
} catch (e) {
|
||
window.location.replace('/login');
|
||
}
|
||
})();
|
||
</script>
|
||
<title>Leave a Tip - Crussell</title>
|
||
</svelte:head>
|
||
|
||
<div class="mx-auto min-h-screen px-4 py-8 sm:max-w-md md:py-12">
|
||
{#if loading || pageState === 'loading'}
|
||
<div class="space-y-6">
|
||
<div class="text-center">
|
||
<Skeleton class="mx-auto h-10 w-40" />
|
||
</div>
|
||
<Card.Root>
|
||
<Card.Content class="space-y-4 pt-6">
|
||
<Skeleton class="h-16 w-full" />
|
||
<Skeleton class="h-24 w-full" />
|
||
</Card.Content>
|
||
</Card.Root>
|
||
</div>
|
||
{:else if error}
|
||
<Card.Root>
|
||
<Card.Header>
|
||
<Card.Title class="text-red-600">Something went wrong</Card.Title>
|
||
</Card.Header>
|
||
<Card.Content>
|
||
<p class="text-gray-600">{error}</p>
|
||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||
<Button class="w-full sm:w-auto" onclick={() => goto('/')}>Go Home</Button>
|
||
<Button variant="outline" class="w-full sm:w-auto" onclick={fetchBookingData}>
|
||
Try Again
|
||
</Button>
|
||
</div>
|
||
</Card.Content>
|
||
</Card.Root>
|
||
{:else if booking}
|
||
<div class="mb-6 text-center">
|
||
<h1 class="text-2xl font-bold text-gray-900 sm:text-3xl">Leave a Tip</h1>
|
||
<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>
|
||
{#if savedCards.length > 0}
|
||
<div class="space-y-3">
|
||
<span
|
||
class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||
>
|
||
Payment Method
|
||
</span>
|
||
<div class="space-y-2">
|
||
{#each savedCards as card (card.id)}
|
||
<button
|
||
type="button"
|
||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId === card.id && !showNewCardForm
|
||
? 'border-input bg-accent'
|
||
: 'border-gray-200 hover:bg-gray-50'}"
|
||
onclick={() => {
|
||
selectedCardId = card.id;
|
||
showNewCardForm = false;
|
||
}}
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<CardBrandIcon brand={card.brand} />
|
||
<div class="text-sm">
|
||
<span class="font-mono">**** {card.last_4}</span>
|
||
<span class="ml-2 text-xs text-gray-400"
|
||
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
{#if selectedCardId === card.id && !showNewCardForm}
|
||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||
{/if}
|
||
</button>
|
||
{/each}
|
||
<button
|
||
type="button"
|
||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
|
||
? 'border-input bg-accent'
|
||
: 'border-gray-200 hover:bg-gray-50'}"
|
||
onclick={() => {
|
||
selectedCardId = null;
|
||
showNewCardForm = true;
|
||
}}
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div
|
||
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
|
||
>
|
||
NEW
|
||
</div>
|
||
<span class="animate-pulse text-sm font-medium text-gray-700"
|
||
>Use a new card</span
|
||
>
|
||
</div>
|
||
{#if showNewCardForm}
|
||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
|
||
{#if showNewCardForm}
|
||
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
|
||
<CardInput
|
||
bind:cardNumber={newCardNumber}
|
||
bind:cardExpiry={newCardExpiry}
|
||
bind:cardCVC={newCardCVC}
|
||
bind:saveCard={saveCardForFuture}
|
||
showSaveCard={canSaveCards}
|
||
{onfieldblur}
|
||
{onfieldinput}
|
||
/>
|
||
{#if newCardError}
|
||
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<CardInput
|
||
bind:cardNumber={newCardNumber}
|
||
bind:cardExpiry={newCardExpiry}
|
||
bind:cardCVC={newCardCVC}
|
||
bind:saveCard={saveCardForFuture}
|
||
showSaveCard={canSaveCards}
|
||
{onfieldblur}
|
||
{onfieldinput}
|
||
/>
|
||
{#if newCardError}
|
||
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
|
||
{/if}
|
||
{/if}
|
||
</Card.Content>
|
||
</Card.Root>
|
||
|
||
{#if paymentState === 'error'}
|
||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
|
||
<p class="text-red-700">Payment failed. Please try again.</p>
|
||
<Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
|
||
</div>
|
||
{/if}
|
||
|
||
<Button
|
||
class="w-full"
|
||
size="lg"
|
||
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing'}
|
||
loading={paymentState === 'processing'}
|
||
onclick={submitTip}
|
||
>
|
||
{paymentState === 'processing' ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
|
||
</Button>
|
||
|
||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||
{/if}
|
||
{/if}
|
||
</div>
|