- Add Square payment integration (mock + handlers + UI): terminal/online payments, refunds, tips, saved cards, webhooks. Build-tagged dev/prod clients. - Redesign booking flow: Step 4 conditional (deposit only), Step 5 confirmation screen with booking ID, auto-submit on transition. - Redesign schedule modal: 2x3 button grid with Pay Deposit/Pay Early logic. - Add deposit warning banner at Step 1 for users with outstanding deposits. - Fix weekday conversion bug: Go 0=Sunday vs DB 0=Monday mismatch in 6 locations. - Fix timezone bug: UTC vs London time in closing hours validation. - Fix frontend error parsing: plain text backend errors now displayed correctly. - Fix crypto.randomUUID fallback for environments without Web Crypto. - Add 7 new regression tests: closing hours, advance check, active booking limit, weekday conversion, UTC/London, deposit snapshot, exceptional hours. - Fix 3 flaky tests: dynamic dates instead of fixed, no-show timing.
316 lines
8.9 KiB
Svelte
316 lines
8.9 KiB
Svelte
<script lang="ts">
|
|
import { toast } from 'svelte-sonner';
|
|
import * as Dialog from '$lib/components/ui/dialog';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
import type { Booking } from '$lib/types/booking';
|
|
|
|
interface Props {
|
|
booking: Booking;
|
|
onClose: () => void;
|
|
onComplete: (payment: PaymentResult) => void;
|
|
}
|
|
|
|
let { booking, onClose, onComplete }: Props = $props();
|
|
|
|
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
|
|
|
|
type PaymentResult = {
|
|
checkout_id: string;
|
|
status: string;
|
|
card_brand?: string;
|
|
last4?: string;
|
|
amount: number;
|
|
};
|
|
|
|
let status = $state<PaymentStatus>('idle');
|
|
let checkoutId = $state<string | null>(null);
|
|
let paymentResult = $state<PaymentResult | null>(null);
|
|
let error = $state<string | null>(null);
|
|
let amount = $derived(booking.total_amount);
|
|
let overrideAmount = $state<string>('');
|
|
let tipEnabled = $state(false);
|
|
|
|
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// Calculate total with tip
|
|
let totalWithTip = $derived(tipEnabled ? amount * 1.1 : amount);
|
|
|
|
function formatCurrency(value: number): string {
|
|
return new Intl.NumberFormat('en-GB', {
|
|
style: 'currency',
|
|
currency: 'GBP'
|
|
}).format(value);
|
|
}
|
|
|
|
async function handleConfirmPayment() {
|
|
const finalAmount = overrideAmount ? parseFloat(overrideAmount) : totalWithTip;
|
|
|
|
if (isNaN(finalAmount) || finalAmount <= 0) {
|
|
toast.error('Please enter a valid amount');
|
|
return;
|
|
}
|
|
|
|
status = 'processing';
|
|
error = null;
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
amount: Math.round(finalAmount * 100),
|
|
payment_type: 'full',
|
|
tip_enabled: tipEnabled
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errData = await response.text();
|
|
throw new Error(errData || 'Failed to initiate payment');
|
|
}
|
|
|
|
const data = await response.json();
|
|
checkoutId = data.checkout_id;
|
|
status = 'polling';
|
|
startPolling();
|
|
} catch (err) {
|
|
status = 'error';
|
|
error = err instanceof Error ? err.message : 'Failed to initiate payment';
|
|
toast.error(error ?? 'Unknown error');
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
if (!checkoutId) return;
|
|
|
|
pollingInterval = setInterval(async () => {
|
|
try {
|
|
const response = await fetch(
|
|
`/api/admin/payments/${checkoutId}/status?booking_id=${booking.id}`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'include'
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to check payment status');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'COMPLETED') {
|
|
stopPolling();
|
|
status = 'success';
|
|
paymentResult = {
|
|
checkout_id: checkoutId!,
|
|
status: data.status,
|
|
card_brand: data.card_brand,
|
|
last4: data.last4,
|
|
amount: data.amount
|
|
};
|
|
toast.success('Payment successful');
|
|
onComplete(paymentResult);
|
|
} else if (data.status === 'FAILED') {
|
|
stopPolling();
|
|
status = 'error';
|
|
const errorMsg = data.error_message || 'Payment failed';
|
|
error = errorMsg;
|
|
toast.error(errorMsg as string);
|
|
}
|
|
// PENDING - continue polling
|
|
} catch (err) {
|
|
stopPolling();
|
|
status = 'error';
|
|
const errorMsg = 'Failed to check payment status';
|
|
error = errorMsg;
|
|
toast.error(errorMsg);
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollingInterval) {
|
|
clearInterval(pollingInterval);
|
|
pollingInterval = null;
|
|
}
|
|
}
|
|
|
|
function handleRetry() {
|
|
status = 'idle';
|
|
checkoutId = null;
|
|
error = null;
|
|
}
|
|
|
|
function handleClose() {
|
|
stopPolling();
|
|
onClose();
|
|
}
|
|
|
|
// Cleanup on unmount
|
|
$effect(() => {
|
|
return () => {
|
|
stopPolling();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
|
<Dialog.Content class="max-w-md">
|
|
<Dialog.Header>
|
|
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
|
</Dialog.Header>
|
|
|
|
{#if status === 'idle' || status === 'processing' || status === 'error'}
|
|
<div class="space-y-4">
|
|
<!-- 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.price ? formatCurrency(service.price) : '-'}
|
|
</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Total Amount -->
|
|
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
|
<span class="text-base font-semibold text-gray-700">Total</span>
|
|
<span class="text-xl font-bold text-gray-900">{formatCurrency(amount)}</span>
|
|
</div>
|
|
|
|
<!-- Price Override -->
|
|
<div class="space-y-2">
|
|
<label for="override-amount" class="text-sm font-medium text-gray-700">
|
|
Override Amount (optional)
|
|
</label>
|
|
<Input
|
|
id="override-amount"
|
|
type="number"
|
|
step="0.01"
|
|
placeholder="Leave empty to use total"
|
|
bind:value={overrideAmount}
|
|
disabled={status === 'processing'}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Tip Toggle -->
|
|
<div class="flex items-center gap-3">
|
|
<Checkbox
|
|
id="tip-enabled"
|
|
bind:checked={tipEnabled}
|
|
disabled={status === 'processing'}
|
|
/>
|
|
<label for="tip-enabled" class="text-sm text-gray-700">
|
|
Add 10% tip ({formatCurrency(amount * 0.1)})
|
|
</label>
|
|
</div>
|
|
|
|
{#if tipEnabled}
|
|
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
|
|
<span class="text-sm font-medium text-green-800">Total with Tip</span>
|
|
<span class="text-lg font-bold text-green-800">
|
|
{formatCurrency(totalWithTip)}
|
|
</span>
|
|
</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>
|
|
<Button variant="outline" onclick={handleRetry} class="w-full">
|
|
Try Again
|
|
</Button>
|
|
{/if}
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-3">
|
|
<Button variant="outline" onclick={handleClose} class="flex-1" disabled={status === 'processing'}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onclick={handleConfirmPayment}
|
|
class="flex-1 bg-green-600 hover:bg-green-700"
|
|
loading={status === 'processing'}
|
|
disabled={status === 'processing'}
|
|
>
|
|
Confirm Payment
|
|
</Button>
|
|
</div>
|
|
</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">Waiting for customer to tap card...</p>
|
|
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
|
|
<Button variant="outline" 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>
|
|
{#if paymentResult.card_brand}
|
|
<div class="flex justify-between">
|
|
<span class="text-sm text-gray-600">Card</span>
|
|
<span class="font-medium text-gray-900">
|
|
{paymentResult.card_brand} ****{paymentResult.last4}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
<div class="flex justify-between">
|
|
<span class="text-sm text-gray-600">Status</span>
|
|
<span class="font-medium text-green-600">Completed</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button onclick={handleClose} class="w-full">
|
|
Done
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</Dialog.Content>
|
|
</Dialog.Root> |