From 7fca4a351a45a6dbabc42b7af44f63920bd9ab79 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 6 Jun 2026 18:27:39 +0000 Subject: [PATCH] Add per-app notifications and enforce hosting brand assets. Wire local notification inbox in the topbar, sync ladillhosting logo and hosting launcher icon, and fix signed-out page branding. Co-authored-by: Cursor --- .../Controllers/NotificationController.php | 64 +++++++++++ ...6_06_200000_create_notifications_table.php | 25 ++++ public/favicon.ico | Bin 7556 -> 8786 bytes public/images/launcher-icons/hosting.svg | 26 ++--- public/images/logo/ladillhosting-logo.svg | 50 ++++---- resources/js/app.js | 85 ++++++++++++++ resources/views/hosting/signed-out.blade.php | 2 +- resources/views/layouts/email.blade.php | 4 +- resources/views/layouts/hosting.blade.php | 4 +- resources/views/notifications/_list.blade.php | 93 +++++++++++++++ resources/views/notifications/index.blade.php | 10 ++ .../partials/notification-dropdown.blade.php | 108 ++++++++++++++++++ resources/views/partials/topbar.blade.php | 6 +- routes/web.php | 6 + 14 files changed, 437 insertions(+), 46 deletions(-) create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 database/migrations/2026_06_06_200000_create_notifications_table.php create mode 100644 resources/views/notifications/_list.blade.php create mode 100644 resources/views/notifications/index.blade.php create mode 100644 resources/views/partials/notification-dropdown.blade.php diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..d438a3a --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,64 @@ +user() + ->notifications() + ->latest() + ->paginate(20); + + return view('notifications.index', compact('notifications')); + } + + public function unread(Request $request): JsonResponse + { + $notifications = $request->user() + ->unreadNotifications() + ->latest() + ->take(10) + ->get() + ->map(fn ($n) => [ + 'id' => $n->id, + 'type' => class_basename($n->type), + 'title' => $n->data['title'] ?? 'Notification', + 'message' => $n->data['message'] ?? '', + 'icon' => $n->data['icon'] ?? 'bell', + 'url' => $n->data['url'] ?? null, + 'created_at' => $n->created_at->diffForHumans(), + ]); + + return response()->json([ + 'notifications' => $notifications, + 'unread_count' => $request->user()->unreadNotifications()->count(), + ]); + } + + public function markAsRead(Request $request, string $id): JsonResponse + { + $notification = $request->user() + ->notifications() + ->where('id', $id) + ->first(); + + if ($notification) { + $notification->markAsRead(); + } + + return response()->json(['success' => true]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['success' => true]); + } +} diff --git a/database/migrations/2026_06_06_200000_create_notifications_table.php b/database/migrations/2026_06_06_200000_create_notifications_table.php new file mode 100644 index 0000000..52e3b00 --- /dev/null +++ b/database/migrations/2026_06_06_200000_create_notifications_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/public/favicon.ico b/public/favicon.ico index 4823a2be5bc5e099d36cfeb99395c24829cd94f3..fdd9742e290cce685cf8c51d915ace6e82774df2 100644 GIT binary patch literal 8786 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 V-mt7(>~Ro4O<7B+@|k7W{{W*8k@Wxo delta 7252 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 iklS4C?boNG{r>?F9E6A9)EZO(0000 - - - - - - + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillhosting-logo.svg b/public/images/logo/ladillhosting-logo.svg index 1e5a897..6a66d01 100644 --- a/public/images/logo/ladillhosting-logo.svg +++ b/public/images/logo/ladillhosting-logo.svg @@ -1,5 +1,5 @@ - + + + - - - - - - + + + + + + - - - - - - + + + + - - - - - - - + + + + + + + \ No newline at end of file diff --git a/resources/js/app.js b/resources/js/app.js index afa6faf..c63c6c1 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -5,6 +5,91 @@ import collapse from '@alpinejs/collapse'; Alpine.plugin(collapse); +Alpine.data('notificationDropdown', (config = {}) => ({ + open: false, + loading: false, + notifications: [], + unreadCount: 0, + unreadUrl: config.unreadUrl || '/notifications/unread', + markReadUrl: config.markReadUrl || '/notifications/__ID__/read', + markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read', + indexUrl: config.indexUrl || '/notifications', + csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '', + + init() { + this.fetchUnread(); + setInterval(() => this.fetchUnread(), 60000); + }, + + async fetchUnread() { + try { + const res = await fetch(this.unreadUrl, { + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const data = await res.json(); + this.notifications = data.notifications || []; + this.unreadCount = data.unread_count || 0; + } catch (e) { + console.error('Failed to fetch notifications', e); + } + }, + + toggle() { + this.open = !this.open; + if (this.open) { + this.loading = true; + this.fetchUnread().finally(() => { this.loading = false; }); + } + }, + + async markRead(id) { + const url = this.markReadUrl.replace('__ID__', id); + try { + await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + this.notifications = this.notifications.filter(n => n.id !== id); + this.unreadCount = Math.max(0, this.unreadCount - 1); + } catch (e) { + console.error('Failed to mark notification as read', e); + } + }, + + async markAllRead() { + try { + await fetch(this.markAllReadUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + this.notifications = []; + this.unreadCount = 0; + } catch (e) { + console.error('Failed to mark all notifications as read', e); + } + }, + + getIconBg(icon) { + const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' }; + return map[icon] || 'bg-slate-100'; + }, + + getIconColor(icon) { + const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' }; + return map[icon] || 'text-slate-500'; + }, +})); + // Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button // which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade. Alpine.data('afia', (config = {}) => ({ diff --git a/resources/views/hosting/signed-out.blade.php b/resources/views/hosting/signed-out.blade.php index 1db1faf..ba3ceee 100644 --- a/resources/views/hosting/signed-out.blade.php +++ b/resources/views/hosting/signed-out.blade.php @@ -9,7 +9,7 @@
- Ladill Hosting + Ladill Hosting

You’ve been signed out. Redirecting…

Go to ladill.com
diff --git a/resources/views/layouts/email.blade.php b/resources/views/layouts/email.blade.php index 15d31e4..9ecf02b 100644 --- a/resources/views/layouts/email.blade.php +++ b/resources/views/layouts/email.blade.php @@ -40,7 +40,9 @@ 'homeActive' => request()->routeIs('email.dashboard'), 'searchUrl' => route('email.mailboxes.index'), 'searchActive' => request()->routeIs('email.mailboxes.*'), - 'notificationsUrl' => $navAcct.'/notifications', + 'notificationsUrl' => route('notifications.index'), + 'notificationsActive' => request()->routeIs('notifications.*'), + 'unreadUrl' => route('notifications.unread'), 'profileActive' => false, 'profileName' => $navUser?->name ?? '', 'profileSubtitle' => $navUser?->email ?? '', diff --git a/resources/views/layouts/hosting.blade.php b/resources/views/layouts/hosting.blade.php index 9853416..3de0095 100644 --- a/resources/views/layouts/hosting.blade.php +++ b/resources/views/layouts/hosting.blade.php @@ -39,7 +39,9 @@ 'homeActive' => request()->routeIs('hosting.dashboard') || request()->routeIs('hosting.index'), 'searchUrl' => route('hosting.dashboard'), 'searchActive' => request()->routeIs('hosting.*'), - 'notificationsUrl' => $navAcct.'/notifications', + 'notificationsUrl' => route('notifications.index'), + 'notificationsActive' => request()->routeIs('notifications.*'), + 'unreadUrl' => route('notifications.unread'), 'profileActive' => false, 'profileName' => $navUser?->name ?? '', 'profileSubtitle' => $navUser?->email ?? '', diff --git a/resources/views/notifications/_list.blade.php b/resources/views/notifications/_list.blade.php new file mode 100644 index 0000000..09c6ebb --- /dev/null +++ b/resources/views/notifications/_list.blade.php @@ -0,0 +1,93 @@ +
+
+
+

Notifications

+

{{ $subtitle ?? 'Stay updated on your account activity.' }}

+
+ @if ($notifications->where('read_at', null)->count() > 0) +
+ @csrf + +
+ @endif +
+ +
+ @forelse ($notifications as $notification) + @php + $data = $notification->data; + $icon = $data['icon'] ?? 'bell'; + $iconBg = match ($icon) { + 'domain' => 'bg-emerald-50', + 'hosting' => 'bg-violet-50', + 'email' => 'bg-pink-50', + 'billing' => 'bg-amber-50', + 'success' => 'bg-green-50', + default => 'bg-slate-100', + }; + $iconColor = match ($icon) { + 'domain' => 'text-emerald-600', + 'hosting' => 'text-violet-600', + 'email' => 'text-pink-600', + 'billing' => 'text-amber-600', + 'success' => 'text-green-600', + default => 'text-slate-500', + }; + @endphp +
+ + @if ($icon === 'domain' && view()->exists('components.icons.domain-globe')) + @include('components.icons.domain-globe', ['class' => 'h-5 w-5 ' . $iconColor]) + @elseif ($icon === 'email') + + @elseif ($icon === 'hosting') + + @elseif ($icon === 'billing') + + @elseif ($icon === 'success') + + @else + + @endif + +
+
+
+

{{ $data['title'] ?? 'Notification' }}

+

{{ $data['message'] ?? '' }}

+
+ {{ $notification->created_at->diffForHumans() }} +
+ @if (! empty($data['url'])) + + View details → + + @endif +
+ @if (! $notification->read_at) +
+ @csrf + +
+ @endif +
+ @empty +
+ + + +

No notifications

+

{{ $emptyMessage ?? "You're all caught up." }}

+
+ @endforelse +
+ + @if ($notifications->hasPages()) +
{{ $notifications->links() }}
+ @endif +
diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php new file mode 100644 index 0000000..9901476 --- /dev/null +++ b/resources/views/notifications/index.blade.php @@ -0,0 +1,10 @@ +@extends('layouts.hosting') + +@section('title', 'Notifications') + +@section('content') + @include('notifications._list', [ + 'subtitle' => 'Hosting activity for your account.', + 'emptyMessage' => "You're all caught up. We'll notify you when something happens with your hosting.", + ]) +@endsection diff --git a/resources/views/partials/notification-dropdown.blade.php b/resources/views/partials/notification-dropdown.blade.php new file mode 100644 index 0000000..0cde085 --- /dev/null +++ b/resources/views/partials/notification-dropdown.blade.php @@ -0,0 +1,108 @@ +{{-- In-app notification bell + dropdown (scoped to this app). --}} + diff --git a/resources/views/partials/topbar.blade.php b/resources/views/partials/topbar.blade.php index c50e487..c73cfe0 100644 --- a/resources/views/partials/topbar.blade.php +++ b/resources/views/partials/topbar.blade.php @@ -36,11 +36,7 @@
@auth - {{-- Notifications (desktop header) --}} - + @include('partials.notification-dropdown') diff --git a/routes/web.php b/routes/web.php index a9d6b65..8c7dca2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Hosting\HostingTerminalSessionController; use App\Http\Controllers\Hosting\OverviewController; use App\Http\Controllers\Hosting\SearchController; use App\Http\Controllers\Hosting\TeamController; +use App\Http\Controllers\NotificationController; use Illuminate\Support\Facades\Route; $serversApp = fn (string $path = ''): string => 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : ''); @@ -29,6 +30,11 @@ Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannel Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('hosting.dashboard') : view('hosting.signed-out'))->name('hosting.signed-out'); Route::middleware(['auth'])->group(function () use ($serversApp) { + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + 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/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read'); + Route::get('/search', SearchController::class)->name('hosting.search'); Route::get('/dashboard', [OverviewController::class, 'index'])->name('hosting.dashboard');