hosted-fields/external/hosted-fields.js

'use strict';

var Destructor = require('destructor');
var classListManager = require('classlist');
var iFramer = require('iframer');
var Bus = require('../../bus');
var BraintreeError = require('../../lib/error');
var composeUrl = require('./compose-url');
var constants = require('../shared/constants');
var INTEGRATION_TIMEOUT_MS = require('../../lib/constants').INTEGRATION_TIMEOUT_MS;
var nodeListToArray = require('nodelist-to-array');
var utils = require('braintree-utilities');
var uuid = require('../../lib/uuid');
var findParentTags = require('../shared/find-parent-tags');
var isIos = require('../../lib/is-ios');
var events = constants.events;
var EventEmitter = require('../../lib/event-emitter');
var injectFrame = require('./inject-frame');
var analytics = require('../../lib/analytics');
var whitelistedFields = constants.whitelistedFields;
var VERSION = require('package.version');
var methods = require('../../lib/methods');
var convertMethodsToError = require('../../lib/convert-methods-to-error');

/**
 * @typedef {object} HostedFields~tokenizePayload
 * @property {string} nonce The payment method nonce
 * @property {object} details Additional account details
 * @property {string} details.cardType Type of card, ex: Visa, MasterCard
 * @property {string} details.lastTwo Last two digits of card number
 * @property {string} description A human-readable description
 */

/**
 * @name HostedFields#on
 * @function
 * @param {string} event The name of the event to subscribe to
 * @param {function} handler A callback to handle the event
 * @description Subscribes a handler function to a named event, such as {@link HostedFields#event:fieldEvent|fieldEvent}
 * @example
 * <caption>Listening to a fieldEvent</caption>
 * var hostedFields = require('braintree-web/hosted-fields');
 *
 * hostedFields.create({ ... }, function (err, instance) {
 *   instance.on('fieldEvent', function (event) {
 *     if (event.card != null) {
 *       console.log(event.card.type);
 *     } else {
 *       console.log('Type of card not yet known');
 *     }
 *   });
 * });
 * @returns {void}
 */

/**
 * This event is emitted when activity within one or more inputs has resulted in a change of state, such as
 * a field attaining focus, an input becoming empty, or the user entering enough information for us to guess the type of card.
 * @event HostedFields#fieldEvent
 * @type {object}
 * @example
 * <caption>Listening to a fieldEvent</caption>
 * var hostedFields = require('braintree-web/hosted-fields');
 *
 * hostedFields.create({ ... }, function (err, instance) {
 *   instance.on('fieldEvent', function (event) {
 *     console.log('fieldEvent', event.type', triggered on "', event.target.fieldKey, '" field');
 *
 *     if (event.card != null) {
 *       console.log(event.card.type);
 *     } else {
 *       console.log('Type of card not yet known');
 *     }
 *   });
 * });
 * @property {string} type
 * <table>
 * <tr><th>Value</th><th>Meaning</th></tr>
 * <tr><td><code>focus</code></td><td>The input has gained focus</td></tr>
 * <tr><td><code>blur</code></td><td> The input has lost focus</td></tr>
 * <tr><td><code>fieldStateChange</code></td><td> Some state has changed within an input including: validation, focus, card type detection, etc</td></tr>
 * </table>
 * @property {boolean} isEmpty Whether or not the user has entered a value in the input
 * @property {boolean} isFocused Whether or not the input is currently focused
 * @property {boolean} isPotentiallyValid
 * A determination based on the future validity of the input value.
 * This is helpful when a user is entering a card number and types <code>"41"</code>.
 * While that value is not valid for submission, it is still possible for
 * it to become a fully qualified entry. However, if the user enters <code>"4x"</code>
 * it is clear that the card number can never become valid and isPotentiallyValid will
 * return false.
 * @property {boolean} isValid Whether or not the value of the associated input is <i>fully</i> qualified for submission
 * @property {object} target
 * @property {object} target.container Reference to the container DOM element on your page associated with the current event.
 * @property {string} target.fieldKey
 * The name of the currently associated field. Examples:<br>
 * <code>"number"</code><br>
 * <code>"cvv"</code><br>
 * <code>"expirationDate"</code><br>
 * <code>"expirationMonth"</code><br>
 * <code>"expirationYear"</code><br>
 * <code>"postalCode"</code>
 * @property {?object} card
 * If not enough information is available, or if there is invalid data, this value will be <code>null</code>.
 * Internally, Hosted Fields uses <a href="https://github.com/braintree/credit-card-type">credit-card-type</a>,
 * an open-source detection library to determine card type.
 * @property {string} card.type The code-friendly representation of the card type:
 * <code>visa</code>
 * <code>discover</code>
 * <code>master-card</code>
 * <code>american-express</code>
 * etc.
 * @property {string} card.niceType The pretty-printed card type:
 * <code>Visa</code>
 * <code>Discover</code>
 * <code>MasterCard</code>
 * <code>American Express</code>
 * etc.
 * @property {object} card.code
 * This object contains data relevant to the security code requirements of the card brand.
 * For example, on a Visa card there will be a <code>cvv</code> of 3 digits, whereas an
 * American Express card requires a 4-digit <code>cid</code>.
 * @property {string} card.code.name <code>"CVV"</code> <code>"CID"</code> <code>"CVC"</code>
 * @property {number} card.code.size The expected length of the security code. Typically, this is 3 or 4
 * @property {number[]} card.lengths
 * An array of integers of expected lengths of the card number excluding spaces, dashes, etc.
 * (Maestro and UnionPay are card types with several possible lengths)
 */

