UNPKG

paypal-checkout

Version:

PayPal Checkout components, for integrating checkout products.

1,084 lines (892 loc) 36.4 kB
/* @flow */ /** @jsx jsxDom */ /* eslint max-lines: 0 */ import { ZalgoPromise } from 'zalgo-promise/src'; import { create } from 'zoid/src'; import { type Component } from 'zoid/src/component/component'; import { info, warn, track, error, flush as flushLogs, immediateFlush } from 'beaver-logger/client'; import { getDomain } from 'cross-domain-utils/src'; import { base64encode, identity, noop, isDevice, isIEIntranet } from 'belter/src'; import { debounce, once } from 'zoid/src/lib'; import { pptm } from '../external'; import { config } from '../config'; import { SOURCE, ENV, FPTI, FUNDING, BUTTON_LABEL, BUTTON_COLOR, BUTTON_SIZE, BUTTON_SHAPE, BUTTON_LAYOUT, COUNTRY, FUNDING_BRAND_LABEL } from '../constants'; import { redirect as redir, checkRecognizedBrowser, getBrowserLocale, getSessionID, getStorageID, request, getScriptVersion, isEligible, getCurrentScriptUrl, getDomainSetting, extendUrl, rememberFunding, getRememberedFunding, memoize, uniqueID, getThrottle, getBrowser, isSupportedNativeBrowser, getRenderedButtons } from '../lib'; import { rest } from '../api'; import { onAuthorizeListener } from '../experiments'; import { getPaymentType, awaitBraintreeClient, mapPaymentToBraintree, type BraintreePayPalClient } from '../integrations'; import { awaitPopupBridge } from '../integrations/popupBridge'; import { validateFunding, isFundingIneligible, isFundingAutoEligible } from '../funding'; import { containerTemplate, componentTemplate } from './template'; import { validateButtonLocale, validateButtonStyle } from './validate'; import { setupButtonChild } from './child'; import { normalizeProps } from './props'; pptm.listenForLoadWithNoContent(); function isCreditDualEligible(props) : boolean { const { label, funding, layout, locale, max, sources, env } = normalizeProps(props, { locale: getBrowserLocale() }); const { allowed } = funding; const { country } = locale; if (allowed && allowed.indexOf(FUNDING.CREDIT) !== -1) { return false; } if (layout !== BUTTON_LAYOUT.HORIZONTAL) { return false; } if (max === 1) { return false; } if (label === BUTTON_LABEL.CREDIT) { return false; } if (country !== COUNTRY.US) { return false; } if (isFundingIneligible(FUNDING.CREDIT, { funding, locale, layout, env })) { return false; } if (isFundingAutoEligible(FUNDING.CREDIT, { funding, locale, layout })) { return false; } if (sources.indexOf(FUNDING.CREDIT) !== -1) { return false; } const domain = getDomain().replace(/^https?:\/\//, '').replace(/^www\./, ''); if (config.creditTestDomains.indexOf(domain) === -1) { return false; } return true; } let creditThrottle; type ButtonOptions = {| style : {| maxbuttons? : number, layout? : string, label? : string, size? : string, shape? : string, color? : string, tagline? : boolean |}, client : { [string] : (string | ZalgoPromise<string>) }, funding? : { allowed? : $ReadOnlyArray<string>, disallowed? : $ReadOnlyArray<string> }, env? : string, locale? : string, logLevel : string, awaitPopupBridge : Function, meta : Object, validate? : ({ enable : () => ZalgoPromise<void>, disable : () => ZalgoPromise<void> }) => void, stage? : string, stageUrl? : string, localhostUrl? : string, checkoutUri? : string, authCode? : string, enableNativeCheckout? : boolean |}; export const Button : Component<ButtonOptions> = create({ tag: 'paypal-button', name: 'ppbutton', buildUrl(props) : string { const env = props.env || config.env; const url = config.buttonUrls[env]; return url; }, contexts: { iframe: true, popup: false }, scrolling: false, listenForResize: true, containerTemplate, autoResize: { height: true, width: false }, prerenderTemplate({ props, jsxDom } : { props : Object, jsxDom : Function }) : HTMLElement { const template = ( <div innerHTML={ componentTemplate({ props }) } /> ); template.addEventListener('click', () => { warn('button_pre_template_click'); if (isIEIntranet()) { warn(`button_pre_template_click_intranet_mode`); flushLogs(); // eslint-disable-next-line no-alert alert(`IE Intranet mode is not supported by PayPal. Please disable intranet mode, or continue in an alternate browser.`); } if (getDomainSetting('allow_full_page_fallback')) { info('pre_template_force_full_page'); this.props.payment().then(token => { window.top.location = extendUrl(config.checkoutUrl, { token }); }); } }); return ( <html> <body> { template } </body> </html> ); }, get version() : string { return getScriptVersion(); }, get domain() : Object { return config.paypalDomains; }, attributes: { iframe: { allowpaymentrequest: 'allowpaymentrequest', title: FUNDING_BRAND_LABEL.PAYPAL } }, validate() { if (!isEligible()) { warn('button_render_ineligible'); } }, props: { domain: { type: 'string', required: false, def() : string { return window.location.host; }, queryParam: true }, sessionID: { type: 'string', required: false, def() : string { return getSessionID(); }, queryParam: true }, buttonSessionID: { type: 'string', required: false, def() : ?string { return uniqueID(); }, queryParam: true }, renderedButtons: { type: 'string', required: false, def(props) : string { return getRenderedButtons(props); }, queryParam: true }, storageID: { type: 'string', required: false, def() : string { return getStorageID(); }, queryParam: true }, env: { type: 'string', required: false, queryParam: true, def() : string { return config.env; }, validate(env) { if (env) { if (!config.paypalUrls[env]) { throw new Error(`Invalid env: ${ env }`); } } } }, meta: { type: 'object', required: false, def() : Object { return {}; } }, enableNativeCheckout: { type: 'boolean', required: false, def() : boolean { return false; } }, client: { type: 'object', required: false, def() : Object { return {}; }, validate(client, props) { const env = props.env || config.env; if (!client[env]) { throw new Error(`Client ID not found for env: ${ env }`); } if (typeof client[env] === 'string') { if (client[env].match(/^(.)\1+$/)) { throw new Error(`Invalid client ID: ${ client[env] }`); } } else if (!ZalgoPromise.isPromise(client[env])) { throw new Error(`Expected client token to be either a string or a promise`); } }, decorate(client : Object) : Object { if (client && client.sandbox === 'demo_sandbox_client_id') { client.sandbox = 'AZDxjDScFpQtjWTOUtWKbyN_bDt4OgqaF4eYXlewfBP4-8aqX3PiV8e1GWU6liB2CUXlkA59kJXE7M6R'; } return client; } }, source: { type: 'string', required: false, def() : string { return SOURCE.MANUAL; } }, prefetchLogin: { type: 'boolean', required: false }, stage: { type: 'string', required: false, queryParam: true, def(props) : ?string { const env = props.env || config.env; if (env === ENV.STAGE || env === ENV.LOCAL) { return config.stage; } } }, updateClientConfiguration: { type: 'boolean', required: false, def: () => { return true; } }, stageUrl: { type: 'string', required: false, queryParam: true, def(props) : ?string { const env = props.env || config.env; if (env === ENV.STAGE || env === ENV.LOCAL) { return config.stageUrl; } } }, localhostUrl: { type: 'string', required: false, queryParam: true, def(props) : ?string { const env = props.env || config.env; if (env === ENV.LOCAL) { return config.localhostUrl; } } }, checkoutUri: { type: 'string', required: false, queryParam: true, def() : ?string { return config.checkoutUri; } }, braintree: { type: 'object', required: false, validate(braintree, props) { if (!braintree.paypalCheckout) { throw new Error(`Expected Braintree paypal-checkout component to be loaded`); } if (!props.client) { throw new Error(`Expected client prop to be passed with Braintree authorization keys`); } }, // $FlowFixMe decorate(braintree, props) : ZalgoPromise<BraintreePayPalClient> { const env = props.env || config.env; // $FlowFixMe return ZalgoPromise.hash(props.client).then(client => { return awaitBraintreeClient(braintree, client[env]); }); } }, payment: { type: 'function', required: true, memoize: false, timeout: __TEST__ ? 500 : 10 * 1000, alias: 'billingAgreement', decorate(original) : Function { return function payment(data = {}) : ZalgoPromise<string> { const actions = { request, payment: { create: (options) => { return this.props.braintree ? this.props.braintree.then(client => { return client.createPayment(mapPaymentToBraintree(options.payment || options)); }) : ZalgoPromise.hash(this.props.client).then(client => { return rest.payment.create(this.props.env, client, options); }); } }, order: { create: (options) => { return ZalgoPromise.hash(this.props.client).then(client => { return rest.order.create(this.props.env, client, options); }); } }, braintree: { create: (options) => { if (!this.props.braintree) { throw new Error(`Can not create using Braintree - no braintree client provided`); } return this.props.braintree.then(client => { return client.createPayment(options); }); } } }; if (getDomainSetting('memoize_payment') && this.memoizedToken) { return this.memoizedToken; } this.memoizedToken = ZalgoPromise.try(original, this, [ data, actions ]); const startTime = Date.now(); this.memoizedToken = this.memoizedToken.then(token => { if (!token) { error(`no_token_passed_to_payment`); throw new Error(`No value passed to payment`); } const elapsed = Date.now() - startTime; track({ [ FPTI.KEY.STATE ]: FPTI.STATE.CHECKOUT, [ FPTI.KEY.TRANSITION ]: FPTI.TRANSITION.RECIEVE_PAYMENT, [ FPTI.KEY.CONTEXT_TYPE ]: FPTI.CONTEXT_TYPE[getPaymentType(token)], [ FPTI.KEY.CONTEXT_ID ]: token, [ FPTI.KEY.BUTTON_SESSION_UID ]: this.props.buttonSessionID, [ FPTI.KEY.RESPONSE_DURATION ]: elapsed, [ FPTI.KEY.BUTTON_VERSION ]: data && data.button_version }); flushLogs(); return token; }); return this.memoizedToken; }; } }, authCode: { type: 'string', required: false, def() : string { return config.authCode; } }, funding: { type: 'object', required: false, queryParam: true, validate({ allowed = [], disallowed = [] } : Object = {}) { validateFunding({ allowed, disallowed, remembered: [] }); }, def() : Object { return {}; }, decorate({ allowed = [], disallowed = [] } : Object = {}, props : ButtonOptions) : {} { allowed = Array.isArray(allowed) ? allowed : []; disallowed = Array.isArray(disallowed) ? disallowed : []; if (allowed && allowed.indexOf(FUNDING.ITAU) !== -1) { allowed = allowed.filter(source => (source !== FUNDING.ITAU)); } if (isCreditDualEligible(props)) { creditThrottle = getThrottle('dual_credit_automatic', 50); if (creditThrottle.isEnabled()) { allowed = [ ...allowed, FUNDING.CREDIT ]; } } let remembered = getRememberedFunding(sources => sources); if (remembered && remembered.indexOf(FUNDING.VENMO) !== -1) { remembered = remembered.filter(source => (source !== FUNDING.VENMO)); } if (!isSupportedNativeBrowser() || getDomainSetting('disable_venmo')) { if (disallowed && disallowed.indexOf(FUNDING.VENMO) === -1) { disallowed = [ ...disallowed, FUNDING.VENMO ]; } } return { allowed, disallowed, remembered, remember(sources) { rememberFunding(sources); } }; } }, commit: { type: 'boolean', required: false, queryParam: true, queryValue: (val) => { return val ? 'true' : 'false'; } }, onRender: { type: 'function', promisify: true, required: false, noop: true, decorate(original) : Function { return function decorateOnRender() : mixed { const { browser = 'unrecognized', version = 'unrecognized' } = getBrowser(); info(`button_render_browser_${ browser }_${ isDevice() ? 'mobile' : 'desktop' }_${ version }`); const style = this.props.style || {}; info(`button_render`); info(`button_render_color_${ style.color || 'default' }`); info(`button_render_shape_${ style.shape || 'default' }`); info(`button_render_size_${ style.size || 'default' }`); info(`button_render_label_${ style.label || 'default' }`); info(`button_render_branding_${ style.branding || 'default' }`); info(`button_render_fundingicons_${ style.fundingicons || 'default' }`); info(`button_render_tagline_${ style.tagline || 'default' }`); pptm.listenForButtonRender(); pptm.reloadPptmScript(this.props.client[this.props.env]); track({ [ FPTI.KEY.STATE ]: FPTI.STATE.LOAD, [ FPTI.KEY.TRANSITION ]: FPTI.TRANSITION.BUTTON_RENDER, [ FPTI.KEY.BUTTON_TYPE ]: FPTI.BUTTON_TYPE.IFRAME, [ FPTI.KEY.BUTTON_SESSION_UID ]: this.props.buttonSessionID, [ FPTI.KEY.BUTTON_SOURCE ]: this.props.source }); if (isIEIntranet()) { warn(`button_render_intranet_mode`); } if (creditThrottle) { creditThrottle.logStart({ [ FPTI.KEY.BUTTON_SESSION_UID ]: this.props.buttonSessionID }); } flushLogs(); return original.apply(this, arguments); }; } }, onAuthorize: { type: 'function', alias: 'onApprove', required: true, decorate(original) : Function { return function decorateOnAuthorize(data, actions) : void | ZalgoPromise<void> { if (data && !data.intent) { warn(`button_authorize_no_intent`, { paymentID: data.paymentID, token: data.paymentToken }); } info('button_authorize'); track({ [ FPTI.KEY.STATE ]: FPTI.STATE.CHECKOUT, [ FPTI.KEY.TRANSITION ]: FPTI.TRANSITION.CHECKOUT_APPROVE, [ FPTI.KEY.BUTTON_SESSION_UID ]: this.props.buttonSessionID, [ FPTI.KEY.BUTTON_VERSION ]: data && data.button_version }); if (isIEIntranet()) { warn(`button_authorize_intranet_mode`); } if (!isEligible()) { info('button_authorize_ineligible'); } checkRecognizedBrowser('authorize'); flushLogs(); const restart = actions.restart; actions.restart = () => { return restart().then(() => { return new ZalgoPromise(); }); }; actions.redirect = (win, url) => { return ZalgoPromise.try(() => { if (actions.close) { return actions.close(); } }).then(() => { return redir(win || window.top, url || data.returnUrl); }); }; actions.payment.tokenize = memoize(() => { if (!this.props.braintree) { throw new Error(`Must pass in Braintree client to tokenize payment`); } return this.props.braintree .then(client => client.tokenizePayment(data)); }); const execute = actions.payment.execute; actions.payment.execute = () => { return execute().then(result => { if (!result || !result.id || !result.intent || !result.state) { warn(`execute_result_missing_data`); return new ZalgoPromise(); } return result; }); }; const get = actions.payment.get; actions.payment.get = () => { return get().then(result => { if (!result || !result.id || !result.intent || !result.state) { warn(`get_result_missing_data`); return new ZalgoPromise(); } return result; }); }; actions.request = request; onAuthorizeListener.trigger({ paymentToken: data.paymentToken }); if (creditThrottle) { creditThrottle.logComplete({ [FPTI.KEY.BUTTON_SESSION_UID]: this.props.buttonSessionID }); } return ZalgoPromise.try(() => { if (this.props.braintree) { return actions.payment.tokenize().then(({ nonce }) => { // $FlowFixMe Object.defineProperty(data, 'nonce', { get: () => { info('nonce_getter'); flushLogs(); return nonce; } }); }); } }).then(() => { return original.call(this, data, actions); }).catch(err => { if (this.props.onError) { return this.props.onError(err); } throw err; }); }; } }, onShippingChange: { type: 'function', required: false, decorate(original) : void | Function { if (!original) { return; } return function decorateOnShippingChange(data, actions) : ZalgoPromise<void> { info('button_shipping_change'); track({ [ FPTI.KEY.STATE ]: FPTI.STATE.CHECKOUT, [ FPTI.KEY.TRANSITION ]: FPTI.TRANSITION.CHECKOUT_SHIPPING_CHANGE, [ FPTI.KEY.BUTTON_SESSION_UID ]: this.props.buttonSessionID, [ FPTI.KEY.BUTTON_VERSION ]: data && data.button_version }); flushLogs(); const timeout = __TEST__ ? 500 : 10 * 1000; const patch = actions.payment.patch; actions.payment.patch = (patchObject) => { return ZalgoPromise.try(() => { return patch(patchObject); }); }; const resolve = () => ZalgoPromise.resolve(); const reject = actions.reject || function reject() { throw new Error(`Missing reject action callback`); }; return ZalgoPromise.try(() => { return original.call(this, data, { ...actions, resolve, reject }); }).timeout(timeout, new Error(`Timed out waiting ${ timeout }ms for payment`)).catch(err => { if (this.props.onError) { this.props.onError(err); } throw err; }); }; } }, onError: { type: 'function', required: false, promisify: true, sendToChild: true, once: true, def() : (() => void) { return function onError(err : mixed) { if (isIEIntranet()) { warn(`button_error_intranet_mode`); flushLogs(); // eslint-disable-next-line no-alert alert(`IE Intranet mode is not supported by PayPal. Please disable intranet mode, or continue in an alternate browser.`); } setTimeout(() => { throw err; }); }; } }, onCancel: { type: 'function', required: false, noop: true, decorate(original) : Function { return function decorateOnCancel(data, actions) : void | ZalgoPromise<void> { info('button_cancel'); track({ [ FPTI.KEY.STATE ]: FPTI.STATE.CHECKOUT, [ FPTI.KEY.TRANSITION ]: FPTI.TRANSITION.CHECKOUT_CANCEL, [ FPTI.KEY.BUTTON_SESSION_UID ]: this.props.buttonSessionID, [ FPTI.KEY.BUTTON_VERSION ]: data && data.button_version }); flushLogs(); const redirect = (win, url) => { return ZalgoPromise.all([ redir(win || window.top, url || data.cancelUrl), actions.close() ]); }; return original.call(this, data, { ...actions, redirect }); }; } }, onClick: { type: 'function', required: false, noop: true, decorate(original) : Function { return function decorateOnClick(data : ?{ fundingSource : string, card? : string, flow? : string, button_version? : string }) : void { info('button_click'); if (data && data.flow) { info(`pay_flow_${ data.flow }`); } track({ [ FPTI.KEY.STATE ]: FPTI.STATE.BUTTON, [ FPTI.KEY.TRANSITION ]: FPTI.TRANSITION.BUTTON_CLICK, [ FPTI.KEY.BUTTON_TYPE ]: FPTI.BUTTON_TYPE.IFRAME, [ FPTI.KEY.BUTTON_SESSION_UID ]: this.props.buttonSessionID, [ FPTI.KEY.CHOSEN_FUNDING ]: data && (data.card || data.fundingSource), [ FPTI.KEY.PAYMENT_FLOW ]: data && data.flow, [ FPTI.KEY.BUTTON_VERSION ]: data && data.button_version }); if (isIEIntranet()) { warn('button_click_intranet_mode'); } if (creditThrottle) { creditThrottle.log('click', { [ FPTI.KEY.STATE ]: FPTI.STATE.BUTTON, [ FPTI.KEY.TRANSITION ]: FPTI.TRANSITION.BUTTON_CLICK, [ FPTI.KEY.BUTTON_SESSION_UID ]: this.props.buttonSessionID }); } const { color = 'default' } = this.props.style || {}; info(`button_click_color_${ color }`); flushLogs(); return original.apply(this, arguments); }; } }, onResize: { type: 'function', required: false, get value() : Function { let initialHeight; const logInlineGuestOutOfViewPortOnlyOnce = once((data) => { info('buttons_expansion_outside_viewport', data); immediateFlush(); }); return function onResizeHandler() { const container = this.container; if (!container) { return; } if (!initialHeight) { initialHeight = container.offsetHeight; } const getScrollOffsetY = () => { if (window.pageYOffset) { return window.pageYOffset; } if (document.documentElement) { return document.documentElement.scrollTop; } return 0; }; // explanation https://github.com/paypal/paypal-checkout-components/pull/1136#discussion_r298025574 const checkIfExpansionInViewport = () => { try { const scrollOffsetY = getScrollOffsetY(); const windowHeight = window.innerHeight; const containerOffsetY = container.getBoundingClientRect().top; const VISIBLE_THRESHOLD = 200; // 200px, for the first input field to be visible return scrollOffsetY + windowHeight > initialHeight + containerOffsetY + VISIBLE_THRESHOLD; } catch (err) { info('cannot_get_the_viewport_information'); return false; } }; const isContainerExpanded = container && container.offsetHeight > initialHeight; const isExpansionInViewport = checkIfExpansionInViewport(); if (!isContainerExpanded || isExpansionInViewport) { return; } logInlineGuestOutOfViewPortOnlyOnce({ height: container.offsetHeight, window: { width: window.innerWidth, height: window.innerHeight } }); }; }, decorate: (original) => debounce(original) }, locale: { type: 'string', required: false, queryParam: 'locale.x', def() : string { const { lang, country } = getBrowserLocale(); return `${ lang }_${ country }`; }, validate: validateButtonLocale }, style: { type: 'object', required: false, queryParam: true, alias: 'buttonStyle', def() : Object { return { color: BUTTON_COLOR.GOLD, shape: BUTTON_SHAPE.PILL, size: BUTTON_SIZE.SMALL, label: BUTTON_LABEL.CHECKOUT, fundingicons: false, layout: BUTTON_LAYOUT.HORIZONTAL }; }, decorate(style : Object) : Object { const { label, layout = BUTTON_LAYOUT.HORIZONTAL } = style; if (!label && layout === BUTTON_LAYOUT.HORIZONTAL) { style.label = BUTTON_LABEL.CHECKOUT; return style; } else if (!label && layout === BUTTON_LAYOUT.VERTICAL) { style.label = BUTTON_LABEL.PAYPAL; return style; } return style; }, validate(style = {}, props) { validateButtonStyle(style, props); flushLogs(); } }, validate: { type: 'function', required: false, decorate(validate) : Function { // $FlowFixMe return function decorateValidate(actions) : mixed { if (!this.validateCalled) { this.validateCalled = true; return validate(actions); } }; } }, logLevel: { type: 'string', required: false, get value() : string { return config.logLevel; } }, sdkMeta: { type: 'string', queryParam: true, sendToChild: false, def: () => { return base64encode(JSON.stringify({ url: getCurrentScriptUrl() })); } }, awaitPopupBridge: { type: 'object', required: false, value: () => awaitPopupBridge(Button) }, getPageUrl: { type: 'function', queryParam: false, required: false, def: () => { return () => window.location.href; } }, test: { type: 'object', required: false, def() : Object { return { action: 'checkout' }; } } } }); // $FlowFixMe const instances = Button.instances = []; const render = Button.render; // $FlowFixMe Button.render = function ButtonRender(props : ButtonOptions = {}, ...args) : ZalgoPromise<void> { const instance = { clone: ({ decorate = identity } : { decorate? : (ButtonOptions) => ButtonOptions } = {}) => { return { render: (container) => { const decoratedProps : ButtonOptions = decorate(props); // $FlowFixMe return render.call(Button, decoratedProps, container); } }; } }; instances.push(instance); const { onDestroy = noop } = props; const newProps = { ...props, onDestroy: function onDestroyWrapper() : void { const index = instances.indexOf(instance); if (index !== -1) { instances.splice(index, 1); } return onDestroy.apply(this, arguments); } }; return render.call(Button, newProps, ...args); }; if (Button.isChild()) { setupButtonChild(Button); }