Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add form validation for checkout page #10

Merged
merged 5 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/checkout.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ <h4 class="mb-1">Payment</h4>
</div>
<div class="col-md-3">
<label class="form-label" for="cc-expiration">Expiration</label>
<input class="form-control" id="cc-expiration" placeholder="" required="" type="text">
<input class="form-control" id="cc-expiration" required="" type="month">
<div class="invalid-feedback">
Expiration date required
</div>
Expand Down
71 changes: 64 additions & 7 deletions src/js/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Cookies from 'js-cookie'
import { products } from './products.ts'
import type { Basket } from './typing'
import { DialogCloseResult, cookieOptions, readBasketCookie } from './shared'
import { isExpirationDateValid, isSecurityCodeValid, isValid } from './creditCard.ts'

const creditCardShown = false
const basket: Basket = readBasketCookie()
Expand All @@ -18,13 +19,6 @@ function init() {
}

function resetListeners() {
document.querySelector('#paycreditcard')?.addEventListener('click', (e) => {
showSweetAlert('Are you sure?', e, (result) => {
if (result === DialogCloseResult.Yes)
showCreditCardPage(e)
}).then()
})

document.querySelector('#clearbasket')?.addEventListener('click', (e) => {
showSweetAlert('All items in the basket will be removed. Continue?', e, (result) => {
if (result === DialogCloseResult.Yes) {
Expand All @@ -34,6 +28,69 @@ function resetListeners() {
}
}).then()
})
const form = document.querySelector('.needs-validation') as HTMLFormElement
document.querySelector('#paycreditcard')?.addEventListener('click', e => onCheckoutButtonClicked(e, form))
initiateCreditCardFormDataBinding(form)
}

function onCheckoutButtonClicked(e: Event, form: HTMLFormElement) {
e.preventDefault()
e.stopPropagation()

if (!isCheckoutFormValidated(form))
return

showSweetAlert('Are you sure?', e, (result) => {
if (result === DialogCloseResult.Yes)
showCreditCardPage(e)
}).then()
}

function isCheckoutFormValidated(form: HTMLFormElement): boolean {
const isValidated = form.checkValidity()
if (!isValidated)
form.classList.add('was-validated')
return isValidated
}

function initiateCreditCardFormDataBinding(parentForm: HTMLFormElement) {
const cardNumberInput = parentForm['cc-number'] as HTMLInputElement
const cardNumberInputInvalidFeedback = cardNumberInput.nextElementSibling as HTMLDivElement

const cardExpirationInput = parentForm['cc-expiration'] as HTMLInputElement
const cardExpirationInputInvalidFeedback = cardExpirationInput.nextElementSibling as HTMLDivElement

const cardCVCInput = parentForm['cc-cvv'] as HTMLInputElement
const cardCVCInputInvalidFeedback = cardCVCInput.nextElementSibling as HTMLDivElement

const allInputBoxEvents = ['change', 'keyup', 'unfocus']
allInputBoxEvents.forEach((event) => {
cardNumberInput.addEventListener(event, () => {
cardNumberInput.setCustomValidity(
isValid(cardNumberInput.value) ? '' : 'Invalid card number',
)
cardNumberInputInvalidFeedback.textContent = cardNumberInput.value.trim() === ''
? 'Credit card number is required'
: cardNumberInput.validationMessage
})
cardExpirationInput.addEventListener(event, () => {
const [year, month] = cardExpirationInput.value.split('-')
cardExpirationInput.setCustomValidity(
isExpirationDateValid(month, year) ? '' : 'Invalid expiration date',
)
cardExpirationInputInvalidFeedback.textContent = cardExpirationInput.value.trim() === ''
? 'Expiration date is required'
: cardExpirationInput.validationMessage
})
cardCVCInput.addEventListener(event, () => {
cardCVCInput.setCustomValidity(
isSecurityCodeValid(cardNumberInput.value, cardCVCInput.value) ? '' : 'Invalid security code',
)
cardCVCInputInvalidFeedback.textContent = cardCVCInput.value.trim() === ''
? 'Security code is required'
: cardCVCInput.validationMessage
})
})
}

function showCreditCardPage(e: Event) {
Expand Down
208 changes: 208 additions & 0 deletions src/js/creditCard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
export interface CreditCard {
name: string
bins: RegExp
codeLength: number
}

type CreditCardProducer = string

const cards: ReadonlyArray<CreditCard> = [
{
name: 'Banescard',
bins: /^(603182)[0-9]{10,12}/,
codeLength: 3,
},
{
name: 'Maxxvan',
bins: /^(603182)[0-9]{10,12}/,
codeLength: 3,
},
{
name: 'Cabal',
bins: /^(604324|604330|604337|604203|604338)[0-9]{10,12}/,
codeLength: 3,
},
{
name: 'GoodCard',
bins: /^(606387|605680|605674|603574)[0-9]{10,12}/,
codeLength: 3,
},
{
name: 'Elo',
bins: /^(401178|401179|431274|438935|451416|457393|457631|457632|504175|627780|636297|636368|(506699|5067[0-6]\d|50677[0-8])|(50900\d|5090[1-9]\d|509[1-9]\d{2})|65003[1-3]|(65003[5-9]|65004\d|65005[0-1])|(65040[5-9]|6504[1-3]\d)|(65048[5-9]|65049\d|6505[0-2]\d|65053[0-8])|(65054[1-9]|6505[5-8]\d|65059[0-8])|(65070\d|65071[0-8])|65072[0-7]|(6509[0-9])|(65165[2-9]|6516[6-7]\d)|(65500\d|65501\d)|(65502[1-9]|6550[3-4]\d|65505[0-8]))[0-9]{10,12}/,
codeLength: 3,
},
{
name: 'Diners',
bins: /^3(?:0[0-5]|[68][0-9])[0-9]{11}$/,
codeLength: 3,
},
{
name: 'Discover',
bins: /^6(?:011|5[0-9]{2}|4[4-9][0-9]{1}|(22(12[6-9]|1[3-9][0-9]|[2-8][0-9]{2}|9[01][0-9]|92[0-5]$)[0-9]{10}$))[0-9]{12}$/,
codeLength: 4,
},
{
name: 'Amex',
bins: /^3[47][0-9]{13}$/,
codeLength: 4,
},
{
name: 'Aura',
bins: /^50[0-9]{14,17}$/,
codeLength: 3,
},
{
name: 'Mastercard',
bins: /^(603136|603689|608619|606200|603326|605919|608783|607998|603690|604891|603600|603134|608718|603680|608710|604998)|(5[1-5][0-9]{14}|2221[0-9]{12}|222[2-9][0-9]{12}|22[3-9][0-9]{13}|2[3-6][0-9]{14}|27[01][0-9]{13}|2720[0-9]{12})$/,
codeLength: 3,
},
{
name: 'Visa',
bins: /^4[0-9]{12}(?:[0-9]{3})?$/,
codeLength: 3,
},
{
name: 'Hipercard',
bins: /^(38[0-9]{17}|60[0-9]{14})$/,
codeLength: 3,
},
{
name: 'JCB',
bins: /^(?:2131|1800|35\d{3})\d{11}$/,
codeLength: 3,
},
] satisfies ReadonlyArray<CreditCard>

const MILLENNIUM = 1000
const DEFAULT_CODE_LENGTH = 3

export function getCreditCardProducerNameByNumber(cardNumber: string) {
return findCreditCardObjectByNumber(cardNumber)?.name || 'Credit card is invalid!'
}

export function isSecurityCodeValid(creditCardNumber: string, securityCode: string) {
const numberLength = getCreditCardCodeLengthByNumber(creditCardNumber)
return new RegExp(`^[0-9]{${numberLength}}$`).test(securityCode)
}

export function isExpirationDateValid(month: string, year: string) {
const monthNumber = Number.parseInt(month)
const yearNumber = Number.parseInt(year)
return (
isValidMonth(monthNumber)
&& isValidYear(yearNumber)
&& isFutureOrPresentDate(monthNumber, yearNumber)
)
}

export function isValid(cardNumber: string, producerNames?: CreditCardProducer[]) {
const rawNumber = removeNonNumbersCharacters(cardNumber)

if (hasSomeInvalidDigit(cardNumber) || !hasCorrectLength(rawNumber))
return false

const sum = sumNumber(rawNumber)

return checkSum(sum) && ((producerNames && validateCardsWhenRequired(cardNumber, producerNames)) ?? true)
}

function validateCardsWhenRequired(cardNumber: string, passedCardProducerNames?: string[]) {
return !cards || !cards.length || validateCards(cardNumber, passedCardProducerNames)
}

function validateCards(cardNumber: string, passedCardProducerNames?: string[]) {
return (
areCardsSupported(passedCardProducerNames)
&& passedCardProducerNames
?.map(c => c.toLowerCase())
.includes(getCreditCardProducerNameByNumber(cardNumber).toLowerCase())
)
}

function hasCorrectLength(cardNumber: string) {
return cardNumber.length <= 19
}

function removeNonNumbersCharacters(cardNumber: string) {
return cardNumber.replace(/\D/g, '')
}

function hasSomeInvalidDigit(cardNumber: string) {
const invalidDigits = /[^0-9- ]/
return invalidDigits.test(cardNumber)
}

function checkSum(sum: number) {
return sum > 0 && sum % 10 === 0
}

function areCardsSupported(passedCardProducerNames?: string[]) {
const supportedCards = cards.map(c => c.name.toLowerCase())
return passedCardProducerNames?.every(c => supportedCards.includes(c.toLowerCase()))
}

function findCreditCardObjectByNumber(cardNumber: string) {
const numberOnly = cardNumber.replace(/\D/g, '')
return cards.find(card => card.bins.test(numberOnly) && card)
}

function getCreditCardCodeLengthByNumber(cardNumber: string) {
return findCreditCardObjectByNumber(cardNumber)?.codeLength || DEFAULT_CODE_LENGTH
}

function isValidMonth(month: number) {
return !Number.isNaN(month) && month >= 1 && month <= 12
}

function isValidYear(year: number) {
return !Number.isNaN(year) && isValidFullYear(formatFullYear(year))
}

function formatFullYear(year: number) {
const stringYear = year.toString()
if (stringYear.length === 2)
return dateRange(year)

return stringYear.length === 4 ? year : 0
}

function dateRange(increaseYear: number = 0) {
const year = increaseYear
const today = new Date()
return Math.floor(today.getFullYear() / MILLENNIUM) * MILLENNIUM + year
}

function isValidFullYear(year: number) {
return year >= dateRange() && year <= dateRange(MILLENNIUM)
}

function isFutureOrPresentDate(month: number, year: number) {
const fullYear = formatFullYear(year)
const currentDate = new Date()
const expirationDate = new Date()

currentDate.setFullYear(currentDate.getFullYear(), currentDate.getMonth(), 1)
expirationDate.setFullYear(fullYear, month - 1, 1)

return currentDate <= expirationDate
}

function sumNumber(cardNumber: string) {
const computed = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9]
let sum = 0
let digit = 0
let i = cardNumber.length
let even = true

while (i--) {
digit = Number.parseInt(cardNumber[i])
even = !even
if (even)
sum += computed[digit]
else
sum += digit
}

return sum
}
Loading