|
|
|
@@ -1,5 +1,6 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
|
|
|
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';
|
|
|
|
@@ -7,6 +8,8 @@
|
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
@@ -14,9 +17,10 @@
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onComplete: () => void;
|
|
|
|
|
canSaveCards?: boolean;
|
|
|
|
|
defaultPaymentType?: 'full' | 'partial' | 'deposit';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let { booking, onClose, onComplete, canSaveCards = true }: Props = $props();
|
|
|
|
|
let { booking, onClose, onComplete, canSaveCards = true, defaultPaymentType }: Props = $props();
|
|
|
|
|
|
|
|
|
|
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
|
|
|
|
|
|
|
|
|
@@ -37,6 +41,9 @@
|
|
|
|
|
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) {
|
|
|
|
@@ -193,13 +200,37 @@
|
|
|
|
|
// Partial payment amount (in pounds, user enters)
|
|
|
|
|
let partialAmount = $state<string>('');
|
|
|
|
|
let lastValidPartialAmount = $state<string>('');
|
|
|
|
|
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
|
|
|
|
|
|
|
|
|
|
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
// 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')
|
|
|
|
@@ -208,6 +239,31 @@
|
|
|
|
|
|
|
|
|
|
let amountRemaining = $derived(booking.total_amount - totalPaid);
|
|
|
|
|
|
|
|
|
|
let loyaltyEligible = $derived(
|
|
|
|
|
stamps >= 10 &&
|
|
|
|
|
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
|
|
|
|
|
booking.total_amount > 0
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let loyaltyDiscount = $derived(
|
|
|
|
|
useLoyalty ? Math.round(booking.total_amount * 100 * 0.1) : 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);
|
|
|
|
@@ -237,7 +293,10 @@
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let payButtonDisabled = $derived(
|
|
|
|
|
status === 'processing' || !cardSelected || (paymentType === 'partial' && !partialAmountValid)
|
|
|
|
|
status === 'processing' ||
|
|
|
|
|
!cardSelected ||
|
|
|
|
|
(paymentType === 'partial' && !partialAmountValid) ||
|
|
|
|
|
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let payButtonError = $derived(
|
|
|
|
@@ -250,6 +309,12 @@
|
|
|
|
|
: 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',
|
|
|
|
@@ -257,6 +322,80 @@
|
|
|
|
|
}).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) {
|
|
|
|
@@ -294,6 +433,23 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)}`;
|
|
|
|
|
}
|
|
|
|
@@ -329,6 +485,33 @@
|
|
|
|
|
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;
|
|
|
|
@@ -380,12 +563,15 @@
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -398,8 +584,9 @@
|
|
|
|
|
|
|
|
|
|
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, fullCents);
|
|
|
|
|
makePayment(paymentType, discountedCents);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handlePayPartial() {
|
|
|
|
@@ -411,6 +598,7 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleClose() {
|
|
|
|
|
releaseLock();
|
|
|
|
|
stopPolling();
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
@@ -426,6 +614,7 @@
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (authStore.isAuthenticated) {
|
|
|
|
|
fetchPaymentMethods();
|
|
|
|
|
fetchLoyaltyData();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -435,10 +624,36 @@
|
|
|
|
|
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-md overflow-y-auto">
|
|
|
|
|
<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}
|
|
|
|
@@ -448,6 +663,42 @@
|
|
|
|
|
|
|
|
|
|
{#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>
|
|
|
|
@@ -467,6 +718,65 @@
|
|
|
|
|
</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 · 10% off ({formatCurrency(Math.round(booking.total_amount * 100 * 0.1))})
|
|
|
|
|
</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">
|
|
|
|
@@ -474,16 +784,30 @@
|
|
|
|
|
<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">Amount Paid</span>
|
|
|
|
|
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<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.round(amountRemaining * 100))}
|
|
|
|
|
</span>
|
|
|
|
|
<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) -->
|
|
|
|
@@ -587,68 +911,29 @@
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- No saved cards or can't save cards - show new card form -->
|
|
|
|
|
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
|
|
|
|
|
<h4 class="mb-3 text-sm font-medium text-gray-700">Card Details</h4>
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label for="cardNumber" class="text-sm font-medium text-gray-700"
|
|
|
|
|
>Card Number</label
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
id="cardNumber"
|
|
|
|
|
type="text"
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
value={newCardNumber}
|
|
|
|
|
oninput={handleCardNumberInput}
|
|
|
|
|
placeholder="1234 5678 9012 3456"
|
|
|
|
|
maxlength={19}
|
|
|
|
|
class="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label for="cardExpiry" class="text-sm font-medium text-gray-700"
|
|
|
|
|
>Expiry (MM/YY)</label
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
id="cardExpiry"
|
|
|
|
|
type="text"
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
value={newCardExpiry}
|
|
|
|
|
oninput={handleExpiryInput}
|
|
|
|
|
placeholder="MM/YY"
|
|
|
|
|
maxlength={5}
|
|
|
|
|
class="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label for="cardCVC" class="text-sm font-medium text-gray-700">CVC</label>
|
|
|
|
|
<Input
|
|
|
|
|
id="cardCVC"
|
|
|
|
|
type="text"
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
value={newCardCVC}
|
|
|
|
|
oninput={handleCvcInput}
|
|
|
|
|
placeholder="123"
|
|
|
|
|
maxlength={4}
|
|
|
|
|
class="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{#if canSaveCards}
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<Checkbox id="saveCard" bind:checked={saveCardForFuture} />
|
|
|
|
|
<label for="saveCard" class="text-sm text-gray-700">
|
|
|
|
|
Save card for next time
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<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">
|
|
|
|
@@ -693,7 +978,7 @@
|
|
|
|
|
: Math.round(booking.total_amount * 0.2 * 100)
|
|
|
|
|
)})
|
|
|
|
|
{:else}
|
|
|
|
|
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
|
|
|
|
|
Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))}
|
|
|
|
|
{/if}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
@@ -743,7 +1028,7 @@
|
|
|
|
|
value={partialAmount}
|
|
|
|
|
oninput={handlePartialAmountInput}
|
|
|
|
|
class="pl-7"
|
|
|
|
|
disabled={status === 'processing'}
|
|
|
|
|
disabled={status !== 'idle'}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -764,7 +1049,7 @@
|
|
|
|
|
? formatCurrency(Math.round(partialAmountNum * 100))
|
|
|
|
|
: 'Part'}
|
|
|
|
|
{:else}
|
|
|
|
|
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
|
|
|
|
|
Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))}
|
|
|
|
|
{/if}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|