Fix Paystack sheet: stop iframe embeds that Paystack blocks.
Deploy Ladill Mini / deploy (push) Successful in 1m0s

Paystack checkout.paystack.com returns X-Frame-Options: SAMEORIGIN, so the shared sheet opened empty. Keep mobile sheet/desktop modal chrome, launch checkout in a named window with a Continue CTA fallback, and escape payment-return via window.opener or top.
This commit is contained in:
isaacclad
2026-07-21 17:34:17 +00:00
parent 8937e67c73
commit 58819832f2
3 changed files with 201 additions and 5 deletions
@@ -8,8 +8,10 @@
Optional overrides: $sheetTitle, $sheetSubtitle, $sheetFooter, $sheetAria, $iframeTitle Optional overrides: $sheetTitle, $sheetSubtitle, $sheetFooter, $sheetAria, $iframeTitle
Requires Alpine ancestor with: showSheet (bool), checkoutUrl (string). Requires Alpine ancestor with: showSheet (bool), checkoutUrl (string).
Loads the provider authorization URL in-frame (verified via production Events/POS). Paystack (checkout.paystack.com) sends X-Frame-Options: SAMEORIGIN and cannot be
MoMo and other non-frameable providers should redirect instead of opening this sheet. 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
user-gesture fallback when the popup is blocked after an async fetch.
--}} --}}
@php @php
$audience = $audience ?? 'buyer'; $audience = $audience ?? 'buyer';
@@ -35,10 +37,101 @@
$iframeTitle = $iframeTitle ?? $defaults['iframe']; $iframeTitle = $iframeTitle ?? $defaults['iframe'];
$sheetFooter = $sheetFooter ?? $defaults['footer']; $sheetFooter = $sheetFooter ?? $defaults['footer'];
@endphp @endphp
@once
<script>
(function () {
if (window.LadillPayCheckout) return;
function isFrameable(url) {
if (!url) return false;
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch (e) {
return false;
}
}
function openCheckout(url) {
if (!url) return null;
try {
// Named window (no noopener) so payment-return can redirect window.opener.
return window.open(url, 'ladill_pay_checkout', 'width=480,height=720');
} catch (e) {
return null;
}
}
function ensureStore() {
if (!window.Alpine || typeof window.Alpine.store !== 'function') return null;
if (window.Alpine.store('ladillPayCheckout')) {
return window.Alpine.store('ladillPayCheckout');
}
window.Alpine.store('ladillPayCheckout', {
frameable: false,
popupBlocked: false,
launchKey: '',
reset() {
this.frameable = false;
this.popupBlocked = false;
this.launchKey = '';
},
sync(show, url) {
if (!show) {
this.reset();
return;
}
if (!url) return;
this.frameable = isFrameable(url);
if (this.frameable) {
this.popupBlocked = false;
return;
}
if (this.launchKey === url) return;
this.launchKey = url;
var popup = openCheckout(url);
this.popupBlocked = !(popup && !popup.closed);
},
continueTo(url) {
if (!url) return;
var popup = openCheckout(url);
this.popupBlocked = !(popup && !popup.closed);
if (this.popupBlocked) {
window.location.assign(url);
}
},
});
return window.Alpine.store('ladillPayCheckout');
}
function onAlpineReady() {
ensureStore();
}
if (window.Alpine) {
onAlpineReady();
}
document.addEventListener('alpine:init', onAlpineReady);
window.LadillPayCheckout = {
isFrameable: isFrameable,
open: openCheckout,
ensureStore: ensureStore,
};
})();
</script>
@endonce
<template x-teleport="body"> <template x-teleport="body">
<div x-show="showSheet" <div x-show="showSheet"
x-cloak x-cloak
x-effect="document.documentElement.style.overflow = showSheet ? 'hidden' : ''" x-effect="
document.documentElement.style.overflow = showSheet ? 'hidden' : '';
if (typeof window.LadillPayCheckout !== 'undefined') {
window.LadillPayCheckout.ensureStore();
}
if ($store.ladillPayCheckout) {
$store.ladillPayCheckout.sync(showSheet, checkoutUrl);
}
"
@keydown.escape.window="if (showSheet) showSheet = false" @keydown.escape.window="if (showSheet) showSheet = false"
class="fixed inset-0 z-[9999] flex items-end justify-center md:items-center md:p-6" class="fixed inset-0 z-[9999] flex items-end justify-center md:items-center md:p-6"
role="dialog" role="dialog"
@@ -90,10 +183,37 @@
</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 :src="showSheet ? checkoutUrl : ''" <iframe x-show="$store.ladillPayCheckout?.frameable"
: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"
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">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
</svg>
</div>
<div class="max-w-xs space-y-1">
<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>
<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">Your browser blocked the popup. Tap continue to open Paystack.</span>
</p>
</div>
<button type="button"
@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">
Continue to Paystack
</button>
<button type="button"
@click="showSheet = false"
class="text-xs font-medium text-slate-500 hover:text-slate-700">
Cancel
</button>
</div>
</div> </div>
@if (! empty($sheetFooter)) @if (! empty($sheetFooter))
<p class="shrink-0 border-t border-slate-100 bg-slate-50 px-4 py-2 text-center text-[11px] text-slate-500"> <p class="shrink-0 border-t border-slate-100 bg-slate-50 px-4 py-2 text-center text-[11px] text-slate-500">
@@ -131,11 +251,38 @@
</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 :src="showSheet ? checkoutUrl : ''" <iframe x-show="$store.ladillPayCheckout?.frameable"
: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"
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">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
</svg>
</div>
<div class="max-w-sm space-y-1">
<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>
<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">Your browser blocked the popup. Click continue to open Paystack.</span>
</p>
</div>
<button type="button"
@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">
Continue to Paystack
</button>
<button type="button"
@click="showSheet = false"
class="text-xs font-medium text-slate-500 hover:text-slate-700">
Cancel
</button>
</div>
</div> </div>
@if (! empty($sheetFooter)) @if (! empty($sheetFooter))
<p class="shrink-0 border-t border-slate-100 bg-slate-50 px-5 py-2 text-center text-[11px] text-slate-500"> <p class="shrink-0 border-t border-slate-100 bg-slate-50 px-5 py-2 text-center text-[11px] text-slate-500">
@@ -14,6 +14,13 @@
<script> <script>
(function () { (function () {
var url = @json($redirect); var url = @json($redirect);
try {
if (window.opener && !window.opener.closed) {
window.opener.location.replace(url);
window.close();
return;
}
} catch (e) {}
try { try {
if (window.top && window.top !== window.self) { if (window.top && window.top !== window.self) {
window.top.location.replace(url); window.top.location.replace(url);
@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ResponsivePaystackSheetTest extends TestCase
{
public function test_checkout_sheet_partial_has_mobile_sheet_and_desktop_modal(): void
{
$html = view('partials.paystack-sheet', [
'audience' => 'buyer',
])->render();
$this->assertStringContainsString('role="dialog"', $html);
$this->assertStringContainsString('aria-modal="true"', $html);
$this->assertStringContainsString('md:items-center', $html);
$this->assertStringContainsString('rounded-t-2xl', $html);
$this->assertStringContainsString('md:h-[min(720px,85vh)]', $html);
$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('ladill_pay_checkout', $html);
$this->assertStringNotContainsString('Paystack checkout', $html);
}
public function test_payment_return_escapes_popup_and_iframe_to_opener_or_top(): void
{
$html = view('public.payment-return', [
'redirect' => 'https://example.test/paid',
])->render();
$this->assertStringContainsString('Confirming your payment', $html);
$this->assertStringContainsString('window.opener', $html);
$this->assertStringContainsString('window.top.location.replace', $html);
$this->assertTrue(
str_contains($html, 'https://example.test/paid')
|| str_contains($html, 'https:\/\/example.test\/paid')
);
}
}