Compare commits
19
Commits
86d2dcbf0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ba69f63f | ||
|
|
faa0205ad6 | ||
|
|
24df57be0e | ||
|
|
8034d62b95 | ||
|
|
5edbf0a677 | ||
|
|
fa82e8e906 | ||
|
|
8db08892c7 | ||
|
|
9403f671f3 | ||
|
|
98a17a6207 | ||
|
|
cf5cc4d50d | ||
|
|
7e99c5554c | ||
|
|
e99e64b517 | ||
|
|
bd8764523c | ||
|
|
4291d95518 | ||
|
|
75d201bcae | ||
|
|
e03a5dc30f | ||
|
|
de6e6f5331 | ||
|
|
4f2293ec4a | ||
|
|
a50b5c6a44 |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
];
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user