diff --git a/app/Livewire/Modals/CreateLink.php b/app/Livewire/Modals/CreateLink.php new file mode 100644 index 0000000..9196b6c --- /dev/null +++ b/app/Livewire/Modals/CreateLink.php @@ -0,0 +1,53 @@ + 'required|url|max:2048', + 'slug' => 'nullable|alpha_dash|max:50|unique:links,slug', + 'title' => 'nullable|max:200', + ]; + + public function generateSlug(): void + { + if (empty($this->slug)) { + $this->slug = Str::random(6); + } + } + + public function save(): void + { + $this->validate(); + $workspace = app('current_workspace'); + + if (empty($this->slug)) { + $this->slug = Str::random(6); + } + + Link::create([ + 'workspace_id' => $workspace->id, + 'target_url' => $this->targetUrl, + 'slug' => $this->slug, + 'title' => $this->title ?: null, + 'status' => 'active', + ]); + + $this->dispatch('linkCreated'); + $this->dispatch('close-modal'); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.modals.create-link'); + } +} diff --git a/app/Livewire/Pages/Analytics/Index.php b/app/Livewire/Pages/Analytics/Index.php index 5c42367..c2f0399 100644 --- a/app/Livewire/Pages/Analytics/Index.php +++ b/app/Livewire/Pages/Analytics/Index.php @@ -39,8 +39,25 @@ class Index extends Component /** @var Workspace $workspace */ $workspace = app('current_workspace'); - return view('livewire.pages.analytics.index', [ - 'workspace' => $workspace, - ])->layout('layouts.nimuli-app', ['title' => 'Analytics']); + $totalClicks = Click::where('workspace_id', $workspace->id)->count(); + $last30 = Click::where('workspace_id', $workspace->id) + ->where('clicked_at', '>=', now()->subDays(30))->count(); + + $byCountry = Click::where('workspace_id', $workspace->id) + ->whereNotNull('country') + ->selectRaw('country, COUNT(*) as total') + ->groupBy('country') + ->orderByDesc('total') + ->limit(5) + ->get(); + + $byDevice = Click::where('workspace_id', $workspace->id) + ->selectRaw('device, COUNT(*) as total') + ->groupBy('device') + ->orderByDesc('total') + ->get(); + + return view('livewire.pages.analytics.index', compact('totalClicks', 'last30', 'byCountry', 'byDevice')) + ->layout('layouts.nimuli-app', ['title' => 'Analytics']); } } diff --git a/app/Livewire/Pages/Billing/Index.php b/app/Livewire/Pages/Billing/Index.php index b150cb8..156e655 100644 --- a/app/Livewire/Pages/Billing/Index.php +++ b/app/Livewire/Pages/Billing/Index.php @@ -9,7 +9,7 @@ class Index extends Component { public function render(): View { - return view('livewire.pages.coming-soon') + return view('livewire.pages.billing.index') ->layout('layouts.nimuli-app', ['title' => 'Billing']); } } diff --git a/app/Livewire/Pages/Bio/Index.php b/app/Livewire/Pages/Bio/Index.php index 9b3db92..1e8dd4f 100644 --- a/app/Livewire/Pages/Bio/Index.php +++ b/app/Livewire/Pages/Bio/Index.php @@ -9,7 +9,7 @@ class Index extends Component { public function render(): View { - return view('livewire.pages.coming-soon') - ->layout('layouts.nimuli-app', ['title' => 'Bio Pages']); + return view('livewire.pages.bio.index') + ->layout('layouts.nimuli-app', ['title' => 'Link-in-Bio']); } } diff --git a/app/Livewire/Pages/Dashboard/Overview.php b/app/Livewire/Pages/Dashboard/Overview.php new file mode 100644 index 0000000..47b4ef6 --- /dev/null +++ b/app/Livewire/Pages/Dashboard/Overview.php @@ -0,0 +1,58 @@ +id) + ->whereDate('clicked_at', today()) + ->count(); + + $last7 = Click::where('workspace_id', $workspace->id) + ->where('clicked_at', '>=', now()->subDays(7)) + ->count(); + + $last30 = Click::where('workspace_id', $workspace->id) + ->where('clicked_at', '>=', now()->subDays(30)) + ->count(); + + $totalLinks = Link::where('workspace_id', $workspace->id)->count(); + + // Chart data: daily clicks for last 30 days + $chartData = Click::where('workspace_id', $workspace->id) + ->where('clicked_at', '>=', now()->subDays(29)->startOfDay()) + ->selectRaw('DATE(clicked_at) as date, COUNT(*) as total') + ->groupBy('date') + ->orderBy('date') + ->pluck('total', 'date'); + + $labels = []; + $data = []; + for ($i = 29; $i >= 0; $i--) { + $date = now()->subDays($i)->toDateString(); + $labels[] = now()->subDays($i)->format('d.m'); + $data[] = $chartData[$date] ?? 0; + } + + // Top links by clicks + $topLinks = Link::where('workspace_id', $workspace->id) + ->withCount(['clicks as total_clicks']) + ->orderByDesc('total_clicks') + ->limit(5) + ->get(); + + return view('livewire.pages.dashboard.overview', compact( + 'today', 'last7', 'last30', 'totalLinks', 'labels', 'data', 'topLinks' + ))->layout('layouts.nimuli-app', ['title' => 'Dashboard']); + } +} diff --git a/app/Livewire/Pages/Domains/Index.php b/app/Livewire/Pages/Domains/Index.php index 05c4ed0..aa1e313 100644 --- a/app/Livewire/Pages/Domains/Index.php +++ b/app/Livewire/Pages/Domains/Index.php @@ -9,7 +9,7 @@ class Index extends Component { public function render(): View { - return view('livewire.pages.coming-soon') - ->layout('layouts.nimuli-app', ['title' => 'Domains']); + return view('livewire.pages.domains.index') + ->layout('layouts.nimuli-app', ['title' => 'Custom Domains']); } } diff --git a/app/Livewire/Pages/Links/Index.php b/app/Livewire/Pages/Links/Index.php index 748a957..cde407f 100644 --- a/app/Livewire/Pages/Links/Index.php +++ b/app/Livewire/Pages/Links/Index.php @@ -16,6 +16,8 @@ class Index extends Component public string $statusFilter = ''; + protected $listeners = ['linkCreated' => '$refresh']; + public function updatingSearch(): void { $this->resetPage(); diff --git a/app/Livewire/Pages/QrCodes/Index.php b/app/Livewire/Pages/QrCodes/Index.php index ceb0d2b..a73fb47 100644 --- a/app/Livewire/Pages/QrCodes/Index.php +++ b/app/Livewire/Pages/QrCodes/Index.php @@ -9,7 +9,7 @@ class Index extends Component { public function render(): View { - return view('livewire.pages.coming-soon') + return view('livewire.pages.qr-codes.index') ->layout('layouts.nimuli-app', ['title' => 'QR Codes']); } } diff --git a/app/Livewire/Pages/Settings/Index.php b/app/Livewire/Pages/Settings/Index.php index a30a0bd..d0b300f 100644 --- a/app/Livewire/Pages/Settings/Index.php +++ b/app/Livewire/Pages/Settings/Index.php @@ -9,7 +9,7 @@ class Index extends Component { public function render(): View { - return view('livewire.pages.coming-soon') + return view('livewire.pages.settings.index') ->layout('layouts.nimuli-app', ['title' => 'Settings']); } } diff --git a/database/seeders/DemoDataSeeder.php b/database/seeders/DemoDataSeeder.php new file mode 100644 index 0000000..e1271ba --- /dev/null +++ b/database/seeders/DemoDataSeeder.php @@ -0,0 +1,81 @@ +firstOrFail(); + + $workspace = Workspace::firstOrCreate( + ['owner_id' => $user->id], + [ + 'name' => 'Demo Agency', + 'slug' => 'demo-agency', + ] + ); + + $this->command->info("Workspace: {$workspace->name} ({$workspace->ulid})"); + + $slugs = ['nimuli', 'get-started', 'pricing', 'blog', 'docs', 'github', 'twitter', 'linkedin', 'youtube', 'discord', 'product', 'api', 'signup', 'login-link', 'partner', 'careers', 'press', 'legal', 'status', 'support']; + $titles = ['Nimuli Homepage', 'Get Started Guide', 'Pricing Page', 'Blog', 'Documentation', 'GitHub Repo', 'Twitter/X', 'LinkedIn', 'YouTube Channel', 'Discord Community', 'Product Page', 'API Docs', 'Sign Up', 'Login Link', 'Partner Program', 'Careers', 'Press Kit', 'Legal', 'Status Page', 'Support']; + $targets = ['https://nimuli.com', 'https://docs.nimuli.com/start', 'https://nimuli.com/pricing', 'https://blog.nimuli.com', 'https://docs.nimuli.com', 'https://github.com/nimuli', 'https://twitter.com/nimuli', 'https://linkedin.com/company/nimuli', 'https://youtube.com/@nimuli', 'https://discord.gg/nimuli', 'https://nimuli.com/product', 'https://api.nimuli.com', 'https://app.nimuli.com/register', 'https://app.nimuli.com/login', 'https://nimuli.com/partners', 'https://nimuli.com/careers', 'https://nimuli.com/press', 'https://nimuli.com/legal', 'https://status.nimuli.com', 'https://support.nimuli.com']; + + $links = []; + foreach ($slugs as $i => $slug) { + $link = Link::firstOrCreate( + ['workspace_id' => $workspace->id, 'slug' => $slug], + [ + 'title' => $titles[$i], + 'target_url' => $targets[$i], + 'status' => $i < 18 ? 'active' : ($i === 18 ? 'disabled' : 'active'), + 'tags' => $i < 5 ? ['marketing'] : ($i < 10 ? ['social'] : ['product']), + ] + ); + $links[] = $link; + } + + $this->command->info('Created ' . count($links) . ' links'); + + $countries = ['DE', 'US', 'GB', 'FR', 'AT', 'CH', 'NL', 'PL', 'IT', 'ES']; + $devices = ['desktop', 'mobile', 'tablet']; + $browsers = ['Chrome', 'Firefox', 'Safari', 'Edge']; + $oses = ['Windows', 'macOS', 'Linux', 'iOS', 'Android']; + + $clickCount = 0; + foreach ($links as $link) { + $baseClicks = random_int(10, 80); + for ($j = 0; $j < $baseClicks; $j++) { + $daysAgo = random_int(0, 29); + $hoursAgo = random_int(0, 23); + $clickedAt = now()->subDays($daysAgo)->subHours($hoursAgo); + + Click::create([ + 'link_id' => $link->id, + 'workspace_id' => $workspace->id, + 'clicked_at' => $clickedAt, + 'ip_hash' => hash('sha256', Str::random(16)), + 'country' => $countries[array_rand($countries)], + 'city' => null, + 'device' => $devices[array_rand($devices)], + 'os' => $oses[array_rand($oses)], + 'browser' => $browsers[array_rand($browsers)], + 'referrer_host' => null, + ]); + $clickCount++; + } + } + + $this->command->info("Created {$clickCount} clicks over 30 days"); + $this->command->info("Dashboard URL: /w/{$workspace->ulid}/links"); + } +} diff --git a/resources/js/app.js b/resources/js/app.js index e59d6a0..99c42cd 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1 +1,33 @@ import './bootstrap'; +import Alpine from 'alpinejs'; +import Chart from 'chart.js/auto'; +window.Chart = Chart; + +// Theme store — reads localStorage, applies to +Alpine.store('theme', { + current: localStorage.getItem('nimuli_theme') || document.documentElement.dataset.theme || 'dark', + + init() { + this.apply(this.current); + }, + + apply(theme) { + this.current = theme; + document.documentElement.dataset.theme = theme; + localStorage.setItem('nimuli_theme', theme); + }, + + cycle() { + const order = ['dark', 'light', 'system']; + const next = order[(order.indexOf(this.current) + 1) % order.length]; + this.apply(next); + }, +}); + +// Sidebar drawer store — mobile open/close +Alpine.store('sidebar', { + open: false, +}); + +window.Alpine = Alpine; +Alpine.start(); diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php index a39bc7d..a45fde2 100644 --- a/resources/views/components/auth-session-status.blade.php +++ b/resources/views/components/auth-session-status.blade.php @@ -1,7 +1,6 @@ @props(['status']) - @if ($status) -