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 Label from '$lib/components/ui/label';
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 {
extractBookedSlots,
getLunchProtectionForSlots,
@@ -77,6 +78,28 @@
);
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(
booking.services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
@@ -110,7 +133,7 @@
let modalTitle = $derived(() => {
switch (editMode) {
case 'select':
return 'Edit Request';
return 'Edit/Reschedule';
case 'time':
return 'Change Time';
case 'services':
@@ -379,9 +402,10 @@
}
});
let servicesHoursFetched = $state(false);
$effect(() => {
if (editMode === 'services') {
// Fetch hours for the booking's current date to calculate remaining time
if (editMode === 'services' && !servicesHoursFetched) {
servicesHoursFetched = true;
const bookingDate = new SvelteDate(booking.start_time);
const calDate = new CalendarDate(
bookingDate.getFullYear(),
@@ -390,6 +414,9 @@
);
fetchHoursForMonth(calDate);
}
if (editMode !== 'services') {
servicesHoursFetched = false;
}
});
// ─── Auto-select ────────────────────────────────────────
@@ -681,12 +708,16 @@
});
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;
onSubmitted();
} else {
const text = await response.text();
toast.error(text || 'Failed to submit edit request');
toast.error(text || 'Failed to submit edit/reschedule request');
}
} catch {
toast.error('Network error');
@@ -734,10 +765,54 @@
<div class="flex flex-col gap-3">
<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
type="button"
disabled={noticePeriodBlocked}
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
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
type="button"
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"
title={hasOverrides
? 'This booking has custom pricing. To change services, please contact the salon.'
@@ -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>
{#if hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS}
<p class="font-medium text-green-800">Full Refund</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.
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>
{#if selectedBooking}
{#if selectedBooking.deposit_paid}
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
<p class="font-medium">Partial Refund</p>
<p class="mt-1">
The deposit of
<span class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2)}</span>
paid for this booking may be forfeited.
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}
{#if selectedBooking.deposit_required && !selectedBooking.deposit_paid}
<p class="mt-1">Any outstanding deposit will no longer be due.</p>
{/if}
{/if}
<p class="mt-2 text-xs">
Refunds are processed via Square and may take 35 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 ===
@@ -29,6 +29,9 @@
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.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 {
extractBookedSlots,
@@ -42,7 +45,8 @@
WorkingHoursDay,
AvailableHoursDay,
BookingService,
BookingStatus
BookingStatus,
Payment
} from '$lib/types/booking';
// =============== State Management ===============
@@ -155,9 +159,23 @@
status: string;
start_time: 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);
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 ===============
async function fetchUserDepositsRequired() {
@@ -167,7 +185,7 @@
}
try {
const response = await fetch('/api/user', {
const response = await fetch('/api/user/profile', {
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (response.ok) {
@@ -252,39 +270,91 @@
function calculateDepositRequired(): boolean {
if (!selectedDate || !selectedTime) return false;
// Deposit required if user has deposits_required > 0 AND appointment is within 24 hours
const [hours, minutes] = selectedTime.split(':').map(Number);
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;
// Deposit required if user has deposits_required > 0, regardless of booking window
return userDepositsRequired > 0;
}
function calculateDepositAmount(): number {
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) {
isProcessingPayment = true;
paymentAttempted = false;
try {
// TODO: Integrate Square SDK for actual payment processing
// For now, simulate successful payment after a delay
await new Promise((resolve) => setTimeout(resolve, 1500));
await submitAndProceed();
if (!confirmedBooking) {
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;
showPaymentForm = false;
nextStep();
confirmedBooking.deposit_paid = true;
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) {
toast.error('Payment failed. Please try again.');
toast.error('An error occurred. Your booking may still be confirmed — check your appointments.');
} finally {
isProcessingPayment = false;
}
}
let paymentAttempted = $state(false);
function handlePayNow() {
showPaymentForm = true;
if (authStore.isAuthenticated) {
@@ -923,8 +993,13 @@
const minute = minutes % 60;
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 =
availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked;
availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked && !isBlockedByDepositAdvance;
if (isAvailable) {
if (currentUnavailableStart !== null) {
@@ -1032,7 +1107,18 @@
15,
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;
}
@@ -1334,9 +1420,18 @@
id: booking.id,
status: booking.status,
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;
fetchDiscountPreview();
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
@@ -1397,9 +1492,14 @@
}
// =============== Validation ===============
const canProceedStep1 = $derived(selectedServices.length > 0);
const canProceedStep2 = $derived(!!(selectedDate && selectedTime));
const isBlockedByActiveBooking = $derived(
authStore.isAuthenticated && userDepositsRequired > 0 && hasActiveBooking
);
const canProceedStep1 = $derived(selectedServices.length > 0 && !isBlockedByActiveBooking);
const canProceedStep2 = $derived(!!(selectedDate && selectedTime) && !isBlockedByActiveBooking);
const canProceedStep3 = $derived(
!isBlockedByActiveBooking &&
(authStore.isAuthenticated
? !!(
authStore.currentUser?.firstName &&
@@ -1486,25 +1586,12 @@
</svg>
</div>
<div class="space-y-1 text-sm text-amber-900">
<h4 class="font-semibold text-amber-800">Booking Limit While Deposits Are Owed</h4>
{#if userDepositsRequired === 1}
<p class="font-semibold text-amber-800">One Booking at a Time</p>
<p>
You have <span class="font-medium">1 deposit remaining</span>. After this
deposit is paid, you'll be able to book in advance again with no further
deposits required.
We are currently asking for deposits on upcoming bookings. While this is active,
only one online booking can be made at a time. If you need another appointment
please <a href="/contact" target="_blank" class="underline font-medium">contact us</a>.
</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>
@@ -1779,9 +1866,16 @@
appointment reminders via email and/or SMS.
</p>
<p class="mt-2 text-xs text-gray-500">
<strong>Cancellation Policy:</strong> Free cancellation up to 24 hours before your appointment.
Cancellations within 24 hours may incur a deposit penalty.
<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.
</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>
</Card.Content>
<Card.Footer class="flex justify-between">
@@ -1830,25 +1924,6 @@
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">
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
@@ -1910,96 +1985,26 @@
{/if}
{#if showNewCardForm || !authStore.isAuthenticated}
<div class="mb-6 rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-4 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-4">
<div class="space-y-2">
<Label for="cardNumber">Card Number</Label>
<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}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
disabled={isProcessingPayment}
/>
</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}
<div class="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
onclick={() => {
showPaymentForm = false;
selectedPaymentMethod = null;
showNewCardForm = false;
}}
>
Cancel
</Button>
<Button variant="ghost" onclick={prevStep}>Back</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment ? 'Processing...' : `Pay £${calculateDepositAmount()}`}
{isProcessingPayment ? 'Processing...' : `Pay Deposit £${calculateDepositAmount()}`}
</Button>
</div>
</div>
{/if}
</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>
{/if}
@@ -2021,6 +2026,21 @@
})}
<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">
<div
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.'}
</Card.Description>
</Card.Header>
{/if}
<Card.Content class="space-y-6">
{#if !(isProcessingPayment && paymentAttempted)}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium text-gray-500">Confirmation Number</span>
@@ -2095,10 +2117,23 @@
</div>
{/each}
<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">
<span>Total (estimated)</span>
<span>£{getTotalPrice()}</span>
</div>
{/if}
</div>
</div>
</div>
@@ -2114,20 +2149,50 @@
</div>
{/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">
<h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3>
<p class="mb-4 text-gray-600">
You can pay when you arrive, or pay ahead of time to speed things up.
</p>
<Button
onclick={() => (showPayEarlyModal = true)}
class="bg-emerald-600 text-white hover:bg-emerald-700"
>
<Button onclick={() => (showPayEarlyModal = true)} class="bg-emerald-600 text-white hover:bg-emerald-700">
Pay Early
</Button>
</div>
{/if}
{/if}
{/if}
</Card.Content>
<Card.Footer class="flex justify-center">
<Button
@@ -2153,27 +2218,27 @@
{/if}
{#if showPayEarlyModal && confirmedBooking}
{@const booking = confirmedBooking}
{@const bk = confirmedBooking}
<UserPaymentModal
booking={{
id: booking.id,
status: booking.status as BookingStatus,
start_time: booking.start_time,
notes: booking.notes,
id: bk.id,
status: bk.status as BookingStatus,
start_time: bk.start_time,
notes: bk.notes,
services: selectedServices.map((s) => ({
booking_id: booking.id,
booking_id: bk.id,
service_id: s.id,
service_name: s.name,
price: s.price,
duration_minutes: s.duration_minutes
})) as BookingService[],
total_amount: getTotalPrice(),
amount_paid: 0,
amount_due: getTotalPrice(),
deposit_required: false,
deposit_paid: true,
payments: [],
duration_minutes: getTotalDuration(),
total_amount: bk.total_amount,
amount_paid: bk.amount_paid,
amount_due: bk.amount_due,
deposit_required: bk.deposit_required,
deposit_paid: bk.deposit_paid,
payments: bk.payments,
duration_minutes: bk.duration_minutes,
created_at: new SvelteDate().toISOString(),
updated_at: new SvelteDate().toISOString()
}}
@@ -2181,8 +2246,7 @@
onComplete={() => {
showPayEarlyModal = false;
}}
canSaveCards={authStore.currentUser?.role === 'verified_email' ||
authStore.currentUser?.role === 'affiliate'}
canSaveCards={authStore.isAuthenticated}
/>
{/if}
</div>