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:
@@ -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="mt-1">
|
<p class="font-medium text-green-800">Full Refund</p>
|
||||||
Any amounts paid (<span class="font-semibold">£{totalPaid.toFixed(2)}</span>) will
|
<p class="mt-1">
|
||||||
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
|
||||||
</p>
|
<strong>full refund</strong> of <strong>£{totalPaid.toFixed(2)}</strong>.
|
||||||
{#if selectedBooking}
|
Nothing will be deducted.
|
||||||
{#if selectedBooking.deposit_paid}
|
</p>
|
||||||
<p class="mt-1">
|
{:else if hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}
|
||||||
The deposit of
|
<p class="font-medium">Partial Refund</p>
|
||||||
<span class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2)}</span>
|
<p class="mt-1">
|
||||||
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
|
||||||
</p>
|
(£{protectedDeposit.toFixed(2)}) is treated as a protected deposit and will be
|
||||||
{/if}
|
retained. The remaining <strong>£{estimatedRefund.toFixed(2)}</strong> will be refunded.
|
||||||
{#if selectedBooking.deposit_required && !selectedBooking.deposit_paid}
|
</p>
|
||||||
<p class="mt-1">Any outstanding deposit will no longer be due.</p>
|
{:else}
|
||||||
{/if}
|
<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}
|
||||||
|
<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>
|
</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> = {
|
||||||
depositPaid = true;
|
payment_type: 'deposit',
|
||||||
showPaymentForm = false;
|
amount: amountCents,
|
||||||
nextStep();
|
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;
|
||||||
|
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) {
|
} 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 isAvailable =
|
const slotDateTime = date.toDate(getLocalTimeZone());
|
||||||
availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked;
|
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 && !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>
|
We are currently asking for deposits on upcoming bookings. While this is active,
|
||||||
You have <span class="font-medium">1 deposit remaining</span>. After this
|
only one online booking can be made at a time. If you need another appointment
|
||||||
deposit is paid, you'll be able to book in advance again with no further
|
please <a href="/contact" target="_blank" class="underline font-medium">contact us</a>.
|
||||||
deposits required.
|
</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,176 +1924,87 @@
|
|||||||
showCustomer={true}
|
showCustomer={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if !showPaymentForm}
|
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-6">
|
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if authStore.isAuthenticated}
|
{#if authStore.isAuthenticated}
|
||||||
{#if paymentMethodsLoading}
|
{#if paymentMethodsLoading}
|
||||||
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
|
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||||
{:else if paymentMethods.length > 0}
|
{:else if paymentMethods.length > 0}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
|
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each paymentMethods as method (method.id)}
|
{#each paymentMethods as method (method.id)}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
||||||
method.id
|
method.id
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
|
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
|
||||||
>
|
|
||||||
{method.brand}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
<span class="font-mono">**** {method.last4}</span>
|
|
||||||
<span class="ml-2 text-gray-500">
|
|
||||||
{formatCardExpiry(method.expiry_month, method.expiry_year)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
|
|
||||||
onclick={() => {
|
|
||||||
selectedPaymentMethod = method.id;
|
|
||||||
showNewCardForm = false;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
|
{method.brand}
|
||||||
</Button>
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-mono">**** {method.last4}</span>
|
||||||
|
<span class="ml-2 text-gray-500">
|
||||||
|
{formatCardExpiry(method.expiry_month, method.expiry_year)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<Button
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
|
||||||
{/if}
|
onclick={() => {
|
||||||
|
selectedPaymentMethod = method.id;
|
||||||
{#if !showNewCardForm}
|
showNewCardForm = false;
|
||||||
<Button
|
}}
|
||||||
variant="outline"
|
>
|
||||||
class="mb-6"
|
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
|
||||||
onclick={() => {
|
</Button>
|
||||||
showNewCardForm = true;
|
|
||||||
selectedPaymentMethod = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ Add new card
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/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}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
<div class="space-y-2">
|
{/each}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center justify-between border-t pt-4">
|
{#if !showNewCardForm}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
|
class="mb-6"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
showPaymentForm = false;
|
showNewCardForm = true;
|
||||||
selectedPaymentMethod = null;
|
selectedPaymentMethod = null;
|
||||||
showNewCardForm = false;
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
+ Add new card
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{/if}
|
||||||
disabled={isProcessingPayment || !depositCardFormValid}
|
{/if}
|
||||||
onclick={() => processPayment(calculateDepositAmount())}
|
|
||||||
class="bg-primary text-primary-foreground"
|
{#if showNewCardForm || !authStore.isAuthenticated}
|
||||||
>
|
<CardInput
|
||||||
{isProcessingPayment ? 'Processing...' : `Pay £${calculateDepositAmount()}`}
|
bind:cardNumber={newCardNumber}
|
||||||
</Button>
|
bind:cardExpiry={newCardExpiry}
|
||||||
</div>
|
bind:cardCVC={newCardCVC}
|
||||||
|
disabled={isProcessingPayment}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t pt-4">
|
||||||
|
<Button variant="ghost" onclick={prevStep}>Back</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isProcessingPayment || !depositCardFormValid}
|
||||||
|
onclick={() => processPayment(calculateDepositAmount())}
|
||||||
|
class="bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
{isProcessingPayment ? 'Processing...' : `Pay Deposit £${calculateDepositAmount()}`}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</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,35 +2026,52 @@
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<Card.Root class="border-emerald-200">
|
<Card.Root class="border-emerald-200">
|
||||||
<Card.Header class="text-center">
|
{#if isProcessingPayment && paymentAttempted}
|
||||||
<div
|
<Card.Header class="text-center">
|
||||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full {isRequested
|
<div
|
||||||
? 'bg-amber-100'
|
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100"
|
||||||
: 'bg-emerald-100'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-8 w-8 {isRequested ? 'text-amber-600' : 'text-emerald-600'}"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
>
|
||||||
<path
|
<div
|
||||||
fill-rule="evenodd"
|
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-amber-600"
|
||||||
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"
|
></div>
|
||||||
clip-rule="evenodd"
|
</div>
|
||||||
/>
|
<Card.Title class="text-2xl font-bold">Processing Payment</Card.Title>
|
||||||
</svg>
|
<Card.Description class="mt-2 text-base">
|
||||||
</div>
|
Your booking is confirmed. We're processing your payment — this should only take a moment.
|
||||||
<Card.Title class="text-2xl font-bold"
|
</Card.Description>
|
||||||
>{isRequested ? 'Booking Requested' : 'Booking Confirmed'}</Card.Title
|
</Card.Header>
|
||||||
>
|
{:else}
|
||||||
<Card.Description class="mt-2 text-base">
|
<Card.Header class="text-center">
|
||||||
{isRequested
|
<div
|
||||||
? "Your booking has been submitted and is awaiting approval. We'll notify you once it's confirmed."
|
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full {isRequested
|
||||||
: 'Your appointment has been booked successfully.'}
|
? 'bg-amber-100'
|
||||||
</Card.Description>
|
: 'bg-emerald-100'}"
|
||||||
</Card.Header>
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 {isRequested ? 'text-amber-600' : '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>
|
||||||
|
<Card.Title class="text-2xl font-bold"
|
||||||
|
>{isRequested ? 'Booking Requested' : 'Booking Confirmed'}</Card.Title
|
||||||
|
>
|
||||||
|
<Card.Description class="mt-2 text-base">
|
||||||
|
{isRequested
|
||||||
|
? "Your booking has been submitted and is awaiting approval. We'll notify you once it's confirmed."
|
||||||
|
: 'Your appointment has been booked successfully.'}
|
||||||
|
</Card.Description>
|
||||||
|
</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">
|
||||||
<div class="flex justify-between font-semibold">
|
{#if discountPreview?.eligible}
|
||||||
<span>Total (estimated)</span>
|
{#each discountPreview.discounts as d}
|
||||||
<span>£{getTotalPrice()}</span>
|
<div class="flex justify-between text-sm text-gray-600">
|
||||||
</div>
|
<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>
|
</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'}
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
|
{#if depositPaid}
|
||||||
<h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3>
|
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-6 text-center">
|
||||||
<p class="mb-4 text-gray-600">
|
<div class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
|
||||||
You can pay when you arrive, or pay ahead of time to speed things up.
|
<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>
|
||||||
</p>
|
</div>
|
||||||
<Button
|
<h3 class="text-lg font-semibold text-emerald-800">Deposit Paid</h3>
|
||||||
onclick={() => (showPayEarlyModal = true)}
|
<p class="mt-1 text-emerald-700">
|
||||||
class="bg-emerald-600 text-white hover:bg-emerald-700"
|
Your deposit of <strong>£{calculateDepositAmount().toFixed(2)}</strong> has been paid
|
||||||
>
|
successfully. See you at your appointment!
|
||||||
Pay Early
|
</p>
|
||||||
</Button>
|
</div>
|
||||||
</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">
|
||||||
|
Pay Early
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user