Files
Crussell/frontend/src/lib/components/payments/PaymentModal.svelte
T
2026-06-06 14:26:33 +01:00

1183 lines
37 KiB
Svelte

<script lang="ts">
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
booking: Booking;
onClose: () => void;
onComplete: (payment: PaymentResult) => void;
}
let { booking, onClose, onComplete }: Props = $props();
type PaymentStatus =
| 'idle'
| 'selecting'
| 'card-processing'
| 'card-polling'
| 'cash-entering'
| 'cash-confirming'
| 'gift-entering'
| 'gift-confirming'
| 'saved-card-selecting'
| 'saved-card-processing'
| 'success'
| 'error';
type PaymentResult = {
checkout_id: string;
status: string;
card_brand?: string;
last4?: string;
amount: number;
};
type PaymentMethod = 'card' | 'cash' | 'giftcard' | 'savedcard' | null;
let status = $state<PaymentStatus>('idle');
let selectedMethod = $state<PaymentMethod>(null);
let checkoutId = $state<string | null>(null);
let paymentResult = $state<PaymentResult | null>(null);
let error = $state<string | null>(null);
let customerBalance = $state(0);
let loadingCustomerBalance = $state(false);
let giftCardPaymentAmount = $state('');
let savedCardList = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
let loadingSavedCardList = $state(false);
async function fetchCustomerGiftCardBalance() {
if (!booking.user_id) return;
loadingCustomerBalance = true;
try {
const res = await fetch(`/api/admin/users/${booking.user_id}/giftcard-balance`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
const data = await res.json();
customerBalance = data.balance;
giftCardPaymentAmount = Math.min(data.balance, totalDue).toFixed(2);
if (data.balance > 0) {
useAccountBalance = true;
}
}
} catch {
// ignore
} finally {
loadingCustomerBalance = false;
}
}
async function fetchSavedCardList() {
if (!booking.user_id) return;
loadingSavedCardList = true;
try {
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
savedCardList = await res.json();
}
} catch {
// ignore
} finally {
loadingSavedCardList = false;
}
}
type ServiceOverride = {
price: string;
originalPrice: number;
};
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
$effect(() => {
fetchCustomerGiftCardBalance();
fetchSavedCardList();
const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {};
for (const s of services) {
const price = s.override_price ?? s.price ?? 0;
overrides[s.service_id] = {
price: price.toFixed(2),
originalPrice: s.price ?? 0
};
}
serviceOverrides = overrides;
});
function handlePriceInput(serviceId: string, value: string) {
const cleaned = 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 === '') {
serviceOverrides = {
...serviceOverrides,
[serviceId]: {
...serviceOverrides[serviceId],
price: sanitized
}
};
}
}
let tipEnabled = $state(false);
let selectedTipPercent = $state<number | null>(null);
let customTipAmount = $state<string>('');
let pollingInterval: ReturnType<typeof setInterval> | null = null;
function selectTipPercent(percent: number) {
selectedTipPercent = percent;
customTipAmount = '';
tipEnabled = true;
}
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 === '') {
customTipAmount = sanitized;
}
selectedTipPercent = null;
tipEnabled = customTipAmount !== '' && parseFloat(customTipAmount) > 0;
}
function getServicePrice(service: BookingService): number {
const override = serviceOverrides[service.service_id];
if (override && override.price !== '') {
const parsed = parseFloat(override.price);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
return service.override_price ?? service.price ?? 0;
}
let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0));
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
let netTotal = $derived(Math.max(0, subtotal - discountSum));
let tipPercentages = $derived.by(() => {
if (netTotal <= 0) return [];
return [
{ pct: 10, amount: Math.round(netTotal * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(netTotal * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(netTotal * 0.2 * 100) / 100 }
];
});
let tipMultiplier = $derived(
selectedTipPercent !== null
? 1 + selectedTipPercent / 100
: customTipAmount && parseFloat(customTipAmount) > 0
? 1 + parseFloat(customTipAmount) / netTotal
: 1
);
let totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal);
let tipDisplay = $derived(
selectedTipPercent !== null
? `${selectedTipPercent}%`
: customTipAmount && parseFloat(customTipAmount) > 0
? ${parseFloat(customTipAmount).toFixed(2)}`
: ''
);
let totalDue = $derived(tipEnabled ? totalWithTip : netTotal);
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(value);
}
async function handleCardPayment() {
const finalAmount = totalDue;
if (isNaN(finalAmount) || finalAmount <= 0) {
toast.error('Please enter a valid amount');
return;
}
status = 'card-processing';
error = null;
try {
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
amount: Math.round(finalAmount * 100),
payment_type: 'full',
tip_enabled: tipEnabled
})
});
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to initiate payment');
}
const data = await response.json();
checkoutId = data.checkout_id;
status = 'card-polling';
startPolling();
} catch (err) {
status = 'error';
error = err instanceof Error ? err.message : 'Failed to initiate payment';
toast.error(error ?? 'Unknown error');
}
}
function startPolling() {
if (!checkoutId) return;
pollingInterval = setInterval(async () => {
try {
const response = await fetch(
`/api/admin/payments/${checkoutId}/status?booking_id=${booking.id}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (!response.ok) {
throw new Error('Failed to check payment status');
}
const data = await response.json();
if (data.status === 'COMPLETED') {
stopPolling();
status = 'success';
paymentResult = {
checkout_id: checkoutId!,
status: data.status,
card_brand: data.card_brand,
last4: data.last4,
amount: data.amount
};
toast.success('Payment successful');
onComplete(paymentResult);
} else if (data.status === 'FAILED') {
stopPolling();
status = 'error';
error = data.error_message || 'Payment failed';
toast.error(error as string);
}
} catch (err) {
stopPolling();
status = 'error';
error = 'Failed to check payment status';
toast.error(error);
}
}, 2000);
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
function handleClose() {
stopPolling();
onClose();
}
function resetToSelect() {
stopPolling();
status = 'idle';
selectedMethod = null;
checkoutId = null;
error = null;
}
$effect(() => {
return () => {
stopPolling();
};
});
let cashAmount = $state<string>('');
let cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount));
let changeDue = $derived(cashAmountNum > totalDue ? cashAmountNum - totalDue : 0);
let extraAsTip = $state(false);
function handleCashInput(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 === '') {
cashAmount = sanitized;
}
}
async function handleCashPayment() {
if (cashAmountNum < totalDue) {
toast.error('Cash amount must cover the total');
return;
}
const tipAmount = extraAsTip ? cashAmountNum - totalDue : 0;
status = 'cash-confirming';
error = null;
try {
const body: Record<string, unknown> = {
amount: Math.round(totalDue * 100),
payment_type: 'full',
payment_method: 'cash'
};
if (tipAmount > 0) {
body.tip_enabled = true;
body.tip_amount = Math.round(tipAmount * 100);
}
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(body)
});
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to process payment');
}
const data = await response.json();
status = 'success';
paymentResult = {
checkout_id: data.checkout_id || data.id || '',
status: 'COMPLETED',
amount: data.amount
};
toast.success('Cash payment recorded');
onComplete(paymentResult);
} catch (err) {
status = 'error';
error = err instanceof Error ? err.message : 'Failed to process payment';
toast.error(error ?? 'Unknown error');
}
}
let giftCardId = $state('');
let useAccountBalance = $state(false);
function formatAndPreserveCursor(
input: HTMLInputElement,
formatter: (val: string) => string,
charRegex: RegExp = /\d/
): string {
const rawValue = input.value;
const oldSelectionStart = input.selectionStart || 0;
let charsBeforeCursor = 0;
for (let i = 0; i < oldSelectionStart; i++) {
if (charRegex.test(rawValue[i])) {
charsBeforeCursor++;
}
}
const formatted = formatter(rawValue);
input.value = formatted;
let newSelectionStart = 0;
let charsFound = 0;
for (let i = 0; i < formatted.length; i++) {
if (charsFound === charsBeforeCursor) {
break;
}
if (charRegex.test(formatted[i])) {
charsFound++;
}
newSelectionStart++;
}
requestAnimationFrame(() => {
input.setSelectionRange(newSelectionStart, newSelectionStart);
});
return formatted;
}
function formatGiftCardId(value: string): string {
let raw = value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
if (raw.length > 12) raw = raw.slice(0, 12);
let formatted = '';
if (raw.length > 0) formatted += raw.slice(0, 4);
if (raw.length > 4) formatted += '-' + raw.slice(4, 8);
if (raw.length > 8) formatted += '-' + raw.slice(8, 12);
return formatted.toUpperCase();
}
function handleGiftCardInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatGiftCardId, /[a-zA-Z0-9]/);
giftCardId = formatted;
}
let giftCardValid = $derived(
useAccountBalance || giftCardId.replace(/-/g, '').length === 12
);
async function handleGiftCardPayment() {
if (!giftCardValid) {
toast.error('Please enter a valid 12-character gift card code');
return;
}
let payAmountCents = Math.round(totalDue * 100);
if (useAccountBalance) {
const parsedAmt = parseFloat(giftCardPaymentAmount);
if (isNaN(parsedAmt) || parsedAmt <= 0) {
toast.error('Please enter a valid payment amount');
return;
}
if (parsedAmt > customerBalance) {
toast.error('Payment amount exceeds available balance');
return;
}
payAmountCents = Math.round(parsedAmt * 100);
}
status = 'gift-confirming';
error = null;
try {
const body: any = {
amount: payAmountCents,
payment_type: 'full',
payment_method: 'giftcard'
};
if (!useAccountBalance) {
body.gift_card_id = giftCardId.replace(/-/g, '');
}
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(body)
});
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to process gift card');
}
const data = await response.json();
status = 'success';
paymentResult = {
checkout_id: data.checkout_id || data.id || '',
status: 'COMPLETED',
amount: data.amount
};
toast.success('Gift card payment recorded');
onComplete(paymentResult);
} catch (err) {
status = 'error';
error = err instanceof Error ? err.message : 'Failed to process gift card';
toast.error(error ?? 'Unknown error');
}
}
// Saved cards
let savedCards = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
let loadingSavedCards = $state(false);
let selectedSavedCardId = $state<string | null>(null);
async function fetchSavedCards() {
if (!booking.user_id) return;
loadingSavedCards = true;
savedCards = [];
selectedSavedCardId = null;
try {
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
savedCards = await res.json();
}
} catch {
toast.error('Failed to load saved cards');
} finally {
loadingSavedCards = false;
}
}
async function handleSavedCardPayment() {
if (!selectedSavedCardId) {
toast.error('Please select a saved card');
return;
}
status = 'saved-card-processing';
error = null;
try {
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
amount: Math.round(totalDue * 100),
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId
})
});
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to process saved card payment');
}
const data = await response.json();
status = 'success';
paymentResult = {
checkout_id: data.checkout_id || data.id || '',
status: 'COMPLETED',
card_brand: data.card_brand,
last4: data.card_last4,
amount: data.amount
};
toast.success('Saved card payment successful');
onComplete(paymentResult);
} catch (err) {
status = 'error';
error = err instanceof Error ? err.message : 'Failed to process saved card payment';
toast.error(error ?? 'Unknown error');
}
}
$effect(() => {
if (selectedMethod === 'cash') {
cashAmount = totalDue.toFixed(2);
extraAsTip = false;
}
if (selectedMethod === 'giftcard') {
giftCardId = '';
}
if (selectedMethod === 'savedcard') {
fetchSavedCards();
}
});
</script>
<Dialog.Root
open={true}
onOpenChange={(open) => !open && handleClose()}
>
<Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
</Dialog.Header>
{#if status === 'idle'}
<div class="space-y-4">
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
<div
class="grid grid-cols-1 {(booking.services?.length ?? 0) > 1
? 'sm:grid-cols-2'
: ''} gap-3"
>
{#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)}
<div class="rounded-lg border bg-white p-3">
<div class="mb-2 text-sm font-medium">
{service.service_name || 'Unknown Service'}
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">£</span>
<input
type="text"
inputmode="decimal"
tabindex={-1}
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
value={serviceOverrides[service.service_id]?.price ??
service.price?.toFixed(2) ??
'0.00'}
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
/>
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
<span class="text-xs text-amber-600">
(was £{serviceOverrides[service.service_id].originalPrice.toFixed(2)})
</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
{#if booking.discounts && booking.discounts.length > 0}
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50/40 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="text-sm font-semibold text-fuchsia-800 flex items-center gap-1.5">
<svg class="h-4 w-4 text-fuchsia-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
Applied Discounts
</div>
<span class="rounded-full bg-fuchsia-100 px-2 py-0.5 text-xs font-semibold text-fuchsia-700">
{((discountSum / subtotal) * 100).toFixed(0)}% Off Total
</span>
</div>
<div class="space-y-2 text-sm">
{#each booking.discounts as d}
<div class="flex items-center justify-between text-gray-600">
<div class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-500"></span>
<span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
{:else if d.campaign_name}
{d.campaign_name}
{:else}
Promo Campaign ({d.discount_percent}% Off)
{/if}
</span>
</div>
<span class="font-medium text-fuchsia-600">-{formatCurrency(d.discount_amount)}</span>
</div>
{/each}
</div>
</div>
{/if}
<div class="flex justify-between items-center rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total</span>
<div class="flex items-baseline gap-2.5">
{#if discountSum > 0.01}
<span class="text-sm font-medium text-gray-400 line-through">{formatCurrency(subtotal)}</span>
{/if}
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
</div>
{#if tipEnabled}
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
<span class="text-sm font-medium text-green-800">
Total with Tip ({tipDisplay})
</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
</div>
{/if}
<div class="grid grid-cols-2 gap-3 {savedCardList.length > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3'}">
<button
type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
'card'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'card';
status = 'selecting';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Card
</button>
<button
type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
'cash'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'cash';
status = 'cash-entering';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
Cash
</button>
{#if savedCardList.length > 0}
<button
type="button"
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
'savedcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'savedcard';
status = 'saved-card-selecting';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<path d="M6 10h12" />
<path d="M6 14h6" />
</svg>
Saved Card
</button>
{/if}
<button
type="button"
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 12 20 22 4 22 4 12" />
<rect x="2" y="7" width="20" height="5" />
<line x1="12" y1="22" x2="12" y2="7" />
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z" />
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z" />
</svg>
Gift Card
</button>
</div>
<div class="sm:hidden flex flex-wrap gap-3">
{#if savedCardList.length > 0}
<button
type="button"
class="text-sm text-gray-600 underline hover:text-gray-900"
onclick={() => {
selectedMethod = 'savedcard';
status = 'saved-card-selecting';
}}
>
Pay with Saved Card
</button>
{/if}
<button
type="button"
class="text-sm text-gray-600 underline hover:text-gray-900"
onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
}}
>
Pay with Gift Card
</button>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1">Cancel</Button>
</div>
</div>
{:else if status === 'selecting'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
<div class="space-y-3">
<span class="text-sm font-medium text-gray-700">Add a Tip</span>
<div class="grid grid-cols-3 gap-2">
{#each tipPercentages as tip (tip.pct)}
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
tip.pct
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}"
onclick={() => selectTipPercent(tip.pct)}
>
<div>{tip.pct}%</div>
<div class="text-xs font-normal text-gray-500">£{tip.amount.toFixed(2)}</div>
</button>
{/each}
</div>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
type="text"
inputmode="decimal"
tabindex={-1}
placeholder="Custom tip amount"
value={customTipAmount}
oninput={handleCustomTipInput}
class="pl-7"
/>
</div>
</div>
{#if tipEnabled}
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
<span class="text-sm font-medium text-green-800">
Total with Tip ({tipDisplay})
</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleCardPayment} class="flex-1 bg-green-600 hover:bg-green-700">
Charge Card
</Button>
</div>
</div>
{:else if status === 'card-processing' || status === 'card-polling'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p>
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
</div>
{:else if status === 'cash-entering'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
<div>
<label for="cash-amount" class="text-sm font-medium text-gray-700"> Cash Received </label>
<div class="relative mt-1">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="cash-amount"
type="text"
inputmode="decimal"
tabindex={-1}
value={cashAmount}
oninput={handleCashInput}
class="pl-7 text-lg font-semibold"
/>
</div>
</div>
{#if cashAmountNum >= totalDue}
<div class="rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex justify-between">
<span class="text-sm font-medium text-green-800">Change Due</span>
<span class="text-lg font-bold text-green-800">{formatCurrency(changeDue)}</span>
</div>
{#if changeDue > 0}
<div class="mt-2 flex items-center gap-2">
<Checkbox id="keep-change" bind:checked={extraAsTip} />
<label for="keep-change" class="text-sm text-green-700">
Keep {formatCurrency(changeDue)} as tip
</label>
</div>
{/if}
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleCashPayment}
class="flex-1 bg-green-600 hover:bg-green-700"
disabled={cashAmountNum < totalDue}
>
Confirm Cash
</Button>
</div>
</div>
{:else if status === 'cash-confirming'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Processing cash payment...</p>
</div>
{:else if status === 'gift-entering'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{#if booking.user_id && customerBalance > 0}
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Source</span>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {useAccountBalance
? 'border-fuchsia-600 bg-fuchsia-50 text-fuchsia-900 font-semibold'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => useAccountBalance = true}
>
Account Balance ({formatCurrency(customerBalance)})
</button>
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {!useAccountBalance
? 'border-fuchsia-600 bg-fuchsia-50 text-fuchsia-900 font-semibold'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => useAccountBalance = false}
>
Physical Gift Card Code
</button>
</div>
</div>
{/if}
{#if useAccountBalance}
<div>
<label for="giftcard-amount" class="text-sm font-medium text-gray-700">Amount to pay with Balance (£)</label>
<Input
id="giftcard-amount"
type="text"
inputmode="decimal"
value={giftCardPaymentAmount}
oninput={(e) => giftCardPaymentAmount = (e.target as HTMLInputElement).value}
class="mt-1 font-mono text-lg"
/>
<p class="mt-1 text-xs text-gray-500">
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance can be used.
</p>
</div>
{:else}
<div>
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card Code </label>
<Input
id="gift-card-id"
type="text"
inputmode="text"
tabindex={-1}
value={giftCardId}
oninput={handleGiftCardInput}
placeholder="XXXX-XXXX-XXXX"
maxlength={14}
class="mt-1 font-mono text-lg tracking-widest"
/>
<p class="mt-1 text-xs text-gray-500">Enter the 12-character code printed on the gift card</p>
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleGiftCardPayment}
class="flex-1 bg-green-600 hover:bg-green-700 text-white"
disabled={!giftCardValid}
>
Apply Gift Card
</Button>
</div>
</div>
{:else if status === 'gift-confirming'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
</div>
{:else if status === 'saved-card-selecting'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{#if loadingSavedCards}
<div class="flex justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-fuchsia-600"></div>
</div>
{:else if savedCards.length === 0}
<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
<p class="text-sm text-gray-600">No saved cards found for this customer.</p>
<p class="mt-1 text-xs text-gray-500">Add a card via Square Dashboard or use another payment method.</p>
</div>
{:else}
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select a Saved Card</span>
{#each savedCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId === card.id
? 'border-fuchsia-600 bg-fuchsia-50'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => selectedSavedCardId = card.id}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<span class="font-medium text-gray-900">{card.card_brand} ••••{card.card_last4}</span>
</div>
<span class="text-xs text-gray-500">{card.card_expiry}</span>
</div>
{#if card.cardholder_name}
<div class="mt-1 text-xs text-gray-500">{card.cardholder_name}</div>
{/if}
</button>
{/each}
</div>
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-xs text-amber-800">This card may require bank app confirmation to complete. Ensure the customer has their phone ready.</p>
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleSavedCardPayment}
class="flex-1 bg-green-600 hover:bg-green-700 text-white"
disabled={!selectedSavedCardId}
>
Charge Saved Card
</Button>
</div>
</div>
{:else if status === 'saved-card-processing'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
</div>
{:else if status === 'error' && error}
<div class="space-y-4">
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1">Close</Button>
<Button onclick={resetToSelect} class="flex-1">Try Again</Button>
</div>
</div>
{:else if status === 'success' && paymentResult}
<div class="space-y-4">
<div class="flex flex-col items-center justify-center py-4">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-green-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900">Payment Successful</h3>
</div>
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Amount</span>
<span class="font-semibold text-gray-900">
{formatCurrency(paymentResult.amount)}
</span>
</div>
{#if paymentResult.card_brand}
<div class="flex justify-between">
<span class="text-sm text-gray-600">Card</span>
<span class="font-medium text-gray-900">
{paymentResult.card_brand} ****{paymentResult.last4}
</span>
</div>
{/if}
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span class="font-medium text-green-600">Completed</span>
</div>
</div>
</div>
<Button onclick={handleClose} class="w-full">Done</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>