Open Paystack from the Pay click gesture, not after fetch.
Deploy Ladill Events / deploy (push) Successful in 57s

Paystack blocks iframes, so checkout uses a popup beside the sheet/modal chrome. Opening about:blank on the user tap keeps that window from being blocked after the async initiate response.
This commit is contained in:
isaacclad
2026-07-21 18:13:33 +00:00
parent 51244c7bad
commit d7ce7459a5
4 changed files with 74 additions and 28 deletions
@@ -39,7 +39,7 @@
if (this.method !== 'paystack') return; if (this.method !== 'paystack') return;
event.preventDefault(); event.preventDefault();
this.loading = true; this.loading = true; window.LadillPayCheckout?.prepare?.();
this.errorMsg = ''; this.errorMsg = '';
try { try {
@@ -59,7 +59,7 @@
this.showSheet = true; this.showSheet = true;
this.loading = false; this.loading = false;
} catch (error) { } catch (error) {
this.errorMsg = 'Network error. Please try again.'; window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
this.loading = false; this.loading = false;
} }
}, },
@@ -10,8 +10,9 @@
Paystack (checkout.paystack.com) sends X-Frame-Options: SAMEORIGIN and cannot be 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. embedded. Same-origin URLs (e.g. MoMo waiting pages) still load in an iframe.
External checkouts open in a named window, with an in-sheet Continue CTA as a External checkouts open in a named window. Call LadillPayCheckout.prepare()
user-gesture fallback when the popup is blocked after an async fetch. synchronously in the Pay click handler (before await fetch) so the popup is not
blocked after the async response. An in-sheet Continue CTA remains as fallback.
--}} --}}
@php @php
$audience = $audience ?? 'buyer'; $audience = $audience ?? 'buyer';
@@ -42,6 +43,9 @@
(function () { (function () {
if (window.LadillPayCheckout) return; if (window.LadillPayCheckout) return;
var PENDING_NAME = 'ladill_pay_checkout';
var pendingWindow = null;
function isFrameable(url) { function isFrameable(url) {
if (!url) return false; if (!url) return false;
try { try {
@@ -51,12 +55,50 @@
} }
} }
function writePendingPlaceholder(win) {
if (!win) return;
try {
win.document.open();
win.document.write('<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Payment</title></head><body style="margin:0;min-height:100vh;display:grid;place-items:center;font-family:system-ui,sans-serif;background:#f8fafc;color:#475569"><p style="font-size:0.95rem">Opening secure checkout…</p></body></html>');
win.document.close();
} catch (e) {}
}
function prepare() {
cancel();
try {
var win = window.open('about:blank', PENDING_NAME, 'width=480,height=720');
if (win && !win.closed) {
pendingWindow = win;
writePendingPlaceholder(win);
return win;
}
} catch (e) {}
pendingWindow = null;
return null;
}
function cancel() {
if (pendingWindow && !pendingWindow.closed) {
try { pendingWindow.close(); } catch (e) {}
}
pendingWindow = null;
}
function openCheckout(url) { function openCheckout(url) {
if (!url) return null; if (!url) return null;
try { 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. // Named window (no noopener) so payment-return can redirect window.opener.
return window.open(url, 'ladill_pay_checkout', 'width=480,height=720'); return window.open(url, PENDING_NAME, 'width=480,height=720');
} catch (e) { } catch (e) {
pendingWindow = null;
return null; return null;
} }
} }
@@ -83,6 +125,7 @@
if (!url) return; if (!url) return;
this.frameable = isFrameable(url); this.frameable = isFrameable(url);
if (this.frameable) { if (this.frameable) {
cancel();
this.popupBlocked = false; this.popupBlocked = false;
return; return;
} }
@@ -114,6 +157,8 @@
window.LadillPayCheckout = { window.LadillPayCheckout = {
isFrameable: isFrameable, isFrameable: isFrameable,
prepare: prepare,
cancel: cancel,
open: openCheckout, open: openCheckout,
ensureStore: ensureStore, ensureStore: ensureStore,
}; };
@@ -183,12 +228,12 @@
</div> </div>
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain"> <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<iframe x-show="$store.ladillPayCheckout?.frameable" <iframe x-show="$store.ladillPayCheckout.frameable"
:src="showSheet && $store.ladillPayCheckout?.frameable ? checkoutUrl : ''" :src="showSheet && $store.ladillPayCheckout.frameable ? checkoutUrl : ''"
class="h-full min-h-[60dvh] w-full border-0" class="h-full min-h-[60dvh] w-full border-0"
allow="payment *" allow="payment *"
title="{{ $iframeTitle }}"></iframe> title="{{ $iframeTitle }}"></iframe>
<div x-show="!$store.ladillPayCheckout?.frameable" <div x-show="!$store.ladillPayCheckout.frameable"
class="flex min-h-[60dvh] flex-col items-center justify-center gap-4 px-6 py-10 text-center"> class="flex min-h-[60dvh] flex-col items-center justify-center gap-4 px-6 py-10 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-indigo-50 text-indigo-600" aria-hidden="true"> <div class="flex h-12 w-12 items-center justify-center rounded-full bg-indigo-50 text-indigo-600" aria-hidden="true">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor"> <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
@@ -197,14 +242,14 @@
</div> </div>
<div class="max-w-xs space-y-1"> <div class="max-w-xs space-y-1">
<p class="text-sm font-semibold text-slate-800" <p class="text-sm font-semibold text-slate-800"
x-text="$store.ladillPayCheckout?.popupBlocked ? 'Continue in a secure payment window' : 'Complete payment in the secure window'"></p> x-text="$store.ladillPayCheckout.popupBlocked ? 'Continue in a secure payment window' : 'Complete payment in the secure window'"></p>
<p class="text-xs leading-snug text-slate-500"> <p class="text-xs leading-snug text-slate-500">
<span x-show="!$store.ladillPayCheckout?.popupBlocked">A Paystack window should be open. If you do not see it, tap continue below.</span> <span x-show="!$store.ladillPayCheckout.popupBlocked">A Paystack window should be open. If you do not see it, tap continue below.</span>
<span x-show="$store.ladillPayCheckout?.popupBlocked">Your browser blocked the popup. Tap continue to open Paystack.</span> <span x-show="$store.ladillPayCheckout.popupBlocked">Your browser blocked the popup. Tap continue to open Paystack.</span>
</p> </p>
</div> </div>
<button type="button" <button type="button"
@click="$store.ladillPayCheckout?.continueTo(checkoutUrl)" @click="$store.ladillPayCheckout.continueTo(checkoutUrl)"
class="inline-flex w-full max-w-xs items-center justify-center rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700"> class="inline-flex w-full max-w-xs items-center justify-center rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700">
Continue to Paystack Continue to Paystack
</button> </button>
@@ -251,13 +296,13 @@
</button> </button>
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain"> <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<iframe x-show="$store.ladillPayCheckout?.frameable" <iframe x-show="$store.ladillPayCheckout.frameable"
:src="showSheet && $store.ladillPayCheckout?.frameable ? checkoutUrl : ''" :src="showSheet && $store.ladillPayCheckout.frameable ? checkoutUrl : ''"
class="h-full min-h-0 w-full border-0" class="h-full min-h-0 w-full border-0"
style="min-height: 28rem" style="min-height: 28rem"
allow="payment *" allow="payment *"
title="{{ $iframeTitle }}"></iframe> title="{{ $iframeTitle }}"></iframe>
<div x-show="!$store.ladillPayCheckout?.frameable" <div x-show="!$store.ladillPayCheckout.frameable"
class="flex h-full min-h-[28rem] flex-col items-center justify-center gap-4 px-8 py-12 text-center"> class="flex h-full min-h-[28rem] flex-col items-center justify-center gap-4 px-8 py-12 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-indigo-50 text-indigo-600" aria-hidden="true"> <div class="flex h-12 w-12 items-center justify-center rounded-full bg-indigo-50 text-indigo-600" aria-hidden="true">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor"> <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
@@ -266,14 +311,14 @@
</div> </div>
<div class="max-w-sm space-y-1"> <div class="max-w-sm space-y-1">
<p class="text-sm font-semibold text-slate-800" <p class="text-sm font-semibold text-slate-800"
x-text="$store.ladillPayCheckout?.popupBlocked ? 'Continue in a secure payment window' : 'Complete payment in the secure window'"></p> x-text="$store.ladillPayCheckout.popupBlocked ? 'Continue in a secure payment window' : 'Complete payment in the secure window'"></p>
<p class="text-xs leading-snug text-slate-500"> <p class="text-xs leading-snug text-slate-500">
<span x-show="!$store.ladillPayCheckout?.popupBlocked">A Paystack window should be open. If you do not see it, click continue below.</span> <span x-show="!$store.ladillPayCheckout.popupBlocked">A Paystack window should be open. If you do not see it, click continue below.</span>
<span x-show="$store.ladillPayCheckout?.popupBlocked">Your browser blocked the popup. Click continue to open Paystack.</span> <span x-show="$store.ladillPayCheckout.popupBlocked">Your browser blocked the popup. Click continue to open Paystack.</span>
</p> </p>
</div> </div>
<button type="button" <button type="button"
@click="$store.ladillPayCheckout?.continueTo(checkoutUrl)" @click="$store.ladillPayCheckout.continueTo(checkoutUrl)"
class="inline-flex w-full max-w-xs items-center justify-center rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700"> class="inline-flex w-full max-w-xs items-center justify-center rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700">
Continue to Paystack Continue to Paystack
</button> </button>
+9 -9
View File
@@ -343,7 +343,7 @@
if (!this.amount || parseFloat(this.amount) <= 0) { this.errorMsg = 'Enter a valid amount.'; return; } if (!this.amount || parseFloat(this.amount) <= 0) { this.errorMsg = 'Enter a valid amount.'; return; }
if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return; } if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return; }
if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return; } if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return; }
this.errorMsg = ''; this.loading = true; this.errorMsg = ''; this.loading = true; window.LadillPayCheckout?.prepare?.();
const labels = @js($churchTypeLabels); const labels = @js($churchTypeLabels);
try { try {
const res = await fetch(@js($churchOrderRoute), { const res = await fetch(@js($churchOrderRoute), {
@@ -357,12 +357,12 @@
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (data.error) { this.errorMsg = data.error; this.loading = false; return; } if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; }
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url;
this.showSheet = true; this.showSheet = true;
this.loading = false; this.loading = false;
} catch(e) { } catch(e) {
this.errorMsg = 'Network error. Please try again.'; window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
this.loading = false; this.loading = false;
} }
} }
@@ -662,7 +662,7 @@
if (!this.name.trim()) { this.errorMsg = 'Enter your full name.'; return; } if (!this.name.trim()) { this.errorMsg = 'Enter your full name.'; return; }
if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email address.'; return; } if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email address.'; return; }
if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return; } if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return; }
this.errorMsg = ''; this.loading = true; this.errorMsg = ''; this.loading = true; window.LadillPayCheckout?.prepare?.();
try { try {
const res = await fetch(@js($evRegRoute), { const res = await fetch(@js($evRegRoute), {
method: 'POST', method: 'POST',
@@ -675,7 +675,7 @@
this.loading = false; this.loading = false;
return; return;
} }
if (data.error) { this.errorMsg = data.error; this.loading = false; return; } if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; }
if (data.paid) { if (data.paid) {
if (!data.checkout_url) { this.errorMsg = 'Could not start checkout. Please try again.'; this.loading = false; return; } 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.showSheet = true; this.loading = false;
@@ -684,7 +684,7 @@
if (data.success_url) { window.location.href = data.success_url; return; } if (data.success_url) { window.location.href = data.success_url; return; }
this.errorMsg = 'Registration failed. Please try again.'; this.errorMsg = 'Registration failed. Please try again.';
this.loading = false; this.loading = false;
} catch(e) { this.errorMsg = 'Network error. Please try again.'; this.loading = false; } } catch(e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
} }
}" class="min-h-screen bg-slate-50 pb-12"> }" class="min-h-screen bg-slate-50 pb-12">
@@ -1241,7 +1241,7 @@
if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return } if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return }
if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return } if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return }
this.errorMsg = ''; this.errorMsg = '';
this.loading = true; this.loading = true; window.LadillPayCheckout?.prepare?.();
const payload = { const payload = {
customer_name: this.name, customer_name: this.name,
customer_email: this.email, customer_email: this.email,
@@ -1256,12 +1256,12 @@
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();
if (data.error) { this.errorMsg = data.error; this.loading = false; return; } if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; }
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url;
this.showSheet = true; this.showSheet = true;
this.loading = false; this.loading = false;
} catch(e) { } catch(e) {
this.errorMsg = 'Network error. Please try again.'; window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
this.loading = false; this.loading = false;
} }
} }
@@ -22,6 +22,7 @@ class ResponsivePaystackSheetTest extends TestCase
$this->assertStringContainsString('LadillPayCheckout', $html); $this->assertStringContainsString('LadillPayCheckout', $html);
$this->assertStringContainsString('Continue to Paystack', $html); $this->assertStringContainsString('Continue to Paystack', $html);
$this->assertStringContainsString('ladill_pay_checkout', $html); $this->assertStringContainsString('ladill_pay_checkout', $html);
$this->assertStringContainsString('prepare:', $html);
$this->assertStringNotContainsString('Paystack checkout', $html); $this->assertStringNotContainsString('Paystack checkout', $html);
} }