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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user