From a95f84e0e0364a146b0aa0e0f2424cd354aef643 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 21 Jul 2026 22:32:01 +0000 Subject: [PATCH] Contain Paystack Inline checkout inside Ladill sheet mount. Redirect Paystack iframes into the bottomsheet/modal body (where the spinner is) instead of allowing a second full-viewport Paystack modal on top. Hide Paystack background dimmer; keep Ladill header/close chrome. --- .../views/partials/paystack-sheet.blade.php | 296 ++++++++++++++++-- tests/Feature/ResponsivePaystackSheetTest.php | 8 +- 2 files changed, 269 insertions(+), 35 deletions(-) diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php index 121bc76..040a60b 100644 --- a/resources/views/partials/paystack-sheet.blade.php +++ b/resources/views/partials/paystack-sheet.blade.php @@ -145,15 +145,31 @@ } } catch (e) {} activePopup = null; + setPaystackContainActive(false); } - // Paystack Inline embeds checkout.paystack.com. Browsers default fullscreen to - // 'self', so cross-origin checkout needs allow="fullscreen" before navigation. - // Patch early (createElement hook + MutationObserver) and re-apply if Paystack - // overwrites allow without fullscreen. + // Paystack Inline defaults to a full-viewport modal (body append + position:fixed). + // We contain it inside our bottomsheet/modal mount so checkout loads where the + // spinner is — not as a second modal on top of Ladill chrome. var PAYSTACK_IFRAME_ALLOW = 'payment *; fullscreen *; clipboard-read *; clipboard-write *; publickey-credentials-get *'; var PAYSTACK_ALLOW_FEATURES = ['payment', 'fullscreen', 'clipboard-read', 'clipboard-write', 'publickey-credentials-get']; + function getPaystackMount() { + return document.querySelector('[data-ladill-pay-mount]'); + } + + function isPaystackCheckoutFrame(iframe) { + if (!iframe || !iframe.tagName || iframe.tagName.toUpperCase() !== 'IFRAME') return false; + var id = (iframe.id || '').toLowerCase(); + return id.indexOf('inline-checkout') === 0 || id.indexOf('embed-checkout') === 0; + } + + function isPaystackBackgroundFrame(iframe) { + if (!iframe || !iframe.tagName || iframe.tagName.toUpperCase() !== 'IFRAME') return false; + var id = (iframe.id || '').toLowerCase(); + return id.indexOf('inline-background') === 0; + } + function isPaystackIframe(iframe) { if (!iframe || !iframe.tagName || iframe.tagName.toUpperCase() !== 'IFRAME') return false; var id = (iframe.id || '').toLowerCase(); @@ -165,6 +181,7 @@ || id.indexOf('paystack') !== -1 || id.indexOf('inline-checkout') !== -1 || id.indexOf('inline-background') !== -1 + || id.indexOf('embed-checkout') !== -1 || name.indexOf('paystack') !== -1 || name.indexOf('inline-checkout') !== -1; } @@ -196,13 +213,94 @@ } catch (e) {} } + function styleContainedCheckout(iframe) { + if (!iframe) return; + try { + iframe.setAttribute('data-ladill-pay-contained', '1'); + // Force embed layout; Paystack sets position:fixed inline without !important. + var s = iframe.style; + s.setProperty('position', 'absolute', 'important'); + s.setProperty('left', '0', 'important'); + s.setProperty('top', '0', 'important'); + s.setProperty('right', '0', 'important'); + s.setProperty('bottom', '0', 'important'); + s.setProperty('width', '100%', 'important'); + s.setProperty('height', '100%', 'important'); + s.setProperty('margin', '0', 'important'); + s.setProperty('padding', '0', 'important'); + s.setProperty('border', '0', 'important'); + s.setProperty('z-index', '2', 'important'); + s.setProperty('visibility', 'visible', 'important'); + s.setProperty('display', 'block', 'important'); + s.setProperty('background', 'transparent', 'important'); + s.setProperty('max-width', 'none', 'important'); + s.setProperty('max-height', 'none', 'important'); + applyIframePermissions(iframe); + } catch (e) {} + } + + function hidePaystackBackground(iframe) { + if (!iframe) return; + try { + iframe.setAttribute('data-ladill-pay-bg-hidden', '1'); + var s = iframe.style; + s.setProperty('display', 'none', 'important'); + s.setProperty('visibility', 'hidden', 'important'); + s.setProperty('pointer-events', 'none', 'important'); + s.setProperty('opacity', '0', 'important'); + } catch (e) {} + } + + function containPaystackIframe(iframe) { + if (!iframe || !window.__ladillContainPaystack) return; + if (isPaystackBackgroundFrame(iframe)) { + hidePaystackBackground(iframe); + return; + } + if (!isPaystackCheckoutFrame(iframe) && !isPaystackIframe(iframe)) return; + if (isPaystackBackgroundFrame(iframe)) return; + + var mount = getPaystackMount(); + if (!mount) return; + + // Prefer checkout frames; skip stray non-checkout paystack frames if unsure. + if (!isPaystackCheckoutFrame(iframe) && (iframe.id || '').indexOf('inline-') !== 0) { + applyIframePermissions(iframe); + return; + } + + try { + if (iframe.parentNode !== mount) { + mount.appendChild(iframe); + } + styleContainedCheckout(iframe); + } catch (e) { + // If reparent fails mid-load, still try absolute restyle in place. + styleContainedCheckout(iframe); + } + } + + function ensurePaystackContained(root) { + if (!window.__ladillContainPaystack) return; + try { + var scope = root || document; + var nodes = scope.querySelectorAll ? scope.querySelectorAll('iframe') : []; + for (var i = 0; i < nodes.length; i++) { + containPaystackIframe(nodes[i]); + } + // Paystack may leave backgrounds on body outside scope walks. + var bgs = document.querySelectorAll('iframe[id^="inline-background"]'); + for (var j = 0; j < bgs.length; j++) { + hidePaystackBackground(bgs[j]); + } + } catch (e) {} + } + function ensurePaystackIframePermissions(root) { try { var scope = root || document; var nodes = scope.querySelectorAll ? scope.querySelectorAll('iframe') : []; for (var i = 0; i < nodes.length; i++) { - // Known Paystack frames, or any frame still missing a src (Paystack - // often inserts empty then sets id/src — pre-allow before navigation). var iframe = nodes[i]; var src = ''; try { src = (iframe.getAttribute('src') || '').trim(); } catch (e) {} @@ -210,9 +308,22 @@ applyIframePermissions(iframe); } } + ensurePaystackContained(scope); } catch (e) {} } + function setPaystackContainActive(active) { + window.__ladillContainPaystack = !!active; + try { + document.documentElement.classList.toggle('ladill-pay-inline-active', !!active); + } catch (e) {} + if (active) { + installIframeAllowHook(); + installAppendHook(); + ensurePaystackContained(document); + } + } + function installIframeAllowHook() { if (window.__ladillIframeAllowHook) return; window.__ladillIframeAllowHook = true; @@ -222,7 +333,6 @@ var el = options !== undefined ? orig.call(this, tagName, options) : orig.call(this, tagName); try { if (String(tagName).toLowerCase() === 'iframe') { - // Pre-delegate before Paystack assigns src so policy applies on first load. applyIframePermissions(el); } } catch (e) {} @@ -231,10 +341,61 @@ } catch (e) {} } + function installAppendHook() { + if (window.__ladillAppendHook) return; + window.__ladillAppendHook = true; + try { + var origAppend = Node.prototype.appendChild; + Node.prototype.appendChild = function (child) { + try { + if (window.__ladillContainPaystack && child && child.tagName === 'IFRAME') { + if (isPaystackBackgroundFrame(child)) { + var appendedBg = origAppend.call(this, child); + hidePaystackBackground(child); + return appendedBg; + } + if (isPaystackCheckoutFrame(child) || ((child.id || '').indexOf('inline-checkout') === 0)) { + var mount = getPaystackMount(); + if (mount) { + var appended = origAppend.call(mount, child); + styleContainedCheckout(child); + return appended; + } + } + } + } catch (e) {} + return origAppend.call(this, child); + }; + + var origInsert = Node.prototype.insertBefore; + Node.prototype.insertBefore = function (child, ref) { + try { + if (window.__ladillContainPaystack && child && child.tagName === 'IFRAME') { + if (isPaystackBackgroundFrame(child)) { + var insertedBg = origInsert.call(this, child, ref); + hidePaystackBackground(child); + return insertedBg; + } + if (isPaystackCheckoutFrame(child)) { + var mount = getPaystackMount(); + if (mount) { + var inserted = origAppend.call(mount, child); + styleContainedCheckout(child); + return inserted; + } + } + } + } catch (e) {} + return origInsert.call(this, child, ref); + }; + } catch (e) {} + } + function watchPaystackIframes() { if (window.__ladillPaystackIframeWatch) return; window.__ladillPaystackIframeWatch = true; installIframeAllowHook(); + installAppendHook(); ensurePaystackIframePermissions(document); try { @@ -243,12 +404,13 @@ for (var i = 0; i < mutations.length; i++) { var m = mutations[i]; if (m.type === 'attributes' && m.target) { - if (isPaystackIframe(m.target) || (m.target.tagName && m.target.tagName.toUpperCase() === 'IFRAME')) { - // Only force-allow known Paystack frames; for bare iframes wait until id/src set. + if (m.target.tagName && m.target.tagName.toUpperCase() === 'IFRAME') { if (isPaystackIframe(m.target)) { applyIframePermissions(m.target); - } else if (m.attributeName === 'id' || m.attributeName === 'src' || m.attributeName === 'name') { - if (isPaystackIframe(m.target)) applyIframePermissions(m.target); + // Paystack rewrites style when animating the popup open — re-contain. + if (window.__ladillContainPaystack) { + containPaystackIframe(m.target); + } } dirty = true; } @@ -259,20 +421,20 @@ var node = m.addedNodes[n]; if (!node || node.nodeType !== 1) continue; if (node.tagName && node.tagName.toUpperCase() === 'IFRAME') { - if (isPaystackIframe(node)) applyIframePermissions(node); - else applyIframePermissions(node); // safe pre-allow; Paystack often sets id after insert + applyIframePermissions(node); + if (window.__ladillContainPaystack) containPaystackIframe(node); dirty = true; } else if (node.querySelectorAll) { var nested = node.querySelectorAll('iframe'); for (var k = 0; k < nested.length; k++) { applyIframePermissions(nested[k]); + if (window.__ladillContainPaystack) containPaystackIframe(nested[k]); dirty = true; } } } } if (dirty) { - // Second pass after Paystack mutates attributes in the same tick. ensurePaystackIframePermissions(document); } }); @@ -280,18 +442,16 @@ childList: true, subtree: true, attributes: true, - attributeFilter: ['allow', 'src', 'id', 'name'], + attributeFilter: ['allow', 'src', 'id', 'name', 'style', 'class'], }); } catch (e) {} - // Brief poll during the first seconds after boot/open — covers cases where - // Paystack inserts via paths MutationObserver can miss under heavy load. var polls = 0; var pollId = setInterval(function () { ensurePaystackIframePermissions(document); polls += 1; - if (polls >= 40) clearInterval(pollId); - }, 250); + if (polls >= 60) clearInterval(pollId); + }, 200); } @@ -330,6 +490,7 @@ this.launching = true; // waiting for checkout payload this.error = ''; this._activeCode = ''; + setPaystackContainActive(false); return; } if (isFrameable(url)) { @@ -356,6 +517,7 @@ this.inline = false; this.launching = false; this._activeCode = ''; + setPaystackContainActive(false); this.error = isPaystackCheckoutUrl(url) ? 'Could not start in-app payment. Please try again.' : 'Secure checkout could not be opened in-page. Please try again.'; @@ -366,22 +528,33 @@ this._activeCode = accessCode; this.launching = true; this.error = ''; + // Arm containment before Paystack creates iframes so the first + // body.appendChild is redirected into our sheet mount. + setPaystackContainActive(true); + watchPaystackIframes(); loadInlineJs().then(function (PaystackPop) { if (token !== store._launchToken) return; if (!PaystackPop) throw new Error('Paystack unavailable'); - cancelInline(); + try { + if (activePopup && typeof activePopup.cancelTransaction === 'function') { + activePopup.cancelTransaction(); + } + } catch (e) {} + activePopup = null; + setPaystackContainActive(true); var popup = new PaystackPop(); activePopup = popup; - watchPaystackIframes(); popup.resumeTransaction(accessCode, { onLoad: function () { if (token !== store._launchToken) return; store.launching = false; ensurePaystackIframePermissions(document); + ensurePaystackContained(document); }, onSuccess: function (transaction) { if (token !== store._launchToken) return; store.launching = false; + setPaystackContainActive(false); var reference = (transaction && (transaction.reference || transaction.trxref)) || ''; var dest = buildReturnUrl(returnUrl, reference); if (dest) { @@ -395,19 +568,26 @@ store.launching = false; store._activeCode = ''; activePopup = null; + setPaystackContainActive(false); window.dispatchEvent(new CustomEvent('ladill-pay-cancelled')); }, onError: function (err) { if (token !== store._launchToken) return; store.launching = false; store._activeCode = ''; + setPaystackContainActive(false); store.error = (err && err.message) ? String(err.message) : 'Could not open secure payment.'; }, }); + // Paystack often inserts frames a tick after resumeTransaction. + setTimeout(function () { ensurePaystackContained(document); }, 0); + setTimeout(function () { ensurePaystackContained(document); }, 50); + setTimeout(function () { ensurePaystackContained(document); }, 200); }).catch(function (err) { if (token !== store._launchToken) return; store.launching = false; store._activeCode = ''; + setPaystackContainActive(false); store.error = (err && err.message) ? String(err.message) : 'Could not load payment script.'; }); }, @@ -452,6 +632,43 @@ })(); @endonce +{{-- Override Paystack's full-viewport popup styles so checkout sits in our sheet mount. --}} +@once('ladill-paystack-contain-css') + +@endonce