feat(frontend): update payments and today components

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-06-18 16:27:04 +01:00
co-authored by Sisyphus
parent 2ad3c01a80
commit fa51c6b2f9
4 changed files with 432 additions and 91 deletions
@@ -1,5 +1,6 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { onMount, onDestroy } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
@@ -7,6 +8,8 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking } from '$lib/types/booking';
import type { UserSavedCard } from '$lib/types';
import CardInput from '$lib/components/payments/CardInput.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
@@ -14,9 +17,10 @@
onClose: () => void;
onComplete: () => void;
canSaveCards?: boolean;
defaultPaymentType?: 'full' | 'partial' | 'deposit';
}
let { booking, onClose, onComplete, canSaveCards = true }: Props = $props();
let { booking, onClose, onComplete, canSaveCards = true, defaultPaymentType }: Props = $props();
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
@@ -37,6 +41,9 @@
let showNewCardForm = $state(false);
let showCardList = $state(false);
let stamps = $state(0);
let useLoyalty = $state(false);
// Auto-select first saved card when methods load
$effect(() => {
if (paymentMethods.length > 0 && !selectedPaymentMethod && !showNewCardForm) {
@@ -193,13 +200,37 @@
// Partial payment amount (in pounds, user enters)
let partialAmount = $state<string>('');
let lastValidPartialAmount = $state<string>('');
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
let pollingInterval: ReturnType<typeof setInterval> | null = null;
// Campaign discount preview — fetched on mount to show eligible discounts
let discountPreview = $state<{
eligible: boolean;
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
original_total: number;
discounted_total: number;
} | null>(null);
// Derived values
let depositOutstanding = $derived(booking.deposit_required && !booking.deposit_paid);
// Auto-select a sensible default payment type based on the booking's deposit
// state. The backend will split the charge into deposit + non-deposit records
// when appropriate, so this choice mainly controls the button label and amount.
let defaultType = $derived(
defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full')
);
let paymentType = $state<'full' | 'partial' | 'deposit'>(defaultType as 'full' | 'partial' | 'deposit');
let pollingInterval: ReturnType<typeof setInterval> | null = null;
// Payment lock state
let lockTimer = $state(-1);
let lockAcquired = $state(false);
let lockInterval: ReturnType<typeof setInterval> | null = null;
let countdownInterval: ReturnType<typeof setInterval> | null = null;
let servicesSubtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0));
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
let totalPaid = $derived(
booking.payments
?.filter((p) => p.status === 'completed')
@@ -208,6 +239,31 @@
let amountRemaining = $derived(booking.total_amount - totalPaid);
let loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
booking.total_amount > 0
);
let loyaltyDiscount = $derived(
useLoyalty ? Math.round(booking.total_amount * 100 * 0.1) : 0
);
// Deposit policy warning text — dynamic based on booking state
let expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
let depositPolicyWarning = $derived<string | null>({
get text(): string | null {
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
if (booking.deposit_required) {
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
}
if (totalPaid > 0 || booking.amount_due > 0) {
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
}
return null;
}
}.text);
let isScenarioA = $derived(depositOutstanding);
let isScenarioB = $derived(!depositOutstanding && totalPaid < booking.total_amount);
@@ -237,7 +293,10 @@
);
let payButtonDisabled = $derived(
status === 'processing' || !cardSelected || (paymentType === 'partial' && !partialAmountValid)
status === 'processing' ||
!cardSelected ||
(paymentType === 'partial' && !partialAmountValid) ||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
);
let payButtonError = $derived(
@@ -250,6 +309,12 @@
: null
);
function campaignDiscountCents(): number {
return discountPreview?.eligible
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
: 0;
}
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
@@ -257,6 +322,80 @@
}).format(pence / 100);
}
function formatTimer(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function clearLockIntervals() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
if (lockInterval) {
clearInterval(lockInterval);
lockInterval = null;
}
}
async function acquireLock() {
try {
const response = await fetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
lockAcquired = true;
lockTimer = 300;
}
} catch (err) {
console.error('Failed to acquire payment lock:', err);
}
}
async function releaseLock() {
lockAcquired = false;
clearLockIntervals();
try {
await fetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
} catch (err) {
console.error('Failed to release payment lock:', err);
}
}
function startCountdown() {
countdownInterval = setInterval(() => {
lockTimer = Math.max(0, lockTimer - 1);
}, 1000);
}
function startRenewal() {
lockInterval = setInterval(async () => {
try {
const response = await fetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
lockTimer = 300;
lockAcquired = true;
}
} catch (err) {
console.error('Failed to renew payment lock:', err);
}
}, 60000);
}
function generateIdempotencyKey(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
@@ -294,6 +433,23 @@
}
}
async function fetchLoyaltyData() {
if (!authStore.isAuthenticated) return;
try {
const response = await fetch('/api/user/loyalty', {
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
stamps = data.stamps ?? 0;
}
} catch (err) {
console.error('Failed to fetch loyalty data:', err);
}
}
function formatCardExpiry(month: number, year: number): string {
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
}
@@ -329,6 +485,33 @@
status = 'processing';
error = null;
// Apply loyalty redemption before payment
if (useLoyalty) {
try {
const redemptionResponse = await fetch(
`/api/bookings/${booking.id}/apply-redemption`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (!redemptionResponse.ok) {
const errData = await redemptionResponse.text();
throw new Error(errData || 'Failed to apply loyalty discount');
}
} catch (err) {
status = 'error';
const msg =
err instanceof Error ? err.message : 'Failed to apply loyalty discount';
error = msg;
toast.error(msg);
return;
}
}
let cardId: string | undefined;
let newCardToken: string | undefined;
let saveCard = false;
@@ -380,12 +563,15 @@
payment_type: data.payment_type
};
toast.success('Payment successful');
fetchPaymentMethods();
onComplete();
releaseLock();
} catch (err) {
status = 'error';
const msg = err instanceof Error ? err.message : 'Payment declined';
error = msg;
toast.error(`${msg}. Please try again or use another card.`);
releaseLock();
}
}
@@ -398,8 +584,9 @@
function handlePayFull() {
const fullCents = Math.round(booking.amount_due * 100);
const discountedCents = Math.max(0, fullCents - campaignDiscountCents() - loyaltyDiscount);
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
makePayment(paymentType, fullCents);
makePayment(paymentType, discountedCents);
}
function handlePayPartial() {
@@ -411,6 +598,7 @@
}
function handleClose() {
releaseLock();
stopPolling();
onClose();
}
@@ -426,6 +614,7 @@
$effect(() => {
if (authStore.isAuthenticated) {
fetchPaymentMethods();
fetchLoyaltyData();
}
});
@@ -435,10 +624,36 @@
stopPolling();
};
});
onMount(async () => {
if (booking.status === 'pending_release') {
acquireLock();
startCountdown();
startRenewal();
}
// Fetch eligible campaign discounts
try {
const resp = await fetch(`/api/bookings/${booking.id}/discount-preview`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (resp.ok) {
discountPreview = await resp.json();
}
} catch (err) {
console.error('Failed to fetch discount preview:', err);
}
});
onDestroy(() => {
if (booking.status === 'pending_release' && lockAcquired) {
releaseLock();
}
});
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Content class="!z-[70] max-h-[90vh] max-w-md overflow-y-auto">
<Dialog.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
{#if booking.id}
@@ -448,6 +663,42 @@
{#if status === 'idle' || status === 'processing' || status === 'error'}
<div class="space-y-4">
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
<div
class="flex items-center gap-2 rounded-md border p-3 text-sm {lockTimer <= 60
? 'border-amber-200 bg-amber-50 text-amber-800'
: 'border-blue-200 bg-blue-50 text-blue-800'}"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<span>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment processing</span>
</div>
{:else if booking.status === 'pending_release' && (lockTimer === 0 || !lockAcquired)}
<div
class="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<span>Slot no longer secured — please close and retry</span>
</div>
{/if}
<!-- Service Breakdown -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
@@ -467,6 +718,65 @@
</div>
</div>
<!-- Loyalty Redemption Checkbox -->
{#if loyaltyEligible}
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4">
<div class="mb-2 text-sm font-semibold text-fuchsia-800">Available Savings</div>
<div class="flex items-start gap-3">
<Checkbox
id="use-loyalty"
bind:checked={useLoyalty}
disabled={status === 'processing'}
/>
<label for="use-loyalty" class="cursor-pointer select-none">
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{stamps} stamps available &middot; 10% off ({formatCurrency(Math.round(booking.total_amount * 100 * 0.1))})
</div>
</label>
</div>
</div>
{/if}
<!-- Applied Discounts -->
{#if booking.discounts && booking.discounts.length > 0}
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
<svg class="h-4 w-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
Applied Discounts
</div>
{#if servicesSubtotal > 0}
<span class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600">
{((discountSum / servicesSubtotal) * 100).toFixed(0)}% Off Total
</span>
{/if}
</div>
<div class="space-y-2 text-sm">
{#each booking.discounts as d}
<div class="flex items-center justify-between text-gray-600">
<div class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
<span>
{#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}
</span>
</div>
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Financial Summary -->
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm">
@@ -474,16 +784,30 @@
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
>
</div>
{#if discountPreview?.eligible}
{#each discountPreview.discounts as d}
<div class="flex justify-between text-sm">
<span class="text-gray-600">{d.name}</span>
<span class="font-medium text-green-700">-{formatCurrency(Math.round(d.amount * 100))}</span>
</div>
{/each}
{/if}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
</div>
{#if useLoyalty && loyaltyDiscount > 0}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
</div>
<div class="flex justify-between border-t border-gray-200 pt-2">
<span class="font-semibold text-gray-900">Amount Remaining</span>
<span class="text-lg font-bold text-red-600">
{formatCurrency(Math.round(amountRemaining * 100))}
</span>
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 pt-2">
<span class="font-semibold text-gray-900">Amount Remaining</span>
<span class="text-lg font-bold text-red-600">
{formatCurrency(Math.max(0, Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount))}
</span>
</div>
</div>
<!-- Card Selection (only when idle) -->
@@ -587,68 +911,29 @@
{/if}
</div>
{:else}
<!-- No saved cards or can't save cards - show new card form -->
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-3 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-3">
<div>
<label for="cardNumber" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input
id="cardNumber"
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={handleCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="cardExpiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input
id="cardExpiry"
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={handleExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="cardCVC" class="text-sm font-medium text-gray-700">CVC</label>
<Input
id="cardCVC"
type="text"
inputmode="numeric"
value={newCardCVC}
oninput={handleCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
{#if canSaveCards}
<div class="flex items-center gap-2">
<Checkbox id="saveCard" bind:checked={saveCardForFuture} />
<label for="saveCard" class="text-sm text-gray-700">
Save card for next time
</label>
</div>
{/if}
</div>
</div>
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
disabled={false}
/>
{/if}
{/if}
{#if depositPolicyWarning}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<p class="font-semibold text-amber-900">Cancellation &amp; Deposit Policy</p>
<p class="mt-1">{depositPolicyWarning}</p>
<PolicyPopover>
{#snippet trigger()}
<span class="mt-1 inline-block underline">Full cancellation policy →</span>
{/snippet}
</PolicyPopover>
</div>
{/if}
<!-- Scenario A: Deposit needed -->
{#if isScenarioA}
<div class="space-y-3">
@@ -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}
</Button>
</div>
@@ -743,7 +1028,7 @@
value={partialAmount}
oninput={handlePartialAmountInput}
class="pl-7"
disabled={status === 'processing'}
disabled={status !== 'idle'}
/>
</div>
</div>
@@ -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}
</Button>
</div>
@@ -85,6 +85,9 @@
let summary = $state<DailySummary | null>(null);
let weekSummary = $state<DailySummary | null>(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<typeof setInterval> | 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 @@
<Card.Content>
<div class="space-y-5">
<!-- Conditional messages -->
{#if minutesUntilClosing >= 30}
<div class="rounded-md border border-teal-200 bg-white p-3 text-sm text-teal-800">
There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings
{#if minutesUntilClosing >= 75}
<div class="flex items-start gap-2.5 rounded-md border border-teal-200 bg-teal-50/60 p-3 text-sm text-teal-800">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
<div>
<p class="font-medium">Online booking still open</p>
<p class="mt-0.5 text-teal-700">Users booking online still have time to book in</p>
</div>
</div>
{/if}
{#if minutesUntilClosing >= 75}
<div class="rounded-md border border-blue-200 bg-white p-3 text-sm text-blue-800">
Users booking online still have time to book in
{#if minutesUntilClosing >= 30}
<div class="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50/60 p-3 text-sm text-amber-800">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
<div>
<p class="font-medium">Walk-ins closing soon</p>
<p class="mt-0.5 text-amber-700">There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings</p>
</div>
</div>
{/if}
@@ -418,6 +461,11 @@
<div class="mt-1 text-lg font-bold text-amber-900">
£{summary.amount_due_today.toFixed(2)}
</div>
{#if summary.last_customer_name}
<div class="text-xs text-amber-600">
{summary.last_customer_name}
</div>
{/if}
</div>
{/if}
@@ -765,7 +813,7 @@
{#if showPaymentModal && activeAppointment}
<PaymentModal
booking={activeAppointment}
booking={activeAppointment as BookingType}
onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete}
/>
@@ -265,8 +265,15 @@
fetchEditRequests();
}, 60_000);
function handleBookingApproved() {
fetchPendingApprovals();
fetchEditRequests();
}
window.addEventListener('bookingApproved', handleBookingApproved);
return () => {
clearInterval(intervalId);
window.removeEventListener('bookingApproved', handleBookingApproved);
};
});
</script>
@@ -398,7 +405,7 @@
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{er.user?.full_name || 'Unknown'}</span>
<span class="text-xs font-medium text-amber-600">Edit Request</span>
<span class="text-xs font-medium text-amber-600">Edit/Reschedule</span>
</div>
<div class="mt-1 text-sm text-gray-600">
{getEditRequestSummary(er)}
@@ -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);