Add wallet balance widget to the profile menu (after Billing)
Deploy Ladill Servers / deploy (push) Successful in 22s

Wallet balance peek rendered as a menu row directly under Billing, backed by
a /wallet/balance endpoint (BillingClient) and guarded by Route::has. Also
opens Afia via a direct window event for reliability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-23 12:25:41 +00:00
co-authored by Claude Opus 4.8
parent dac9fd37fc
commit 6ba9d7ba2d
9 changed files with 96 additions and 2 deletions
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Lightweight wallet balance for the avatar dropdown widget.
*/
class WalletBalanceController extends Controller
{
public function balance(Request $request, BillingClient $billing): JsonResponse
{
$publicId = (string) $request->user()->public_id;
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
try {
return $billing->balanceMinor($publicId);
} catch (\Throwable) {
return null;
}
});
if ($minor === null) {
return response()->json(['available' => false]);
}
$currency = (string) config('billing.currency', 'GHS');
return response()->json([
'available' => true,
'balance_minor' => $minor,
'currency' => $currency,
'formatted' => $currency.' '.number_format($minor / 100, 2),
]);
}
}
+5
View File
@@ -43,6 +43,11 @@ class UserProfileMenu
]; ];
} }
if (Route::has((string) config("billing.wallet_balance_route", "wallet.balance"))) {
$items[] = ["type" => "wallet"];
}
if (self::userIsAdmin($user)) { if (self::userIsAdmin($user)) {
$adminHref = self::platformUrl('account', 'admin'); $adminHref = self::platformUrl('account', 'admin');
+2
View File
@@ -11,4 +11,6 @@ return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'), 'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_SERVERS'), 'api_key' => env('BILLING_API_KEY_SERVERS'),
'service' => 'servers', 'service' => 'servers',
'wallet_balance_route' => 'wallet.balance',
'currency' => 'GHS',
]; ];
+20
View File
@@ -555,6 +555,26 @@ window.serverOrderForm = (products = {}, billingCycleLabels = {}, initial = {})
}, },
}); });
// Wallet balance peek for the avatar dropdown.
Alpine.data('walletWidget', (config = {}) => ({
display: '…',
async load() {
if (! config.url) {
this.display = 'View wallet';
return;
}
try {
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
const data = await res.json();
this.display = data.available ? data.formatted : 'View wallet';
} catch (e) {
this.display = 'View wallet';
}
},
}));
window.Alpine = Alpine; window.Alpine = Alpine;
registerLadillConfirmStore(Alpine); registerLadillConfirmStore(Alpine);
@@ -8,7 +8,7 @@
@if ($handler === 'openAfia') @if ($handler === 'openAfia')
@click="openAfia()" @click="openAfia()"
@else @else
@click="$dispatch('afia-open')" @click="window.dispatchEvent(new CustomEvent('afia-open'))"
@endif @endif
@class([ @class([
'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95', 'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95',
+1 -1
View File
@@ -75,7 +75,7 @@
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg> <svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
</button> </button>
<div x-show="open" x-cloak @click.outside="open = false" <div x-show="open" x-cloak @click.outside="open = false"
class="absolute right-0 z-50 mt-2 w-56 rounded-xl border border-slate-200 bg-white shadow-lg"> class="absolute right-0 z-50 mt-2 w-64 rounded-xl border border-slate-200 bg-white shadow-lg">
@include('partials.user-profile-menu', [ @include('partials.user-profile-menu', [
'items' => \App\Support\UserProfileMenu::items($u), 'items' => \App\Support\UserProfileMenu::items($u),
]) ])
@@ -53,6 +53,8 @@
@if ($onNavigate) @click="{{ $onNavigate }}" @endif> @if ($onNavigate) @click="{{ $onNavigate }}" @endif>
{{ $item['label'] }} {{ $item['label'] }}
</a> </a>
@elseif (($item['type'] ?? '') === 'wallet')
@includeIf('partials.wallet-widget')
@elseif (($item['type'] ?? '') === 'logout') @elseif (($item['type'] ?? '') === 'logout')
@if ($variant !== 'sheet') @if ($variant !== 'sheet')
<div class="{{ $dividerClass }}"></div> <div class="{{ $dividerClass }}"></div>
@@ -0,0 +1,23 @@
{{-- Wallet balance peek (links to the account wallet on account.ladill.com). --}}
@php
$balanceRoute = (string) config('billing.wallet_balance_route', 'wallet.balance');
$balanceUrl = \Illuminate\Support\Facades\Route::has($balanceRoute) ? route($balanceRoute) : null;
@endphp
@if ($balanceUrl)
<a href="{{ ladill_account_url('/wallet') }}"
x-data="walletWidget({ url: {{ \Illuminate\Support\Js::from($balanceUrl) }} })" x-init="load()"
class="mx-1 flex items-center justify-between gap-3 rounded-xl border border-slate-100 bg-gradient-to-r from-indigo-50 to-slate-50 px-3 py-2.5 transition hover:border-indigo-200 hover:from-indigo-100/70">
<span class="flex items-center gap-2.5 min-w-0">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-indigo-100 text-indigo-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.6" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 1 3 7.5m18 4.5v3.75A2.25 2.25 0 0 1 18.75 18H5.25A2.25 2.25 0 0 1 3 15.75V7.5m18 4.5h-3.75a1.5 1.5 0 0 0 0 3H21M3 7.5A2.25 2.25 0 0 1 5.25 5.25h11.25A2.25 2.25 0 0 1 18.75 7.5"/>
</svg>
</span>
<span class="min-w-0">
<span class="block text-[11px] font-medium text-slate-500">Wallet balance</span>
<span class="block truncate text-sm font-semibold text-slate-900" x-text="display"></span>
</span>
</span>
<svg class="h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m9 5 7 7-7 7"/></svg>
</a>
@endif
+2
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\WalletBalanceController;
use App\Http\Controllers\Hosting\AccountController; use App\Http\Controllers\Hosting\AccountController;
use App\Http\Controllers\Hosting\AfiaController; use App\Http\Controllers\Hosting\AfiaController;
use App\Http\Controllers\Hosting\DeveloperController; use App\Http\Controllers\Hosting\DeveloperController;
@@ -29,6 +30,7 @@ Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSign
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('servers.dashboard') : view('servers.signed-out'))->name('servers.signed-out'); Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('servers.dashboard') : view('servers.signed-out'))->name('servers.signed-out');
Route::middleware(['auth', 'platform.session'])->group(function () { Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance');
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');