Fix Paystack sheet: stop iframe embeds that Paystack blocks.
Deploy Ladill Hosting / deploy (push) Successful in 49s
Deploy Ladill Hosting / deploy (push) Successful in 49s
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:
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user