Payment card input
Card number, expiry date and security code laid out as they are typed, with the card network named and the number's checksum checked.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add payment-card-inputpnpm dlx ermineui add payment-card-inputyarn dlx ermineui add payment-card-inputbunx ermineui add payment-card-inputThat copies 3 files into src/components/ui/payment-card-input/, and the 4 components it is built from. Files you already have are kept.
Use
import { PaymentCardInput, cardBrand, emptyCard, expiryState, passesLuhn } from '@/components/ui/payment-card-input'<script setup lang="ts">
import { PaymentCardInput } from '@/components/ui/payment-card-input'
</script>
<template>
<PaymentCardInput />
</template>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.
What gets copied
| File | Where it goes |
|---|---|
PaymentCardInput.vue | src/components/ui/payment-card-input/PaymentCardInput.vue |
card.ts | src/components/ui/payment-card-input/card.ts |
index.ts | src/components/ui/payment-card-input/index.ts |
It is built from: Field, Input, Input group, Masked input. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
PaymentCardInput.vue 100 lines
<script setup lang="ts">
import { computed } from 'vue'
import { Field, required, useValidation } from '../field'
import { Input } from '../input'
import { InputGroup } from '../input-group'
import { MaskedInput, masks } from '../masked-input'
import { cardBrand, expiryState, passesLuhn, type PaymentCard } from './card'
// These fields are for prototypes and for payment providers that take card details from you. Where your
// provider has its own card fields (Stripe Elements, Adyen and the like), use those instead: card numbers
// then never pass through your pages or your server, which is what keeps you out of most of PCI DSS.
const props = defineProps<{
/** Leave out the name, when it is asked for somewhere else. */
hideName?: boolean
}>()
/** The card. Start from emptyCard() in card.ts. */
const model = defineModel<PaymentCard>({ required: true })
const brand = computed(() => cardBrand(model.value.number))
const amex = computed(() => brand.value === 'American Express')
const form = useValidation(() => model.value, {
name: [(value) => !props.hideName && required('Enter the name on the card.')(value)],
number: [required('Enter the card number.'), (value) => !passesLuhn(value) && 'Check the card number: a digit is missing or mistyped.'],
expiry: [
required('Enter the expiry date.'),
(value) => expiryState(value) === 'malformed' && 'Enter the expiry date as month and year, such as 09/27.',
(value) => expiryState(value) === 'expired' && 'This card has expired.',
],
cvc: [required('Enter the security code.'), (value) => value.length < (amex.value ? 4 : 3) && `The security code has ${amex.value ? 4 : 3} digits.`],
})
function set(key: keyof PaymentCard, value: string | number | undefined) {
model.value = { ...model.value, [key]: value === undefined ? '' : String(value) }
}
defineExpose({
/** Check every field and show what is wrong. True when the card can be sent. Call it from your form's submit. */
validate: () => form.validate(),
/** Clear the messages without changing the answers. */
reset: form.reset,
})
</script>
<template>
<div class="er-payment-card-input">
<Field v-if="!hideName" v-slot="{ control }" label="Name on card" required :error="form.errors.name">
<Input v-bind="control" :model-value="model.name" name="cc-name" autocomplete="cc-name" @update:model-value="set('name', $event)" />
</Field>
<Field v-slot="{ control }" label="Card number" required :error="form.errors.number">
<InputGroup>
<MaskedInput
v-bind="control"
:model-value="model.number"
:mask="amex ? masks.amex : masks.card"
name="cc-number"
autocomplete="cc-number"
placeholder="1234 1234 1234 1234"
@update:model-value="set('number', $event)"
@blur="model.number && form.touch('number')"
/>
<!-- Which network the number belongs to, once enough of it is typed to tell. -->
<template v-if="brand" #suffix><span class="er-payment-card-input-brand">{{ brand }}</span></template>
</InputGroup>
</Field>
<div class="er-payment-card-input-row">
<Field v-slot="{ control }" label="Expiry date" required :error="form.errors.expiry">
<MaskedInput v-bind="control" :model-value="model.expiry" :mask="masks.expiry" name="cc-exp" autocomplete="cc-exp" placeholder="MM/YY" @update:model-value="set('expiry', $event)" />
</Field>
<Field v-slot="{ control }" label="Security code" required :error="form.errors.cvc" :hint="amex ? 'Four digits on the front of the card.' : undefined">
<MaskedInput v-bind="control" :model-value="model.cvc" :mask="amex ? '####' : '###'" name="cc-csc" autocomplete="cc-csc" placeholder="CVC" @update:model-value="set('cvc', $event)" />
</Field>
</div>
</div>
</template>
<style>
@layer components {
.er-payment-card-input {
display: grid;
gap: 1rem;
min-width: 0;
}
.er-payment-card-input-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
align-items: start;
gap: 1rem;
}
.er-payment-card-input-brand {
font-size: 0.75rem;
font-weight: var(--er-weight-medium);
}
}
</style>card.ts 46 lines
/** What is typed into the card fields. The number and the dates are digits only, without spaces or slashes. */
export type PaymentCard = {
name: string
number: string
/** Month then year, four digits: 0927. */
expiry: string
cvc: string
}
export const emptyCard = (): PaymentCard => ({ name: '', number: '', expiry: '', cvc: '' })
export type CardBrand = 'Visa' | 'Mastercard' | 'American Express' | 'Discover'
/** Which network a card number belongs to, from how it starts. Undefined until enough has been typed to tell, or when it is none of these. */
export function cardBrand(number: string): CardBrand | undefined {
if (/^4/.test(number)) return 'Visa'
if (/^3[47]/.test(number)) return 'American Express'
if (/^(5[1-5]|2(2[2-9]|[3-6]|7[01]|720))/.test(number)) return 'Mastercard'
if (/^(6011|65|64[4-9])/.test(number)) return 'Discover'
return undefined
}
/** The checksum every real card number passes. It catches a mistyped or swapped digit; it says nothing about whether the card exists. */
export function passesLuhn(number: string): boolean {
if (!/^\d{12,19}$/.test(number)) return false
let sum = 0
for (let index = 0; index < number.length; index++) {
let digit = Number(number[number.length - 1 - index])
if (index % 2 === 1) {
digit *= 2
if (digit > 9) digit -= 9
}
sum += digit
}
return sum % 10 === 0
}
/** Whether "MMYY" is a real month and the card is still good on `today`. A card runs to the end of its month. */
export function expiryState(expiry: string, today = new Date()): 'ok' | 'malformed' | 'expired' {
if (!/^(0[1-9]|1[0-2])\d\d$/.test(expiry)) return 'malformed'
const month = Number(expiry.slice(0, 2))
const year = 2000 + Number(expiry.slice(2))
const current = today.getFullYear() * 12 + today.getMonth() + 1
return year * 12 + month < current ? 'expired' : 'ok'
}index.ts 4 lines
/** Payment card input — card number, expiry date and security code laid out as they are typed, with the card network named and the number's checksum checked. */
export { default as PaymentCardInput } from './PaymentCardInput.vue'
export { cardBrand, emptyCard, expiryState, passesLuhn, type CardBrand, type PaymentCard } from './card'