Files
Crussell/frontend/src/lib/components/account/UserBookingModal.svelte
T
popertots 4abcb324c9 Square payment integration: real HTTP client, tip flow rewrite, card UI/validation overhaul
Backend:
- Create square_http_client.go: real Square REST API client (Payments, Terminal Checkouts, Refunds, Cards, Locations) with proper JSON types, auth, error handling
- Update ProdClient in square.go to delegate to shared HTTP functions
- Wire devProdClient in square_dev.go to also make real HTTP calls for sandbox/prod env
- Rewrite CreateTipPayment handler: accept card_id OR new_card_token (+save_card), advisory lock, idempotency check, max amount validation
- Add ValidateCardInfo, bump ValidateAmount max to £10,000
- Fix mock CreateCardOnFile to detect brand/last4 from raw card numbers
- Fix mock RefundPayment to index by SquarePayID and accept unknown payment IDs
- Remove dead types (ProcessingFee, sqAddress), add Deadline parity
- Fix AMEX brand inconsistency (AMEX -> AMERICAN_EXPRESS)
- Pre-existing fix: remove unused context import in giftcards.go

Frontend:
- CardInput.svelte: add onfieldblur/onfieldinput callbacks for blur-based validation
- CardBrandIcon.svelte: brand SVGs for VISA, MC, AMEX, Discover, Diners, JCB, Square Gift Card, UnionPay, Interac, EFTPOS
- tip/+page, pay-tip/[id], UserBookingModal tip: saved card list + CardInput + Luhn/expiry/CVC validation + blur-based errors + no-saved-cards edge case
- UserPaymentModal, BookingFlow: card validation parity (blur-based, all-valid check)
- account page: replace text brand badges with CardBrandIcon
- Fix handleCustomTip bug (state mutations outside if block)
- Remove dead pageState variable
- Add tip modal scroll (max-h-[90vh] overflow-y-auto)
- Submit button disabled on !isCardValid

Tests:
- 30 square package tests (+new: CreateCardOnFile raw number path, detectCardInfo variants)
- 5 tip handler tests (HappyPath, NoPriorPayment, WrongOwner, MultipleTips, TxFailure)
- All +-race clean, refund tests fixed
2026-08-22 00:34:49 +01:00

