feat: add amount input fields for card and saved card payments
PaymentModal now allows partial payments for card and saved card: - Added amount input fields with validation (max 2 decimal places) - Default to full amount when left blank - Validate amount is > 0 and <= totalDue - Button label updates to show charge amount - Validation errors shown inline UserPaymentModal already had partial payment functionality, so no changes needed there. This allows customers to pay part of the balance now and pay the rest later online.
This commit is contained in:
@@ -271,6 +271,12 @@
|
|||||||
let selectedTipPercent = $state<number | null>(null);
|
let selectedTipPercent = $state<number | null>(null);
|
||||||
let customTipAmount = $state<string>('');
|
let customTipAmount = $state<string>('');
|
||||||
|
|
||||||
|
// Partial payment amounts for card and saved card (in pounds, user enters)
|
||||||
|
let cardPaymentAmount = $state<string>('');
|
||||||
|
let lastValidCardAmount = $state<string>('');
|
||||||
|
let savedCardPaymentAmount = $state<string>('');
|
||||||
|
let lastValidSavedCardAmount = $state<string>('');
|
||||||
|
|
||||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function selectTipPercent(percent: number) {
|
function selectTipPercent(percent: number) {
|
||||||
@@ -289,6 +295,30 @@
|
|||||||
tipEnabled = customTipAmount !== '' && parseFloat(customTipAmount) > 0;
|
tipEnabled = customTipAmount !== '' && parseFloat(customTipAmount) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCardAmountInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const sanitized = sanitizeDecimalInput(input.value);
|
||||||
|
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
|
||||||
|
cardPaymentAmount = sanitized;
|
||||||
|
lastValidCardAmount = sanitized;
|
||||||
|
} else {
|
||||||
|
cardPaymentAmount = lastValidCardAmount;
|
||||||
|
input.value = lastValidCardAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSavedCardAmountInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const sanitized = sanitizeDecimalInput(input.value);
|
||||||
|
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
|
||||||
|
savedCardPaymentAmount = sanitized;
|
||||||
|
lastValidSavedCardAmount = sanitized;
|
||||||
|
} else {
|
||||||
|
savedCardPaymentAmount = lastValidSavedCardAmount;
|
||||||
|
input.value = lastValidSavedCardAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getServicePrice(service: BookingService): number {
|
function getServicePrice(service: BookingService): number {
|
||||||
const override = serviceOverrides[service.service_id];
|
const override = serviceOverrides[service.service_id];
|
||||||
if (override && override.price !== '') {
|
if (override && override.price !== '') {
|
||||||
@@ -367,6 +397,56 @@
|
|||||||
// disabled in that state; the handlers also guard defensively.
|
// disabled in that state; the handlers also guard defensively.
|
||||||
const nothingToCharge = $derived(totalDue <= 0);
|
const nothingToCharge = $derived(totalDue <= 0);
|
||||||
|
|
||||||
|
// Card payment amount validation
|
||||||
|
const cardAmountNum = $derived(cardPaymentAmount === '' ? totalDue : parseFloat(cardPaymentAmount));
|
||||||
|
const cardAmountValid = $derived(
|
||||||
|
cardPaymentAmount === '' ||
|
||||||
|
(cardPaymentAmount !== '' &&
|
||||||
|
!isNaN(cardAmountNum) &&
|
||||||
|
cardAmountNum > 0 &&
|
||||||
|
cardAmountNum <= totalDue &&
|
||||||
|
/^\d+(\.\d{0,2})?$/.test(cardPaymentAmount))
|
||||||
|
);
|
||||||
|
const cardValidationError = $derived(
|
||||||
|
cardPaymentAmount !== '' && !cardAmountValid
|
||||||
|
? cardPaymentAmount === ''
|
||||||
|
? ''
|
||||||
|
: !/^\d+(\.\d{0,2})?$/.test(cardPaymentAmount)
|
||||||
|
? 'Enter a valid amount (max 2 decimal places)'
|
||||||
|
: cardAmountNum <= 0
|
||||||
|
? 'Amount must be greater than 0'
|
||||||
|
: cardAmountNum > totalDue
|
||||||
|
? `Amount cannot exceed ${formatCurrency(totalDue)}`
|
||||||
|
: ''
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Saved card payment amount validation
|
||||||
|
const savedCardAmountNum = $derived(
|
||||||
|
savedCardPaymentAmount === '' ? totalDue : parseFloat(savedCardPaymentAmount)
|
||||||
|
);
|
||||||
|
const savedCardAmountValid = $derived(
|
||||||
|
savedCardPaymentAmount === '' ||
|
||||||
|
(savedCardPaymentAmount !== '' &&
|
||||||
|
!isNaN(savedCardAmountNum) &&
|
||||||
|
savedCardAmountNum > 0 &&
|
||||||
|
savedCardAmountNum <= totalDue &&
|
||||||
|
/^\d+(\.\d{0,2})?$/.test(savedCardPaymentAmount))
|
||||||
|
);
|
||||||
|
const savedCardValidationError = $derived(
|
||||||
|
savedCardPaymentAmount !== '' && !savedCardAmountValid
|
||||||
|
? savedCardPaymentAmount === ''
|
||||||
|
? ''
|
||||||
|
: !/^\d+(\.\d{0,2})?$/.test(savedCardPaymentAmount)
|
||||||
|
? 'Enter a valid amount (max 2 decimal places)'
|
||||||
|
: savedCardAmountNum <= 0
|
||||||
|
? 'Amount must be greater than 0'
|
||||||
|
: savedCardAmountNum > totalDue
|
||||||
|
? `Amount cannot exceed ${formatCurrency(totalDue)}`
|
||||||
|
: ''
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
async function applyLoyaltyRedemption(): Promise<void> {
|
async function applyLoyaltyRedemption(): Promise<void> {
|
||||||
if (!useLoyalty) return;
|
if (!useLoyalty) return;
|
||||||
const res = await apiFetch(`/api/admin/bookings/${booking.id}/apply-redemption`, {
|
const res = await apiFetch(`/api/admin/bookings/${booking.id}/apply-redemption`, {
|
||||||
@@ -381,12 +461,16 @@
|
|||||||
|
|
||||||
async function handleCardPayment() {
|
async function handleCardPayment() {
|
||||||
if (isProcessingPaymentSync) return;
|
if (isProcessingPaymentSync) return;
|
||||||
const finalAmount = totalDue;
|
const finalAmount = cardAmountNum;
|
||||||
|
|
||||||
if (isNaN(finalAmount) || finalAmount <= 0) {
|
if (isNaN(finalAmount) || finalAmount <= 0) {
|
||||||
toast.error('Please enter a valid amount');
|
toast.error('Please enter a valid amount');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (finalAmount > totalDue) {
|
||||||
|
toast.error(`Amount cannot exceed ${formatCurrency(totalDue)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The loyalty redemption is applied on top of totalDue; guard against
|
// The loyalty redemption is applied on top of totalDue; guard against
|
||||||
// the effective charge being zero or negative.
|
// the effective charge being zero or negative.
|
||||||
if (Math.round(finalAmount * 100) - loyaltyDiscount <= 0) {
|
if (Math.round(finalAmount * 100) - loyaltyDiscount <= 0) {
|
||||||
@@ -837,7 +921,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chargeAmount = Math.round(totalDue * 100) - loyaltyDiscount;
|
const chargeAmount = Math.round(savedCardAmountNum * 100) - loyaltyDiscount;
|
||||||
|
|
||||||
|
if (isNaN(savedCardAmountNum) || savedCardAmountNum <= 0) {
|
||||||
|
toast.error('Please enter a valid amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (savedCardAmountNum > totalDue) {
|
||||||
|
toast.error(`Amount cannot exceed ${formatCurrency(totalDue)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// totalDue can be £0 (fully discounted) and the loyalty discount is
|
// totalDue can be £0 (fully discounted) and the loyalty discount is
|
||||||
// applied on top — the effective charge could otherwise be 0 or negative.
|
// applied on top — the effective charge could otherwise be 0 or negative.
|
||||||
@@ -1446,10 +1539,40 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="card-amount" class="text-sm font-medium text-gray-700">
|
||||||
|
Amount to Charge
|
||||||
|
</label>
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
|
||||||
|
<Input
|
||||||
|
id="card-amount"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
step="0.01"
|
||||||
|
placeholder={totalDue.toFixed(2)}
|
||||||
|
value={cardPaymentAmount}
|
||||||
|
oninput={handleCardAmountInput}
|
||||||
|
class="pl-7"
|
||||||
|
disabled={status !== 'selecting'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if cardValidationError}
|
||||||
|
<p class="mt-1 text-sm text-red-600">{cardValidationError}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Leave blank to charge full amount ({formatCurrency(totalDue)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
||||||
<Button onclick={handleCardPayment} class="min-h-11 flex-1" disabled={nothingToCharge}>
|
<Button
|
||||||
Charge Card
|
onclick={handleCardPayment}
|
||||||
|
class="min-h-11 flex-1"
|
||||||
|
disabled={nothingToCharge || !cardAmountValid}
|
||||||
|
>
|
||||||
|
{cardPaymentAmount === '' ? 'Charge Card' : `Charge ${formatCurrency(cardAmountNum)}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1710,14 +1833,46 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if selectedSavedCardId}
|
||||||
|
<div>
|
||||||
|
<label for="saved-card-amount" class="text-sm font-medium text-gray-700">
|
||||||
|
Amount to Charge
|
||||||
|
</label>
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
|
||||||
|
<Input
|
||||||
|
id="saved-card-amount"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
step="0.01"
|
||||||
|
placeholder={totalDue.toFixed(2)}
|
||||||
|
value={savedCardPaymentAmount}
|
||||||
|
oninput={handleSavedCardAmountInput}
|
||||||
|
class="pl-7"
|
||||||
|
disabled={status !== 'saved-card-selecting'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if savedCardValidationError}
|
||||||
|
<p class="mt-1 text-sm text-red-600">{savedCardValidationError}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Leave blank to charge full amount ({formatCurrency(totalDue)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSavedCardPayment}
|
onclick={handleSavedCardPayment}
|
||||||
class="min-h-11 flex-1"
|
class="min-h-11 flex-1"
|
||||||
disabled={!selectedSavedCardId || nothingToCharge}
|
disabled={
|
||||||
|
!selectedSavedCardId || nothingToCharge || !savedCardAmountValid
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Charge Saved Card
|
{savedCardPaymentAmount === ''
|
||||||
|
? 'Charge Saved Card'
|
||||||
|
: `Charge ${formatCurrency(savedCardAmountNum)}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user