diff --git a/app/Http/Controllers/Public/PaymentController.php b/app/Http/Controllers/Public/PaymentController.php index 6acc46b..d17f22f 100644 --- a/app/Http/Controllers/Public/PaymentController.php +++ b/app/Http/Controllers/Public/PaymentController.php @@ -44,6 +44,10 @@ class PaymentController extends Controller if ($request->expectsJson()) { return response()->json([ 'checkout_url' => $result['checkout_url'], + 'access_code' => $result['access_code'] ?? null, + 'public_key' => $result['public_key'] ?? null, + 'callback_url' => $result['callback_url'] ?? null, + 'reference' => $result['reference'] ?? null, 'provider' => $result['provider'] ?? null, ]); } diff --git a/app/Services/Mini/MiniPaymentService.php b/app/Services/Mini/MiniPaymentService.php index f6b30d3..1346c83 100644 --- a/app/Services/Mini/MiniPaymentService.php +++ b/app/Services/Mini/MiniPaymentService.php @@ -26,7 +26,7 @@ class MiniPaymentService /** * @param array{amount: float, customer_phone?: string} $data - * @return array{payment: MiniPayment, checkout_url: string, provider: string} + * @return array{payment: MiniPayment, checkout_url: string, access_code: ?string, public_key: ?string, callback_url: string, reference: ?string, provider: string} */ public function initiate(QrCode $qrCode, array $data): array { @@ -101,6 +101,10 @@ class MiniPaymentService return [ 'payment' => $payment->fresh(), '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, + 'callback_url' => url('/q/'.$qrCode->short_code.'/pay/callback'), + 'reference' => isset($payOrder['reference']) ? (string) $payOrder['reference'] : null, 'provider' => (string) ($payOrder['provider'] ?? ''), ]; } diff --git a/resources/js/app.js b/resources/js/app.js index b23f0f6..e3e8576 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -164,6 +164,9 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ errorMsg: config.errorMsg ?? '', showSheet: false, checkoutUrl: '', + accessCode: '', + publicKey: '', + returnUrl: '', paymentSheetStyle: '', sheetBleedStyle: '', @@ -218,7 +221,10 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ this.errorMsg = ''; this.loading = true; this.checkoutUrl = ''; - // Show sheet/modal immediately; Paystack opens from the in-sheet Continue CTA. + this.accessCode = ''; + this.publicKey = ''; + this.returnUrl = ''; + // Show sheet/modal immediately; Paystack Inline opens in-page from the sheet. this.showSheet = true; const controller = new AbortController(); @@ -245,7 +251,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ this.errorMsg = apiError || (`Could not start payment (${res.status}). Please try again.`); return; } - if (!data.checkout_url) { + if (!data.checkout_url && !data.access_code) { this.showSheet = false; this.errorMsg = 'Could not start payment. Please try again.'; return; @@ -255,7 +261,10 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ // (cross-origin iframe is blank / looks like "Pay did nothing"). const useSheet = data.provider !== 'mtn_momo'; if (useSheet) { - this.checkoutUrl = data.checkout_url; + this.checkoutUrl = data.checkout_url || ''; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; } else { this.showSheet = false; window.location.assign(data.checkout_url); diff --git a/resources/views/components/user/service-topup-modal.blade.php b/resources/views/components/user/service-topup-modal.blade.php index 65d6938..269a420 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('
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). --}}