Two-step add-funds modal instead of wallet redirect on insufficient balance
Deploy Ladill QR Plus / deploy (push) Successful in 40s
Deploy Ladill QR Plus / deploy (push) Successful in 40s
When a user hits an action they can't afford (e.g. creating a QR code with 0/low balance), open an in-app two-step modal instead of bouncing them to the central wallet page. Step 1 explains pay-as-you-go billing + shows the action cost and current balance; step 2 takes an amount and starts a Paystack checkout via the central identity wallet-topup API, returning the user where they were. - service-topup-modal: rebuilt as two-step (billing explainer -> amount/pay) - IdentityClient::walletTopup + WalletTopupController + POST /wallet/topup - balanceGate.openTopup / qrCustomizer.redirectTopup now prefer the modal, falling back to the wallet page only when no modal is wired - qr-codes index + create render the modal and open it on insufficient balance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bbef00b2f0
commit
73faa20fff
@@ -1,13 +1,15 @@
|
||||
@props([
|
||||
'id',
|
||||
'title' => 'Add credits',
|
||||
'title' => 'Add funds',
|
||||
'description' => '',
|
||||
'topupAction',
|
||||
'minTopup' => 5,
|
||||
'suggestedAmount' => 10,
|
||||
'ladillWalletBalance' => 0,
|
||||
'serviceBalance' => null,
|
||||
'serviceBalanceLabel' => 'Current balance',
|
||||
'serviceBalanceLabel' => 'Wallet balance',
|
||||
'pricePerAction' => null,
|
||||
'actionNoun' => 'action',
|
||||
'openOnLoad' => false,
|
||||
'returnUrl' => null,
|
||||
])
|
||||
@@ -17,18 +19,68 @@
|
||||
// wallet into service credits" is a meaningless round-trip — top up the one
|
||||
// wallet directly (Paystack). Transfer option removed.
|
||||
$canPayFromWallet = false;
|
||||
|
||||
// Balance shown in the explainer — prefer the service balance, fall back to
|
||||
// the central wallet figure.
|
||||
$shownBalance = $serviceBalance !== null ? (float) $serviceBalance : (float) $ladillWalletBalance;
|
||||
@endphp
|
||||
|
||||
<x-modal :name="$id" :show="$openOnLoad" maxWidth="md">
|
||||
<div class="flex min-h-full flex-col sm:min-h-0">
|
||||
<div x-data="{ step: 1 }"
|
||||
x-on:open-modal.window="String($event.detail) === @js($id) ? step = 1 : null"
|
||||
class="flex min-h-full flex-col sm:min-h-0">
|
||||
|
||||
{{-- Header with step indicator --}}
|
||||
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ $title }}</h2>
|
||||
@if($description)
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $description }}</p>
|
||||
@endif
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="h-1.5 w-6 rounded-full transition-colors" :class="step >= 1 ? 'bg-indigo-600' : 'bg-slate-200'"></span>
|
||||
<span class="h-1.5 w-6 rounded-full transition-colors" :class="step >= 2 ? 'bg-indigo-600' : 'bg-slate-200'"></span>
|
||||
<span class="ml-2 text-xs font-medium text-slate-400" x-text="'Step ' + step + ' of 2'"></span>
|
||||
</div>
|
||||
<h2 class="mt-2 text-lg font-semibold text-slate-900"
|
||||
x-text="step === 1 ? 'How billing works' : @js($title)">How billing works</h2>
|
||||
<p x-show="step === 2 && @js((bool) $description)" x-cloak class="mt-1 text-sm text-slate-500">{{ $description }}</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6"
|
||||
{{-- STEP 1 — billing explainer --}}
|
||||
<div x-show="step === 1" class="flex flex-1 flex-col p-5 sm:p-6">
|
||||
<p class="text-sm leading-6 text-slate-600">
|
||||
Ladill is <span class="font-medium text-slate-800">pay-as-you-go</span>. One wallet funds
|
||||
every Ladill app, and you're only charged when you perform an action.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-2.5 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
@if($pricePerAction !== null)
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">Each {{ $actionNoun }} costs</span>
|
||||
<span class="font-semibold text-slate-900">GHS {{ number_format((float) $pricePerAction, 2) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">{{ $serviceBalanceLabel }}</span>
|
||||
<span class="font-semibold text-slate-900">GHS {{ number_format($shownBalance, 2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs leading-5 text-slate-500">
|
||||
Top up any amount — unused funds stay in your wallet for next time. You can withdraw
|
||||
them whenever you like.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
|
||||
<button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
|
||||
data-close-modal="{{ $id }}"
|
||||
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">
|
||||
Not now
|
||||
</button>
|
||||
<button type="button" @click="step = 2" class="btn-primary w-full sm:w-auto">
|
||||
Add funds
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- STEP 2 — amount + payment --}}
|
||||
<form x-show="step === 2" x-cloak action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6"
|
||||
x-data="{
|
||||
method: 'paystack',
|
||||
showSheet: false,
|
||||
@@ -70,13 +122,6 @@
|
||||
<input type="hidden" name="return_url" value="{{ $returnUrl }}">
|
||||
@endif
|
||||
|
||||
@if($serviceBalance !== null)
|
||||
<div class="mb-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-center">
|
||||
<p class="text-xl font-bold text-slate-900">GHS {{ number_format((float) $serviceBalance, 2) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $serviceBalanceLabel }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div x-show="errorMsg" x-cloak class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" x-text="errorMsg"></div>
|
||||
|
||||
<div>
|
||||
@@ -88,33 +133,18 @@
|
||||
<p class="mt-1.5 text-xs text-slate-500">Minimum GHS {{ number_format($minTopup, 2) }}</p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="payment_method" value="paystack" @if($canPayFromWallet) x-model="method" @endif>
|
||||
<input type="hidden" name="payment_method" value="paystack">
|
||||
|
||||
@if($canPayFromWallet)
|
||||
<div class="mt-4">
|
||||
<p class="mb-2 text-xs font-medium text-slate-600">Payment method</p>
|
||||
<div class="flex rounded-xl border border-slate-200 bg-white p-0.5 text-sm">
|
||||
<button type="button" @click="method = 'paystack'"
|
||||
:class="method === 'paystack' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
|
||||
class="flex-1 rounded-lg py-2 font-medium transition">Paystack</button>
|
||||
<button type="button" @click="method = 'wallet'"
|
||||
:class="method === 'wallet' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
|
||||
class="flex-1 rounded-lg py-2 font-medium transition">Wallet (GHS {{ number_format($ladillWalletBalance, 2) }})</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<p class="mt-4 text-xs text-slate-500">Pay with Paystack.</p>
|
||||
@endif
|
||||
<p class="mt-4 text-xs text-slate-500">Pay securely with Paystack.</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
|
||||
<button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
|
||||
data-close-modal="{{ $id }}"
|
||||
<button type="button" @click="step = 1"
|
||||
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">
|
||||
Cancel
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary w-full sm:w-auto">
|
||||
<span x-text="loading ? 'Processing…' : 'Add credits'">Add credits</span>
|
||||
<span x-text="loading ? 'Processing…' : 'Continue to payment'">Continue to payment</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -207,19 +207,19 @@ document.addEventListener('alpine:init', () => {
|
||||
return this.needsTopupFor(this.requiresPayment);
|
||||
},
|
||||
openTopup() {
|
||||
// Prefer the in-app two-step add-funds modal; only fall back to the
|
||||
// central wallet page when no modal is wired on this view.
|
||||
if (this.modalId) {
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.modalId,
|
||||
bubbles: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
if (! this.modalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.modalId,
|
||||
bubbles: true,
|
||||
}));
|
||||
},
|
||||
goOrTopup(event, href = null, requiresPayment = null) {
|
||||
const paymentRequired = requiresPayment === null
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
balance: @js((float) $wallet->spendableBalance()),
|
||||
price: @js((float) $pricePerQr),
|
||||
topupUrl: @js($topupUrl),
|
||||
topupModalId: 'qr',
|
||||
type: @js(old('type', $prefill['type'] ?? $defaultType ?? 'url')),
|
||||
})">
|
||||
|
||||
@@ -254,5 +255,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Two-step add-funds modal (insufficient balance opens this instead of redirecting to the wallet) --}}
|
||||
<x-user.service-topup-modal
|
||||
id="qr"
|
||||
title="Add funds"
|
||||
:topup-action="route('user.wallet.topup')"
|
||||
:price-per-action="(float) $pricePerQr"
|
||||
action-noun="QR code"
|
||||
:service-balance="(float) $wallet->spendableBalance()"
|
||||
service-balance-label="Wallet balance"
|
||||
:return-url="route('user.qr-codes.create')"
|
||||
:open-on-load="session('open_topup_modal') === 'qr'" />
|
||||
|
||||
</div>
|
||||
</x-user-layout>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
balance: @js((float) $wallet->spendableBalance()),
|
||||
price: @js((float) $pricePerQr),
|
||||
topupUrl: @js($topupUrl),
|
||||
modalId: 'qr',
|
||||
href: @js(route('user.qr-codes.create')),
|
||||
})">
|
||||
@foreach(['success', 'error'] as $flash)
|
||||
@@ -82,5 +83,17 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Two-step add-funds modal (insufficient balance opens this instead of redirecting to the wallet) --}}
|
||||
<x-user.service-topup-modal
|
||||
id="qr"
|
||||
title="Add funds"
|
||||
:topup-action="route('user.wallet.topup')"
|
||||
:price-per-action="(float) $pricePerQr"
|
||||
action-noun="QR code"
|
||||
:service-balance="(float) $wallet->spendableBalance()"
|
||||
service-balance-label="Wallet balance"
|
||||
:return-url="route('user.qr-codes.index')"
|
||||
:open-on-load="session('open_topup_modal') === 'qr'" />
|
||||
</div>
|
||||
</x-user-layout>
|
||||
|
||||
@@ -227,11 +227,8 @@
|
||||
this.schedulePreview();
|
||||
},
|
||||
redirectTopup() {
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prefer the in-app two-step add-funds modal; only fall back to the
|
||||
// central wallet page when no modal is wired on this view.
|
||||
if (this.topupModalId) {
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.topupModalId,
|
||||
@@ -240,6 +237,11 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
checkBalance(event) {
|
||||
|
||||
Reference in New Issue
Block a user