benjamincharity
7/12/2016 - 2:42 PM

Files for custom credit card display

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 */