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 type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
booking: Booking;
|
booking: Booking;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -45,6 +47,20 @@
|
|||||||
let paymentResult = $state<PaymentResult | null>(null);
|
let paymentResult = $state<PaymentResult | null>(null);
|
||||||
let error = $state<string | 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 customerBalance = $state(0);
|
||||||
let loadingCustomerBalance = $state(false);
|
let loadingCustomerBalance = $state(false);
|
||||||
let giftCardPaymentAmount = $state('');
|
let giftCardPaymentAmount = $state('');
|
||||||
@@ -218,6 +234,21 @@
|
|||||||
}).format(value);
|
}).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() {
|
async function handleCardPayment() {
|
||||||
const finalAmount = totalDue;
|
const finalAmount = totalDue;
|
||||||
|
|
||||||
@@ -230,6 +261,8 @@
|
|||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await applyLoyaltyRedemption();
|
||||||
|
|
||||||
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -237,7 +270,7 @@
|
|||||||
Authorization: `Bearer ${authStore.currentToken}`
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
amount: Math.round(finalAmount * 100),
|
amount: Math.round(finalAmount * 100) - loyaltyDiscount,
|
||||||
payment_type: 'full',
|
payment_type: 'full',
|
||||||
tip_enabled: tipEnabled
|
tip_enabled: tipEnabled
|
||||||
})
|
})
|
||||||
@@ -357,19 +390,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCashPayment() {
|
async function handleCashPayment() {
|
||||||
if (cashAmountNum < totalDue) {
|
const cashDue = totalDue - loyaltyDiscount / 100;
|
||||||
|
|
||||||
|
if (cashAmountNum < cashDue) {
|
||||||
toast.error('Cash amount must cover the total');
|
toast.error('Cash amount must cover the total');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tipAmount = extraAsTip ? cashAmountNum - totalDue : 0;
|
const tipAmount = extraAsTip ? cashAmountNum - cashDue : 0;
|
||||||
|
|
||||||
status = 'cash-confirming';
|
status = 'cash-confirming';
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await applyLoyaltyRedemption();
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
amount: Math.round(totalDue * 100),
|
amount: Math.round(cashDue * 100),
|
||||||
payment_type: 'full',
|
payment_type: 'full',
|
||||||
payment_method: 'cash'
|
payment_method: 'cash'
|
||||||
};
|
};
|
||||||
@@ -474,7 +511,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let payAmountCents = Math.round(totalDue * 100);
|
const giftDue = totalDue - loyaltyDiscount / 100;
|
||||||
|
|
||||||
|
let payAmountCents = Math.round(giftDue * 100);
|
||||||
if (useAccountBalance) {
|
if (useAccountBalance) {
|
||||||
const parsedAmt = parseFloat(giftCardPaymentAmount);
|
const parsedAmt = parseFloat(giftCardPaymentAmount);
|
||||||
if (isNaN(parsedAmt) || parsedAmt <= 0) {
|
if (isNaN(parsedAmt) || parsedAmt <= 0) {
|
||||||
@@ -492,6 +531,8 @@
|
|||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await applyLoyaltyRedemption();
|
||||||
|
|
||||||
const body: any = {
|
const body: any = {
|
||||||
amount: payAmountCents,
|
amount: payAmountCents,
|
||||||
payment_type: 'full',
|
payment_type: 'full',
|
||||||
@@ -566,6 +607,8 @@
|
|||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await applyLoyaltyRedemption();
|
||||||
|
|
||||||
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -573,7 +616,7 @@
|
|||||||
Authorization: `Bearer ${authStore.currentToken}`
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
amount: Math.round(totalDue * 100),
|
amount: Math.round(totalDue * 100) - loyaltyDiscount,
|
||||||
payment_type: 'full',
|
payment_type: 'full',
|
||||||
payment_method: 'saved_card',
|
payment_method: 'saved_card',
|
||||||
saved_card_id: selectedSavedCardId
|
saved_card_id: selectedSavedCardId
|
||||||
@@ -663,6 +706,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
{#if booking.discounts && booking.discounts.length > 0}
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
|
<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="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 PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
booking: Booking;
|
booking: Booking;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -242,11 +244,12 @@ import { SvelteDate } from 'svelte/reactivity';
|
|||||||
let loyaltyEligible = $derived(
|
let loyaltyEligible = $derived(
|
||||||
stamps >= 10 &&
|
stamps >= 10 &&
|
||||||
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
|
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
|
||||||
booking.total_amount > 0
|
booking.total_amount > 0 &&
|
||||||
|
booking.amount_paid === 0
|
||||||
);
|
);
|
||||||
|
|
||||||
let loyaltyDiscount = $derived(
|
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
|
// 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">
|
<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="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
|
||||||
<div class="mt-0.5 text-xs text-fuchsia-700">
|
<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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user