From fa51c6b2f927e7cbd6f1ce15c683f19f212c9019 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 18 Jun 2026 16:27:04 +0100 Subject: [PATCH] feat(frontend): update payments and today components Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../payments/UserPaymentModal.svelte | 439 +++++++++++++++--- .../today/CurrentAppointment.svelte | 70 ++- .../components/today/PendingApprovals.svelte | 9 +- .../lib/components/today/TodayCalendar.svelte | 5 +- 4 files changed, 432 insertions(+), 91 deletions(-) diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index 068f3d1..55d59ff 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -1,5 +1,6 @@ !open && handleClose()}> - + Make a Payment {#if booking.id} @@ -448,6 +663,42 @@ {#if status === 'idle' || status === 'processing' || status === 'error'}
+ + {#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0} +
+ + + + + Slot re-secured for {formatTimer(lockTimer)} to ensure smooth payment processing +
+ {:else if booking.status === 'pending_release' && (lockTimer === 0 || !lockAcquired)} +
+ + + + + Slot no longer secured — please close and retry +
+ {/if}
Services
@@ -467,6 +718,65 @@
+ + {#if loyaltyEligible} +
+
Available Savings
+
+ + +
+
+ {/if} + + + {#if booking.discounts && booking.discounts.length > 0} +
+
+
+ + + + + Applied Discounts +
+ {#if servicesSubtotal > 0} + + {((discountSum / servicesSubtotal) * 100).toFixed(0)}% Off Total + + {/if} +
+
+ {#each booking.discounts as d} +
+
+ + + {#if d.discount_source === 'loyalty'} + Loyalty Stamp Card (10% Off) + {:else if d.campaign_name} + {d.campaign_name} + {:else} + Promo Campaign ({d.discount_percent}% Off) + {/if} + +
+ -{formatCurrency(d.discount_amount)} +
+ {/each} +
+
+ {/if} +
@@ -474,16 +784,30 @@ {formatCurrency(Math.round(booking.total_amount * 100))}
+ {#if discountPreview?.eligible} + {#each discountPreview.discounts as d} +
+ {d.name} + -{formatCurrency(Math.round(d.amount * 100))} +
+ {/each} + {/if} +
+ Amount Paid + {formatCurrency(totalPaid)} +
+ {#if useLoyalty && loyaltyDiscount > 0}
- Amount Paid - {formatCurrency(totalPaid)} -
-
- Amount Remaining - - {formatCurrency(Math.round(amountRemaining * 100))} - + Loyalty Stamp Card (10% Off) + -{formatCurrency(loyaltyDiscount)}
+ {/if} +
+ Amount Remaining + + {formatCurrency(Math.max(0, Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount))} + +
@@ -587,68 +911,29 @@ {/if} {:else} - -
-

Card Details

-
-
- - -
-
-
- - -
-
- - -
-
- {#if canSaveCards} -
- - -
- {/if} -
-
+ {/if} {/if} + {#if depositPolicyWarning} +
+

Cancellation & Deposit Policy

+

{depositPolicyWarning}

+ + {#snippet trigger()} + Full cancellation policy → + {/snippet} + +
+ {/if} + {#if isScenarioA}
@@ -693,7 +978,7 @@ : Math.round(booking.total_amount * 0.2 * 100) )}) {:else} - Pay {formatCurrency(Math.round(booking.amount_due * 100))} + Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))} {/if}
@@ -743,7 +1028,7 @@ value={partialAmount} oninput={handlePartialAmountInput} class="pl-7" - disabled={status === 'processing'} + disabled={status !== 'idle'} /> @@ -764,7 +1049,7 @@ ? formatCurrency(Math.round(partialAmountNum * 100)) : 'Part'} {:else} - Pay {formatCurrency(Math.round(booking.amount_due * 100))} + Pay {formatCurrency(Math.max(0, Math.round(booking.amount_due * 100) - campaignDiscountCents() - (useLoyalty ? loyaltyDiscount : 0)))} {/if} diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index d652d0a..c1a1c61 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -85,6 +85,9 @@ let summary = $state(null); let weekSummary = $state(null); + // Data diffing — only update UI when payload actually changes + let prevDataJson = $state(''); + function formatRemainingTime(minutes: number): string { const hours = Math.floor(minutes / 60); const mins = minutes % 60; @@ -102,8 +105,6 @@ return Math.max(0, Math.floor((closing.getTime() - now.getTime()) / 60000)); }); - let interval: ReturnType | null = null; - function calculateTimes() { const now = new SvelteDate(); @@ -181,6 +182,20 @@ if (response.ok) { const data = await response.json(); + const newJson = JSON.stringify({ + current: data.current || null, + next: data.next || null, + closingTime: data.closing_time || null, + doneForDay: !!data.done_for_day, + summary: data.summary || null, + weekSummary: data.week_summary || null + }); + if (newJson === prevDataJson) { + loading = false; + return; + } + prevDataJson = newJson; + currentAppointment = data.current || null; nextAppointment = data.next || null; closingTime = data.closing_time || null; @@ -210,12 +225,26 @@ $effect(() => { fetchCurrentAndNext(); - interval = setInterval(() => { + // Recalculate time display every 15s (no data fetch) + const timeInterval = setInterval(() => { calculateTimes(); }, 15000); + // Poll for new data every 30s so new bookings appear automatically + const dataInterval = setInterval(() => { + fetchCurrentAndNext(); + }, 30_000); + + // Immediate refresh when a booking is created/approved + function handleBookingApproved() { + fetchCurrentAndNext(); + } + window.addEventListener('bookingApproved', handleBookingApproved); + return () => { - if (interval) clearInterval(interval); + clearInterval(timeInterval); + clearInterval(dataInterval); + window.removeEventListener('bookingApproved', handleBookingApproved); }; }); @@ -362,14 +391,28 @@
- {#if minutesUntilClosing >= 30} -
- There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings + {#if minutesUntilClosing >= 75} +
+ + + + +
+

Online booking still open

+

Users booking online still have time to book in

+
{/if} - {#if minutesUntilClosing >= 75} -
- Users booking online still have time to book in + {#if minutesUntilClosing >= 30} +
+ + + + +
+

Walk-ins closing soon

+

There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings

+
{/if} @@ -418,6 +461,11 @@
£{summary.amount_due_today.toFixed(2)}
+ {#if summary.last_customer_name} +
+ — {summary.last_customer_name} +
+ {/if}
{/if} @@ -765,7 +813,7 @@ {#if showPaymentModal && activeAppointment} (showPaymentModal = false)} onComplete={handlePaymentComplete} /> diff --git a/frontend/src/lib/components/today/PendingApprovals.svelte b/frontend/src/lib/components/today/PendingApprovals.svelte index f3efc3d..ebe0cb1 100644 --- a/frontend/src/lib/components/today/PendingApprovals.svelte +++ b/frontend/src/lib/components/today/PendingApprovals.svelte @@ -265,8 +265,15 @@ fetchEditRequests(); }, 60_000); + function handleBookingApproved() { + fetchPendingApprovals(); + fetchEditRequests(); + } + window.addEventListener('bookingApproved', handleBookingApproved); + return () => { clearInterval(intervalId); + window.removeEventListener('bookingApproved', handleBookingApproved); }; }); @@ -398,7 +405,7 @@
{er.user?.full_name || 'Unknown'} - Edit Request + Edit/Reschedule
{getEditRequestSummary(er)} diff --git a/frontend/src/lib/components/today/TodayCalendar.svelte b/frontend/src/lib/components/today/TodayCalendar.svelte index 98f947c..4801c0e 100644 --- a/frontend/src/lib/components/today/TodayCalendar.svelte +++ b/frontend/src/lib/components/today/TodayCalendar.svelte @@ -2,6 +2,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { SvelteDate } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; + import { sanitizeText } from '$lib/utils/toast-safe'; import * as Card from '$lib/components/ui/card'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; @@ -521,7 +522,7 @@ await fetchTodayBlockersData(); } else { const text = await response.text(); - toast.error('Failed to create: ' + text, { id: loadingToast }); + toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast }); } } catch (err) { console.error('Error creating time blocker:', err); @@ -546,7 +547,7 @@ await fetchTodayBlockersData(); } else { const text = await response.text(); - toast.error('Failed to delete: ' + text, { id: loadingToast }); + toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast }); } } catch (err) { console.error('Error deleting time blocker:', err);