feat: Add notification services for Email, Telegram, and WhatsApp
- Implement EmailNotifier for sending batch and test notifications via email. - Implement TelegramNotifier for sending batch and test notifications via Telegram. - Implement WahaNotifier for sending batch and test notifications via WhatsApp using WAHA API. - Create migration for client_categories table and update clients table to include category_id. - Create migration for notification_settings table to manage notification configurations. - Add ClientCategorySeeder to populate default client categories. - Create manifest.json for PWA support. - Implement service worker (sw.js) for caching static assets and offline support. - Add edit event view for modifying scheduled events. - Create search results view for displaying search queries. - Develop notification settings view for configuring notification channels and triggers.
This commit is contained in:
@@ -2,14 +2,20 @@
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta name="theme-color" content="#FFB7C5">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ config('app.name') }}">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png">
|
||||
<title>{{ config('app.name') }} — @yield('title', 'Dashboard')</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="bg-cream font-sans antialiased" x-data="{ sidebarOpen: false }">
|
||||
<body class="bg-cream font-sans antialiased" x-data="appShell()">
|
||||
|
||||
{{-- ── Backdrop (mobile only) ─────────────────────────────────── --}}
|
||||
<div
|
||||
@@ -33,20 +39,16 @@
|
||||
transition-transform duration-300 ease-in-out
|
||||
lg:translate-x-0"
|
||||
>
|
||||
{{-- Logo / Close trigger --}}
|
||||
{{-- Logo --}}
|
||||
<div class="flex items-center justify-between gap-3 mb-8 px-2">
|
||||
<button
|
||||
@click="sidebarOpen = false"
|
||||
class="flex items-center gap-3 group focus:outline-none"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<a href="{{ route('dashboard') }}" @click="sidebarOpen = false"
|
||||
class="flex items-center gap-3 group focus:outline-none">
|
||||
<span class="text-3xl transition-transform duration-200 group-hover:scale-110 group-active:scale-95 select-none">🐱</span>
|
||||
<div class="text-left">
|
||||
<div class="text-lg font-extrabold text-pink-500 leading-tight">KOL Manager</div>
|
||||
<div class="text-xs text-gray-400">Your Purr-fect Assistant</div>
|
||||
</div>
|
||||
</button>
|
||||
{{-- X button — mobile only --}}
|
||||
</a>
|
||||
<button @click="sidebarOpen = false" class="lg:hidden text-gray-400 hover:text-pink-500 text-xl leading-none" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
@@ -65,9 +67,6 @@
|
||||
class="nav-item {{ request()->routeIs('schedule.*') ? 'active' : '' }}">
|
||||
<span class="text-lg shrink-0">📅</span>
|
||||
<span class="flex-1">Schedule</span>
|
||||
@if($alertCount > 0)
|
||||
<span class="badge-sakura shrink-0">{{ $alertCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<a href="{{ route('clients.index') }}"
|
||||
@@ -109,17 +108,15 @@
|
||||
class="nav-item {{ request()->routeIs('files.*') ? 'active' : '' }}">
|
||||
<span class="text-lg shrink-0">📁</span> Files
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{{-- Meow Toggle --}}
|
||||
<div class="mt-4 px-2 border-t border-pink-50 pt-4">
|
||||
<button onclick="window.KolManager.toggleMeow()"
|
||||
class="flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors w-full">
|
||||
<span class="text-base">🔔</span>
|
||||
<span>Meow Sounds</span>
|
||||
</button>
|
||||
<div class="text-xs text-gray-400 mt-1">Toggle notification sounds</div>
|
||||
</div>
|
||||
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mt-4 mb-1 px-2">Settings</div>
|
||||
|
||||
<a href="{{ route('settings.notifications') }}"
|
||||
@click="sidebarOpen = false"
|
||||
class="nav-item {{ request()->routeIs('settings.*') ? 'active' : '' }}">
|
||||
<span class="text-lg shrink-0">🔔</span> Notifications
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{{-- ── Main wrapper ─────────────────────────────────────────────── --}}
|
||||
@@ -127,33 +124,91 @@
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
{{-- ── Mobile / Desktop Topbar ─────────────────────────── --}}
|
||||
{{-- ── Topbar ───────────────────────────────────────────── --}}
|
||||
<header class="sticky top-0 z-20 bg-white border-b border-pink-100 px-4 lg:px-8 py-3 lg:py-4 flex items-center gap-3 shadow-sm">
|
||||
|
||||
{{-- Cat icon — hamburger for mobile --}}
|
||||
{{-- Hamburger (mobile) --}}
|
||||
<button
|
||||
@click="sidebarOpen = true"
|
||||
class="lg:hidden text-2xl transition-transform duration-150 active:scale-90 focus:outline-none select-none"
|
||||
aria-label="Open menu"
|
||||
>🐱</button>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
{{-- Page title (hidden when search open) --}}
|
||||
<div class="flex-1 min-w-0" x-show="!searchOpen" x-transition>
|
||||
<h1 class="text-base lg:text-xl font-bold text-gray-800 truncate">@yield('page-title', 'Dashboard')</h1>
|
||||
<p class="text-xs lg:text-sm text-gray-400 hidden sm:block">@yield('page-subtitle', today()->format('l, d F Y'))</p>
|
||||
</div>
|
||||
|
||||
{{-- ── Global Search bar ─────────────────────────── --}}
|
||||
<div class="flex-1 min-w-0" x-show="searchOpen" x-transition style="display:none">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none">🔍</span>
|
||||
<input
|
||||
id="global-search-input"
|
||||
type="text"
|
||||
placeholder="Search clients, events, invoices…"
|
||||
class="input-soft pl-9 pr-4 py-2 text-sm w-full"
|
||||
x-model="searchQuery"
|
||||
@input.debounce.300ms="doSearch()"
|
||||
@keydown.escape="closeSearch()"
|
||||
x-ref="searchInput"
|
||||
>
|
||||
{{-- Search results dropdown --}}
|
||||
<div
|
||||
x-show="searchResults.length > 0 || (searchQuery.length >= 2 && !searchLoading)"
|
||||
class="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl shadow-xl border border-pink-100 z-50 max-h-80 overflow-y-auto"
|
||||
@click.outside="searchResults = []"
|
||||
>
|
||||
<template x-if="searchLoading">
|
||||
<div class="px-4 py-3 text-sm text-gray-400 flex items-center gap-2">
|
||||
<span class="animate-spin">🐾</span> Searching…
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!searchLoading && searchResults.length === 0 && searchQuery.length >= 2">
|
||||
<div class="px-4 py-3 text-sm text-gray-400">No results for "<span x-text="searchQuery"></span>"</div>
|
||||
</template>
|
||||
<template x-for="group in searchResults" :key="group.type">
|
||||
<div>
|
||||
<div class="px-4 py-1.5 text-xs font-bold text-gray-400 uppercase tracking-wide bg-pink-50/60 sticky top-0"
|
||||
x-text="group.label"></div>
|
||||
<template x-for="item in group.items" :key="item.url">
|
||||
<a :href="item.url"
|
||||
class="flex items-center gap-3 px-4 py-2.5 hover:bg-pink-50 transition-colors"
|
||||
@click="closeSearch()">
|
||||
<span class="text-lg shrink-0" x-text="item.icon"></span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-700 truncate" x-text="item.title"></div>
|
||||
<div class="text-xs text-gray-400 truncate" x-text="item.subtitle"></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="border-t border-pink-50 px-4 py-2">
|
||||
<a :href="'/search?q=' + encodeURIComponent(searchQuery)"
|
||||
class="text-xs text-pink-500 hover:text-pink-700 font-medium flex items-center gap-1"
|
||||
@click="closeSearch()">
|
||||
🔍 See all results for "<span x-text="searchQuery"></span>"
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right actions --}}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
@if($alertCount > 0)
|
||||
<a href="{{ route('schedule.index') }}"
|
||||
class="hidden sm:flex items-center gap-1.5 bg-pink-50 text-pink-600 text-xs px-3 py-1.5 rounded-full border border-pink-200">
|
||||
<span>🔔</span>
|
||||
<span class="font-semibold">{{ $alertCount }} alert{{ $alertCount > 1 ? 's' : '' }}</span>
|
||||
</a>
|
||||
{{-- Mobile: just the badge --}}
|
||||
<a href="{{ route('schedule.index') }}" class="sm:hidden badge-sakura flex items-center gap-1">
|
||||
🔔 {{ $alertCount }}
|
||||
</a>
|
||||
@endif
|
||||
{{-- Search toggle --}}
|
||||
<button
|
||||
@click="toggleSearch()"
|
||||
class="w-8 h-8 lg:w-9 lg:h-9 rounded-full flex items-center justify-center hover:bg-pink-50 transition-colors text-gray-500 hover:text-pink-500"
|
||||
:title="searchOpen ? 'Close search' : 'Search'"
|
||||
aria-label="Toggle search"
|
||||
>
|
||||
<span x-text="searchOpen ? '✕' : '🔍'" class="text-sm lg:text-base"></span>
|
||||
</button>
|
||||
|
||||
{{-- Avatar --}}
|
||||
<div class="w-8 h-8 lg:w-9 lg:h-9 rounded-full flex items-center justify-center text-white font-bold text-xs lg:text-sm shadow-sm"
|
||||
style="background: linear-gradient(135deg, #FFB7C5, #E8D5F5);">
|
||||
KOL
|
||||
@@ -185,7 +240,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@stack('scripts')
|
||||
|
||||
<script>
|
||||
function appShell() {
|
||||
return {
|
||||
sidebarOpen: false,
|
||||
searchOpen: false,
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
searchLoading: false,
|
||||
_searchTimer: null,
|
||||
|
||||
toggleSearch() {
|
||||
this.searchOpen = !this.searchOpen;
|
||||
if (this.searchOpen) {
|
||||
this.$nextTick(() => this.$refs.searchInput?.focus());
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
}
|
||||
},
|
||||
|
||||
closeSearch() {
|
||||
this.searchOpen = false;
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
},
|
||||
|
||||
async doSearch() {
|
||||
if (this.searchQuery.length < 2) { this.searchResults = []; return; }
|
||||
this.searchLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/search/quick?q=${encodeURIComponent(this.searchQuery)}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
this.searchResults = await res.json();
|
||||
} catch { this.searchResults = []; }
|
||||
this.searchLoading = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user