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
@@ -720,7 +720,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-[70] 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">
@@ -7,7 +7,7 @@
import { Input } from '$lib/components/ui/input';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import type { Booking } from '$lib/types/booking';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
interface Props {
open: boolean;
@@ -244,11 +244,30 @@
return method.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
}
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 formatPaymentMethod(payment.payment_method);
}
</script>
<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">
@@ -417,26 +436,48 @@
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600"
>{selectedBooking.amount_paid > selectedBooking.total_amount
? 'Pre-tip Subtotal'
: '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>
{#if selectedBooking.amount_due > 0}
{#if 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}
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
</span>
<span class="text-lg font-bold text-red-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>
{/if}
@@ -454,9 +495,9 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium"
>{formatPaymentMethod(payment.payment_method)}</span
>
<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
{payment.status === 'completed'
@@ -469,10 +510,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.is_vat_applicable}
@@ -492,7 +537,7 @@
</div>
</div>
<div class="text-right font-semibold">
£{payment.amount.toFixed(2)}
{payment.payment_method === 'discount' ? '-' : ''}£{payment.amount.toFixed(2)}
</div>
</div>
</div>
@@ -585,7 +630,7 @@
{/if}
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
<Modal.Content class="max-w-[calc(100%-2rem)]">
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
<Modal.Header>
<Modal.Title>Cancel Booking</Modal.Title>
<Modal.Description>
@@ -623,7 +668,7 @@
}
}}
>
<Modal.Content class="max-w-[calc(100%-2rem)]">
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
<Modal.Header>
<Modal.Title>Leave a Tip</Modal.Title>
<Modal.Description>Show your appreciation for great service</Modal.Description>
@@ -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">
@@ -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}
@@ -382,7 +382,7 @@
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Content class="max-h-[90vh] max-w-md overflow-y-auto">
<Dialog.Content class="!z-[70] max-h-[90vh] max-w-md overflow-y-auto">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
{#if booking.id}
@@ -20,7 +20,7 @@
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<Dialog.Overlay class={typeof className === 'string' && /(!?z-\[[^\]]+\]|!?z-\d+)/.test(className) ? className.match(/(!?z-\[[^\]]+\]|!?z-\d+)/)?.[0] : ''} />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
+2
View File
@@ -118,6 +118,7 @@ export interface Booking {
user?: BookingUser;
services?: BookingService[];
payments?: Payment[];
discounts?: BookingDiscount[];
total_amount: number;
amount_paid: number;
amount_due: number;
@@ -167,6 +168,7 @@ export interface BookingDiscount {
user_id: string;
discount_source: 'loyalty' | 'campaign';
source_id?: string;
campaign_name?: string;
campaign_type?: CampaignType;
milestone_type?: MilestoneType;
discount_percent: number;