function inputEventHandler(fields) {
  return function (eventData) {
    var container = fields[eventData.fieldKey].containerElement;
    var classList = classListManager(container);

    classList
      .toggle(constants.externalClasses.FOCUSED, eventData.isFocused)
      .toggle(constants.externalClasses.VALID, eventData.isValid);
    if (eventData.isStrictlyValidating) {
      classList.toggle(constants.externalClasses.INVALID, !eventData.isValid);
    } else {
      classList.toggle(constants.externalClasses.INVALID, !eventData.isPotentiallyValid);
    }

    eventData.target = {
      fieldKey: eventData.fieldKey,
      container: container
    };

    delete eventData.fieldKey;
    delete eventData.isStrictlyValidating;

    this._emit('fieldEvent', eventData); // eslint-disable-line no-invalid-this
  };
}

/**
 * @class HostedFields
 * @param {object} options Hosted Fields {@link module:braintree-web/hosted-fields.create create} options
 * @description <strong>Do not use this constructor directly. Use {@link module:braintree-web/hosted-fields.create|braintree-web.hosted-fields.create} instead.</strong>
 * @classdesc This class represents a Hosted Fields component produced by {@link module:braintree-web/hosted-fields.create|braintree-web/hosted-fields.create}. Instances of this class have methods for interacting with the input fields within Hosted Fields' iframes.
 */
function HostedFields(options) {
  var field, container, frame, key, failureTimeout, config;
  var self = this;
  var fields = {};
  var fieldCount = 0;
  var componentId = uuid();

  if (!options.client) {
    throw new BraintreeError({
      type: BraintreeError.types.MERCHANT,
      message: 'You must specify a client when initializing Hosted Fields'
    });
  }

  config = options.client.getConfiguration();

  if (config.analyticsMetadata.sdkVersion !== VERSION) {
    throw new BraintreeError({
      type: BraintreeError.types.MERCHANT,
      message: 'Client and Hosted Fields components must be from the same SDK version'
    });
  }

  if (!options.fields) {
    throw new BraintreeError({
      type: BraintreeError.types.MERCHANT,
      message: 'You must specify fields when initializing Hosted Fields'
    });
  }

  EventEmitter.call(this);

  this._injectedNodes = [];
  this._destructor = new Destructor();
  this._fields = fields;

  this._bus = new Bus({
    channel: componentId,
    merchantUrl: location.href
  });

  this._destructor.registerFunctionForTeardown(function () {
    self._bus.teardown();
  });

  this._client = options.client;

  analytics.sendEvent(this._client, 'web.custom.hosted-fields.initialized');

  for (key in constants.whitelistedFields) {
    if (constants.whitelistedFields.hasOwnProperty(key)) {
      field = options.fields[key];

      if (!field) { continue; }

      container = document.querySelector(field.selector);

      if (!container) {
        throw new BraintreeError({
          type: BraintreeError.types.MERCHANT,
          message: 'Selector does not reference a valid DOM node',
          details: {
            fieldSelector: field.selector,
            fieldKey: key
          }
        });
      } else if (container.querySelector('iframe[name^="braintree-"]')) {
        throw new BraintreeError({
          type: BraintreeError.types.MERCHANT,
          message: 'Element already contains a Braintree iframe',
          details: {
            fieldSelector: field.selector,
            fieldKey: key
          }
        });
      }

      frame = iFramer({
        type: key,
        name: 'braintree-hosted-field-' + key,
        style: constants.defaultIFrameStyle
      });

      this._injectedNodes = this._injectedNodes.concat(injectFrame(frame, container));
      this._setupLabelFocus(key, container);
      fields[key] = {
        frameElement: frame,
        containerElement: container
      };
      fieldCount++;

      /* eslint-disable no-loop-func */
      setTimeout((function (f) {
        return function () {
          f.src = composeUrl(
            self._client.getConfiguration().gatewayConfiguration.assetsUrl,
            componentId
          );
        };
      })(frame), 0);
    }
  } /* eslint-enable no-loop-func */

  failureTimeout = setTimeout(function () {
    analytics.sendEvent(self._client, 'web.custom.hosted-fields.load.timed-out');
  }, INTEGRATION_TIMEOUT_MS);

  this._bus.on(events.FRAME_READY, function (reply) {
    fieldCount--;
    if (fieldCount === 0) {
      clearTimeout(failureTimeout);
      reply(options);
      self._emit('ready');
    }
  });

  this._bus.on(
    events.INPUT_EVENT,
    inputEventHandler(fields).bind(this)
  );

  this._destructor.registerFunctionForTeardown(function () {
    var j, node, parent;

    for (j = 0; j < self._injectedNodes.length; j++) {
      node = self._injectedNodes[j];
      parent = node.parentNode;

      parent.removeChild(node);

      classListManager(parent).remove(
        constants.externalClasses.FOCUSED,
        constants.externalClasses.INVALID,
        constants.externalClasses.VALID
      );
    }
  });

  this._destructor.registerFunctionForTeardown(function () {
    var methodNames = methods(HostedFields.prototype).concat(methods(EventEmitter.prototype));

    convertMethodsToError(self, methodNames);
  });
}

