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:
2026-08-22 00:34:51 +01:00
parent 80c5c6ce43
commit bb51c1512f
@@ -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>