'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 create = require('lodash/object/create');
var EventEmitter = require('../../lib/event-emitter');
var bind = require('lodash/function/bind');
var injectFrame = require('./inject-frame');
var analytics = require('../../lib/analytics');
/**
* @typedef {object} HostedFields~tokenizePayload
* @property {string} nonce The payment method nonce
* @property {string} type Always <code>CreditCard</code>
* @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/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/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} configuration Hosted Fields {@link module:braintree/hosted-fields.create create} options
* @description <strong>Do not use this constructor directly. Use {@link module:braintree/hosted-fields.create|braintree.hosted-fields.create} instead.</strong>
* @classdesc This class represents a Hosted Fields component produced by {@link module:braintree/hosted-fields.create|braintree/hosted-fields.create}. Instances of this class have methods for interacting with the input fields within Hosted Fields' iframes.
*/
function HostedFields(configuration) {
var field, container, frame, key, failureTimeout;
var self = this;
var fields = {};
var fieldCount = 0;
var componentId = uuid();
if (!configuration.client) {
throw new BraintreeError({
type: BraintreeError.types.MERCHANT,
message: 'You must specify a client when initializing Hosted Fields'
});
} else if (!configuration.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._bus = new Bus({
channel: componentId,
merchantUrl: location.href
});
this._destructor.registerFunctionForTeardown(function () {
self._bus.teardown();
});
this._client = configuration.client;
analytics.sendEvent(this._client, 'web.custom.hosted-fields.initialized');
for (key in constants.whitelistedFields) {
if (constants.whitelistedFields.hasOwnProperty(key)) {
field = configuration.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(configuration);
self._emit('ready');
}
});
this._bus.on(
events.INPUT_EVENT,
bind(inputEventHandler(fields), 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
);
}
});
}
HostedFields.prototype = 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/hosted-fields.create|create}
* @public
* @param {errorCallback} done An errback called when teardown has completed
* @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 (done) {
this._destructor.teardown(done);
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) {
this._bus.emit(events.TOKENIZATION_REQUEST, function (response) {
callback.apply(null, response);
});
};
module.exports = HostedFields;