From f553ebad9991318687008d45489e9b92fdc3a2ed Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 25 May 2026 18:04:43 +0100 Subject: [PATCH] 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 --- backend/handlers/bookings/bookings.go | 6 +- backend/testutils/jwt/jwt.go | 2 +- .../account/UserBookingModal.svelte | 220 +++++++++++++--- .../lib/components/admin/BookingModal.svelte | 26 +- .../lib/components/admin/HolidayHours.svelte | 7 +- .../components/admin/WeeklySchedule.svelte | 7 +- .../components/booking/BookingActions.svelte | 6 +- .../lib/components/booking/BookingFlow.svelte | 124 +++++++-- .../today/CurrentAppointment.svelte | 4 +- .../booking-confirmed/[id]/+page.svelte | 33 ++- frontend/src/routes/demo/+page.svelte | 7 +- .../src/routes/notifications/+page.svelte | 5 +- frontend/src/routes/pay-tip/[id]/+page.svelte | 240 +++++++++--------- local-dev-2.sh | 6 +- 14 files changed, 468 insertions(+), 225 deletions(-) diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 2ff2eea..e7b7b79 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -2323,8 +2323,10 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { if dav.Service != nil { var durationMinutes int db.DB.QueryRow(r.Context(), ` - SELECT COALESCE(SUM(override_duration_minutes), (SELECT SUM(duration_minutes) FROM booking_services WHERE booking_id = $1)) - FROM booking_services WHERE booking_id = $1 + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 `, bookingID).Scan(&durationMinutes) if durationMinutes == 0 { durationMinutes = 60 diff --git a/backend/testutils/jwt/jwt.go b/backend/testutils/jwt/jwt.go index 168ed67..dbd1a26 100644 --- a/backend/testutils/jwt/jwt.go +++ b/backend/testutils/jwt/jwt.go @@ -45,7 +45,7 @@ func GenerateTestToken(userID, role string) string { } func GenerateAdminToken() string { - return GenerateTestToken("admin-test-001", "admin") + return GenerateTestToken("admintest001", "admin") } func GenerateVerifiedUserToken(userID string) string { diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 036da51..0cbdc50 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -5,10 +5,11 @@ import { toast } from 'svelte-sonner'; import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; import * as Textarea from '$lib/components/ui/textarea'; import * as Label from '$lib/components/ui/label'; import DatePicker from '$lib/components/booking/DatePicker.svelte'; - import PaymentModal from '$lib/components/payments/PaymentModal.svelte'; + import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking'; import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection'; @@ -42,8 +43,10 @@ const maxCalendarDate = new CalendarDate(maxDate.getFullYear(), maxDate.getMonth() + 1, maxDate.getDate()); let reschedulePlaceholder = $state(minDate); + // IMPORTANT: Use override_duration_minutes when present — services may have been + // customised at booking time. Showing base values misleads users about what was booked. let totalDuration = $derived( - selectedBooking?.services?.reduce((sum, service) => sum + (service.duration_minutes || 0), 0) || 0 + selectedBooking?.services?.reduce((sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0), 0) || 0 ); const rescheduleLunchProtection = $derived(() => { @@ -106,8 +109,85 @@ ['confirmed', 'pending'].includes(selectedBooking.status) ); + let isCompleted = $derived(selectedBooking?.status === 'completed'); + let showPaymentModal = $state(false); + let showTipModal = $state(false); + let tipAmount = $state(0); + let selectedTipPreset = $state(null); + let customTipInput = $state(''); + let tipProcessing = $state(false); + + let canSaveCards = $derived( + authStore.currentUser?.role === 'verified_email' || + authStore.currentUser?.role === 'affiliate' + ); + + let tipPresets = $derived(selectedBooking ? [ + { pct: 10, amount: Math.round(selectedBooking.total_amount * 0.10 * 100) / 100 }, + { pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 }, + { pct: 20, amount: Math.round(selectedBooking.total_amount * 0.20 * 100) / 100 } + ] : []); + + function selectTipPreset(amount: number) { + selectedTipPreset = amount; + customTipInput = ''; + tipAmount = amount; + } + + function handleCustomTip(e: Event) { + const input = e.target as HTMLInputElement; + const cleaned = input.value.replace(/[^0-9.]/g, ''); + const firstDot = cleaned.indexOf('.'); + let sanitized: string; + if (firstDot !== -1) { + const integerPart = cleaned.substring(0, firstDot); + const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, ''); + sanitized = integerPart + '.' + decimalPart; + } else { + sanitized = cleaned; + } + if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') { + customTipInput = sanitized; + } + selectedTipPreset = null; + tipAmount = parseFloat(customTipInput) || 0; + } + + async function submitTip() { + if (!selectedBooking) return; + if (tipAmount <= 0) { + toast.error('Please select a tip amount'); + return; + } + tipProcessing = true; + try { + const response = await fetch(`/api/bookings/${selectedBooking.id}/tip`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify({ + amount: Math.round(tipAmount * 100), + card_token: 'placeholder' + }) + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Tip payment failed'); + } + toast.success('Thank you for your tip!'); + showTipModal = false; + fetchBookingDetails(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Tip payment failed'); + } finally { + tipProcessing = false; + } + } + function handlePaymentComplete() { toast.success('Payment completed'); showPaymentModal = false; @@ -244,6 +324,23 @@ return slots.length === 0; } + function formatPaymentMethod(method: string): string { + switch (method) { + case 'in_person_card': + return 'Card, In-person'; + case 'online_square': + return 'Card, Online'; + case 'cash': + return 'Cash'; + case 'giftcard': + return 'Gift Card'; + case 'discount': + return 'Discount'; + default: + return method.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + } + } + function formatTime(time: string): string { const parts = time.split(':').map(Number); const hours = parts[0]; @@ -587,8 +684,8 @@
{service.service_description}
{/if}
- {service.duration_minutes} min - £{service.price?.toFixed(2) || '0.00'} + {service.override_duration_minutes ?? service.duration_minutes} min + £{(service.override_price ?? service.price ?? 0).toFixed(2)}
{/each} @@ -634,7 +731,7 @@ {/if}
- Total Amount + {selectedBooking.amount_paid > selectedBooking.total_amount ? 'Pre-tip Subtotal' : 'Total Amount'} £{selectedBooking.total_amount.toFixed(2)}
@@ -643,18 +740,18 @@ >£{selectedBooking.amount_paid.toFixed(2)}
-
- - {isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'} - - - £{selectedBooking.amount_due.toFixed(2)} - -
+ {#if selectedBooking.amount_due > 0} +
+ + {isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'} + + + £{selectedBooking.amount_due.toFixed(2)} + +
+ {/if} @@ -669,9 +766,7 @@
- {payment.payment_method.replace('_', ' ')} + {formatPaymentMethod(payment.payment_method)} {payment.payment_type.charAt(0).toUpperCase() + - payment.payment_type.slice(1)} + payment.payment_type.slice(1)} payment + {#if payment.card_last4} + , Card ending in {payment.card_last4} + {/if}
{#if payment.is_vat_applicable}
@@ -853,18 +951,14 @@ {/if}
- {#if selectedBooking} + {#if isCompleted} {/if} {#if depositOutstanding} @@ -891,10 +985,11 @@ {#if showPaymentModal && selectedBooking} - (showPaymentModal = false)} onComplete={handlePaymentComplete} + canSaveCards={canSaveCards} /> {/if} @@ -925,3 +1020,66 @@ + + { + if (!v) { + showTipModal = false; + tipAmount = 0; + selectedTipPreset = null; + customTipInput = ''; + } + }}> + + + Leave a Tip + Show your appreciation for great service + + +
+
+ {#each tipPresets as preset (preset.pct)} + + {/each} +
+ +
+ +
+ £ + +
+
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index 36f9df5..22a40e5 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -18,8 +18,11 @@ let showApprovalModal = $state(false); // Calculate total duration from services + // IMPORTANT: Use override_duration_minutes when present — services may have been + // customised at booking time (discounts, extended sessions). Showing base values + // misleads admins about what was actually booked. let totalDuration = $derived( - selectedBooking?.services?.reduce((sum, service) => sum + (service.duration_minutes || 0), 0) || + selectedBooking?.services?.reduce((sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0), 0) || 0 ); @@ -278,8 +281,8 @@
{service.service_description}
{/if}
- {service.duration_minutes} min - £{service.price?.toFixed(2) || '0.00'} + {service.override_duration_minutes ?? service.duration_minutes} min + £{(service.override_price ?? service.price ?? 0).toFixed(2)}
{/each} @@ -410,9 +413,15 @@
- {payment.payment_method.replace('_', ' ')} + + {#if payment.payment_method === 'online_square'}Online + {:else if payment.payment_method === 'in_person_card'}Card Machine + {:else if payment.payment_method === 'cash'}Cash + {:else if payment.payment_method === 'giftcard'}Gift Card + {:else if payment.payment_method === 'discount'}Discount + {:else}{payment.payment_method.replace('_', ' ')} + {/if} + {payment.payment_type.charAt(0).toUpperCase() + - payment.payment_type.slice(1)} + payment.payment_type.slice(1)} payment + {#if payment.card_last4} + , Card ending in {payment.card_last4} + {/if}
{#if payment.vendor_code || payment.invoice_number}
diff --git a/frontend/src/lib/components/admin/HolidayHours.svelte b/frontend/src/lib/components/admin/HolidayHours.svelte index 4d63330..61b6b40 100644 --- a/frontend/src/lib/components/admin/HolidayHours.svelte +++ b/frontend/src/lib/components/admin/HolidayHours.svelte @@ -7,6 +7,7 @@ import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; import { Input } from '$lib/components/ui/input'; + import { Checkbox } from '$lib/components/ui/checkbox'; import { Separator } from '$lib/components/ui/separator'; import * as Modal from '$lib/components/ui/dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; @@ -523,11 +524,7 @@ {weekdayLabel(row.weekday)} - + {weekdayLabel(row.weekday)} - +
- + {#if canBack} + + {:else} +
+ {/if} {#if showSubmit} {/if} - {#if currentStep === 4} + {#if currentStep === 4 && depositRequired} Pay Your Deposit @@ -1579,7 +1618,10 @@ (newCardNumber = formatDepositCardNumber((e.target as HTMLInputElement).value))} placeholder="1234 5678 9012 3456" maxlength={19} /> @@ -1589,14 +1631,17 @@ (newCardExpiry = formatDepositExpiry((e.target as HTMLInputElement).value))} placeholder="MM/YY" maxlength={5} />
- +
{#if authStore.isAuthenticated} @@ -1623,8 +1668,7 @@ Cancel {/if} - - {#if currentStep === 5} + + {#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.

diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index b9f8673..973524e 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -32,6 +32,8 @@ service_description?: string; price?: number; duration_minutes?: number; + override_price?: number; + override_duration_minutes?: number; }>; duration_minutes: number; total_amount: number; @@ -318,7 +320,7 @@
{service.service_description}
{/if}
- {service.duration_minutes} mins + {service.override_duration_minutes ?? service.duration_minutes} mins
{/each} diff --git a/frontend/src/routes/booking-confirmed/[id]/+page.svelte b/frontend/src/routes/booking-confirmed/[id]/+page.svelte index a1d6bf6..9369830 100644 --- a/frontend/src/routes/booking-confirmed/[id]/+page.svelte +++ b/frontend/src/routes/booking-confirmed/[id]/+page.svelte @@ -1,5 +1,6 @@