feat: multi-method payment modal with tip presets and price overrides
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
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 { Booking, BookingService } from '$lib/types/booking';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
interface Props {
|
||||
booking: Booking;
|
||||
@@ -14,7 +15,7 @@
|
||||
|
||||
let { booking, onClose, onComplete }: Props = $props();
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
|
||||
type PaymentStatus = 'idle' | 'selecting' | 'card-processing' | 'card-polling' | 'cash-entering' | 'cash-confirming' | 'gift-entering' | 'gift-confirming' | 'success' | 'error';
|
||||
|
||||
type PaymentResult = {
|
||||
checkout_id: string;
|
||||
@@ -24,18 +25,127 @@
|
||||
amount: number;
|
||||
};
|
||||
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | null;
|
||||
|
||||
let status = $state<PaymentStatus>('idle');
|
||||
let selectedMethod = $state<PaymentMethod>(null);
|
||||
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>('');
|
||||
|
||||
type ServiceOverride = {
|
||||
price: string;
|
||||
originalPrice: number;
|
||||
};
|
||||
|
||||
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
|
||||
|
||||
$effect(() => {
|
||||
const services = booking.services ?? [];
|
||||
const overrides: Record<string, ServiceOverride> = {};
|
||||
for (const s of services) {
|
||||
const price = s.override_price ?? s.price ?? 0;
|
||||
overrides[s.service_id] = {
|
||||
price: price.toFixed(2),
|
||||
originalPrice: s.price ?? 0
|
||||
};
|
||||
}
|
||||
serviceOverrides = overrides;
|
||||
});
|
||||
|
||||
function handlePriceInput(serviceId: string, value: string) {
|
||||
const cleaned = 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 === '') {
|
||||
serviceOverrides = {
|
||||
...serviceOverrides,
|
||||
[serviceId]: {
|
||||
...serviceOverrides[serviceId],
|
||||
price: sanitized
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let tipEnabled = $state(false);
|
||||
let selectedTipPercent = $state<number | null>(null);
|
||||
let customTipAmount = $state<string>('');
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Calculate total with tip
|
||||
let totalWithTip = $derived(tipEnabled ? amount * 1.1 : amount);
|
||||
let tipPercentages = $derived.by(() => {
|
||||
if (subtotal <= 0) return [];
|
||||
return [
|
||||
{ pct: 10, amount: Math.round(subtotal * 0.10 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(subtotal * 0.20 * 100) / 100 },
|
||||
];
|
||||
});
|
||||
|
||||
function selectTipPercent(percent: number) {
|
||||
selectedTipPercent = percent;
|
||||
customTipAmount = '';
|
||||
tipEnabled = true;
|
||||
}
|
||||
|
||||
function handleCustomTipInput(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 === '') {
|
||||
customTipAmount = sanitized;
|
||||
}
|
||||
selectedTipPercent = null;
|
||||
tipEnabled = customTipAmount !== '' && parseFloat(customTipAmount) > 0;
|
||||
}
|
||||
|
||||
function getServicePrice(service: BookingService): number {
|
||||
const override = serviceOverrides[service.service_id];
|
||||
if (override && override.price !== '') {
|
||||
const parsed = parseFloat(override.price);
|
||||
if (!isNaN(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return service.override_price ?? service.price ?? 0;
|
||||
}
|
||||
|
||||
let subtotal = $derived(
|
||||
(booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0)
|
||||
);
|
||||
|
||||
let tipMultiplier = $derived(
|
||||
selectedTipPercent !== null
|
||||
? 1 + selectedTipPercent / 100
|
||||
: customTipAmount && parseFloat(customTipAmount) > 0
|
||||
? 1 + parseFloat(customTipAmount) / subtotal
|
||||
: 1
|
||||
);
|
||||
|
||||
let totalWithTip = $derived(tipEnabled ? subtotal * tipMultiplier : subtotal);
|
||||
let tipDisplay = $derived(
|
||||
selectedTipPercent !== null
|
||||
? `${selectedTipPercent}%`
|
||||
: customTipAmount && parseFloat(customTipAmount) > 0
|
||||
? `£${parseFloat(customTipAmount).toFixed(2)}`
|
||||
: ''
|
||||
);
|
||||
|
||||
let totalDue = $derived(tipEnabled ? totalWithTip : subtotal);
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
@@ -44,24 +154,24 @@
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
async function handleConfirmPayment() {
|
||||
const finalAmount = overrideAmount ? parseFloat(overrideAmount) : totalWithTip;
|
||||
async function handleCardPayment() {
|
||||
const finalAmount = totalDue;
|
||||
|
||||
if (isNaN(finalAmount) || finalAmount <= 0) {
|
||||
toast.error('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
status = 'processing';
|
||||
status = 'card-processing';
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
amount: Math.round(finalAmount * 100),
|
||||
payment_type: 'full',
|
||||
@@ -76,7 +186,7 @@
|
||||
|
||||
const data = await response.json();
|
||||
checkoutId = data.checkout_id;
|
||||
status = 'polling';
|
||||
status = 'card-polling';
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
@@ -95,9 +205,9 @@
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include'
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -122,17 +232,14 @@
|
||||
} else if (data.status === 'FAILED') {
|
||||
stopPolling();
|
||||
status = 'error';
|
||||
const errorMsg = data.error_message || 'Payment failed';
|
||||
error = errorMsg;
|
||||
toast.error(errorMsg as string);
|
||||
error = data.error_message || 'Payment failed';
|
||||
toast.error(error as string);
|
||||
}
|
||||
// PENDING - continue polling
|
||||
} catch (err) {
|
||||
stopPolling();
|
||||
status = 'error';
|
||||
const errorMsg = 'Failed to check payment status';
|
||||
error = errorMsg;
|
||||
toast.error(errorMsg);
|
||||
error = 'Failed to check payment status';
|
||||
toast.error(error);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -144,126 +251,472 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
status = 'idle';
|
||||
checkoutId = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopPolling();
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
function resetToSelect() {
|
||||
stopPolling();
|
||||
status = 'idle';
|
||||
selectedMethod = null;
|
||||
checkoutId = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
});
|
||||
|
||||
let cashAmount = $state<string>('');
|
||||
let cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount));
|
||||
let changeDue = $derived(cashAmountNum > totalDue ? cashAmountNum - totalDue : 0);
|
||||
let extraAsTip = $state(false);
|
||||
|
||||
function handleCashInput(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 === '') {
|
||||
cashAmount = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCashPayment() {
|
||||
if (cashAmountNum < totalDue) {
|
||||
toast.error('Cash amount must cover the total');
|
||||
return;
|
||||
}
|
||||
|
||||
const tipAmount = extraAsTip ? cashAmountNum - totalDue : 0;
|
||||
|
||||
status = 'cash-confirming';
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
amount: Math.round(totalDue * 100),
|
||||
payment_type: 'full',
|
||||
payment_method: 'cash'
|
||||
};
|
||||
if (tipAmount > 0) {
|
||||
body.tip_enabled = true;
|
||||
body.tip_amount = Math.round(tipAmount * 100);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
throw new Error(errData || 'Failed to process payment');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
status = 'success';
|
||||
paymentResult = {
|
||||
checkout_id: data.checkout_id || data.id || '',
|
||||
status: 'COMPLETED',
|
||||
amount: data.amount
|
||||
};
|
||||
toast.success('Cash payment recorded');
|
||||
onComplete(paymentResult);
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
error = err instanceof Error ? err.message : 'Failed to process payment';
|
||||
toast.error(error ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
let giftCardId = $state('');
|
||||
|
||||
function formatGiftCardId(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').substring(0, 12);
|
||||
const groups = digits.match(/.{1,4}/g);
|
||||
return groups ? groups.join(' ') : digits;
|
||||
}
|
||||
|
||||
function handleGiftCardInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
giftCardId = formatGiftCardId(input.value);
|
||||
}
|
||||
|
||||
let giftCardValid = $derived(giftCardId.replace(/\s/g, '').length === 12);
|
||||
|
||||
async function handleGiftCardPayment() {
|
||||
if (!giftCardValid) {
|
||||
toast.error('Please enter a valid 12-digit gift card ID');
|
||||
return;
|
||||
}
|
||||
|
||||
status = 'gift-confirming';
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: 1000,
|
||||
payment_type: 'full',
|
||||
payment_method: 'giftcard',
|
||||
gift_card_id: giftCardId.replace(/\s/g, '')
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
throw new Error(errData || 'Failed to process gift card');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
status = 'success';
|
||||
paymentResult = {
|
||||
checkout_id: data.checkout_id || data.id || '',
|
||||
status: 'COMPLETED',
|
||||
amount: data.amount
|
||||
};
|
||||
toast.success('Gift card payment recorded');
|
||||
onComplete(paymentResult);
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
error = err instanceof Error ? err.message : 'Failed to process gift card';
|
||||
toast.error(error ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (selectedMethod === 'cash') {
|
||||
cashAmount = totalDue.toFixed(2);
|
||||
extraAsTip = false;
|
||||
}
|
||||
if (selectedMethod === 'giftcard') {
|
||||
giftCardId = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()} onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<Dialog.Content class="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if status === 'idle' || status === 'processing' || status === 'error'}
|
||||
{#if status === 'idle'}
|
||||
<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 class="grid grid-cols-1 {(booking.services?.length ?? 0) > 1 ? 'sm:grid-cols-2' : ''} gap-3">
|
||||
{#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)}
|
||||
<div class="rounded-lg border bg-white p-3">
|
||||
<div class="mb-2 font-medium text-sm">{service.service_name || 'Unknown Service'}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">£</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex="-1"
|
||||
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
value={serviceOverrides[service.service_id]?.price ?? (service.price?.toFixed(2) ?? '0.00')}
|
||||
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
|
||||
/>
|
||||
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
|
||||
<span class="text-xs text-amber-600">
|
||||
(was £{serviceOverrides[service.service_id].originalPrice.toFixed(2)})
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</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>
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(subtotal)}</span>
|
||||
</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-sm font-medium text-green-800">
|
||||
Total with Tip ({tipDisplay})
|
||||
</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'}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod === 'card' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => { selectedMethod = 'card'; status = 'selecting'; }}
|
||||
>
|
||||
Confirm Payment
|
||||
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
Card
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod === 'cash' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => { selectedMethod = 'cash'; status = 'cash-entering'; }}
|
||||
>
|
||||
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod === 'giftcard' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => { selectedMethod = 'giftcard'; status = 'gift-entering'; }}
|
||||
>
|
||||
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 12 20 22 4 22 4 12" />
|
||||
<rect x="2" y="7" width="20" height="5" />
|
||||
<line x1="12" y1="22" x2="12" y2="7" />
|
||||
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z" />
|
||||
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z" />
|
||||
</svg>
|
||||
Gift Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-600 underline hover:text-gray-900"
|
||||
onclick={() => { selectedMethod = 'giftcard'; status = 'gift-entering'; }}
|
||||
>
|
||||
Pay with Gift Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={handleClose} class="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'polling'}
|
||||
<!-- Polling State -->
|
||||
|
||||
{:else if status === 'selecting'}
|
||||
<div class="space-y-4">
|
||||
<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(totalDue)}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<span class="text-sm font-medium text-gray-700">Add a Tip</span>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each tipPercentages as tip (tip.pct)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
|
||||
tip.pct
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input'}"
|
||||
onclick={() => selectTipPercent(tip.pct)}
|
||||
>
|
||||
<div>{tip.pct}%</div>
|
||||
<div class="text-xs font-normal text-gray-500">£{tip.amount.toFixed(2)}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex="-1"
|
||||
placeholder="Custom tip amount"
|
||||
value={customTipAmount}
|
||||
oninput={handleCustomTipInput}
|
||||
class="pl-7"
|
||||
/>
|
||||
</div>
|
||||
</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 ({tipDisplay})
|
||||
</span>
|
||||
<span class="text-lg font-bold text-green-800">
|
||||
{formatCurrency(totalWithTip)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleCardPayment}
|
||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Charge Card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if status === 'card-processing' || status === 'card-polling'}
|
||||
<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 === 'cash-entering'}
|
||||
<div class="space-y-4">
|
||||
<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 Due</span>
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cash-amount" class="text-sm font-medium text-gray-700">
|
||||
Cash Received
|
||||
</label>
|
||||
<div class="relative mt-1">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
|
||||
<Input
|
||||
id="cash-amount"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex="-1"
|
||||
value={cashAmount}
|
||||
oninput={handleCashInput}
|
||||
class="pl-7 text-lg font-semibold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if cashAmountNum >= totalDue}
|
||||
<div class="rounded-md border border-green-200 bg-green-50 p-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm font-medium text-green-800">Change Due</span>
|
||||
<span class="text-lg font-bold text-green-800">{formatCurrency(changeDue)}</span>
|
||||
</div>
|
||||
{#if changeDue > 0}
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="keep-change"
|
||||
bind:checked={extraAsTip}
|
||||
/>
|
||||
<label for="keep-change" class="text-sm text-green-700">
|
||||
Keep {formatCurrency(changeDue)} as tip
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleCashPayment}
|
||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
||||
disabled={cashAmountNum < totalDue}
|
||||
>
|
||||
Confirm Cash
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if status === 'cash-confirming'}
|
||||
<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 cash payment...</p>
|
||||
</div>
|
||||
|
||||
{:else if status === 'gift-entering'}
|
||||
<div class="space-y-4">
|
||||
<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 Due</span>
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="gift-card-id" class="text-sm font-medium text-gray-700">
|
||||
Gift Card ID
|
||||
</label>
|
||||
<Input
|
||||
id="gift-card-id"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
tabindex="-1"
|
||||
value={giftCardId}
|
||||
oninput={handleGiftCardInput}
|
||||
placeholder="XXXX XXXX XXXX"
|
||||
maxlength={14}
|
||||
class="mt-1 font-mono text-lg tracking-widest"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Enter the 12-digit ID printed on the gift card</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleGiftCardPayment}
|
||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
||||
disabled={!giftCardValid}
|
||||
>
|
||||
Apply Gift Card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if status === 'gift-confirming'}
|
||||
<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 gift card...</p>
|
||||
</div>
|
||||
|
||||
{:else if status === 'error' && error}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3">
|
||||
<p class="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={handleClose} class="flex-1">
|
||||
Close
|
||||
</Button>
|
||||
<Button onclick={resetToSelect} class="flex-1">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
@@ -283,7 +736,6 @@
|
||||
<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">
|
||||
@@ -313,4 +765,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Dialog.Root>
|
||||
|
||||
Reference in New Issue
Block a user