feat: Square payment integration, booking flow redesign, and timezone/weekday fixes
- 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.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
import * as Textarea from '$lib/components/ui/textarea';
|
||||
import * as Label from '$lib/components/ui/label';
|
||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
|
||||
import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
|
||||
@@ -94,6 +95,25 @@
|
||||
.reduce((sum, p) => sum + p.amount, 0) || 0
|
||||
);
|
||||
|
||||
let depositOutstanding = $derived(
|
||||
selectedBooking?.deposit_required && !selectedBooking?.deposit_paid
|
||||
);
|
||||
|
||||
let canPayEarly = $derived(
|
||||
selectedBooking &&
|
||||
!depositOutstanding &&
|
||||
totalPaid < selectedBooking.total_amount &&
|
||||
['confirmed', 'pending'].includes(selectedBooking.status)
|
||||
);
|
||||
|
||||
let showPaymentModal = $state(false);
|
||||
|
||||
function handlePaymentComplete() {
|
||||
toast.success('Payment completed');
|
||||
showPaymentModal = false;
|
||||
fetchBookingDetails();
|
||||
}
|
||||
|
||||
let isRescheduleValid = $derived(
|
||||
rescheduleDate && rescheduleTime && rescheduleTime.length >= 4
|
||||
);
|
||||
@@ -847,12 +867,37 @@
|
||||
Add to Calendar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" class="flex-1" onclick={() => (open = false)}>Close</Button>
|
||||
{#if depositOutstanding}
|
||||
<Button
|
||||
size="sm"
|
||||
class="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onclick={() => (showPaymentModal = true)}
|
||||
>
|
||||
Pay Deposit
|
||||
</Button>
|
||||
{:else if canPayEarly}
|
||||
<Button
|
||||
size="sm"
|
||||
class="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onclick={() => (showPaymentModal = true)}
|
||||
>
|
||||
Pay Early
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
{#if showPaymentModal && selectedBooking}
|
||||
<PaymentModal
|
||||
booking={selectedBooking}
|
||||
onClose={() => (showPaymentModal = false)}
|
||||
onComplete={handlePaymentComplete}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
|
||||
<Modal.Content class="max-w-sm">
|
||||
<Modal.Header>
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
let availableServices = $state<Service[]>([]);
|
||||
let loadingServices = $state(false);
|
||||
|
||||
// Refund dialog state
|
||||
let showRefundModal = $state(false);
|
||||
let refundPaymentId = $state('');
|
||||
let refundAmount = $state('');
|
||||
let refundReason = $state('');
|
||||
let refundLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && bookingId) {
|
||||
fetchBooking();
|
||||
@@ -331,6 +338,55 @@
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundModal(paymentId: string, amountPence: number) {
|
||||
refundPaymentId = paymentId;
|
||||
refundAmount = (amountPence / 100).toFixed(2);
|
||||
refundReason = '';
|
||||
showRefundModal = true;
|
||||
}
|
||||
|
||||
async function processRefund() {
|
||||
if (!refundAmount || !refundReason.trim()) {
|
||||
toast.error('Please enter a refund amount and reason');
|
||||
return;
|
||||
}
|
||||
|
||||
refundLoading = true;
|
||||
try {
|
||||
const amountPence = Math.round(parseFloat(refundAmount) * 100);
|
||||
if (isNaN(amountPence) || amountPence <= 0) {
|
||||
toast.error('Invalid refund amount');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/payments/${refundPaymentId}/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amountPence,
|
||||
reason: refundReason.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Refund processed');
|
||||
showRefundModal = false;
|
||||
fetchBooking();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to process refund: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing refund:', err);
|
||||
toast.error('Network error processing refund');
|
||||
} finally {
|
||||
refundLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
@@ -473,6 +529,57 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
{#if booking?.payments && booking.payments.length > 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Payments ({booking.payments.length})
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each booking.payments as payment (payment.id)}
|
||||
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
{payment.payment_type === 'deposit' ? 'Deposit' : payment.payment_type === 'full' ? 'Full Payment' : payment.payment_type}
|
||||
{#if payment.payment_method}
|
||||
<span class="text-gray-500"> via {payment.payment_method}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
<span
|
||||
class:text-green-600={payment.status === 'completed'}
|
||||
class:text-amber-600={payment.status === 'pending'}
|
||||
class:text-red-600={payment.status === 'failed' || payment.status === 'refunded'}
|
||||
>
|
||||
{payment.status}
|
||||
</span>
|
||||
<span class="mx-1">|</span>
|
||||
£{(payment.amount / 100).toFixed(2)}
|
||||
{#if payment.invoice_number}
|
||||
<span class="mx-1">|</span>
|
||||
<span class="text-gray-500">Inv: {payment.invoice_number}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new Date(payment.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
{#if payment.status === 'completed'}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onclick={() => openRefundModal(payment.id, payment.amount)}
|
||||
>
|
||||
Refund
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label for="edit-notes" class="mb-2 block text-sm font-medium">Notes</label>
|
||||
@@ -665,6 +772,59 @@
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Refund Dialog -->
|
||||
<Modal.Root open={showRefundModal} onOpenChange={(v) => (showRefundModal = v)}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title>
|
||||
<Modal.Description>
|
||||
Enter the refund amount and reason.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<div>
|
||||
<label for="refund-amount" class="mb-1 block text-xs text-gray-600">
|
||||
Refund Amount
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">£</span>
|
||||
</div>
|
||||
<Input
|
||||
id="refund-amount"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={refundAmount}
|
||||
class="no-spin w-full pl-7"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="refund-reason" class="mb-1 block text-xs text-gray-600">
|
||||
Reason (required)
|
||||
</label>
|
||||
<Textarea
|
||||
id="refund-reason"
|
||||
bind:value={refundReason}
|
||||
placeholder="Enter reason for refund..."
|
||||
rows={3}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onclick={() => (showRefundModal = false)}>Cancel</Button>
|
||||
<Button onclick={processRefund} disabled={refundLoading || !refundAmount || !refundReason.trim()}>
|
||||
{refundLoading ? 'Processing...' : 'Confirm Refund'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<style>
|
||||
:global(input[type='number']) {
|
||||
-moz-appearance: textfield;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
<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>
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
|
||||
|
||||
interface Props {
|
||||
openBookingModal: (bookingId: string) => void;
|
||||
@@ -44,6 +45,7 @@
|
||||
let loading = $state(true);
|
||||
let timeRemaining = $state(0); // minutes remaining in current appointment
|
||||
let isInProgress = $state(false);
|
||||
let showPaymentModal = $state(false);
|
||||
|
||||
// Calculate time remaining and free time
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -163,7 +165,12 @@
|
||||
}
|
||||
|
||||
function handleTakePayment() {
|
||||
toast.info('Take payment - Coming soon');
|
||||
showPaymentModal = true;
|
||||
}
|
||||
|
||||
function handlePaymentComplete(payment: unknown) {
|
||||
toast.success('Payment completed');
|
||||
showPaymentModal = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
@@ -425,3 +432,7 @@
|
||||
</Card.Content>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
{#if showPaymentModal}
|
||||
<PaymentModal booking={activeAppointment} onClose={() => showPaymentModal = false} onComplete={handlePaymentComplete} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface UserSavedCard {
|
||||
id: string;
|
||||
square_card_id: string;
|
||||
brand: string;
|
||||
last_4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
fingerprint: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
booking_id: string;
|
||||
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
|
||||
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount';
|
||||
vendor_code: string | null;
|
||||
invoice_number: number | null;
|
||||
status: 'pending' | 'completed' | 'failed' | 'refunded';
|
||||
amount: number;
|
||||
is_vat_applicable: boolean;
|
||||
vat_rate: number | null;
|
||||
vat_amount: number | null;
|
||||
net_amount: number | null;
|
||||
user_saved_card_id: string | null;
|
||||
square_payment_id: string | null;
|
||||
idempotency_key: string | null;
|
||||
fees: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface Refund {
|
||||
id: string;
|
||||
payment_id: string;
|
||||
booking_id: string;
|
||||
amount: number;
|
||||
square_refund_id: string | null;
|
||||
status: 'pending' | 'completed' | 'failed' | 'refunded';
|
||||
reason: string;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PaymentSummary {
|
||||
total_amount: number;
|
||||
paid_amount: number;
|
||||
refunded_amount: number;
|
||||
remaining_amount: number;
|
||||
payments: Payment[];
|
||||
refunds: Refund[];
|
||||
}
|
||||
|
||||
export interface CheckoutResponse {
|
||||
checkout_id: string;
|
||||
status: 'PENDING' | 'COMPLETED' | 'FAILED';
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// Types
|
||||
type Service = {
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status: string;
|
||||
services: Service[];
|
||||
deposit_required: boolean;
|
||||
deposit_amount?: number;
|
||||
deposit_deadline?: string;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
type PaymentSummary = {
|
||||
total_amount: number;
|
||||
paid_amount: number;
|
||||
refunded_amount: number;
|
||||
remaining_amount: number;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
payment_type: string;
|
||||
}>;
|
||||
refunds: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
// State
|
||||
let booking = $state<Booking | null>(null);
|
||||
let paymentSummary = $state<PaymentSummary | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Get booking ID from URL
|
||||
const bookingId = $derived($page.params.id);
|
||||
|
||||
// Derived values
|
||||
const totalDuration = $derived(
|
||||
booking?.services?.reduce((sum, s) => sum + s.duration_minutes, 0) ?? 0
|
||||
);
|
||||
|
||||
const totalPrice = $derived(
|
||||
booking?.services?.reduce((sum, s) => sum + s.price, 0) ?? 0
|
||||
);
|
||||
|
||||
const hasPaidDeposit = $derived(() => {
|
||||
if (!paymentSummary?.payments) return false;
|
||||
return paymentSummary.payments.some(
|
||||
(p) => p.status === 'completed' && (p.payment_type === 'deposit' || p.payment_type === 'full')
|
||||
);
|
||||
});
|
||||
|
||||
const hasPaidAnything = $derived(() => {
|
||||
if (!paymentSummary?.payments) return false;
|
||||
return paymentSummary.payments.some((p) => p.status === 'completed');
|
||||
});
|
||||
|
||||
// Format functions
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${remainingMinutes} minutes`;
|
||||
} else if (remainingMinutes === 0) {
|
||||
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||
} else {
|
||||
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(pence: number): string {
|
||||
return `£${(pence / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDeadline(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch data
|
||||
async function fetchBookingData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Fetch booking details
|
||||
const bookingResponse = await fetch(`/api/bookings/${bookingId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!bookingResponse.ok) {
|
||||
if (bookingResponse.status === 404) {
|
||||
throw new Error('Booking not found');
|
||||
}
|
||||
throw new Error('Failed to load booking');
|
||||
}
|
||||
|
||||
booking = await bookingResponse.json();
|
||||
|
||||
// Fetch payment summary
|
||||
const paymentResponse = await fetch(`/api/bookings/${bookingId}/payment-summary`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (paymentResponse.ok) {
|
||||
paymentSummary = await paymentResponse.json();
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
$effect(() => {
|
||||
if (bookingId) {
|
||||
fetchBookingData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Booking Confirmed - Crussell</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl p-6">
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<Skeleton class="mx-auto h-12 w-64" />
|
||||
<Skeleton class="mx-auto mt-2 h-6 w-48" />
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Content class="space-y-4 pt-6">
|
||||
<Skeleton class="h-8 w-full" />
|
||||
<Skeleton class="h-20 w-full" />
|
||||
<Skeleton class="h-16 w-full" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else if error}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-red-600">Error</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-gray-600">{error}</p>
|
||||
<Button class="mt-4" onclick={() => (window.location.href = '/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if booking}
|
||||
<div class="mb-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-green-100">
|
||||
<svg
|
||||
class="h-10 w-10 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>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Booking Confirmed!</h1>
|
||||
<p class="mt-2 text-gray-600">Your appointment has been successfully booked</p>
|
||||
</div>
|
||||
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Appointment Details</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-500">Date</div>
|
||||
<div class="text-lg font-semibold">{formatDate(booking.start_time)}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-medium text-gray-500">Time</div>
|
||||
<div class="text-lg font-semibold">{formatTime(booking.start_time)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-b pb-4">
|
||||
<div class="text-sm font-medium text-gray-500">Estimated Duration</div>
|
||||
<div class="font-semibold">{formatDuration(totalDuration)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 text-sm font-medium text-gray-500">Services</div>
|
||||
<div class="space-y-2">
|
||||
{#each booking.services as service}
|
||||
<div class="flex justify-between rounded bg-gray-50 p-3">
|
||||
<div>
|
||||
<div class="font-medium">{service.service_name}</div>
|
||||
<div class="text-sm text-gray-500">{service.duration_minutes} mins</div>
|
||||
</div>
|
||||
<div class="font-semibold">{formatPrice(service.price)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between border-t pt-4">
|
||||
<div class="text-lg font-semibold">Total</div>
|
||||
<div class="text-lg font-bold">{formatPrice(totalPrice)}</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Payment</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if booking.deposit_required}
|
||||
{#if hasPaidDeposit()}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<svg
|
||||
class="h-6 w-6 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>
|
||||
<div class="font-semibold text-green-800">Deposit Paid</div>
|
||||
<div class="text-sm text-green-700">
|
||||
Your deposit of {formatPrice(booking.deposit_amount ?? 0)} has been paid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if paymentSummary && paymentSummary.remaining_amount > 0}
|
||||
<div class="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Remaining Balance</span>
|
||||
<span class="font-semibold">{formatPrice(paymentSummary.remaining_amount)}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
You can pay the remaining balance on the day of your appointment
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-amber-800">Deposit Required</div>
|
||||
<div class="text-sm text-amber-700">
|
||||
To secure your booking, please pay a deposit of {formatPrice(booking.deposit_amount ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-amber-800">
|
||||
{formatPrice(booking.deposit_amount ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
{#if booking.deposit_deadline}
|
||||
<div class="mt-2 text-sm text-amber-600">
|
||||
Please pay before {formatDeadline(booking.deposit_deadline)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Button class="w-full" onclick={() => toast.info('Payment integration coming soon')}>
|
||||
Pay Deposit Now
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<p class="text-gray-600">
|
||||
You can pay on the day, but if you'd prefer you can pay ahead here
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if hasPaidAnything()}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<svg
|
||||
class="h-6 w-6 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>
|
||||
<div class="font-semibold text-green-800">Paid</div>
|
||||
<div class="text-sm text-green-700">
|
||||
{formatPrice(paymentSummary?.paid_amount ?? 0)} paid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Button class="w-full" onclick={() => toast.info('Payment integration coming soon')}>
|
||||
Pay Now
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-4 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" onclick={() => (window.location.href = '/account')}>
|
||||
View My Bookings
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={() => (window.location.href = '/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,387 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// Types
|
||||
type Service = {
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status: string;
|
||||
customer_first_name: string;
|
||||
services: Service[];
|
||||
};
|
||||
|
||||
// State
|
||||
let booking = $state<Booking | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
|
||||
// Tip selection state
|
||||
let selectedTip = $state<number | null>(null);
|
||||
let customTip = $state('');
|
||||
let tipAmount = $derived(
|
||||
selectedTip !== null
|
||||
? selectedTip
|
||||
: customTip
|
||||
? parseFloat(customTip) || 0
|
||||
: 0
|
||||
);
|
||||
|
||||
// Card form state (placeholder for Square SDK)
|
||||
let cardNumber = $state('');
|
||||
let cardExpiry = $state('');
|
||||
let cardCvc = $state('');
|
||||
|
||||
// Get booking ID from URL
|
||||
const bookingId = $derived($page.params.id);
|
||||
|
||||
// Format functions
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatPrice(pence: number): string {
|
||||
return `£${(pence / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Fetch booking data
|
||||
async function fetchBookingData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/bookings/${bookingId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Booking not found');
|
||||
}
|
||||
throw new Error('Failed to load booking');
|
||||
}
|
||||
|
||||
booking = await response.json();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tip selection
|
||||
function selectTip(amount: number) {
|
||||
selectedTip = amount;
|
||||
customTip = '';
|
||||
}
|
||||
|
||||
function handleCustomTipInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
customTip = input.value;
|
||||
selectedTip = null;
|
||||
}
|
||||
|
||||
// Submit tip payment
|
||||
async function submitTip() {
|
||||
if (tipAmount <= 0) {
|
||||
toast.error('Please select a tip amount');
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation for placeholder card form
|
||||
if (!cardNumber || !cardExpiry || !cardCvc) {
|
||||
toast.error('Please enter your card details');
|
||||
return;
|
||||
}
|
||||
|
||||
paymentState = 'processing';
|
||||
|
||||
try {
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
|
||||
const response = await fetch(`/api/bookings/${bookingId}/tip`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset and retry
|
||||
function retryPayment() {
|
||||
paymentState = 'idle';
|
||||
}
|
||||
|
||||
// Initialize
|
||||
$effect(() => {
|
||||
if (bookingId) {
|
||||
fetchBookingData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Leave a Tip - Crussell</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md p-6">
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<Skeleton class="mx-auto h-10 w-40" />
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Content class="space-y-4 pt-6">
|
||||
<Skeleton class="h-16 w-full" />
|
||||
<Skeleton class="h-24 w-full" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else if error}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-red-600">Error</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-gray-600">{error}</p>
|
||||
<Button class="mt-4" onclick={() => (window.location.href = '/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if booking}
|
||||
<div class="mb-6 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">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 for your tip!</h2>
|
||||
<p class="mt-2 text-gray-600">Your generosity is greatly appreciated.</p>
|
||||
<Button class="mt-6" onclick={() => (window.location.href = '/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Your Appointment</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Name</span>
|
||||
<span class="font-medium">{booking.customer_first_name}</span>
|
||||
</div>
|
||||
<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">{formatTime(booking.start_time)}</span>
|
||||
</div>
|
||||
<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.price)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
class="rounded-lg border-2 py-3 text-center font-semibold transition-colors {selectedTip ===
|
||||
2
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'}"
|
||||
onclick={() => selectTip(2)}
|
||||
>
|
||||
£2
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border-2 py-3 text-center font-semibold transition-colors {selectedTip ===
|
||||
5
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'}"
|
||||
onclick={() => selectTip(5)}
|
||||
>
|
||||
£5
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border-2 py-3 text-center font-semibold transition-colors {selectedTip ===
|
||||
10
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'}"
|
||||
onclick={() => selectTip(10)}
|
||||
>
|
||||
£10
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="custom-tip" class="text-sm font-medium text-gray-700">Or enter custom amount</label>
|
||||
<div class="mt-1 relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
|
||||
<Input
|
||||
id="custom-tip"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
class="pl-7"
|
||||
value={customTip}
|
||||
oninput={handleCustomTipInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tipAmount > 0}
|
||||
<div class="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<span class="text-lg font-semibold text-blue-700">Tip: £{tipAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Card Details</Card.Title>
|
||||
<Card.Description>Secure payment powered by Square</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div>
|
||||
<label for="card-number" class="text-sm font-medium text-gray-700">Card Number</label>
|
||||
<Input
|
||||
id="card-number"
|
||||
type="text"
|
||||
placeholder="1234 5678 9012 3456"
|
||||
maxlength="19"
|
||||
bind:value={cardNumber}
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="card-expiry" class="text-sm font-medium text-gray-700">Expiry</label>
|
||||
<Input
|
||||
id="card-expiry"
|
||||
type="text"
|
||||
placeholder="MM/YY"
|
||||
maxlength="5"
|
||||
bind:value={cardExpiry}
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="card-cvc" class="text-sm font-medium text-gray-700">CVC</label>
|
||||
<Input
|
||||
id="card-cvc"
|
||||
type="text"
|
||||
placeholder="123"
|
||||
maxlength="4"
|
||||
bind:value={cardCvc}
|
||||
class="mt-1"
|
||||
/>
|
||||
</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">
|
||||
This is a placeholder form. Square SDK integration coming soon.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user