diff --git a/app/Http/Controllers/Mini/PaymentQrController.php b/app/Http/Controllers/Mini/PaymentQrController.php index b26e231..fab5e86 100644 --- a/app/Http/Controllers/Mini/PaymentQrController.php +++ b/app/Http/Controllers/Mini/PaymentQrController.php @@ -92,6 +92,8 @@ class PaymentQrController extends Controller 'is_active' => 'sometimes|boolean', ]); + $data['is_active'] = $request->boolean('is_active', $paymentQr->is_active); + try { $this->manager->update($paymentQr, $data); } catch (RuntimeException $e) { diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index 29f6df2..62b07f4 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -371,6 +371,7 @@ class QrCodeManagerService QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'], QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'], QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'], + QrCode::TYPE_PAYMENT => ['business_name', 'branch_label', 'currency'], default => [], }; diff --git a/resources/css/app.css b/resources/css/app.css index ed9643f..371c9e5 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -67,6 +67,39 @@ html.qr-mobile-page body { background-color: #fff; } +/* Payment QR landing: lock scroll and keep the amount sheet flush to the screen bottom */ +@media (max-width: 767px) { + html.mini-payment-page, + html.mini-payment-page body { + overflow: hidden; + width: 100%; + height: 100%; + } + + html.mini-payment-page body { + position: fixed; + inset: 0; + } + + .mini-payment-sheet { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 20; + } + + .mini-payment-bottom-bleed { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 19; + pointer-events: none; + background-color: #fff; + height: calc(12rem + env(safe-area-inset-bottom, 0px)); + min-height: 8rem; + } +} + .mobile-stats-card { width: calc(66.666667% - 0.5rem); } diff --git a/resources/js/app.js b/resources/js/app.js index ad8634c..f9f7f99 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -144,6 +144,111 @@ Alpine.data('afia', (config = {}) => ({ }, })); +function mobileKeyboardBottomOffset() { + const viewport = window.visualViewport; + if (!viewport) { + return 0; + } + + return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)); +} + +Alpine.data('miniPaymentLanding', (config = {}) => ({ + amount: config.amount ?? '', + loading: false, + errorMsg: config.errorMsg ?? '', + showSheet: false, + checkoutUrl: '', + paymentSheetStyle: '', + sheetBleedStyle: '', + + init() { + this._syncSheet = () => { + if (window.innerWidth >= 768) { + this.paymentSheetStyle = ''; + this.sheetBleedStyle = ''; + return; + } + + const offset = mobileKeyboardBottomOffset(); + 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(); + this._onFocusChange = () => { + requestAnimationFrame(this._syncSheet); + setTimeout(this._syncSheet, 150); + setTimeout(this._syncSheet, 350); + }; + + window.visualViewport?.addEventListener('resize', this._onViewportChange); + window.visualViewport?.addEventListener('scroll', this._onViewportChange); + document.addEventListener('focusin', this._onFocusChange); + document.addEventListener('focusout', this._onFocusChange); + + if (window.innerWidth < 768) { + document.documentElement.classList.add('mini-payment-page'); + } + + this._syncSheet(); + }, + + destroy() { + window.visualViewport?.removeEventListener('resize', this._onViewportChange); + window.visualViewport?.removeEventListener('scroll', this._onViewportChange); + document.removeEventListener('focusin', this._onFocusChange); + document.removeEventListener('focusout', this._onFocusChange); + document.documentElement.classList.remove('mini-payment-page'); + }, + + 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; + + 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 }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error || data.message) { + this.errorMsg = data.error || data.message || 'Could not start payment. Please try again.'; + this.loading = false; + return; + } + if (!data.checkout_url) { + this.errorMsg = 'Could not start payment. Please try again.'; + this.loading = false; + return; + } + if (window.innerWidth < 768) { + this.checkoutUrl = data.checkout_url; + this.showSheet = true; + this.loading = false; + } else { + window.location.href = data.checkout_url; + } + } catch (e) { + this.errorMsg = 'Network error. Please try again.'; + this.loading = false; + } + }, +})); + Alpine.data('topbarSearch', (config = {}) => ({ query: config.initialQuery || '', results: Array.isArray(config.initialResults) ? config.initialResults : [], diff --git a/resources/views/public/qr/payment-landing.blade.php b/resources/views/public/qr/payment-landing.blade.php index d0ed4a3..cd1fb19 100644 --- a/resources/views/public/qr/payment-landing.blade.php +++ b/resources/views/public/qr/payment-landing.blade.php @@ -17,68 +17,32 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) ({})); - if (!res.ok || data.error || data.message) { - this.errorMsg = data.error || data.message || 'Could not start payment. Please try again.'; - this.loading = false; - return; - } - if (!data.checkout_url) { - this.errorMsg = 'Could not start payment. Please try again.'; - this.loading = false; - return; - } - if (window.innerWidth < 768) { - this.checkoutUrl = data.checkout_url; - this.showSheet = true; - this.loading = false; - } else { - window.location.href = data.checkout_url; - } - } catch (e) { - this.errorMsg = 'Network error. Please try again.'; - this.loading = false; - } - } - }"> + })"> - {{-- Mobile: Ladill Mini branding + content-sized payment sheet --}} -
-
- Ladill Mini -

{{ $businessName }}

- @if(!empty($content['branch_label'])) -

{{ $content['branch_label'] }}

- @endif + {{-- Mobile: Ladill Mini branding + fixed payment sheet --}} +
+
+
+ Ladill Mini +

{{ $businessName }}

+ @if(!empty($content['branch_label'])) +

{{ $content['branch_label'] }}

+ @endif +
-
+ + +
diff --git a/tests/Unit/Services/Qr/PaymentQrUpdateTest.php b/tests/Unit/Services/Qr/PaymentQrUpdateTest.php new file mode 100644 index 0000000..7949c3a --- /dev/null +++ b/tests/Unit/Services/Qr/PaymentQrUpdateTest.php @@ -0,0 +1,57 @@ + (string) Str::uuid(), + 'name' => 'Trader', + 'email' => 'trader@example.com', + ]); + + $qrCode = QrCode::create([ + 'user_id' => $user->id, + 'short_code' => 'paytest01', + 'type' => QrCode::TYPE_PAYMENT, + 'label' => 'Till 1', + 'payload' => [ + 'content' => [ + 'business_name' => 'Old Shop', + 'branch_label' => 'Main', + 'currency' => 'GHS', + ], + 'style' => [], + ], + 'is_active' => true, + ]); + + $manager = new QrCodeManagerService( + $this->createMock(QrWalletBillingService::class), + $this->createMock(QrImageGeneratorService::class), + new QrPayloadValidator(), + ); + + $updated = $manager->update($qrCode, [ + 'business_name' => 'New Shop', + 'branch_label' => 'Accra Mall', + ]); + + $this->assertSame('New Shop', $updated->content()['business_name']); + $this->assertSame('Accra Mall', $updated->content()['branch_label']); + } +}