Compare commits

...
19 Commits
Author SHA1 Message Date
isaacclad 44ba69f63f Replace native confirms with Ladill custom dialogs.
Deploy Ladill Mini / deploy (push) Successful in 36s
2026-07-24 20:56:30 +00:00
isaacclad faa0205ad6 Sync app launcher from registry
Deploy Ladill Mini / deploy (push) Successful in 39s
2026-07-24 19:32:11 +00:00
isaacclad 24df57be0e Let hero stats cards auto-expand to fit long values.
Deploy Ladill Mini / deploy (push) Successful in 53s
Replace fixed mobile widths and equal grid columns with content-sized cards so figures like GHS balances and storage sizes are never clipped.
2026-07-24 14:53:35 +00:00
isaacclad 8034d62b95 Clarify Mini uses Ladill Pay only with platform fees.
Deploy Ladill Mini / deploy (push) Successful in 32s
Document that payment QRs settle only via Ladill Pay with standard platform fees.
2026-07-24 14:15:46 +00:00
isaacclad 5edbf0a677 Stop Paystack pre-modal flash on customer checkout.
Deploy Ladill Mini / deploy (push) Successful in 1m39s
Use the shared Inline sheet, hold busy until payment UI opens, and avoid locking page scroll during the handoff.
2026-07-24 10:14:41 +00:00
isaacclad fa82e8e906 Simplify Paystack checkout handoff
Deploy Ladill Mini / deploy (push) Successful in 1m19s
2026-07-24 08:25:28 +00:00
isaacclad 8db08892c7 Simplify pay wait copy and scale Paystack to fill the loading area.
Deploy Ladill Mini / deploy (push) Successful in 1m52s
Use “Please wait…” while loading, and scale the contained Paystack iframe so
checkout fills the sheet body without a floating second card.
2026-07-23 22:56:17 +00:00
isaacclad 9403f671f3 Render Paystack flush in the sheet loading area without a second stage.
Deploy Ladill Mini / deploy (push) Successful in 1m43s
Drop host shadows and keep the iframe transparent so checkout fills the
Ladill sheet body instead of floating as a second card.
2026-07-23 22:43:50 +00:00
isaacclad 98a17a6207 Force Paystack checkout into the sheet mount on first insert.
Deploy Ladill Mini / deploy (push) Successful in 53s
Redirect first iframe append into the Ladill sheet body and keep absolute
fill styles applied so checkout is not a second full-screen modal.
2026-07-23 22:36:21 +00:00
isaacclad cf5cc4d50d Unify payment sheet to one POS design across all apps.
Deploy Ladill Mini / deploy (push) Successful in 1m2s
Loading uses the standard white bottomsheet/modal; once Paystack Inline is
ready the host shell goes transparent so only Paystack's card shows on the
backdrop — no oversized second white frame on Merchant/Give/etc.
2026-07-21 23:15:36 +00:00
isaacclad 7e99c5554c Tighten payment sheet UI to match POS single-surface checkout.
Deploy Ladill Mini / deploy (push) Successful in 1m3s
When Paystack Inline is ready: widen desktop panel for channel sidebar, collapse
to a minimal header, hide footer trust line, and re-pin the checkout iframe to
the sheet body so it does not read as a second modal on top of Ladill chrome.
2026-07-21 23:02:30 +00:00
isaacclad e99e64b517 Contain Paystack Inline checkout inside Ladill sheet mount.
Deploy Ladill Mini / deploy (push) Successful in 1m39s
Redirect Paystack iframes into the bottomsheet/modal body (where the spinner
is) instead of allowing a second full-viewport Paystack modal on top. Hide
Paystack background dimmer; keep Ladill header/close chrome.
2026-07-21 22:31:56 +00:00
isaacclad bd8764523c Fix Paystack Fullscreen permission policy for Inline iframes.
Deploy Ladill Mini / deploy (push) Successful in 44s
Pre-set allow=fullscreen on Paystack iframes before navigation (createElement
hook + MutationObserver on allow/src/id), re-apply if Paystack overwrites
allow, and send Permissions-Policy HTTP headers so checkout.paystack.com can
use fullscreen inside the in-app sheet.
2026-07-21 22:17:14 +00:00
isaacclad 4291d95518 Allow Paystack fullscreen on Mini payment pages.
Deploy Ladill Mini / deploy (push) Successful in 47s
Patch Inline iframes for fullscreen/payment permissions and advertise a
Permissions-Policy on the public payment landing.
2026-07-21 22:09:51 +00:00
isaacclad 75d201bcae Fix ladl.link CORS for Mini payment assets and pay URL.
Deploy Ladill Mini / deploy (push) Successful in 44s
Allow cross-origin module loads of Mini build assets and use a same-origin
pay path when HTML is proxied through ladl.link so fetch and Vite JS work.
2026-07-21 22:05:36 +00:00
isaacclad e03a5dc30f Harden Mini Paystack sheet so payment modals open reliably.
Deploy Ladill Mini / deploy (push) Successful in 49s
Use Alpine watches for checkout sync, prewarm the Paystack script, and
preserve bottomsheet wrapping around in-page Inline.
2026-07-21 21:55:37 +00:00
isaacclad de6e6f5331 Fix Mini payment bottomsheet wrap and Inline launch.
Deploy Ladill Mini / deploy (push) Successful in 1m55s
Always show the mobile bottomsheet/desktop modal shell, extract access_code
from checkout URLs when needed, and open the sheet immediately on Pay.
2026-07-21 21:43:42 +00:00
isaacclad 4f2293ec4a Hide Ladill One from the Mini app launcher until launch.
Deploy Ladill Mini / deploy (push) Successful in 52s
2026-07-21 21:32:36 +00:00
isaacclad a50b5c6a44 Keep Mini QR Paystack checkout fully in-app.
Stop form POSTs from 307ing to ladl.link or redirecting away to
checkout.paystack.com, open Paystack Inline with access_code, and use
the single-chrome sheet so payment stays on the Mini page.
2026-07-21 21:32:36 +00:00
25 changed files with 1024 additions and 403 deletions
@@ -41,7 +41,7 @@ class PaymentController extends Controller
return back()->withInput()->with('error', $e->getMessage());
}
if ($request->expectsJson()) {
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'checkout_url' => $result['checkout_url'],
'access_code' => $result['access_code'] ?? null,
@@ -52,7 +52,24 @@ class PaymentController extends Controller
]);
}
return redirect()->away($result['checkout_url']);
// Never send the browser to checkout.paystack.com — reopen the landing
// with checkout payload so Paystack Inline can run in-page.
$provider = (string) ($result['provider'] ?? '');
if ($provider === 'mtn_momo') {
$waiting = (string) ($result['checkout_url'] ?? '');
if ($waiting !== '' && str_starts_with($waiting, url('/'))) {
return redirect()->to($waiting);
}
}
return redirect()
->to(url('/q/'.$shortCode))
->with('checkout_url', $result['checkout_url'] ?? null)
->with('access_code', $result['access_code'] ?? null)
->with('public_key', $result['public_key'] ?? null)
->with('callback_url', $result['callback_url'] ?? null)
->with('provider', $provider !== '' ? $provider : 'paystack')
->with('amount', $validated['amount']);
}
public function callback(Request $request, string $shortCode): View
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Delegate payment-related browser features to Paystack Inline iframes.
*
* Cross-origin checkout.paystack.com needs the top-level document to allow
* fullscreen (and payment/clipboard) via Permissions-Policy. Meta tags are
* not reliably honored this must be an HTTP response header.
*/
class PaystackPermissionsPolicy
{
public const HEADER_VALUE = 'payment=*, fullscreen=*, clipboard-read=*, clipboard-write=*, publickey-credentials-get=*';
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
if (! $response->headers->has('Permissions-Policy')) {
$response->headers->set('Permissions-Policy', self::HEADER_VALUE);
}
return $response;
}
}
@@ -19,13 +19,20 @@ class RedirectLegacyQrToLadillLink
return $next($request);
}
// Same-origin AJAX from Mini-hosted payment pages (e.g. mini.ladill.com)
// must hit local /q/{code}/pay — redirecting to ladl.link breaks CORS and
// the checkout sheet never opens.
// Same-origin pay from Mini-hosted pages (e.g. mini.ladill.com) must hit
// local /q/{code}/pay — redirecting to ladl.link breaks CORS / form POSTs
// and used to dump the browser onto checkout.paystack.com.
if ($request->ajax() || $request->expectsJson() || $request->wantsJson()) {
return $next($request);
}
// Form POST (and any method) to the Mini pay endpoint stays on Mini so
// PaymentController can return JSON or flash payload for in-page Inline.
$path = ltrim($request->path(), '/');
if (preg_match('#^q/[^/]+/pay(?:/callback)?$#i', $path) === 1) {
return $next($request);
}
return LadillLink::legacyRedirect($request);
}
}
+1
View File
@@ -25,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [
\App\Http\Middleware\InjectBootSplash::class,
\App\Http\Middleware\SetActingAccount::class,
\App\Http\Middleware\PaystackPermissionsPolicy::class,
]);
$middleware->alias([
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
+41
View File
@@ -0,0 +1,41 @@
<?php
/**
* Public Mini payment pages are HTML-proxied through ladl.link while Vite assets
* and Mini pay endpoints live on mini.ladill.com. Browsers require CORS for those
* cross-origin module scripts and JSON pay posts.
*/
return [
'paths' => [
'q/*',
'build/*',
'images/*',
'sanctum/csrf-cookie',
],
'allowed_methods' => ['*'],
'allowed_origins' => array_values(array_filter([
'https://ladl.link',
'https://www.ladl.link',
'https://mini.ladill.com',
env('APP_URL') ? rtrim((string) env('APP_URL'), '/') : null,
])),
'allowed_origins_patterns' => [
// Public short-link host and optional subdomains.
'#^https://([a-z0-9-]+\.)?ladl\.link$#i',
// Platform family (custom / satellite hosts that may proxy Mini HTML).
'#^https://([a-z0-9-]+\.)?ladill\.com$#i',
],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 60 * 60,
'supports_credentials' => false,
];
+1 -1
View File
@@ -26,13 +26,13 @@ return [
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
['name' => 'Stock', 'url' => 'https://stock.'.$root.'/sso/connect?redirect='.urlencode('https://stock.'.$root.'/dashboard'), 'icon' => 'stock.svg'],
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'],
['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'],
['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'],
['name' => 'One', 'url' => 'https://one.'.$root.'/sso/connect?redirect='.urlencode('https://one.'.$root.'/dashboard'), 'icon' => 'one.svg'],
['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'],
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #00997d;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #ffa34d;
}
.cls-3 {
fill: #0776be;
}
.cls-4 {
fill: #5051df;
}
</style>
</defs>
<path class="cls-4" d="M340.5,213.3l-7.7-7.7c-10.4-7.1-23-11-36.2-11h-157.5l110.3,110.3c25.2,25.2,66.4,25.2,91.6,0,25.2-25.2,25.2-66.4,0-91.6h-.5Z"/>
<path class="cls-1" d="M18.9,305l4.9,4.9c11,8.8,24.7,13.7,40.1,13.7h156.9l-110.8-110.3c-25.2-24.7-65.9-24.7-91.1,0-25.2,24.7-25.2,66.4,0,91.6Z"/>
<path class="cls-3" d="M340.5,54.5l-7.7-7.7c-10.4-7.1-23-11-36.2-11h-157.5l110.3,110.3c25.2,25.2,66.4,25.2,91.6,0,25.2-25.2,25.2-66.4,0-91.6h-.5Z"/>
<path class="cls-2" d="M18.9,146.2l4.9,4.9c11,8.8,24.7,13.7,40.1,13.7h156.9L110,54.6c-25.2-24.7-65.9-24.7-91.1,0-25.2,24.7-25.2,66.4,0,91.6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #a277ff;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #b6362f;
}
.cls-3 {
fill: #0091ac;
}
</style>
</defs>
<path class="cls-3" d="M131.4,56.7c-7.2,0-14.5.1-21.7,0-7-.1-12,3.4-15.4,11-6,13.8-12.1,27.4-18.2,41.2C51.7,163.9,27.4,219.1,2.7,274.1c-6.1,13.5.2,29.6,14.2,29.4,14.4,0,28.8,0,43.1,0s12.1-3.6,15.5-11.5c16.5-37.5,33.2-75,49.8-112.5,13.8-31.1,27.3-62.4,41.4-93.3,6.9-15-1.4-30.9-14.4-29.8-7,.5-14,.1-21,.1h0Z"/>
<path class="cls-2" d="M228.6,56.7c7.2,0,14.5.1,21.7,0,7-.1,12,3.4,15.4,11,6,13.8,12.1,27.4,18.2,41.2,24.4,55.1,48.6,110.3,73.3,165.3,6.1,13.5-.2,29.6-14.2,29.4-14.4,0-28.8,0-43.1,0s-12.1-3.6-15.5-11.5c-16.5-37.5-33.2-75-49.8-112.5-13.8-31.1-27.3-62.4-41.4-93.3-6.9-15,1.4-30.9,14.4-29.8,7,.5,14,.1,21,.1h0Z"/>
<path class="cls-1" d="M228.8,56.6c-7.2,0-14.5.1-21.7,0-7-.1-12,3.4-15.4,11-6,13.8-12.1,27.4-18.2,41.2-24.4,55.1-48.6,110.3-73.3,165.3-6.1,13.5.2,29.6,14.2,29.4,14.4,0,28.8,0,43.1,0s12.1-3.6,15.5-11.5c16.5-37.5,33.2-75,49.8-112.5,13.8-31.1,27.3-62.4,41.4-93.3,6.9-15-1.4-30.9-14.4-29.8-7,.5-14,.1-21,.1h0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+8 -2
View File
@@ -101,7 +101,10 @@ html.qr-mobile-page body {
}
.mobile-stats-card {
width: calc(66.666667% - 0.5rem);
width: max-content;
min-width: 9rem;
max-width: none;
box-sizing: border-box;
}
@layer components {
@@ -161,7 +164,10 @@ html.qr-mobile-page body {
@media (min-width: 640px) {
.mobile-stats-card {
width: auto;
width: max-content;
min-width: 9rem;
max-width: none;
flex: 1 1 auto;
}
}
+63 -13
View File
@@ -201,6 +201,17 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
}
this._syncSheet();
// Non-JS form POST fallback flashes checkout payload — open Inline in-page.
if (config.bootstrapCheckoutUrl || config.bootstrapAccessCode) {
this.openInlineCheckout({
checkout_url: config.bootstrapCheckoutUrl || '',
access_code: config.bootstrapAccessCode || '',
public_key: config.bootstrapPublicKey || '',
callback_url: config.bootstrapCallbackUrl || '',
provider: config.bootstrapProvider || 'paystack',
});
}
},
destroy() {
@@ -211,6 +222,51 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
document.documentElement.classList.remove('mini-payment-page');
},
isSameOrigin(url) {
if (!url) return false;
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch (e) {
return false;
}
},
accessCodeFromCheckoutUrl(url) {
if (!url) return '';
try {
const u = new URL(url, window.location.href);
const host = u.hostname.toLowerCase();
if (host !== 'checkout.paystack.com' && !host.endsWith('.paystack.com')) {
return '';
}
const code = u.pathname.replace(/^\/+/, '').split('/')[0] || '';
return /^[A-Za-z0-9_-]{6,}$/.test(code) ? code : '';
} catch (e) {
return '';
}
},
openInlineCheckout(data = {}) {
// Never navigate to checkout.paystack.com. Same-origin MoMo waiting can navigate.
if (data.provider === 'mtn_momo' && this.isSameOrigin(data.checkout_url)) {
window.location.assign(data.checkout_url);
return false;
}
const checkoutUrl = data.checkout_url || '';
let accessCode = data.access_code || '';
if (!accessCode) {
accessCode = this.accessCodeFromCheckoutUrl(checkoutUrl);
}
this.checkoutUrl = checkoutUrl;
this.accessCode = accessCode;
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
return true;
},
async submitPay() {
const value = parseFloat(this.amount);
if (!value || value <= 0) {
@@ -224,7 +280,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
this.accessCode = '';
this.publicKey = '';
this.returnUrl = '';
// Show sheet/modal immediately; Paystack Inline opens in-page from the sheet.
// Open bottomsheet/modal immediately so payment feels in-app while checkout starts.
this.showSheet = true;
const controller = new AbortController();
@@ -257,17 +313,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
return;
}
// Legacy MoMo waiting page must be full-page — never trap it in the Paystack iframe
// (cross-origin iframe is blank / looks like "Pay did nothing").
const useSheet = data.provider !== 'mtn_momo';
if (useSheet) {
this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
} else {
this.showSheet = false;
window.location.assign(data.checkout_url);
if (!this.openInlineCheckout(data)) {
return;
}
} catch (e) {
@@ -277,7 +323,11 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
: 'Network error. Please try again.';
} finally {
clearTimeout(timer);
this.loading = false;
// Keep loading while the sheet is open — ladill-pay-opened / cancel / error clear it.
// Clearing here caused a form flash before Paystack's modal painted.
if (!this.showSheet) {
this.loading = false;
}
}
},
}));
+12 -1
View File
@@ -33,6 +33,10 @@ export function ladillNeedsTopup(balance, price) {
return normalizedBalance <= 0 || normalizedBalance < normalizedPrice;
}
/**
* Alpine store mirrors window.ladillConfirm (defined in partials/confirm-prompt).
* Prefer window.ladillConfirm for new code — it works without Alpine and powers data-confirm forms.
*/
export function registerLadillConfirmStore(Alpine) {
Alpine.store('ladillConfirm', {
open: false,
@@ -44,6 +48,11 @@ export function registerLadillConfirmStore(Alpine) {
_resolve: null,
ask(options = {}) {
// Delegate to the global custom dialog when available (no double UI).
if (typeof window.ladillConfirm === 'function' && window.__ladillConfirmInstalled) {
return window.ladillConfirm(options);
}
return new Promise((resolve) => {
this.title = options.title || 'Are you sure?';
this.message = options.message || '';
@@ -65,7 +74,9 @@ export function registerLadillConfirmStore(Alpine) {
},
});
window.ladillConfirm = (options = {}) => Alpine.store('ladillConfirm').ask(options);
if (typeof window.ladillConfirm !== 'function') {
window.ladillConfirm = (options = {}) => Alpine.store('ladillConfirm').ask(options);
}
}
export function registerLadillModalHelpers() {
@@ -29,5 +29,6 @@
@auth
@include('partials.afia')
@endauth
@include('partials.confirm-prompt')
</body>
</html>
@@ -69,5 +69,6 @@
</div>
@stack('scripts')
@include('partials.confirm-prompt')
</body>
</html>
@@ -29,5 +29,6 @@
@auth
@include('partials.afia')
@endauth
@include('partials.confirm-prompt')
</body>
</html>
@@ -59,8 +59,7 @@
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
this.loading = false;
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
} catch (error) {
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
this.loading = false;
@@ -38,16 +38,16 @@
</div>
</div>
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:flex sm:flex-wrap sm:justify-end sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="mobile-stats-card w-max min-w-[9rem] shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">GHS {{ number_format($wallet->spendableBalance(), 2) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Account balance</p>
</div>
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<div class="mobile-stats-card w-max min-w-[9rem] shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ $qrCodes->count() }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Payment QRs</p>
</div>
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<div class="mobile-stats-card w-max min-w-[9rem] shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ number_format($activeCount) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Active</p>
</div>
+8
View File
@@ -56,6 +56,14 @@
</label>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 space-y-3">
<div>
<h2 class="text-sm font-semibold text-slate-900">Customer payment engine</h2>
<p class="mt-1 text-xs text-slate-500">All payment QR checkouts use Ladill Pay zero-config, no owner API keys.</p>
</div>
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Ladill Pay is the only payment option. Standard platform fees apply and settle to your Ladill wallet.</p>
</div>
<button type="submit" class="btn-primary">
Save settings
</button>
+198 -50
View File
@@ -1,65 +1,213 @@
<div
x-cloak
x-show="$store.ladillConfirm.open"
x-transition:enter="transition-opacity ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-[60] flex items-end sm:items-center sm:justify-center"
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
@keydown.escape.window="$store.ladillConfirm.answer(false)"
>
<div class="absolute inset-0" @click="$store.ladillConfirm.answer(false)"></div>
{{--
Ladill custom confirm dialog (replaces native window.confirm for app UX).
<div
x-show="$store.ladillConfirm.open"
class="relative z-10 w-full max-h-[90vh] overflow-y-auto rounded-t-3xl bg-white shadow-2xl sm:mb-6 sm:max-h-[85vh] sm:max-w-md sm:rounded-2xl sm:border sm:border-gray-200"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
x-transition:enter-end="translate-y-0 sm:opacity-100 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="translate-y-0 sm:opacity-100 sm:scale-100"
x-transition:leave-end="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
@click.stop
>
Usage:
1) Forms: <form data-confirm="Delete this item?" data-confirm-title="Delete?" ...>
2) JS: const ok = await window.ladillConfirm({ title, message, confirmLabel, variant })
Bottom sheet on mobile, centered card on desktop same pattern as x-modal.
--}}
<div id="ladill-confirm-root" class="fixed inset-0 z-[60] hidden items-end sm:items-center sm:justify-center"
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
role="dialog" aria-modal="true" aria-labelledby="ladill-confirm-title">
<div id="ladill-confirm-backdrop" class="absolute inset-0"></div>
<div id="ladill-confirm-panel"
class="relative z-10 w-full max-h-[90vh] translate-y-full overflow-y-auto rounded-t-3xl bg-white shadow-2xl opacity-0 transition duration-200 sm:mb-6 sm:max-h-[85vh] sm:max-w-md sm:translate-y-2 sm:scale-95 sm:rounded-2xl sm:border sm:border-gray-200">
<div class="flex justify-center pb-1 pt-3 sm:hidden">
<div class="h-1 w-10 rounded-full bg-slate-200"></div>
</div>
<div class="px-5 pb-6 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
<div class="flex flex-col items-center text-center sm:items-start sm:text-left">
<div class="flex h-12 w-12 items-center justify-center rounded-full"
:class="$store.ladillConfirm.variant === 'primary' ? 'bg-violet-100 text-violet-600' : 'bg-red-100 text-red-600'">
<template x-if="$store.ladillConfirm.variant === 'primary'">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/>
</svg>
</template>
<template x-if="$store.ladillConfirm.variant !== 'primary'">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
</svg>
</template>
<div id="ladill-confirm-icon" class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 text-red-600">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
</svg>
</div>
<h2 class="mt-4 text-lg font-semibold text-slate-900" x-text="$store.ladillConfirm.title"></h2>
<p x-show="$store.ladillConfirm.message" class="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-500" x-text="$store.ladillConfirm.message"></p>
<h2 id="ladill-confirm-title" class="mt-4 text-lg font-semibold text-slate-900">Are you sure?</h2>
<p id="ladill-confirm-message" class="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-500"></p>
</div>
<div class="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
<button type="button"
@click="$store.ladillConfirm.answer(false)"
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
x-text="$store.ladillConfirm.cancelLabel">
<button type="button" id="ladill-confirm-cancel"
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
Cancel
</button>
<button type="button"
@click="$store.ladillConfirm.answer(true)"
class="rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition"
:class="$store.ladillConfirm.variant === 'primary' ? 'bg-violet-600 hover:bg-violet-700' : 'bg-red-600 hover:bg-red-700'"
x-text="$store.ladillConfirm.confirmLabel">
<button type="button" id="ladill-confirm-ok"
class="rounded-xl bg-red-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-700">
Confirm
</button>
</div>
</div>
</div>
</div>
<script>
(function () {
if (window.__ladillConfirmInstalled) {
return;
}
window.__ladillConfirmInstalled = true;
let resolveAsk = null;
const root = () => document.getElementById('ladill-confirm-root');
const panel = () => document.getElementById('ladill-confirm-panel');
const titleEl = () => document.getElementById('ladill-confirm-title');
const messageEl = () => document.getElementById('ladill-confirm-message');
const okBtn = () => document.getElementById('ladill-confirm-ok');
const cancelBtn = () => document.getElementById('ladill-confirm-cancel');
const iconEl = () => document.getElementById('ladill-confirm-icon');
function close(result) {
const r = root();
const p = panel();
if (!r || !p) {
if (resolveAsk) {
resolveAsk(result);
resolveAsk = null;
}
return;
}
p.classList.add('translate-y-full', 'opacity-0', 'sm:translate-y-2', 'sm:scale-95');
p.classList.remove('translate-y-0', 'opacity-100', 'sm:scale-100');
setTimeout(() => {
r.classList.add('hidden');
r.classList.remove('flex');
document.body.classList.remove('overflow-y-hidden');
if (resolveAsk) {
resolveAsk(result);
resolveAsk = null;
}
}, 180);
}
function openDialog(options) {
const opts = options || {};
return new Promise((resolve) => {
if (resolveAsk) {
resolveAsk(false);
}
resolveAsk = resolve;
const r = root();
const p = panel();
if (!r || !p) {
resolve(window.__ladillNativeConfirm
? window.__ladillNativeConfirm(opts.message || opts.title || 'Are you sure?')
: false);
return;
}
titleEl().textContent = opts.title || 'Are you sure?';
const msg = opts.message || '';
messageEl().textContent = msg;
messageEl().classList.toggle('hidden', !msg);
okBtn().textContent = opts.confirmLabel || 'Confirm';
cancelBtn().textContent = opts.cancelLabel || 'Cancel';
const primary = (opts.variant || 'danger') === 'primary';
iconEl().className = 'flex h-12 w-12 items-center justify-center rounded-full ' +
(primary ? 'bg-violet-100 text-violet-600' : 'bg-red-100 text-red-600');
okBtn().className = 'rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition ' +
(primary ? 'bg-violet-600 hover:bg-violet-700' : 'bg-red-600 hover:bg-red-700');
r.classList.remove('hidden');
r.classList.add('flex');
document.body.classList.add('overflow-y-hidden');
requestAnimationFrame(() => {
p.classList.remove('translate-y-full', 'opacity-0', 'sm:translate-y-2', 'sm:scale-95');
p.classList.add('translate-y-0', 'opacity-100', 'sm:scale-100');
});
okBtn().focus();
});
}
window.ladillConfirm = openDialog;
// Prefer custom dialog; keep native as emergency fallback only.
if (!window.__ladillNativeConfirm) {
window.__ladillNativeConfirm = window.confirm.bind(window);
}
document.addEventListener('click', function (e) {
if (e.target.closest('#ladill-confirm-cancel') || e.target.closest('#ladill-confirm-backdrop')) {
close(false);
}
if (e.target.closest('#ladill-confirm-ok')) {
close(true);
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root() && !root().classList.contains('hidden')) {
close(false);
}
});
// Forms: data-confirm="…" replaces onsubmit="return confirm(…)"
document.addEventListener('submit', function (e) {
const form = e.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if (form.dataset.ladillConfirming === '1') {
return;
}
const message = form.getAttribute('data-confirm');
if (message === null || message === '') {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
openDialog({
title: form.getAttribute('data-confirm-title') || 'Are you sure?',
message: message,
confirmLabel: form.getAttribute('data-confirm-label') || 'Confirm',
cancelLabel: form.getAttribute('data-confirm-cancel') || 'Cancel',
variant: form.getAttribute('data-confirm-variant') || 'danger',
}).then(function (ok) {
if (!ok) {
return;
}
form.dataset.ladillConfirming = '1';
// Native submit skips the submit event → no re-entry.
HTMLFormElement.prototype.submit.call(form);
queueMicrotask(function () {
delete form.dataset.ladillConfirming;
});
});
}, true);
// Buttons/links: data-confirm on non-submit controls that should gate a click.
document.addEventListener('click', function (e) {
const el = e.target.closest('[data-confirm-click]');
if (!el || el.closest('form[data-confirm]')) {
return;
}
const message = el.getAttribute('data-confirm-click');
if (message === null || message === '') {
return;
}
e.preventDefault();
e.stopPropagation();
openDialog({
title: el.getAttribute('data-confirm-title') || 'Are you sure?',
message: message,
confirmLabel: el.getAttribute('data-confirm-label') || 'Confirm',
cancelLabel: el.getAttribute('data-confirm-cancel') || 'Cancel',
variant: el.getAttribute('data-confirm-variant') || 'danger',
}).then(function (ok) {
if (!ok) {
return;
}
if (el.tagName === 'A' && el.href) {
window.location.href = el.href;
return;
}
if (el.form) {
el.form.dataset.ladillConfirming = '1';
HTMLFormElement.prototype.submit.call(el.form);
}
});
}, true);
})();
</script>
+380 -155
View File
@@ -1,5 +1,5 @@
{{--
Responsive payment checkout shell: bottom sheet on mobile, centered modal on desktop.
Payment checkout shell.
Audiences:
- buyer (default): public ticket buyers, donors, shoppers self-serve checkout
@@ -10,12 +10,15 @@
- showSheet (bool)
- checkoutUrl (string) authorization / waiting URL
- accessCode (string, optional) Paystack initialize access_code for Inline
- publicKey (string, optional) unused by resumeTransaction; kept for callers
- publicKey (string, optional)
- returnUrl (string, optional) where to go after Inline onSuccess (?reference=)
Paystack checkout.paystack.com cannot be iframed (X-Frame-Options: SAMEORIGIN).
Primary path: Paystack Inline JS (PaystackPop.resumeTransaction) in-page overlay,
not a separate browser tab. Same-origin URLs (e.g. MoMo waiting) still use iframe.
checkout.paystack.com cannot be iframed (X-Frame-Options: SAMEORIGIN).
Paystack Inline owns its secure payment popup. We do not place a Ladill
loading sheet in front of it: that created a needless first screen before
the checkout appeared. The Ladill bottomsheet/modal is reserved for
same-origin waiting pages (such as MoMo) and recoverable errors.
--}}
@php
$audience = $audience ?? 'buyer';
@@ -44,7 +47,8 @@
@once
<script>
(function () {
if (window.LadillPayCheckout) return;
if (window.__ladillPayCheckoutBootstrapped) return;
window.__ladillPayCheckoutBootstrapped = true;
var INLINE_SRC = 'https://js.paystack.co/v2/inline.js';
var inlineLoading = null;
@@ -94,6 +98,10 @@
inlineLoading = new Promise(function (resolve, reject) {
var existing = document.querySelector('script[data-ladill-paystack-inline]');
if (existing) {
if (window.PaystackPop) {
resolve(window.PaystackPop);
return;
}
existing.addEventListener('load', function () { resolve(window.PaystackPop); });
existing.addEventListener('error', function () { reject(new Error('Paystack script failed')); });
return;
@@ -133,12 +141,110 @@
activePopup = 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', {
// Fullscreen permission for Paystack's own iframes (do not reparent them).
// Only write attributes when they actually change — writing always re-fires
// MutationObservers and freezes the page (wallet include path).
function applyIframePermissions(iframe) {
if (!iframe || !iframe.setAttribute) return;
try {
var cur = (iframe.getAttribute('allow') || '').trim();
var need = ['payment *', 'fullscreen *', 'clipboard-read *', 'clipboard-write *', 'publickey-credentials-get *'];
var parts = cur ? cur.split(';').map(function (p) { return p.trim(); }).filter(Boolean) : [];
var lower = parts.map(function (p) { return p.toLowerCase(); });
need.forEach(function (perm) {
var base = perm.split(' ')[0].toLowerCase();
if (!lower.some(function (p) { return p === base || p.indexOf(base + ' ') === 0; })) {
parts.push(perm);
}
});
var next = parts.join('; ');
if (cur !== next) {
iframe.setAttribute('allow', next);
}
if (!iframe.hasAttribute('allowfullscreen')) {
iframe.setAttribute('allowfullscreen', '');
}
} catch (e) {}
}
function ensurePaystackIframePermissions(root) {
try {
var scope = root || document;
var nodes = scope.querySelectorAll ? scope.querySelectorAll('iframe') : [];
for (var i = 0; i < nodes.length; i++) {
var iframe = nodes[i];
var id = (iframe.id || '').toLowerCase();
var src = '';
try { src = (iframe.getAttribute('src') || '').toLowerCase(); } catch (e) {}
if (id.indexOf('inline-') === 0 || id.indexOf('embed-') === 0 || src.indexOf('paystack') !== -1) {
applyIframePermissions(iframe);
}
}
} catch (e) {}
}
function installIframeAllowHook() {
if (window.__ladillIframeAllowHook) return;
window.__ladillIframeAllowHook = true;
try {
var orig = Document.prototype.createElement;
Document.prototype.createElement = function (tagName, options) {
var el = options !== undefined ? orig.call(this, tagName, options) : orig.call(this, tagName);
try {
if (String(tagName).toLowerCase() === 'iframe') {
applyIframePermissions(el);
}
} catch (e) {}
return el;
};
} catch (e) {}
}
/**
* Arm iframe permission helpers only while a payment is starting.
* Do not install on every wallet/page load observing style/class + always
* rewriting allow caused an infinite MutationObserver loop (page unresponsive).
*/
function watchPaystackIframes() {
if (window.__ladillPaystackIframeWatch) return;
window.__ladillPaystackIframeWatch = true;
installIframeAllowHook();
ensurePaystackIframePermissions(document);
try {
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
// childList only — never re-enter on attribute writes.
if (!m.addedNodes || !m.addedNodes.length) continue;
for (var n = 0; n < m.addedNodes.length; n++) {
var node = m.addedNodes[n];
if (!node || node.nodeType !== 1) continue;
if (node.tagName === 'IFRAME') {
applyIframePermissions(node);
} else if (node.querySelectorAll) {
var nested = node.querySelectorAll('iframe');
for (var k = 0; k < nested.length; k++) {
applyIframePermissions(nested[k]);
}
}
}
}
});
obs.observe(document.documentElement || document.body, {
childList: true,
subtree: true,
});
} catch (e) {}
}
function emitPayEvent(name, detail) {
try {
window.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
} catch (e) {}
}
function createStoreDefinition() {
return {
frameable: false,
inline: false,
ready: false,
@@ -146,6 +252,10 @@
error: '',
_launchToken: 0,
_activeCode: '',
_openedEmitted: false,
shellVisible() {
return !!(this.frameable || this.error);
},
reset() {
this.frameable = false;
this.inline = false;
@@ -153,30 +263,61 @@
this.launching = false;
this.error = '';
this._activeCode = '';
this._openedEmitted = false;
cancelInline();
},
markOpened() {
if (this._openedEmitted) return;
this._openedEmitted = true;
this.launching = false;
emitPayEvent('ladill-pay-opened');
},
markFailed(message) {
this.launching = false;
this._activeCode = '';
this._openedEmitted = false;
this.error = message || 'Could not open secure payment.';
emitPayEvent('ladill-pay-error', { message: this.error });
},
sync(show, url, accessCode, returnUrl) {
url = (url || '').toString();
accessCode = (accessCode || '').toString();
returnUrl = (returnUrl || '').toString();
if (!show) {
this._launchToken += 1;
this.reset();
return;
}
this.ready = !!(url || accessCode);
// Keep a previous recoverable error only until a new attempt supplies payload.
if (url || accessCode) {
this.error = '';
}
if (!url && !accessCode) {
// Waiting for parent to set access_code / checkout_url after fetch.
this.frameable = false;
this.inline = false;
this.launching = false;
this.launching = true;
this._activeCode = '';
this._openedEmitted = false;
return;
}
if (isFrameable(url)) {
this.frameable = true;
this.inline = false;
this.launching = false;
this._activeCode = '';
this._openedEmitted = false;
cancelInline();
// Same-origin waiting UI is our shell — it is "open" for busy-state release.
emitPayEvent('ladill-pay-opened');
return;
}
var code = resolveAccessCode(accessCode, url);
if (code) {
this.frameable = false;
@@ -187,12 +328,14 @@
this.launchInline(code, returnUrl);
return;
}
// Non-Paystack external URL without access_code — cannot embed.
this.frameable = false;
this.inline = false;
this.launching = false;
this._activeCode = '';
this.error = 'Secure checkout could not be opened in-page. Please try again.';
this.markFailed(
isPaystackCheckoutUrl(url)
? 'Could not start in-app payment. Please try again.'
: 'Secure checkout could not be opened in-page. Please try again.'
);
},
launchInline(accessCode, returnUrl) {
var store = this;
@@ -200,69 +343,119 @@
this._activeCode = accessCode;
this.launching = true;
this.error = '';
this.inline = true;
this.frameable = false;
this._openedEmitted = false;
watchPaystackIframes();
// Ensure Paystack script is fully ready before resumeTransaction so we
// do not paint an empty popup shell for a beat (the pre-modal flash).
loadInlineJs().then(function (PaystackPop) {
if (token !== store._launchToken) return;
if (!PaystackPop) throw new Error('Paystack unavailable');
cancelInline();
var popup = new PaystackPop();
activePopup = popup;
ensurePaystackIframePermissions(document);
popup.resumeTransaction(accessCode, {
onLoad: function () {
if (token !== store._launchToken) return;
store.launching = false;
store.markOpened();
ensurePaystackIframePermissions(document);
},
onSuccess: function (transaction) {
if (token !== store._launchToken) return;
store.launching = false;
activePopup = null;
var reference = (transaction && (transaction.reference || transaction.trxref)) || '';
var dest = buildReturnUrl(returnUrl, reference);
if (dest) {
window.location.assign(dest);
return;
}
// Fallback: reload so server can reconcile via webhook.
window.location.reload();
},
onCancel: function () {
if (token !== store._launchToken) return;
store.launching = false;
store._activeCode = '';
store._openedEmitted = false;
activePopup = null;
window.dispatchEvent(new CustomEvent('ladill-pay-cancelled'));
emitPayEvent('ladill-pay-cancelled');
},
onError: function (err) {
if (token !== store._launchToken) return;
store.launching = false;
store._activeCode = '';
store.error = (err && err.message) ? String(err.message) : 'Could not open secure payment.';
activePopup = null;
store.markFailed((err && err.message) ? String(err.message) : 'Could not open secure payment.');
},
});
// Safety: if onLoad is slow, treat a live checkout iframe as open.
var polls = 0;
var pollId = setInterval(function () {
if (token !== store._launchToken) {
clearInterval(pollId);
return;
}
ensurePaystackIframePermissions(document);
var live = document.querySelector(
'iframe[id^="inline-checkout"], iframe[id^="embed-checkout"]'
);
if (live) {
store.markOpened();
clearInterval(pollId);
}
polls += 1;
if (polls >= 50) {
clearInterval(pollId);
if (store.launching && token === store._launchToken) {
store.markFailed('Payment UI did not open. Please try again.');
}
}
}, 100);
}).catch(function (err) {
if (token !== store._launchToken) return;
store.launching = false;
store._activeCode = '';
store.error = (err && err.message) ? String(err.message) : 'Could not load payment script.';
store.markFailed((err && err.message) ? String(err.message) : 'Could not load payment script.');
});
},
});
};
}
function ensureStore() {
if (!window.Alpine || typeof window.Alpine.store !== 'function') return null;
try {
var existing = window.Alpine.store('ladillPayCheckout');
if (existing) return existing;
} catch (e) {}
window.Alpine.store('ladillPayCheckout', createStoreDefinition());
return window.Alpine.store('ladillPayCheckout');
}
function onAlpineReady() {
document.addEventListener('alpine:init', function () {
ensureStore();
});
if (window.Alpine && window.Alpine.version) {
ensureStore();
}
if (window.Alpine) {
onAlpineReady();
}
document.addEventListener('alpine:init', onAlpineReady);
// Do not watch iframes or preload Paystack on every page that includes this
// partial (e.g. wallet). That froze the tab. prepare()/launchInline arm it.
window.LadillPayCheckout = {
isFrameable: isFrameable,
isPaystackCheckoutUrl: isPaystackCheckoutUrl,
accessCodeFromUrl: accessCodeFromUrl,
resolveAccessCode: resolveAccessCode,
prepare: function () { return null; },
prepare: function () {
// Warm script + hooks only when user is about to pay.
installIframeAllowHook();
loadInlineJs().catch(function () {});
return null;
},
cancel: function () {
cancelInline();
var store = ensureStore();
@@ -275,136 +468,168 @@
</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) {
{{-- Inherit showSheet / checkoutUrl / accessCode / returnUrl from parent Alpine scope. --}}
<div
x-init="
window.LadillPayCheckout && window.LadillPayCheckout.ensureStore();
// Drop parent busy flags only when payment UI is actually up (or sheet closed).
// Prevents the form/button from flashing back while Paystack is still booting.
const releaseBusy = () => {
try {
if (typeof loading !== 'undefined' && loading) loading = false;
if (typeof renewLoading !== 'undefined' && renewLoading) renewLoading = false;
} catch (e) {}
};
const onPayOpened = () => releaseBusy();
const onPayCancelled = () => { showSheet = false; releaseBusy(); };
const onPayError = () => releaseBusy();
window.addEventListener('ladill-pay-opened', onPayOpened);
window.addEventListener('ladill-pay-cancelled', onPayCancelled);
window.addEventListener('ladill-pay-error', onPayError);
const runSync = () => {
const store = $store.ladillPayCheckout;
if (!store) return;
store.sync(
showSheet,
typeof checkoutUrl !== 'undefined' ? checkoutUrl : '',
typeof accessCode !== 'undefined' ? accessCode : '',
typeof returnUrl !== 'undefined' ? returnUrl : ''
!!showSheet,
(typeof checkoutUrl === 'undefined' || checkoutUrl === null) ? '' : String(checkoutUrl),
(typeof accessCode === 'undefined' || accessCode === null) ? '' : String(accessCode),
(typeof returnUrl === 'undefined' || returnUrl === null) ? '' : String(returnUrl)
);
}
"
@keydown.escape.window="if (showSheet) showSheet = false"
@ladill-pay-cancelled.window="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 }}">
};
$watch('showSheet', (value) => {
runSync();
if (!value) releaseBusy();
});
$watch('checkoutUrl', () => runSync());
$watch('accessCode', () => runSync());
$watch('returnUrl', () => runSync());
$nextTick(() => runSync());
"
x-show="showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
x-cloak
data-ladill-pay-sheet
style="display: none"
x-effect="
// Only lock page scroll when OUR shell is visible — never during pure
// Paystack Inline handoff (that scrollbar jump was the pre-modal flash).
const lock = !!(showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error));
document.documentElement.style.overflow = lock ? 'hidden' : '';
"
@keydown.escape.window="
if (!showSheet) return;
// While Paystack Inline is the active UI, Escape is handled by Paystack.
if ($store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching && !$store.ladillPayCheckout.error) return;
showSheet = false;
window.LadillPayCheckout?.cancel?.();
"
@ladill-pay-cancelled.window="showSheet = false"
class="fixed inset-0 z-[9999]"
role="dialog"
aria-modal="true"
:aria-hidden="(!(showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error))).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; window.LadillPayCheckout?.cancel?.()">
</div>
{{-- One panel: bottom sheet (mobile) + centered modal (desktop). --}}
<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; window.LadillPayCheckout?.cancel?.()"
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>
{{--
Ladill bottomsheet (mobile) / modal (desktop) ONLY for:
- same-origin waiting pages (MoMo etc.)
- recoverable errors
Paystack Inline opens its own secure payment popup; this shell stays
hidden during launch so nothing flashes in front of it.
--}}
<div x-show="$store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
x-cloak
class="pointer-events-auto absolute inset-0 flex items-end justify-center md:items-center md:p-6"
data-ladill-pay-shell>
<div class="absolute inset-0 bg-black/60"
data-ladill-pay-backdrop
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()">
</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"
data-ladill-pay-inline-status>
<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="!$store.ladillPayCheckout || $store.ladillPayCheckout.launching || !checkoutUrl && !(typeof accessCode !== 'undefined' && accessCode)"
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="$store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching && !$store.ladillPayCheckout.error"
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>
<svg x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.error"
class="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/>
</svg>
<div data-ladill-pay-panel
data-paystack-live="0"
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-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))"
data-ladill-pay-header>
<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 && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)),
(open) => { if (open) $nextTick(() => $refs.checkoutClose?.focus()); }
)
"
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"
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 class="max-w-xs space-y-1 md:max-w-sm">
<p class="text-sm font-semibold text-slate-800"
data-ladill-pay-status-title
x-text="$store.ladillPayCheckout && $store.ladillPayCheckout.error
? 'Checkout unavailable'
: ($store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching
? 'Complete payment in the Paystack window'
: 'Starting secure checkout…')"></p>
<p class="text-xs leading-snug text-slate-500"
x-text="$store.ladillPayCheckout && $store.ladillPayCheckout.error
? $store.ladillPayCheckout.error
: 'Paystack opens on this page — not in a separate browser.'"></p>
</div>
<button type="button"
data-ladill-pay-retry
x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.error"
@click="$store.ladillPayCheckout.sync(true, typeof checkoutUrl !== 'undefined' ? checkoutUrl : '', typeof accessCode !== 'undefined' ? accessCode : '', typeof returnUrl !== 'undefined' ? returnUrl : '')"
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">
Try again
</button>
<button type="button"
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"
class="text-xs font-medium text-slate-500 hover:text-slate-700">
Cancel
</button>
</div>
{{-- Markers kept for tests / callers that query live close chrome. --}}
<div class="sr-only" data-ladill-pay-header-minimal data-ladill-pay-close-live aria-hidden="true"></div>
<div class="sr-only" data-ladill-pay-mount data-ladill-pay-body aria-hidden="true"></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 *; fullscreen *; clipboard-read *; clipboard-write *"
title="{{ $iframeTitle }}"></iframe>
<div x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.error"
class="flex min-h-[40dvh] flex-col items-center justify-center gap-4 px-6 py-10 text-center md:min-h-[20rem] md:px-8 md:py-12"
data-ladill-pay-error>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-50 text-red-500" 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="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/>
</svg>
</div>
<div class="max-w-xs space-y-1 md:max-w-sm">
<p class="text-sm font-semibold text-slate-800" data-ladill-pay-status-title>Checkout unavailable</p>
<p class="text-xs leading-snug text-slate-500" x-text="$store.ladillPayCheckout.error"></p>
</div>
<button type="button"
data-ladill-pay-retry
@click="window.LadillPayCheckout?.ensureStore()?.sync(true, (typeof checkoutUrl==='undefined'||checkoutUrl===null)?'':String(checkoutUrl), (typeof accessCode==='undefined'||accessCode===null)?'':String(accessCode), (typeof returnUrl==='undefined'||returnUrl===null)?'':String(returnUrl))"
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">
Try again
</button>
<button type="button"
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"
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"
data-ladill-pay-footer>
{{ $sheetFooter }}
</p>
@endif
</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>
+36 -22
View File
@@ -362,15 +362,19 @@
const data = await res.json();
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.provider === 'mtn_momo') {
window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url;
// Never send the browser to checkout.paystack.com.
const isSameOrigin = (u) => {
try { return new URL(u, window.location.href).origin === window.location.origin; } catch (e) { return false; }
};
if (data.provider === 'mtn_momo' && isSameOrigin(data.checkout_url)) {
window.LadillPayCheckout?.cancel?.();
window.location.href = data.checkout_url;
} else {
this.checkoutUrl = data.checkout_url;
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
this.loading = false;
this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
}
} catch(e) {
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
@@ -678,13 +682,19 @@
const data = await res.json();
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.provider === 'mtn_momo') {
window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url;
// Never send the browser to checkout.paystack.com.
const isSameOrigin = (u) => {
try { return new URL(u, window.location.href).origin === window.location.origin; } catch (e) { return false; }
};
if (data.provider === 'mtn_momo' && isSameOrigin(data.checkout_url)) {
window.LadillPayCheckout?.cancel?.();
window.location.href = data.checkout_url;
} else {
this.checkoutUrl = data.checkout_url;
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || ''; this.showSheet = true; this.loading = false;
this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
}
} catch(e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
}
@@ -1222,15 +1232,19 @@
const data = await res.json();
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.provider === 'mtn_momo') {
window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url;
// Never send the browser to checkout.paystack.com.
const isSameOrigin = (u) => {
try { return new URL(u, window.location.href).origin === window.location.origin; } catch (e) { return false; }
};
if (data.provider === 'mtn_momo' && isSameOrigin(data.checkout_url)) {
window.LadillPayCheckout?.cancel?.();
window.location.href = data.checkout_url;
} else {
this.checkoutUrl = data.checkout_url;
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
this.loading = false;
this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
}
} catch(e) {
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
@@ -2,9 +2,13 @@
$content = $qrCode->content();
$businessName = $content['business_name'] ?? $qrCode->label;
$currency = $content['currency'] ?? 'GHS';
// Same-origin pay endpoint — ladl.link publicPath is cross-origin from
// mini.ladill.com and has no CORS headers, so fetch() never reaches showSheet.
$payUrl = url('/q/'.$qrCode->short_code.'/pay');
// Public scans stay on ladl.link/{code} (HTML proxied to Mini). Prefer a
// same-origin pay path so fetch() does not cross to mini.ladill.com.
// Direct visits on mini.ladill.com keep the local /q/{code}/pay route.
$isProxiedPublicScan = request()->headers->get('X-Ladill-Internal') === '1';
$payUrl = $isProxiedPublicScan
? '/'.$qrCode->short_code.'/pay'
: url('/q/'.$qrCode->short_code.'/pay');
$csrf = csrf_token();
@endphp
<!DOCTYPE html>
@@ -13,6 +17,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ $csrf }}">
{{-- Permissions-Policy for Paystack is set as an HTTP header (middleware + nginx).
Meta http-equiv is not reliably applied by browsers for this feature. --}}
<title>Pay {{ $businessName }}</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
@@ -23,8 +29,13 @@
x-data="miniPaymentLanding({
payUrl: @js($payUrl),
csrf: @js($csrf),
amount: @js(old('amount')),
amount: @js(old('amount', session('amount'))),
errorMsg: @js(session('error')),
bootstrapCheckoutUrl: @js(session('checkout_url')),
bootstrapAccessCode: @js(session('access_code')),
bootstrapPublicKey: @js(session('public_key')),
bootstrapCallbackUrl: @js(session('callback_url')),
bootstrapProvider: @js(session('provider', 'paystack')),
})">
{{-- Mobile: Ladill Mini branding + fixed payment sheet --}}
@@ -123,112 +134,5 @@
</main>
@include('partials.paystack-sheet')
{{--
Safety net: if Alpine/Vite fails to boot, native form POST still works.
If Alpine boots but miniPaymentLanding was missing from an old bundle, redefine it.
--}}
<script>
document.addEventListener('alpine:init', () => {
const Alpine = window.Alpine;
if (!Alpine || typeof Alpine.data !== 'function') return;
// Re-register so Pay works even when Blade deploys ahead of an old Vite bundle.
Alpine.data('miniPaymentLanding', (config = {}) => ({
amount: config.amount ?? '',
loading: false,
errorMsg: config.errorMsg ?? '',
showSheet: false,
checkoutUrl: '',
accessCode: '',
publicKey: '',
returnUrl: '',
paymentSheetStyle: '',
sheetBleedStyle: '',
init() {
this._syncSheet = () => {
if (window.innerWidth >= 768) {
this.paymentSheetStyle = '';
this.sheetBleedStyle = '';
return;
}
const vp = window.visualViewport;
const offset = vp ? Math.max(0, Math.round(window.innerHeight - vp.height - vp.offsetTop)) : 0;
const safePad = offset > 0 ? '1.25rem' : 'max(1.25rem, env(safe-area-inset-bottom))';
this.paymentSheetStyle = `bottom: ${offset}px; padding-bottom: ${safePad};`;
this.sheetBleedStyle = `bottom: ${offset}px;`;
};
this._onViewportChange = () => this._syncSheet();
window.visualViewport?.addEventListener('resize', this._onViewportChange);
window.visualViewport?.addEventListener('scroll', this._onViewportChange);
if (window.innerWidth < 768) document.documentElement.classList.add('mini-payment-page');
this._syncSheet();
},
async submitPay() {
const value = parseFloat(this.amount);
if (!value || value <= 0) {
this.errorMsg = 'Enter an amount greater than zero.';
return;
}
this.errorMsg = '';
this.loading = true;
this.checkoutUrl = '';
this.accessCode = '';
this.publicKey = '';
this.returnUrl = '';
// Open sheet chrome immediately (mobile bottom sheet / desktop modal).
this.showSheet = true;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 45000);
try {
const res = await fetch(config.payUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': config.csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ amount: value }),
signal: controller.signal,
});
const data = await res.json().catch(() => ({}));
const apiError = typeof data.error === 'string'
? data.error
: (typeof data.message === 'string' ? data.message : '');
if (!res.ok || apiError) {
this.showSheet = false;
this.errorMsg = apiError || ('Could not start payment (' + res.status + '). Please try again.');
return;
}
if (!data.checkout_url && !data.access_code) {
this.showSheet = false;
this.errorMsg = 'Could not start payment. Please try again.';
return;
}
// Legacy MoMo waiting page must be full-page — never trap it in the Paystack iframe.
const useSheet = data.provider !== 'mtn_momo';
if (useSheet) {
this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
} else {
this.showSheet = false;
window.location.assign(data.checkout_url);
return;
}
} catch (e) {
this.showSheet = false;
this.errorMsg = e?.name === 'AbortError'
? 'Payment is taking too long. Please try again.'
: 'Network error. Please try again.';
} finally {
clearTimeout(timer);
this.loading = false;
}
},
}));
});
</script>
</body>
</html>
+4 -4
View File
@@ -38,16 +38,16 @@
</div>
</div>
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:flex sm:flex-wrap sm:justify-end sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="mobile-stats-card w-max min-w-[9rem] shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">GHS {{ number_format($wallet->spendableBalance(), 2) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Account balance</p>
</div>
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<div class="mobile-stats-card w-max min-w-[9rem] shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ $qrCodes->count() }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Events</p>
</div>
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<div class="mobile-stats-card w-max min-w-[9rem] shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ number_format($totalRegistrations) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Registrations</p>
</div>
+55 -3
View File
@@ -44,13 +44,16 @@ class MiniPaymentCheckoutTest extends TestCase
->assertSee('md:items-center', false)
->assertSee('Complete your payment', false)
->assertSee('data-ladill-pay-sheet', false)
->assertSee('data-ladill-pay-panel', false)
->assertSee('resumeTransaction', false)
->assertSee('/q/'.$qr->short_code.'/pay', false)
->assertDontSee('https://ladl.link/'.$qr->short_code.'/pay', false)
// Proxied ladl.link scans use same-origin /{code}/pay (not mini absolute /q/...).
->assertSee('/'.$qr->short_code.'/pay', false)
->assertDontSee('https://mini.ladill.com/q/'.$qr->short_code.'/pay', false)
->assertDontSee('MTN MoMo number', false)
->assertDontSee('Pay with MTN MoMo', false)
->assertDontSee('Continue to Paystack', false)
->assertDontSee('Paystack checkout', false);
->assertDontSee('Paystack checkout', false)
->assertDontSee('window.location.assign(data.checkout_url)', false);
}
public function test_json_pay_post_is_not_redirected_to_ladill_link(): void
@@ -101,6 +104,55 @@ class MiniPaymentCheckoutTest extends TestCase
->assertJsonPath('provider', 'paystack');
}
public function test_html_pay_post_does_not_redirect_browser_to_paystack(): void
{
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Vendor',
'email' => 'vendor-html@example.com',
]);
$qr = QrCode::query()->create([
'user_id' => $user->id,
'short_code' => 'payhtml01',
'type' => QrCode::TYPE_PAYMENT,
'label' => 'Till',
'payload' => [
'content' => [
'business_name' => 'Accra Kiosk',
'currency' => 'GHS',
],
'style' => [],
],
'is_active' => true,
]);
$pay = Mockery::mock(PayClient::class);
$pay->shouldReceive('createCheckout')
->once()
->andReturn([
'id' => 101,
'reference' => 'LP-TESTMINIHtml01',
'checkout_url' => 'https://checkout.paystack.com/mini-html',
'access_code' => 'mini-html',
'public_key' => 'pk_test_x',
'provider' => 'paystack',
'platform_fee_minor' => 15,
'merchant_amount_minor' => 985,
]);
$this->app->instance(PayClient::class, $pay);
// HTML form POST (no Accept: application/json) must stay on Mini and
// must not 307 to ladl.link or away to checkout.paystack.com.
$this->post('/q/'.$qr->short_code.'/pay', [
'amount' => 10,
])
->assertRedirect(url('/q/'.$qr->short_code))
->assertSessionHas('checkout_url', 'https://checkout.paystack.com/mini-html')
->assertSessionHas('access_code', 'mini-html')
->assertSessionMissing('error');
}
public function test_pay_initiation_does_not_require_phone_and_returns_paystack_checkout(): void
{
$user = User::create([
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature;
use App\Models\QrCode;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class MiniPaymentCorsTest extends TestCase
{
use RefreshDatabase;
public function test_proxied_payment_landing_uses_same_origin_pay_path(): void
{
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Vendor',
'email' => 'vendor-cors@example.com',
]);
$qr = QrCode::query()->create([
'user_id' => $user->id,
'short_code' => 'paycors01',
'type' => QrCode::TYPE_PAYMENT,
'label' => 'Till',
'payload' => [
'content' => [
'business_name' => 'Accra Kiosk',
'currency' => 'GHS',
],
'style' => [],
],
'is_active' => true,
]);
$html = $this->withHeader(\App\Support\LadillLink::INTERNAL_HEADER, '1')
->get('/q/'.$qr->short_code)
->assertOk()
->getContent();
$this->assertStringContainsString('paycors01/pay', $html);
$this->assertStringNotContainsString('https://mini.ladill.com/q/paycors01/pay', $html);
}
public function test_payment_landing_redirects_non_internal_to_public_host(): void
{
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Vendor',
'email' => 'vendor-mode@example.com',
]);
$qr = QrCode::query()->create([
'user_id' => $user->id,
'short_code' => 'paymode1',
'type' => QrCode::TYPE_PAYMENT,
'label' => 'Till',
'payload' => [
'content' => [
'business_name' => 'Accra Kiosk',
'currency' => 'GHS',
],
'style' => [],
],
'is_active' => true,
]);
// Direct hit (no internal header) is redirected to ladl.link by legacy middleware.
$this->get('/q/'.$qr->short_code)->assertRedirect();
}
}
+31 -5
View File
@@ -18,26 +18,52 @@ class ResponsivePaystackSheetTest extends TestCase
$this->assertStringContainsString('data-ladill-pay-panel', $html);
$this->assertStringContainsString('data-ladill-pay-backdrop', $html);
$this->assertStringContainsString('data-ladill-pay-handle', $html);
$this->assertStringContainsString('data-ladill-pay-inline-status', $html);
$this->assertStringContainsString('md:items-center', $html);
$this->assertStringContainsString('rounded-t-2xl', $html);
$this->assertStringContainsString('md:rounded-2xl', $html);
$this->assertStringContainsString('md:max-w-lg', $html);
$this->assertStringContainsString('data-paystack-live', $html);
$this->assertStringContainsString('data-ladill-pay-close-live', $html);
$this->assertStringContainsString('data-ladill-pay-header-minimal', $html);
$this->assertStringContainsString('Complete your payment', $html);
$this->assertStringContainsString('safe-area-inset-bottom', $html);
$this->assertStringContainsString('LadillPayCheckout', $html);
$this->assertStringContainsString('resumeTransaction', $html);
$this->assertStringContainsString("\$watch('showSheet'", $html);
$this->assertStringContainsString('js.paystack.co/v2/inline.js', $html);
$this->assertStringContainsString('Starting secure checkout', $html);
$this->assertStringContainsString('not in a separate browser', $html);
// Must not push Paystack to a separate browser as the primary path.
$this->assertStringContainsString('items-end justify-center md:items-center', $html);
$this->assertStringContainsString('rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl', $html);
$this->assertStringContainsString('ensurePaystackIframePermissions', $html);
$this->assertStringContainsString('installIframeAllowHook', $html);
$this->assertStringContainsString('fullscreen *', $html);
$this->assertStringNotContainsString("attributeFilter: ['allow', 'src', 'id', 'name', 'style', 'class']", $html);
$this->assertStringContainsString('childList only', $html);
$this->assertStringContainsString('Only write attributes when they actually change', $html);
$this->assertStringContainsString('Do not watch iframes or preload Paystack on every page', $html);
$this->assertStringContainsString('data-ladill-pay-mount', $html);
$this->assertStringContainsString('data-ladill-pay-body', $html);
$this->assertStringNotContainsString('styleCheckoutPinnedToRect', $html);
$this->assertStringNotContainsString('waitForPinTarget', $html);
$this->assertStringNotContainsString('placeCheckoutInMount', $html);
$this->assertStringNotContainsString('installAppendHook', $html);
$this->assertStringNotContainsString('__ladillContainPaystack', $html);
$this->assertStringNotContainsString('Node.prototype.appendChild', $html);
$this->assertStringNotContainsString('data-ladill-pay-inline-status', $html);
$this->assertStringNotContainsString('Please wait', $html);
$this->assertStringContainsString('Payment UI did not open', $html);
$this->assertStringContainsString('ladill-pay-opened', $html);
$this->assertStringContainsString('Only lock page scroll when OUR shell is visible', $html);
$this->assertStringContainsString('releaseBusy', $html);
$this->assertStringContainsString('markOpened', $html);
$this->assertStringNotContainsString('Continue to Paystack', $html);
$this->assertStringNotContainsString('window.open(', $html);
// Must not use Tailwind `hidden` + x-show on the panel (desktop stays invisible).
$this->assertStringNotContainsString('hidden w-full max-w-lg', $html);
$this->assertStringNotContainsString('Paystack checkout', $html);
$this->assertStringNotContainsString('bg-transparent shadow-none', $html);
$this->assertStringNotContainsString('not in a separate browser', $html);
}
public function test_payment_return_escapes_popup_and_iframe_to_opener_or_top(): void
{
$view = view()->exists('partials.payment-return')