feat(loyalty-discount): implement loyalty and discount system

- Add discount campaign management and validation logic
- Update booking handlers with discount application flow
- Add customer relationship endpoints for loyalty tracking
- Update frontend modals (booking, approval, payment, reschedule)
- Add DiscountsManagement and loyalty reference documentation
- Update dev scripts and database init for discount tables
- Clean up completed plan files
This commit is contained in:
2026-06-04 23:13:02 +01:00
parent 79b23a3cf7
commit 6d4bc4d637
28 changed files with 2248 additions and 1461 deletions
@@ -371,7 +371,7 @@
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Content class="!z-[70] max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Approve Booking</Modal.Title>
<Modal.Description>
@@ -6,8 +6,7 @@
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format';
import type { Booking, BookingService } from '$lib/types/booking';
import type { Payment } from '$lib/types';
import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking';
interface Props {
open: boolean;
@@ -118,7 +117,8 @@
total_amount: data.total_amount || 0,
amount_paid: data.amount_paid || 0,
amount_due: data.amount_due || 0,
duration_minutes: data.duration_minutes || 0
duration_minutes: data.duration_minutes || 0,
discounts: data.discounts || []
};
} else {
const text = await response.text();
@@ -130,6 +130,25 @@
}
}
function getPaymentName(payment: Payment, index: number, payments: Payment[], discounts: BookingDiscount[] | undefined): string {
if (payment.payment_method === 'online_square') return 'Online Card';
if (payment.payment_method === 'in_person_card') return 'Card Machine';
if (payment.payment_method === 'cash') return 'Cash';
if (payment.payment_method === 'giftcard') return 'Gift Card';
if (payment.payment_method === 'discount') {
const discountPaymentsBefore = payments.slice(0, index).filter(p => p.payment_method === 'discount').length;
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
if (discountList[discountPaymentsBefore]) {
const d = discountList[discountPaymentsBefore];
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
if (d.campaign_name) return `${d.campaign_name}`;
return 'Promo Campaign Discount';
}
return 'Discount';
}
return (payment.payment_method as string).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
$effect(() => {
if (open && bookingId) {
fetchBookingDetails();
@@ -139,7 +158,7 @@
<Modal.Root bind:open>
<Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header>
<div class="flex items-center justify-between">
@@ -418,23 +437,49 @@
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span>
<span class="text-sm text-gray-600">Subtotal (Services)</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
</div>
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
<div class="border-y border-fuchsia-100 bg-fuchsia-50/20 py-2 my-2 space-y-1 rounded-md px-2">
{#each selectedBooking.discounts as d}
<div class="flex items-center justify-between text-xs text-fuchsia-800">
<span class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
{:else if d.campaign_name}
{d.campaign_name} ({d.discount_percent}% Off)
{:else}
Promo Campaign ({d.discount_percent}% Off)
{/if}
</span>
<span class="font-medium">{d.discount_amount.toFixed(2)}</span>
</div>
{/each}
</div>
<div class="flex items-center justify-between font-medium text-gray-900">
<span class="text-sm">Net Total</span>
<span>£{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)}</span>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid</span>
<span class="font-semibold text-green-700"
>£{selectedBooking.amount_paid.toFixed(2)}</span
>
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
<span class="font-semibold text-green-700">
£{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}
</span>
</div>
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">Amount Due</span>
<span class="font-medium text-gray-900">Balance Due</span>
<span
class="text-lg font-bold {selectedBooking.amount_due > 0
class="text-lg font-bold {Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)) > 0.01
? 'text-red-600'
: 'text-green-600'}"
>
£{selectedBooking.amount_due.toFixed(2)}
£{Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)).toFixed(2)}
</span>
</div>
</div>
@@ -452,14 +497,8 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">
{#if payment.payment_method === 'online_square'}Online
{:else if payment.payment_method === 'in_person_card'}Card Machine
{:else if payment.payment_method === 'cash'}Cash
{:else if payment.payment_method === 'giftcard'}Gift Card
{:else if payment.payment_method === 'discount'}Discount
{:else}{(payment.payment_method as string).replace('_', ' ')}
{/if}
<span class="font-medium text-gray-900">
{getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)}
</span>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
@@ -475,10 +514,14 @@
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
{payment.payment_type.charAt(0).toUpperCase() +
payment.payment_type.slice(1)} payment
{#if payment.card_last4}
, Card ending in {payment.card_last4}
{#if payment.payment_method === 'discount'}
Applied automatically on completion
{:else}
{payment.payment_type.charAt(0).toUpperCase() +
payment.payment_type.slice(1)} payment
{#if payment.card_last4}
, Card ending in {payment.card_last4}
{/if}
{/if}
</div>
{#if payment.vendor_code || payment.invoice_number}
@@ -505,7 +548,7 @@
</div>
</div>
<div class="text-right font-semibold">
£{payment.amount.toFixed(2)}
{payment.payment_method === 'discount' ? '-' : ''}£{payment.amount.toFixed(2)}
</div>
</div>
</div>
@@ -96,7 +96,7 @@
if (form.discount_percent <= 0 || form.discount_percent > 100) return false;
if (form.campaign_type === 'time_based') {
if (!form.start_date || !form.end_date) return false;
if (new SvelteDate(form.end_date) <= new SvelteDate(form.start_date)) return false;
if (new SvelteDate(form.end_date) < new SvelteDate(form.start_date)) return false;
}
if (form.campaign_type === 'milestone') {
if (form.milestone_value <= 0) return false;
@@ -146,8 +146,8 @@
campaign_type: c.campaign_type,
discount_percent: c.discount_percent,
scope: c.scope || 'all_bookings',
start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 16) : '',
end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 16) : '',
start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 10) : '',
end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 10) : '',
milestone_type: c.milestone_type || 'per_user_booking_count',
milestone_value: c.milestone_value || 0,
milestone_unit: c.milestone_unit || 'bookings',
@@ -168,7 +168,7 @@
if (
form.start_date &&
form.end_date &&
new Date(form.end_date) <= new Date(form.start_date)
new Date(form.end_date) < new Date(form.start_date)
) {
errors.end_date = 'Must be after start';
}
@@ -188,8 +188,8 @@
};
if (form.campaign_type === 'time_based') {
payload.scope = form.scope;
payload.start_date = new Date(form.start_date).toISOString();
payload.end_date = new Date(form.end_date).toISOString();
payload.start_date = form.start_date + 'T00:00:00.000Z';
payload.end_date = form.end_date + 'T23:59:59.999Z';
} else {
payload.milestone_type = form.milestone_type;
payload.milestone_value = form.milestone_value;
@@ -562,12 +562,12 @@
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label.Root for="dc-start">Start Date *</Label.Root>
<Input id="dc-start" type="datetime-local" bind:value={form.start_date} />
<Input id="dc-start" type="date" bind:value={form.start_date} />
{#if errors.start_date}<p class="text-xs text-red-500">{errors.start_date}</p>{/if}
</div>
<div class="space-y-2">
<Label.Root for="dc-end">End Date *</Label.Root>
<Input id="dc-end" type="datetime-local" bind:value={form.end_date} />
<Input id="dc-end" type="date" bind:value={form.end_date} />
{#if errors.end_date}<p class="text-xs text-red-500">{errors.end_date}</p>{/if}
</div>
</div>
@@ -98,7 +98,7 @@
</script>
<Modal.Root bind:open>
<Modal.Content class="sm:max-w-[425px]">
<Modal.Content class="!z-[60] sm:max-w-[425px]">
<Modal.Header>
<Modal.Title>Record Patch Test</Modal.Title>
</Modal.Header>
@@ -408,7 +408,7 @@
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
<Modal.Content class="!z-[70] max-h-[90vh] max-w-4xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Reschedule Booking</Modal.Title>
<Modal.Description>
@@ -72,6 +72,7 @@
type CustomerRelationship = {
totalSpend: number;
totalSaved: number;
totalTips: number;
totalVisits: number;
customerFor: string;
@@ -485,13 +486,19 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Relationship
</h3>
<div class="grid gap-3 md:grid-cols-3">
<div class="grid gap-3 md:grid-cols-5">
<div>
<div class="text-xs text-gray-500">Total Spend</div>
<div class="text-2xl font-bold text-emerald-600">
£{customerRelationship.totalSpend.toFixed(2)}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Total Saved</div>
<div class="text-2xl font-bold text-fuchsia-600">
£{customerRelationship.totalSaved.toFixed(2)}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Total Tips</div>
<div class="text-2xl font-bold text-amber-600">