Add loyalty stamp card checkbox UI to both payment modals. Admin modal: checkbox with card count, applies redemption via /apply-redemption before payment, adjusts amounts accordingly. Customer modal: add amount_paid === 0 guard, extract LOYALTY_DISCOUNT_RATE constant. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1143 lines
36 KiB
Svelte
1143 lines
36 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
import { toast } from 'svelte-sonner';
|
|
import * as Dialog from '$lib/components/ui/dialog';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
import type { Booking } from '$lib/types/booking';
|
|
import type { UserSavedCard } from '$lib/types';
|
|
import CardInput from '$lib/components/payments/CardInput.svelte';
|
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
|
|
const LOYALTY_DISCOUNT_RATE = 0.1;
|
|
|
|
interface Props {
|
|
booking: Booking;
|
|
onClose: () => void;
|
|
onComplete: () => void;
|
|
canSaveCards?: boolean;
|
|
defaultPaymentType?: 'full' | 'partial' | 'deposit';
|
|
}
|
|
|
|
let { booking, onClose, onComplete, canSaveCards = true, defaultPaymentType }: Props = $props();
|
|
|
|
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
|
|
|
|
let status = $state<PaymentStatus>('idle');
|
|
let error = $state<string | null>(null);
|
|
let paymentResult = $state<{
|
|
id: string;
|
|
amount: number;
|
|
card_brand?: string;
|
|
card_last4?: string;
|
|
payment_type: string;
|
|
} | null>(null);
|
|
|
|
// Card selection state
|
|
let paymentMethods = $state<UserSavedCard[]>([]);
|
|
let paymentMethodsLoading = $state(false);
|
|
let selectedPaymentMethod = $state<string | null>(null);
|
|
let showNewCardForm = $state(false);
|
|
let showCardList = $state(false);
|
|
|
|
let stamps = $state(0);
|
|
let useLoyalty = $state(false);
|
|
|
|
// Auto-select first saved card when methods load
|
|
$effect(() => {
|
|
if (paymentMethods.length > 0 && !selectedPaymentMethod && !showNewCardForm) {
|
|
const defaultCard = paymentMethods.find((m) => m.is_default) ?? paymentMethods[0];
|
|
selectedPaymentMethod = defaultCard.id;
|
|
}
|
|
});
|
|
|
|
// New card form fields
|
|
let newCardNumber = $state('');
|
|
let newCardExpiry = $state('');
|
|
let newCardCVC = $state('');
|
|
let saveCardForFuture = $state(false);
|
|
|
|
function formatAndPreserveCursor(
|
|
input: HTMLInputElement,
|
|
formatter: (val: string) => string,
|
|
charRegex: RegExp = /\d/
|
|
): string {
|
|
const rawValue = input.value;
|
|
const oldSelectionStart = input.selectionStart || 0;
|
|
|
|
let charsBeforeCursor = 0;
|
|
for (let i = 0; i < oldSelectionStart; i++) {
|
|
if (charRegex.test(rawValue[i])) {
|
|
charsBeforeCursor++;
|
|
}
|
|
}
|
|
|
|
const formatted = formatter(rawValue);
|
|
input.value = formatted;
|
|
|
|
let newSelectionStart = 0;
|
|
let charsFound = 0;
|
|
for (let i = 0; i < formatted.length; i++) {
|
|
if (charsFound === charsBeforeCursor) {
|
|
break;
|
|
}
|
|
if (charRegex.test(formatted[i])) {
|
|
charsFound++;
|
|
}
|
|
newSelectionStart++;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
input.setSelectionRange(newSelectionStart, newSelectionStart);
|
|
});
|
|
|
|
return formatted;
|
|
}
|
|
|
|
function formatCardNumber(value: string): string {
|
|
const digits = value.replace(/\D/g, '').substring(0, 16);
|
|
const groups = digits.match(/.{1,4}/g);
|
|
return groups ? groups.join(' ') : digits;
|
|
}
|
|
|
|
function handleCardNumberInput(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const formatted = formatAndPreserveCursor(input, formatCardNumber);
|
|
newCardNumber = formatted;
|
|
}
|
|
|
|
function formatExpiryDate(value: string): string {
|
|
const digits = value.replace(/\D/g, '').substring(0, 4);
|
|
if (digits.length >= 3) {
|
|
return digits.substring(0, 2) + '/' + digits.substring(2);
|
|
}
|
|
return digits;
|
|
}
|
|
|
|
function handleExpiryInput(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
|
|
newCardExpiry = formatted;
|
|
}
|
|
|
|
function handleCvcInput(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
|
|
newCardCVC = formatted;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
let expiryParts = $derived(parseExpiryParts(newCardExpiry));
|
|
|
|
let isExpiryInPast = $derived(
|
|
expiryParts !== null &&
|
|
(() => {
|
|
const expiryDate = new SvelteDate(expiryParts.year, expiryParts.month);
|
|
return expiryDate < new SvelteDate();
|
|
})()
|
|
);
|
|
|
|
let hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
|
|
|
|
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;
|
|
}
|
|
|
|
let cardFormValid = $derived(
|
|
isValidLuhn(newCardNumber) &&
|
|
expiryParts !== null &&
|
|
newCardCVC.length >= 3 &&
|
|
!isExpiryInPast
|
|
);
|
|
|
|
let cardSelected = $derived(
|
|
(selectedPaymentMethod !== null && paymentMethods.length > 0) ||
|
|
(showNewCardForm && cardFormValid) ||
|
|
(paymentMethods.length === 0 && cardFormValid)
|
|
);
|
|
|
|
let cardValidationError = $derived(
|
|
!cardSelected
|
|
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
|
|
? 'Please select a card'
|
|
: !isValidLuhn(newCardNumber) && newCardNumber.length > 0
|
|
? 'Invalid card number'
|
|
: hasInvalidMonth
|
|
? 'Invalid expiry month'
|
|
: isExpiryInPast
|
|
? 'Expiry date in the past'
|
|
: !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
|
|
? 'Enter expiry as MM/YY'
|
|
: newCardCVC.length < 3 && newCardCVC.length > 0
|
|
? 'Enter your CVC number'
|
|
: paymentMethods.length === 0 && !showNewCardForm && newCardNumber.length === 0
|
|
? 'Please enter card details'
|
|
: 'Please complete all card fields'
|
|
: null
|
|
);
|
|
|
|
// Partial payment amount (in pounds, user enters)
|
|
let partialAmount = $state<string>('');
|
|
let lastValidPartialAmount = $state<string>('');
|
|
|
|
// Campaign discount preview — fetched on mount to show eligible discounts
|
|
let discountPreview = $state<{
|
|
eligible: boolean;
|
|
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
|
|
original_total: number;
|
|
discounted_total: number;
|
|
} | null>(null);
|
|
|
|
// Derived values
|
|
let depositOutstanding = $derived(booking.deposit_required && !booking.deposit_paid);
|
|
|
|
// Auto-select a sensible default payment type based on the booking's deposit
|
|
// state. The backend will split the charge into deposit + non-deposit records
|
|
// when appropriate, so this choice mainly controls the button label and amount.
|
|
let defaultType = $derived(
|
|
defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full')
|
|
);
|
|
let paymentType = $state<'full' | 'partial' | 'deposit'>(defaultType as 'full' | 'partial' | 'deposit');
|
|
|
|
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// Payment lock state
|
|
let lockTimer = $state(-1);
|
|
let lockAcquired = $state(false);
|
|
let lockInterval: ReturnType<typeof setInterval> | null = null;
|
|
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
let servicesSubtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0));
|
|
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
|
|
|
|
let totalPaid = $derived(
|
|
booking.payments
|
|
?.filter((p) => p.status === 'completed')
|
|
.reduce((sum, p) => sum + p.amount, 0) || 0
|
|
);
|
|
|
|
let amountRemaining = $derived(booking.total_amount - totalPaid);
|
|
|
|
let loyaltyEligible = $derived(
|
|
stamps >= 10 &&
|
|
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
|
|
booking.total_amount > 0 &&
|
|
booking.amount_paid === 0
|
|
);
|
|
|
|
let loyaltyDiscount = $derived(
|
|
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
|
|
);
|
|
|
|
// Deposit policy warning text — dynamic based on booking state
|
|
let expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
|
|
let depositPolicyWarning = $derived<string | null>({
|
|
get text(): string | null {
|
|
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
|
|
if (booking.deposit_required) {
|
|
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
|
|
}
|
|
if (totalPaid > 0 || booking.amount_due > 0) {
|
|
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
|
|
}
|
|
return null;
|
|
}
|
|
}.text);
|
|
|
|
let isScenarioA = $derived(depositOutstanding);
|
|
|
|
let isScenarioB = $derived(!depositOutstanding && totalPaid < booking.total_amount);
|
|
|
|
let partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
|
|
let partialAmountValid = $derived(
|
|
paymentType === 'partial' &&
|
|
partialAmount !== '' &&
|
|
!isNaN(partialAmountNum) &&
|
|
partialAmountNum > 0 &&
|
|
partialAmountNum <= amountRemaining &&
|
|
/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
|
);
|
|
|
|
let partialValidationError = $derived(
|
|
paymentType === 'partial' && !partialAmountValid
|
|
? partialAmount === ''
|
|
? 'Enter an amount'
|
|
: !/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
|
? 'Invalid amount format'
|
|
: partialAmountNum <= 0
|
|
? 'Amount must be greater than 0'
|
|
: partialAmountNum > amountRemaining
|
|
? 'Amount exceeds balance'
|
|
: 'Invalid amount'
|
|
: null
|
|
);
|
|
|
|
let payButtonDisabled = $derived(
|
|
status === 'processing' ||
|
|
!cardSelected ||
|
|
(paymentType === 'partial' && !partialAmountValid) ||
|
|
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
|
|
);
|
|
|
|
let payButtonError = $derived(
|
|
status === 'processing'
|
|
? null
|
|
: !cardSelected
|
|
? cardValidationError
|
|
: paymentType === 'partial'
|
|
? partialValidationError
|
|
: null
|
|
);
|
|
|
|
function campaignDiscountCents(): number {
|
|
return discountPreview?.eligible
|
|
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
|
|
: 0;
|
|
}
|
|
|
|
function formatCurrency(pence: number): string {
|
|
return new Intl.NumberFormat('en-GB', {
|
|
style: 'currency',
|
|
currency: 'GBP'
|
|
}).format(pence / 100);
|
|
}
|
|
|
|
function formatTimer(seconds: number): string {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
function clearLockIntervals() {
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
countdownInterval = null;
|
|
}
|
|
if (lockInterval) {
|
|
clearInterval(lockInterval);
|
|
lockInterval = null;
|
|
}
|
|
}
|
|
|
|
async function acquireLock() {
|
|
try {
|
|
const response = await fetch(`/api/bookings/${booking.id}/payment-lock`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
lockAcquired = true;
|
|
lockTimer = 300;
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to acquire payment lock:', err);
|
|
}
|
|
}
|
|
|
|
async function releaseLock() {
|
|
lockAcquired = false;
|
|
clearLockIntervals();
|
|
try {
|
|
await fetch(`/api/bookings/${booking.id}/payment-lock`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to release payment lock:', err);
|
|
}
|
|
}
|
|
|
|
function startCountdown() {
|
|
countdownInterval = setInterval(() => {
|
|
lockTimer = Math.max(0, lockTimer - 1);
|
|
}, 1000);
|
|
}
|
|
|
|
function startRenewal() {
|
|
lockInterval = setInterval(async () => {
|
|
try {
|
|
const response = await fetch(`/api/bookings/${booking.id}/payment-lock`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
lockTimer = 300;
|
|
lockAcquired = true;
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to renew payment lock:', err);
|
|
}
|
|
}, 60000);
|
|
}
|
|
|
|
function generateIdempotencyKey(): string {
|
|
const array = new Uint8Array(16);
|
|
if (typeof window !== 'undefined' && window.crypto) {
|
|
window.crypto.getRandomValues(array);
|
|
} else {
|
|
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
|
|
}
|
|
array[6] = (array[6] & 0x0f) | 0x40;
|
|
array[8] = (array[8] & 0x3f) | 0x80;
|
|
return [...array]
|
|
.map((b, i) => {
|
|
const hex = b.toString(16).padStart(2, '0');
|
|
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
|
|
return hex;
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
async function fetchPaymentMethods() {
|
|
if (!authStore.isAuthenticated) return;
|
|
paymentMethodsLoading = true;
|
|
try {
|
|
const response = await fetch('/api/user/payment-methods', {
|
|
headers: {
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
paymentMethods = await response.json();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch payment methods:', err);
|
|
} finally {
|
|
paymentMethodsLoading = false;
|
|
}
|
|
}
|
|
|
|
async function fetchLoyaltyData() {
|
|
if (!authStore.isAuthenticated) return;
|
|
try {
|
|
const response = await fetch('/api/user/loyalty', {
|
|
headers: {
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
stamps = data.stamps ?? 0;
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch loyalty data:', err);
|
|
}
|
|
}
|
|
|
|
function formatCardExpiry(month: number, year: number): string {
|
|
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
|
|
}
|
|
|
|
function sanitizeAmountInput(value: string): string {
|
|
// Remove all non-numeric chars except .
|
|
const cleaned = value.replace(/[^0-9.]/g, '');
|
|
// Keep only the first .
|
|
const firstDot = cleaned.indexOf('.');
|
|
if (firstDot !== -1) {
|
|
const integerPart = cleaned.substring(0, firstDot);
|
|
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
|
return integerPart + '.' + decimalPart;
|
|
}
|
|
return cleaned;
|
|
}
|
|
|
|
function handlePartialAmountInput(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const sanitized = sanitizeAmountInput(input.value);
|
|
// Only update if the sanitized value passes the regex (max 2 decimal places)
|
|
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
|
|
partialAmount = sanitized;
|
|
lastValidPartialAmount = sanitized;
|
|
} else {
|
|
// Reject input with more than 2 decimal places — revert to last valid
|
|
partialAmount = lastValidPartialAmount;
|
|
input.value = lastValidPartialAmount;
|
|
}
|
|
}
|
|
|
|
async function makePayment(paymentType: string, amountCents: number) {
|
|
status = 'processing';
|
|
error = null;
|
|
|
|
// Apply loyalty redemption before payment
|
|
if (useLoyalty) {
|
|
try {
|
|
const redemptionResponse = await fetch(
|
|
`/api/bookings/${booking.id}/apply-redemption`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
}
|
|
);
|
|
if (!redemptionResponse.ok) {
|
|
const errData = await redemptionResponse.text();
|
|
throw new Error(errData || 'Failed to apply loyalty discount');
|
|
}
|
|
} catch (err) {
|
|
status = 'error';
|
|
const msg =
|
|
err instanceof Error ? err.message : 'Failed to apply loyalty discount';
|
|
error = msg;
|
|
toast.error(msg);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let cardId: string | undefined;
|
|
let newCardToken: string | undefined;
|
|
let saveCard = false;
|
|
|
|
if (selectedPaymentMethod) {
|
|
cardId = selectedPaymentMethod;
|
|
} else if (newCardNumber) {
|
|
newCardToken = newCardNumber;
|
|
saveCard = saveCardForFuture;
|
|
} else {
|
|
status = 'error';
|
|
error = 'Please select or enter card details';
|
|
toast.error('Please select or enter card details');
|
|
return;
|
|
}
|
|
|
|
const idempotencyKey = generateIdempotencyKey();
|
|
|
|
try {
|
|
const response = await fetch(`/api/bookings/${booking.id}/payment`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
amount: amountCents,
|
|
payment_type: paymentType,
|
|
card_id: cardId,
|
|
new_card_token: newCardToken,
|
|
save_card: saveCard,
|
|
idempotency_key: idempotencyKey
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errData = await response.text();
|
|
throw new Error(errData || 'Failed to initiate payment');
|
|
}
|
|
|
|
const data = await response.json();
|
|
// Payment is synchronous (completed immediately)
|
|
status = 'success';
|
|
paymentResult = {
|
|
id: data.id,
|
|
amount: data.amount,
|
|
card_brand: data.card_brand,
|
|
card_last4: data.card_last4,
|
|
payment_type: data.payment_type
|
|
};
|
|
toast.success('Payment successful');
|
|
fetchPaymentMethods();
|
|
onComplete();
|
|
releaseLock();
|
|
} catch (err) {
|
|
status = 'error';
|
|
const msg = err instanceof Error ? err.message : 'Payment declined';
|
|
error = msg;
|
|
toast.error(`${msg}. Please try again or use another card.`);
|
|
releaseLock();
|
|
}
|
|
}
|
|
|
|
function handlePayDeposit() {
|
|
const depositCents = booking.deposit_amount
|
|
? Math.round(booking.deposit_amount * 100)
|
|
: Math.round(booking.total_amount * 0.2 * 100);
|
|
makePayment('deposit', depositCents);
|
|
}
|
|
|
|
function handlePayFull() {
|
|
const fullCents = Math.round(booking.amount_due * 100);
|
|
const discountedCents = Math.max(0, fullCents - campaignDiscountCents() - loyaltyDiscount);
|
|
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
|
|
makePayment(paymentType, discountedCents);
|
|
}
|
|
|
|
function handlePayPartial() {
|
|
if (!partialAmountValid) {
|
|
toast.error('Please enter a valid amount');
|
|
return;
|
|
}
|
|
makePayment('partial', Math.round(partialAmountNum * 100));
|
|
}
|
|
|
|
function handleClose() {
|
|
releaseLock();
|
|
stopPolling();
|
|
onClose();
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollingInterval) {
|
|
clearInterval(pollingInterval);
|
|
pollingInterval = null;
|
|
}
|
|
}
|
|
|
|
// Fetch payment methods on mount if authenticated
|
|
$effect(() => {
|
|
if (authStore.isAuthenticated) {
|
|
fetchPaymentMethods();
|
|
fetchLoyaltyData();
|
|
}
|
|
});
|
|
|
|
// Cleanup on unmount
|
|
$effect(() => {
|
|
return () => {
|
|
stopPolling();
|
|
};
|
|
});
|
|
|
|
onMount(async () => {
|
|
if (booking.status === 'pending_release') {
|
|
acquireLock();
|
|
startCountdown();
|
|
startRenewal();
|
|
}
|
|
|
|
// Fetch eligible campaign discounts
|
|
try {
|
|
const resp = await fetch(`/api/bookings/${booking.id}/discount-preview`, {
|
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
|
});
|
|
if (resp.ok) {
|
|
discountPreview = await resp.json();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch discount preview:', err);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (booking.status === 'pending_release' && lockAcquired) {
|
|
releaseLock();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
|
<Dialog.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md">
|
|
<Dialog.Header>
|
|
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
|
|
{#if booking.id}
|
|
<div class="mt-1 text-sm text-gray-500">Booking ID: {booking.id}</div>
|
|
{/if}
|
|
</Dialog.Header>
|
|
|
|
{#if status === 'idle' || status === 'processing' || status === 'error'}
|
|
<div class="space-y-4">
|
|
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
|
|
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
|
|
<div
|
|
class="flex items-center gap-2 rounded-md border p-3 text-sm {lockTimer <= 60
|
|
? 'border-amber-200 bg-amber-50 text-amber-800'
|
|
: 'border-blue-200 bg-blue-50 text-blue-800'}"
|
|
>
|
|
<svg
|
|
class="h-4 w-4 shrink-0"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
<path d="M7 11V7a5 5 0 0110 0v4" />
|
|
</svg>
|
|
<span>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment processing</span>
|
|
</div>
|
|
{:else if booking.status === 'pending_release' && (lockTimer === 0 || !lockAcquired)}
|
|
<div
|
|
class="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"
|
|
>
|
|
<svg
|
|
class="h-4 w-4 shrink-0"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
<path d="M7 11V7a5 5 0 0110 0v4" />
|
|
</svg>
|
|
<span>Slot no longer secured — please close and retry</span>
|
|
</div>
|
|
{/if}
|
|
<!-- Service Breakdown -->
|
|
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
|
|
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
|
|
<div class="space-y-2">
|
|
{#each booking.services ?? [] as service, index (index)}
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
|
|
<span class="font-medium">
|
|
{service.override_price
|
|
? formatCurrency(Math.round(service.override_price * 100))
|
|
: service.price
|
|
? formatCurrency(Math.round(service.price * 100))
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loyalty Redemption Checkbox -->
|
|
{#if loyaltyEligible}
|
|
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4">
|
|
<div class="mb-2 text-sm font-semibold text-fuchsia-800">Available Savings</div>
|
|
<div class="flex items-start gap-3">
|
|
<Checkbox
|
|
id="use-loyalty"
|
|
bind:checked={useLoyalty}
|
|
disabled={status === 'processing'}
|
|
/>
|
|
<label for="use-loyalty" class="cursor-pointer select-none">
|
|
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
|
|
<div class="mt-0.5 text-xs text-fuchsia-700">
|
|
{stamps} stamps available · {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Applied Discounts -->
|
|
{#if booking.discounts && booking.discounts.length > 0}
|
|
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
|
|
<div class="mb-3 flex items-center justify-between">
|
|
<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
|
|
<svg class="h-4 w-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
|
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
|
</svg>
|
|
Applied Discounts
|
|
</div>
|
|
{#if servicesSubtotal > 0}
|
|
<span class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600">
|
|
{((discountSum / servicesSubtotal) * 100).toFixed(0)}% Off Total
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<div class="space-y-2 text-sm">
|
|
{#each booking.discounts as d}
|
|
<div class="flex items-center justify-between text-gray-600">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
|
|
<span>
|
|
{#if d.discount_source === 'loyalty'}
|
|
Loyalty Stamp Card (10% Off)
|
|
{:else if d.campaign_name}
|
|
{d.campaign_name}
|
|
{:else}
|
|
Promo Campaign ({d.discount_percent}% Off)
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Financial Summary -->
|
|
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-gray-600">Total</span>
|
|
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
|
|
>
|
|
</div>
|
|
{#if discountPreview?.eligible}
|
|
{#each discountPreview.discounts as d}
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-gray-600">{d.name}</span>
|
|
<span class="font-medium text-green-700">-{formatCurrency(Math.round(d.amount * 100))}</span>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-gray-600">Amount Paid</span>
|
|
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
|
|
</div>
|
|
{#if useLoyalty && loyaltyDiscount > 0}
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
|
|
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
|
|
</div>
|
|
{/if}
|
|
<div class="flex justify-between border-t border-gray-200 pt-2">
|
|
<span class="font-semibold text-gray-900">Amount Remaining</span>
|
|
<span class="text-lg font-bold text-red-600">
|
|
{formatCurrency(Math.max(0, Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount))}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card Selection (only when idle) -->
|
|
{#if status === 'idle' && authStore.isAuthenticated}
|
|
{#if paymentMethodsLoading}
|
|
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
|
{:else if paymentMethods.length > 0 && !showNewCardForm}
|
|
<!-- Saved card selected -->
|
|
<div class="space-y-3">
|
|
{#if selectedPaymentMethod}
|
|
{#each paymentMethods as method (method.id)}
|
|
{#if method.id === selectedPaymentMethod}
|
|
<div
|
|
class="flex items-center justify-between rounded-lg border border-input bg-fuchsia-100 p-3"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"
|
|
>
|
|
{method.brand}
|
|
</div>
|
|
<div class="text-sm">
|
|
<span class="font-mono">**** {method.last_4}</span>
|
|
<span class="ml-2 text-gray-500">
|
|
{formatCardExpiry(method.exp_month, method.exp_year)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span class="text-xs font-medium text-foreground">Selected</span>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
{#if canSaveCards}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="text-sm text-gray-600"
|
|
onclick={() => (showCardList = !showCardList)}
|
|
>
|
|
{showCardList ? 'Hide other cards' : 'Use a different card'}
|
|
</Button>
|
|
{/if}
|
|
|
|
{#if showCardList}
|
|
<div class="space-y-2">
|
|
{#if canSaveCards}
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-lg border p-3 transition-colors hover:border-gray-300"
|
|
onclick={() => {
|
|
showNewCardForm = true;
|
|
showCardList = false;
|
|
selectedPaymentMethod = null;
|
|
}}
|
|
>
|
|
<div class="text-sm font-medium text-gray-700">Enter card details</div>
|
|
<svg
|
|
class="h-4 w-4 text-gray-400"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M9 18l6-6-6-6" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
{#each paymentMethods as method (method.id)}
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-lg border p-3 {selectedPaymentMethod ===
|
|
method.id
|
|
? 'border-input bg-fuchsia-100'
|
|
: 'border-input hover:bg-fuchsia-50'}"
|
|
onclick={() => {
|
|
selectedPaymentMethod = method.id;
|
|
showNewCardForm = false;
|
|
showCardList = false;
|
|
}}
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"
|
|
>
|
|
{method.brand}
|
|
</div>
|
|
<div class="text-sm">
|
|
<span class="font-mono">**** {method.last_4}</span>
|
|
<span class="ml-2 text-gray-500">
|
|
{formatCardExpiry(method.exp_month, method.exp_year)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{#if selectedPaymentMethod === method.id}
|
|
<span class="text-xs font-medium text-foreground">Selected</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<CardInput
|
|
bind:cardNumber={newCardNumber}
|
|
bind:cardExpiry={newCardExpiry}
|
|
bind:cardCVC={newCardCVC}
|
|
bind:saveCard={saveCardForFuture}
|
|
showSaveCard={canSaveCards}
|
|
disabled={false}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if depositPolicyWarning}
|
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
|
<p class="font-semibold text-amber-900">Cancellation & Deposit Policy</p>
|
|
<p class="mt-1">{depositPolicyWarning}</p>
|
|
<PolicyPopover>
|
|
{#snippet trigger()}
|
|
<span class="mt-1 inline-block underline">Full cancellation policy →</span>
|
|
{/snippet}
|
|
</PolicyPopover>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Scenario A: Deposit needed -->
|
|
{#if isScenarioA}
|
|
<div class="space-y-3">
|
|
<!-- Payment type radio buttons -->
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<button
|
|
type="button"
|
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
|
|
'deposit'
|
|
? 'border-input bg-fuchsia-100 text-foreground'
|
|
: 'border-input hover:bg-fuchsia-50'}"
|
|
onclick={() => (paymentType = 'deposit')}
|
|
>
|
|
Pay Deposit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
|
|
'full'
|
|
? 'border-input bg-fuchsia-100 text-foreground'
|
|
: 'border-input hover:bg-fuchsia-50'}"
|
|
onclick={() => (paymentType = 'full')}
|
|
>
|
|
Pay in Full
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Pay button -->
|
|
{#if payButtonError}
|
|
<p class="text-sm text-red-600">{payButtonError}</p>
|
|
{/if}
|
|
<Button
|
|
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
|
class="w-full"
|
|
loading={status === 'processing'}
|
|
disabled={payButtonDisabled}
|
|
>
|
|
{#if paymentType === 'deposit'}
|
|
Pay Deposit ({formatCurrency(
|
|
booking.deposit_amount
|
|
? Math.round(booking.deposit_amount * 100)
|
|
: Math.round(booking.total_amount * 0.2 * 100)
|
|
)})
|
|
{:else}
|
|
Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))}
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Scenario B: Partial or full payment -->
|
|
{#if isScenarioB}
|
|
<div class="space-y-3">
|
|
<!-- Payment type radio buttons -->
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<button
|
|
type="button"
|
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
|
|
'full'
|
|
? 'border-input bg-fuchsia-100 text-foreground'
|
|
: 'border-input hover:bg-fuchsia-50'}"
|
|
onclick={() => (paymentType = 'full')}
|
|
>
|
|
Pay in Full
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
|
|
'partial'
|
|
? 'border-input bg-fuchsia-100 text-foreground'
|
|
: 'border-input hover:bg-fuchsia-50'}"
|
|
onclick={() => (paymentType = 'partial')}
|
|
>
|
|
Pay Part
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Partial amount input (only when partial selected) -->
|
|
{#if paymentType === 'partial'}
|
|
<div>
|
|
<label for="partial-amount" class="text-sm font-medium text-gray-700">
|
|
Partial Payment 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="partial-amount"
|
|
type="text"
|
|
inputmode="decimal"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
value={partialAmount}
|
|
oninput={handlePartialAmountInput}
|
|
class="pl-7"
|
|
disabled={status !== 'idle'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Pay button -->
|
|
{#if payButtonError}
|
|
<p class="text-sm text-red-600">{payButtonError}</p>
|
|
{/if}
|
|
<Button
|
|
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
|
class="w-full"
|
|
loading={status === 'processing'}
|
|
disabled={payButtonDisabled}
|
|
>
|
|
{#if paymentType === 'partial'}
|
|
Pay {partialAmountValid
|
|
? formatCurrency(Math.round(partialAmountNum * 100))
|
|
: 'Part'}
|
|
{:else}
|
|
Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))}
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if status === 'error' && error}
|
|
<div class="rounded-md border border-red-200 bg-red-50 p-3">
|
|
<p class="text-sm text-red-800">{error}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Close button -->
|
|
<Button
|
|
variant="ghost"
|
|
onclick={handleClose}
|
|
class="w-full"
|
|
disabled={status === 'processing'}
|
|
>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
{:else if status === 'polling'}
|
|
<!-- Polling State -->
|
|
<div class="flex flex-col items-center justify-center py-8">
|
|
<div
|
|
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
|
|
></div>
|
|
<p class="text-lg font-medium text-gray-700">Processing payment...</p>
|
|
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
|
|
<Button variant="ghost" onclick={handleClose} class="mt-6">Cancel</Button>
|
|
</div>
|
|
{:else if status === 'success' && paymentResult}
|
|
<!-- Success State -->
|
|
<div class="space-y-4">
|
|
<div class="flex flex-col items-center justify-center py-4">
|
|
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-8 w-8 text-green-600"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-xl font-semibold text-gray-900">Payment Successful</h3>
|
|
</div>
|
|
|
|
<!-- Receipt -->
|
|
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
|
|
<div class="space-y-3">
|
|
<div class="flex justify-between">
|
|
<span class="text-sm text-gray-600">Amount</span>
|
|
<span class="font-semibold text-gray-900">
|
|
{formatCurrency(paymentResult.amount)}
|
|
</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-sm text-gray-600">Type</span>
|
|
<span class="font-medium text-gray-900 capitalize">
|
|
{paymentResult.payment_type}
|
|
</span>
|
|
</div>
|
|
{#if paymentResult.card_brand}
|
|
<div class="flex justify-between">
|
|
<span class="text-sm text-gray-600">Card</span>
|
|
<span class="font-medium text-gray-900">
|
|
{paymentResult.card_brand} ****{paymentResult.card_last4}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
<div class="flex justify-between">
|
|
<span class="text-sm text-gray-600">Status</span>
|
|
<span class="font-medium text-green-600">Completed</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button onclick={handleClose} class="w-full">Done</Button>
|
|
</div>
|
|
{/if}
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|