Files for custom credit card display
<div
class="cc"
id="ccContainer"
data-ng-class="{ 'has--three-children': expiryIsValid }"
data-ng-show="isVisible"
>
<div
class="cc-number cc-number--{{ type }}"
data-ng-class="{ 'is--invalid': ccError }"
data-ng-style="{'width': width}"
set-class
class-to-set="is--compact"
flag="{{ numberIsValid }}"
ngfx-wobble="ccError"
>
<div class="cc-number__type" data-ng-include="svgUrl(type)" ></div>
<ol
class="cc-number__list cc-number__list--{{ type }}"
data-ng-if="card.number"
data-ng-style="{'width': adjustedWidth}"
bc-disable-animate
>
<li
class="cc__item"
data-ng-class="{ 'is--filled': number.length }"
data-ng-repeat="number in card.number track by $index"
>
<span class="cc__spacer"></span>
{{ number || '8' }}
</li>
</ol>
<ol
class="cc-number__list cc-number__list--{{ type }}"
data-ng-if="!card.number"
data-ng-style="{'width': adjustedWidth}"
bc-disable-animate
>
<li
class="cc__item"
data-ng-class="{ 'is--filled': number.length }"
data-ng-repeat="number in numbersArray track by $index"
>
<span class="cc__spacer"></span>
{{ number || '8' }}
</li>
</ol>
</div>
<div
class="cc-expiry"
set-class
class-to-set="is--visible"
flag="{{ numberIsValid }}"
ngfx-wobble="expiryError"
>
<ol
class="cc-expiry__list"
data-ng-if="card.expiry"
bc-disable-animate
>
<li
class="cc__item"
data-ng-class="{ 'is--filled': number.length }"
data-ng-repeat="number in card.expiry track by $index"
>
<span class="cc__spacer"></span>
{{ number || '8' }}
</li>
</ol>
<ol
class="cc-expiry__list"
data-ng-if="!card.expiry"
bc-disable-animate
>
<li
class="cc__item"
data-ng-class="{ 'is--filled': number.length }"
data-ng-repeat="number in numbersArray track by $index"
>
<span class="cc__spacer"></span>
{{ number || '8' }}
</li>
</ol>
</div>
<div
class="cc-cvc"
set-class
class-to-set="is--visible"
flag="{{ expiryIsValid }}"
>
<ol
class="cc-cvc__list"
data-ng-class="{ 'not--four': numberModel.length < 4 }"
bc-disable-animate
>
<li
class="cc__item"
data-ng-class="{ 'is--filled': number.length, 'cc__item--last': $last }"
data-ng-repeat="number in numbersArray track by $index"
>
<span class="cc__spacer"></span>
{{ number || '8' }}
</li>
</ol>
</div>
<div class="cc__message" data-ng-show="message">
{{ errorMessage || message }}
</div>
</div>
//
//
//
// $CC_DISPLAY
//
//
// @author Benjamin Charity <ben@benjamincharity.com>
//
// @doc
//
// @end
// <div> primary container
.cc {
font-size: 24px;
font-weight: 500;
height: 1.6em;
position: relative;
@include bp(5) {
font-size: 22px;
}
&.has--three-children {
.cc-number {
left: 20px;
}
.cc-expiry {
right: 100px;
}
@include bp(5) {
.cc-number {
left: 10px;
}
.cc-expiry {
right: 90px;
}
}
}
}
// <li>
.cc__item {
@include flex_component();
color: transparent;
min-height: 1em;
position: relative;
pointer-events: none;
text-align: center;
&.is--filled {
color: inherit;
.cc__spacer {
display: none;
}
}
}
// <span> dot when no number exists
.cc__spacer {
@include vendor_prefix(
transform,
translate(-50%, -50%)
);
display: block;
position: absolute;
left: 50%;
top: 50%;
width: .3em;
&::before {
background-color: $color_secondary;
border-radius: 50%;
content: '';
display: block;
padding-top: 100%;
}
}
// light theme
.cc--light {
color: $color_off_white;
.cc__spacer {
&::before {
background-color: $color_off_white;
}
}
}
// dark theme
.cc--dark {
color: $color_secondary;
.cc__spacer {
&::before {
background-color: $color_secondary;
}
}
}
// <div> info and errors
.cc__message {
font-size: 14px;
text-align: center;
position: absolute;
left: 10%;
top: 100%;
width: 80%;
}
/////////////////////////////////////////////
//
// Card Number
//
// <div> container for cc number
.cc-number {
@include vendor_prefix(
backface-visibility,
hidden
);
@include vendor_prefix(
transition-property,
'width, left'
);
@include vendor_prefix(
transition-duration,
'300ms, 300ms'
);
@include vendor_prefix(
transition-timing-function,
$timing_function
);
@include vendor_prefix(
transition-delay,
'440ms, 440ms'
);
height: 1.5em;
overflow: hidden;
position: absolute;
left: 0;
top: 0;
&.is--compact {
@include vendor_prefix(
transition-delay,
'0ms, 0ms'
);
left: 60px;
// scss-lint:disable ImportantRule
width: 108px !important;
// scss-lint:enable ImportantRule
@include bp(5) {
left: 40px;
}
.cc-number__type {
left: -1px;
}
.cc-number__list {
right: 4px;
@include bp(5) {
right: 20px;
}
}
}
&.is--invalid {
color: $color_primary;
}
}
// adjust the visible width for amex
.cc-number--americanexpress {
&.is--compact {
// scss-lint:disable ImportantRule
width: 112px !important;
// scss-lint:enable ImportantRule
}
.cc-number__type {
left: -1px;
}
}
// <div> container for card svg
.cc-number__type {
@include custom_transition();
height: 30px;
position: absolute;
left: 0;
top: 3px;
width: 40px;
z-index: 2;
@include bp(5) {
top: 5px;
width: 30px;
}
svg {
display: block;
margin: 0 auto;
max-height: 100%;
}
}
// <ol>
.cc-number__list {
@include flex_container(horizontal);
@include custom_transition();
position: absolute;
right: 0;
top: 0;
z-index: 1;
.cc__item {
// set up spacing for standard cards
&:nth-of-type(4n+4) {
margin-right: .6em;
@include bp(5) {
margin-right: .5em;
}
}
&:last-of-type {
margin-right: 0;
}
}
}
// spacing for amex
.cc-number__list--americanexpress {
right: 20px;
.cc__item {
&:nth-of-type(4n+4) {
margin-right: 0;
}
&:nth-of-type(4),
&:nth-of-type(10) {
margin-right: .6em;
@include bp(5) {
margin-right: .5em;
}
}
}
}
/////////////////////////////////////////////
//
// Expiry
//
// <div>
.cc-expiry {
@include vendor_prefix(
transition-property,
'-webkit-transform, opacity, right'
);
@include vendor_prefix(
transition-duration,
'200ms, 200ms, 300ms'
);
@include vendor_prefix(
transition-timing-function,
$timing_function
);
@include vendor_prefix(
transition-delay,
'0ms, 0ms, 0ms'
);
@include vendor_prefix(
transform,
scale(.8, .8)
);
margin-left: -36px;
opacity: 0;
position: absolute;
right: 60px;
top: 0;
width: 72px;
@include bp(5) {
right: 40px;
}
&.is--visible {
@include vendor_prefix(
transform,
scale(1, 1)
);
@include vendor_prefix(
transition-delay,
'440ms, 440ms, 0ms'
);
opacity: 1;
}
}
// <ol>
.cc-expiry__list {
@include flex_container(horizontal);
margin: 0 auto;
width: 3em;
.cc__item {
&:nth-of-type(3) {
margin: 0 .2em;
}
}
}
/////////////////////////////////////////////
//
// CVC
//
// <div>
.cc-cvc {
@include custom_transition(all, 100ms);
@include vendor_prefix(
transform,
scale(.8, .8)
);
opacity: 0;
position: absolute;
right: 10px;
top: 0;
@include bp(5) {
right: 10px;
}
&.is--visible {
@include custom_transition(all, 200ms);
@include vendor_prefix(
transform,
scale(1, 1)
);
@include vendor_prefix(
transition-delay,
100ms
);
opacity: 1;
}
}
// <ol>
.cc-cvc__list {
@include flex_container(horizontal);
margin: 0 auto;
width: 2.4em;
&.not--four {
.cc__item--last {
opacity: 0;
}
}
}
/* globals Stripe */
/* eslint-disable no-magic-numbers */
function ccDisplay(
$timeout, $document
) {
'use strict';
// Define the basic credit card types
const types = {
americanexpress: {
neededLength: 15,
format: new Array(15).join('.').split('.'),
},
visa: {
neededLength: 16,
format: new Array(16).join('.').split('.'),
},
mastercard: {
neededLength: 16,
format: new Array(16).join('.').split('.'),
},
jcb: {
neededLength: 16,
format: new Array(16).join('.').split('.'),
},
dinersclub: {
neededLength: 14,
format: new Array(14).join('.').split('.'),
},
unknown: {
neededLength: 16,
format: new Array(16).join('.').split('.'),
},
expiry: {
neededLength: 4,
format: ['', '', '/', '', ''],
},
cvc: {
neededLength: 4,
format: new Array(4).join('.').split('.'),
},
};
const messages = {
card: {
standard: 'Enter your credit card number',
error: 'Please enter a valid card number',
},
expiry: {
standard: 'Enter the expiration date of the card (MM/YY)',
error: 'Must be a valid, future date',
},
cvc: {
standard: 'Enter the CVC number',
error: 'Please enter a valid 3 or 4 digit CVC number',
},
};
return {
restrict: 'E',
templateUrl: 'app/components/cc-display/cc-display.html',
replace: true,
scope: {
numberModel: '=',
card: '=',
neededLength: '=',
cardIsValid: '=',
isVisible: '@',
theme: '@',
errorMessage: '@',
},
link: linkFunction,
};
/**
* Link function
*
* @param {Object} $scope
*/
function linkFunction($scope, $element) {
// Check for visibility on scope and set to true if undefined
if (angular.isUndefined($scope.isVisible)) {
$scope.isVisible = true;
}
// Set the theme
$element.addClass('cc--' + $scope.theme);
// Find the width of the payment display
const width = $document[0].getElementById('ccContainer').offsetWidth;
$scope.width = width + 'px';
$scope.adjustedWidth = (width - 46) + 'px';
// When initialized set the length to default
$scope.neededLength = types.unknown.neededLength;
// Set defaults
$scope.type = 'unknown';
$scope.numberIsValid = false;
$scope.expiryIsValid = false;
$scope.cvcIsValid = false;
$scope.message = messages.card.standard;
// Expose the numbers array to scope with default settings
$scope.numbersArray = formatArray($scope.numberModel, types.unknown.format);
// Expose functions to scope
$scope.svgUrl = buildSvgUrl;
// Watch for number updates and retest for type and validity
$scope.$watch('numberModel', function(newValue, oldValue) {
numberModelUpdated(newValue, oldValue);
});
// Watch for changes in card type
$scope.$watch('type', function(newValue) {
// Update needed length
$scope.neededLength = types[newValue] ?
types[newValue].neededLength : types.unknown.neededLength;
});
// Watch for changes in number validation result
$scope.$watch('numberIsValid', function(newValue) {
if (newValue === true) {
// Save the value
$scope.card.number = $scope.numberModel;
// Clear the model
$scope.numberModel = '';
}
});
// Watch for changes in expiry validation result
$scope.$watch('expiryIsValid', function(newValue) {
if (newValue === true) {
// Save the value
$scope.card.expiry = $scope.numbersArray;
// Clear the model
$scope.numberModel = '';
}
});
// Watch for changes in expiry validation result
$scope.$watch('cvcIsValid', function(newValue) {
if (newValue === true) {
$scope.cardIsValid = true;
} else {
$scope.cardIsValid = false;
}
});
// Expose error message if the parent controller sets one
$scope.$watch('errorMessage', function(newValue) {
if (_.isString(newValue) && newValue.length > 0) {
$scope.message = newValue;
}
});
// Watch for backspaces when no content exists
// Broadcasted from keypad.directive
$scope.$on('KeypadGoBack', function() {
if ($scope.card.number && !$scope.card.expiry) {
// Go back to editing number
const savedNumber = _.clone($scope.card.number);
// Set to invalid before we change the actual number so that the animation can
// finish
$scope.numberIsValid = false;
$timeout(function() {
$scope.numberModel = savedNumber.substring(0, savedNumber.length - 1);
$scope.neededLength = types[$scope.type].neededLength;
$scope.card.number = null;
}, 300);
}
if ($scope.card.expiry && !$scope.card.cvc) {
// Go back to editing expiry
const cleanExpiry = convertArrayToCleanString($scope.card.expiry);
$scope.numberModel = cleanExpiry.substring(0, cleanExpiry.length - 1);
$scope.neededLength = types.expiry.neededLength;
$scope.card.expiry = null;
}
if ($scope.card.cvc) {
$scope.card.cvc = null;
}
});
/**
* Turn array into a string and strip out all non-numeric characters
*
* @param {Array} array
* @return {String} cleanString
*/
function convertArrayToCleanString(array) {
return array.toString().replace(/\D/g, '');
}
/**
* Update card type and validity as the model changes
*
* @param {String} newValue
* @param {String} oldValue
*/
function numberModelUpdated(newValue, oldValue) {
// Return if nothing changed
if (newValue === oldValue) {
return false;
}
// Working on card number
if (!$scope.card.number) {
$scope.message = messages.card.standard;
// Test for card type
const typeFromStripe = Stripe.card.cardType(newValue);
// Set type
$scope.type = typeFromStripe.replace(/\s+/g, '').toLowerCase();
// Test for card validity
const numbersAreValid = Stripe.card.validateCardNumber(newValue);
// If the numbers are a valid card and match the needed length
if (numbersAreValid && newValue.length === types[$scope.type].neededLength) {
$scope.numberIsValid = true;
} else {
$scope.numberIsValid = false;
// Show an error message
$scope.message = messages.card.error;
}
// Shake numbers if fully entered and still not valid
if (!numbersAreValid && newValue.length === types[$scope.type].neededLength) {
$scope.ccError = true;
} else {
$scope.ccError = false;
}
// If a type exists, use it. Otherwise, fall back to `unknown`
const currentType = $scope.type ? types[$scope.type].format : types.unknown.format;
// Format for the new type
$scope.numbersArray = formatArray(newValue, currentType);
}
// Working on expiry
if ($scope.card.number && !$scope.card.expiry) {
$scope.message = messages.expiry.standard;
// Update needed length
$scope.neededLength = types.expiry.neededLength;
// Format the array for the expiry
$scope.numbersArray = formatArray(newValue, types.expiry.format);
const expiryRegex = new RegExp('[0-1]');
const beginsWithZeroOrOne = expiryRegex.test(newValue);
// If the first number isn't a zero or one, prefix the month with a zero
if (newValue.length === 1 && !beginsWithZeroOrOne) {
$scope.numberModel = '0' + newValue;
}
// If the user inputs a month number higher than 12
if (newValue.length === 2 && newValue > 12) {
// Strip the second character
const valueWithoutSecondCharacter = newValue.substring(0, newValue.length - 1);
$scope.numberModel = $scope.numberModel.substring(0, $scope.numberModel.length -
1);
$scope.numbersArray = formatArray(valueWithoutSecondCharacter,
types.expiry.format);
}
// Test for expiry validity if a full date is present
if (newValue.length === 4) {
const month = newValue.substring(0, 2);
const year = newValue.substring(2, 4);
$scope.expiryIsValid = Stripe.card.validateExpiry(month, year);
if (!$scope.expiryIsValid) {
$scope.message = messages.expiry.error;
$scope.expiryError = true;
} else {
$scope.expiryError = false;
}
} else {
$scope.expiryIsValid = false;
$scope.message = messages.expiry.standard;
}
}
// Working on CVC
if ($scope.card.expiry) {
// Update messaging
$scope.message = messages.cvc.standard;
// Update the needed length
$scope.neededLength = types.cvc.neededLength;
// Format
$scope.numbersArray = formatArray(newValue, types.cvc.format);
// Values are always assigned since multiple lengths can be valid
$scope.card.cvc = newValue;
// Validate CVC
$scope.cvcIsValid = Stripe.card.validateCVC(newValue);
}
}
/**
* Build the path to the SVG icon
*
* @param {String} type
* @return {String} path
*/
function buildSvgUrl(type) {
if (!type) {
return 'assets/icons/cards/unknown.svg';
}
return 'assets/icons/cards/' + type + '.svg';
}
/**
* Accepts an array of numbers to the specified format
*
* @param {Array} numbers
* @param {Array} format
* @return {Array} finalArray
*/
function formatArray(numbers, format) {
const array = _.clone(format);
if (!numbers || !numbers.length) {
return array;
}
let skippedSpots = 0;
_.forEach(numbers, function(number, i) {
const index = i + skippedSpots;
const numberStringRegex = new RegExp('^$|[0-9]');
const isNumberOrEmpty = numberStringRegex.test(array[i]);
// Only replacing when it is an empty string or it is a number; this preserves any
// formatting characters
if (isNumberOrEmpty) {
array[index] = number;
} else {
skippedSpots += 1;
array[index + 1] = number;
}
});
return array;
}
}
}
export default ccDisplay;
/* eslint-enable no-magic-numbers */