1317 lines
47 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import { computeBalanceDue } from '$lib/utils/booking';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
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';
interface Props {
open: boolean;
bookingId: string;
}
let { open = $bindable(), bookingId }: Props = $props();
let selectedBooking = $state<Booking | null>(null);
const businessSettings = $derived(getBusinessInfo());
let loading = $state(false);
let hasPendingEditRequest = $state(false);
let pendingEditRequest = $state<{
id: string;
notes: string | null;
requested_at: string;
original: {
start_time: string | null;
services: Array<{ name: string; price: number; duration_minutes: number }>;
};
proposed: {
start_time: string | null;
services: Array<{ name: string; price: number; duration_minutes: number }>;
};
} | null>(null);
let showEditModal = $state(false);
let showCancelConfirm = $state(false);
let cancelling = $state(false);
// IMPORTANT: Use override_duration_minutes when present — services may have been
// customised at booking time. Showing base values misleads users about what was booked.
const totalDuration = $derived(
selectedBooking?.services?.reduce(
(sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0),
0
) || 0
);
const isFutureBooking = $derived(
selectedBooking ? new SvelteDate(selectedBooking.start_time) > new SvelteDate() : false
);
const hasPayments = $derived(
selectedBooking && selectedBooking.payments && selectedBooking.payments.length > 0
);
const isCancellable = $derived(
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
);
const canEditBooking = $derived(isCancellable);
const totalPaid = $derived(
selectedBooking?.payments
?.filter((p) => p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0) || 0
);
const totalRefunds = $derived(
(selectedBooking?.refunds ?? [])
.filter((r) => r.status === 'completed')
.reduce((sum, r) => sum + r.amount, 0)
);
const balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0);
const totalVAT = $derived(
selectedBooking?.payments
?.filter((p) => p.status === 'completed' && p.vat_amount)
.reduce((sum, p) => sum + (p.vat_amount ?? 0), 0) || 0
);
const totalNet = $derived(
selectedBooking?.payments
?.filter((p) => p.status === 'completed' && p.net_amount)
.reduce((sum, p) => sum + (p.net_amount ?? 0), 0) || 0
);
const hasVAT = $derived(totalVAT > 0);
const depositOutstanding = $derived(
selectedBooking?.deposit_required && !selectedBooking?.deposit_paid
);
const canPayEarly = $derived(
selectedBooking &&
!depositOutstanding &&
totalPaid < selectedBooking.total_amount &&
['confirmed', 'in_progress'].includes(selectedBooking.status)
);
const isCompleted = $derived(selectedBooking?.status === 'completed');
const hoursUntilAppointment = $derived(
selectedBooking
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) /
(1000 * 60 * 60)
: Infinity
);
const protectedDeposit = $derived(
selectedBooking
? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT)
: 0
);
const estimatedRefund = $derived(
hoursUntilAppointment > POLICY.FULL_REFUND_THRESHOLD_HOURS
? totalPaid
: hoursUntilAppointment >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
? Math.max(0, totalPaid - protectedDeposit)
: 0
);
let showPaymentModal = $state(false);
let showTipModal = $state(false);
let tipAmount = $state<number>(0);
let selectedTipPreset = $state<number | null>(null);
let customTipInput = $state('');
let tipProcessing = $state(false);
// Card selection state for tips
let tipSavedCards = $state<SavedCard[]>([]);
let tipLoadingCards = $state(false);
let tipSelectedCardId = $state<string | null>(null);
let tipShowNewCard = $state(false);
// New card form state for tips
let tipNewCardNumber = $state('');
let tipNewCardExpiry = $state('');
let tipNewCardCVC = $state('');
let tipSaveCardFuture = $state(false);
let tipCardNumberTouched = $state(false);
let tipCardExpiryTouched = $state(false);
let tipCVCTouched = $state(false);
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 handleTipFieldBlur(field: string) {
if (field === 'cardNumber') tipCardNumberTouched = true;
else if (field === 'cardExpiry') tipCardExpiryTouched = true;
else if (field === 'cardCVC') tipCVCTouched = true;
}
function handleTipFieldInput(field: string) {
if (field === 'cardNumber') tipCardNumberTouched = false;
else if (field === 'cardExpiry') tipCardExpiryTouched = false;
else if (field === 'cardCVC') tipCVCTouched = false;
}
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 tipNewCardExpiryParts = $derived(parseExpiryParts(tipNewCardExpiry));
const isTipNewCardExpiryPast = $derived(
tipNewCardExpiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(tipNewCardExpiryParts.year, tipNewCardExpiryParts.month);
return expiryDate < new SvelteDate();
})()
);
const hasTipNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null);
const tipNewCardError = $derived(
tipShowNewCard || tipSavedCards.length === 0
? tipCardNumberTouched && !isValidLuhn(tipNewCardNumber) && tipNewCardNumber.length > 0
? 'Invalid card number'
: tipCardExpiryTouched && hasTipNewCardInvalidMonth
? 'Invalid expiry month'
: tipCardExpiryTouched && isTipNewCardExpiryPast
? 'This card has expired'
: tipCardExpiryTouched && tipNewCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(tipNewCardExpiry)
? 'Enter expiry as MM/YY'
: tipCVCTouched && tipNewCardCVC.length < 3 && tipNewCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(tipNewCardNumber) && /^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardCVC.length >= 3
? null
: tipNewCardNumber.length === 0 && tipNewCardExpiry.length === 0 && tipNewCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isTipCardValid = $derived(
tipSelectedCardId !== null ||
(isValidLuhn(tipNewCardNumber) &&
tipNewCardExpiryParts !== null &&
!isTipNewCardExpiryPast &&
tipNewCardCVC.length >= 3)
);
const tipPresets = $derived(
selectedBooking
? [
{ pct: 10, amount: Math.round(selectedBooking.total_amount * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(selectedBooking.total_amount * 0.2 * 100) / 100 }
]
: []
);
function selectTipPreset(amount: number) {
selectedTipPreset = amount;
customTipInput = '';
tipAmount = amount;
}
function handleCustomTip(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 === '') {
customTipInput = sanitized;
selectedTipPreset = null;
tipAmount = parseFloat(sanitized) || 0;
}
}
async function loadTipSavedCards() {
if (savedCardsStore.loaded) {
tipSavedCards = savedCardsStore.cards;
if (tipSavedCards.length > 0 && !tipSelectedCardId) {
tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id;
}
return;
}
tipLoadingCards = true;
try {
await savedCardsStore.fetch();
tipSavedCards = savedCardsStore.cards;
if (tipSavedCards.length > 0 && !tipSelectedCardId) {
tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id;
}
} catch {
// ignore
} finally {
tipLoadingCards = false;
}
}
async function submitTip() {
if (!selectedBooking) return;
if (tipAmount <= 0) {
toast.error('Please select a tip amount');
return;
}
if (tipSavedCards.length > 0 && !tipSelectedCardId && !tipShowNewCard) {
toast.error('Please select a payment method');
return;
}
if ((tipShowNewCard || tipSavedCards.length === 0) && !tipNewCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
return;
}
tipProcessing = true;
// Validate card details for new card payments
if (tipShowNewCard || tipSavedCards.length === 0) {
if (!isValidLuhn(tipNewCardNumber) || !/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) || isTipNewCardExpiryPast || tipNewCardCVC.length < 3) {
tipProcessing = false;
toast.error(tipNewCardError || 'Please enter valid credit card details');
return;
}
}
try {
const body: Record<string, unknown> = { amount: Math.round(tipAmount * 100) };
if (tipShowNewCard || tipSavedCards.length === 0) {
body.new_card_token = tipNewCardNumber.replace(/\s/g, '');
body.card_expiry = tipNewCardExpiry;
body.card_cvc = tipNewCardCVC;
body.save_card = tipSaveCardFuture;
} else {
body.card_id = tipSelectedCardId;
}
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Tip payment failed');
}
toast.success('Thank you for your tip!');
showTipModal = false;
fetchBookingDetails();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Tip payment failed');
} finally {
tipProcessing = false;
}
}
function handlePaymentComplete() {
toast.success('Payment completed');
showPaymentModal = false;
fetchBookingDetails();
}
async function fetchBookingDetails() {
if (!bookingId) return;
try {
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (bookingResp.ok) {
const data = await bookingResp.json();
selectedBooking = data as Booking;
// Ensure business info is loaded (cached by shared store)
ensureBusinessInfo();
const editResp = await apiFetch(`/api/bookings/${bookingId}/edit-request`);
const editData = await editResp.json();
hasPendingEditRequest = editData.edit_request != null;
pendingEditRequest = editData.edit_request || null;
} else {
const text = await bookingResp.text();
toast.error('Failed to load booking: ' + extractErrorMessage(text));
open = false;
}
} catch (err) {
console.error('Error fetching booking:', err);
toast.error('Network error');
open = false;
} finally {
loading = false;
}
}
$effect(() => {
if (!open) {
setTimeout(() => {
selectedBooking = null;
hasPendingEditRequest = false;
pendingEditRequest = null;
showCancelConfirm = false;
showEditModal = false;
}, 200);
} else if (bookingId && !selectedBooking) {
fetchBookingDetails();
}
});
$effect(() => {
if (showTipModal) {
loadTipSavedCards();
}
});
function printReceipt() {
if (!selectedBooking) {
toast.error('No booking data to print');
return;
}
const pw = window.open('', '_blank');
if (!pw) {
toast.error('Please allow pop-ups to print the receipt');
return;
}
const biz = businessSettings;
const paidPayments =
selectedBooking.payments?.filter(
(p) => p.status === 'completed' && p.payment_method !== 'discount'
) ?? [];
const discountPayments =
selectedBooking.payments?.filter(
(p) => p.payment_method === 'discount' && p.status === 'completed'
) ?? [];
const refunds = selectedBooking.refunds?.filter((r) => r.status === 'completed') ?? [];
const grossTotal = paidPayments.reduce((s, p) => s + p.amount, 0);
const esc = (str: string | null | undefined): string => {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
const fmt = (val: number | null | undefined, fallback = '\u2014'): string => {
return val != null ? '\u00a3' + val.toFixed(2) : fallback;
};
pw.document
.write(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Receipt</title>
<style>
@page { margin: 12mm; }
* { color: #000 !important; background: transparent !important; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 12px; line-height: 1.5; max-width: 700px; margin: 0 auto; padding: 20px; }
h1 { font-size: 18px; margin: 0 0 2px 0; }
.header { border-bottom: 2px solid #000; padding-bottom: 10px; margin-bottom: 14px; }
.header p { margin: 1px 0; font-size: 11px; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { padding: 5px 8px; text-align: left; border-bottom: 1px solid #000; font-size: 11px; }
th { font-weight: 600; }
.total td { font-weight: 700; border-top: 2px solid #000; }
.warning { font-size: 10px; margin-top: 4px; }
.footer { margin-top: 20px; padding-top: 10px; border-top: 1px solid #000; font-size: 10px; text-align: center; }
@media print { body { padding: 0; } }
</style></head><body>
<div class="header">
<h1>${esc(biz?.business_name ?? 'Crussell Nail Art Studio')}</h1>
<p>${esc(biz?.business_address ?? '')}</p>
${biz?.is_vat_registered && biz?.vat_registration_number ? `<p>VAT Reg: ${esc(biz.vat_registration_number)}</p>` : ''}
${biz?.business_phone ? `<p>Tel: ${esc(biz.business_phone)}</p>` : ''}
${biz?.business_email ? `<p>Email: ${esc(biz.business_email)}</p>` : ''}
</div>
<h2>Receipt</h2>
<table>
<tr><td style="width:110px;font-weight:600">Booking Ref</td><td>${esc(selectedBooking.id)}</td></tr>
<tr><td style="font-weight:600">Date</td><td>${parseWallClockDate(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</td></tr>
<tr><td style="font-weight:600">Status</td><td style="text-transform:capitalize">${selectedBooking.status.replace('_', ' ')}</td></tr>
</table>
<h2>Services</h2>
<table>
<tr><th>Service</th><th style="text-align:right">Price</th></tr>
${(selectedBooking.services ?? []).map((s) => `<tr><td>${esc(s.service_name)}${s.duration_minutes ? ' (' + s.duration_minutes + ' min)' : ''}</td><td style="text-align:right">\u00a3${(s.price ?? 0).toFixed(2)}</td></tr>`).join('')}
</table>
<h2>Payments</h2>
<table>
<tr><th>Type</th><th>Method</th><th style="text-align:right">Net</th><th style="text-align:right">VAT</th><th style="text-align:right">Gross</th></tr>
${paidPayments.map((p) => `<tr><td style="text-transform:capitalize">${p.payment_type}</td><td style="text-transform:capitalize">${p.payment_method ?? '\u2014'}</td><td style="text-align:right">${fmt(p.net_amount)}</td><td style="text-align:right">${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}</td><td style="text-align:right">\u00a3${p.amount.toFixed(2)}</td></tr>`).join('')}
${discountPayments.map((d) => `<tr><td>Discount</td><td>\u2014</td><td style="text-align:right;color:#059669">-\u00a3${Math.abs(d.amount).toFixed(2)}</td><td style="text-align:right;color:#059669">\u2014</td><td style="text-align:right;color:#059669">\u2014</td></tr>`).join('')}
${refunds.map((r) => `<tr><td>Refund</td><td>\u2014</td><td style="text-align:right;color:#dc2626">-\u00a3${r.amount.toFixed(2)}</td><td style="text-align:right;color:#dc2626">\u2014</td><td style="text-align:right;color:#dc2626">\u2014</td></tr>`).join('')}
${hasVAT ? `<tr class="total"><td colspan="2"></td><td style="text-align:right">\u00a3${totalNet.toFixed(2)}</td><td style="text-align:right">\u00a3${totalVAT.toFixed(2)}</td><td style="text-align:right">\u00a3${(totalNet + totalVAT).toFixed(2)}</td></tr>` : ''}
<tr class="total"><td colspan="4" style="text-align:right">Total Paid</td><td style="text-align:right">\u00a3${grossTotal.toFixed(2)}</td></tr>
</table>
${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}% where shown above.</p>` : '<p class="warning">VAT is not applicable for this transaction.</p>'}
<div class="footer"><p>Thank you for visiting ${esc(biz?.business_name ?? 'Crussell')}.</p></div>
</body></html>`);
pw.document.close();
pw.onload = () => pw.print();
}
async function cancelBooking() {
if (!selectedBooking) return;
cancelling = true;
try {
const body = hasPayments ? { reason: 'client_cancelled' } : undefined;
const response = await apiFetch(`/api/bookings/${selectedBooking.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (response.ok) {
toast.success('Booking cancelled');
showCancelConfirm = false;
open = false;
} else {
const text = await response.text();
toast.error('Failed to cancel: ' + extractErrorMessage(text));
}
} catch {
toast.error('Network error');
} finally {
cancelling = false;
}
}
function formatPaymentMethod(method: string): string {
switch (method) {
case 'in_person_card':
return 'Card, In-person';
case 'online_square':
return 'Card, Online';
case 'cash':
return 'Cash';
case 'giftcard':
return 'Gift Card';
case 'discount':
return 'Discount';
default:
return method.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
}
function getPaymentName(
payment: Payment,
index: number,
payments: Payment[] | undefined,
discounts: BookingDiscount[] | undefined
): string {
if (payment.payment_method === 'online_square') return 'Online Card';
if (payment.payment_method === 'in_person_card') return 'Card Machine';
if (payment.payment_method === 'cash') return 'Cash';
if (payment.payment_method === 'giftcard') return 'Gift Card';
if (payment.payment_method === 'discount') {
const discountPaymentsBefore = (payments ?? [])
.slice(0, index)
.filter((p) => p.payment_method === 'discount').length;
const discountList = (discounts ?? []).filter((d) => d.discount_amount > 0.01);
if (discountList[discountPaymentsBefore]) {
const d = discountList[discountPaymentsBefore];
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
if (d.campaign_name) return `${d.campaign_name}`;
return 'Promo Campaign Discount';
}
return 'Discount';
}
return formatPaymentMethod(payment.payment_method);
}
</script>
<Modal.Root bind:open>
<Modal.Content
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header>
<div class="flex items-center justify-between">
<div>
<Modal.Title class="text-lg font-semibold">Booking Details</Modal.Title>
{#if selectedBooking}
<div class="mt-1 text-sm text-gray-500">ID: {selectedBooking.id}</div>
{/if}
</div>
{#if selectedBooking}
{@const isPastBooking = new SvelteDate(selectedBooking.start_time) < new SvelteDate()}
{@const isUnpaid = selectedBooking.amount_due > 0}
{@const showChip = !isPastBooking || isUnpaid}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(
selectedBooking.status
)}
{#if showChip}
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{isPastBooking
? 'bg-red-100 text-red-800'
: selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed'
? 'bg-emerald-100 text-emerald-800'
: selectedBooking.status === 'pending'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-100 text-gray-800'}"
>
{isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')}
</span>
{#if selectedBooking.deposit_required}
{#if selectedBooking.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if isConfirmedOrLater}
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
{/if}
</div>
{/if}
{/if}
</div>
</Modal.Header>
{#if loading}
<div class="flex items-center justify-center p-8 text-gray-500">Loading...</div>
{:else if selectedBooking}
<div class="space-y-6 px-4 pb-4">
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Appointment Details
</h3>
{#if selectedBooking.user?.previous_first_name && selectedBooking.user?.previous_last_name}
{@const fullName = `${selectedBooking.user.first_name} ${selectedBooking.user.last_name}`}
{@const formerName = `${selectedBooking.user.previous_first_name} ${selectedBooking.user.previous_last_name}`}
<div class="mb-3 text-sm text-gray-500">
{fullName}, (formerly {formerName})
</div>
{/if}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
<div class="font-medium">
{(() => {
const date = parseWallClockDate(selectedBooking.start_time);
const dateStr = date.toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'short'
});
const timeStr = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
return `${dateStr} at ${timeStr}`;
})()}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Duration</div>
<div class="font-medium">{totalDuration} minutes</div>
</div>
{#if selectedBooking.notes}
<div class="md:col-span-2">
<div class="text-xs text-gray-500">Notes</div>
<div class="mt-1 rounded-md border border-gray-300 bg-white p-2 text-sm">
{selectedBooking.notes}
</div>
</div>
{/if}
</div>
</div>
{#if selectedBooking.services && selectedBooking.services.length > 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Services
</h3>
<div class="space-y-3">
{#each selectedBooking.services as service, index (index)}
<div class="rounded-md border border-gray-300 bg-white p-3">
<div class="font-medium">{service.service_name || '—'}</div>
{#if service.service_description}
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
{/if}
<div class="mt-2 flex items-center justify-between text-sm">
<span class="text-gray-600"
>{service.override_duration_minutes ?? service.duration_minutes} min</span
>
<span class="font-semibold"
>£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span
>
</div>
</div>
{/each}
</div>
</div>
{/if}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Financial Summary
</h3>
<div class="space-y-2">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
</div>
<div class="text-xs">
<span
class={selectedBooking.deposit_paid ? 'text-green-600' : 'text-orange-600'}
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {parseWallClockDate(
selectedBooking.deposit_deadline
).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})} at {parseWallClockDate(
selectedBooking.deposit_deadline
).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
{/if}
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Subtotal (Services)</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
</div>
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
<div
class="my-2 space-y-1 rounded-md border-y border-fuchsia-100 bg-fuchsia-50/20 px-2 py-2"
>
{#each selectedBooking.discounts as d (d.id)}
<div class="flex items-center justify-between text-xs text-fuchsia-800">
<span class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
{:else if d.discount_source === 'referral'}
Referral Discount ({d.discount_percent}% Off)
{:else if d.campaign_name}
{d.campaign_name} ({d.discount_percent}% Off)
{:else}
Promo Campaign ({d.discount_percent}% Off)
{/if}
</span>
<span class="font-medium">-£{d.discount_amount.toFixed(2)}</span>
</div>
{/each}
</div>
<div class="flex items-center justify-between font-medium text-gray-900">
<span class="text-sm">Net Total</span>
<span
>£{(
selectedBooking.total_amount -
selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)
).toFixed(2)}</span
>
</div>
{/if}
{#if hasVAT}
<div class="mt-2 border-t border-gray-200 pt-2">
<div class="flex items-center justify-between text-xs text-gray-500">
<span>Net amount (excl. VAT)</span>
<span class="font-medium text-gray-700">£{totalNet.toFixed(2)}</span>
</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>VAT ({businessSettings?.default_vat_rate ?? 20}%)</span>
<span class="font-medium text-gray-700">£{totalVAT.toFixed(2)}</span>
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
<span class="font-semibold text-green-700">
£{(selectedBooking.payments ?? [])
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0)
.toFixed(2)}
</span>
</div>
{#if totalRefunds > 0}
<div class="flex items-center justify-between">
<span class="text-sm text-red-600">Refunds</span>
<span class="font-semibold text-red-600">-£{totalRefunds.toFixed(2)}</span>
</div>
{/if}
{#if balanceDue > 0.01}
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
</span>
<span class="text-lg font-bold text-red-600">
£{balanceDue.toFixed(2)}
</span>
</div>
{/if}
</div>
</div>
{#if (selectedBooking.payments && selectedBooking.payments.length > 0) || (selectedBooking.refunds && selectedBooking.refunds.length > 0)}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Payment History
</h3>
<div class="space-y-3">
{#each selectedBooking.payments as payment, index (index)}
<div class="rounded-md border border-gray-300 bg-white p-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900"
>{getPaymentName(
payment,
index,
selectedBooking.payments,
selectedBooking.discounts
)}</span
>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{payment.status === 'completed'
? 'bg-green-100 text-green-800'
: payment.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'}"
>
{payment.status}
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
{#if payment.payment_method === 'discount'}
Applied automatically on completion
{:else}
{payment.payment_type.charAt(0).toUpperCase() +
payment.payment_type.slice(1)} payment
{#if payment.card_last4}
, Card ending in {payment.card_last4}
{/if}
{/if}
</div>
{#if payment.is_vat_applicable}
<div class="mt-2 text-xs text-gray-600">
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
{#if payment.vat_amount}
<div>
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount.toFixed(2)}
</div>
{/if}
</div>
{/if}
<div class="mt-1 text-xs text-gray-400">
{new SvelteDate(payment.created_at).toLocaleString()}
</div>
</div>
<div class="text-right font-semibold">
{payment.payment_method === 'discount' ? '-' : ''}£{payment.amount.toFixed(2)}
</div>
</div>
</div>
{/each}
{#each selectedBooking.refunds ?? [] as refund (refund.id)}
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-red-700">Refund</span>
<span
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
>
{refund.status}
</span>
</div>
<div class="mt-1 text-xs text-red-600">
{refund.reason || 'Refund processed'}
</div>
<div class="mt-1 text-xs text-gray-400">
{new SvelteDate(refund.created_at).toLocaleString()}
</div>
</div>
<div class="text-right font-semibold text-red-600">
-£{refund.amount.toFixed(2)}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<div class="flex flex-col gap-2 border-t px-4 py-3">
<div class="flex gap-2">
{#if isCancellable}
<Button
variant="destructive"
size="sm"
class="flex-1"
onclick={() => (showCancelConfirm = true)}
>
Cancel Booking
</Button>
{#if canEditBooking}
<Button
variant="outline"
size="sm"
class="flex-1"
onclick={() => {
showEditModal = true;
}}
>
Edit/Reschedule
</Button>
{/if}
{/if}
</div>
{#if pendingEditRequest}
{@const timeChanged =
pendingEditRequest.original.start_time &&
pendingEditRequest.proposed?.start_time &&
pendingEditRequest.original.start_time !== pendingEditRequest.proposed.start_time}
{@const servicesChanged =
pendingEditRequest.proposed?.services?.length &&
JSON.stringify(pendingEditRequest.original.services?.map((s) => s.name)) !==
JSON.stringify(pendingEditRequest.proposed.services?.map((s) => s.name))}
{@const originalNames = (pendingEditRequest.original.services ?? []).map((s) => s.name)}
{@const proposedNames = (pendingEditRequest.proposed.services ?? []).map((s) => s.name)}
{@const addedServices = proposedNames.filter((n) => !originalNames.includes(n))}
{@const removedServices = originalNames.filter((n) => !proposedNames.includes(n))}
{#if timeChanged || servicesChanged}
<div class="rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm">
<div class="flex items-start gap-2.5">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div class="text-amber-900">
<p class="font-medium">Awaiting admin approval</p>
<p class="mt-0.5 text-amber-700">
{#if timeChanged}
Reschedule requested from {parseWallClockDate(
pendingEditRequest.original.start_time!
).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})} to {parseWallClockDate(
pendingEditRequest.proposed.start_time!
).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}
{/if}
</p>
{#if addedServices.length > 0}
<p class="mt-1 text-amber-700">
<span class="font-medium text-green-700">Services added:</span>
{addedServices.join(', ')}
</p>
{/if}
{#if removedServices.length > 0}
<p class="mt-0.5 text-amber-700">
<span class="font-medium text-red-600">Services removed:</span>
{removedServices.join(', ')}
</p>
{/if}
<p class="mt-1 text-xs text-amber-500">
We'll let you know once it's been reviewed
</p>
</div>
</div>
</div>
{/if}
{/if}
<div class="flex gap-2">
{#if selectedBooking}
<Button
size="sm"
class="flex-1 hover:bg-gray-50"
variant="outline"
onclick={printReceipt}
>
Print Receipt
</Button>
{/if}
{#if isCompleted}
<Button
size="sm"
class="flex-1 hover:bg-fuchsia-50"
variant="outline"
onclick={() => (showTipModal = true)}
>
Leave a Tip
</Button>
{/if}
{#if depositOutstanding && selectedBooking?.status !== 'pending'}
<Button
size="sm"
class="flex-1 bg-amber-600 text-white hover:bg-amber-700"
onclick={() => (showPaymentModal = true)}
disabled={hasPendingEditRequest}
>
Pay Deposit
</Button>
{:else if canPayEarly && !hasPendingEditRequest}
<Button
size="sm"
class="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
onclick={() => (showPaymentModal = true)}
>
Pay Early
</Button>
{/if}
{#if hasPendingEditRequest && !depositOutstanding}
<div
class="flex-1 rounded-md border border-dashed border-gray-200 bg-gray-50/50 px-3 py-2 text-center text-xs text-gray-400"
>
Payments paused while awaiting approval
</div>
{/if}
</div>
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
</div>
</Modal.Content>
</Modal.Root>
{#if showEditModal && selectedBooking}
<EditRequestModal
bind:open={showEditModal}
booking={selectedBooking}
onSubmitted={() => {
showEditModal = false;
fetchBookingDetails();
}}
/>
{/if}
{#if showPaymentModal && selectedBooking}
<UserPaymentModal
booking={selectedBooking}
onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete}
{canSaveCards}
defaultPaymentType={depositOutstanding ? 'deposit' : undefined}
/>
{/if}
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
<Modal.Header>
<Modal.Title>Cancel Booking</Modal.Title>
<Modal.Description>
{@const hoursUntilAppt = Math.round(hoursUntilAppointment)}
{#if hasPayments}
<p>
Are you sure you want to cancel this booking? You have already made payments totalling
<span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
</p>
<div
class="mt-2 rounded-md border p-3 text-sm {hoursUntilAppt <
POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
? 'border-red-200 bg-red-50 text-red-800'
: 'border-amber-200 bg-amber-50 text-amber-800'}"
>
{#if hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS}
<p class="font-medium text-green-800">Full Refund</p>
<p class="mt-1">
You have given over {POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. You will receive
a
<strong>full refund</strong> of <strong>£{totalPaid.toFixed(2)}</strong>. Nothing
will be deducted.
</p>
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
<p class="font-medium">Partial Refund</p>
<p class="mt-1">
Based on your notice period ({hoursUntilAppt}h), up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT *
100}% of the total (£{protectedDeposit.toFixed(2)}) is treated as a protected
deposit and will be retained. The remaining
<strong>£{estimatedRefund.toFixed(2)}</strong> will be refunded.
</p>
{:else}
<p class="font-semibold text-red-900">Cancelling With No Refund</p>
<p class="mt-1">
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. The full
amount you have paid (<strong>£{totalPaid.toFixed(2)}</strong>) will be retained to
cover the lost slot. It will also count as a <strong>no-show</strong> toward your booking
history (2 no-shows within 6 months would require deposits on future bookings).
</p>
{/if}
<p class="mt-2 text-xs">
Refunds are processed via Square and may take 35 business days. See our
<PolicyPopover>
{#snippet trigger()}
<span class="underline">cancellation policy</span>
{/snippet}
</PolicyPopover>
for full details.
</p>
</div>
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
<p>Are you sure you want to cancel this booking?</p>
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
<p class="font-medium">Clean Cancellation</p>
<p class="mt-1">
This booking has no payments and is being cancelled with
{hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS ? 'plenty of' : 'sufficient'} notice.
It will be removed completely and will not appear in your booking history.
</p>
</div>
{:else}
<p>Are you sure you want to cancel this booking?</p>
<div class="mt-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
<p class="font-semibold text-red-900">Short Notice Cancellation</p>
<p class="mt-1">
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. Cancelling
now counts as a
<strong>no-show</strong> (2 no-shows within 6 months will require deposits on future bookings).
</p>
</div>
{/if}
</Modal.Description>
</Modal.Header>
<Modal.Footer>
<Button variant="outline" onclick={() => (showCancelConfirm = false)}>Keep Booking</Button>
<Button variant="destructive" onclick={cancelBooking} disabled={cancelling}>
{cancelling ? 'Cancelling...' : 'Yes, Cancel'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<Modal.Root
open={showTipModal}
onOpenChange={(v) => {
if (!v) {
showTipModal = false;
tipAmount = 0;
selectedTipPreset = null;
customTipInput = '';
}
}}
>
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto">
<Modal.Header>
<Modal.Title>Leave a Tip</Modal.Title>
<Modal.Description>Show your appreciation for great service</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{#each tipPresets as preset (preset.pct)}
<button
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset ===
preset.amount
? 'bg-fuchsia-100'
: ''}"
onclick={() => selectTipPreset(preset.amount)}
type="button"
>
<div>£{preset.amount.toFixed(2)}</div>
<div class="text-xs font-normal text-gray-500">{preset.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={customTipInput}
oninput={handleCustomTip}
/>
</div>
</div>
<!-- Card Selection for Tip -->
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">Payment Method</span>
{#if tipLoadingCards}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else if tipSavedCards.length > 0}
<div class="space-y-2">
{#each tipSavedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId === card.id && !tipShowNewCard ? 'border-input bg-accent' : 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { tipSelectedCardId = card.id; tipShowNewCard = 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 tipSelectedCardId === card.id && !tipShowNewCard}
<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 {tipShowNewCard ? 'border-input bg-accent' : 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { tipSelectedCardId = null; tipShowNewCard = 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 tipShowNewCard}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{#if tipShowNewCard}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={tipNewCardNumber}
bind:cardExpiry={tipNewCardExpiry}
bind:cardCVC={tipNewCardCVC}
bind:saveCard={tipSaveCardFuture}
showSaveCard={canSaveCards}
onfieldblur={handleTipFieldBlur}
onfieldinput={handleTipFieldInput}
/>
{#if tipNewCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{tipNewCardError}</div>
{/if}
</div>
{/if}
{:else}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={tipNewCardNumber}
bind:cardExpiry={tipNewCardExpiry}
bind:cardCVC={tipNewCardCVC}
bind:saveCard={tipSaveCardFuture}
showSaveCard={canSaveCards}
onfieldblur={handleTipFieldBlur}
onfieldinput={handleTipFieldInput}
/>
{#if tipNewCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{tipNewCardError}</div>
{/if}
</div>
{/if}
</div>
</div>
<Modal.Footer>
<Button variant="outline" onclick={() => (showTipModal = false)}>Cancel</Button>
<Button
class="hover:bg-fuchsia-50"
onclick={submitTip}
disabled={tipAmount <= 0 || !isTipCardValid || tipProcessing}
loading={tipProcessing}
>
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>