feat: caret preservation and Luhn validation in card inputs

- Added generic formatAndPreserveCursor() helper on frontend to track
  and restore selection caret position during dynamic input sanitization
- Applied to all card inputs, gift card code inputs, and expiry inputs
- Added Luhn validation (isValidLuhn) for saved cards and gift cards
- Rebuilt payments test DB and got 100% green tests
This commit is contained in:
2026-06-05 21:05:36 +01:00
parent fd41ca2920
commit 0d4a74bd4a
15 changed files with 3039 additions and 51 deletions
@@ -51,6 +51,43 @@
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
function formatAndPreserveCursor(
input: HTMLInputElement,
formatter: (val: string) => string,
charRegex: RegExp = /\d/
): string {
const rawValue = input.value;
const oldSelectionStart = input.selectionStart || 0;
let charsBeforeCursor = 0;
for (let i = 0; i < oldSelectionStart; i++) {
if (charRegex.test(rawValue[i])) {
charsBeforeCursor++;
}
}
const formatted = formatter(rawValue);
input.value = formatted;
let newSelectionStart = 0;
let charsFound = 0;
for (let i = 0; i < formatted.length; i++) {
if (charsFound === charsBeforeCursor) {
break;
}
if (charRegex.test(formatted[i])) {
charsFound++;
}
newSelectionStart++;
}
requestAnimationFrame(() => {
input.setSelectionRange(newSelectionStart, newSelectionStart);
});
return formatted;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
@@ -59,7 +96,8 @@
function handleCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
newCardNumber = formatCardNumber(input.value);
const formatted = formatAndPreserveCursor(input, formatCardNumber);
newCardNumber = formatted;
}
function formatExpiryDate(value: string): string {
@@ -72,12 +110,14 @@
function handleExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
newCardExpiry = formatExpiryDate(input.value);
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
newCardExpiry = formatted;
}
function handleCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
newCardCVC = input.value.replace(/\D/g, '').substring(0, 4);
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
newCardCVC = formatted;
}
function parseExpiryParts(value: string): { month: number; year: number } | null {
@@ -101,8 +141,24 @@
let hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
let cardFormValid = $derived(
newCardNumber.replace(/\s/g, '').length >= 13 &&
isValidLuhn(newCardNumber) &&
expiryParts !== null &&
newCardCVC.length >= 3 &&
!isExpiryInPast
@@ -118,8 +174,8 @@
!cardSelected
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
? 'Please select a card'
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
? 'Card number too short'
: !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: hasInvalidMonth
? 'Invalid expiry month'
: isExpiryInPast