diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php index 19e1aa8..26164a9 100644 --- a/app/Http/Controllers/Pos/RegisterController.php +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -235,6 +235,7 @@ class RegisterController extends Controller $result = $this->sales->initiatePayCheckout($sale, $merchant); $checkoutUrl = $result['checkout_url'] ?? null; + $accessCode = $result['access_code'] ?? null; $this->customerDisplays->pushPayment($location, $sale->fresh(['lines']), [ 'checkout_url' => $checkoutUrl, @@ -246,6 +247,7 @@ class RegisterController extends Controller if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'checkout_url' => $checkoutUrl, + 'access_code' => $accessCode, 'sale_id' => $sale->id, ]); } @@ -253,6 +255,7 @@ class RegisterController extends Controller return redirect() ->route('pos.sales.show', $sale) ->with('checkout_url', $checkoutUrl) + ->with('access_code', $accessCode) ->with('success', 'Payment sheet ready — customer screen shows the QR; till sheet is for the operator.'); } catch (RuntimeException $e) { return back()->withInput()->with('error', $e->getMessage()); diff --git a/app/Http/Controllers/Pos/TicketController.php b/app/Http/Controllers/Pos/TicketController.php index 40f7e1a..6efb678 100644 --- a/app/Http/Controllers/Pos/TicketController.php +++ b/app/Http/Controllers/Pos/TicketController.php @@ -230,6 +230,7 @@ class TicketController extends Controller if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'checkout_url' => $result['checkout_url'], + 'access_code' => $result['access_code'] ?? '', 'sale_id' => $sale->id, ]); } @@ -237,6 +238,7 @@ class TicketController extends Controller return redirect() ->route('pos.tickets.show', $sale) ->with('checkout_url', $result['checkout_url']) + ->with('access_code', $result['access_code'] ?? '') ->with('success', 'Payment sheet ready — hand the device to the customer for card or MoMo.'); } catch (RuntimeException $e) { return back()->with('error', $e->getMessage()); diff --git a/app/Services/Payments/MerchantGatewayService.php b/app/Services/Payments/MerchantGatewayService.php index 87717f2..a6a5964 100644 --- a/app/Services/Payments/MerchantGatewayService.php +++ b/app/Services/Payments/MerchantGatewayService.php @@ -132,6 +132,7 @@ class MerchantGatewayService return [ 'checkout_url' => (string) $checkout['checkout_url'], + 'access_code' => (string) ($checkout['access_code'] ?? ''), 'reference' => (string) $checkout['reference'], 'provider' => 'ladill_pay', ]; @@ -186,6 +187,7 @@ class MerchantGatewayService return [ 'checkout_url' => $url, + 'access_code' => (string) ($response->json('data.access_code') ?? ''), 'reference' => (string) ($response->json('data.reference') ?: $reference), 'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK, ]; diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index b90d963..8081cab 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -362,7 +362,11 @@ class PosSaleService 'payment_reference' => $checkout['reference'], ])->save(); - return ['payment' => $payment, 'checkout_url' => $checkout['checkout_url']]; + return [ + 'payment' => $payment, + 'checkout_url' => $checkout['checkout_url'], + 'access_code' => (string) ($checkout['access_code'] ?? ''), + ]; } public function completePayPayment(string $reference): PosPayment @@ -577,6 +581,7 @@ class PosSaleService return [ 'sale' => $sale->fresh('lines'), 'checkout_url' => $checkoutUrl, + 'access_code' => (string) ($checkout['access_code'] ?? ''), ]; } diff --git a/resources/js/app.js b/resources/js/app.js index 0fb1976..5053df2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -21,13 +21,17 @@ document.addEventListener('alpine:init', () => { Alpine.store('paymentCheckout', { isOpen: false, url: '', - open(url) { + accessCode: '', + open(url, accessCode = '') { this.url = url || ''; - this.isOpen = this.url !== ''; + this.accessCode = accessCode || ''; + this.isOpen = this.url !== '' || this.accessCode !== ''; }, close() { this.isOpen = false; this.url = ''; + this.accessCode = ''; + window.LadillPayCheckout?.cancel?.(); }, }); }); diff --git a/resources/views/partials/payment-checkout-host.blade.php b/resources/views/partials/payment-checkout-host.blade.php index 91d2855..f1f23eb 100644 --- a/resources/views/partials/payment-checkout-host.blade.php +++ b/resources/views/partials/payment-checkout-host.blade.php @@ -2,8 +2,9 @@
@@ -11,6 +12,7 @@ get showSheet() { return $store.paymentCheckout.isOpen }, set showSheet(v) { if (!v) { $store.paymentCheckout.close() } }, get checkoutUrl() { return $store.paymentCheckout.url }, + get accessCode() { return $store.paymentCheckout.accessCode }, }"> @include('partials.paystack-sheet', [ 'audience' => 'operator', diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php index 4aee647..f4e6192 100644 --- a/resources/views/partials/paystack-sheet.blade.php +++ b/resources/views/partials/paystack-sheet.blade.php @@ -187,12 +187,14 @@ this.launchInline(code, returnUrl); return; } - // Non-Paystack external URL without access_code — cannot embed. + // Never open checkout.paystack.com in an external tab. 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.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) { var store = this; @@ -293,118 +295,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" - 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" aria-modal="true" :aria-hidden="(!showSheet).toString()" aria-label="{{ $sheetAria }}"> -
+ x-transition:leave-end="opacity-0"> +
+ +

Opening payment…

+
- {{-- One panel: bottom sheet (mobile) + centered modal (desktop). --}} -
-
- -
-
-

{{ $sheetTitle }}

- @if (! empty($sheetSubtitle)) -

{{ $sheetSubtitle }}

- @endif -
- -
+ {{-- Full Ladill shell only for same-origin iframe (e.g. MoMo waiting) or recovery errors. --}} +
+
-
- -
-
diff --git a/tests/Feature/ResponsivePaystackSheetTest.php b/tests/Feature/ResponsivePaystackSheetTest.php index 0db01ec..edf49fd 100644 --- a/tests/Feature/ResponsivePaystackSheetTest.php +++ b/tests/Feature/ResponsivePaystackSheetTest.php @@ -18,6 +18,8 @@ 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-loading', $html); + $this->assertStringContainsString('data-ladill-pay-shell', $html); $this->assertStringContainsString('data-ladill-pay-inline-status', $html); $this->assertStringContainsString('md:items-center', $html); $this->assertStringContainsString('rounded-t-2xl', $html); @@ -28,11 +30,13 @@ class ResponsivePaystackSheetTest extends TestCase $this->assertStringContainsString('LadillPayCheckout', $html); $this->assertStringContainsString('resumeTransaction', $html); $this->assertStringContainsString('js.paystack.co/v2/inline.js', $html); - $this->assertStringContainsString('Starting secure checkout', $html); - $this->assertStringContainsString('not in a separate browser', $html); + $this->assertStringContainsString('Opening payment', $html); + $this->assertStringContainsString('pointer-events-none', $html); // Must not push Paystack to a separate browser as the primary path. $this->assertStringNotContainsString('Continue to Paystack', $html); $this->assertStringNotContainsString('window.open(', $html); + // Must not stack Ladill chrome under Paystack Inline (double interface). + $this->assertStringNotContainsString('Complete payment in the Paystack window', $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); @@ -56,4 +60,16 @@ class ResponsivePaystackSheetTest extends TestCase || str_contains($html, 'https:\/\/example.test\/paid') ); } + + public function test_payment_checkout_host_passes_access_code(): void + { + $path = resource_path('views/partials/payment-checkout-host.blade.php'); + $this->assertFileExists($path); + $html = file_get_contents($path); + + $this->assertStringContainsString('access_code', $html); + $this->assertStringContainsString('accessCode', $html); + $this->assertStringContainsString('paymentCheckout.open', $html); + $this->assertStringContainsString('partials.paystack-sheet', $html); + } }