Add mobile bottom nav and hide header avatar on small screens.
Deploy Ladill Frontdesk / deploy (push) Successful in 29s

Frontdesk now matches the Ladill mobile pattern: app name in the header,
Home/Search/Notifications/Profile in the bottom bar with a profile sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-28 12:03:49 +00:00
co-authored by Cursor
parent fab246ac83
commit e2faaa0c1d
9 changed files with 218 additions and 17 deletions
+4
View File
@@ -48,5 +48,9 @@ class AppServiceProvider extends ServiceProvider
$view->with('isPro', $isPro);
});
View::composer(['partials.topbar'], function ($view) {
$view->with(\App\Support\MobileTopbar::resolve());
});
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Support;
class MobileTopbar
{
public static function resolve(): array
{
$appName = config('mobile-topbar.app_name', 'Ladill');
$title = $appName === 'Ladill'
? 'Ladill'
: "Ladill {$appName}";
return [
'mobileTopbarTitle' => $title,
];
}
}
+22 -13
View File
@@ -5,14 +5,14 @@ namespace App\Support;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Route;
/**
* Standard signed-in profile links for Ladill product apps (identical across
* the suite). Points at the account/home portals, not app-local routes.
*
* @return list<array<string, mixed>>
*/
class UserProfileMenu
{
/**
* Standard signed-in profile links for Ladill product apps.
* Not used by Ladill Mail (mail.ladill.com) that app keeps a mailbox-specific menu.
*
* @return list<array<string, mixed>>
*/
public static function items(?Authenticatable $user = null): array
{
$user ??= auth()->user();
@@ -43,8 +43,6 @@ class UserProfileMenu
];
}
// Wallet balance peek — sits directly after Billing (only when this
// app exposes a balance endpoint).
if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) {
$items[] = ['type' => 'wallet'];
}
@@ -79,10 +77,13 @@ class UserProfileMenu
return ladill_home_url($path);
}
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
$domain = config('app.home_domain');
if (! $domain) {
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
}
return self::absoluteUrl($domain, $path);
return self::absoluteUrl((string) $domain, $path);
}
if (function_exists('ladill_account_url')) {
@@ -92,7 +93,11 @@ class UserProfileMenu
return self::absoluteUrl((string) config('app.account_domain'), $path);
}
/** @param array{label?: string, path?: string, host?: string} $link */
/**
* Hide a menu link when the signed-in user is already on that destination.
*
* @param array{label?: string, path?: string, host?: string} $link
*/
private static function isCurrentMenuLink(array $link, string $href): bool
{
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
@@ -140,7 +145,11 @@ class UserProfileMenu
{
$base = 'https://'.trim($domain, '/');
return $path === '' ? $base : $base.'/'.ltrim($path, '/');
if ($path === '') {
return $base;
}
return $base.'/'.ltrim($path, '/');
}
private static function userIsAdmin(Authenticatable $user): bool
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
'app_name' => 'Frontdesk',
];
@@ -28,6 +28,33 @@
</main>
</div>
</div>
@auth
@php
$navUser = auth()->user();
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
->filter()->take(2)
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
->implode('');
$navAvatarUrl = $navUser && method_exists($navUser, 'avatarUrl')
? $navUser->avatarUrl()
: ($navUser?->avatar_url ?? null);
@endphp
@include('partials.mobile-bottom-nav', [
'homeUrl' => route('frontdesk.dashboard'),
'homeActive' => request()->routeIs('frontdesk.dashboard'),
'searchUrl' => route('frontdesk.visitors.index'),
'searchActive' => request()->routeIs('frontdesk.visitors.*'),
'notificationsUrl' => route('notifications.index'),
'notificationsActive' => request()->routeIs('notifications.*'),
'unreadUrl' => route('notifications.unread'),
'profileActive' => false,
'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navAvatarUrl,
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
@endauth
@include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')])
@include('partials.afia')
</body>
@@ -0,0 +1,129 @@
@php
$showSearch = isset($searchUrl) && $searchUrl !== null && $searchUrl !== '#';
$gridCols = match (true) {
! empty($centerCompose) => $showSearch ? 'grid-cols-5' : 'grid-cols-4',
default => $showSearch ? 'grid-cols-4' : 'grid-cols-3',
};
$avatarUrl = $avatarUrl ?? null;
$initials = $initials ?? 'U';
$notificationsUrl = $notificationsUrl ?? '#';
$unreadUrl = $unreadUrl ?? null;
$profileUrl = $profileUrl ?? '#';
$profileName = trim((string) ($profileName ?? ''));
$profileSubtitle = trim((string) ($profileSubtitle ?? ''));
$profileMenuItems = $profileMenuItems ?? [];
if ($profileMenuItems === [] && $profileUrl !== '#') {
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
}
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
@endphp
<div x-data="{ profileOpen: false }" class="lg:hidden">
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white"
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
<div class="grid h-16 {{ $gridCols }}">
<a href="{{ $homeUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($homeActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
<span class="text-[10px] font-medium">Home</span>
</a>
@if ($showSearch)
<a href="{{ $searchUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<span class="text-[10px] font-medium">Search</span>
</a>
@endif
@if (!empty($centerCompose))
<div class="flex items-center justify-center">
<button type="button"
@click="$dispatch('compose-open')"
class="btn-fab -translate-y-1 ring-4 ring-white shadow-md"
aria-label="Compose">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z"/></svg>
</button>
</div>
@endif
<a href="{{ $notificationsUrl }}"
@if ($unreadUrl)
x-data="{
unread: 0,
init() {
fetch({{ \Illuminate\Support\Js::from($unreadUrl) }}, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
}).then(r => r.json()).then(d => this.unread = d.unread_count || 0).catch(() => {});
},
}"
@endif
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
@if ($unreadUrl)
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
class="absolute right-3 top-1.5 inline-flex min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 py-0.5 text-[9px] font-semibold text-white"></span>
@endif
<span class="text-[10px] font-medium">Notifications</span>
</a>
<button type="button"
@click="profileOpen = true"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($profileActive ?? false) }}"
:class="profileOpen ? 'text-indigo-600' : ''"
aria-label="Open profile menu">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}" alt="" class="h-7 w-7 rounded-full object-cover ring-1 ring-slate-200">
@else
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900 text-[10px] font-semibold text-white">{{ $initials }}</span>
@endif
<span class="text-[10px] font-medium">Profile</span>
</button>
</div>
</nav>
{{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}}
<div x-show="profileOpen" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="profileOpen = false">
<div x-show="profileOpen" x-transition.opacity.duration.200ms
@click="profileOpen = false"
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
<div x-show="profileOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-full"
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-slate-200 bg-white shadow-2xl"
style="padding-bottom: env(safe-area-inset-bottom, 0px)"
@click.outside="profileOpen = false">
<div class="flex justify-center pt-3">
<span class="h-1 w-10 rounded-full bg-slate-200" aria-hidden="true"></span>
</div>
@if ($profileName !== '' || $profileSubtitle !== '')
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}" alt="" class="h-12 w-12 rounded-full object-cover ring-1 ring-slate-200">
@else
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white">{{ $initials }}</span>
@endif
<div class="min-w-0">
@if ($profileName !== '')
<p class="truncate text-sm font-semibold text-slate-900">{{ $profileName }}</p>
@endif
@if ($profileSubtitle !== '')
<p class="truncate text-xs text-slate-500">{{ $profileSubtitle }}</p>
@endif
</div>
</div>
@endif
@include('partials.user-profile-menu', [
'items' => $profileMenuItems,
'variant' => 'sheet',
'onNavigate' => 'profileOpen = false',
])
</div>
</div>
</div>
@@ -0,0 +1,6 @@
@php
$title = $mobileTopbarTitle ?? 'Ladill';
@endphp
<div class="min-w-0 flex-1 lg:hidden">
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
</div>
@@ -1,5 +1,7 @@
{{--
Standard desktop top-right widgets: notifications launcher divider avatar dropdown.
Top-right header widgets:
Mobile Afia + launcher only (profile/notifications live in bottom nav).
Desktop Afia notifications launcher divider avatar dropdown.
--}}
@php
$topbarUser = $user ?? auth()->user();
@@ -21,7 +23,7 @@
<div class="hidden h-8 w-px bg-slate-200 lg:block"></div>
<div class="relative" x-data="{ profileOpen: false }" @click.outside="profileOpen = false" @keydown.escape.window="profileOpen = false">
<div class="relative hidden lg:block" x-data="{ profileOpen: false }" @click.outside="profileOpen = false" @keydown.escape.window="profileOpen = false">
<button type="button" @click="profileOpen = !profileOpen"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-2 py-1.5 text-slate-700 transition hover:bg-slate-50">
@if ($avatarUrl)
+2 -2
View File
@@ -9,8 +9,8 @@
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
{{-- Mobile: current page title --}}
<h1 class="truncate text-base font-semibold text-slate-900 lg:hidden">{{ $heading ?? 'Ladill Frontdesk' }}</h1>
{{-- Mobile: app name --}}
@include('partials.mobile-topbar-title')
{{-- Desktop: search (visitors) --}}
<form method="GET" action="{{ route('frontdesk.visitors.index') }}"