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:
@@ -12,7 +12,8 @@
|
||||
"Bash(PHP_BIN=\"/c/laragon/bin/php/php-8.3.16-Win32-vs16-x64/php.exe\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" __NEW_LINE_7c463fa4c32b98a2__ \"$PHP_BIN\" artisan route:list)",
|
||||
"Bash(NODE_BIN=\"/c/laragon/bin/nodejs/node-v22\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" \"$NODE_BIN/node.exe\" \"$NODE_BIN/node_modules/npm/bin/npm-cli.js\" run build)",
|
||||
"Bash(/c/laragon/bin/nodejs/node-v22/node node_modules/npm/bin/npm-cli.js run build)",
|
||||
"Bash(/c/laragon/bin/nodejs/node-v22/node /c/laragon/bin/nodejs/node-v22/node_modules/npm/bin/npm-cli.js run build)"
|
||||
"Bash(/c/laragon/bin/nodejs/node-v22/node /c/laragon/bin/nodejs/node-v22/node_modules/npm/bin/npm-cli.js run build)",
|
||||
"Bash(php artisan:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
71
app/Http/Controllers/ClientCategoryController.php
Normal file
71
app/Http/Controllers/ClientCategoryController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ClientCategory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ClientCategoryController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// Managed inline from the clients page; redirect there
|
||||
return redirect()->route('clients.index');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'parent_id' => 'nullable|exists:client_categories,id',
|
||||
'icon' => 'nullable|string|max:10',
|
||||
'color' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
// Auto slug
|
||||
$validated['slug'] = \Illuminate\Support\Str::slug($validated['name']);
|
||||
|
||||
// If slug conflicts, suffix with random
|
||||
if (ClientCategory::where('slug', $validated['slug'])->exists()) {
|
||||
$validated['slug'] .= '-' . substr(uniqid(), -4);
|
||||
}
|
||||
|
||||
ClientCategory::create($validated);
|
||||
|
||||
return back()->with('success', '🏷️ Category added!');
|
||||
}
|
||||
|
||||
public function update(Request $request, ClientCategory $category): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'parent_id' => 'nullable|exists:client_categories,id',
|
||||
'icon' => 'nullable|string|max:10',
|
||||
'color' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
// Prevent a category from being its own parent
|
||||
if (isset($validated['parent_id']) && $validated['parent_id'] == $category->id) {
|
||||
$validated['parent_id'] = null;
|
||||
}
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
return back()->with('success', '🏷️ Category updated!');
|
||||
}
|
||||
|
||||
public function destroy(ClientCategory $category): RedirectResponse
|
||||
{
|
||||
// Reassign children to the parent
|
||||
$category->children()->update(['parent_id' => $category->parent_id]);
|
||||
// Detach clients
|
||||
$category->clients()->update(['category_id' => null]);
|
||||
|
||||
$category->delete();
|
||||
|
||||
return back()->with('success', '🏷️ Category removed.');
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,61 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\ClientStatus;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCategory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
private function categoryTree(): \Illuminate\Support\Collection
|
||||
{
|
||||
$clients = Client::withCount(['invoices', 'events', 'contentBriefs'])
|
||||
->latest()
|
||||
->paginate(12);
|
||||
return ClientCategory::with('children')
|
||||
->whereNull('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('clients.index', compact('clients'));
|
||||
private function flatCategories(): \Illuminate\Support\Collection
|
||||
{
|
||||
$flat = collect();
|
||||
foreach ($this->categoryTree() as $parent) {
|
||||
$flat->push($parent);
|
||||
foreach ($parent->children as $child) {
|
||||
$flat->push($child);
|
||||
}
|
||||
}
|
||||
return $flat;
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Client::with('category')
|
||||
->withCount(['invoices', 'events', 'contentBriefs'])
|
||||
->latest();
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->where('category_id', $request->category);
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$clients = $query->paginate(12)->withQueryString();
|
||||
$categories = $this->categoryTree();
|
||||
$statuses = ClientStatus::cases();
|
||||
|
||||
return view('clients.index', compact('clients', 'categories', 'statuses'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$statuses = ClientStatus::cases();
|
||||
return view('clients.create', compact('statuses'));
|
||||
$categories = $this->flatCategories();
|
||||
$categoryTree = $this->categoryTree();
|
||||
|
||||
return view('clients.create', compact('statuses', 'categories', 'categoryTree'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
@@ -44,6 +80,7 @@ class ClientController extends Controller
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'status' => 'required|in:active,inactive,prospect',
|
||||
'category_id' => 'nullable|exists:client_categories,id',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
@@ -55,10 +92,13 @@ class ClientController extends Controller
|
||||
|
||||
public function show(Client $client): View
|
||||
{
|
||||
$client->load(['events' => fn ($q) => $q->latest()->limit(5)]);
|
||||
$client->load(['invoices' => fn ($q) => $q->latest()->limit(5)]);
|
||||
$client->load(['contentBriefs' => fn ($q) => $q->latest()->limit(5)]);
|
||||
$client->load(['mediaFiles' => fn ($q) => $q->latest()->limit(10)]);
|
||||
$client->load([
|
||||
'category',
|
||||
'events' => fn ($q) => $q->latest()->limit(5),
|
||||
'invoices' => fn ($q) => $q->latest()->limit(5),
|
||||
'contentBriefs'=> fn ($q) => $q->latest()->limit(5),
|
||||
'mediaFiles' => fn ($q) => $q->latest()->limit(10),
|
||||
]);
|
||||
|
||||
return view('clients.show', compact('client'));
|
||||
}
|
||||
@@ -66,7 +106,10 @@ class ClientController extends Controller
|
||||
public function edit(Client $client): View
|
||||
{
|
||||
$statuses = ClientStatus::cases();
|
||||
return view('clients.edit', compact('client', 'statuses'));
|
||||
$categories = $this->flatCategories();
|
||||
$categoryTree = $this->categoryTree();
|
||||
|
||||
return view('clients.edit', compact('client', 'statuses', 'categories', 'categoryTree'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Client $client): RedirectResponse
|
||||
@@ -86,6 +129,7 @@ class ClientController extends Controller
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'status' => 'required|in:active,inactive,prospect',
|
||||
'category_id' => 'nullable|exists:client_categories,id',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ class EventController extends Controller
|
||||
->with('success', '🐾 Event scheduled!');
|
||||
}
|
||||
|
||||
public function edit(Event $event): View
|
||||
{
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = EventStatus::cases();
|
||||
|
||||
return view('schedule.edit', compact('event', 'clients', 'statuses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
|
||||
97
app/Http/Controllers/NotificationSettingController.php
Normal file
97
app/Http/Controllers/NotificationSettingController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Services\NotificationDispatcher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationSettingController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$settings = NotificationSetting::orderBy('channel')->orderBy('name')->get();
|
||||
|
||||
return view('settings.notifications', compact('settings'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'channel' => 'required|in:email,telegram,whatsapp',
|
||||
'triggers' => 'nullable|array',
|
||||
'triggers.*' => 'in:upcoming_event,invoice_due,content_due',
|
||||
'notify_before_hours' => 'nullable|integer|min:1|max:720',
|
||||
// Email
|
||||
'config.to_email' => 'required_if:channel,email|nullable|email',
|
||||
// Telegram
|
||||
'config.bot_token' => 'required_if:channel,telegram|nullable|string',
|
||||
'config.chat_id' => 'required_if:channel,telegram|nullable|string',
|
||||
// WhatsApp (WAHA)
|
||||
'config.waha_url' => 'required_if:channel,whatsapp|nullable|url',
|
||||
'config.waha_session' => 'required_if:channel,whatsapp|nullable|string',
|
||||
'config.phone' => 'required_if:channel,whatsapp|nullable|string',
|
||||
]);
|
||||
|
||||
NotificationSetting::create([
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'config' => $validated['config'] ?? [],
|
||||
'triggers' => $validated['triggers'] ?? [],
|
||||
'notify_before_hours' => $validated['notify_before_hours'] ?? 24,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
return back()->with('success', '🔔 Notification channel added!');
|
||||
}
|
||||
|
||||
public function update(Request $request, NotificationSetting $setting): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'triggers' => 'nullable|array',
|
||||
'triggers.*' => 'in:upcoming_event,invoice_due,content_due',
|
||||
'notify_before_hours' => 'nullable|integer|min:1|max:720',
|
||||
'config' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$setting->update([
|
||||
'name' => $validated['name'],
|
||||
'triggers' => $validated['triggers'] ?? [],
|
||||
'notify_before_hours' => $validated['notify_before_hours'] ?? $setting->notify_before_hours,
|
||||
'config' => $validated['config'] ?? $setting->config,
|
||||
]);
|
||||
|
||||
return back()->with('success', '🔔 Notification updated!');
|
||||
}
|
||||
|
||||
public function destroy(NotificationSetting $setting): RedirectResponse
|
||||
{
|
||||
$setting->delete();
|
||||
|
||||
return back()->with('success', '🔔 Channel removed.');
|
||||
}
|
||||
|
||||
public function toggle(NotificationSetting $setting): RedirectResponse
|
||||
{
|
||||
$setting->update(['is_enabled' => ! $setting->is_enabled]);
|
||||
|
||||
return back()->with('success', $setting->is_enabled ? '🔔 Enabled!' : '🔕 Disabled.');
|
||||
}
|
||||
|
||||
public function test(NotificationSetting $setting): RedirectResponse
|
||||
{
|
||||
try {
|
||||
app(NotificationDispatcher::class)->sendTest($setting);
|
||||
return back()->with('success', '✅ Test notification sent!');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', '❌ Failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/SearchController.php
Normal file
143
app/Http/Controllers/SearchController.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ContentBrief;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\RateCard;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$q = trim($request->get('q', ''));
|
||||
$results = $q ? $this->search($q) : [];
|
||||
|
||||
return view('search.index', compact('q', 'results'));
|
||||
}
|
||||
|
||||
/** Quick AJAX endpoint — returns grouped results for the dropdown */
|
||||
public function quick(Request $request): JsonResponse
|
||||
{
|
||||
$q = trim($request->get('q', ''));
|
||||
|
||||
if (strlen($q) < 2) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
return response()->json($this->search($q, limit: 5));
|
||||
}
|
||||
|
||||
private function search(string $q, int $limit = 20): array
|
||||
{
|
||||
$groups = [];
|
||||
$like = "%{$q}%";
|
||||
|
||||
// ── Clients ──────────────────────────────────────────────
|
||||
$clients = Client::where('name', 'LIKE', $like)
|
||||
->orWhere('email', 'LIKE', $like)
|
||||
->orWhere('company', 'LIKE', $like)
|
||||
->orWhere('ig_handle', 'LIKE', $like)
|
||||
->orWhere('tiktok_handle', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($clients->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'clients',
|
||||
'label' => '👥 Clients',
|
||||
'items' => $clients->map(fn ($c) => [
|
||||
'icon' => '👥',
|
||||
'title' => $c->name,
|
||||
'subtitle' => collect([$c->company, $c->email])->filter()->implode(' · '),
|
||||
'url' => route('clients.show', $c),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────
|
||||
$events = Event::with('client')
|
||||
->where('title', 'LIKE', $like)
|
||||
->orWhere('description', 'LIKE', $like)
|
||||
->orWhere('location', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($events->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'events',
|
||||
'label' => '📅 Events',
|
||||
'items' => $events->map(fn ($e) => [
|
||||
'icon' => '📅',
|
||||
'title' => $e->title,
|
||||
'subtitle' => $e->start_time->format('d M Y') . ($e->client ? ' · ' . $e->client->name : ''),
|
||||
'url' => route('schedule.index'),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Invoices ─────────────────────────────────────────────
|
||||
$invoices = Invoice::with('client')
|
||||
->where('invoice_number', 'LIKE', $like)
|
||||
->orWhere('title', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($invoices->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'invoices',
|
||||
'label' => '🧾 Invoices',
|
||||
'items' => $invoices->map(fn ($i) => [
|
||||
'icon' => '🧾',
|
||||
'title' => $i->invoice_number . ($i->title ? ' — ' . $i->title : ''),
|
||||
'subtitle' => ($i->client?->name ?? '') . ' · ' . ($i->formatted_total ?? ''),
|
||||
'url' => route('invoices.show', $i),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Content Briefs ───────────────────────────────────────
|
||||
$content = ContentBrief::with('client')
|
||||
->where('title', 'LIKE', $like)
|
||||
->orWhere('description', 'LIKE', $like)
|
||||
->orWhere('platform', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($content->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'content',
|
||||
'label' => '🎬 Content',
|
||||
'items' => $content->map(fn ($c) => [
|
||||
'icon' => $c->platform_icon ?? '🎬',
|
||||
'title' => $c->title,
|
||||
'subtitle' => ucfirst($c->platform) . ($c->client ? ' · ' . $c->client->name : ''),
|
||||
'url' => route('content.show', $c),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Rate Cards ───────────────────────────────────────────
|
||||
$rateCards = RateCard::where('name', 'LIKE', $like)
|
||||
->orWhere('description', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($rateCards->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'rate-cards',
|
||||
'label' => '💰 Rate Cards',
|
||||
'items' => $rateCards->map(fn ($r) => [
|
||||
'icon' => '💰',
|
||||
'title' => $r->name,
|
||||
'subtitle' => $r->description ?? '',
|
||||
'url' => route('rate-cards.edit', $r),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Enums\ClientStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -18,13 +19,20 @@ class Client extends Model
|
||||
'name', 'email', 'phone', 'company',
|
||||
'ig_handle', 'tiktok_handle', 'youtube_handle',
|
||||
'address_line1', 'address_line2', 'city', 'state', 'postal_code', 'country',
|
||||
'status', 'notes', 'avatar',
|
||||
'status', 'category_id', 'notes', 'avatar',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'status' => ClientStatus::class,
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
@@ -45,6 +53,8 @@ class Client extends Model
|
||||
return $this->hasMany(MediaFile::class);
|
||||
}
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────
|
||||
|
||||
public function getFullAddressAttribute(): string
|
||||
{
|
||||
return collect([
|
||||
|
||||
74
app/Models/ClientCategory.php
Normal file
74
app/Models/ClientCategory.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ClientCategory extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'slug', 'parent_id', 'icon', 'color',
|
||||
'description', 'is_system', 'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_system' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientCategory::class, 'parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function clients(): HasMany
|
||||
{
|
||||
return $this->hasMany(Client::class, 'category_id');
|
||||
}
|
||||
|
||||
// ── Scopes ───────────────────────────────────────────────────
|
||||
|
||||
public function scopeRoots($query)
|
||||
{
|
||||
return $query->whereNull('parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function scopeTopLevel($query)
|
||||
{
|
||||
return $query->whereNull('parent_id');
|
||||
}
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────
|
||||
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return $this->parent
|
||||
? $this->parent->name . ' › ' . $this->name
|
||||
: $this->name;
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (ClientCategory $cat): void {
|
||||
if (empty($cat->slug)) {
|
||||
$cat->slug = Str::slug($cat->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
53
app/Models/NotificationSetting.php
Normal file
53
app/Models/NotificationSetting.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'channel', 'config', 'triggers',
|
||||
'is_enabled', 'notify_before_hours',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => 'array',
|
||||
'triggers' => 'array',
|
||||
'is_enabled' => 'boolean',
|
||||
'notify_before_hours' => 'integer',
|
||||
];
|
||||
|
||||
// ── Channel icons/labels ─────────────────────────────────────
|
||||
|
||||
public static array $channelMeta = [
|
||||
'email' => ['icon' => '✉️', 'label' => 'Email', 'color' => '#AEC6CF'],
|
||||
'telegram' => ['icon' => '✈️', 'label' => 'Telegram', 'color' => '#E8D5F5'],
|
||||
'whatsapp' => ['icon' => '💬', 'label' => 'WhatsApp', 'color' => '#B5EAD7'],
|
||||
];
|
||||
|
||||
public function getChannelIconAttribute(): string
|
||||
{
|
||||
return static::$channelMeta[$this->channel]['icon'] ?? '🔔';
|
||||
}
|
||||
|
||||
public function getChannelLabelAttribute(): string
|
||||
{
|
||||
return static::$channelMeta[$this->channel]['label'] ?? ucfirst($this->channel);
|
||||
}
|
||||
|
||||
// ── Trigger helpers ──────────────────────────────────────────
|
||||
|
||||
public static array $triggerMeta = [
|
||||
'upcoming_event' => ['icon' => '📅', 'label' => 'Upcoming Events'],
|
||||
'invoice_due' => ['icon' => '🧾', 'label' => 'Invoice Due Date'],
|
||||
'content_due' => ['icon' => '🎬', 'label' => 'Content Target Date'],
|
||||
];
|
||||
|
||||
public function hasTrigger(string $trigger): bool
|
||||
{
|
||||
return in_array($trigger, $this->triggers ?? []);
|
||||
}
|
||||
}
|
||||
113
app/Services/NotificationDispatcher.php
Normal file
113
app/Services/NotificationDispatcher.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ContentBrief;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Services\Notifications\EmailNotifier;
|
||||
use App\Services\Notifications\TelegramNotifier;
|
||||
use App\Services\Notifications\WahaNotifier;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotificationDispatcher
|
||||
{
|
||||
/** Dispatch all due notifications for enabled channels */
|
||||
public function dispatchAll(): void
|
||||
{
|
||||
$settings = NotificationSetting::where('is_enabled', true)->get();
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
try {
|
||||
$this->dispatchForSetting($setting);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Notification dispatch failed for [{$setting->name}]: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function dispatchForSetting(NotificationSetting $setting): void
|
||||
{
|
||||
$triggers = $setting->triggers ?? [];
|
||||
$hours = $setting->notify_before_hours;
|
||||
|
||||
$messages = collect();
|
||||
|
||||
if (in_array('upcoming_event', $triggers)) {
|
||||
$events = Event::with('client')
|
||||
->where('start_time', '>=', now())
|
||||
->where('start_time', '<=', now()->addHours($hours))
|
||||
->whereNotIn('status', ['completed', 'cancelled'])
|
||||
->get();
|
||||
|
||||
foreach ($events as $event) {
|
||||
$messages->push(
|
||||
"📅 *Upcoming Event*\n"
|
||||
. "*{$event->title}*\n"
|
||||
. "⏰ " . $event->start_time->format('d M Y, H:i') . "\n"
|
||||
. ($event->client ? "👤 {$event->client->name}\n" : '')
|
||||
. ($event->location ? "📍 {$event->location}" : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('invoice_due', $triggers)) {
|
||||
$invoices = Invoice::with('client')
|
||||
->whereBetween('due_date', [now(), now()->addHours($hours)])
|
||||
->whereNotIn('status', ['paid', 'cancelled'])
|
||||
->get();
|
||||
|
||||
foreach ($invoices as $inv) {
|
||||
$messages->push(
|
||||
"🧾 *Invoice Due Soon*\n"
|
||||
. "*{$inv->invoice_number}*"
|
||||
. ($inv->title ? " — {$inv->title}" : '') . "\n"
|
||||
. "📅 Due: " . $inv->due_date->format('d M Y') . "\n"
|
||||
. ($inv->client ? "👤 {$inv->client->name}\n" : '')
|
||||
. ($inv->formatted_total ?? '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('content_due', $triggers)) {
|
||||
$content = ContentBrief::with('client')
|
||||
->whereBetween('target_date', [now(), now()->addHours($hours)])
|
||||
->whereNotIn('status', ['posted', 'archived'])
|
||||
->get();
|
||||
|
||||
foreach ($content as $c) {
|
||||
$messages->push(
|
||||
"🎬 *Content Target*\n"
|
||||
. "*{$c->title}*\n"
|
||||
. "📅 " . $c->target_date->format('d M Y') . "\n"
|
||||
. ucfirst($c->platform)
|
||||
. ($c->client ? " · {$c->client->name}" : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($messages->isEmpty()) return;
|
||||
|
||||
$notifier = $this->resolveNotifier($setting);
|
||||
$notifier->sendBatch($setting, $messages->all());
|
||||
}
|
||||
|
||||
/** Send a simple test message */
|
||||
public function sendTest(NotificationSetting $setting): void
|
||||
{
|
||||
$this->resolveNotifier($setting)->sendTest($setting);
|
||||
}
|
||||
|
||||
private function resolveNotifier(NotificationSetting $setting): EmailNotifier|TelegramNotifier|WahaNotifier
|
||||
{
|
||||
return match ($setting->channel) {
|
||||
'email' => new EmailNotifier,
|
||||
'telegram' => new TelegramNotifier,
|
||||
'whatsapp' => new WahaNotifier,
|
||||
default => throw new \InvalidArgumentException("Unknown channel: {$setting->channel}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
43
app/Services/Notifications/EmailNotifier.php
Normal file
43
app/Services/Notifications/EmailNotifier.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class EmailNotifier
|
||||
{
|
||||
public function sendBatch(NotificationSetting $setting, array $messages): void
|
||||
{
|
||||
$toEmail = $setting->config['to_email'] ?? null;
|
||||
|
||||
if (! $toEmail) {
|
||||
throw new \RuntimeException('Email to_email address is required.');
|
||||
}
|
||||
|
||||
$combined = implode("\n\n---\n\n", $messages);
|
||||
|
||||
Mail::raw($combined, function ($mail) use ($toEmail, $setting): void {
|
||||
$mail->to($toEmail)
|
||||
->subject("🐱 KOL Manager — {$setting->name} Notifications");
|
||||
});
|
||||
}
|
||||
|
||||
public function sendTest(NotificationSetting $setting): void
|
||||
{
|
||||
$toEmail = $setting->config['to_email'] ?? null;
|
||||
|
||||
if (! $toEmail) {
|
||||
throw new \RuntimeException('Email to_email address is required.');
|
||||
}
|
||||
|
||||
Mail::raw(
|
||||
"🐱 KOL Manager — Test Notification\n\nYour Email channel is configured correctly! 🎉",
|
||||
function ($mail) use ($toEmail): void {
|
||||
$mail->to($toEmail)->subject('🐱 KOL Manager — Test Notification');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/Services/Notifications/TelegramNotifier.php
Normal file
50
app/Services/Notifications/TelegramNotifier.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class TelegramNotifier
|
||||
{
|
||||
public function sendBatch(NotificationSetting $setting, array $messages): void
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
$this->send($setting, $message);
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTest(NotificationSetting $setting): void
|
||||
{
|
||||
$this->send(
|
||||
$setting,
|
||||
"🐱 *KOL Manager — Test Notification*\n\nYour Telegram channel is configured correctly! 🎉"
|
||||
);
|
||||
}
|
||||
|
||||
private function send(NotificationSetting $setting, string $text): void
|
||||
{
|
||||
$config = $setting->config ?? [];
|
||||
$botToken = $config['bot_token'] ?? null;
|
||||
$chatId = $config['chat_id'] ?? null;
|
||||
|
||||
if (! $botToken || ! $chatId) {
|
||||
throw new \RuntimeException('Telegram bot_token and chat_id are required.');
|
||||
}
|
||||
|
||||
$response = Http::timeout(10)->post(
|
||||
"https://api.telegram.org/bot{$botToken}/sendMessage",
|
||||
[
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'Markdown',
|
||||
]
|
||||
);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('Telegram API error: ' . $response->body());
|
||||
}
|
||||
}
|
||||
}
|
||||
55
app/Services/Notifications/WahaNotifier.php
Normal file
55
app/Services/Notifications/WahaNotifier.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* WAHA (WhatsApp HTTP API) — self-hosted WhatsApp bridge.
|
||||
* Docs: https://waha.devlike.pro/docs/how-to/send-messages/
|
||||
*/
|
||||
class WahaNotifier
|
||||
{
|
||||
public function sendBatch(NotificationSetting $setting, array $messages): void
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
$this->send($setting, $message);
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTest(NotificationSetting $setting): void
|
||||
{
|
||||
$this->send(
|
||||
$setting,
|
||||
"🐱 *KOL Manager — Test Notification*\n\nYour WhatsApp channel is configured correctly! 🎉"
|
||||
);
|
||||
}
|
||||
|
||||
private function send(NotificationSetting $setting, string $text): void
|
||||
{
|
||||
$config = $setting->config ?? [];
|
||||
$url = rtrim($config['waha_url'] ?? '', '/');
|
||||
$session = $config['waha_session'] ?? 'default';
|
||||
$phone = $config['phone'] ?? null;
|
||||
|
||||
if (! $url || ! $phone) {
|
||||
throw new \RuntimeException('WAHA waha_url and phone are required.');
|
||||
}
|
||||
|
||||
// Normalise phone: strip non-digits, ensure no leading +
|
||||
$chatId = preg_replace('/[^0-9]/', '', $phone) . '@c.us';
|
||||
|
||||
$response = Http::timeout(15)->post("{$url}/api/sendText", [
|
||||
'session' => $session,
|
||||
'chatId' => $chatId,
|
||||
'text' => $text,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('WAHA API error: ' . $response->body());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_categories', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->unsignedBigInteger('parent_id')->nullable();
|
||||
$table->string('icon')->default('🏷️');
|
||||
$table->string('color')->default('#FFB7C5');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('parent_id')
|
||||
->references('id')
|
||||
->on('client_categories')
|
||||
->onDelete('set null');
|
||||
});
|
||||
|
||||
// Add category_id to clients table
|
||||
Schema::table('clients', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('category_id')->nullable()->after('status');
|
||||
$table->foreign('category_id')
|
||||
->references('id')
|
||||
->on('client_categories')
|
||||
->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('clients', function (Blueprint $table): void {
|
||||
$table->dropForeign(['category_id']);
|
||||
$table->dropColumn('category_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('client_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notification_settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name'); // e.g. "My Telegram Bot"
|
||||
$table->string('channel'); // email | telegram | whatsapp
|
||||
$table->json('config'); // channel-specific credentials
|
||||
$table->json('triggers')->nullable(); // array: upcoming_event, invoice_due, content_due
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->integer('notify_before_hours')->default(24); // hours before event to notify
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notification_settings');
|
||||
}
|
||||
};
|
||||
89
database/seeders/ClientCategorySeeder.php
Normal file
89
database/seeders/ClientCategorySeeder.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\ClientCategory;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ClientCategorySeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
['name' => 'Food & Beverages', 'icon' => '🍔', 'color' => '#FFD5B8', 'children' => [
|
||||
['name' => 'Food', 'icon' => '🍱', 'color' => '#FFD5B8'],
|
||||
['name' => 'Beverages', 'icon' => '🥤', 'color' => '#FFD5B8'],
|
||||
['name' => 'Restaurant & Café', 'icon' => '☕', 'color' => '#FFD5B8'],
|
||||
['name' => 'Snacks & Treats', 'icon' => '🍪', 'color' => '#FFD5B8'],
|
||||
]],
|
||||
['name' => 'Fashion', 'icon' => '👗', 'color' => '#FFB7C5', 'children' => [
|
||||
['name' => 'Clothing', 'icon' => '👕', 'color' => '#FFB7C5'],
|
||||
['name' => 'Accessories', 'icon' => '💍', 'color' => '#FFB7C5'],
|
||||
['name' => 'Shoes & Footwear', 'icon' => '👠', 'color' => '#FFB7C5'],
|
||||
['name' => 'Bags & Luggage', 'icon' => '👜', 'color' => '#FFB7C5'],
|
||||
]],
|
||||
['name' => 'Beauty & Skincare', 'icon' => '💄', 'color' => '#E8D5F5', 'children' => [
|
||||
['name' => 'Skincare', 'icon' => '🧴', 'color' => '#E8D5F5'],
|
||||
['name' => 'Makeup', 'icon' => '💋', 'color' => '#E8D5F5'],
|
||||
['name' => 'Hair Care', 'icon' => '💇', 'color' => '#E8D5F5'],
|
||||
['name' => 'Fragrance', 'icon' => '🌸', 'color' => '#E8D5F5'],
|
||||
]],
|
||||
['name' => 'Technology', 'icon' => '💻', 'color' => '#AEC6CF', 'children' => [
|
||||
['name' => 'Consumer Electronics', 'icon' => '📱', 'color' => '#AEC6CF'],
|
||||
['name' => 'Apps & Software', 'icon' => '🖥️', 'color' => '#AEC6CF'],
|
||||
['name' => 'Gaming', 'icon' => '🎮', 'color' => '#AEC6CF'],
|
||||
['name' => 'Smart Home', 'icon' => '🏠', 'color' => '#AEC6CF'],
|
||||
]],
|
||||
['name' => 'Health & Wellness', 'icon' => '💪', 'color' => '#B5EAD7', 'children' => [
|
||||
['name' => 'Fitness', 'icon' => '🏋️', 'color' => '#B5EAD7'],
|
||||
['name' => 'Supplements', 'icon' => '💊', 'color' => '#B5EAD7'],
|
||||
['name' => 'Mental Health', 'icon' => '🧘', 'color' => '#B5EAD7'],
|
||||
]],
|
||||
['name' => 'Lifestyle', 'icon' => '🌿', 'color' => '#FDFD96', 'children' => [
|
||||
['name' => 'Travel', 'icon' => '✈️', 'color' => '#FDFD96'],
|
||||
['name' => 'Home & Living', 'icon' => '🛋️', 'color' => '#FDFD96'],
|
||||
['name' => 'Pets', 'icon' => '🐾', 'color' => '#FDFD96'],
|
||||
['name' => 'Parenting', 'icon' => '👶', 'color' => '#FDFD96'],
|
||||
]],
|
||||
['name' => 'Entertainment', 'icon' => '🎬', 'color' => '#AEC6CF', 'children' => [
|
||||
['name' => 'Movies & TV', 'icon' => '🎥', 'color' => '#AEC6CF'],
|
||||
['name' => 'Music', 'icon' => '🎵', 'color' => '#AEC6CF'],
|
||||
['name' => 'Sports', 'icon' => '⚽', 'color' => '#AEC6CF'],
|
||||
['name' => 'Gaming & Esports', 'icon' => '🕹️', 'color' => '#AEC6CF'],
|
||||
]],
|
||||
['name' => 'Finance', 'icon' => '💰', 'color' => '#FDFD96', 'children' => [
|
||||
['name' => 'Banking', 'icon' => '🏦', 'color' => '#FDFD96'],
|
||||
['name' => 'Insurance', 'icon' => '🛡️', 'color' => '#FDFD96'],
|
||||
['name' => 'Investment', 'icon' => '📈', 'color' => '#FDFD96'],
|
||||
]],
|
||||
['name' => 'Education', 'icon' => '📚', 'color' => '#B5EAD7', 'children' => [
|
||||
['name' => 'Online Courses', 'icon' => '🎓', 'color' => '#B5EAD7'],
|
||||
['name' => 'Kids Education', 'icon' => '🧒', 'color' => '#B5EAD7'],
|
||||
]],
|
||||
['name' => 'Other', 'icon' => '🏷️', 'color' => '#E5E7EB', 'children' => []],
|
||||
];
|
||||
|
||||
foreach ($categories as $i => $data) {
|
||||
$parent = ClientCategory::create([
|
||||
'name' => $data['name'],
|
||||
'icon' => $data['icon'],
|
||||
'color' => $data['color'],
|
||||
'is_system' => true,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
|
||||
foreach ($data['children'] as $j => $child) {
|
||||
ClientCategory::create([
|
||||
'name' => $child['name'],
|
||||
'icon' => $child['icon'],
|
||||
'color' => $child['color'],
|
||||
'parent_id' => $parent->id,
|
||||
'is_system' => true,
|
||||
'sort_order' => $j,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,9 @@ class DatabaseSeeder extends Seeder
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$this->call([
|
||||
ClientCategorySeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
26
public/manifest.json
Normal file
26
public/manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "KOL Manager",
|
||||
"short_name": "KOL",
|
||||
"description": "Your Purr-fect Influencer Management Assistant",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#FFF8F0",
|
||||
"theme_color": "#FFB7C5",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["business", "productivity"],
|
||||
"screenshots": []
|
||||
}
|
||||
61
public/sw.js
Normal file
61
public/sw.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// KOL Manager Service Worker
|
||||
// Provides basic offline support and caches static assets
|
||||
|
||||
const CACHE_NAME = 'kol-manager-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// Install — pre-cache shell
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate — clean up old caches
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch — network-first for HTML/API, cache-first for static assets
|
||||
self.addEventListener('fetch', event => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET and cross-origin requests
|
||||
if (request.method !== 'GET' || url.origin !== location.origin) return;
|
||||
|
||||
// Cache-first for Vite build assets (hashed filenames)
|
||||
if (url.pathname.startsWith('/build/')) {
|
||||
event.respondWith(
|
||||
caches.match(request).then(cached => cached || fetch(request).then(response => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
|
||||
return response;
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for everything else (app routes)
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then(response => {
|
||||
// Cache successful HTML responses
|
||||
if (response.ok && request.headers.get('Accept')?.includes('text/html')) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
});
|
||||
@@ -6,39 +6,26 @@ Alpine.plugin(sort);
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
|
||||
// Meow sound toggle (stored in localStorage)
|
||||
// ── PWA Service Worker ────────────────────────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {/* silently fail */});
|
||||
});
|
||||
}
|
||||
|
||||
// ── KolManager helpers (toast notifications only — no meow) ──
|
||||
window.KolManager = {
|
||||
meowEnabled: localStorage.getItem('meow') !== 'false',
|
||||
|
||||
toggleMeow() {
|
||||
this.meowEnabled = !this.meowEnabled;
|
||||
localStorage.setItem('meow', this.meowEnabled);
|
||||
},
|
||||
|
||||
playMeow() {
|
||||
if (!this.meowEnabled) return;
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.connect(g);
|
||||
g.connect(ctx.destination);
|
||||
o.type = 'sine';
|
||||
o.frequency.setValueAtTime(880, ctx.currentTime);
|
||||
o.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.05);
|
||||
o.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.15);
|
||||
g.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
|
||||
o.start(ctx.currentTime);
|
||||
o.stop(ctx.currentTime + 0.3);
|
||||
},
|
||||
|
||||
notify(msg) {
|
||||
this.playMeow();
|
||||
const el = document.createElement('div');
|
||||
el.className = 'fixed top-4 right-4 z-50 cat-card px-5 py-3 flex items-center gap-2 shadow-lg border border-pink-200 animate-bounce';
|
||||
el.className = 'fixed top-4 right-4 z-50 cat-card px-5 py-3 flex items-center gap-2 shadow-lg border border-pink-200';
|
||||
el.style.cssText = 'animation: slideIn .3s ease';
|
||||
el.innerHTML = `<span class="text-xl">🐱</span><span class="text-sm font-medium text-pink-700">${msg}</span>`;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 3500);
|
||||
setTimeout(() => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transition = 'opacity .3s';
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 3200);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,7 +33,6 @@ window.KolManager = {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const flash = document.getElementById('flash-message');
|
||||
if (flash) {
|
||||
window.KolManager.playMeow();
|
||||
setTimeout(() => {
|
||||
flash.style.opacity = '0';
|
||||
flash.style.transform = 'translateY(-10px)';
|
||||
|
||||
@@ -42,6 +42,22 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">🏷️ Category</label>
|
||||
<select name="category_id" class="input-soft">
|
||||
<option value="">— No Category —</option>
|
||||
@foreach($categoryTree as $parent)
|
||||
<option value="{{ $parent->id }}" {{ old('category_id') == $parent->id ? 'selected' : '' }}>
|
||||
{{ $parent->icon }} {{ $parent->name }}
|
||||
</option>
|
||||
@foreach($parent->children as $child)
|
||||
<option value="{{ $child->id }}" {{ old('category_id') == $child->id ? 'selected' : '' }}>
|
||||
↳ {{ $child->icon }} {{ $child->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,22 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">🏷️ Category</label>
|
||||
<select name="category_id" class="input-soft">
|
||||
<option value="">— No Category —</option>
|
||||
@foreach($categoryTree as $parent)
|
||||
<option value="{{ $parent->id }}" {{ old('category_id', $client->category_id) == $parent->id ? 'selected' : '' }}>
|
||||
{{ $parent->icon }} {{ $parent->name }}
|
||||
</option>
|
||||
@foreach($parent->children as $child)
|
||||
<option value="{{ $child->id }}" {{ old('category_id', $client->category_id) == $child->id ? 'selected' : '' }}>
|
||||
↳ {{ $child->icon }} {{ $child->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -5,33 +5,102 @@
|
||||
@section('page-subtitle', 'Manage your brand partners and contacts')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
|
||||
<div class="relative">
|
||||
<input type="text" placeholder="Search clients..." class="input-soft pl-9 text-sm"
|
||||
oninput="filterClients(this.value)" id="search-input">
|
||||
|
||||
{{-- ── Filters bar ─────────────────────────────────────────── --}}
|
||||
<div class="flex flex-col gap-3 mb-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
{{-- Local text filter --}}
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<span class="absolute left-3 top-2.5 text-gray-400 text-sm">🔍</span>
|
||||
<input type="text" placeholder="Filter by name…" class="input-soft pl-9 text-sm"
|
||||
oninput="filterClients(this.value)" id="search-input">
|
||||
</div>
|
||||
|
||||
{{-- Category filter --}}
|
||||
<form method="GET" action="{{ route('clients.index') }}" class="flex flex-wrap gap-2 items-center" id="filter-form">
|
||||
<select name="category" class="input-soft text-sm py-2"
|
||||
onchange="document.getElementById('filter-form').submit()">
|
||||
<option value="">All Categories</option>
|
||||
@foreach($categories as $parent)
|
||||
<option value="{{ $parent->id }}"
|
||||
{{ request('category') == $parent->id ? 'selected' : '' }}>
|
||||
{{ $parent->icon }} {{ $parent->name }}
|
||||
</option>
|
||||
@foreach($parent->children as $child)
|
||||
<option value="{{ $child->id }}"
|
||||
{{ request('category') == $child->id ? 'selected' : '' }}>
|
||||
↳ {{ $child->icon }} {{ $child->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
<select name="status" class="input-soft text-sm py-2"
|
||||
onchange="document.getElementById('filter-form').submit()">
|
||||
<option value="">All Statuses</option>
|
||||
@foreach($statuses as $s)
|
||||
<option value="{{ $s->value }}" {{ request('status') === $s->value ? 'selected' : '' }}>
|
||||
{{ $s->label() }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
@if(request('category') || request('status'))
|
||||
<a href="{{ route('clients.index') }}" class="text-xs text-gray-400 hover:text-gray-600">✕ Clear</a>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<a href="{{ route('clients.create') }}" class="btn-sakura text-sm text-center shrink-0">+ Add Client</a>
|
||||
</div>
|
||||
|
||||
{{-- Category pills (top-level only) --}}
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4 lg:mx-0 lg:px-0">
|
||||
<a href="{{ route('clients.index') }}"
|
||||
class="shrink-0 text-xs px-3 py-1.5 rounded-full font-semibold transition-colors
|
||||
{{ ! request('category') ? 'bg-pink-400 text-white' : 'bg-white border border-pink-200 text-gray-600 hover:bg-pink-50' }}">
|
||||
All
|
||||
</a>
|
||||
@foreach($categories as $cat)
|
||||
<a href="{{ route('clients.index', ['category' => $cat->id, 'status' => request('status')]) }}"
|
||||
class="shrink-0 text-xs px-3 py-1.5 rounded-full font-semibold transition-colors whitespace-nowrap
|
||||
{{ request('category') == $cat->id ? 'bg-pink-400 text-white' : 'bg-white border border-pink-200 text-gray-600 hover:bg-pink-50' }}"
|
||||
style="{{ request('category') == $cat->id ? '' : 'border-color:' . $cat->color }}">
|
||||
{{ $cat->icon }} {{ $cat->name }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
<a href="{{ route('clients.create') }}" class="btn-sakura text-sm text-center">+ Add Client</a>
|
||||
</div>
|
||||
|
||||
{{-- ── Client grid ─────────────────────────────────────────── --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4" id="clients-grid">
|
||||
@forelse($clients as $client)
|
||||
<div class="cat-card p-4 flex flex-col gap-3 hover:shadow-md transition-shadow duration-200"
|
||||
data-name="{{ strtolower($client->name) }}">
|
||||
data-name="{{ strtolower($client->name) }} {{ strtolower($client->company ?? '') }}">
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-11 h-11 rounded-2xl flex items-center justify-center text-white font-bold text-base shadow-sm shrink-0"
|
||||
style="background: linear-gradient(135deg, #FFB7C5, #E8D5F5);">
|
||||
style="background: linear-gradient(135deg, {{ $client->category?->color ?? '#FFB7C5' }}, #E8D5F5);">
|
||||
{{ $client->initials }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="font-bold text-gray-800 text-sm truncate">{{ $client->name }}</h3>
|
||||
<span class="badge {{ $client->status->badgeClass() }} text-xs shrink-0">{{ $client->status->label() }}</span>
|
||||
<span class="badge {{ $client->status->badgeClass() }} text-xs shrink-0">
|
||||
{{ $client->status->label() }}
|
||||
</span>
|
||||
</div>
|
||||
@if($client->company)
|
||||
<div class="text-xs text-gray-400 truncate">{{ $client->company }}</div>
|
||||
@endif
|
||||
@if($client->category)
|
||||
<div class="text-xs mt-0.5" style="color: {{ $client->category->color === '#FDFD96' ? '#8B8000' : $client->category->color }}">
|
||||
{{ $client->category->icon }} {{ $client->category->name }}
|
||||
@if($client->category->parent)
|
||||
<span class="text-gray-300 mx-0.5">·</span>
|
||||
{{ $client->category->parent->name }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,18 +131,28 @@
|
||||
|
||||
<div class="flex gap-2 pt-2 border-t border-pink-50">
|
||||
<a href="{{ route('clients.show', $client) }}" class="btn-sakura text-xs py-1.5 flex-1 text-center">View</a>
|
||||
<a href="{{ route('clients.edit', $client) }}" class="flex-1 text-center text-xs py-1.5 px-3 rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50">Edit</a>
|
||||
<form action="{{ route('clients.destroy', $client) }}" method="POST" onsubmit="return confirm('Remove this client?')">
|
||||
<a href="{{ route('clients.edit', $client) }}"
|
||||
class="flex-1 text-center text-xs py-1.5 px-3 rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50">
|
||||
Edit
|
||||
</a>
|
||||
<form action="{{ route('clients.destroy', $client) }}" method="POST"
|
||||
onsubmit="return confirm('Remove this client?')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="text-xs py-1.5 px-3 rounded-full border border-red-100 text-red-400 hover:bg-red-50">Del</button>
|
||||
<button class="text-xs py-1.5 px-3 rounded-full border border-red-100 text-red-400 hover:bg-red-50">
|
||||
Del
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full text-center py-16 text-gray-400">
|
||||
<span class="text-5xl block mb-3">🐱</span>
|
||||
<p class="text-base font-medium">No clients yet!</p>
|
||||
<p class="text-base font-medium">No clients found!</p>
|
||||
@if(request('category') || request('status'))
|
||||
<a href="{{ route('clients.index') }}" class="text-sm text-pink-500 mt-2 block">Clear filters</a>
|
||||
@else
|
||||
<a href="{{ route('clients.create') }}" class="btn-sakura mt-4 inline-block">Add your first client</a>
|
||||
@endif
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
175
resources/views/schedule/edit.blade.php
Normal file
175
resources/views/schedule/edit.blade.php
Normal file
@@ -0,0 +1,175 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Edit Event')
|
||||
@section('page-title', 'Edit Event 📅')
|
||||
@section('page-subtitle', $event->title)
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="cat-card p-4 lg:p-8">
|
||||
<form action="{{ route('schedule.update', $event) }}" method="POST" id="edit-event-form"
|
||||
class="space-y-5">
|
||||
@csrf @method('PUT')
|
||||
|
||||
{{-- Hidden combined datetime fields --}}
|
||||
<input type="hidden" name="start_time" id="start_time_hidden">
|
||||
<input type="hidden" name="end_time" id="end_time_hidden">
|
||||
|
||||
{{-- Title --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">
|
||||
Title <span class="text-pink-400">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" value="{{ old('title', $event->title) }}"
|
||||
class="input-soft" placeholder="Event title" required>
|
||||
@error('title') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{{-- Type --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Type</label>
|
||||
<select name="event_type" class="input-soft">
|
||||
@foreach(['task' => '📋 Task', 'meeting' => '🤝 Meeting', 'deadline' => '⏰ Deadline', 'posting' => '📸 Posting'] as $val => $label)
|
||||
<option value="{{ $val }}" {{ old('event_type', $event->event_type) === $val ? 'selected' : '' }}>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Status --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Status</label>
|
||||
<select name="status" class="input-soft">
|
||||
@foreach($statuses as $s)
|
||||
<option value="{{ $s->value }}"
|
||||
{{ old('status', $event->status->value) === $s->value ? 'selected' : '' }}>
|
||||
{{ $s->label() }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Client --}}
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
|
||||
<select name="client_id" class="input-soft">
|
||||
<option value="">— None —</option>
|
||||
@foreach($clients as $client)
|
||||
<option value="{{ $client->id }}"
|
||||
{{ old('client_id', $event->client_id) == $client->id ? 'selected' : '' }}>
|
||||
{{ $client->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Start Date --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">
|
||||
Start Date <span class="text-pink-400">*</span>
|
||||
</label>
|
||||
<input type="date" id="start_date" class="input-soft"
|
||||
value="{{ old('_start_date', $event->start_time->format('Y-m-d')) }}" required>
|
||||
</div>
|
||||
|
||||
{{-- Start Time --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Start Time</label>
|
||||
<input type="time" id="start_time_input" class="input-soft"
|
||||
value="{{ old('_start_time', $event->is_all_day ? '00:00' : $event->start_time->format('H:i')) }}">
|
||||
</div>
|
||||
|
||||
{{-- End Date --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">End Date</label>
|
||||
<input type="date" id="end_date" class="input-soft"
|
||||
value="{{ old('_end_date', $event->end_time?->format('Y-m-d') ?? '') }}">
|
||||
</div>
|
||||
|
||||
{{-- End Time --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">End Time</label>
|
||||
<input type="time" id="end_time_input" class="input-soft"
|
||||
value="{{ old('_end_time', $event->end_time?->format('H:i') ?? '18:00') }}">
|
||||
</div>
|
||||
|
||||
{{-- Location --}}
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Location</label>
|
||||
<input type="text" name="location" value="{{ old('location', $event->location) }}"
|
||||
class="input-soft" placeholder="Online / venue address">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Description --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Description</label>
|
||||
<textarea name="description" rows="2" class="input-soft resize-none"
|
||||
placeholder="Brief description…">{{ old('description', $event->description) }}</textarea>
|
||||
</div>
|
||||
|
||||
{{-- Color --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">Color</label>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
@foreach(['#FFB7C5','#B5EAD7','#FDFD96','#E8D5F5','#FFD5B8','#AEC6CF'] as $hex)
|
||||
<label class="cursor-pointer">
|
||||
<input type="radio" name="color" value="{{ $hex }}" class="sr-only peer"
|
||||
{{ old('color', $event->color) === $hex ? 'checked' : '' }}>
|
||||
<span class="w-8 h-8 rounded-full block ring-2 ring-transparent peer-checked:ring-gray-500 peer-checked:ring-offset-1 transition-all"
|
||||
style="background:{{ $hex }};"></span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Priority checkbox --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="is_priority" value="1" id="priority" class="w-4 h-4 accent-pink-400"
|
||||
{{ old('is_priority', $event->is_priority) ? 'checked' : '' }}>
|
||||
<label for="priority" class="text-sm text-gray-600 cursor-pointer">⭐ Mark as Priority</label>
|
||||
</div>
|
||||
|
||||
{{-- Notes --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes</label>
|
||||
<textarea name="notes" rows="3" class="input-soft resize-none"
|
||||
placeholder="Additional notes…">{{ old('notes', $event->notes) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-1">
|
||||
<button type="submit" class="btn-sakura flex-1 py-3">💾 Save Changes</button>
|
||||
<a href="{{ route('schedule.index') }}"
|
||||
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.getElementById('edit-event-form').addEventListener('submit', function (e) {
|
||||
const startDate = document.getElementById('start_date').value;
|
||||
const startTime = document.getElementById('start_time_input').value || '00:00';
|
||||
const endDate = document.getElementById('end_date').value;
|
||||
const endTime = document.getElementById('end_time_input').value || '00:00';
|
||||
|
||||
if (!startDate) {
|
||||
e.preventDefault();
|
||||
alert('Please select a start date.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('start_time_hidden').value = startDate + 'T' + startTime;
|
||||
|
||||
if (endDate) {
|
||||
document.getElementById('end_time_hidden').value = endDate + 'T' + endTime;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
62
resources/views/search/index.blade.php
Normal file
62
resources/views/search/index.blade.php
Normal file
@@ -0,0 +1,62 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Search')
|
||||
@section('page-title', '🔍 Search Results')
|
||||
@section('page-subtitle', $q ? "Results for \"" . e($q) . "\"" : 'Enter a search term')
|
||||
|
||||
@section('content')
|
||||
|
||||
<form method="GET" action="{{ route('search.index') }}" class="mb-6">
|
||||
<div class="relative max-w-xl">
|
||||
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ e($q) }}"
|
||||
placeholder="Search clients, events, invoices, content…"
|
||||
class="input-soft pl-11 py-3 text-base w-full"
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if(! $q)
|
||||
<div class="text-center py-20 text-gray-400">
|
||||
<span class="text-5xl block mb-3">🔍</span>
|
||||
<p class="text-base font-medium">Start typing to search across everything</p>
|
||||
<p class="text-sm mt-1">Clients, events, invoices, content briefs, rate cards</p>
|
||||
</div>
|
||||
@elseif(empty($results))
|
||||
<div class="text-center py-20 text-gray-400">
|
||||
<span class="text-5xl block mb-3">😿</span>
|
||||
<p class="text-base font-medium">No results for "{{ e($q) }}"</p>
|
||||
<p class="text-sm mt-1">Try a different keyword</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-6">
|
||||
@foreach($results as $group)
|
||||
<div class="cat-card overflow-hidden">
|
||||
<div class="px-5 py-3 bg-pink-50/60 border-b border-pink-100">
|
||||
<h2 class="text-sm font-bold text-gray-600">{{ $group['label'] }}</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-pink-50">
|
||||
@foreach($group['items'] as $item)
|
||||
<a href="{{ $item['url'] }}"
|
||||
class="flex items-center gap-4 px-5 py-3 hover:bg-pink-50/40 transition-colors">
|
||||
<span class="text-2xl shrink-0">{{ $item['icon'] }}</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-gray-800 text-sm truncate">{{ $item['title'] }}</div>
|
||||
@if($item['subtitle'])
|
||||
<div class="text-xs text-gray-400 truncate mt-0.5">{{ $item['subtitle'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<span class="ml-auto text-gray-300 text-sm shrink-0">→</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
263
resources/views/settings/notifications.blade.php
Normal file
263
resources/views/settings/notifications.blade.php
Normal file
@@ -0,0 +1,263 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Notification Settings')
|
||||
@section('page-title', '🔔 Notifications')
|
||||
@section('page-subtitle', 'Configure Email, Telegram & WhatsApp alerts')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
|
||||
{{-- ── How it works ─────────────────────────────────────── --}}
|
||||
<div class="cat-card p-4 flex gap-3 items-start bg-blue-50/50 border-blue-100">
|
||||
<span class="text-2xl shrink-0">💡</span>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<p class="font-semibold text-gray-700">How notifications work</p>
|
||||
<p>Add one or more channels below. Notifications fire automatically every hour via the scheduler.</p>
|
||||
<p class="text-xs text-gray-400">To enable the scheduler on your server: add <code class="bg-white px-1 rounded text-xs">* * * * * php /path/to/artisan schedule:run</code> to crontab.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Existing channels ────────────────────────────────── --}}
|
||||
@if($settings->isNotEmpty())
|
||||
<div class="space-y-3">
|
||||
@foreach($settings as $setting)
|
||||
<div class="cat-card p-4" x-data="{ editing: false }">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl shrink-0">{{ $setting->channelIcon }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-semibold text-gray-800 text-sm">{{ $setting->name }}</span>
|
||||
<span class="badge badge-ghost text-xs">{{ $setting->channelLabel }}</span>
|
||||
@if(! $setting->is_enabled)
|
||||
<span class="badge badge-error text-xs">Disabled</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5 flex flex-wrap gap-2">
|
||||
@foreach($setting->triggers ?? [] as $trigger)
|
||||
<span>{{ \App\Models\NotificationSetting::$triggerMeta[$trigger]['icon'] ?? '🔔' }}
|
||||
{{ \App\Models\NotificationSetting::$triggerMeta[$trigger]['label'] ?? $trigger }}</span>
|
||||
@endforeach
|
||||
<span>⏰ {{ $setting->notify_before_hours }}h before</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0 flex-wrap justify-end">
|
||||
{{-- Test --}}
|
||||
<form action="{{ route('settings.notifications.test', $setting) }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit" class="text-xs px-3 py-1.5 rounded-full border border-blue-200 text-blue-500 hover:bg-blue-50">
|
||||
Test
|
||||
</button>
|
||||
</form>
|
||||
{{-- Toggle --}}
|
||||
<form action="{{ route('settings.notifications.toggle', $setting) }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="text-xs px-3 py-1.5 rounded-full border {{ $setting->is_enabled ? 'border-amber-200 text-amber-500 hover:bg-amber-50' : 'border-green-200 text-green-500 hover:bg-green-50' }}">
|
||||
{{ $setting->is_enabled ? 'Disable' : 'Enable' }}
|
||||
</button>
|
||||
</form>
|
||||
{{-- Edit --}}
|
||||
<button @click="editing = !editing"
|
||||
class="text-xs px-3 py-1.5 rounded-full border border-pink-200 text-pink-500 hover:bg-pink-50">
|
||||
Edit
|
||||
</button>
|
||||
{{-- Delete --}}
|
||||
<form action="{{ route('settings.notifications.destroy', $setting) }}" method="POST"
|
||||
onsubmit="return confirm('Remove this channel?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="text-xs px-3 py-1.5 rounded-full border border-red-100 text-red-400 hover:bg-red-50">
|
||||
Del
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Inline edit form --}}
|
||||
<div x-show="editing" x-transition class="mt-4 pt-4 border-t border-pink-50">
|
||||
<form action="{{ route('settings.notifications.update', $setting) }}" method="POST"
|
||||
class="space-y-3">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Name</label>
|
||||
<input type="text" name="name" value="{{ $setting->name }}"
|
||||
class="input-soft text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Notify before (hours)</label>
|
||||
<input type="number" name="notify_before_hours"
|
||||
value="{{ $setting->notify_before_hours }}"
|
||||
min="1" max="720" class="input-soft text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Triggers --}}
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-2">Triggers</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@foreach(\App\Models\NotificationSetting::$triggerMeta as $key => $meta)
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-sm">
|
||||
<input type="checkbox" name="triggers[]" value="{{ $key }}"
|
||||
class="accent-pink-400"
|
||||
{{ in_array($key, $setting->triggers ?? []) ? 'checked' : '' }}>
|
||||
{{ $meta['icon'] }} {{ $meta['label'] }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Channel-specific config --}}
|
||||
@if($setting->channel === 'email')
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">To Email</label>
|
||||
<input type="email" name="config[to_email]"
|
||||
value="{{ $setting->config['to_email'] ?? '' }}"
|
||||
class="input-soft text-sm" placeholder="you@example.com">
|
||||
</div>
|
||||
@elseif($setting->channel === 'telegram')
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Bot Token</label>
|
||||
<input type="text" name="config[bot_token]"
|
||||
value="{{ $setting->config['bot_token'] ?? '' }}"
|
||||
class="input-soft text-sm" placeholder="123456:ABC-DEF...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Chat ID</label>
|
||||
<input type="text" name="config[chat_id]"
|
||||
value="{{ $setting->config['chat_id'] ?? '' }}"
|
||||
class="input-soft text-sm" placeholder="-100123456789">
|
||||
</div>
|
||||
</div>
|
||||
@elseif($setting->channel === 'whatsapp')
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">WAHA URL</label>
|
||||
<input type="url" name="config[waha_url]"
|
||||
value="{{ $setting->config['waha_url'] ?? '' }}"
|
||||
class="input-soft text-sm" placeholder="http://localhost:3000">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Session Name</label>
|
||||
<input type="text" name="config[waha_session]"
|
||||
value="{{ $setting->config['waha_session'] ?? 'default' }}"
|
||||
class="input-soft text-sm" placeholder="default">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Phone Number</label>
|
||||
<input type="text" name="config[phone]"
|
||||
value="{{ $setting->config['phone'] ?? '' }}"
|
||||
class="input-soft text-sm" placeholder="628123456789">
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="btn-sakura text-sm px-6 py-2">💾 Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="cat-card p-8 text-center text-gray-400">
|
||||
<span class="text-4xl block mb-2">🔕</span>
|
||||
<p class="text-sm font-medium">No notification channels yet</p>
|
||||
<p class="text-xs mt-1">Add one below to get started</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── Add new channel ─────────────────────────────────── --}}
|
||||
<div class="cat-card p-5" x-data="{ channel: 'email' }">
|
||||
<h2 class="font-bold text-gray-700 mb-4 flex items-center gap-2">
|
||||
<span>➕</span> Add Notification Channel
|
||||
</h2>
|
||||
|
||||
<form action="{{ route('settings.notifications.store') }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Name <span class="text-pink-400">*</span></label>
|
||||
<input type="text" name="name" class="input-soft text-sm"
|
||||
placeholder="My Telegram Bot" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Channel <span class="text-pink-400">*</span></label>
|
||||
<select name="channel" class="input-soft text-sm" x-model="channel">
|
||||
<option value="email">✉️ Email</option>
|
||||
<option value="telegram">✈️ Telegram</option>
|
||||
<option value="whatsapp">💬 WhatsApp (WAHA)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Notify before (hours)</label>
|
||||
<input type="number" name="notify_before_hours" value="24"
|
||||
min="1" max="720" class="input-soft text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Triggers --}}
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-2">Send when…</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
@foreach(\App\Models\NotificationSetting::$triggerMeta as $key => $meta)
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-sm">
|
||||
<input type="checkbox" name="triggers[]" value="{{ $key }}"
|
||||
class="accent-pink-400" checked>
|
||||
{{ $meta['icon'] }} {{ $meta['label'] }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Email --}}
|
||||
<div x-show="channel === 'email'" x-transition>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">To Email <span class="text-pink-400">*</span></label>
|
||||
<input type="email" name="config[to_email]" class="input-soft text-sm"
|
||||
placeholder="you@example.com">
|
||||
</div>
|
||||
|
||||
{{-- Telegram --}}
|
||||
<div x-show="channel === 'telegram'" x-transition class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Bot Token <span class="text-pink-400">*</span></label>
|
||||
<input type="text" name="config[bot_token]" class="input-soft text-sm"
|
||||
placeholder="123456:ABC-DEF...">
|
||||
<p class="text-xs text-gray-400 mt-1">Get from <a href="https://t.me/BotFather" target="_blank" class="text-pink-500">@BotFather</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Chat ID <span class="text-pink-400">*</span></label>
|
||||
<input type="text" name="config[chat_id]" class="input-soft text-sm"
|
||||
placeholder="-100123456789 or @username">
|
||||
<p class="text-xs text-gray-400 mt-1">Use <a href="https://t.me/userinfobot" target="_blank" class="text-pink-500">@userinfobot</a> to find your ID</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- WhatsApp / WAHA --}}
|
||||
<div x-show="channel === 'whatsapp'" x-transition class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">WAHA API URL <span class="text-pink-400">*</span></label>
|
||||
<input type="url" name="config[waha_url]" class="input-soft text-sm"
|
||||
placeholder="http://localhost:3000">
|
||||
<p class="text-xs text-gray-400 mt-1">Your self-hosted WAHA instance</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Session Name</label>
|
||||
<input type="text" name="config[waha_session]" value="default" class="input-soft text-sm">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="text-xs font-semibold text-gray-600 block mb-1">Phone Number <span class="text-pink-400">*</span></label>
|
||||
<input type="text" name="config[phone]" class="input-soft text-sm"
|
||||
placeholder="628123456789 (country code, no +)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-sakura">🔔 Add Channel</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,8 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Services\NotificationDispatcher;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Artisan::command('notifications:dispatch', function () {
|
||||
/** @var NotificationDispatcher $dispatcher */
|
||||
$dispatcher = app(NotificationDispatcher::class);
|
||||
$dispatcher->dispatchAll();
|
||||
$this->info('🔔 Notifications dispatched.');
|
||||
})->purpose('Send all pending KOL Manager notifications');
|
||||
|
||||
// Run every hour automatically
|
||||
Schedule::command('notifications:dispatch')->hourly();
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\ClientCategoryController;
|
||||
use App\Http\Controllers\ClientController;
|
||||
use App\Http\Controllers\ContentBriefController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\EventController;
|
||||
use App\Http\Controllers\InvoiceController;
|
||||
use App\Http\Controllers\MediaFileController;
|
||||
use App\Http\Controllers\NotificationSettingController;
|
||||
use App\Http\Controllers\RateCardController;
|
||||
use App\Http\Controllers\SearchController;
|
||||
use App\Http\Controllers\SocialHubController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -17,10 +20,19 @@ Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||
// CRM – Clients
|
||||
Route::resource('clients', ClientController::class);
|
||||
|
||||
// Client Categories
|
||||
Route::prefix('client-categories')->name('client-categories.')->group(function (): void {
|
||||
Route::get('/', [ClientCategoryController::class, 'index'])->name('index');
|
||||
Route::post('/', [ClientCategoryController::class, 'store'])->name('store');
|
||||
Route::put('/{category}', [ClientCategoryController::class, 'update'])->name('update');
|
||||
Route::delete('/{category}', [ClientCategoryController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Schedule & Calendar
|
||||
Route::prefix('schedule')->name('schedule.')->group(function (): void {
|
||||
Route::get('/', [EventController::class, 'index'])->name('index');
|
||||
Route::post('/', [EventController::class, 'store'])->name('store');
|
||||
Route::get('/{event}/edit', [EventController::class, 'edit'])->name('edit');
|
||||
Route::put('/{event}', [EventController::class, 'update'])->name('update');
|
||||
Route::patch('/{event}/dates', [EventController::class, 'updateDates'])->name('update-dates');
|
||||
Route::delete('/{event}', [EventController::class, 'destroy'])->name('destroy');
|
||||
@@ -60,3 +72,19 @@ Route::prefix('files')->name('files.')->group(function (): void {
|
||||
Route::get('/{mediaFile}/serve', [MediaFileController::class, 'serve'])->name('serve');
|
||||
Route::delete('/{mediaFile}', [MediaFileController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Global Search
|
||||
Route::prefix('search')->name('search.')->group(function (): void {
|
||||
Route::get('/', [SearchController::class, 'index'])->name('index');
|
||||
Route::get('/quick', [SearchController::class, 'quick'])->name('quick');
|
||||
});
|
||||
|
||||
// Settings
|
||||
Route::prefix('settings')->name('settings.')->group(function (): void {
|
||||
Route::get('/notifications', [NotificationSettingController::class, 'index'])->name('notifications');
|
||||
Route::post('/notifications', [NotificationSettingController::class, 'store'])->name('notifications.store');
|
||||
Route::put('/notifications/{setting}', [NotificationSettingController::class, 'update'])->name('notifications.update');
|
||||
Route::delete('/notifications/{setting}', [NotificationSettingController::class, 'destroy'])->name('notifications.destroy');
|
||||
Route::post('/notifications/{setting}/test', [NotificationSettingController::class, 'test'])->name('notifications.test');
|
||||
Route::post('/notifications/{setting}/toggle', [NotificationSettingController::class, 'toggle'])->name('notifications.toggle');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user