diff --git a/docs/apm-ui-system.md b/docs/apm-ui-system.md index 50b86e52..783f1316 100644 --- a/docs/apm-ui-system.md +++ b/docs/apm-ui-system.md @@ -574,6 +574,14 @@ const apm = client.apm.authorization(container, { timeout: 900, // Timeout in seconds (15 minutes) allowCancelation: true // Allow cancellation during confirmation }, + + // Redirect step (web only): optional keys match Context.ts FlowData.redirect (see sdks-embedded-components.md) + redirect: { + enableHeadlessMode: false, + // silentFailureView: false, + // showHeadlessLoader: true, + // actionOverlayMountParent: document.getElementById('overlay-root'), // HTMLElement | null + }, // Success screen settings success: { diff --git a/package.json b/package.json index 17a90cd5..0fbe768b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.8.7", + "version": "1.8.8", "description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.", "scripts": { "build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js", diff --git a/src/apm/Context.ts b/src/apm/Context.ts index 9ef4adde..35c9f49f 100644 --- a/src/apm/Context.ts +++ b/src/apm/Context.ts @@ -39,6 +39,30 @@ module ProcessOut { /** Whether user must take action to dismiss success screen (default: false) */ requiresAction: boolean } + + /** + * Client-side redirect behaviour (web APM). Aligns with mobile + * `RedirectConfiguration.enableHeadlessMode`: skip the intermediate + * “Continue to payment” / Pay button and open the PSP flow as soon as the + * redirect step is shown. + */ + redirect?: { + enableHeadlessMode?: boolean + /** + * When headless, emit `failure` for `handleAction` errors but do not render the in-widget + * error screen (mobile-style; merchant handles UX). Popup-blocked fallback UI is unchanged. + * Default false for backward compatibility. + */ + silentFailureView?: boolean + /** + * When headless, show the loader while opening the PSP. Default true; set false for no in-widget chrome. + */ + showHeadlessLoader?: boolean + /** + * Append `ActionHandler` iframe modal / new-window overlay to this element instead of `document.body`. + */ + actionOverlayMountParent?: HTMLElement | null + } } export type TokenizationUserData = TokenizationFlowData & FlowData diff --git a/src/apm/Page.ts b/src/apm/Page.ts index 99707b72..0fc6b1c5 100644 --- a/src/apm/Page.ts +++ b/src/apm/Page.ts @@ -91,11 +91,14 @@ module ProcessOut { }) } - criticalFailure({ - title, - code, - message, - }: { message: string, title: string, code?: string, }) { + criticalFailure( + { + title, + code, + message, + }: { message: string, title: string, code?: string, }, + options?: { renderErrorView?: boolean }, + ) { ContextImpl.context.events.emit("failure", { failure: { code: code || 'processout-js.internal-error', @@ -104,7 +107,11 @@ module ProcessOut { paymentState: this.state }) - ContextImpl.context.page.render(APMViewError, { + if (options && options.renderErrorView === false) { + return + } + + this.render(APMViewError, { title: title || "Unable to connect", message: message || "An unexpected error occurred. We're working to fix this issue, please check back later or contact support if you need assistance.", hideRefresh: true diff --git a/src/apm/index.ts b/src/apm/index.ts index 42b481f0..1da9b951 100644 --- a/src/apm/index.ts +++ b/src/apm/index.ts @@ -33,13 +33,17 @@ module ProcessOut { requiresAction: false, autoDismissDuration: 3, manualDismissDuration: 60, - ...data.success, + ...(data.success || {}), }, confirmation: { requiresAction: false, timeout: MIN_15 / 1000, allowCancelation: true, - ...data.confirmation, + ...(data.confirmation || {}), + }, + redirect: { + enableHeadlessMode: false, + ...(data.redirect || {}), }, logger: { error: (options: Omit[0], 'stack'>) => { diff --git a/src/apm/views/Redirect.ts b/src/apm/views/Redirect.ts index b83bb521..310faa62 100644 --- a/src/apm/views/Redirect.ts +++ b/src/apm/views/Redirect.ts @@ -6,26 +6,95 @@ module ProcessOut { } export class APMViewRedirect extends APMViewImpl { + /** After a retryable headless error (e.g. pop-up blocked), show the normal Pay / Cancel UI. */ + private headlessManualFallback = false + + styles = css` + .redirect-headless-loading { + justify-content: center; + align-items: center; + flex-direction: column; + gap: 8px; + min-height: 120px; + } + .redirect-headless-empty { + min-height: 0; + margin: 0; + padding: 0; + overflow: hidden; + } + ` + + protected componentDidMount(): void { + const headless = ContextImpl.context.redirect && ContextImpl.context.redirect.enableHeadlessMode + if (headless && !this.headlessManualFallback) { + this.handleRedirectClick() + } + } + handleRedirectClick() { ContextImpl.context.events.emit('redirect-initiated') + const pm = this.props.config.payment_method + const actionOptions = pm + ? new ActionHandlerOptions( + pm.gateway_name.toLowerCase(), + pm.logo && pm.logo.light_url ? pm.logo.light_url.raster : undefined, + ) + : new ActionHandlerOptions() + const redir = ContextImpl.context.redirect + if (redir && redir.actionOverlayMountParent != null) { + actionOptions.overlayMountParent = redir.actionOverlayMountParent + } ContextImpl.context.poClient.handleAction( - this.props.config.redirect.url, + this.props.config.redirect.url, () => { ContextImpl.context.events.emit('redirect-completed') ContextImpl.context.page.load(APIImpl.getCurrentStep) }, (err) => { - ContextImpl.context.events.emit('failure', { - failure: { - message: err.message, - code: err.code + const failure = { + message: err.message, + code: err.code, + } + const headless = ContextImpl.context.redirect && ContextImpl.context.redirect.enableHeadlessMode + if (headless) { + if (!this.headlessManualFallback && failure.code === 'customer.popup-blocked') { + this.headlessManualFallback = true + this.forceUpdate() + return } - }) - } + if (this.headlessManualFallback) { + ContextImpl.context.events.emit('failure', { failure }) + return + } + const silentFailureView = !!(redir && redir.silentFailureView) + ContextImpl.context.page.criticalFailure( + { + title: 'Could not open payment', + code: failure.code, + message: failure.message, + }, + { renderErrorView: !silentFailureView }, + ) + return + } + ContextImpl.context.events.emit('failure', { failure }) + }, + actionOptions, ) } render() { + const headless = ContextImpl.context.redirect && ContextImpl.context.redirect.enableHeadlessMode + if (headless && !this.headlessManualFallback) { + const showLoader = + !ContextImpl.context.redirect || ContextImpl.context.redirect.showHeadlessLoader !== false + if (showLoader) { + return page({ className: 'redirect-headless-loading' }, Loader()) + } + return page({ className: 'redirect-headless-empty', 'aria-hidden': 'true' }) + } + const redirectLabel = `Pay ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}`; return ( Main({ diff --git a/src/processout/actionhandler.ts b/src/processout/actionhandler.ts index 0ae2b3f1..4ddbac8b 100644 --- a/src/processout/actionhandler.ts +++ b/src/processout/actionhandler.ts @@ -9,10 +9,12 @@ module ProcessOut { protected element: HTMLElement; protected iframe: HTMLIFrameElement; public closed: boolean; + protected mountParent?: HTMLElement; - constructor(el: HTMLElement, iframe: HTMLIFrameElement) { + constructor(el: HTMLElement, iframe: HTMLIFrameElement, mountParent?: HTMLElement) { this.element = el; this.iframe = iframe; + this.mountParent = mountParent; window.addEventListener("resize", function(event) { var width = Math.max(document.body.clientWidth, @@ -42,7 +44,7 @@ module ProcessOut { document.body.style.overflow = "hidden"; // And finally show the modal to the user - document.body.appendChild(this.element); + (this.mountParent || document.body).appendChild(this.element); } public close() { @@ -80,6 +82,9 @@ module ProcessOut { // gatewayLogo is shown when the action is done on another tab or window public gatewayLogo?: string; + /** When set, iframe modal and new-window overlay are appended here instead of document.body */ + public overlayMountParent?: HTMLElement; + public static ThreeDSChallengeFlow = "three-d-s-challenge-flow"; public static ThreeDSChallengeFlowNoIframe = "three-d-s-challenge-flow-no-iframe"; public static ThreeDSFingerprintFlow = "three-d-s-fingerprint-flow"; @@ -248,7 +253,11 @@ module ProcessOut { iframeWrapper.appendChild(iframe); iframeWrapper.appendChild(buttonWrapper); - this.iframeWrapper = new MockedIFrameWindow(iframeWrapper, iframe); + this.iframeWrapper = new MockedIFrameWindow( + iframeWrapper, + iframe, + this.options.overlayMountParent, + ); button.onclick = function() { this.iframeWrapper.close(); }.bind(this); @@ -464,7 +473,8 @@ module ProcessOut { this.cancel(); }.bind(this), false); - document.body.appendChild(ret.topLayer); + var mount = this.options.overlayMountParent || document.body; + mount.appendChild(ret.topLayer); return ret; }