refactor(frontend): timezone-safe date handling with London-aware utilities
Introduce getLondonTodayCalendarDate(), parseWallClockDate(), and formatLocalDateTime() for reliable Europe/London timezone handling. Replace ad-hoc SvelteDate/new Date() usage with these utilities across all components and stores. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -13,8 +13,12 @@
|
||||
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
|
||||
// salon app. All customers are physically in the UK and book UK appointment slots. We do NOT
|
||||
// auto-adjust for international timezones — the slot time shown is the actual UK salon time.
|
||||
// Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled manually by
|
||||
// staff adjusting working hours; the app does not need timezone-aware scheduling logic.
|
||||
// Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled automatically:
|
||||
// formatLocalDateTime converts wall-clock time to UTC using the correct DST offset for the
|
||||
// target date (via @internationalized/date's CalendarDate.toDate which applies the target
|
||||
// date's timezone rules, not the current date's). The backend stores all timestamps as
|
||||
// TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at
|
||||
// "10am June 15" stays at 10am BST regardless of when the booking was made.
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
@@ -40,6 +44,7 @@
|
||||
getLunchProtectionForSlots,
|
||||
type TimeSlot
|
||||
} from '$lib/lunchProtection';
|
||||
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
|
||||
|
||||
import type {
|
||||
Service,
|
||||
@@ -52,7 +57,18 @@
|
||||
} from '$lib/types/booking';
|
||||
|
||||
// =============== State Management ===============
|
||||
let currentStep = $state<number>(authStore.isAuthenticated ? 1 : 0);
|
||||
let currentStep = $state<number>(0);
|
||||
let authReady = $state(false);
|
||||
|
||||
// Wait for auth store to finish initializing before deciding which step to show.
|
||||
// This prevents a flash of the login prompt on SSR + hydration — the skeleton
|
||||
// displays while auth checks are pending, then the correct screen appears.
|
||||
$effect(() => {
|
||||
if (authStore.hasLoaded && !authReady) {
|
||||
authReady = true;
|
||||
currentStep = authStore.isAuthenticated ? 1 : 0;
|
||||
}
|
||||
});
|
||||
let selectedServices = $state<Service[]>([]);
|
||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||
let selectedTime = $state<string | null>(null);
|
||||
@@ -198,7 +214,6 @@
|
||||
userDepositsRequired = user.deposits_required ?? 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user deposits status:', err);
|
||||
userDepositsRequired = 0;
|
||||
}
|
||||
}
|
||||
@@ -234,7 +249,6 @@
|
||||
hasActiveBooking = data.bookings && data.bookings.length > 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check active booking status:', err);
|
||||
hasActiveBooking = false;
|
||||
} finally {
|
||||
activeBookingCheckDone = true;
|
||||
@@ -265,7 +279,6 @@
|
||||
paymentMethods = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch payment methods:', err);
|
||||
paymentMethods = [];
|
||||
} finally {
|
||||
paymentMethodsLoading = false;
|
||||
@@ -417,7 +430,7 @@
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
const bookingDate = selectedDate.toDate(getLocalTimeZone());
|
||||
bookingDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = bookingDate.toISOString();
|
||||
const startTimeISO = formatLocalDateTime(bookingDate);
|
||||
const serviceIds = selectedServices.map((s) => s.id);
|
||||
|
||||
const response = await fetch('/api/bookings/reserve', {
|
||||
@@ -533,11 +546,9 @@
|
||||
// Combine: valid first, then grayed out
|
||||
services = [...valid, ...grayedOut];
|
||||
} else {
|
||||
console.error('Failed to fetch services:', response.status);
|
||||
toast.error('Failed to load services');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching services:', err);
|
||||
toast.error('Network error loading services');
|
||||
} finally {
|
||||
servicesLoading = false;
|
||||
@@ -569,14 +580,10 @@
|
||||
> = {};
|
||||
|
||||
// Initialize date boundaries
|
||||
const today = new SvelteDate();
|
||||
const tomorrow = new SvelteDate(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const maxDate = new SvelteDate();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
|
||||
// Create CalendarDate objects
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const minDate = today;
|
||||
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
|
||||
maxDate.setMonth(today.month - 1 + 6);
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
maxDate.getMonth() + 1,
|
||||
@@ -609,14 +616,8 @@
|
||||
}
|
||||
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
||||
if (!(key in workingHoursCache)) {
|
||||
workingHoursCache[key] = null as unknown as Record<
|
||||
string,
|
||||
{ isOpen: boolean; startTime: string; endTime: string }
|
||||
>;
|
||||
availableHoursCache[key] = null as unknown as Record<
|
||||
string,
|
||||
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||
>;
|
||||
workingHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; startTime: string; endTime: string }>;
|
||||
availableHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>;
|
||||
loadingMonths[key] = true;
|
||||
}
|
||||
}
|
||||
@@ -646,7 +647,7 @@
|
||||
) {
|
||||
bookingFlowAutoSelectDone = true;
|
||||
|
||||
const currentDate = new SvelteDate();
|
||||
const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new SvelteDate(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
@@ -661,7 +662,7 @@
|
||||
for (let i = 1; i <= daysToCheck; i++) {
|
||||
const nextDate = new SvelteDate(currentDate);
|
||||
nextDate.setDate(currentDate.getDate() + i);
|
||||
const dateStr = nextDate.toISOString().split('T')[0];
|
||||
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
|
||||
if (workingHours[dateStr]?.isOpen) {
|
||||
const calDate = new CalendarDate(
|
||||
@@ -679,15 +680,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
const tomorrow = new SvelteDate();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
selectedDate = new CalendarDate(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth() + 1,
|
||||
tomorrow.getDate()
|
||||
const tomorrowCal = getLondonTodayCalendarDate();
|
||||
const tomorrowDate = new CalendarDate(
|
||||
tomorrowCal.year,
|
||||
tomorrowCal.month,
|
||||
tomorrowCal.day + 1
|
||||
);
|
||||
selectedDate = tomorrowDate;
|
||||
if (!userNavigatedCalendar) {
|
||||
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
||||
placeholder = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -752,7 +753,6 @@
|
||||
workingHours = { ...workingHours, ...whMap };
|
||||
availableHours = { ...availableHours, ...ahMap };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hours:', error);
|
||||
// Clean up loadingMonths for the range
|
||||
for (let i = 0; i < months; i++) {
|
||||
let mYear = startDate.year;
|
||||
@@ -853,7 +853,6 @@
|
||||
availableHoursCache[monthKey] = availableHoursMap;
|
||||
availableHours = { ...availableHours, ...availableHoursMap };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hours:', error);
|
||||
if (!selectedDate) {
|
||||
selectedDate = minDate;
|
||||
}
|
||||
@@ -911,8 +910,10 @@
|
||||
}
|
||||
|
||||
const slots: string[] = [];
|
||||
const now = new SvelteDate();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const now = new Date();
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
||||
const isToday = date.compare(today) === 0;
|
||||
|
||||
for (const slot of dayAvailableHours.slots) {
|
||||
@@ -923,7 +924,7 @@
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const currentMinutes = londonHours * 60 + londonMinutes;
|
||||
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
@@ -979,12 +980,14 @@
|
||||
let startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
const now = new SvelteDate();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(today) === 0;
|
||||
const todayCal = getLondonTodayCalendarDate();
|
||||
const now = new Date();
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
||||
const isToday = date.compare(todayCal) === 0;
|
||||
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const currentMinutes = londonHours * 60 + londonMinutes;
|
||||
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
@@ -1281,13 +1284,17 @@
|
||||
);
|
||||
|
||||
let depositRequired = $derived(calculateDepositRequired());
|
||||
let totalSteps = $derived(depositRequired ? 5 : 4);
|
||||
let totalSteps = $derived(authStore.isAuthenticated ? 4 : 5);
|
||||
// StepIndicator uses displayNumber = startAt + index. currentStep aligns with displayNumber,
|
||||
// not the array index. For auth: startAt=1, totalSteps=4 → last displayNumber=4.
|
||||
// For guest: startAt=0, totalSteps=5 → last displayNumber=4. Always evaluates to 4.
|
||||
let finalStep = $derived(totalSteps - 1 + (authStore.isAuthenticated ? 1 : 0));
|
||||
let stepLabels = $derived(
|
||||
authStore.isAuthenticated
|
||||
? depositRequired
|
||||
? ['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
|
||||
: ['Service', 'Date & Time', 'Details', 'Confirmation']
|
||||
: ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
|
||||
? ['Service', 'Date & Time', 'Details', 'Payment']
|
||||
: userDepositsRequired > 0
|
||||
? ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment']
|
||||
: ['Welcome', 'Service', 'Date & Time', 'Details', 'Confirmation']
|
||||
);
|
||||
|
||||
// =============== Navigation ===============
|
||||
@@ -1308,24 +1315,24 @@
|
||||
if (!reserved) return;
|
||||
}
|
||||
|
||||
// Step 3 -> Step 4 (if deposit required) or Step 4 (confirmation, if no deposit)
|
||||
// Step 3 -> Final step (Payment if deposit required, else submit booking)
|
||||
if (currentStep === 3) {
|
||||
if (calculateDepositRequired()) {
|
||||
currentStep = 4;
|
||||
currentStep = finalStep;
|
||||
} else {
|
||||
await submitAndProceed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: if deposit required, this is payment step -> submit booking -> step 5
|
||||
// Step 4: if no deposit, this is confirmation step -> nothing
|
||||
if (currentStep === 4 && calculateDepositRequired()) {
|
||||
await submitAndProceed();
|
||||
// Final step with deposit: user must pay before booking is created.
|
||||
// Payment is handled by processPayment(), not nextStep().
|
||||
// This guards against manual increment from the payment step.
|
||||
if (currentStep === finalStep && calculateDepositRequired()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep < (depositRequired ? 5 : 4)) {
|
||||
if (currentStep < finalStep) {
|
||||
currentStep++;
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@@ -1364,7 +1371,7 @@
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
const bookingDate = selectedDate.toDate(getLocalTimeZone());
|
||||
bookingDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = bookingDate.toISOString();
|
||||
const startTimeISO = formatLocalDateTime(bookingDate);
|
||||
|
||||
const serviceIds = selectedServices.map((s) => s.id);
|
||||
|
||||
@@ -1436,7 +1443,7 @@
|
||||
total_amount: booking.total_amount || getTotalPrice(),
|
||||
duration_minutes: booking.duration_minutes || getTotalDuration()
|
||||
};
|
||||
currentStep = depositRequired ? 5 : 4;
|
||||
currentStep = finalStep;
|
||||
fetchDiscountPreview();
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@@ -1478,10 +1485,8 @@
|
||||
} else {
|
||||
toast.error('Failed to submit booking: ' + errorMessage);
|
||||
}
|
||||
console.error('Booking submission failed:', response.status, errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Booking submission error:', error);
|
||||
toast.error('Network error. Please check your connection and try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
@@ -1532,6 +1537,34 @@
|
||||
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
||||
</div>
|
||||
|
||||
{#if !authReady}
|
||||
|
||||
<div class="mb-6 flex items-center justify-center gap-2">
|
||||
{#each [1, 2, 3, 4] as _}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
{#if _ < 4}
|
||||
<div class="mx-1 h-0.5 w-8 animate-pulse rounded bg-gray-200" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="h-7 w-40 animate-pulse rounded bg-gray-200" />
|
||||
<div class="mt-2 h-4 w-64 animate-pulse rounded bg-gray-200" />
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
|
||||
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
|
||||
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{:else}
|
||||
|
||||
<StepIndicator
|
||||
{currentStep}
|
||||
steps={stepLabels}
|
||||
@@ -1752,6 +1785,12 @@
|
||||
|
||||
<!-- Step 3: Customer Details -->
|
||||
{#if currentStep === 3}
|
||||
<div class="mb-6 text-center">
|
||||
<h2 class="font-['Playfair_Display'] text-2xl font-bold">Almost There</h2>
|
||||
{#if !authStore.isAuthenticated}
|
||||
<p class="mt-1 text-gray-500">Just a couple more details</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Your Details</Card.Title>
|
||||
@@ -1765,7 +1804,7 @@
|
||||
{:else}
|
||||
<div class="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<p class="text-blue-700">
|
||||
Your slot is reserved for {reservationCountdown} — complete your booking before time expires
|
||||
Your slot will be held for {reservationCountdown} — complete your booking before time expires
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1903,119 +1942,8 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 4: Deposit Payment (only shown if deposit required) -->
|
||||
{#if currentStep === 4 && depositRequired}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Pay Your Deposit</Card.Title>
|
||||
<Card.Description>
|
||||
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
|
||||
your appointment.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<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 paymentMethodsLoading}
|
||||
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||
{:else if paymentMethods.length > 0}
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
|
||||
<div class="space-y-3">
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
||||
method.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
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'}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showNewCardForm}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mb-6"
|
||||
onclick={() => {
|
||||
showNewCardForm = true;
|
||||
selectedPaymentMethod = null;
|
||||
}}
|
||||
>
|
||||
+ Add new card
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showNewCardForm || !authStore.isAuthenticated}
|
||||
<CardInput
|
||||
bind:cardNumber={newCardNumber}
|
||||
bind:cardExpiry={newCardExpiry}
|
||||
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>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 5: Confirmation (or Step 4 if no deposit required) -->
|
||||
{#if currentStep === 5 || (currentStep === 4 && !depositRequired)}
|
||||
<!-- Step 4: Payment & Confirmation (final step) -->
|
||||
{#if currentStep === finalStep}
|
||||
{#if confirmedBooking}
|
||||
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
|
||||
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
|
||||
@@ -2209,14 +2137,111 @@
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
{:else if depositRequired}
|
||||
<Card.Root>
|
||||
<Card.Content class="flex items-center justify-center p-12">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
<p class="text-gray-600">Confirming your booking...</p>
|
||||
<Card.Header>
|
||||
<Card.Title>Pay Your Deposit</Card.Title>
|
||||
<Card.Description>
|
||||
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
|
||||
your appointment.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<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 paymentMethodsLoading}
|
||||
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||
{:else if paymentMethods.length > 0}
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
|
||||
<div class="space-y-3">
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
||||
method.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
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'}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showNewCardForm}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mb-6"
|
||||
onclick={() => {
|
||||
showNewCardForm = true;
|
||||
selectedPaymentMethod = null;
|
||||
}}
|
||||
>
|
||||
+ Add new card
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showNewCardForm || !authStore.isAuthenticated}
|
||||
<CardInput
|
||||
bind:cardNumber={newCardNumber}
|
||||
bind:cardExpiry={newCardExpiry}
|
||||
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>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -2255,4 +2280,5 @@
|
||||
canSaveCards={authStore.isAuthenticated}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user