Open Paystack from the Pay click gesture, not after fetch.
Deploy Ladill Mini / deploy (push) Successful in 1m10s

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:13 +00:00
parent 58819832f2
commit 50e1b68b5f
6 changed files with 89 additions and 31 deletions
+7
View File
@@ -218,6 +218,9 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
this.errorMsg = ''; this.errorMsg = '';
this.loading = true; this.loading = true;
// Open a blank checkout window synchronously so Paystack is not blocked after await.
window.LadillPayCheckout?.prepare?.();
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 45000); const timer = setTimeout(() => controller.abort(), 45000);
@@ -238,10 +241,12 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
? data.error ? data.error
: (typeof data.message === 'string' ? data.message : ''); : (typeof data.message === 'string' ? data.message : '');
if (!res.ok || apiError) { if (!res.ok || apiError) {
window.LadillPayCheckout?.cancel?.();
this.errorMsg = apiError || (`Could not start payment (${res.status}). Please try again.`); this.errorMsg = apiError || (`Could not start payment (${res.status}). Please try again.`);
return; return;
} }
if (!data.checkout_url) { if (!data.checkout_url) {
window.LadillPayCheckout?.cancel?.();
this.errorMsg = 'Could not start payment. Please try again.'; this.errorMsg = 'Could not start payment. Please try again.';
return; return;
} }
@@ -253,10 +258,12 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url;
this.showSheet = true; this.showSheet = true;
} else { } else {
window.LadillPayCheckout?.cancel?.();
window.location.assign(data.checkout_url); window.location.assign(data.checkout_url);
return; return;
} }
} catch (e) { } catch (e) {
window.LadillPayCheckout?.cancel?.();
this.errorMsg = e?.name === 'AbortError' this.errorMsg = e?.name === 'AbortError'
? 'Payment is taking too long. Please try again.' ? 'Payment is taking too long. Please try again.'
: 'Network error. Please try again.'; : 'Network error. Please try again.';
@@ -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>
+12 -12
View File
@@ -343,7 +343,7 @@
if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return; } if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return; }
if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email.'; return; } if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email.'; 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,17 +357,17 @@
}), }),
}); });
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; }
if (!data.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; } if (!data.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; }
if (data.provider === 'mtn_momo') { if (data.provider === 'mtn_momo') {
window.location.href = data.checkout_url; window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url;
} else { } else {
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.mode === 'contributions' && !(parseFloat(this.amount) > 0)) { this.errorMsg = 'Enter a contribution amount.'; return; } if (this.mode === 'contributions' && !(parseFloat(this.amount) > 0)) { this.errorMsg = 'Enter a contribution amount.'; return; }
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.'; return; } if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email.'; 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',
@@ -670,14 +670,14 @@
body: JSON.stringify({ tier: this.tier, amount: this.mode === 'contributions' ? this.amount : null, attendee_name: this.name, attendee_email: this.email, attendee_phone: this.phone, badge_fields: this.fields }), body: JSON.stringify({ tier: this.tier, amount: this.mode === 'contributions' ? this.amount : null, attendee_name: this.name, attendee_email: this.email, attendee_phone: this.phone, badge_fields: this.fields }),
}); });
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; }
if (!data.paid) { window.location.href = data.success_url; return; } if (!data.paid) { window.location.href = data.success_url; return; }
if (data.provider === 'mtn_momo') { if (data.provider === 'mtn_momo') {
window.location.href = data.checkout_url; window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url;
} else { } else {
this.checkoutUrl = data.checkout_url; this.showSheet = true; this.loading = false; this.checkoutUrl = data.checkout_url; this.showSheet = true; 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">
@@ -1193,7 +1193,7 @@
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.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,
@@ -1208,17 +1208,17 @@
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; }
if (!data.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; } if (!data.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; }
if (data.provider === 'mtn_momo') { if (data.provider === 'mtn_momo') {
window.location.href = data.checkout_url; window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url;
} else { } else {
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;
} }
} }
@@ -166,6 +166,7 @@
} }
this.errorMsg = ''; this.errorMsg = '';
this.loading = true; this.loading = true;
window.LadillPayCheckout?.prepare?.();
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 45000); const timer = setTimeout(() => controller.abort(), 45000);
try { try {
@@ -185,10 +186,12 @@
? data.error ? data.error
: (typeof data.message === 'string' ? data.message : ''); : (typeof data.message === 'string' ? data.message : '');
if (!res.ok || apiError) { if (!res.ok || apiError) {
window.LadillPayCheckout?.cancel?.();
this.errorMsg = apiError || ('Could not start payment (' + res.status + '). Please try again.'); this.errorMsg = apiError || ('Could not start payment (' + res.status + '). Please try again.');
return; return;
} }
if (!data.checkout_url) { if (!data.checkout_url) {
window.LadillPayCheckout?.cancel?.();
this.errorMsg = 'Could not start payment. Please try again.'; this.errorMsg = 'Could not start payment. Please try again.';
return; return;
} }
@@ -198,10 +201,12 @@
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url;
this.showSheet = true; this.showSheet = true;
} else { } else {
window.LadillPayCheckout?.cancel?.();
window.location.assign(data.checkout_url); window.location.assign(data.checkout_url);
return; return;
} }
} catch (e) { } catch (e) {
window.LadillPayCheckout?.cancel?.();
this.errorMsg = e?.name === 'AbortError' this.errorMsg = e?.name === 'AbortError'
? 'Payment is taking too long. Please try again.' ? 'Payment is taking too long. Please try again.'
: 'Network error. Please try again.'; : 'Network error. Please try again.';
@@ -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);
} }