Deploy Ladill Mini / deploy (push) Successful in 57s
Cross-origin fetch to ladl.link had no CORS, so showSheet never ran. Open the sheet immediately and keep Paystack behind the Continue CTA.
283 lines
13 KiB
PHP
283 lines
13 KiB
PHP
{{--
|
|
Responsive payment checkout shell: bottom sheet on mobile, centered modal on desktop.
|
|
|
|
Audiences:
|
|
- buyer (default): public ticket buyers, donors, shoppers — self-serve checkout
|
|
- operator: signed-in staff flows such as wallet top-up
|
|
|
|
Optional overrides: $sheetTitle, $sheetSubtitle, $sheetFooter, $sheetAria, $iframeTitle
|
|
Requires Alpine ancestor with: showSheet (bool), checkoutUrl (string).
|
|
|
|
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.
|
|
External checkouts stay in this chrome with a Continue CTA that opens Paystack
|
|
in a named window (user gesture — not blocked). Auto-popups are intentionally
|
|
avoided so the sheet/modal always remains visibly on screen.
|
|
--}}
|
|
@php
|
|
$audience = $audience ?? 'buyer';
|
|
$defaults = match ($audience) {
|
|
'operator' => [
|
|
'title' => 'Complete payment',
|
|
'subtitle' => null,
|
|
'aria' => 'Complete payment',
|
|
'iframe' => 'Payment checkout',
|
|
'footer' => null,
|
|
],
|
|
default => [
|
|
'title' => 'Complete your payment',
|
|
'subtitle' => 'Pay securely. You\'ll get a confirmation when it succeeds.',
|
|
'aria' => 'Complete your payment',
|
|
'iframe' => 'Secure payment checkout',
|
|
'footer' => 'Your payment is processed securely. Do not close this window until finished.',
|
|
],
|
|
};
|
|
$sheetTitle = $sheetTitle ?? $defaults['title'];
|
|
$sheetSubtitle = $sheetSubtitle ?? $defaults['subtitle'];
|
|
$sheetAria = $sheetAria ?? $defaults['aria'];
|
|
$iframeTitle = $iframeTitle ?? $defaults['iframe'];
|
|
$sheetFooter = $sheetFooter ?? $defaults['footer'];
|
|
@endphp
|
|
@once
|
|
<script>
|
|
(function () {
|
|
if (window.LadillPayCheckout) return;
|
|
|
|
var PENDING_NAME = 'ladill_pay_checkout';
|
|
var pendingWindow = null;
|
|
|
|
function isFrameable(url) {
|
|
if (!url) return false;
|
|
try {
|
|
return new URL(url, window.location.href).origin === window.location.origin;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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) {}
|
|
}
|
|
|
|
// Kept for callers; no longer opens a window on Pay click (that stole focus
|
|
// from the sheet/modal). Prefer opening from the in-sheet Continue CTA.
|
|
function prepare() {
|
|
return null;
|
|
}
|
|
|
|
function cancel() {
|
|
if (pendingWindow && !pendingWindow.closed) {
|
|
try { pendingWindow.close(); } catch (e) {}
|
|
}
|
|
pendingWindow = null;
|
|
}
|
|
|
|
function openCheckout(url) {
|
|
if (!url) return null;
|
|
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.
|
|
return window.open(url, PENDING_NAME, 'width=480,height=720');
|
|
} catch (e) {
|
|
pendingWindow = null;
|
|
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,
|
|
ready: false,
|
|
reset() {
|
|
this.frameable = false;
|
|
this.popupBlocked = false;
|
|
this.ready = false;
|
|
},
|
|
sync(show, url) {
|
|
if (!show) {
|
|
this.reset();
|
|
return;
|
|
}
|
|
this.ready = !!url;
|
|
if (!url) {
|
|
this.frameable = false;
|
|
this.popupBlocked = false;
|
|
return;
|
|
}
|
|
this.frameable = isFrameable(url);
|
|
// Never auto-open an external popup here — the sheet/modal must stay
|
|
// visible. External checkouts use the Continue CTA (user gesture).
|
|
this.popupBlocked = !this.frameable;
|
|
},
|
|
continueTo(url) {
|
|
if (!url) return;
|
|
if (isFrameable(url)) {
|
|
this.frameable = true;
|
|
this.popupBlocked = false;
|
|
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,
|
|
prepare: prepare,
|
|
cancel: cancel,
|
|
open: openCheckout,
|
|
ensureStore: ensureStore,
|
|
};
|
|
})();
|
|
</script>
|
|
@endonce
|
|
<template x-teleport="body">
|
|
<div x-show="showSheet"
|
|
x-cloak
|
|
data-ladill-pay-sheet
|
|
x-effect="
|
|
document.documentElement.style.overflow = showSheet ? 'hidden' : '';
|
|
if (typeof window.LadillPayCheckout !== 'undefined') {
|
|
window.LadillPayCheckout.ensureStore();
|
|
}
|
|
const store = $store.ladillPayCheckout;
|
|
if (store) {
|
|
store.sync(showSheet, checkoutUrl);
|
|
}
|
|
"
|
|
@keydown.escape.window="if (showSheet) showSheet = false"
|
|
class="fixed inset-0 z-[9999] flex items-end justify-center md:items-center md:p-6"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
:aria-hidden="(!showSheet).toString()"
|
|
aria-label="{{ $sheetAria }}">
|
|
|
|
<div class="absolute inset-0 bg-black/60"
|
|
data-ladill-pay-backdrop
|
|
x-show="showSheet"
|
|
x-transition:enter="transition-opacity duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition-opacity duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="showSheet = false">
|
|
</div>
|
|
|
|
{{-- One panel: bottom sheet (mobile) + centered modal (desktop). Avoid Tailwind
|
|
`hidden` + Alpine x-show which can leave the desktop panel stuck invisible. --}}
|
|
<div data-ladill-pay-panel
|
|
class="relative flex w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl"
|
|
style="height: min(90dvh, 720px); max-height: 90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)"
|
|
x-show="showSheet"
|
|
x-transition:enter="transition duration-300 ease-out md:duration-200"
|
|
x-transition:enter-start="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
|
|
x-transition:enter-end="translate-y-0 opacity-100 md:scale-100"
|
|
x-transition:leave="transition duration-200 ease-in md:duration-150"
|
|
x-transition:leave-start="translate-y-0 opacity-100 md:scale-100"
|
|
x-transition:leave-end="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
|
|
@click.stop>
|
|
<div class="relative flex shrink-0 flex-col gap-0.5 border-b border-slate-100 px-4 pb-3 pt-5 md:px-5 md:pt-3"
|
|
style="padding-top: max(1.25rem, env(safe-area-inset-top, 0px))">
|
|
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200 md:hidden" data-ladill-pay-handle aria-hidden="true"></div>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-semibold text-slate-800">{{ $sheetTitle }}</p>
|
|
@if (! empty($sheetSubtitle))
|
|
<p class="mt-0.5 text-xs leading-snug text-slate-500">{{ $sheetSubtitle }}</p>
|
|
@endif
|
|
</div>
|
|
<button type="button"
|
|
x-ref="checkoutClose"
|
|
x-init="$watch('showSheet', value => { if (value) { $nextTick(() => $refs.checkoutClose?.focus()) } })"
|
|
@click="showSheet = false"
|
|
class="shrink-0 rounded-full p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
|
aria-label="Close">
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
<iframe x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.frameable"
|
|
:src="showSheet && $store.ladillPayCheckout && $store.ladillPayCheckout.frameable ? checkoutUrl : ''"
|
|
class="h-full min-h-[60dvh] w-full border-0 md:min-h-0"
|
|
style="min-height: 28rem"
|
|
allow="payment *"
|
|
title="{{ $iframeTitle }}"></iframe>
|
|
|
|
<div x-show="!$store.ladillPayCheckout || !$store.ladillPayCheckout.frameable"
|
|
class="flex min-h-[60dvh] flex-col items-center justify-center gap-4 px-6 py-10 text-center md:min-h-[28rem] md:px-8 md:py-12">
|
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-indigo-50 text-indigo-600" aria-hidden="true">
|
|
<svg x-show="!checkoutUrl" class="h-6 w-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
<svg x-show="checkoutUrl" 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 md:max-w-sm">
|
|
<p class="text-sm font-semibold text-slate-800"
|
|
x-text="!checkoutUrl
|
|
? 'Starting secure checkout…'
|
|
: 'Continue to Paystack to pay'"></p>
|
|
<p class="text-xs leading-snug text-slate-500">
|
|
<span x-show="!checkoutUrl">Please wait while we prepare your payment.</span>
|
|
<span x-show="checkoutUrl">A secure Paystack window will open. Keep this page open until you finish.</span>
|
|
</p>
|
|
</div>
|
|
<button type="button"
|
|
data-ladill-pay-continue
|
|
x-show="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">
|
|
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>
|
|
@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 md:px-5">
|
|
{{ $sheetFooter }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</template>
|