Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions examples/apm/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<label><input type="checkbox" id="redirect-headless" /> Enable headless mode (skip &ldquo;Continue to payment&rdquo; / Pay; open PSP immediately)</label>
<label style="margin-left: 1.25rem;"><input type="checkbox" id="redirect-silent-failure" disabled /> Silent failure view (headless: no in-widget error; listen for <code>failure</code>)</label>
<label style="margin-left: 1.25rem;"><input type="checkbox" id="redirect-show-loader" checked disabled /> Show headless loader while opening PSP</label>
<label style="margin-left: 1.25rem;"><input type="checkbox" id="redirect-popup-blocked-overlay" checked disabled /> SDK overlay on popup-blocked (default <code>true</code>; uncheck to suppress and handle <code>redirect-popup-blocked</code> yourself)</label>
<label><input type="checkbox" id="redirect-overlay-root" /> Mount PSP action overlay on <code>#action-overlay-root</code> instead of <code>document.body</code></label>
</div>
</fieldset>
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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');
}
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
29 changes: 25 additions & 4 deletions src/apm/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -413,6 +422,10 @@ module ProcessOut {
endpoint,
data,
(apiResponse: AuthorizationNetworkResponse | TokenizationNetworkResponse) => {
if (requestGen !== POLLING_GENERATION) {
return;
}

if (isErrorResponse(apiResponse)) {
INITIAL_MAX_RETRIES = 0;

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/apm/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/apm/events/APMEventListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions src/apm/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
/// <reference path="views/NextSteps.ts" />
/// <reference path="views/Pending.ts" />
/// <reference path="views/Redirect.ts" />
/// <reference path="views/PopupBlockedFallback.ts" />
/// <reference path="views/CancelRequest.ts" />
/// <reference path="views/utils/render-elements.ts" />
/// <reference path="views/utils/form.ts" />
Expand Down
76 changes: 76 additions & 0 deletions src/apm/views/PopupBlockedFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
module ProcessOut {
const { div } = elements

interface PopupBlockedFallbackProps {
config: APIRedirectBase & Partial<PaymentContext>
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<PopupBlockedFallbackProps> {
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'),
)
)
)
}
}
}
58 changes: 55 additions & 3 deletions src/apm/views/Redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes me wonder how we managed to calculate this z-index 😅

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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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: [
Expand Down
15 changes: 11 additions & 4 deletions src/processout/actionhandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading