From 837c24963465633911eb0e1b7fc98047f3df3a3e Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 21 Jul 2026 19:12:24 +0000 Subject: [PATCH] Restore Paystack Inline in-page sheet/modal; stop separate-browser checkout. Pass access_code through pay responses and launch PaystackPop from the shared sheet instead of window.open. --- .../Public/EventRegistrationController.php | 3 + .../Events/EventRegistrationService.php | 2 + .../user/service-topup-modal.blade.php | 3 + .../views/partials/paystack-sheet.blade.php | 296 +++++++++++++----- resources/views/public/qr/landing.blade.php | 17 +- tests/Feature/ResponsivePaystackSheetTest.php | 10 +- 6 files changed, 243 insertions(+), 88 deletions(-) diff --git a/app/Http/Controllers/Public/EventRegistrationController.php b/app/Http/Controllers/Public/EventRegistrationController.php index ded32b6..5af413d 100644 --- a/app/Http/Controllers/Public/EventRegistrationController.php +++ b/app/Http/Controllers/Public/EventRegistrationController.php @@ -45,6 +45,9 @@ class EventRegistrationController extends Controller return response()->json([ 'paid' => $result['paid'], 'checkout_url' => $result['checkout_url'], + 'access_code' => $result['access_code'] ?? null, + 'public_key' => $result['public_key'] ?? null, + 'callback_url' => $result['callback_url'] ?? null, 'badge_code' => $result['registration']->badge_code, 'success_url' => $this->confirmedUrl($qrCode, $result['registration'], $request->input('meet_return')), ]); diff --git a/app/Services/Events/EventRegistrationService.php b/app/Services/Events/EventRegistrationService.php index 191d118..2e80d7c 100644 --- a/app/Services/Events/EventRegistrationService.php +++ b/app/Services/Events/EventRegistrationService.php @@ -178,6 +178,8 @@ class EventRegistrationService 'registration' => $registration->fresh(), 'paid' => true, 'checkout_url' => $checkoutUrl, + 'access_code' => isset($payOrder['access_code']) ? (string) $payOrder['access_code'] : null, + 'public_key' => isset($payOrder['public_key']) ? (string) $payOrder['public_key'] : null, ]; } diff --git a/resources/views/components/user/service-topup-modal.blade.php b/resources/views/components/user/service-topup-modal.blade.php index 7d41d93..db85e33 100644 --- a/resources/views/components/user/service-topup-modal.blade.php +++ b/resources/views/components/user/service-topup-modal.blade.php @@ -56,6 +56,9 @@ } this.checkoutUrl = data.checkout_url; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; this.showSheet = true; this.loading = false; } catch (error) { diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php index 9817a4e..4aee647 100644 --- a/resources/views/partials/paystack-sheet.blade.php +++ b/resources/views/partials/paystack-sheet.blade.php @@ -6,13 +6,16 @@ - operator: signed-in staff flows such as wallet top-up Optional overrides: $sheetTitle, $sheetSubtitle, $sheetFooter, $sheetAria, $iframeTitle - Requires Alpine ancestor with: showSheet (bool), checkoutUrl (string). + Requires Alpine ancestor with: + - 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 + - returnUrl (string, optional) — where to go after Inline onSuccess (?reference=) - Paystack (checkout.paystack.com) sends X-Frame-Options: SAMEORIGIN and cannot be - embedded. Same-origin URLs (e.g. MoMo waiting pages) still load in an iframe. - External checkouts stay in this chrome with a Continue CTA that opens Paystack - in a named window (user gesture — not blocked). Auto-popups are intentionally - avoided so the sheet/modal always remains visibly on screen. + Paystack 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. --}} @php $audience = $audience ?? 'buyer'; @@ -43,8 +46,9 @@ (function () { if (window.LadillPayCheckout) return; - var PENDING_NAME = 'ladill_pay_checkout'; - var pendingWindow = null; + var INLINE_SRC = 'https://js.paystack.co/v2/inline.js'; + var inlineLoading = null; + var activePopup = null; function isFrameable(url) { if (!url) return false; @@ -55,46 +59,80 @@ } } - function writePendingPlaceholder(win) { - if (!win) return; + function isPaystackCheckoutUrl(url) { + if (!url) return false; try { - win.document.open(); - win.document.write('Payment

Opening secure checkout…

'); - win.document.close(); - } catch (e) {} - } - - // Kept for callers; no longer opens a window on Pay click (that stole focus - // from the sheet/modal). Prefer opening from the in-sheet Continue CTA. - function prepare() { - return null; - } - - function cancel() { - if (pendingWindow && !pendingWindow.closed) { - try { pendingWindow.close(); } catch (e) {} - } - pendingWindow = null; - } - - function openCheckout(url) { - if (!url) return null; - try { - if (pendingWindow && !pendingWindow.closed) { - pendingWindow.location = url; - var win = pendingWindow; - pendingWindow = null; - return win; - } - pendingWindow = null; - // Named window (no noopener) so payment-return can redirect window.opener. - return window.open(url, PENDING_NAME, 'width=480,height=720'); + var host = new URL(url, window.location.href).hostname.toLowerCase(); + return host === 'checkout.paystack.com' || host.endsWith('.paystack.com'); } catch (e) { - pendingWindow = null; - return null; + return false; } } + function accessCodeFromUrl(url) { + if (!url || !isPaystackCheckoutUrl(url)) return ''; + try { + var path = new URL(url, window.location.href).pathname.replace(/^\/+/, ''); + var code = path.split('/')[0] || ''; + return /^[A-Za-z0-9_-]{6,}$/.test(code) ? code : ''; + } catch (e) { + return ''; + } + } + + function resolveAccessCode(accessCode, checkoutUrl) { + var code = (accessCode || '').toString().trim(); + if (code) return code; + return accessCodeFromUrl(checkoutUrl); + } + + function loadInlineJs() { + if (window.PaystackPop) { + return Promise.resolve(window.PaystackPop); + } + if (inlineLoading) return inlineLoading; + inlineLoading = new Promise(function (resolve, reject) { + var existing = document.querySelector('script[data-ladill-paystack-inline]'); + if (existing) { + existing.addEventListener('load', function () { resolve(window.PaystackPop); }); + existing.addEventListener('error', function () { reject(new Error('Paystack script failed')); }); + return; + } + var script = document.createElement('script'); + script.src = INLINE_SRC; + script.async = true; + script.dataset.ladillPaystackInline = '1'; + script.onload = function () { resolve(window.PaystackPop); }; + script.onerror = function () { + inlineLoading = null; + reject(new Error('Paystack script failed')); + }; + document.head.appendChild(script); + }); + return inlineLoading; + } + + function buildReturnUrl(returnUrl, reference) { + if (!returnUrl || !reference) return ''; + try { + var u = new URL(returnUrl, window.location.href); + u.searchParams.set('reference', reference); + return u.toString(); + } catch (e) { + var join = returnUrl.indexOf('?') >= 0 ? '&' : '?'; + return returnUrl + join + 'reference=' + encodeURIComponent(reference); + } + } + + function cancelInline() { + try { + if (activePopup && typeof activePopup.cancelTransaction === 'function') { + activePopup.cancelTransaction(); + } + } catch (e) {} + activePopup = null; + } + function ensureStore() { if (!window.Alpine || typeof window.Alpine.store !== 'function') return null; if (window.Alpine.store('ladillPayCheckout')) { @@ -102,41 +140,109 @@ } window.Alpine.store('ladillPayCheckout', { frameable: false, - popupBlocked: false, + inline: false, ready: false, + launching: false, + error: '', + _launchToken: 0, + _activeCode: '', reset() { this.frameable = false; - this.popupBlocked = false; + this.inline = false; this.ready = false; + this.launching = false; + this.error = ''; + this._activeCode = ''; + cancelInline(); }, - sync(show, url) { + sync(show, url, accessCode, returnUrl) { if (!show) { + this._launchToken += 1; this.reset(); return; } - this.ready = !!url; - if (!url) { + this.ready = !!(url || accessCode); + if (!url && !accessCode) { this.frameable = false; - this.popupBlocked = false; + this.inline = false; + this.launching = false; + this._activeCode = ''; return; } - this.frameable = isFrameable(url); - // Never auto-open an external popup here — the sheet/modal must stay - // visible. External checkouts use the Continue CTA (user gesture). - this.popupBlocked = !this.frameable; - }, - continueTo(url) { - if (!url) return; if (isFrameable(url)) { this.frameable = true; - this.popupBlocked = false; + this.inline = false; + this.launching = false; + this._activeCode = ''; + cancelInline(); return; } - var popup = openCheckout(url); - this.popupBlocked = !(popup && !popup.closed); - if (this.popupBlocked) { - window.location.assign(url); + var code = resolveAccessCode(accessCode, url); + if (code) { + this.frameable = false; + this.inline = true; + if (this._activeCode === code && (this.launching || activePopup)) { + return; + } + this.launchInline(code, returnUrl); + return; } + // Non-Paystack external URL without access_code — cannot embed. + this.frameable = false; + this.inline = false; + this.launching = false; + this._activeCode = ''; + this.error = 'Secure checkout could not be opened in-page. Please try again.'; + }, + launchInline(accessCode, returnUrl) { + var store = this; + var token = ++this._launchToken; + this._activeCode = accessCode; + this.launching = true; + this.error = ''; + loadInlineJs().then(function (PaystackPop) { + if (token !== store._launchToken) return; + if (!PaystackPop) throw new Error('Paystack unavailable'); + cancelInline(); + var popup = new PaystackPop(); + activePopup = popup; + popup.resumeTransaction(accessCode, { + onLoad: function () { + if (token !== store._launchToken) return; + store.launching = false; + }, + onSuccess: function (transaction) { + if (token !== store._launchToken) return; + store.launching = false; + var reference = (transaction && (transaction.reference || transaction.trxref)) || ''; + var dest = buildReturnUrl(returnUrl, reference); + if (dest) { + window.location.assign(dest); + return; + } + // Fallback: reload so server can reconcile via webhook. + window.location.reload(); + }, + onCancel: function () { + if (token !== store._launchToken) return; + store.launching = false; + store._activeCode = ''; + activePopup = null; + window.dispatchEvent(new CustomEvent('ladill-pay-cancelled')); + }, + onError: function (err) { + if (token !== store._launchToken) return; + store.launching = false; + store._activeCode = ''; + store.error = (err && err.message) ? String(err.message) : 'Could not open secure payment.'; + }, + }); + }).catch(function (err) { + if (token !== store._launchToken) return; + store.launching = false; + store._activeCode = ''; + store.error = (err && err.message) ? String(err.message) : 'Could not load payment script.'; + }); }, }); return window.Alpine.store('ladillPayCheckout'); @@ -153,9 +259,16 @@ window.LadillPayCheckout = { isFrameable: isFrameable, - prepare: prepare, - cancel: cancel, - open: openCheckout, + isPaystackCheckoutUrl: isPaystackCheckoutUrl, + accessCodeFromUrl: accessCodeFromUrl, + resolveAccessCode: resolveAccessCode, + prepare: function () { return null; }, + cancel: function () { + cancelInline(); + var store = ensureStore(); + if (store) store.reset(); + }, + open: function () { return null; }, ensureStore: ensureStore, }; })(); @@ -172,10 +285,16 @@ } const store = $store.ladillPayCheckout; if (store) { - store.sync(showSheet, checkoutUrl); + store.sync( + showSheet, + typeof checkoutUrl !== 'undefined' ? checkoutUrl : '', + typeof accessCode !== 'undefined' ? accessCode : '', + typeof returnUrl !== 'undefined' ? returnUrl : '' + ); } " @keydown.escape.window="if (showSheet) showSheet = false" + @ladill-pay-cancelled.window="showSheet = false" class="fixed inset-0 z-[9999] flex items-end justify-center md:items-center md:p-6" role="dialog" aria-modal="true" @@ -191,11 +310,10 @@ x-transition:leave="transition-opacity duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" - @click="showSheet = false"> + @click="showSheet = false; window.LadillPayCheckout?.cancel?.()"> - {{-- One panel: bottom sheet (mobile) + centered modal (desktop). Avoid Tailwind - `hidden` + Alpine x-show which can leave the desktop panel stuck invisible. --}} + {{-- One panel: bottom sheet (mobile) + centered modal (desktop). --}}
+ class="flex min-h-[60dvh] flex-col items-center justify-center gap-4 px-6 py-10 text-center md:min-h-[28rem] md:px-8 md:py-12" + data-ladill-pay-inline-status>

-

- Please wait while we prepare your payment. - A secure Paystack window will open. Keep this page open until you finish. -

+ data-ladill-pay-status-title + x-text="$store.ladillPayCheckout && $store.ladillPayCheckout.error + ? 'Checkout unavailable' + : ($store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching + ? 'Complete payment in the Paystack window' + : 'Starting secure checkout…')">

+

diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php index 714bb65..ada4a99 100644 --- a/resources/views/public/qr/landing.blade.php +++ b/resources/views/public/qr/landing.blade.php @@ -339,6 +339,9 @@ errorMsg: '', showSheet: false, checkoutUrl: '', + accessCode: '', + publicKey: '', + returnUrl: '', async give() { if (!this.amount || parseFloat(this.amount) <= 0) { this.errorMsg = 'Enter a valid amount.'; return; } if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return; } @@ -359,6 +362,9 @@ const data = await res.json(); if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } this.checkoutUrl = data.checkout_url; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; this.showSheet = true; this.loading = false; } catch(e) { @@ -678,7 +684,10 @@ if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } if (data.paid) { if (!data.checkout_url) { this.errorMsg = 'Could not start checkout. Please try again.'; this.loading = false; return; } - this.checkoutUrl = data.checkout_url; this.showSheet = true; this.loading = false; + this.checkoutUrl = data.checkout_url; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; this.showSheet = true; this.loading = false; return; } if (data.success_url) { window.location.href = data.success_url; return; } @@ -1210,6 +1219,9 @@ errorMsg: '', showSheet: false, checkoutUrl: '', + accessCode: '', + publicKey: '', + returnUrl: '', shippingType: @js($shippingType), shippingFee: @js($shippingFee), freeAbove: @js($freeShippingAbove), @@ -1258,6 +1270,9 @@ const data = await res.json(); if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } this.checkoutUrl = data.checkout_url; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; this.showSheet = true; this.loading = false; } catch(e) { diff --git a/tests/Feature/ResponsivePaystackSheetTest.php b/tests/Feature/ResponsivePaystackSheetTest.php index 1768e5e..0db01ec 100644 --- a/tests/Feature/ResponsivePaystackSheetTest.php +++ b/tests/Feature/ResponsivePaystackSheetTest.php @@ -18,7 +18,7 @@ class ResponsivePaystackSheetTest extends TestCase $this->assertStringContainsString('data-ladill-pay-panel', $html); $this->assertStringContainsString('data-ladill-pay-backdrop', $html); $this->assertStringContainsString('data-ladill-pay-handle', $html); - $this->assertStringContainsString('data-ladill-pay-continue', $html); + $this->assertStringContainsString('data-ladill-pay-inline-status', $html); $this->assertStringContainsString('md:items-center', $html); $this->assertStringContainsString('rounded-t-2xl', $html); $this->assertStringContainsString('md:rounded-2xl', $html); @@ -26,9 +26,13 @@ class ResponsivePaystackSheetTest extends TestCase $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('resumeTransaction', $html); + $this->assertStringContainsString('js.paystack.co/v2/inline.js', $html); $this->assertStringContainsString('Starting secure checkout', $html); - $this->assertStringContainsString('prepare:', $html); + $this->assertStringContainsString('not in a separate browser', $html); + // Must not push Paystack to a separate browser as the primary path. + $this->assertStringNotContainsString('Continue to Paystack', $html); + $this->assertStringNotContainsString('window.open(', $html); // Must not use Tailwind `hidden` + x-show on the panel (desktop stays invisible). $this->assertStringNotContainsString('hidden w-full max-w-lg', $html); $this->assertStringNotContainsString('Paystack checkout', $html);