diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index 63909a7..c5ef980 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -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('idle'); + let selectedMethod = $state(null); let checkoutId = $state(null); let paymentResult = $state(null); let error = $state(null); - let amount = $derived(booking.total_amount); - let overrideAmount = $state(''); + + type ServiceOverride = { + price: string; + originalPrice: number; + }; + + let serviceOverrides = $state>({}); + + $effect(() => { + const services = booking.services ?? []; + const overrides: Record = {}; + 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(null); + let customTipAmount = $state(''); let pollingInterval: ReturnType | 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(''); + 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 = { + 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 = ''; + } + }); - !open && handleClose()}> - + !open && handleClose()} onOpenAutoFocus={(e) => e.preventDefault()}> + Take Payment - {#if status === 'idle' || status === 'processing' || status === 'error'} + {#if status === 'idle'}
-
Services
-
- {#each booking.services ?? [] as service, index (index)} -
- {service.service_name || 'Unknown Service'} - - {service.price ? formatCurrency(service.price) : '-'} - +
+ {#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)} +
+
{service.service_name || 'Unknown Service'}
+
+ £ + 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} + + (was £{serviceOverrides[service.service_id].originalPrice.toFixed(2)}) + + {/if} +
{/each}
-
Total - {formatCurrency(amount)} -
- - -
- - -
- - -
- - + {formatCurrency(subtotal)}
{#if tipEnabled}
- Total with Tip + + Total with Tip ({tipDisplay}) + {formatCurrency(totalWithTip)}
{/if} - {#if status === 'error' && error} -
-

{error}

-
- - {/if} - - -
- - + + +
+ +
+ +
+ +
+
- {:else if status === 'polling'} - + + {:else if status === 'selecting'} +
+
+ Total + {formatCurrency(totalDue)} +
+ +
+ Add a Tip +
+ {#each tipPercentages as tip (tip.pct)} + + {/each} +
+
+ £ + +
+
+ + {#if tipEnabled} +
+ + Total with Tip ({tipDisplay}) + + + {formatCurrency(totalWithTip)} + +
+ {/if} + +
+ + +
+
+ + {:else if status === 'card-processing' || status === 'card-polling'}

Waiting for customer to tap card...

This may take a few moments

-
+ + {:else if status === 'cash-entering'} +
+
+ Total Due + {formatCurrency(totalDue)} +
+ +
+ +
+ £ + +
+
+ + {#if cashAmountNum >= totalDue} +
+
+ Change Due + {formatCurrency(changeDue)} +
+ {#if changeDue > 0} +
+ + +
+ {/if} +
+ {/if} + +
+ + +
+
+ + {:else if status === 'cash-confirming'} +
+
+

Processing cash payment...

+
+ + {:else if status === 'gift-entering'} +
+
+ Total Due + {formatCurrency(totalDue)} +
+ +
+ + +

Enter the 12-digit ID printed on the gift card

+
+ +
+ + +
+
+ + {:else if status === 'gift-confirming'} +
+
+

Processing gift card...

+
+ + {:else if status === 'error' && error} +
+
+

{error}

+
+
+ + +
+
+ {:else if status === 'success' && paymentResult} -
@@ -283,7 +736,6 @@

Payment Successful

-
@@ -313,4 +765,4 @@
{/if} - \ No newline at end of file +