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