diff --git a/examples/apm/index.html b/examples/apm/index.html index df393ada..e23e6d6d 100644 --- a/examples/apm/index.html +++ b/examples/apm/index.html @@ -26,6 +26,7 @@ + @@ -82,9 +83,11 @@ const headless = document.getElementById('redirect-headless').checked; document.getElementById('redirect-silent-failure').disabled = !headless; document.getElementById('redirect-show-loader').disabled = !headless; + document.getElementById('redirect-popup-blocked-overlay').disabled = !headless; if (!headless) { document.getElementById('redirect-silent-failure').checked = false; document.getElementById('redirect-show-loader').checked = true; + document.getElementById('redirect-popup-blocked-overlay').checked = true; } } @@ -103,6 +106,9 @@ if (document.getElementById('redirect-headless').checked && !document.getElementById('redirect-show-loader').checked) { redirect.showHeadlessLoader = false; } + if (document.getElementById('redirect-headless').checked && !document.getElementById('redirect-popup-blocked-overlay').checked) { + redirect.popupBlockedOverlay = false; + } if (document.getElementById('redirect-overlay-root').checked) { redirect.actionOverlayMountParent = document.getElementById('action-overlay-root'); } @@ -211,6 +217,25 @@ } }) + // redirect-popup-blocked fires when the headless auto-open was blocked by the browser + // (e.g. Safari). By default the SDK shows its own Pay button overlay automatically. + // When popupBlockedOverlay: false is set, handle it here instead. + apm.on('redirect-popup-blocked', function (data) { + if (document.getElementById('redirect-popup-blocked-overlay').checked) return; // SDK overlay or forceUpdate is handling it + // Example custom UI: a simple fixed overlay with a retry button + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(0,0,0,0.5);'; + var btn = document.createElement('button'); + btn.textContent = 'Continue to payment'; + btn.style.cssText = 'padding:0.75em 1.5em;font-size:1rem;cursor:pointer;'; + btn.onclick = function () { + overlay.remove(); + data.retry(); + }; + overlay.appendChild(btn); + document.body.appendChild(overlay); + }) + try { apm.initialise() } catch (e) { diff --git a/package.json b/package.json index 0fbe768b..0fd800c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.8.8", + "version": "1.8.9", "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/API.ts b/src/apm/API.ts index 14cbe83c..64dbedb3 100644 --- a/src/apm/API.ts +++ b/src/apm/API.ts @@ -209,6 +209,8 @@ module ProcessOut { let INITIAL_MAX_RETRIES = 0 let POLLING_TIMEOUT_ID: number | null = null let POLLING_CANCELLED = false // Add cancellation flag + /** Bumped on each makeRequest and on cancelPolling so stale responses / timers cannot duplicate polls. */ + let POLLING_GENERATION = 0 const isErrorResponse = (data: AuthorizationNetworkResponse | TokenizationNetworkResponse): data is NetworkErrorResponse => { const hasInvalidFields = 'invalid_fields' in data; @@ -391,6 +393,13 @@ module ProcessOut { }; } + const requestGen = ++POLLING_GENERATION; + if (POLLING_TIMEOUT_ID) { + window.clearTimeout(POLLING_TIMEOUT_ID); + POLLING_TIMEOUT_ID = null; + } + POLLING_CANCELLED = false; + if (INITIAL_MAX_RETRIES === 0) { INITIAL_MAX_RETRIES = internalOptions.serviceRetries; } @@ -413,6 +422,10 @@ module ProcessOut { endpoint, data, (apiResponse: AuthorizationNetworkResponse | TokenizationNetworkResponse) => { + if (requestGen !== POLLING_GENERATION) { + return; + } + if (isErrorResponse(apiResponse)) { INITIAL_MAX_RETRIES = 0; @@ -513,11 +526,10 @@ module ProcessOut { } } - // Continue polling in background (only if not cancelled) + // Continue polling in background (only if not cancelled and this chain is still current) if (!POLLING_CANCELLED) { POLLING_TIMEOUT_ID = window.setTimeout(() => { - // Double-check cancellation before continuing - if (!POLLING_CANCELLED) { + if (!POLLING_CANCELLED && requestGen === POLLING_GENERATION) { internalOptions.serviceRetries = INITIAL_MAX_RETRIES this.getCurrentStep(internalOptions); } @@ -554,8 +566,16 @@ module ProcessOut { return; }, (req, _, errorCode) => { + if (requestGen !== POLLING_GENERATION) { + return; + } + if ((req.status === 0 || req.status > 500) && internalOptions.serviceRetries > 0) { + const retryGen = POLLING_GENERATION; setTimeout(() => { + if (retryGen !== POLLING_GENERATION) { + return; + } internalOptions.serviceRetries--; this.makeRequest(method, pathOrOptions, data, internalOptions) }, TIMEOUT * ((INITIAL_MAX_RETRIES - internalOptions.serviceRetries) + 1)); @@ -629,7 +649,8 @@ module ProcessOut { } public static cancelPolling(): void { - POLLING_CANCELLED = true; // Set cancellation flag + POLLING_CANCELLED = true; + POLLING_GENERATION++; if (POLLING_TIMEOUT_ID) { window.clearTimeout(POLLING_TIMEOUT_ID); POLLING_TIMEOUT_ID = null; diff --git a/src/apm/Context.ts b/src/apm/Context.ts index 35c9f49f..dd56f14a 100644 --- a/src/apm/Context.ts +++ b/src/apm/Context.ts @@ -62,6 +62,12 @@ module ProcessOut { * Append `ActionHandler` iframe modal / new-window overlay to this element instead of `document.body`. */ actionOverlayMountParent?: HTMLElement | null + /** + * When the headless popup is blocked, mount the SDK's built-in Pay button modal + * overlay directly on `document.body`. Default `true`. + * Set to `false` to suppress the SDK overlay and handle `redirect-popup-blocked` yourself. + */ + popupBlockedOverlay?: boolean } } diff --git a/src/apm/events/APMEventListener.ts b/src/apm/events/APMEventListener.ts index b4447087..3ed57514 100644 --- a/src/apm/events/APMEventListener.ts +++ b/src/apm/events/APMEventListener.ts @@ -60,6 +60,15 @@ module ProcessOut { paymentState?: string } + // Emitted when a headless redirect auto-open was blocked by the browser (e.g. Safari + // popup blocker) and the SDK has surfaced its manual-fallback Pay UI. Fired before + // forceUpdate so the merchant can make their container visible first. + // `retry` is a bound reference to handleRedirectClick — call it from a real user-gesture + // handler (e.g. an onclick) to open the payment tab with a fresh browser gesture. + "redirect-popup-blocked": { + retry: () => void + } + "copy-to-clipboard": { text: string } diff --git a/src/apm/references.ts b/src/apm/references.ts index 3dca5f09..8e6e2ede 100644 --- a/src/apm/references.ts +++ b/src/apm/references.ts @@ -34,6 +34,7 @@ /// /// /// +/// /// /// /// diff --git a/src/apm/views/PopupBlockedFallback.ts b/src/apm/views/PopupBlockedFallback.ts new file mode 100644 index 00000000..311915ec --- /dev/null +++ b/src/apm/views/PopupBlockedFallback.ts @@ -0,0 +1,76 @@ +module ProcessOut { + const { div } = elements + + interface PopupBlockedFallbackProps { + config: APIRedirectBase & Partial + onRetry: () => void + } + + /** + * Renders the Pay button UI inside the popup-blocked overlay modal. + * Used when a headless redirect popup is blocked by the browser — this view + * is mounted into a fresh APMPageImpl overlaid on document.body so it is + * always visible regardless of the merchant's container visibility. + * + * Cancel confirmation is handled inline (local state) so it renders inside + * the overlay rather than delegating to ContextImpl.context.page, which is + * the invisible main container in headless mode. + */ + export class APMViewPopupBlockedFallback extends APMViewImpl { + private showingCancelConfirm = false + + private onCancelClick() { + ContextImpl.context.events.emit('request-cancel') + this.showingCancelConfirm = true + this.forceUpdate() + } + + private onCancelConfirm() { + ContextImpl.context.events.emit('payment-cancelled') + } + + private onCancelBack() { + this.showingCancelConfirm = false + this.forceUpdate() + } + + render() { + if (this.showingCancelConfirm) { + return ( + Main({ + config: this.props.config, + hideAmount: true, + buttons: [ + Button({ onclick: this.onCancelBack.bind(this) }, 'Back to payment'), + Button({ onclick: this.onCancelConfirm.bind(this), variant: 'secondary' }, 'Cancel payment'), + ] + }, + div({ className: 'cancel-request' }, + div({ className: 'cancel-request-message' }, 'Are you sure you want to cancel the payment?') + ), + ) + ) + } + + const redirectLabel = `Pay ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}` + return ( + Main({ + config: this.props.config, + className: 'redirect-page', + hideAmount: true, + buttons: [ + Button({ onclick: this.props.onRetry }, redirectLabel), + ContextImpl.context.confirmation.allowCancelation + ? Button({ onclick: this.onCancelClick.bind(this), variant: 'secondary' }, 'Cancel') + : null + ] + }, + div({ className: 'heading-container' }, + Header('Continue to payment'), + SubHeader('Click the button below to complete your payment'), + ) + ) + ) + } + } +} diff --git a/src/apm/views/Redirect.ts b/src/apm/views/Redirect.ts index 310faa62..e8c6c5b5 100644 --- a/src/apm/views/Redirect.ts +++ b/src/apm/views/Redirect.ts @@ -9,6 +9,10 @@ module ProcessOut { /** After a retryable headless error (e.g. pop-up blocked), show the normal Pay / Cancel UI. */ private headlessManualFallback = false + /** Overlay mounted on document.body when popup is blocked in headless mode. */ + private popupBlockedOverlayEl: HTMLElement | null = null + private popupBlockedOverlayPage: APMPageImpl | null = null + styles = css` .redirect-headless-loading { justify-content: center; @@ -32,6 +36,44 @@ module ProcessOut { } } + private showPopupBlockedFallbackOverlay() { + // Full-screen backdrop with centered content card + const overlay = document.createElement('div') + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483647;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;' + document.body.appendChild(overlay) + this.popupBlockedOverlayEl = overlay + + // Inner wrapper gives APMPageImpl a bounded container to render into + const content = document.createElement('div') + content.style.cssText = 'width:100%;max-width:400px;' + overlay.appendChild(content) + + const overlayPage = new APMPageImpl(content) + this.popupBlockedOverlayPage = overlayPage + + overlayPage.render(APMViewPopupBlockedFallback, { + config: this.props.config, + onRetry: () => { + this.removePopupBlockedFallbackOverlay() + this.handleRedirectClick() + }, + }) + + // Auto-clean up when the payment reaches a terminal state + const remove = () => this.removePopupBlockedFallbackOverlay() + ContextImpl.context.events.on('success', remove) + ContextImpl.context.events.on('failure', remove) + ContextImpl.context.events.on('payment-cancelled', remove) + } + + private removePopupBlockedFallbackOverlay() { + if (this.popupBlockedOverlayEl) { + this.popupBlockedOverlayEl.remove() + this.popupBlockedOverlayEl = null + this.popupBlockedOverlayPage = null + } + } + handleRedirectClick() { ContextImpl.context.events.emit('redirect-initiated') const pm = this.props.config.payment_method @@ -60,7 +102,17 @@ module ProcessOut { if (headless) { if (!this.headlessManualFallback && failure.code === 'customer.popup-blocked') { this.headlessManualFallback = true - this.forceUpdate() + // popupBlockedOverlay: true (default) → mount SDK Pay button overlay on document.body, + // always visible regardless of container visibility. + // popupBlockedOverlay: false → suppress SDK overlay; merchant handles redirect-popup-blocked. + if (!redir || redir.popupBlockedOverlay !== false) { + this.showPopupBlockedFallbackOverlay() + } + // Always emit so merchants can provide their own UI if needed. + // retry() must be called from a real user-gesture handler. + ContextImpl.context.events.emit('redirect-popup-blocked', { + retry: this.handleRedirectClick.bind(this), + }) return } if (this.headlessManualFallback) { @@ -97,8 +149,8 @@ module ProcessOut { const redirectLabel = `Pay ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}`; return ( - Main({ - config: this.props.config, + Main({ + config: this.props.config, className: "redirect-page", hideAmount: true, buttons: [ diff --git a/src/processout/actionhandler.ts b/src/processout/actionhandler.ts index 4ddbac8b..4dab547e 100644 --- a/src/processout/actionhandler.ts +++ b/src/processout/actionhandler.ts @@ -325,6 +325,16 @@ module ProcessOut { return null; } + // Safari (pattern 2): window.open returns a non-null window that is immediately + // closed — treat this as popup-blocked rather than letting the 500ms timer fire + // a misleading "customer.canceled" error. Only applies to real popup flows; + // MockedIFrameWindow (IFrame/FingerprintIframe) initialises closed as undefined. + if ((this.options.flow === ActionFlow.NewTab || this.options.flow === ActionFlow.NewWindow) && newWindow.closed) { + error(new Exception("customer.popup-blocked")); + refocus(); + return null; + } + // We now want to monitor the payment page var timer = setInterval(function() { if (!timer) return; @@ -335,7 +345,6 @@ module ProcessOut { newWindow.close() error(new Exception("customer.canceled")) - // Temporary just to investigate the issue telemetryClient.reportWarning({ host: window && window.location ? window.location.host : "", fileName: "actionhandler.ts/ActionHandler.handle.timer", @@ -357,8 +366,7 @@ module ProcessOut { clearInterval(timer) timer = null error(new Exception("customer.canceled", undefined, { reason: "tab_closed" })) - - // Temporary just to investigate the issue + telemetryClient.reportWarning({ host: window && window.location ? window.location.host : "", fileName: "actionhandler.ts/ActionHandler.handle.cancelf", @@ -536,7 +544,6 @@ module ProcessOut { error(new Exception("customer.canceled")); - // Temporary just to investigate the issue telemetryClient.reportWarning({ host: window && window.location ? window.location.host : "", fileName: "actionhandler.ts/ActionHandler.listenEvents",