feat(frontend): add loyalty checkbox to admin and customer payment modals

Add loyalty stamp card checkbox UI to both payment modals. Admin modal: checkbox with card count, applies redemption via /apply-redemption before payment, adjusts amounts accordingly. Customer modal: add amount_paid === 0 guard, extract LOYALTY_DISCOUNT_RATE constant.

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-19 11:34:36 +01:00
co-authored by Sisyphus
parent e74f190cdd
commit 0e727818e1
2 changed files with 73 additions and 9 deletions
@@ -7,6 +7,8 @@
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { authStore } from '$lib/stores/auth.svelte';
const LOYALTY_DISCOUNT_RATE = 0.1;
interface Props {
booking: Booking;
onClose: () => void;
@@ -45,6 +47,20 @@
let paymentResult = $state<PaymentResult | null>(null);
let error = $state<string | null>(null);
let stamps = $state(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
let loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
booking.total_amount > 0 &&
booking.amount_paid === 0
);
let loyaltyDiscount = $derived(
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
);
let customerBalance = $state(0);
let loadingCustomerBalance = $state(false);
let giftCardPaymentAmount = $state('');
@@ -218,6 +234,21 @@
}).format(value);
}
async function applyLoyaltyRedemption(): Promise<void> {
if (!useLoyalty) return;
const res = await fetch(`/api/admin/bookings/${booking.id}/apply-redemption`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (!res.ok) {
const errData = await res.text();
throw new Error(errData || 'Failed to apply loyalty discount');
}
}
async function handleCardPayment() {
const finalAmount = totalDue;
@@ -230,6 +261,8 @@
error = null;
try {
await applyLoyaltyRedemption();
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
@@ -237,7 +270,7 @@
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
amount: Math.round(finalAmount * 100),
amount: Math.round(finalAmount * 100) - loyaltyDiscount,
payment_type: 'full',
tip_enabled: tipEnabled
})
@@ -357,19 +390,23 @@
}
async function handleCashPayment() {
if (cashAmountNum < totalDue) {
const cashDue = totalDue - loyaltyDiscount / 100;
if (cashAmountNum < cashDue) {
toast.error('Cash amount must cover the total');
return;
}
const tipAmount = extraAsTip ? cashAmountNum - totalDue : 0;
const tipAmount = extraAsTip ? cashAmountNum - cashDue : 0;
status = 'cash-confirming';
error = null;
try {
await applyLoyaltyRedemption();
const body: Record<string, unknown> = {
amount: Math.round(totalDue * 100),
amount: Math.round(cashDue * 100),
payment_type: 'full',
payment_method: 'cash'
};
@@ -474,7 +511,9 @@
return;
}
let payAmountCents = Math.round(totalDue * 100);
const giftDue = totalDue - loyaltyDiscount / 100;
let payAmountCents = Math.round(giftDue * 100);
if (useAccountBalance) {
const parsedAmt = parseFloat(giftCardPaymentAmount);
if (isNaN(parsedAmt) || parsedAmt <= 0) {
@@ -492,6 +531,8 @@
error = null;
try {
await applyLoyaltyRedemption();
const body: any = {
amount: payAmountCents,
payment_type: 'full',
@@ -566,6 +607,8 @@
error = null;
try {
await applyLoyaltyRedemption();
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
@@ -573,7 +616,7 @@
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
amount: Math.round(totalDue * 100),
amount: Math.round(totalDue * 100) - loyaltyDiscount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId
@@ -663,6 +706,24 @@
</div>
</div>
{#if loyaltyEligible}
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4">
<div class="flex items-start gap-3">
<Checkbox
id="use-loyalty-admin"
bind:checked={useLoyalty}
disabled={status !== 'idle'}
/>
<label for="use-loyalty-admin" class="cursor-pointer select-none">
<div class="text-sm font-medium text-fuchsia-900">Use Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
</div>
</label>
</div>
</div>
{/if}
{#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">
@@ -12,6 +12,8 @@ import { SvelteDate } from 'svelte/reactivity';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
const LOYALTY_DISCOUNT_RATE = 0.1;
interface Props {
booking: Booking;
onClose: () => void;
@@ -242,11 +244,12 @@ import { SvelteDate } from 'svelte/reactivity';
let loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
booking.total_amount > 0
booking.total_amount > 0 &&
booking.amount_paid === 0
);
let loyaltyDiscount = $derived(
useLoyalty ? Math.round(booking.total_amount * 100 * 0.1) : 0
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
);
// Deposit policy warning text — dynamic based on booking state
@@ -731,7 +734,7 @@ import { SvelteDate } from 'svelte/reactivity';
<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))})
{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
</div>
</label>
</div>