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:
@@ -4,7 +4,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import type { Booking, BookingService } from '$lib/types/booking';
|
||||
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -91,15 +91,6 @@
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
let tipPercentages = $derived.by(() => {
|
||||
if (subtotal <= 0) return [];
|
||||
return [
|
||||
{ pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 }
|
||||
];
|
||||
});
|
||||
|
||||
function selectTipPercent(percent: number) {
|
||||
selectedTipPercent = percent;
|
||||
customTipAmount = '';
|
||||
@@ -135,16 +126,27 @@
|
||||
}
|
||||
|
||||
let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0));
|
||||
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
|
||||
let netTotal = $derived(Math.max(0, subtotal - discountSum));
|
||||
|
||||
let tipPercentages = $derived.by(() => {
|
||||
if (netTotal <= 0) return [];
|
||||
return [
|
||||
{ pct: 10, amount: Math.round(netTotal * 0.1 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(netTotal * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(netTotal * 0.2 * 100) / 100 }
|
||||
];
|
||||
});
|
||||
|
||||
let tipMultiplier = $derived(
|
||||
selectedTipPercent !== null
|
||||
? 1 + selectedTipPercent / 100
|
||||
: customTipAmount && parseFloat(customTipAmount) > 0
|
||||
? 1 + parseFloat(customTipAmount) / subtotal
|
||||
? 1 + parseFloat(customTipAmount) / netTotal
|
||||
: 1
|
||||
);
|
||||
|
||||
let totalWithTip = $derived(tipEnabled ? subtotal * tipMultiplier : subtotal);
|
||||
let totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal);
|
||||
let tipDisplay = $derived(
|
||||
selectedTipPercent !== null
|
||||
? `${selectedTipPercent}%`
|
||||
@@ -153,7 +155,7 @@
|
||||
: ''
|
||||
);
|
||||
|
||||
let totalDue = $derived(tipEnabled ? totalWithTip : subtotal);
|
||||
let totalDue = $derived(tipEnabled ? totalWithTip : netTotal);
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
@@ -469,9 +471,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
||||
{#if booking.discounts && booking.discounts.length > 0}
|
||||
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50/40 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-fuchsia-800 flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4 text-fuchsia-600" 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>
|
||||
<span class="rounded-full bg-fuchsia-100 px-2 py-0.5 text-xs font-semibold text-fuchsia-700">
|
||||
{((discountSum / subtotal) * 100).toFixed(0)}% Off Total
|
||||
</span>
|
||||
</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-500"></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-fuchsia-600">-{formatCurrency(d.discount_amount)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between items-center rounded-md border border-gray-200 bg-white p-4">
|
||||
<span class="text-base font-semibold text-gray-700">Total</span>
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(subtotal)}</span>
|
||||
<div class="flex items-baseline gap-2.5">
|
||||
{#if discountSum > 0.01}
|
||||
<span class="text-sm font-medium text-gray-400 line-through">{formatCurrency(subtotal)}</span>
|
||||
{/if}
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tipEnabled}
|
||||
|
||||
Reference in New Issue
Block a user