'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;