feat(frontend): update booking flow and account components
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -19,6 +21,19 @@
|
||||
let selectedBooking = $state<Booking | null>(null);
|
||||
let loading = $state(false);
|
||||
let hasPendingEditRequest = $state(false);
|
||||
let pendingEditRequest = $state<{
|
||||
id: string;
|
||||
notes: string | null;
|
||||
requested_at: string;
|
||||
original: {
|
||||
start_time: string | null;
|
||||
services: Array<{ name: string; price: number; duration_minutes: number }>;
|
||||
};
|
||||
proposed: {
|
||||
start_time: string | null;
|
||||
services: Array<{ name: string; price: number; duration_minutes: number }>;
|
||||
};
|
||||
} | null>(null);
|
||||
|
||||
let showEditModal = $state(false);
|
||||
let showCancelConfirm = $state(false);
|
||||
@@ -45,7 +60,7 @@
|
||||
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
|
||||
);
|
||||
|
||||
let canEditBooking = $derived(isCancellable && !hasPayments);
|
||||
let canEditBooking = $derived(isCancellable);
|
||||
|
||||
let totalPaid = $derived(
|
||||
selectedBooking?.payments
|
||||
@@ -61,11 +76,27 @@
|
||||
selectedBooking &&
|
||||
!depositOutstanding &&
|
||||
totalPaid < selectedBooking.total_amount &&
|
||||
['confirmed', 'pending'].includes(selectedBooking.status)
|
||||
['confirmed', 'in_progress'].includes(selectedBooking.status)
|
||||
);
|
||||
|
||||
let isCompleted = $derived(selectedBooking?.status === 'completed');
|
||||
|
||||
let hoursUntilAppointment = $derived(
|
||||
selectedBooking
|
||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||
: Infinity
|
||||
);
|
||||
let protectedDeposit = $derived(
|
||||
selectedBooking ? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT) : 0
|
||||
);
|
||||
let estimatedRefund = $derived(
|
||||
hoursUntilAppointment > POLICY.FULL_REFUND_THRESHOLD_HOURS
|
||||
? totalPaid
|
||||
: hoursUntilAppointment >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
|
||||
? Math.max(0, totalPaid - protectedDeposit)
|
||||
: 0
|
||||
);
|
||||
|
||||
let showPaymentModal = $state(false);
|
||||
|
||||
let showTipModal = $state(false);
|
||||
@@ -171,7 +202,9 @@
|
||||
const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
hasPendingEditRequest = editResp.ok;
|
||||
const editData = await editResp.json();
|
||||
hasPendingEditRequest = editData.edit_request != null;
|
||||
pendingEditRequest = editData.edit_request || null;
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load booking: ' + text);
|
||||
@@ -191,6 +224,7 @@
|
||||
setTimeout(() => {
|
||||
selectedBooking = null;
|
||||
hasPendingEditRequest = false;
|
||||
pendingEditRequest = null;
|
||||
showCancelConfirm = false;
|
||||
showEditModal = false;
|
||||
}, 200);
|
||||
@@ -331,7 +365,7 @@
|
||||
<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 class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
||||
<div class="font-medium">
|
||||
@@ -568,11 +602,38 @@
|
||||
showEditModal = true;
|
||||
}}
|
||||
>
|
||||
Edit Request
|
||||
Edit/Reschedule
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if pendingEditRequest}
|
||||
{@const timeChanged = pendingEditRequest.original.start_time && pendingEditRequest.proposed?.start_time && pendingEditRequest.original.start_time !== pendingEditRequest.proposed.start_time}
|
||||
{@const servicesChanged = pendingEditRequest.proposed?.services?.length && JSON.stringify(pendingEditRequest.original.services?.map(s => s.name)) !== JSON.stringify(pendingEditRequest.proposed.services?.map(s => s.name))}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
<div class="text-amber-900">
|
||||
<p class="font-medium">Awaiting admin approval</p>
|
||||
<p class="mt-0.5 text-amber-700">
|
||||
{#if timeChanged}
|
||||
Reschedule requested from {new SvelteDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {new SvelteDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
{/if}
|
||||
{#if servicesChanged}
|
||||
{timeChanged ? ' • ' : ''}Service change requested
|
||||
{/if}
|
||||
{pendingEditRequest.notes ? (timeChanged || servicesChanged ? ' — ' : '') + pendingEditRequest.notes : ''}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-amber-500">We'll let you know once it's been reviewed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if isCompleted}
|
||||
<Button
|
||||
@@ -584,7 +645,7 @@
|
||||
Leave a Tip
|
||||
</Button>
|
||||
{/if}
|
||||
{#if depositOutstanding}
|
||||
{#if depositOutstanding && selectedBooking?.status !== 'pending'}
|
||||
<Button
|
||||
size="sm"
|
||||
class="flex-1 bg-amber-600 text-white hover:bg-amber-700"
|
||||
@@ -598,11 +659,15 @@
|
||||
size="sm"
|
||||
class="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
onclick={() => (showPaymentModal = true)}
|
||||
disabled={hasPendingEditRequest}
|
||||
>
|
||||
Pay Early
|
||||
</Button>
|
||||
{/if}
|
||||
{#if hasPendingEditRequest && !depositOutstanding}
|
||||
<div class="flex-1 rounded-md border border-dashed border-gray-200 bg-gray-50/50 px-3 py-2 text-center text-xs text-gray-400">
|
||||
Payments paused while awaiting approval
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
|
||||
</div>
|
||||
@@ -626,6 +691,7 @@
|
||||
onClose={() => (showPaymentModal = false)}
|
||||
onComplete={handlePaymentComplete}
|
||||
{canSaveCards}
|
||||
defaultPaymentType={depositOutstanding ? 'deposit' : undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -634,36 +700,76 @@
|
||||
<Modal.Header>
|
||||
<Modal.Title>Cancel Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
{@const hoursUntilAppt = Math.round(hoursUntilAppointment)}
|
||||
{#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"
|
||||
class="mt-2 rounded-md border p-3 text-sm {hoursUntilAppt < POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
|
||||
? 'border-red-200 bg-red-50 text-red-800'
|
||||
: 'border-amber-200 bg-amber-50 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 hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS}
|
||||
<p class="font-medium text-green-800">Full Refund</p>
|
||||
<p class="mt-1">
|
||||
You have given over {POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. You will receive a
|
||||
<strong>full refund</strong> of <strong>£{totalPaid.toFixed(2)}</strong>.
|
||||
Nothing will be deducted.
|
||||
</p>
|
||||
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
|
||||
<p class="font-medium">Partial Refund</p>
|
||||
<p class="mt-1">
|
||||
Based on your notice period ({hoursUntilAppt}h), up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of the total
|
||||
(£{protectedDeposit.toFixed(2)}) is treated as a protected deposit and will be
|
||||
retained. The remaining <strong>£{estimatedRefund.toFixed(2)}</strong> will be refunded.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="font-semibold text-red-900">Cancelling With No Refund</p>
|
||||
<p class="mt-1">
|
||||
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. The full amount you have paid
|
||||
(<strong>£{totalPaid.toFixed(2)}</strong>) will be retained to cover the lost slot.
|
||||
It will also count as a <strong>no-show</strong> toward your booking history
|
||||
(2 no-shows within 6 months would require deposits on future bookings).
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs">
|
||||
Refunds are processed via Square and may take 3–5 business days. See our
|
||||
<PolicyPopover>
|
||||
{#snippet trigger()}
|
||||
<span class="underline">cancellation policy</span>
|
||||
{/snippet}
|
||||
</PolicyPopover>
|
||||
for full details.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
|
||||
<p>
|
||||
Are you sure you want to cancel this booking?
|
||||
</p>
|
||||
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
|
||||
<p class="font-medium">Clean Cancellation</p>
|
||||
<p class="mt-1">
|
||||
This booking has no payments and is being cancelled with
|
||||
{hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS ? 'plenty of' : 'sufficient'} notice.
|
||||
It will be removed completely and will not appear in your booking history.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>
|
||||
Are you sure you want to cancel this booking?
|
||||
</p>
|
||||
<div class="mt-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<p class="font-semibold text-red-900">Short Notice Cancellation</p>
|
||||
<p class="mt-1">
|
||||
This booking is under {POLICY.PARTIAL_REFUND_THRESHOLD_HOURS} hours' notice. Cancelling now counts as a
|
||||
<strong>no-show</strong> (2 no-shows within 6 months will require deposits on
|
||||
future bookings).
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
@@ -694,7 +800,7 @@
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-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 ===
|
||||
|
||||
Reference in New Issue
Block a user