HostedFields.prototype = Object.create(EventEmitter.prototype, {
  constructor: HostedFields
});

HostedFields.prototype._setupLabelFocus = function (type, container) {
  var labels, i;
  var shouldSkipLabelFocus = isIos();
  var bus = this._bus;

  if (shouldSkipLabelFocus) { return; }
  if (container.id == null) { return; }

  function triggerFocus() {
    bus.emit(events.TRIGGER_INPUT_FOCUS, type);
  }

  labels = nodeListToArray(document.querySelectorAll('label[for="' + container.id + '"]'));
  labels = labels.concat(findParentTags(container, 'label'));

  for (i = 0; i < labels.length; i++) {
    utils.addEventListener(labels[i], 'click', triggerFocus, false);
  }

  this._destructor.registerFunctionForTeardown(function () {
    for (i = 0; i < labels.length; i++) {
      utils.removeEventListener(labels[i], 'click', triggerFocus, false);
    }
  });
};

/**
 * Cleanly tear down anything set up by {@link module:braintree-web/hosted-fields.create|create}
 * @public
 * @param {errback} [callback] Callback executed on completion, containing an error if one occurred. No data is returned if teardown completes successfully.
 * @example
 * hostedFieldsInstance.teardown(function (err) {
 *   if (err) {
 *     console.error('Could not tear down Hosted Fields!');
 *   } else {
 *     console.info('Hosted Fields has been torn down!');
 *   }
 * });
 * @returns {void}
 */
HostedFields.prototype.teardown = function (callback) {
  this._destructor.teardown(callback);
  analytics.sendEvent(this._client, 'web.custom.hosted-fields.teardown-completed');
};

/**
 * Attempts to tokenize fields, returning a nonce payload
 * @public
 * @param {errback} callback The second argument, <code>data</code>, is a {@link HostedFields~tokenizePayload|tokenizePayload}
 * @example
 * hostedFieldsInstance.tokenize(function (err, payload) {
 *   if (err) {
 *     console.error(err);
 *   } else {
 *     console.log('Got nonce:', payload.nonce);
 *   }
 * });
 * @returns {void}
 */
HostedFields.prototype.tokenize = function (callback) {
  if (typeof callback !== 'function') {
    throw new BraintreeError({
      type: BraintreeError.types.MERCHANT,
      message: 'tokenize must include a callback function'
    });
  }

  this._bus.emit(events.TOKENIZATION_REQUEST, function (response) {
    callback.apply(null, response);
  });
};

/**
 * Sets the placeholder of a {@link module:braintree-web/hosted-fields~field field}.
 * @public
 * @param {string} field The field whose placeholder you wish to change. Must be a valid {@link module:braintree-web/hosted-fields~fieldOptions fieldOption}.
 * @param {string} placeholder Will be used as the `placeholder` attribute of the input.
 * @param {errback} [callback] Callback executed on completion, containing an error if one occurred. No data is returned if the placeholder updated successfully.
 *
 * @example
 * hostedFieldsInstance.setPlaceholder('number', '4111 1111 1111 1111', function (err) {
 *   if (err) {
 *     console.error(err);
 *   }
 * });
 *
 * @example <caption>Update CVV field on card type change</caption>
 * var cvvPlaceholder = 'CVV'; // Create a default value
 *
 * hostedFieldsInstance.on('fieldEvent', function (event) {
 *   if (event.target.fieldKey !== 'number') { return; } // Ignore all non-number field events
 *
 *   // Update the placeholder value if the card code name has changed
 *   if (event.card && event.card.code.name !== cvvPlaceholder) {
 *     cvvPlaceholder = event.card.code.name;
 *     hostedFields.setPlaceholder('cvv', cvvPlaceholder, function (err) {
 *       if (err) {
 *         console.error(err);
 *       }
 *     });
 *   }
 * });
 * @returns {void}
 */

HostedFields.prototype.setPlaceholder = function (field, placeholder, callback) {
  var err;

  if (!whitelistedFields.hasOwnProperty(field)) {
    err = new BraintreeError({
      type: BraintreeError.types.MERCHANT,
      message: field + ' is not a valid field. You must use a valid field option when setting a placeholder.'
    });
  } else if (!this._fields.hasOwnProperty(field)) {
    err = new BraintreeError({
      type: BraintreeError.types.MERCHANT,
      message: 'Cannot set placeholder for ' + field + ' field because it is not part of the current Hosted Fields options.'
    });
  } else {
    this._bus.emit(events.SET_PLACEHOLDER, field, placeholder);
  }

  if (typeof callback === 'function') {
    callback(err);
  }
};

module.exports = HostedFields;