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:
@@ -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 · {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 · 10% off ({formatCurrency(Math.round(booking.total_amount * 100 * 0.1))})
|
||||
{stamps} stamps available · {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user