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
@@ -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<CalendarDate>(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<number>(0);
let selectedTipPreset = $state<number | null>(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 @@
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
{/if}
<div class="mt-2 flex items-center justify-between text-sm">
<span class="text-gray-600">{service.duration_minutes} min</span>
<span class="font-semibold">£{service.price?.toFixed(2) || '0.00'}</span>
<span class="text-gray-600">{service.override_duration_minutes ?? service.duration_minutes} min</span>
<span class="font-semibold">£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span>
</div>
</div>
{/each}
@@ -634,7 +731,7 @@
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span>
<span class="text-sm text-gray-600">{selectedBooking.amount_paid > selectedBooking.total_amount ? 'Pre-tip Subtotal' : 'Total Amount'}</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
</div>
<div class="flex items-center justify-between">
@@ -643,18 +740,18 @@
>£{selectedBooking.amount_paid.toFixed(2)}</span
>
</div>
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
</span>
<span
class="text-lg font-bold {selectedBooking.amount_due > 0
? 'text-red-600'
: 'text-green-600'}"
>
£{selectedBooking.amount_due.toFixed(2)}
</span>
</div>
{#if selectedBooking.amount_due > 0}
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
</span>
<span
class="text-lg font-bold text-red-600"
>
£{selectedBooking.amount_due.toFixed(2)}
</span>
</div>
{/if}
</div>
</div>
@@ -669,9 +766,7 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium capitalize"
>{payment.payment_method.replace('_', ' ')}</span
>
<span class="font-medium">{formatPaymentMethod(payment.payment_method)}</span>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{payment.status === 'completed'
@@ -685,7 +780,10 @@
</div>
<div class="mt-1 text-xs text-gray-500">
{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}
</div>
{#if payment.is_vat_applicable}
<div class="mt-2 text-xs text-gray-600">
@@ -853,18 +951,14 @@
{/if}
</div>
<div class="flex gap-2">
{#if selectedBooking}
{#if isCompleted}
<Button
variant="outline"
size="sm"
class="flex-1"
onclick={() => {
if (selectedBooking) {
window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank');
}
}}
class="flex-1 hover:bg-fuchsia-50"
variant="outline"
onclick={() => (showTipModal = true)}
>
Add to Calendar
Leave a Tip
</Button>
{/if}
{#if depositOutstanding}
@@ -891,10 +985,11 @@
</Modal.Root>
{#if showPaymentModal && selectedBooking}
<PaymentModal
<UserPaymentModal
booking={selectedBooking}
onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete}
canSaveCards={canSaveCards}
/>
{/if}
@@ -925,3 +1020,66 @@
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<Modal.Root open={showTipModal} onOpenChange={(v) => {
if (!v) {
showTipModal = false;
tipAmount = 0;
selectedTipPreset = null;
customTipInput = '';
}
}}>
<Modal.Content class="max-w-sm">
<Modal.Header>
<Modal.Title>Leave a Tip</Modal.Title>
<Modal.Description>Show your appreciation for great service</Modal.Description>
</Modal.Header>
<div class="px-4 pb-4 space-y-4">
<div class="grid grid-cols-3 gap-3">
{#each tipPresets as preset (preset.pct)}
<button
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset === preset.amount ? 'bg-fuchsia-100' : ''}"
onclick={() => selectTipPreset(preset.amount)}
type="button"
>
<div>£{preset.amount.toFixed(2)}</div>
<div class="text-xs font-normal text-gray-500">{preset.pct}%</div>
</button>
{/each}
</div>
<div>
<label for="custom-tip" class="text-sm font-medium text-gray-700">Or enter custom amount</label>
<div class="relative mt-1">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
<Input
id="custom-tip"
type="text"
inputmode="decimal"
step="0.01"
min="0"
placeholder="0.00"
class="pl-7"
value={customTipInput}
oninput={handleCustomTip}
/>
</div>
</div>
</div>
<Modal.Footer>
<Button variant="outline" onclick={() => (showTipModal = false)}>
Cancel
</Button>
<Button
class="hover:bg-fuchsia-50"
onclick={submitTip}
disabled={tipAmount <= 0 || tipProcessing}
loading={tipProcessing}
>
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>