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
1317 lines
47 KiB
Svelte
1317 lines
47 KiB
Svelte
<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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
};
|
||
|
||
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 3–5 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>
|