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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user