diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php index 3183cd4..2a0d785 100644 --- a/config/ladill_launcher.php +++ b/config/ladill_launcher.php @@ -32,7 +32,6 @@ return [ ['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'], ['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'], ['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'], - ['name' => 'One', 'url' => 'https://one.'.$root.'/sso/connect?redirect='.urlencode('https://one.'.$root.'/dashboard'), 'icon' => 'one.svg'], ['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'], ['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'], ['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'], diff --git a/resources/js/app.js b/resources/js/app.js index c755128..e13d5f4 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -165,6 +165,8 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ errorMsg: config.errorMsg ?? '', showSheet: false, checkoutUrl: '', + accessCode: '', + returnUrl: '', paymentSheetStyle: '', sheetBleedStyle: '', @@ -241,8 +243,10 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ this.loading = false; return; } - this.checkoutUrl = data.checkout_url; - this.showSheet = true; + this.checkoutUrl = data.checkout_url || data.authorization_url || ''; + this.accessCode = data.access_code || ''; + this.returnUrl = data.callback_url || this.returnUrl || ''; + this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened) } catch (e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; diff --git a/resources/views/components/user/service-topup-modal.blade.php b/resources/views/components/user/service-topup-modal.blade.php index 65d6938..308796b 100644 --- a/resources/views/components/user/service-topup-modal.blade.php +++ b/resources/views/components/user/service-topup-modal.blade.php @@ -56,8 +56,7 @@ } this.checkoutUrl = data.checkout_url; - this.showSheet = true; - this.loading = false; + this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened) } catch (error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php index 558f236..51f3e62 100644 --- a/resources/views/partials/paystack-sheet.blade.php +++ b/resources/views/partials/paystack-sheet.blade.php @@ -1,5 +1,5 @@ {{-- - Responsive payment checkout shell: bottom sheet on mobile, centered modal on desktop. + Payment checkout shell. Audiences: - buyer (default): public ticket buyers, donors, shoppers — self-serve checkout @@ -10,12 +10,15 @@ - showSheet (bool) - checkoutUrl (string) — authorization / waiting URL - accessCode (string, optional) — Paystack initialize access_code for Inline - - publicKey (string, optional) — unused by resumeTransaction; kept for callers + - publicKey (string, optional) - returnUrl (string, optional) — where to go after Inline onSuccess (?reference=) checkout.paystack.com cannot be iframed (X-Frame-Options: SAMEORIGIN). - Primary path: Paystack Inline JS (PaystackPop.resumeTransaction) — in-page overlay, - not a separate browser tab. Same-origin URLs (e.g. MoMo waiting) still use iframe. + + Paystack Inline owns its secure payment popup. We do not place a Ladill + loading sheet in front of it: that created a needless first screen before + the checkout appeared. The Ladill bottomsheet/modal is reserved for + same-origin waiting pages (such as MoMo) and recoverable errors. --}} @php $audience = $audience ?? 'buyer'; @@ -117,15 +120,6 @@ return inlineLoading; } - // Warm the Paystack script early so resumeTransaction is ready on Pay click. - if (document.readyState === 'complete' || document.readyState === 'interactive') { - loadInlineJs().catch(function () {}); - } else { - document.addEventListener('DOMContentLoaded', function () { - loadInlineJs().catch(function () {}); - }); - } - function buildReturnUrl(returnUrl, reference) { if (!returnUrl || !reference) return ''; try { @@ -145,377 +139,31 @@ } } catch (e) {} activePopup = null; - setPaystackContainActive(false); - } - - // Paystack Inline always body-appends a full-viewport modal (position:fixed 100%). - // Strategy: - // 1) On FIRST append only, put checkout iframe into our sheet mount (no later reparent - // — reparent after load reloads the frame and breaks Paystack). - // 2) Force position:absolute + fill mount (fixed+100% always paints the whole viewport - // even when the node is nested under the sheet). - // 3) Continuously re-apply styles (Paystack rewrites style on open/animate). - // 4) Hide Paystack's own dimmer; Ladill sheet owns backdrop + 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']; - var activeMountEl = null; - var activeBodyEl = null; - var pinRafId = 0; - var lastPinRect = null; - var nativeAppendChild = Node.prototype.appendChild; - var nativeInsertBefore = Node.prototype.insertBefore; - - function isElementShown(el) { - if (!el || el.nodeType !== 1) return false; - if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return false; - var p = el; - while (p && p.nodeType === 1) { - try { - var s = window.getComputedStyle(p); - if (s.display === 'none' || s.visibility === 'hidden') return false; - } catch (e) {} - p = p.parentElement; - } - return true; - } - - function resolveOpenSheet() { - var sheets = document.querySelectorAll('[data-ladill-pay-sheet]'); - var fallback = null; - for (var i = 0; i < sheets.length; i++) { - var sheet = sheets[i]; - if (!fallback) fallback = sheet; - if (sheet.getAttribute('aria-hidden') !== 'true') return sheet; - } - for (var j = 0; j < sheets.length; j++) { - if (isElementShown(sheets[j])) return sheets[j]; - } - return fallback; - } - - function resolvePaystackMount() { - var sheet = resolveOpenSheet(); - if (sheet) { - var mount = sheet.querySelector('[data-ladill-pay-mount]'); - if (mount) return mount; - var body = sheet.querySelector('[data-ladill-pay-body]'); - if (body) return body; - } - return document.querySelector('[data-ladill-pay-mount]') - || document.querySelector('[data-ladill-pay-body]'); - } - - function getPaystackMount() { - if (activeMountEl && activeMountEl.isConnected) return activeMountEl; - return resolvePaystackMount(); - } - - function getPaystackBody() { - if (activeBodyEl && activeBodyEl.isConnected) return activeBodyEl; - var mount = getPaystackMount(); - if (mount) { - var body = mount.closest('[data-ladill-pay-body]'); - if (body) return body; - } - var sheet = resolveOpenSheet(); - return sheet ? sheet.querySelector('[data-ladill-pay-body]') : document.querySelector('[data-ladill-pay-body]'); - } - - function armPaystackMount() { - var sheet = resolveOpenSheet(); - activeMountEl = sheet - ? (sheet.querySelector('[data-ladill-pay-mount]') || sheet.querySelector('[data-ladill-pay-body]')) - : resolvePaystackMount(); - activeBodyEl = sheet - ? sheet.querySelector('[data-ladill-pay-body]') - : getPaystackBody(); - // Keep the mount measurable while we pin (Alpine x-show can set display:none). - try { - if (activeMountEl) { - activeMountEl.style.setProperty('display', 'block', 'important'); - activeMountEl.style.setProperty('visibility', 'visible', 'important'); - activeMountEl.style.setProperty('pointer-events', 'none', 'important'); - } - } catch (e) {} - return activeMountEl; - } - - function measurePinTarget() { - var el = getPaystackMount() || getPaystackBody(); - if (!el) return null; - var rect = el.getBoundingClientRect(); - if (rect.width >= 40 && rect.height >= 40) { - return rect; - } - // Mount may still be display:none — fall back to the white body panel. - var body = getPaystackBody(); - if (body && body !== el) { - rect = body.getBoundingClientRect(); - if (rect.width >= 40 && rect.height >= 40) return rect; - } - return null; - } - - function waitForPinTarget(timeoutMs) { - timeoutMs = timeoutMs || 1200; - return new Promise(function (resolve) { - var start = Date.now(); - var tick = function () { - armPaystackMount(); - var rect = measurePinTarget(); - if (rect) { - syncPaystackMountBox(); - resolve(rect); - return; - } - if (Date.now() - start >= timeoutMs) { - syncPaystackMountBox(); - resolve(null); - return; - } - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(tick); - } else { - setTimeout(tick, 16); - } - }; - tick(); - }); - } - - 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(); - var name = (iframe.getAttribute('name') || iframe.name || '').toLowerCase(); - var src = ''; - try { src = (iframe.getAttribute('src') || iframe.src || '').toLowerCase(); } catch (e) {} - return src.indexOf('paystack') !== -1 - || src.indexOf('checkout.paystack.com') !== -1 - || 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; - } - - function mergeIframeAllow(current) { - var parts = current - ? current.split(';').map(function (p) { return p.trim(); }).filter(Boolean) - : []; - var lower = parts.map(function (p) { return p.toLowerCase(); }); - PAYSTACK_ALLOW_FEATURES.forEach(function (perm) { - if (!lower.some(function (p) { return p === perm || p.indexOf(perm + ' ') === 0; })) { - parts.push(perm + ' *'); - } - }); - return parts.join('; ') || PAYSTACK_IFRAME_ALLOW; } + // Fullscreen permission for Paystack's own iframes (do not reparent them). + // Only write attributes when they actually change — writing always re-fires + // MutationObservers and freezes the page (wallet include path). function applyIframePermissions(iframe) { if (!iframe || !iframe.setAttribute) return; try { - var next = mergeIframeAllow((iframe.getAttribute('allow') || '').trim()); - if ((iframe.getAttribute('allow') || '') !== next) { + var cur = (iframe.getAttribute('allow') || '').trim(); + var need = ['payment *', 'fullscreen *', 'clipboard-read *', 'clipboard-write *', 'publickey-credentials-get *']; + var parts = cur ? cur.split(';').map(function (p) { return p.trim(); }).filter(Boolean) : []; + var lower = parts.map(function (p) { return p.toLowerCase(); }); + need.forEach(function (perm) { + var base = perm.split(' ')[0].toLowerCase(); + if (!lower.some(function (p) { return p === base || p.indexOf(base + ' ') === 0; })) { + parts.push(perm); + } + }); + var next = parts.join('; '); + if (cur !== next) { iframe.setAttribute('allow', next); } if (!iframe.hasAttribute('allowfullscreen')) { iframe.setAttribute('allowfullscreen', ''); } - try { iframe.allowFullscreen = true; } catch (e) {} - } catch (e) {} - } - - function isCheckoutFrame(iframe) { - if (!iframe || !iframe.tagName || iframe.tagName.toUpperCase() !== 'IFRAME') return false; - var id = (iframe.id || ''); - return id.indexOf('inline-checkout') === 0 - || id.indexOf('embed-checkout') === 0 - || iframe.hasAttribute('data-ladill-pay-contained'); - } - - function styleMountAsContainer(mount) { - if (!mount) return; - try { - // Create a fixed containing block so position:fixed descendants still - // cannot escape to the viewport (transform + isolation). - var s = mount.style; - s.setProperty('position', 'absolute', 'important'); - s.setProperty('inset', '0', '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('overflow', 'hidden', 'important'); - s.setProperty('display', 'block', 'important'); - s.setProperty('visibility', 'visible', 'important'); - s.setProperty('background', '#fff', 'important'); - s.setProperty('transform', 'translateZ(0)', 'important'); - s.setProperty('isolation', 'isolate', 'important'); - s.setProperty('contain', 'layout size', 'important'); - s.setProperty('z-index', '1', 'important'); - } catch (e) {} - } - - function styleCheckoutInMount(iframe) { - if (!iframe) return; - try { - iframe.setAttribute('data-ladill-pay-contained', '1'); - iframe.setAttribute('data-ladill-pay-mode', 'mount'); - var s = iframe.style; - // Absolute fill of mount — NOT position:fixed 100% (that always paints viewport). - // Transparent so we don't paint a second white surface under Paystack's card. - 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('max-width', 'none', 'important'); - s.setProperty('max-height', 'none', 'important'); - s.setProperty('margin', '0', 'important'); - s.setProperty('padding', '0', 'important'); - s.setProperty('border', '0', 'important'); - s.setProperty('border-radius', '0', 'important'); - s.setProperty('box-shadow', 'none', 'important'); - s.setProperty('filter', 'none', 'important'); - s.setProperty('z-index', '2', 'important'); - s.setProperty('visibility', 'visible', 'important'); - s.setProperty('display', 'block', 'important'); - s.setProperty('opacity', '1', 'important'); - s.setProperty('background', 'transparent', 'important'); - s.setProperty('background-color', 'transparent', 'important'); - s.setProperty('pointer-events', 'auto', 'important'); - s.setProperty('color-scheme', 'light', 'important'); - // Scale up so Paystack's centered card fills the loading area; mount clips - // letterbox margins and the card's drop-shadow (cross-origin, can't restyle). - s.setProperty('transform', 'scale(1.32)', 'important'); - s.setProperty('transform-origin', 'center center', 'important'); - applyIframePermissions(iframe); - } catch (e) {} - } - - function styleCheckoutPinnedToRect(iframe, rect) { - if (!iframe || !rect) return; - try { - iframe.setAttribute('data-ladill-pay-contained', '1'); - iframe.setAttribute('data-ladill-pay-mode', 'pin'); - var s = iframe.style; - var left = Math.max(0, Math.round(rect.left)); - var top = Math.max(0, Math.round(rect.top)); - var width = Math.max(0, Math.round(rect.width)); - var height = Math.max(0, Math.round(rect.height)); - // Direct pixels — CSS vars in setProperty are unreliable across browsers. - s.setProperty('position', 'fixed', 'important'); - s.setProperty('left', left + 'px', 'important'); - s.setProperty('top', top + 'px', 'important'); - s.setProperty('right', 'auto', 'important'); - s.setProperty('bottom', 'auto', 'important'); - s.setProperty('width', width + 'px', 'important'); - s.setProperty('height', height + 'px', 'important'); - s.setProperty('max-width', 'none', 'important'); - s.setProperty('max-height', 'none', 'important'); - s.setProperty('margin', '0', 'important'); - s.setProperty('padding', '0', 'important'); - s.setProperty('border', '0', 'important'); - s.setProperty('border-radius', '0', 'important'); - s.setProperty('box-shadow', 'none', 'important'); - s.setProperty('filter', 'none', 'important'); - s.setProperty('z-index', '10050', 'important'); - s.setProperty('visibility', 'visible', 'important'); - s.setProperty('display', 'block', 'important'); - s.setProperty('opacity', '1', 'important'); - s.setProperty('background', 'transparent', 'important'); - s.setProperty('background-color', 'transparent', 'important'); - s.setProperty('pointer-events', 'auto', 'important'); - s.setProperty('color-scheme', 'light', 'important'); - s.setProperty('transform', 'scale(1.32)', 'important'); - s.setProperty('transform-origin', 'center center', 'important'); - applyIframePermissions(iframe); - } catch (e) {} - } - - function pinCheckoutIframe(iframe) { - if (!iframe || !window.__ladillContainPaystack) return; - var mount = getPaystackMount(); - if (mount) { - styleMountAsContainer(mount); - // Prefer living inside the mount (absolute fill). Only first-append - // redirects into mount; if already elsewhere, pin with fixed+pixels. - if (iframe.parentNode === mount) { - styleCheckoutInMount(iframe); - return; - } - } - var rect = measurePinTarget() || lastPinRect; - if (rect) { - lastPinRect = rect; - styleCheckoutPinnedToRect(iframe, rect); - } - } - - 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'); - s.setProperty('z-index', '-1', 'important'); - s.setProperty('width', '0', 'important'); - s.setProperty('height', '0', 'important'); - s.setProperty('left', '-9999px', 'important'); - s.setProperty('top', '-9999px', 'important'); - s.setProperty('position', 'fixed', 'important'); - } catch (e) {} - } - - function containPaystackIframe(iframe) { - if (!iframe || !window.__ladillContainPaystack) return; - if (isPaystackBackgroundFrame(iframe)) { - hidePaystackBackground(iframe); - return; - } - if (!isCheckoutFrame(iframe) && !isPaystackCheckoutFrame(iframe)) { - if (isPaystackIframe(iframe)) applyIframePermissions(iframe); - return; - } - pinCheckoutIframe(iframe); - } - - function ensurePaystackContained(root) { - if (!window.__ladillContainPaystack) return; - try { - syncPaystackMountBox(); - var bgs = document.querySelectorAll('iframe[id^="inline-background"]'); - for (var j = 0; j < bgs.length; j++) hidePaystackBackground(bgs[j]); - - var checks = document.querySelectorAll( - 'iframe[id^="inline-checkout"], iframe[id^="embed-checkout"], iframe[data-ladill-pay-contained]' - ); - for (var k = 0; k < checks.length; k++) pinCheckoutIframe(checks[k]); - - if (root && root !== document && root.querySelectorAll) { - var nested = root.querySelectorAll('iframe'); - for (var i = 0; i < nested.length; i++) containPaystackIframe(nested[i]); - } } catch (e) {} } @@ -525,116 +173,16 @@ var nodes = scope.querySelectorAll ? scope.querySelectorAll('iframe') : []; for (var i = 0; i < nodes.length; i++) { var iframe = nodes[i]; - if (isPaystackIframe(iframe) || isCheckoutFrame(iframe)) { + var id = (iframe.id || '').toLowerCase(); + var src = ''; + try { src = (iframe.getAttribute('src') || '').toLowerCase(); } catch (e) {} + if (id.indexOf('inline-') === 0 || id.indexOf('embed-') === 0 || src.indexOf('paystack') !== -1) { applyIframePermissions(iframe); } } - ensurePaystackContained(scope); } catch (e) {} } - function syncPaystackMountBox() { - try { - if (!window.__ladillContainPaystack) return; - var rect = measurePinTarget(); - var root = document.documentElement; - if (!rect) { - root.style.setProperty('--ladill-pay-left', '0px'); - root.style.setProperty('--ladill-pay-top', '0px'); - root.style.setProperty('--ladill-pay-width', '0px'); - root.style.setProperty('--ladill-pay-height', '0px'); - return; - } - lastPinRect = rect; - root.style.setProperty('--ladill-pay-left', Math.max(0, Math.round(rect.left)) + 'px'); - root.style.setProperty('--ladill-pay-top', Math.max(0, Math.round(rect.top)) + 'px'); - root.style.setProperty('--ladill-pay-width', Math.max(0, Math.round(rect.width)) + 'px'); - root.style.setProperty('--ladill-pay-height', Math.max(0, Math.round(rect.height)) + 'px'); - } catch (e) {} - } - - function startPinLoop() { - stopPinLoop(); - var tick = function () { - if (!window.__ladillContainPaystack) { - pinRafId = 0; - return; - } - ensurePaystackContained(document); - pinRafId = typeof requestAnimationFrame === 'function' - ? requestAnimationFrame(tick) - : setTimeout(tick, 32); - }; - tick(); - } - - function stopPinLoop() { - if (!pinRafId) return; - if (typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(pinRafId); - } else { - clearTimeout(pinRafId); - } - pinRafId = 0; - } - - function setPaystackContainActive(active) { - window.__ladillContainPaystack = !!active; - try { - document.documentElement.classList.toggle('ladill-pay-inline-active', !!active); - } catch (e) {} - if (active) { - armPaystackMount(); - styleMountAsContainer(getPaystackMount()); - installIframeAllowHook(); - installAppendHook(); - ensurePaystackContained(document); - syncPaystackMountBox(); - startPinLoop(); - if (!window.__ladillPayMountResizeBound) { - window.__ladillPayMountResizeBound = true; - window.addEventListener('resize', function () { - syncPaystackMountBox(); - ensurePaystackContained(document); - }, { passive: true }); - window.addEventListener('orientationchange', function () { - setTimeout(function () { - syncPaystackMountBox(); - ensurePaystackContained(document); - }, 100); - }, { passive: true }); - window.addEventListener('scroll', function () { - if (window.__ladillContainPaystack) { - syncPaystackMountBox(); - ensurePaystackContained(document); - } - }, { passive: true, capture: true }); - } - } else { - stopPinLoop(); - lastPinRect = null; - try { - if (activeMountEl) { - activeMountEl.style.removeProperty('display'); - activeMountEl.style.removeProperty('visibility'); - activeMountEl.style.removeProperty('pointer-events'); - activeMountEl.style.removeProperty('transform'); - activeMountEl.style.removeProperty('isolation'); - activeMountEl.style.removeProperty('contain'); - } - } catch (e) {} - activeMountEl = null; - activeBodyEl = null; - try { - var root = document.documentElement; - root.style.removeProperty('--ladill-pay-left'); - root.style.removeProperty('--ladill-pay-top'); - root.style.removeProperty('--ladill-pay-width'); - root.style.removeProperty('--ladill-pay-height'); - } catch (e) {} - } - } - function installIframeAllowHook() { if (window.__ladillIframeAllowHook) return; window.__ladillIframeAllowHook = true; @@ -652,134 +200,48 @@ } catch (e) {} } - function installAppendHook() { - if (window.__ladillAppendHook) return; - window.__ladillAppendHook = true; - try { - Node.prototype.appendChild = function (child) { - try { - if (window.__ladillContainPaystack && child && child.tagName === 'IFRAME') { - if (isPaystackBackgroundFrame(child)) { - var bg = nativeAppendChild.call(this, child); - hidePaystackBackground(child); - return bg; - } - if (isCheckoutFrame(child) || isPaystackCheckoutFrame(child) - || (child.id || '').indexOf('inline-checkout') === 0 - || (child.id || '').indexOf('embed-checkout') === 0) { - var mount = getPaystackMount(); - if (mount) { - styleMountAsContainer(mount); - // FIRST insert goes into mount — avoids later reparent reload. - var mounted = nativeAppendChild.call(mount, child); - styleCheckoutInMount(child); - return mounted; - } - } - } - } catch (e) {} - var result = nativeAppendChild.call(this, child); - try { - if (window.__ladillContainPaystack && child && child.tagName === 'IFRAME') { - containPaystackIframe(child); - } - } catch (e2) {} - return result; - }; - - Node.prototype.insertBefore = function (child, ref) { - try { - if (window.__ladillContainPaystack && child && child.tagName === 'IFRAME') { - if (isPaystackBackgroundFrame(child)) { - var bg = nativeInsertBefore.call(this, child, ref); - hidePaystackBackground(child); - return bg; - } - if (isCheckoutFrame(child) || isPaystackCheckoutFrame(child)) { - var mount = getPaystackMount(); - if (mount) { - styleMountAsContainer(mount); - var mounted = nativeAppendChild.call(mount, child); - styleCheckoutInMount(child); - return mounted; - } - } - } - } catch (e) {} - var result = nativeInsertBefore.call(this, child, ref); - try { - if (window.__ladillContainPaystack && child && child.tagName === 'IFRAME') { - containPaystackIframe(child); - } - } catch (e2) {} - return result; - }; - } catch (e) {} - } - + /** + * Arm iframe permission helpers only while a payment is starting. + * Do not install on every wallet/page load — observing style/class + always + * rewriting allow caused an infinite MutationObserver loop (page unresponsive). + */ function watchPaystackIframes() { if (window.__ladillPaystackIframeWatch) return; window.__ladillPaystackIframeWatch = true; installIframeAllowHook(); - installAppendHook(); ensurePaystackIframePermissions(document); - try { var obs = new MutationObserver(function (mutations) { - var dirty = false; for (var i = 0; i < mutations.length; i++) { var m = mutations[i]; - if (m.type === 'attributes' && m.target) { - if (m.target.tagName && m.target.tagName.toUpperCase() === 'IFRAME') { - 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; - } - continue; - } + // childList only — never re-enter on attribute writes. if (!m.addedNodes || !m.addedNodes.length) continue; for (var n = 0; n < m.addedNodes.length; n++) { var node = m.addedNodes[n]; if (!node || node.nodeType !== 1) continue; - if (node.tagName && node.tagName.toUpperCase() === 'IFRAME') { + if (node.tagName === 'IFRAME') { 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) { - ensurePaystackIframePermissions(document); - } }); obs.observe(document.documentElement || document.body, { childList: true, subtree: true, - attributes: true, - attributeFilter: ['allow', 'src', 'id', 'name', 'style', 'class'], }); } catch (e) {} - - var polls = 0; - var pollId = setInterval(function () { - ensurePaystackIframePermissions(document); - polls += 1; - if (polls >= 60) clearInterval(pollId); - }, 200); } + function emitPayEvent(name, detail) { + try { + window.dispatchEvent(new CustomEvent(name, { detail: detail || {} })); + } catch (e) {} + } function createStoreDefinition() { return { @@ -790,6 +252,10 @@ error: '', _launchToken: 0, _activeCode: '', + _openedEmitted: false, + shellVisible() { + return !!(this.frameable || this.error); + }, reset() { this.frameable = false; this.inline = false; @@ -797,8 +263,22 @@ this.launching = false; this.error = ''; this._activeCode = ''; + this._openedEmitted = false; cancelInline(); }, + markOpened() { + if (this._openedEmitted) return; + this._openedEmitted = true; + this.launching = false; + emitPayEvent('ladill-pay-opened'); + }, + markFailed(message) { + this.launching = false; + this._activeCode = ''; + this._openedEmitted = false; + this.error = message || 'Could not open secure payment.'; + emitPayEvent('ladill-pay-error', { message: this.error }); + }, sync(show, url, accessCode, returnUrl) { url = (url || '').toString(); accessCode = (accessCode || '').toString(); @@ -809,25 +289,35 @@ this.reset(); return; } + this.ready = !!(url || accessCode); + // Keep a previous recoverable error only until a new attempt supplies payload. + if (url || accessCode) { + this.error = ''; + } + if (!url && !accessCode) { + // Waiting for parent to set access_code / checkout_url after fetch. this.frameable = false; this.inline = false; - this.launching = true; // waiting for checkout payload - this.error = ''; + this.launching = true; this._activeCode = ''; - setPaystackContainActive(false); + this._openedEmitted = false; return; } + if (isFrameable(url)) { this.frameable = true; this.inline = false; this.launching = false; - this.error = ''; this._activeCode = ''; + this._openedEmitted = false; cancelInline(); + // Same-origin waiting UI is our shell — it is "open" for busy-state release. + emitPayEvent('ladill-pay-opened'); return; } + var code = resolveAccessCode(accessCode, url); if (code) { this.frameable = false; @@ -838,15 +328,14 @@ this.launchInline(code, returnUrl); return; } - // Never navigate to checkout.paystack.com (external tab/window). + this.frameable = false; 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.'; + this.markFailed( + isPaystackCheckoutUrl(url) + ? 'Could not start in-app payment. Please try again.' + : 'Secure checkout could not be opened in-page. Please try again.' + ); }, launchInline(accessCode, returnUrl) { var store = this; @@ -854,90 +343,84 @@ this._activeCode = accessCode; this.launching = true; this.error = ''; - // Wait for the bottomsheet/modal body to lay out, then open Paystack - // and pin its body iframe over that loading area (no reparent). - var start = function () { + this.inline = true; + this.frameable = false; + this._openedEmitted = false; + + watchPaystackIframes(); + + // Ensure Paystack script is fully ready before resumeTransaction so we + // do not paint an empty popup shell for a beat (the pre-modal flash). + loadInlineJs().then(function (PaystackPop) { if (token !== store._launchToken) return; - armPaystackMount(); - setPaystackContainActive(true); - watchPaystackIframes(); - waitForPinTarget(1200).then(function () { - if (token !== store._launchToken) return; - return loadInlineJs(); - }).then(function (PaystackPop) { - if (token !== store._launchToken) return; - if (!PaystackPop) throw new Error('Paystack unavailable'); - try { - if (activePopup && typeof activePopup.cancelTransaction === 'function') { - activePopup.cancelTransaction(); + if (!PaystackPop) throw new Error('Paystack unavailable'); + + cancelInline(); + + var popup = new PaystackPop(); + activePopup = popup; + ensurePaystackIframePermissions(document); + + popup.resumeTransaction(accessCode, { + onLoad: function () { + if (token !== store._launchToken) return; + store.markOpened(); + ensurePaystackIframePermissions(document); + }, + onSuccess: function (transaction) { + if (token !== store._launchToken) return; + store.launching = false; + activePopup = null; + var reference = (transaction && (transaction.reference || transaction.trxref)) || ''; + var dest = buildReturnUrl(returnUrl, reference); + if (dest) { + window.location.assign(dest); + return; } - } catch (e) {} - activePopup = null; - armPaystackMount(); - setPaystackContainActive(true); - syncPaystackMountBox(); - // PaystackPop constructor appends iframes to document.body. - // We leave them there and pin checkout over our sheet body. - var popup = new PaystackPop(); - activePopup = popup; - ensurePaystackContained(document); - popup.resumeTransaction(accessCode, { - onLoad: function () { - if (token !== store._launchToken) return; - store.launching = false; - ensurePaystackIframePermissions(document); - ensurePaystackContained(document); - // Alpine swaps header/spinner; re-measure then re-pin. - setTimeout(function () { ensurePaystackContained(document); }, 0); - setTimeout(function () { ensurePaystackContained(document); }, 50); - setTimeout(function () { ensurePaystackContained(document); }, 150); - setTimeout(function () { ensurePaystackContained(document); }, 400); - }, - 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) { - window.location.assign(dest); - return; - } - window.location.reload(); - }, - onCancel: function () { - if (token !== store._launchToken) return; - 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.'; - }, - }); - setTimeout(function () { ensurePaystackContained(document); }, 0); - setTimeout(function () { ensurePaystackContained(document); }, 50); - setTimeout(function () { ensurePaystackContained(document); }, 200); - setTimeout(function () { ensurePaystackContained(document); }, 500); - }).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.'; + window.location.reload(); + }, + onCancel: function () { + if (token !== store._launchToken) return; + store.launching = false; + store._activeCode = ''; + store._openedEmitted = false; + activePopup = null; + emitPayEvent('ladill-pay-cancelled'); + }, + onError: function (err) { + if (token !== store._launchToken) return; + activePopup = null; + store.markFailed((err && err.message) ? String(err.message) : 'Could not open secure payment.'); + }, }); - }; - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(function () { requestAnimationFrame(start); }); - } else { - setTimeout(start, 32); - } + + // Safety: if onLoad is slow, treat a live checkout iframe as open. + var polls = 0; + var pollId = setInterval(function () { + if (token !== store._launchToken) { + clearInterval(pollId); + return; + } + ensurePaystackIframePermissions(document); + var live = document.querySelector( + 'iframe[id^="inline-checkout"], iframe[id^="embed-checkout"]' + ); + if (live) { + store.markOpened(); + clearInterval(pollId); + } + polls += 1; + if (polls >= 50) { + clearInterval(pollId); + if (store.launching && token === store._launchToken) { + store.markFailed('Payment UI did not open. Please try again.'); + } + } + }, 100); + }).catch(function (err) { + if (token !== store._launchToken) return; + store.markFailed((err && err.message) ? String(err.message) : 'Could not load payment script.'); + }); }, }; } @@ -955,20 +438,24 @@ document.addEventListener('alpine:init', function () { ensureStore(); }); - - // If Alpine already started (late include), register immediately. if (window.Alpine && window.Alpine.version) { ensureStore(); } - watchPaystackIframes(); + // Do not watch iframes or preload Paystack on every page that includes this + // partial (e.g. wallet). That froze the tab. prepare()/launchInline arm it. window.LadillPayCheckout = { isFrameable: isFrameable, isPaystackCheckoutUrl: isPaystackCheckoutUrl, accessCodeFromUrl: accessCodeFromUrl, resolveAccessCode: resolveAccessCode, - prepare: function () { loadInlineJs().catch(function () {}); return null; }, + prepare: function () { + // Warm script + hooks only when user is about to pay. + installIframeAllowHook(); + loadInlineJs().catch(function () {}); + return null; + }, cancel: function () { cancelInline(); var store = ensureStore(); @@ -980,131 +467,25 @@ })(); @endonce -{{-- Keep Paystack checkout flush inside the Ladill sheet loading area (no double card). --}} -@once('ladill-paystack-contain-css') - -@endonce diff --git a/tests/Feature/ResponsivePaystackSheetTest.php b/tests/Feature/ResponsivePaystackSheetTest.php index 8da1d1b..929a80a 100644 --- a/tests/Feature/ResponsivePaystackSheetTest.php +++ b/tests/Feature/ResponsivePaystackSheetTest.php @@ -14,18 +14,56 @@ class ResponsivePaystackSheetTest extends TestCase $this->assertStringContainsString('role="dialog"', $html); $this->assertStringContainsString('aria-modal="true"', $html); + $this->assertStringContainsString('data-ladill-pay-sheet', $html); + $this->assertStringContainsString('data-ladill-pay-panel', $html); + $this->assertStringContainsString('data-ladill-pay-backdrop', $html); + $this->assertStringContainsString('data-ladill-pay-handle', $html); $this->assertStringContainsString('md:items-center', $html); $this->assertStringContainsString('rounded-t-2xl', $html); - $this->assertStringContainsString('md:h-[min(720px,85vh)]', $html); + $this->assertStringContainsString('md:rounded-2xl', $html); + $this->assertStringContainsString('md:max-w-lg', $html); + $this->assertStringContainsString('data-paystack-live', $html); + $this->assertStringContainsString('data-ladill-pay-close-live', $html); + $this->assertStringContainsString('data-ladill-pay-header-minimal', $html); $this->assertStringContainsString('Complete your payment', $html); $this->assertStringContainsString('safe-area-inset-bottom', $html); $this->assertStringContainsString('LadillPayCheckout', $html); - $this->assertStringContainsString('Continue to Paystack', $html); - $this->assertStringContainsString('ladill_pay_checkout', $html); - $this->assertStringContainsString('prepare:', $html); + $this->assertStringContainsString('resumeTransaction', $html); + $this->assertStringContainsString("\$watch('showSheet'", $html); + $this->assertStringContainsString('js.paystack.co/v2/inline.js', $html); + $this->assertStringContainsString('items-end justify-center md:items-center', $html); + $this->assertStringContainsString('rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl', $html); + $this->assertStringContainsString('ensurePaystackIframePermissions', $html); + $this->assertStringContainsString('installIframeAllowHook', $html); + $this->assertStringContainsString('fullscreen *', $html); + $this->assertStringNotContainsString("attributeFilter: ['allow', 'src', 'id', 'name', 'style', 'class']", $html); + $this->assertStringContainsString('childList only', $html); + $this->assertStringContainsString('Only write attributes when they actually change', $html); + $this->assertStringContainsString('Do not watch iframes or preload Paystack on every page', $html); + $this->assertStringContainsString('data-ladill-pay-mount', $html); + $this->assertStringContainsString('data-ladill-pay-body', $html); + $this->assertStringNotContainsString('styleCheckoutPinnedToRect', $html); + $this->assertStringNotContainsString('waitForPinTarget', $html); + $this->assertStringNotContainsString('placeCheckoutInMount', $html); + $this->assertStringNotContainsString('installAppendHook', $html); + $this->assertStringNotContainsString('__ladillContainPaystack', $html); + $this->assertStringNotContainsString('Node.prototype.appendChild', $html); + $this->assertStringNotContainsString('data-ladill-pay-inline-status', $html); + $this->assertStringNotContainsString('Please wait', $html); + $this->assertStringContainsString('Payment UI did not open', $html); + $this->assertStringContainsString('ladill-pay-opened', $html); + $this->assertStringContainsString('Only lock page scroll when OUR shell is visible', $html); + $this->assertStringContainsString('releaseBusy', $html); + $this->assertStringContainsString('markOpened', $html); + $this->assertStringNotContainsString('Continue to Paystack', $html); + $this->assertStringNotContainsString('window.open(', $html); + $this->assertStringNotContainsString('hidden w-full max-w-lg', $html); $this->assertStringNotContainsString('Paystack checkout', $html); + $this->assertStringNotContainsString('bg-transparent shadow-none', $html); + $this->assertStringNotContainsString('not in a separate browser', $html); } + public function test_payment_return_escapes_popup_and_iframe_to_opener_or_top(): void { $html = view('public.payment-return', [