Shows the total amount already paid in the cancellation confirmation, plus deposit-specific messaging: forfeiture warning when deposit is paid, and a note that outstanding deposit is no longer due when deposit is required but not yet paid. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
747 lines
24 KiB
Svelte
747 lines
24 KiB
Svelte
<script lang="ts">
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
import { toast } from 'svelte-sonner';
|
|
import * as Modal from '$lib/components/ui/dialog';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
|
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
|
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
bookingId: string;
|
|
}
|
|
|
|
let { open = $bindable(), bookingId }: Props = $props();
|
|
|
|
let selectedBooking = $state<Booking | null>(null);
|
|
let loading = $state(false);
|
|
let hasPendingEditRequest = $state(false);
|
|
|
|
let showEditModal = $state(false);
|
|
let showCancelConfirm = $state(false);
|
|
let cancelling = $state(false);
|
|
|
|
// IMPORTANT: Use override_duration_minutes when present — services may have been
|
|
// customised at booking time. Showing base values misleads users about what was booked.
|
|
let totalDuration = $derived(
|
|
selectedBooking?.services?.reduce(
|
|
(sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0),
|
|
0
|
|
) || 0
|
|
);
|
|
|
|
let isFutureBooking = $derived(
|
|
selectedBooking ? new SvelteDate(selectedBooking.start_time) > new SvelteDate() : false
|
|
);
|
|
|
|
let hasPayments = $derived(
|
|
selectedBooking && selectedBooking.payments && selectedBooking.payments.length > 0
|
|
);
|
|
|
|
let isCancellable = $derived(
|
|
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
|
|
);
|
|
|
|
let canEditBooking = $derived(isCancellable && !hasPayments);
|
|
|
|
let totalPaid = $derived(
|
|
selectedBooking?.payments
|
|
?.filter((p) => p.status === 'completed')
|
|
.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 isCompleted = $derived(selectedBooking?.status === 'completed');
|
|
|
|
let showPaymentModal = $state(false);
|
|
|
|
let showTipModal = $state(false);
|
|
let tipAmount = $state<number>(0);
|
|
let selectedTipPreset = $state<number | null>(null);
|
|
let customTipInput = $state('');
|
|
let tipProcessing = $state(false);
|
|
|
|
let canSaveCards = $derived(
|
|
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
|
);
|
|
|
|
let tipPresets = $derived(
|
|
selectedBooking
|
|
? [
|
|
{ pct: 10, amount: Math.round(selectedBooking.total_amount * 0.1 * 100) / 100 },
|
|
{ pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 },
|
|
{ pct: 20, amount: Math.round(selectedBooking.total_amount * 0.2 * 100) / 100 }
|
|
]
|
|
: []
|
|
);
|
|
|
|
function selectTipPreset(amount: number) {
|
|
selectedTipPreset = amount;
|
|
customTipInput = '';
|
|
tipAmount = amount;
|
|
}
|
|
|
|
function handleCustomTip(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 === '') {
|
|
customTipInput = sanitized;
|
|
}
|
|
selectedTipPreset = null;
|
|
tipAmount = parseFloat(customTipInput) || 0;
|
|
}
|
|
|
|
async function submitTip() {
|
|
if (!selectedBooking) return;
|
|
if (tipAmount <= 0) {
|
|
toast.error('Please select a tip amount');
|
|
return;
|
|
}
|
|
tipProcessing = true;
|
|
try {
|
|
const response = await fetch(`/api/bookings/${selectedBooking.id}/tip`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
amount: Math.round(tipAmount * 100),
|
|
card_token: 'placeholder'
|
|
})
|
|
});
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(errorText || 'Tip payment failed');
|
|
}
|
|
toast.success('Thank you for your tip!');
|
|
showTipModal = false;
|
|
fetchBookingDetails();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Tip payment failed');
|
|
} finally {
|
|
tipProcessing = false;
|
|
}
|
|
}
|
|
|
|
function handlePaymentComplete() {
|
|
toast.success('Payment completed');
|
|
showPaymentModal = false;
|
|
fetchBookingDetails();
|
|
}
|
|
|
|
async function fetchBookingDetails() {
|
|
if (!bookingId) return;
|
|
loading = true;
|
|
try {
|
|
const response = await fetch(`/api/bookings/${bookingId}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
selectedBooking = data as Booking;
|
|
|
|
const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, {
|
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
|
});
|
|
hasPendingEditRequest = editResp.ok;
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Failed to load booking: ' + text);
|
|
open = false;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching booking:', err);
|
|
toast.error('Network error');
|
|
open = false;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!open) {
|
|
setTimeout(() => {
|
|
selectedBooking = null;
|
|
hasPendingEditRequest = false;
|
|
showCancelConfirm = false;
|
|
showEditModal = false;
|
|
}, 200);
|
|
} else if (bookingId && !selectedBooking) {
|
|
fetchBookingDetails();
|
|
}
|
|
});
|
|
|
|
async function cancelBooking() {
|
|
if (!selectedBooking) return;
|
|
cancelling = true;
|
|
try {
|
|
const body = hasPayments ? { reason: 'client_cancelled' } : undefined;
|
|
const response = await fetch(`/api/bookings/${selectedBooking.id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast.success('Booking cancelled');
|
|
showCancelConfirm = false;
|
|
open = false;
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Failed to cancel: ' + text);
|
|
}
|
|
} catch {
|
|
toast.error('Network error');
|
|
} finally {
|
|
cancelling = false;
|
|
}
|
|
}
|
|
|
|
function formatPaymentMethod(method: string): string {
|
|
switch (method) {
|
|
case 'in_person_card':
|
|
return 'Card, In-person';
|
|
case 'online_square':
|
|
return 'Card, Online';
|
|
case 'cash':
|
|
return 'Cash';
|
|
case 'giftcard':
|
|
return 'Gift Card';
|
|
case 'discount':
|
|
return 'Discount';
|
|
default:
|
|
return method.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
}
|
|
|
|
function getPaymentName(payment: Payment, index: number, payments: Payment[], discounts: BookingDiscount[] | undefined): string {
|
|
if (payment.payment_method === 'online_square') return 'Online Card';
|
|
if (payment.payment_method === 'in_person_card') return 'Card Machine';
|
|
if (payment.payment_method === 'cash') return 'Cash';
|
|
if (payment.payment_method === 'giftcard') return 'Gift Card';
|
|
if (payment.payment_method === 'discount') {
|
|
const discountPaymentsBefore = payments.slice(0, index).filter(p => p.payment_method === 'discount').length;
|
|
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
|
|
if (discountList[discountPaymentsBefore]) {
|
|
const d = discountList[discountPaymentsBefore];
|
|
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
|
|
if (d.campaign_name) return `${d.campaign_name}`;
|
|
return 'Promo Campaign Discount';
|
|
}
|
|
return 'Discount';
|
|
}
|
|
return formatPaymentMethod(payment.payment_method);
|
|
}
|
|
</script>
|
|
|
|
<Modal.Root bind:open>
|
|
<Modal.Content
|
|
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
|
>
|
|
<Modal.Header>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<Modal.Title class="text-lg font-semibold">Booking Details</Modal.Title>
|
|
{#if selectedBooking}
|
|
<div class="mt-1 text-sm text-gray-500">ID: {selectedBooking.id}</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if selectedBooking}
|
|
{@const isPastBooking = new SvelteDate(selectedBooking.start_time) < new SvelteDate()}
|
|
{@const isUnpaid = selectedBooking.amount_due > 0}
|
|
{@const showChip = !isPastBooking || isUnpaid}
|
|
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(
|
|
selectedBooking.status
|
|
)}
|
|
|
|
{#if showChip}
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
|
{isPastBooking
|
|
? 'bg-red-100 text-red-800'
|
|
: selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed'
|
|
? 'bg-emerald-100 text-emerald-800'
|
|
: selectedBooking.status === 'pending'
|
|
? 'bg-amber-100 text-amber-800'
|
|
: 'bg-gray-100 text-gray-800'}"
|
|
>
|
|
{isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')}
|
|
</span>
|
|
|
|
{#if selectedBooking.deposit_required}
|
|
{#if selectedBooking.status === 'pending'}
|
|
<span
|
|
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
|
|
>
|
|
Will Require Deposit
|
|
</span>
|
|
{:else if isConfirmedOrLater}
|
|
<span
|
|
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
|
{selectedBooking.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
|
|
>
|
|
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
|
|
</span>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</Modal.Header>
|
|
|
|
{#if loading}
|
|
<div class="flex items-center justify-center p-8 text-gray-500">Loading...</div>
|
|
{:else if selectedBooking}
|
|
<div class="space-y-6 px-4 pb-4">
|
|
<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">
|
|
Appointment Details
|
|
</h3>
|
|
<div class="grid gap-3 md:grid-cols-2">
|
|
<div>
|
|
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
|
<div class="font-medium">
|
|
{(() => {
|
|
const date = new SvelteDate(selectedBooking.start_time);
|
|
const dateStr = date.toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'short'
|
|
});
|
|
const timeStr = date.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
return `${dateStr} at ${timeStr}`;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Duration</div>
|
|
<div class="font-medium">{totalDuration} minutes</div>
|
|
</div>
|
|
{#if selectedBooking.notes}
|
|
<div class="md:col-span-2">
|
|
<div class="text-xs text-gray-500">Notes</div>
|
|
<div class="mt-1 rounded-md border border-gray-300 bg-white p-2 text-sm">
|
|
{selectedBooking.notes}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if selectedBooking.services && selectedBooking.services.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">
|
|
Services
|
|
</h3>
|
|
<div class="space-y-3">
|
|
{#each selectedBooking.services as service, index (index)}
|
|
<div class="rounded-md border border-gray-300 bg-white p-3">
|
|
<div class="font-medium">{service.service_name || '—'}</div>
|
|
{#if service.service_description}
|
|
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
|
|
{/if}
|
|
<div class="mt-2 flex items-center justify-between text-sm">
|
|
<span class="text-gray-600"
|
|
>{service.override_duration_minutes ?? service.duration_minutes} min</span
|
|
>
|
|
<span class="font-semibold"
|
|
>£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<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">
|
|
Financial Summary
|
|
</h3>
|
|
<div class="space-y-2">
|
|
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
|
|
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
|
|
<span class="text-sm text-gray-600">Deposit Required</span>
|
|
<div class="text-right">
|
|
<div class="font-semibold">
|
|
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
|
|
</div>
|
|
<div class="text-xs">
|
|
<span
|
|
class={selectedBooking.deposit_paid ? 'text-green-600' : 'text-orange-600'}
|
|
>
|
|
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
|
|
</span>
|
|
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
|
|
<span class="text-gray-500">
|
|
• Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString(
|
|
'en-GB',
|
|
{
|
|
weekday: 'short',
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
}
|
|
)} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString(
|
|
'en-GB',
|
|
{
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
}
|
|
)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm text-gray-600">Subtotal (Services)</span>
|
|
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
|
|
</div>
|
|
|
|
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
|
|
<div class="border-y border-fuchsia-100 bg-fuchsia-50/20 py-2 my-2 space-y-1 rounded-md px-2">
|
|
{#each selectedBooking.discounts as d}
|
|
<div class="flex items-center justify-between text-xs text-fuchsia-800">
|
|
<span class="flex items-center gap-1.5">
|
|
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
|
|
{#if d.discount_source === 'loyalty'}
|
|
Loyalty Stamp Card (10% Off)
|
|
{:else if d.campaign_name}
|
|
{d.campaign_name} ({d.discount_percent}% Off)
|
|
{:else}
|
|
Promo Campaign ({d.discount_percent}% Off)
|
|
{/if}
|
|
</span>
|
|
<span class="font-medium">-£{d.discount_amount.toFixed(2)}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
<div class="flex items-center justify-between font-medium text-gray-900">
|
|
<span class="text-sm">Net Total</span>
|
|
<span>£{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
|
|
<span class="font-semibold text-green-700">
|
|
£{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}
|
|
</span>
|
|
</div>
|
|
|
|
{#if Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)) > 0.01}
|
|
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
|
|
<span class="font-medium text-gray-900">
|
|
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
|
|
</span>
|
|
<span class="text-lg font-bold text-red-600">
|
|
£{Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)).toFixed(2)}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if selectedBooking.payments && selectedBooking.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">
|
|
Payment History
|
|
</h3>
|
|
<div class="space-y-3">
|
|
{#each selectedBooking.payments as payment, index (index)}
|
|
<div class="rounded-md border border-gray-300 bg-white p-3">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium text-gray-900"
|
|
>{getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)}</span
|
|
>
|
|
<span
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
|
{payment.status === 'completed'
|
|
? 'bg-green-100 text-green-800'
|
|
: payment.status === 'pending'
|
|
? 'bg-yellow-100 text-yellow-800'
|
|
: 'bg-gray-100 text-gray-800'}"
|
|
>
|
|
{payment.status}
|
|
</span>
|
|
</div>
|
|
<div class="mt-1 text-xs text-gray-500">
|
|
{#if payment.payment_method === 'discount'}
|
|
Applied automatically on completion
|
|
{:else}
|
|
{payment.payment_type.charAt(0).toUpperCase() +
|
|
payment.payment_type.slice(1)} payment
|
|
{#if payment.card_last4}
|
|
, Card ending in {payment.card_last4}
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{#if payment.is_vat_applicable}
|
|
<div class="mt-2 text-xs text-gray-600">
|
|
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
|
|
{#if payment.vat_amount}
|
|
<div>
|
|
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount.toFixed(
|
|
2
|
|
)}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<div class="mt-1 text-xs text-gray-400">
|
|
{new SvelteDate(payment.created_at).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
<div class="text-right font-semibold">
|
|
{payment.payment_method === 'discount' ? '-' : ''}£{payment.amount.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex flex-col gap-2 border-t px-4 py-3">
|
|
<div class="flex gap-2">
|
|
{#if isCancellable}
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
class="flex-1"
|
|
onclick={() => (showCancelConfirm = true)}
|
|
>
|
|
Cancel Booking
|
|
</Button>
|
|
{#if canEditBooking}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
class="flex-1"
|
|
onclick={() => {
|
|
showEditModal = true;
|
|
}}
|
|
>
|
|
Edit Request
|
|
</Button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
<div class="flex gap-2">
|
|
{#if isCompleted}
|
|
<Button
|
|
size="sm"
|
|
class="flex-1 hover:bg-fuchsia-50"
|
|
variant="outline"
|
|
onclick={() => (showTipModal = true)}
|
|
>
|
|
Leave a Tip
|
|
</Button>
|
|
{/if}
|
|
{#if depositOutstanding}
|
|
<Button
|
|
size="sm"
|
|
class="flex-1 bg-amber-600 text-white hover:bg-amber-700"
|
|
onclick={() => (showPaymentModal = true)}
|
|
disabled={hasPendingEditRequest}
|
|
>
|
|
Pay Deposit
|
|
</Button>
|
|
{:else if canPayEarly && !hasPendingEditRequest}
|
|
<Button
|
|
size="sm"
|
|
class="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
|
|
onclick={() => (showPaymentModal = true)}
|
|
disabled={hasPendingEditRequest}
|
|
>
|
|
Pay Early
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
|
|
</div>
|
|
</Modal.Content>
|
|
</Modal.Root>
|
|
|
|
{#if showEditModal && selectedBooking}
|
|
<EditRequestModal
|
|
bind:open={showEditModal}
|
|
booking={selectedBooking}
|
|
onSubmitted={() => {
|
|
showEditModal = false;
|
|
fetchBookingDetails();
|
|
}}
|
|
/>
|
|
{/if}
|
|
|
|
{#if showPaymentModal && selectedBooking}
|
|
<UserPaymentModal
|
|
booking={selectedBooking}
|
|
onClose={() => (showPaymentModal = false)}
|
|
onComplete={handlePaymentComplete}
|
|
{canSaveCards}
|
|
/>
|
|
{/if}
|
|
|
|
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
|
|
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
|
|
<Modal.Header>
|
|
<Modal.Title>Cancel Booking</Modal.Title>
|
|
<Modal.Description>
|
|
{#if hasPayments}
|
|
<p>
|
|
Are you sure you want to cancel this booking? You have already made payments totalling
|
|
<span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
|
|
</p>
|
|
{:else}
|
|
Are you sure you want to cancel this booking?
|
|
{/if}
|
|
{#if hasPayments}
|
|
<div
|
|
class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
|
|
>
|
|
<p class="font-medium">Please note:</p>
|
|
<p class="mt-1">
|
|
Any amounts paid (<span class="font-semibold">£{totalPaid.toFixed(2)}</span>) will
|
|
not be refunded, but will be retained as credit towards a future appointment.
|
|
</p>
|
|
{#if selectedBooking}
|
|
{#if selectedBooking.deposit_paid}
|
|
<p class="mt-1">
|
|
The deposit of
|
|
<span class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2)}</span>
|
|
paid for this booking may be forfeited.
|
|
</p>
|
|
{/if}
|
|
{#if selectedBooking.deposit_required && !selectedBooking.deposit_paid}
|
|
<p class="mt-1">Any outstanding deposit will no longer be due.</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</Modal.Description>
|
|
</Modal.Header>
|
|
<Modal.Footer>
|
|
<Button variant="outline" onclick={() => (showCancelConfirm = false)}>Keep Booking</Button>
|
|
<Button variant="destructive" onclick={cancelBooking} disabled={cancelling}>
|
|
{cancelling ? 'Cancelling...' : 'Yes, Cancel'}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</Modal.Content>
|
|
</Modal.Root>
|
|
|
|
<Modal.Root
|
|
open={showTipModal}
|
|
onOpenChange={(v) => {
|
|
if (!v) {
|
|
showTipModal = false;
|
|
tipAmount = 0;
|
|
selectedTipPreset = null;
|
|
customTipInput = '';
|
|
}
|
|
}}
|
|
>
|
|
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
|
|
<Modal.Header>
|
|
<Modal.Title>Leave a Tip</Modal.Title>
|
|
<Modal.Description>Show your appreciation for great service</Modal.Description>
|
|
</Modal.Header>
|
|
|
|
<div class="space-y-4 px-4 pb-4">
|
|
<div class="grid grid-cols-3 gap-3">
|
|
{#each tipPresets as preset (preset.pct)}
|
|
<button
|
|
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset ===
|
|
preset.amount
|
|
? 'bg-fuchsia-100'
|
|
: ''}"
|
|
onclick={() => selectTipPreset(preset.amount)}
|
|
type="button"
|
|
>
|
|
<div>£{preset.amount.toFixed(2)}</div>
|
|
<div class="text-xs font-normal text-gray-500">{preset.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={customTipInput}
|
|
oninput={handleCustomTip}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal.Footer>
|
|
<Button variant="outline" onclick={() => (showTipModal = false)}>Cancel</Button>
|
|
<Button
|
|
class="hover:bg-fuchsia-50"
|
|
onclick={submitTip}
|
|
disabled={tipAmount <= 0 || tipProcessing}
|
|
loading={tipProcessing}
|
|
>
|
|
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</Modal.Content>
|
|
</Modal.Root>
|