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

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:20 +00:00
parent fb36f13a43
commit 9345efbfdb
8 changed files with 85 additions and 39 deletions
+2 -2
View File
@@ -215,7 +215,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
} }
this.errorMsg = ''; this.errorMsg = '';
this.loading = true; this.loading = true; window.LadillPayCheckout?.prepare?.();
try { try {
const res = await fetch(config.payUrl, { const res = await fetch(config.payUrl, {
@@ -242,7 +242,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url;
this.showSheet = true; this.showSheet = true;
} 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;
} }
}, },
@@ -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>
@@ -36,7 +36,7 @@
async submitOrder() { async submitOrder() {
if (!this.customerName.trim()) { this.errorMsg = 'Enter your full name.'; return; } if (!this.customerName.trim()) { this.errorMsg = 'Enter your full name.'; return; }
if (!this.customerPhone.trim()) { this.errorMsg = 'Enter your phone number.'; return; } if (!this.customerPhone.trim()) { this.errorMsg = 'Enter your phone number.'; return; }
this.loading = true; this.errorMsg = ''; this.loading = true; window.LadillPayCheckout?.prepare?.(); this.errorMsg = '';
try { try {
const res = await fetch(@js($orderUrl), { const res = await fetch(@js($orderUrl), {
method: 'POST', method: 'POST',
@@ -44,13 +44,13 @@
body: JSON.stringify({ customer_name: this.customerName, customer_email: this.customerEmail, customer_phone: this.customerPhone }), body: JSON.stringify({ customer_name: this.customerName, customer_email: this.customerEmail, customer_phone: this.customerPhone }),
}); });
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.showModal = false; this.showModal = false;
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;
} }
} }
@@ -48,7 +48,7 @@
const data = await res.json(); const data = await res.json();
this.slots = data.slots || []; this.slots = data.slots || [];
} catch (e) { } catch (e) {
this.errorMsg = 'Could not load times. Try again.'; window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Could not load times. Try again.';
} }
this.loadingSlots = false; this.loadingSlots = false;
}, },
@@ -56,7 +56,7 @@
if (!this.customerName.trim()) { this.errorMsg = 'Enter your full name.'; return; } if (!this.customerName.trim()) { this.errorMsg = 'Enter your full name.'; return; }
if (!this.customerPhone.trim()) { this.errorMsg = 'Enter your phone number.'; return; } if (!this.customerPhone.trim()) { this.errorMsg = 'Enter your phone number.'; return; }
if (!this.selectedSlot) { this.errorMsg = 'Pick a time slot.'; return; } if (!this.selectedSlot) { this.errorMsg = 'Pick a time slot.'; return; }
this.loading = true; this.errorMsg = ''; this.loading = true; window.LadillPayCheckout?.prepare?.(); this.errorMsg = '';
try { try {
const res = await fetch(@js($bookUrl), { const res = await fetch(@js($bookUrl), {
method: 'POST', method: 'POST',
@@ -70,7 +70,7 @@
}), }),
}); });
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.confirmed && data.redirect_url) { if (data.confirmed && data.redirect_url) {
window.location.href = data.redirect_url; window.location.href = data.redirect_url;
return; return;
@@ -79,7 +79,7 @@
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;
} }
} }
+9 -9
View File
@@ -342,7 +342,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), {
@@ -356,11 +356,11 @@
}), }),
}); });
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;
} 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;
} }
} }
@@ -652,7 +652,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.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',
@@ -660,11 +660,11 @@
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; }
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url;
this.showSheet = true; this.showSheet = true;
} 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">
@@ -1177,7 +1177,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,
@@ -1192,11 +1192,11 @@
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;
} 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;
} }
} }
@@ -184,7 +184,7 @@ function storefront(cfg) {
this.errorMsg = ''; this.errorMsg = '';
if (!this.name.trim() || !this.phone.trim()) { this.errorMsg = 'Please fill in your name and phone.'; return; } if (!this.name.trim() || !this.phone.trim()) { this.errorMsg = 'Please fill in your name and phone.'; return; }
if (this.count === 0) { this.errorMsg = 'Your cart is empty.'; return; } if (this.count === 0) { this.errorMsg = 'Your cart is empty.'; return; }
this.loading = true; this.loading = true; window.LadillPayCheckout?.prepare?.();
try { try {
const items = Object.values(this.cart).map(l => ({ name: l.name, price: l.price, qty: l.qty })); const items = Object.values(this.cart).map(l => ({ name: l.name, price: l.price, qty: l.qty }));
const res = await fetch(this.orderUrl, { const res = await fetch(this.orderUrl, {
@@ -200,7 +200,7 @@ function storefront(cfg) {
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);
} }