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.
This commit is contained in:
isaacclad
2026-07-21 21:32:36 +00:00
parent 86d2dcbf0b
commit a50b5c6a44
8 changed files with 275 additions and 249 deletions
@@ -41,7 +41,7 @@ class PaymentController extends Controller
return back()->withInput()->with('error', $e->getMessage()); return back()->withInput()->with('error', $e->getMessage());
} }
if ($request->expectsJson()) { if ($request->expectsJson() || $request->ajax()) {
return response()->json([ return response()->json([
'checkout_url' => $result['checkout_url'], 'checkout_url' => $result['checkout_url'],
'access_code' => $result['access_code'] ?? null, '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 public function callback(Request $request, string $shortCode): View
@@ -19,13 +19,20 @@ class RedirectLegacyQrToLadillLink
return $next($request); return $next($request);
} }
// Same-origin AJAX from Mini-hosted payment pages (e.g. mini.ladill.com) // Same-origin pay from Mini-hosted pages (e.g. mini.ladill.com) must hit
// must hit local /q/{code}/pay — redirecting to ladl.link breaks CORS and // local /q/{code}/pay — redirecting to ladl.link breaks CORS / form POSTs
// the checkout sheet never opens. // and used to dump the browser onto checkout.paystack.com.
if ($request->ajax() || $request->expectsJson() || $request->wantsJson()) { if ($request->ajax() || $request->expectsJson() || $request->wantsJson()) {
return $next($request); 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); return LadillLink::legacyRedirect($request);
} }
} }
+38 -13
View File
@@ -201,6 +201,17 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
} }
this._syncSheet(); 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() { destroy() {
@@ -211,6 +222,30 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
document.documentElement.classList.remove('mini-payment-page'); 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;
}
},
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;
}
this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
return true;
},
async submitPay() { async submitPay() {
const value = parseFloat(this.amount); const value = parseFloat(this.amount);
if (!value || value <= 0) { if (!value || value <= 0) {
@@ -224,8 +259,8 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
this.accessCode = ''; this.accessCode = '';
this.publicKey = ''; this.publicKey = '';
this.returnUrl = ''; this.returnUrl = '';
// Show sheet/modal immediately; Paystack Inline opens in-page from the sheet. // Keep sheet closed until we have a checkout payload so Inline syncs once with access_code.
this.showSheet = true; this.showSheet = false;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 45000); const timer = setTimeout(() => controller.abort(), 45000);
@@ -257,17 +292,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
return; return;
} }
// Legacy MoMo waiting page must be full-page — never trap it in the Paystack iframe if (!this.openInlineCheckout(data)) {
// (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);
return; return;
} }
} catch (e) { } catch (e) {
+107 -98
View File
@@ -187,12 +187,15 @@
this.launchInline(code, returnUrl); this.launchInline(code, returnUrl);
return; return;
} }
// Non-Paystack external URL without access_code — cannot embed. // Never navigate to checkout.paystack.com (external tab/window).
// Without access_code we cannot open Inline in-page.
this.frameable = false; this.frameable = false;
this.inline = false; this.inline = false;
this.launching = false; this.launching = false;
this._activeCode = ''; this._activeCode = '';
this.error = 'Secure checkout could not be opened in-page. Please try again.'; this.error = 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) { launchInline(accessCode, returnUrl) {
var store = this; var store = this;
@@ -293,118 +296,124 @@
); );
} }
" "
@keydown.escape.window="if (showSheet) showSheet = false" @keydown.escape.window="if (showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error || $store.ladillPayCheckout.launching)) showSheet = false"
@ladill-pay-cancelled.window="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" class="fixed inset-0 z-[9999]"
:class="($store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching && !$store.ladillPayCheckout.error && !$store.ladillPayCheckout.frameable) ? 'pointer-events-none' : ''"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
:aria-hidden="(!showSheet).toString()" :aria-hidden="(!showSheet).toString()"
aria-label="{{ $sheetAria }}"> aria-label="{{ $sheetAria }}">
<div class="absolute inset-0 bg-black/60" {{-- Brief loader only then Paystack Inline is the sole payment UI (avoids double modal). --}}
data-ladill-pay-backdrop <div x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.launching && !$store.ladillPayCheckout.error"
x-show="showSheet" x-cloak
x-transition:enter="transition-opacity duration-300" data-ladill-pay-loading
class="pointer-events-auto absolute inset-0 flex items-center justify-center bg-black/40 p-6"
x-transition:enter="transition-opacity duration-200"
x-transition:enter-start="opacity-0" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-200" x-transition:leave="transition-opacity duration-150"
x-transition:leave-start="opacity-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" x-transition:leave-end="opacity-0">
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"> <div class="flex items-center gap-3 rounded-2xl bg-white px-5 py-4 shadow-xl">
<svg class="h-5 w-5 animate-spin text-indigo-600" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<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>
<p class="text-sm font-semibold text-slate-800">Opening payment…</p>
</div>
</div> </div>
{{-- One panel: bottom sheet (mobile) + centered modal (desktop). --}} {{-- Full Ladill shell only for same-origin iframe (e.g. MoMo waiting) or recovery errors. --}}
<div data-ladill-pay-panel <div x-show="$store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
class="relative flex w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl" x-cloak
style="height: min(90dvh, 720px); max-height: 90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)" class="pointer-events-auto absolute inset-0 flex items-end justify-center md:items-center md:p-6"
x-show="showSheet" data-ladill-pay-shell>
x-transition:enter="transition duration-300 ease-out md:duration-200" <div class="absolute inset-0 bg-black/60"
x-transition:enter-start="translate-y-full opacity-0 md:translate-y-0 md:scale-95" data-ladill-pay-backdrop
x-transition:enter-end="translate-y-0 opacity-100 md:scale-100" x-show="$store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
x-transition:leave="transition duration-200 ease-in md:duration-150" x-transition:enter="transition-opacity duration-300"
x-transition:leave-start="translate-y-0 opacity-100 md:scale-100" x-transition:enter-start="opacity-0"
x-transition:leave-end="translate-y-full opacity-0 md:translate-y-0 md:scale-95" x-transition:enter-end="opacity-100"
@click.stop> x-transition:leave="transition-opacity duration-200"
<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" x-transition:leave-start="opacity-100"
style="padding-top: max(1.25rem, env(safe-area-inset-top, 0px))"> x-transition:leave-end="opacity-0"
<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> @click="showSheet = false; window.LadillPayCheckout?.cancel?.()">
<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>
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<iframe x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.frameable"
:src="showSheet && $store.ladillPayCheckout && $store.ladillPayCheckout.frameable ? checkoutUrl : ''"
class="h-full min-h-[60dvh] w-full border-0 md:min-h-0"
style="min-height: 28rem"
allow="payment *"
title="{{ $iframeTitle }}"></iframe>
<div x-show="!$store.ladillPayCheckout || !$store.ladillPayCheckout.frameable" <div data-ladill-pay-panel
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" class="relative flex w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl"
data-ladill-pay-inline-status> style="height: min(90dvh, 720px); max-height: 90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)"
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-indigo-50 text-indigo-600" aria-hidden="true"> x-show="$store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
<svg x-show="!$store.ladillPayCheckout || $store.ladillPayCheckout.launching || !checkoutUrl && !(typeof accessCode !== 'undefined' && accessCode)" x-transition:enter="transition duration-300 ease-out md:duration-200"
class="h-6 w-6 animate-spin" fill="none" viewBox="0 0 24 24"> x-transition:enter-start="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> x-transition:enter-end="translate-y-0 opacity-100 md:scale-100"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> x-transition:leave="transition duration-200 ease-in md:duration-150"
</svg> x-transition:leave-start="translate-y-0 opacity-100 md:scale-100"
<svg x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching && !$store.ladillPayCheckout.error" x-transition:leave-end="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor"> @click.stop>
<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"/> <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"
</svg> style="padding-top: max(1.25rem, env(safe-area-inset-top, 0px))">
<svg x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.error" <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>
class="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor"> <div class="flex items-start justify-between gap-3">
<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"/> <div class="min-w-0">
</svg> <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> </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> </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.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-inline-status>
<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="$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>
</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>
@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>
</div> </div>
</template> </template>
+39 -22
View File
@@ -362,15 +362,20 @@
const data = await res.json(); const data = await res.json();
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } 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.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; }
if (data.provider === 'mtn_momo') { // Never send the browser to checkout.paystack.com.
window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url; 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 { } else {
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || ''; this.accessCode = data.access_code || '';
this.publicKey = data.public_key || ''; this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || ''; this.returnUrl = data.callback_url || '';
this.showSheet = true; this.showSheet = true;
this.loading = false; this.loading = false;
} }
} catch(e) { } catch(e) {
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
@@ -678,13 +683,20 @@
const data = await res.json(); const data = await res.json();
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } 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.paid) { window.location.href = data.success_url; return; }
if (data.provider === 'mtn_momo') { // Never send the browser to checkout.paystack.com.
window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url; 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 { } else {
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || ''; this.accessCode = data.access_code || '';
this.publicKey = data.public_key || ''; this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || ''; this.showSheet = true; this.loading = false; this.returnUrl = data.callback_url || '';
this.showSheet = true;
this.loading = false;
} }
} catch(e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; } } catch(e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
} }
@@ -1222,15 +1234,20 @@
const data = await res.json(); const data = await res.json();
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } 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.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; }
if (data.provider === 'mtn_momo') { // Never send the browser to checkout.paystack.com.
window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url; 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 { } else {
this.checkoutUrl = data.checkout_url; this.checkoutUrl = data.checkout_url || '';
this.accessCode = data.access_code || ''; this.accessCode = data.access_code || '';
this.publicKey = data.public_key || ''; this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || ''; this.returnUrl = data.callback_url || '';
this.showSheet = true; this.showSheet = true;
this.loading = false; this.loading = false;
} }
} catch(e) { } catch(e) {
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
@@ -23,8 +23,13 @@
x-data="miniPaymentLanding({ x-data="miniPaymentLanding({
payUrl: @js($payUrl), payUrl: @js($payUrl),
csrf: @js($csrf), csrf: @js($csrf),
amount: @js(old('amount')), amount: @js(old('amount', session('amount'))),
errorMsg: @js(session('error')), 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 --}} {{-- Mobile: Ladill Mini branding + fixed payment sheet --}}
@@ -123,112 +128,5 @@
</main> </main>
@include('partials.paystack-sheet') @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> </body>
</html> </html>
+52 -1
View File
@@ -44,13 +44,15 @@ class MiniPaymentCheckoutTest extends TestCase
->assertSee('md:items-center', false) ->assertSee('md:items-center', false)
->assertSee('Complete your payment', false) ->assertSee('Complete your payment', false)
->assertSee('data-ladill-pay-sheet', false) ->assertSee('data-ladill-pay-sheet', false)
->assertSee('data-ladill-pay-loading', false)
->assertSee('resumeTransaction', false) ->assertSee('resumeTransaction', false)
->assertSee('/q/'.$qr->short_code.'/pay', false) ->assertSee('/q/'.$qr->short_code.'/pay', false)
->assertDontSee('https://ladl.link/'.$qr->short_code.'/pay', false) ->assertDontSee('https://ladl.link/'.$qr->short_code.'/pay', false)
->assertDontSee('MTN MoMo number', false) ->assertDontSee('MTN MoMo number', false)
->assertDontSee('Pay with MTN MoMo', false) ->assertDontSee('Pay with MTN MoMo', false)
->assertDontSee('Continue to Paystack', 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 public function test_json_pay_post_is_not_redirected_to_ladill_link(): void
@@ -101,6 +103,55 @@ class MiniPaymentCheckoutTest extends TestCase
->assertJsonPath('provider', 'paystack'); ->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 public function test_pay_initiation_does_not_require_phone_and_returns_paystack_checkout(): void
{ {
$user = User::create([ $user = User::create([
@@ -28,11 +28,13 @@ class ResponsivePaystackSheetTest extends TestCase
$this->assertStringContainsString('LadillPayCheckout', $html); $this->assertStringContainsString('LadillPayCheckout', $html);
$this->assertStringContainsString('resumeTransaction', $html); $this->assertStringContainsString('resumeTransaction', $html);
$this->assertStringContainsString('js.paystack.co/v2/inline.js', $html); $this->assertStringContainsString('js.paystack.co/v2/inline.js', $html);
$this->assertStringContainsString('Starting secure checkout', $html); $this->assertStringContainsString('Opening payment', $html);
$this->assertStringContainsString('not in a separate browser', $html); $this->assertStringContainsString('data-ladill-pay-loading', $html);
$this->assertStringContainsString('pointer-events-none', $html);
// Must not push Paystack to a separate browser as the primary path. // Must not push Paystack to a separate browser as the primary path.
$this->assertStringNotContainsString('Continue to Paystack', $html); $this->assertStringNotContainsString('Continue to Paystack', $html);
$this->assertStringNotContainsString('window.open(', $html); $this->assertStringNotContainsString('window.open(', $html);
$this->assertStringNotContainsString('Complete payment in the Paystack window', $html);
// Must not use Tailwind `hidden` + x-show on the panel (desktop stays invisible). // Must not use Tailwind `hidden` + x-show on the panel (desktop stays invisible).
$this->assertStringNotContainsString('hidden w-full max-w-lg', $html); $this->assertStringNotContainsString('hidden w-full max-w-lg', $html);
$this->assertStringNotContainsString('Paystack checkout', $html); $this->assertStringNotContainsString('Paystack checkout', $html);