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:
2026-06-18 16:27:02 +01:00
co-authored by Sisyphus
parent b23a8b89c4
commit 2ad3c01a80
3 changed files with 541 additions and 296 deletions
@@ -8,7 +8,8 @@
import * as Textarea from '$lib/components/ui/textarea'; import * as Textarea from '$lib/components/ui/textarea';
import * as Label from '$lib/components/ui/label'; import * as Label from '$lib/components/ui/label';
import DatePicker from '$lib/components/booking/DatePicker.svelte'; import DatePicker from '$lib/components/booking/DatePicker.svelte';
import type { Booking, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking'; import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import type { Booking, BookingDiscount, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import { import {
extractBookedSlots, extractBookedSlots,
getLunchProtectionForSlots, getLunchProtectionForSlots,
@@ -77,6 +78,28 @@
); );
let placeholderDate = $state<CalendarDate>(minDate); let placeholderDate = $state<CalendarDate>(minDate);
let hoursUntilAppointment = $derived(
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
);
let hasPayments = $derived(
(booking.amount_paid ?? 0) > 0
);
let noticePeriodBlocked = $derived(
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
);
let noticePeriodWarning = $derived(
!hasPayments && hoursUntilAppointment < 24 && hoursUntilAppointment >= 0
);
let noticeBlockedMessage = $derived(
hasPayments
? 'This booking has payments and is too close to the original appointment to reschedule online.'
: 'This booking is too close to the original appointment time to reschedule online.'
);
let discountTotal = $derived(
booking.discounts?.reduce((sum, d) => sum + d.discount_amount, 0) ?? 0
);
let bookingTotalDuration = $derived( let bookingTotalDuration = $derived(
booking.services?.reduce( booking.services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0), (sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
@@ -110,7 +133,7 @@
let modalTitle = $derived(() => { let modalTitle = $derived(() => {
switch (editMode) { switch (editMode) {
case 'select': case 'select':
return 'Edit Request'; return 'Edit/Reschedule';
case 'time': case 'time':
return 'Change Time'; return 'Change Time';
case 'services': case 'services':
@@ -379,9 +402,10 @@
} }
}); });
let servicesHoursFetched = $state(false);
$effect(() => { $effect(() => {
if (editMode === 'services') { if (editMode === 'services' && !servicesHoursFetched) {
// Fetch hours for the booking's current date to calculate remaining time servicesHoursFetched = true;
const bookingDate = new SvelteDate(booking.start_time); const bookingDate = new SvelteDate(booking.start_time);
const calDate = new CalendarDate( const calDate = new CalendarDate(
bookingDate.getFullYear(), bookingDate.getFullYear(),
@@ -390,6 +414,9 @@
); );
fetchHoursForMonth(calDate); fetchHoursForMonth(calDate);
} }
if (editMode !== 'services') {
servicesHoursFetched = false;
}
}); });
// ─── Auto-select ──────────────────────────────────────── // ─── Auto-select ────────────────────────────────────────
@@ -681,12 +708,16 @@
}); });
if (response.ok) { if (response.ok) {
toast.success("Edit request sent — we'll confirm shortly"); const noShowWarning = response.headers.get('X-No-Show-Warning');
if (noShowWarning) {
toast.warning(noShowWarning, { duration: 8000 });
}
toast.success("Edit/reschedule request sent — we'll confirm shortly");
open = false; open = false;
onSubmitted(); onSubmitted();
} else { } else {
const text = await response.text(); const text = await response.text();
toast.error(text || 'Failed to submit edit request'); toast.error(text || 'Failed to submit edit/reschedule request');
} }
} catch { } catch {
toast.error('Network error'); toast.error('Network error');
@@ -734,10 +765,54 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<p class="text-sm text-gray-600">What would you like to change?</p> <p class="text-sm text-gray-600">What would you like to change?</p>
{#if noticePeriodBlocked}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<p class="font-medium text-amber-900">Cannot Reschedule Online</p>
<p class="mt-1">{noticeBlockedMessage}</p>
<p class="mt-1">
<a href="/contact" target="_blank" class="underline">Contact us</a> to discuss options,
or <button type="button" onclick={() => open = false} class="inline underline cursor-pointer">cancel this booking</button>
and rebook — note that cancellation fees may apply based on our
<PolicyPopover>
{#snippet trigger()}
<span class="underline">deposit policy</span>
{/snippet}
</PolicyPopover>.
</p>
</div>
{:else if noticePeriodWarning}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<p class="font-medium text-amber-900">Rescheduling Within 24h</p>
<p class="mt-1">
Rescheduling within 24h counts as a no-show towards your deposit obligations. Two no-shows
within 6 months will require deposits on future bookings.
<PolicyPopover>
{#snippet trigger()}
<span class="underline">Full policy</span>
{/snippet}
</PolicyPopover>
</p>
</div>
{/if}
{#if booking.discounts && booking.discounts.length > 0}
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
>
<p class="font-medium text-amber-900">Discounts Applied to This Booking</p>
<p class="mt-1">
Your booking has £{discountTotal.toFixed(2)} in savings from loyalty stamps or
promotional offers. A time change requires admin approval. If denied, you can cancel
(standard refund policy applies) and rebook at full price.
</p>
</div>
{/if}
<button <button
type="button" type="button"
disabled={noticePeriodBlocked}
onclick={() => selectMode('time')} onclick={() => selectMode('time')}
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50" class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50"
> >
<div <div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600" class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"
@@ -775,7 +850,7 @@
<button <button
type="button" type="button"
onclick={() => selectMode('both-services')} onclick={() => selectMode('both-services')}
disabled={hasOverrides} disabled={hasOverrides || noticePeriodBlocked}
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50" class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50"
title={hasOverrides title={hasOverrides
? 'This booking has custom pricing. To change services, please contact the salon.' ? 'This booking has custom pricing. To change services, please contact the salon.'
@@ -1,4 +1,6 @@
<script lang="ts"> <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 { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -19,6 +21,19 @@
let selectedBooking = $state<Booking | null>(null); let selectedBooking = $state<Booking | null>(null);
let loading = $state(false); let loading = $state(false);
let hasPendingEditRequest = $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 showEditModal = $state(false);
let showCancelConfirm = $state(false); let showCancelConfirm = $state(false);
@@ -45,7 +60,7 @@
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status) selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
); );
let canEditBooking = $derived(isCancellable && !hasPayments); let canEditBooking = $derived(isCancellable);
let totalPaid = $derived( let totalPaid = $derived(
selectedBooking?.payments selectedBooking?.payments
@@ -61,11 +76,27 @@
selectedBooking && selectedBooking &&
!depositOutstanding && !depositOutstanding &&
totalPaid < selectedBooking.total_amount && totalPaid < selectedBooking.total_amount &&
['confirmed', 'pending'].includes(selectedBooking.status) ['confirmed', 'in_progress'].includes(selectedBooking.status)
); );
let isCompleted = $derived(selectedBooking?.status === 'completed'); 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 showPaymentModal = $state(false);
let showTipModal = $state(false); let showTipModal = $state(false);
@@ -171,7 +202,9 @@
const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, { const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` } headers: { Authorization: `Bearer ${authStore.currentToken}` }
}); });
hasPendingEditRequest = editResp.ok; const editData = await editResp.json();
hasPendingEditRequest = editData.edit_request != null;
pendingEditRequest = editData.edit_request || null;
} else { } else {
const text = await response.text(); const text = await response.text();
toast.error('Failed to load booking: ' + text); toast.error('Failed to load booking: ' + text);
@@ -191,6 +224,7 @@
setTimeout(() => { setTimeout(() => {
selectedBooking = null; selectedBooking = null;
hasPendingEditRequest = false; hasPendingEditRequest = false;
pendingEditRequest = null;
showCancelConfirm = false; showCancelConfirm = false;
showEditModal = false; showEditModal = false;
}, 200); }, 200);
@@ -331,7 +365,7 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Appointment Details Appointment Details
</h3> </h3>
<div class="grid gap-3 md:grid-cols-2"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<div class="text-xs text-gray-500">Scheduled Date & Time</div> <div class="text-xs text-gray-500">Scheduled Date & Time</div>
<div class="font-medium"> <div class="font-medium">
@@ -568,11 +602,38 @@
showEditModal = true; showEditModal = true;
}} }}
> >
Edit Request Edit/Reschedule
</Button> </Button>
{/if} {/if}
{/if} {/if}
</div> </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"> <div class="flex gap-2">
{#if isCompleted} {#if isCompleted}
<Button <Button
@@ -584,7 +645,7 @@
Leave a Tip Leave a Tip
</Button> </Button>
{/if} {/if}
{#if depositOutstanding} {#if depositOutstanding && selectedBooking?.status !== 'pending'}
<Button <Button
size="sm" size="sm"
class="flex-1 bg-amber-600 text-white hover:bg-amber-700" class="flex-1 bg-amber-600 text-white hover:bg-amber-700"
@@ -598,11 +659,15 @@
size="sm" size="sm"
class="flex-1 bg-emerald-600 text-white hover:bg-emerald-700" class="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
onclick={() => (showPaymentModal = true)} onclick={() => (showPaymentModal = true)}
disabled={hasPendingEditRequest}
> >
Pay Early Pay Early
</Button> </Button>
{/if} {/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> </div>
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button> <Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
</div> </div>
@@ -626,6 +691,7 @@
onClose={() => (showPaymentModal = false)} onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete} onComplete={handlePaymentComplete}
{canSaveCards} {canSaveCards}
defaultPaymentType={depositOutstanding ? 'deposit' : undefined}
/> />
{/if} {/if}
@@ -634,36 +700,76 @@
<Modal.Header> <Modal.Header>
<Modal.Title>Cancel Booking</Modal.Title> <Modal.Title>Cancel Booking</Modal.Title>
<Modal.Description> <Modal.Description>
{@const hoursUntilAppt = Math.round(hoursUntilAppointment)}
{#if hasPayments} {#if hasPayments}
<p> <p>
Are you sure you want to cancel this booking? You have already made payments totalling Are you sure you want to cancel this booking? You have already made payments totalling
<span class="font-semibold">£{totalPaid.toFixed(2)}</span>. <span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
</p> </p>
{:else}
Are you sure you want to cancel this booking?
{/if}
{#if hasPayments}
<div <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> {#if hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS}
<p class="font-medium text-green-800">Full Refund</p>
<p class="mt-1"> <p class="mt-1">
Any amounts paid (<span class="font-semibold">£{totalPaid.toFixed(2)}</span>) will You have given over {POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. You will receive a
not be refunded, but will be retained as credit towards a future appointment. <strong>full refund</strong> of <strong>£{totalPaid.toFixed(2)}</strong>.
Nothing will be deducted.
</p> </p>
{#if selectedBooking} {:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
{#if selectedBooking.deposit_paid} <p class="font-medium">Partial Refund</p>
<p class="mt-1"> <p class="mt-1">
The deposit of Based on your notice period ({hoursUntilAppt}h), up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of the total
<span class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2)}</span> (£{protectedDeposit.toFixed(2)}) is treated as a protected deposit and will be
paid for this booking may be forfeited. 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> </p>
{/if} {/if}
{#if selectedBooking.deposit_required && !selectedBooking.deposit_paid} <p class="mt-2 text-xs">
<p class="mt-1">Any outstanding deposit will no longer be due.</p> Refunds are processed via Square and may take 35 business days. See our
{/if} <PolicyPopover>
{/if} {#snippet trigger()}
<span class="underline">cancellation policy</span>
{/snippet}
</PolicyPopover>
for full details.
</p>
</div> </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} {/if}
</Modal.Description> </Modal.Description>
</Modal.Header> </Modal.Header>
@@ -694,7 +800,7 @@
</Modal.Header> </Modal.Header>
<div class="space-y-4 px-4 pb-4"> <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)} {#each tipPresets as preset (preset.pct)}
<button <button
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset === class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset ===
@@ -29,6 +29,9 @@
import DatePicker from '$lib/components/booking/DatePicker.svelte'; import DatePicker from '$lib/components/booking/DatePicker.svelte';
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte'; import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte'; import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import CardInput from '$lib/components/payments/CardInput.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import { import {
extractBookedSlots, extractBookedSlots,
@@ -42,7 +45,8 @@
WorkingHoursDay, WorkingHoursDay,
AvailableHoursDay, AvailableHoursDay,
BookingService, BookingService,
BookingStatus BookingStatus,
Payment
} from '$lib/types/booking'; } from '$lib/types/booking';
// =============== State Management =============== // =============== State Management ===============
@@ -155,9 +159,23 @@
status: string; status: string;
start_time: string; start_time: string;
notes: string; notes: string;
deposit_required: boolean;
deposit_paid: boolean;
deposit_amount: number;
amount_paid: number;
amount_due: number;
payments: Payment[];
total_amount: number;
duration_minutes: number;
} | null>(null); } | null>(null);
let showPayEarlyModal = $state(false); let showPayEarlyModal = $state(false);
let discountPreview = $state<{
eligible: boolean;
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
original_total: number;
discounted_total: number;
} | null>(null);
// =============== Payment Functions =============== // =============== Payment Functions ===============
async function fetchUserDepositsRequired() { async function fetchUserDepositsRequired() {
@@ -167,7 +185,7 @@
} }
try { try {
const response = await fetch('/api/user', { const response = await fetch('/api/user/profile', {
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {} headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
}); });
if (response.ok) { if (response.ok) {
@@ -252,39 +270,91 @@
function calculateDepositRequired(): boolean { function calculateDepositRequired(): boolean {
if (!selectedDate || !selectedTime) return false; if (!selectedDate || !selectedTime) return false;
// Deposit required if user has deposits_required > 0 AND appointment is within 24 hours // Deposit required if user has deposits_required > 0, regardless of booking window
const [hours, minutes] = selectedTime.split(':').map(Number); return userDepositsRequired > 0;
const appointmentDate = selectedDate.toDate(getLocalTimeZone());
appointmentDate.setHours(hours, minutes, 0, 0);
const now = new SvelteDate();
const hoursUntilAppointment = (appointmentDate.getTime() - now.getTime()) / (1000 * 60 * 60);
return userDepositsRequired > 0 && hoursUntilAppointment <= 24;
} }
function calculateDepositAmount(): number { function calculateDepositAmount(): number {
return Math.round(getTotalPrice() * 0.2 * 100) / 100; return Math.round(getTotalPrice() * 0.2 * 100) / 100;
} }
async function fetchDiscountPreview() {
if (!confirmedBooking?.id) return;
try {
const resp = await fetch(`/api/bookings/${confirmedBooking.id}/discount-preview`, {
headers: authStore.currentToken
? { Authorization: `Bearer ${authStore.currentToken}` }
: undefined
});
if (resp.ok) {
const data = await resp.json();
// Only show time-based (auto-apply) discounts on the confirmation screen
if (data.eligible && data.discounts?.length > 0) {
discountPreview = data;
}
}
} catch (err) {
console.error('Failed to fetch discount preview:', err);
}
}
async function processPayment(amount: number) { async function processPayment(amount: number) {
isProcessingPayment = true; isProcessingPayment = true;
paymentAttempted = false;
try { try {
// TODO: Integrate Square SDK for actual payment processing await submitAndProceed();
// For now, simulate successful payment after a delay if (!confirmedBooking) {
await new Promise((resolve) => setTimeout(resolve, 1500)); toast.error('Booking was not created. Please try again.');
return;
}
const bookingId = confirmedBooking.id;
const amountCents = Math.round(amount * 100);
toast.success('Payment successful!'); const body: Record<string, unknown> = {
payment_type: 'deposit',
amount: amountCents,
idempotency_key: crypto.randomUUID?.() ?? Date.now().toString()
};
if (selectedPaymentMethod) {
body.card_id = selectedPaymentMethod;
} else {
const rawNumber = newCardNumber.replace(/\s/g, '');
if (rawNumber.length >= 13) {
body.new_card_token = rawNumber;
}
}
paymentAttempted = true;
const response = await fetch(`/api/bookings/${bookingId}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {})
},
body: JSON.stringify(body)
});
if (response.ok) {
depositPaid = true; depositPaid = true;
showPaymentForm = false; confirmedBooking.deposit_paid = true;
nextStep(); confirmedBooking.amount_paid = (confirmedBooking.amount_paid || 0) + amount;
confirmedBooking.amount_due = Math.max(0, (confirmedBooking.amount_due || 0) - amount);
toast.success('Payment successful!');
} else {
const text = await response.text();
toast.warning(text || 'Payment failed — you can pay again from your booking details.');
}
} catch (err) { } catch (err) {
toast.error('Payment failed. Please try again.'); toast.error('An error occurred. Your booking may still be confirmed — check your appointments.');
} finally { } finally {
isProcessingPayment = false; isProcessingPayment = false;
} }
} }
let paymentAttempted = $state(false);
function handlePayNow() { function handlePayNow() {
showPaymentForm = true; showPaymentForm = true;
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
@@ -923,8 +993,13 @@
const minute = minutes % 60; const minute = minutes % 60;
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
const slotDateTime = date.toDate(getLocalTimeZone());
slotDateTime.setHours(hour, minute, 0, 0);
const hoursUntilSlot = (slotDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
const isBlockedByDepositAdvance = userDepositsRequired > 0 && hoursUntilSlot < POLICY.DEPOSIT_ADVANCE_HOURS;
const isAvailable = const isAvailable =
availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked; availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked && !isBlockedByDepositAdvance;
if (isAvailable) { if (isAvailable) {
if (currentUnavailableStart !== null) { if (currentUnavailableStart !== null) {
@@ -1032,7 +1107,18 @@
15, 15,
false false
); );
const validSlots = availableSlots.filter((t) => !lunchProtection.get(t)?.isBlocked); const now = new SvelteDate();
const validSlots = availableSlots.filter((t) => {
if (lunchProtection.get(t)?.isBlocked) return false;
if (userDepositsRequired > 0) {
const [h, m] = t.split(':').map(Number);
const slotDate = date.toDate(getLocalTimeZone());
slotDate.setHours(h, m, 0, 0);
const hoursUntil = (slotDate.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursUntil < POLICY.DEPOSIT_ADVANCE_HOURS) return false;
}
return true;
});
if (validSlots.length === 0) return true; if (validSlots.length === 0) return true;
} }
@@ -1334,9 +1420,18 @@
id: booking.id, id: booking.id,
status: booking.status, status: booking.status,
start_time: booking.start_time, start_time: booking.start_time,
notes: booking.notes || '' notes: booking.notes || '',
deposit_required: booking.deposit_required ?? false,
deposit_paid: booking.deposit_paid ?? false,
deposit_amount: booking.deposit_amount ?? 0,
amount_paid: booking.amount_paid ?? 0,
amount_due: booking.amount_due || getTotalPrice(),
payments: booking.payments ?? [],
total_amount: booking.total_amount || getTotalPrice(),
duration_minutes: booking.duration_minutes || getTotalDuration()
}; };
currentStep = depositRequired ? 5 : 4; currentStep = depositRequired ? 5 : 4;
fetchDiscountPreview();
setTimeout(() => { setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50); }, 50);
@@ -1397,9 +1492,14 @@
} }
// =============== Validation =============== // =============== Validation ===============
const canProceedStep1 = $derived(selectedServices.length > 0); const isBlockedByActiveBooking = $derived(
const canProceedStep2 = $derived(!!(selectedDate && selectedTime)); authStore.isAuthenticated && userDepositsRequired > 0 && hasActiveBooking
);
const canProceedStep1 = $derived(selectedServices.length > 0 && !isBlockedByActiveBooking);
const canProceedStep2 = $derived(!!(selectedDate && selectedTime) && !isBlockedByActiveBooking);
const canProceedStep3 = $derived( const canProceedStep3 = $derived(
!isBlockedByActiveBooking &&
(authStore.isAuthenticated (authStore.isAuthenticated
? !!( ? !!(
authStore.currentUser?.firstName && authStore.currentUser?.firstName &&
@@ -1486,25 +1586,12 @@
</svg> </svg>
</div> </div>
<div class="space-y-1 text-sm text-amber-900"> <div class="space-y-1 text-sm text-amber-900">
<h4 class="font-semibold text-amber-800">Booking Limit While Deposits Are Owed</h4> <p class="font-semibold text-amber-800">One Booking at a Time</p>
{#if userDepositsRequired === 1}
<p> <p>
You have <span class="font-medium">1 deposit remaining</span>. After this We are currently asking for deposits on upcoming bookings. While this is active,
deposit is paid, you'll be able to book in advance again with no further only one online booking can be made at a time. If you need another appointment
deposits required. please <a href="/contact" target="_blank" class="underline font-medium">contact us</a>.
</p> </p>
{:else}
<p>
You currently owe <span class="font-medium"
>{userDepositsRequired} deposits</span
>. You can only have <span class="font-medium">1 upcoming booking</span> at a time
while deposits are outstanding.
</p>
<p>
Once your current booking is complete and paid for, you'll be able to book
again.
</p>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -1779,9 +1866,16 @@
appointment reminders via email and/or SMS. appointment reminders via email and/or SMS.
</p> </p>
<p class="mt-2 text-xs text-gray-500"> <p class="mt-2 text-xs text-gray-500">
<strong>Cancellation Policy:</strong> Free cancellation up to 24 hours before your appointment. <strong>Cancellation Policy:</strong> If paying early a free full refund will be given if cancelled more than 72 hours before your appointment. Between 24-72 hours, up to 50% of the booking total may be retained as a protected deposit. Cancellations within 24 hours are non-refundable and count as a no-show against your account.
Cancellations within 24 hours may incur a deposit penalty.
</p> </p>
<p class="mt-1 text-xs text-gray-500">
<PolicyPopover>
{#snippet trigger()}
<span class="underline">Read full cancellation policy →</span>
{/snippet}
</PolicyPopover>
</p>
</div> </div>
</Card.Content> </Card.Content>
<Card.Footer class="flex justify-between"> <Card.Footer class="flex justify-between">
@@ -1830,25 +1924,6 @@
showCustomer={true} showCustomer={true}
/> />
{#if !showPaymentForm}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-6">
<h3 class="mb-2 text-lg font-semibold text-amber-800">Deposit Required</h3>
<p class="mb-4 text-amber-700">
Due to your booking being within 24 hours, a deposit is required.
</p>
<div class="flex flex-wrap gap-3">
<Button
onclick={() => {
showPaymentForm = true;
}}
class="bg-primary text-primary-foreground"
>
Pay Deposit Now
</Button>
<Button variant="outline" onclick={nextStep}>Pay at Appointment</Button>
</div>
</div>
{:else}
<div class="rounded-lg border border-gray-200 bg-white p-6"> <div class="rounded-lg border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3> <h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
@@ -1910,96 +1985,26 @@
{/if} {/if}
{#if showNewCardForm || !authStore.isAuthenticated} {#if showNewCardForm || !authStore.isAuthenticated}
<div class="mb-6 rounded-lg border border-gray-100 bg-gray-50 p-4"> <CardInput
<h4 class="mb-4 text-sm font-medium text-gray-700">Card Details</h4> bind:cardNumber={newCardNumber}
<div class="space-y-4"> bind:cardExpiry={newCardExpiry}
<div class="space-y-2"> bind:cardCVC={newCardCVC}
<Label for="cardNumber">Card Number</Label> disabled={isProcessingPayment}
<Input
id="cardNumber"
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={(e) =>
(newCardNumber = formatDepositCardNumber(
(e.target as HTMLInputElement).value
))}
placeholder="1234 5678 9012 3456"
maxlength={19}
/> />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="cardExpiry">Expiry (MM/YY)</Label>
<Input
id="cardExpiry"
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={(e) =>
(newCardExpiry = formatDepositExpiry(
(e.target as HTMLInputElement).value
))}
placeholder="MM/YY"
maxlength={5}
/>
</div>
<div class="space-y-2">
<Label for="cardCVC">CVC</Label>
<Input
id="cardCVC"
type="text"
inputmode="numeric"
bind:value={newCardCVC}
placeholder="123"
maxlength={4}
/>
</div>
</div>
{#if authStore.isAuthenticated}
<div class="flex items-center gap-2">
<Checkbox id="saveCard" bind:checked={saveCardForFuture} />
<Label for="saveCard" class="text-sm font-normal">
Save card for next time
</Label>
</div>
{/if}
</div>
</div>
{/if} {/if}
<div class="flex items-center justify-between border-t pt-4"> <div class="flex items-center justify-between border-t pt-4">
<Button <Button variant="ghost" onclick={prevStep}>Back</Button>
variant="ghost"
onclick={() => {
showPaymentForm = false;
selectedPaymentMethod = null;
showNewCardForm = false;
}}
>
Cancel
</Button>
<Button <Button
disabled={isProcessingPayment || !depositCardFormValid} disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())} onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground" class="bg-primary text-primary-foreground"
> >
{isProcessingPayment ? 'Processing...' : `Pay £${calculateDepositAmount()}`} {isProcessingPayment ? 'Processing...' : `Pay Deposit £${calculateDepositAmount()}`}
</Button> </Button>
</div> </div>
</div> </div>
{/if}
</Card.Content> </Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={prevStep}>Back</Button>
<Button
disabled={isSubmitting}
onclick={nextStep}
class="bg-primary text-primary-foreground"
>
{isSubmitting ? 'Processing...' : 'Continue'}
</Button>
</Card.Footer>
</Card.Root> </Card.Root>
{/if} {/if}
@@ -2021,6 +2026,21 @@
})} })}
<Card.Root class="border-emerald-200"> <Card.Root class="border-emerald-200">
{#if isProcessingPayment && paymentAttempted}
<Card.Header class="text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-amber-600"
></div>
</div>
<Card.Title class="text-2xl font-bold">Processing Payment</Card.Title>
<Card.Description class="mt-2 text-base">
Your booking is confirmed. We're processing your payment — this should only take a moment.
</Card.Description>
</Card.Header>
{:else}
<Card.Header class="text-center"> <Card.Header class="text-center">
<div <div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full {isRequested class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full {isRequested
@@ -2049,7 +2069,9 @@
: 'Your appointment has been booked successfully.'} : 'Your appointment has been booked successfully.'}
</Card.Description> </Card.Description>
</Card.Header> </Card.Header>
{/if}
<Card.Content class="space-y-6"> <Card.Content class="space-y-6">
{#if !(isProcessingPayment && paymentAttempted)}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium text-gray-500">Confirmation Number</span> <span class="text-sm font-medium text-gray-500">Confirmation Number</span>
@@ -2095,10 +2117,23 @@
</div> </div>
{/each} {/each}
<div class="border-t pt-2"> <div class="border-t pt-2">
{#if discountPreview?.eligible}
{#each discountPreview.discounts as d}
<div class="flex justify-between text-sm text-gray-600">
<span>{d.name}</span>
<span>{d.amount.toFixed(2)}</span>
</div>
{/each}
<div class="flex justify-between font-semibold text-emerald-700">
<span>Estimated Total After Discount</span>
<span>£{discountPreview.discounted_total.toFixed(2)}</span>
</div>
{:else}
<div class="flex justify-between font-semibold"> <div class="flex justify-between font-semibold">
<span>Total (estimated)</span> <span>Total (estimated)</span>
<span>£{getTotalPrice()}</span> <span>£{getTotalPrice()}</span>
</div> </div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -2114,20 +2149,50 @@
</div> </div>
{/if} {/if}
{#if !calculateDepositRequired() && authStore.isAuthenticated && authStore.currentUser?.role !== 'admin' && authStore.currentUser?.role !== 'guest'} {#if authStore.isAuthenticated && authStore.currentUser?.role !== 'admin' && authStore.currentUser?.role !== 'guest'}
{#if depositPaid}
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-6 text-center">
<div class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
<svg class="h-5 w-5 text-emerald-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-lg font-semibold text-emerald-800">Deposit Paid</h3>
<p class="mt-1 text-emerald-700">
Your deposit of <strong>£{calculateDepositAmount().toFixed(2)}</strong> has been paid
successfully. See you at your appointment!
</p>
</div>
{:else if depositRequired}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
<h3 class="mb-2 text-lg font-semibold text-amber-800">Deposit Not Paid</h3>
<p class="mb-4 text-amber-700">
Your booking is confirmed but the deposit of <strong>£{calculateDepositAmount().toFixed(2)}</strong>
was not paid. If the deposit remains unpaid within 24 hours of your appointment,
the slot may be released and the booking could be cancelled or rebooked by someone else.
</p>
<p class="mb-4 text-xs text-amber-600">
<PolicyPopover>
{#snippet trigger()}
<span class="underline">Read our cancellation policy →</span>
{/snippet}
</PolicyPopover>
</p>
<Button onclick={() => (showPayEarlyModal = true)} class="bg-amber-600 text-white hover:bg-amber-700">
Pay Deposit Now
</Button>
</div>
{:else}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
<h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3> <h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3>
<p class="mb-4 text-gray-600"> <p class="mb-4 text-gray-600">
You can pay when you arrive, or pay ahead of time to speed things up. You can pay when you arrive, or pay ahead of time to speed things up.
</p> </p>
<Button <Button onclick={() => (showPayEarlyModal = true)} class="bg-emerald-600 text-white hover:bg-emerald-700">
onclick={() => (showPayEarlyModal = true)}
class="bg-emerald-600 text-white hover:bg-emerald-700"
>
Pay Early Pay Early
</Button> </Button>
</div> </div>
{/if} {/if}
{/if}
{/if}
</Card.Content> </Card.Content>
<Card.Footer class="flex justify-center"> <Card.Footer class="flex justify-center">
<Button <Button
@@ -2153,27 +2218,27 @@
{/if} {/if}
{#if showPayEarlyModal && confirmedBooking} {#if showPayEarlyModal && confirmedBooking}
{@const booking = confirmedBooking} {@const bk = confirmedBooking}
<UserPaymentModal <UserPaymentModal
booking={{ booking={{
id: booking.id, id: bk.id,
status: booking.status as BookingStatus, status: bk.status as BookingStatus,
start_time: booking.start_time, start_time: bk.start_time,
notes: booking.notes, notes: bk.notes,
services: selectedServices.map((s) => ({ services: selectedServices.map((s) => ({
booking_id: booking.id, booking_id: bk.id,
service_id: s.id, service_id: s.id,
service_name: s.name, service_name: s.name,
price: s.price, price: s.price,
duration_minutes: s.duration_minutes duration_minutes: s.duration_minutes
})) as BookingService[], })) as BookingService[],
total_amount: getTotalPrice(), total_amount: bk.total_amount,
amount_paid: 0, amount_paid: bk.amount_paid,
amount_due: getTotalPrice(), amount_due: bk.amount_due,
deposit_required: false, deposit_required: bk.deposit_required,
deposit_paid: true, deposit_paid: bk.deposit_paid,
payments: [], payments: bk.payments,
duration_minutes: getTotalDuration(), duration_minutes: bk.duration_minutes,
created_at: new SvelteDate().toISOString(), created_at: new SvelteDate().toISOString(),
updated_at: new SvelteDate().toISOString() updated_at: new SvelteDate().toISOString()
}} }}
@@ -2181,8 +2246,7 @@
onComplete={() => { onComplete={() => {
showPayEarlyModal = false; showPayEarlyModal = false;
}} }}
canSaveCards={authStore.currentUser?.role === 'verified_email' || canSaveCards={authStore.isAuthenticated}
authStore.currentUser?.role === 'affiliate'}
/> />
{/if} {/if}
</div> </div>