fix: booking duration calculation, tip page auth, and UI polish across frontend

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-05-25 18:04:43 +01:00
co-authored by Sisyphus
parent 2a8f04e7b3
commit f553ebad99
14 changed files with 468 additions and 225 deletions
@@ -23,6 +23,7 @@
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 UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import {
extractBookedSlots,
getLunchProtectionForSlots,
@@ -33,7 +34,9 @@
Service,
CustomerInfo,
WorkingHoursDay,
AvailableHoursDay
AvailableHoursDay,
BookingService,
BookingStatus
} from '$lib/types/booking';
// =============== State Management ===============
@@ -73,6 +76,11 @@
let depositPaid = $state(false);
let showPaymentForm = $state(false);
let depositCardFormValid = $derived(
selectedPaymentMethod !== null ||
(showNewCardForm && newCardNumber.replace(/\s/g, '').length >= 13 && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3)
);
// Confirmation state
let confirmedBooking = $state<{
id: string;
@@ -81,6 +89,8 @@
notes: string;
} | null>(null);
let showPayEarlyModal = $state(false);
// =============== Payment Functions ===============
async function fetchUserDepositsRequired() {
if (!authStore.isAuthenticated) {
@@ -90,7 +100,6 @@
try {
const response = await fetch('/api/user', {
credentials: 'include',
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (response.ok) {
@@ -114,7 +123,6 @@
try {
// Check for pending bookings
const pendingResp = await fetch('/api/bookings?status=pending&perPage=1', {
credentials: 'include',
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (pendingResp.ok) {
@@ -128,7 +136,6 @@
// Check for confirmed bookings
const confirmedResp = await fetch('/api/bookings?status=confirmed&perPage=1', {
credentials: 'include',
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (confirmedResp.ok) {
@@ -152,13 +159,16 @@
paymentMethodsLoading = true;
try {
const response = await fetch('/api/user/payment-methods', {
credentials: 'include',
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
});
if (response.ok) {
const data = await response.json();
paymentMethods = data.payment_methods ?? [];
if (paymentMethods.length > 0 && !selectedPaymentMethod) {
const defaultCard = paymentMethods.find((m: any) => m.is_default) ?? paymentMethods[0];
selectedPaymentMethod = defaultCard.id;
}
} else {
paymentMethods = [];
}
@@ -222,6 +232,20 @@
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
}
function formatDepositCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function formatDepositExpiry(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
// Fetch user deposit and active booking status when step 1 is reached
$effect(() => {
if (currentStep === 1 && authStore.isAuthenticated) {
@@ -945,6 +969,14 @@
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
);
let depositRequired = $derived(calculateDepositRequired());
let totalSteps = $derived(depositRequired ? 5 : 4);
let stepLabels = $derived(
depositRequired
? ['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
: ['Service', 'Date & Time', 'Details', 'Confirmation']
);
// =============== Navigation ===============
async function nextStep() {
// Step 2 -> Step 3: Re-validate slot, then reserve
@@ -955,7 +987,7 @@
if (!reserved) return;
}
// Step 3 -> Step 4 (if deposit required) or Step 5 (submit booking)
// Step 3 -> Step 4 (if deposit required) or Step 4 (confirmation, if no deposit)
if (currentStep === 3) {
if (calculateDepositRequired()) {
currentStep = 4;
@@ -965,13 +997,14 @@
return;
}
// Step 4 -> Step 5 (submit booking)
if (currentStep === 4) {
// 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();
return;
}
if (currentStep < 5) {
if (currentStep < (depositRequired ? 5 : 4)) {
currentStep++;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -1074,7 +1107,7 @@
start_time: booking.start_time,
notes: booking.notes || ''
};
currentStep = 5;
currentStep = depositRequired ? 5 : 4;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
@@ -1163,7 +1196,7 @@
<StepIndicator
{currentStep}
steps={['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']}
steps={stepLabels}
/>
<!-- Step 1: Service Selection -->
@@ -1459,14 +1492,20 @@
onclick={nextStep}
class="bg-primary text-primary-foreground"
>
{reservationExpired ? 'Reservation Expired' : 'Next: Review & Payment'}
{#if reservationExpired}
Reservation Expired
{:else if depositRequired}
Next: Payment
{:else}
Confirm Booking
{/if}
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 4: Deposit Payment (only shown if deposit required) -->
{#if currentStep === 4}
{#if currentStep === 4 && depositRequired}
<Card.Root>
<Card.Header>
<Card.Title>Pay Your Deposit</Card.Title>
@@ -1579,7 +1618,10 @@
<Label for="cardNumber">Card Number</Label>
<Input
id="cardNumber"
bind:value={newCardNumber}
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={(e) => (newCardNumber = formatDepositCardNumber((e.target as HTMLInputElement).value))}
placeholder="1234 5678 9012 3456"
maxlength={19}
/>
@@ -1589,14 +1631,17 @@
<Label for="cardExpiry">Expiry (MM/YY)</Label>
<Input
id="cardExpiry"
bind:value={newCardExpiry}
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" bind:value={newCardCVC} placeholder="123" maxlength={4} />
<Input id="cardCVC" type="text" inputmode="numeric" bind:value={newCardCVC} placeholder="123" maxlength={4} />
</div>
</div>
{#if authStore.isAuthenticated}
@@ -1623,8 +1668,7 @@
Cancel
</Button>
<Button
disabled={isProcessingPayment ||
(!selectedPaymentMethod && !newCardNumber && !showNewCardForm)}
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
@@ -1641,14 +1685,14 @@
onclick={nextStep}
class="bg-primary text-primary-foreground"
>
{isSubmitting ? 'Processing...' : 'Skip Payment'}
{isSubmitting ? 'Processing...' : 'Continue'}
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 5: Confirmation -->
{#if currentStep === 5}
<!-- Step 5: Confirmation (or Step 4 if no deposit required) -->
{#if currentStep === 5 || (currentStep === 4 && !depositRequired)}
{#if confirmedBooking}
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
@@ -1764,8 +1808,7 @@
You can pay when you arrive, or pay ahead of time to speed things up.
</p>
<Button
onclick={() =>
(window.location.href = `/booking-confirmed/${confirmedBooking!.id}`)}
onclick={() => (showPayEarlyModal = true)}
class="bg-emerald-600 text-white hover:bg-emerald-700"
>
Pay Early
@@ -1775,7 +1818,7 @@
</Card.Content>
<Card.Footer class="flex justify-center">
<Button
onclick={() => (window.location.href = authStore.isAuthenticated ? '/account' : '/')}
onclick={() => (window.location.href = authStore.isAuthenticated ? '/schedule' : '/')}
class="w-full"
>
{authStore.isAuthenticated ? 'View My Bookings' : 'Return Home'}
@@ -1795,4 +1838,37 @@
</Card.Root>
{/if}
{/if}
{#if showPayEarlyModal && confirmedBooking}
{@const booking = confirmedBooking}
<UserPaymentModal
booking={{
id: booking.id,
status: booking.status as BookingStatus,
start_time: booking.start_time,
notes: booking.notes,
services: selectedServices.map((s) => ({
booking_id: booking.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(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}}
onClose={() => (showPayEarlyModal = false)}
onComplete={() => {
showPayEarlyModal = false;
}}
canSaveCards={authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'}
/>
{/if}
</div>