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 4823a2b..fdd9742 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/images/launcher-icons/hosting.svg b/public/images/launcher-icons/hosting.svg index d61ee90..ccd24dd 100644 --- a/public/images/launcher-icons/hosting.svg +++ b/public/images/launcher-icons/hosting.svg @@ -11,30 +11,30 @@ } .cls-2 { - fill: #5b95cb; + fill: #30b37c; } .cls-3 { - fill: #b573e3; - } - - .cls-4 { fill: #5c9d9e; } + .cls-4 { + fill: #b573e3; + } + .cls-5 { - fill: #00b6b2; + fill: #5b95cb; } .cls-6 { - fill: #30b37c; + fill: #00b6b2; } - - - - - - + + + + + + \ 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');