feat: user payment modal and tip route for online payments
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,756 @@
|
||||
<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';
|
||||
import type { UserSavedCard } from '$lib/types';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
interface Props {
|
||||
booking: Booking;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
canSaveCards?: boolean;
|
||||
}
|
||||
|
||||
let { booking, onClose, onComplete, canSaveCards = true }: 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);
|
||||
|
||||
// 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 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;
|
||||
newCardNumber = formatCardNumber(input.value);
|
||||
}
|
||||
|
||||
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;
|
||||
newCardExpiry = formatExpiryDate(input.value);
|
||||
}
|
||||
|
||||
function handleCvcInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
newCardCVC = input.value.replace(/\D/g, '').substring(0, 4);
|
||||
}
|
||||
|
||||
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 Date(expiryParts.year, expiryParts.month);
|
||||
return expiryDate < new Date();
|
||||
})()
|
||||
);
|
||||
|
||||
let hasInvalidMonth = $derived(
|
||||
/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null
|
||||
);
|
||||
|
||||
let cardFormValid = $derived(
|
||||
newCardNumber.replace(/\s/g, '').length >= 13 &&
|
||||
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'
|
||||
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
|
||||
? 'Card number too short'
|
||||
: 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>('');
|
||||
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Derived values
|
||||
let depositOutstanding = $derived(
|
||||
booking.deposit_required && !booking.deposit_paid
|
||||
);
|
||||
|
||||
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 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)
|
||||
);
|
||||
|
||||
let payButtonError = $derived(
|
||||
status === 'processing' ? null : (
|
||||
!cardSelected ? cardValidationError : (paymentType === 'partial' ? partialValidationError : null)
|
||||
)
|
||||
);
|
||||
|
||||
function formatCurrency(pence: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'GBP'
|
||||
}).format(pence / 100);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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');
|
||||
onComplete();
|
||||
} 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.`);
|
||||
}
|
||||
}
|
||||
|
||||
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 paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
|
||||
makePayment(paymentType, fullCents);
|
||||
}
|
||||
|
||||
function handlePayPartial() {
|
||||
if (!partialAmountValid) {
|
||||
toast.error('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
makePayment('partial', Math.round(partialAmountNum * 100));
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
status = 'idle';
|
||||
error = null;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopPolling();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch payment methods on mount if authenticated
|
||||
$effect(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
fetchPaymentMethods();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
$effect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Content class="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<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">
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
</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 w-14 items-center justify-center rounded bg-gray-100 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}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer hover:border-gray-300 transition-colors"
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer {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 w-14 items-center justify-center rounded bg-gray-100 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}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
{/if}
|
||||
{/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.round(booking.amount_due * 100))}
|
||||
{/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 left-3 top-1/2 -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 === 'processing'}
|
||||
/>
|
||||
</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.round(booking.amount_due * 100))}
|
||||
{/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>
|
||||
@@ -0,0 +1,414 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
type BookingService = {
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
override_price?: number;
|
||||
override_duration_minutes?: number;
|
||||
};
|
||||
|
||||
type Payment = {
|
||||
id: string;
|
||||
payment_type: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status: string;
|
||||
services: BookingService[];
|
||||
total_amount: number;
|
||||
amount_paid: number;
|
||||
duration_minutes: number;
|
||||
payments?: Payment[];
|
||||
};
|
||||
|
||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let booking = $state<Booking | null>(null);
|
||||
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
|
||||
let selectedTip = $state<number | null>(null);
|
||||
let customTip = $state('');
|
||||
let tipAmount = $derived(
|
||||
selectedTip !== null ? selectedTip : customTip ? parseFloat(customTip) || 0 : 0
|
||||
);
|
||||
|
||||
const tipsPaid = $derived(
|
||||
booking?.payments
|
||||
?.filter((p) => p.status === 'completed' && p.payment_type === 'tip')
|
||||
.reduce((sum, p) => sum + p.amount, 0) ?? 0
|
||||
);
|
||||
|
||||
const subtotal = $derived(booking?.total_amount ?? 0);
|
||||
|
||||
const 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 formatDate(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTimeRange(startStr: string, durationMinutes: number): string {
|
||||
const start = new SvelteDate(startStr);
|
||||
const end = new SvelteDate(start.getTime() + durationMinutes * 60000);
|
||||
|
||||
const formatOpt: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
};
|
||||
|
||||
return `${start.toLocaleTimeString('en-GB', formatOpt)} – ${end.toLocaleTimeString('en-GB', formatOpt)}`;
|
||||
}
|
||||
|
||||
function formatPrice(pounds: number): string {
|
||||
return `£${pounds.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function selectTip(amount: number) {
|
||||
selectedTip = amount;
|
||||
customTip = '';
|
||||
}
|
||||
|
||||
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 === '') {
|
||||
customTip = sanitized;
|
||||
}
|
||||
selectedTip = null;
|
||||
}
|
||||
|
||||
async function submitTip() {
|
||||
if (!booking) return;
|
||||
if (tipAmount <= 0) {
|
||||
toast.error('Please select a tip amount');
|
||||
return;
|
||||
}
|
||||
|
||||
paymentState = 'processing';
|
||||
|
||||
try {
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
|
||||
const response = await fetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amountInPence,
|
||||
card_token: 'placeholder'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Payment failed');
|
||||
}
|
||||
|
||||
paymentState = 'success';
|
||||
toast.success('Thank you for your tip!');
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
const errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function retryPayment() {
|
||||
paymentState = 'idle';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
if (authStore.isLoading) {
|
||||
pageState = 'loading';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
pageState = 'unauthorized';
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStore.currentUser?.role === 'admin') {
|
||||
pageState = 'admin';
|
||||
goto('/admin', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
pageState = 'authorized';
|
||||
fetchMostRecentBooking();
|
||||
});
|
||||
|
||||
async function fetchMostRecentBooking() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/bookings', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load your bookings');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const bookings: Booking[] = data.bookings || [];
|
||||
|
||||
if (!bookings || bookings.length === 0) {
|
||||
error = 'No bookings found';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const sorted = bookings.sort((a, b) => {
|
||||
const dateA = new Date(a.start_time).getTime();
|
||||
const dateB = new Date(b.start_time).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Find most recent past booking (start_time <= now)
|
||||
const pastBooking = sorted.find((b) => {
|
||||
const startTime = new Date(b.start_time);
|
||||
return startTime <= now;
|
||||
});
|
||||
|
||||
if (!pastBooking) {
|
||||
error = 'No completed appointments found';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch full booking details (includes payments for tips tracking)
|
||||
const detailsResp = await fetch(`/api/bookings/${pastBooking.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!detailsResp.ok) {
|
||||
throw new Error('Failed to load booking details');
|
||||
}
|
||||
|
||||
booking = await detailsResp.json();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Leave a Tip - Crussell</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto min-h-screen px-4 py-8 sm:max-w-md md:py-12">
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<Skeleton class="mx-auto h-8 w-48" />
|
||||
<Skeleton class="mx-auto mt-2 h-4 w-64" />
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Content class="space-y-4 pt-6">
|
||||
<Skeleton class="h-16 w-full" />
|
||||
<Skeleton class="h-24 w-full" />
|
||||
<Skeleton class="h-32 w-full" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else if error}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-red-600">Something went wrong</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-gray-600">{error}</p>
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<Button class="w-full sm:w-auto" onclick={() => goto('/')}>Go Home</Button>
|
||||
<Button variant="outline" class="w-full sm:w-auto" onclick={fetchMostRecentBooking}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if booking}
|
||||
<div class="mb-6 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 sm:text-3xl">Leave a Tip</h1>
|
||||
<p class="mt-1 text-gray-600">Show your appreciation for great service</p>
|
||||
</div>
|
||||
|
||||
{#if paymentState === 'success'}
|
||||
<Card.Root>
|
||||
<Card.Content class="py-8 text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8 text-green-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">Thank you!</h2>
|
||||
<p class="mt-2 text-gray-600">Your generosity is greatly appreciated.</p>
|
||||
<Button class="mt-6" onclick={() => goto('/')}>Go Home</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Your last Appointment</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Date</span>
|
||||
<span class="font-medium">{formatDate(booking.start_time)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Time</span>
|
||||
<span class="font-medium">{formatTimeRange(booking.start_time, booking.duration_minutes ?? 0)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Subtotal</span>
|
||||
<span class="font-medium">{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
{#if tipsPaid > 0}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Tips</span>
|
||||
<span class="font-medium">{formatPrice(tipsPaid)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if booking.services && booking.services.length > 0}
|
||||
<div class="border-t pt-3">
|
||||
<div class="text-sm text-gray-500">Services</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each booking.services as service}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700">{service.service_name}</span>
|
||||
<span class="text-gray-500"
|
||||
>{formatPrice(service.override_price ?? service.price)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Tip Amount</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each tipPercentages as tip (tip.pct)}
|
||||
<button
|
||||
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTip ===
|
||||
tip.amount
|
||||
? 'bg-fuchsia-100'
|
||||
: ''}"
|
||||
onclick={() => selectTip(tip.amount)}
|
||||
type="button"
|
||||
>
|
||||
<div>{formatPrice(tip.amount)}</div>
|
||||
<div class="text-xs font-normal text-gray-500">{tip.pct}%</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="custom-tip" class="text-sm font-medium text-gray-700"
|
||||
>Or enter custom 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="custom-tip"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
class="pl-7"
|
||||
value={customTip}
|
||||
oninput={handleCustomTipInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if paymentState === 'error'}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p class="text-red-700">Payment failed. Please try again.</p>
|
||||
<Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
size="lg"
|
||||
disabled={tipAmount <= 0 || paymentState === 'processing'}
|
||||
loading={paymentState === 'processing'}
|
||||
onclick={submitTip}
|
||||
>
|
||||
{paymentState === 'processing' ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
|
||||
</Button>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user