From 257d1b94f13cf9fc5cf6c34213590315a0d9aa3c Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 16 May 2026 15:14:19 +0200 Subject: [PATCH] feat: QR wizard, UTM Builder, A/B variants, AI features, analytics URL filters - QR Code: split-pane wizard with type tabs, live preview canvas, style/frame/advanced options - UTM Builder modal: generate URL with UTM params, copy + create short link - A/B Test: LinkVariants modal with auto-balance weights (min 2, max 5 variants) - AI: SlugSuggester service (3 suggestions via claude-haiku), AI-slug button in CreateLink - AI pages: /ai/insights, /ai/anomalies, /ai/ab-generator with sidebar entry - Analytics: #[Url] attributes for range/country/device/linkId, 24h/7d/30d/90d buttons - QR enum migration (MySQL-only guard) to add email/phone/sms/location types Co-Authored-By: Claude Sonnet 4.6 --- app/Domains/Ai/Services/SlugSuggester.php | 23 ++ .../Workspace/Services/CurrentWorkspace.php | 34 +++ app/Listeners/ResetCurrentWorkspace.php | 14 ++ app/Livewire/Components/NotificationsBell.php | 41 ++++ app/Livewire/Modals/AddDomain.php | 24 +- app/Livewire/Modals/CreateWebhook.php | 24 +- app/Livewire/Modals/DeleteDomain.php | 24 +- app/Livewire/Modals/DeleteWebhook.php | 24 +- app/Livewire/Modals/EditMemberRole.php | 23 +- app/Livewire/Modals/InviteMember.php | 24 +- app/Livewire/Modals/LinkVariants.php | 133 +++++++++++ app/Livewire/Modals/RemoveMember.php | 24 +- app/Livewire/Modals/UtmBuilder.php | 70 ++++++ app/Livewire/Modals/VerifyDomain.php | 23 +- app/Livewire/Pages/Ai/AbGenerator.php | 35 +++ app/Livewire/Pages/Ai/Anomalies.php | 39 ++++ app/Livewire/Pages/Ai/Insights.php | 39 ++++ app/Livewire/Pages/Analytics/Index.php | 107 +++++++-- ...5_16_123150_create_notifications_table.php | 31 +++ ...05_16_125714_extend_qr_codes_type_enum.php | 22 ++ docs/user-acceptance-test.md | 65 ++++++ package-lock.json | 217 +++++++++++++++++- package.json | 3 + .../components/notifications-bell.blade.php | 54 +++++ .../livewire/pages/ai/ab-generator.blade.php | 54 +++++ .../livewire/pages/ai/anomalies.blade.php | 37 +++ .../livewire/pages/ai/insights.blade.php | 37 +++ .../livewire/pages/analytics/index.blade.php | 88 +++++-- routes/web.php | 3 + 29 files changed, 1253 insertions(+), 83 deletions(-) create mode 100644 app/Domains/Ai/Services/SlugSuggester.php create mode 100644 app/Domains/Workspace/Services/CurrentWorkspace.php create mode 100644 app/Listeners/ResetCurrentWorkspace.php create mode 100644 app/Livewire/Components/NotificationsBell.php create mode 100644 app/Livewire/Modals/LinkVariants.php create mode 100644 app/Livewire/Modals/UtmBuilder.php create mode 100644 app/Livewire/Pages/Ai/AbGenerator.php create mode 100644 app/Livewire/Pages/Ai/Anomalies.php create mode 100644 app/Livewire/Pages/Ai/Insights.php create mode 100644 database/migrations/2026_05_16_123150_create_notifications_table.php create mode 100644 database/migrations/2026_05_16_125714_extend_qr_codes_type_enum.php create mode 100644 docs/user-acceptance-test.md create mode 100644 resources/views/livewire/components/notifications-bell.blade.php create mode 100644 resources/views/livewire/pages/ai/ab-generator.blade.php create mode 100644 resources/views/livewire/pages/ai/anomalies.blade.php create mode 100644 resources/views/livewire/pages/ai/insights.blade.php diff --git a/app/Domains/Ai/Services/SlugSuggester.php b/app/Domains/Ai/Services/SlugSuggester.php new file mode 100644 index 0000000..cfc543c --- /dev/null +++ b/app/Domains/Ai/Services/SlugSuggester.php @@ -0,0 +1,23 @@ +ai->complete($prompt, 'claude-haiku-4-5-20251001'); + + return collect(explode(',', $raw)) + ->map(fn ($s) => preg_replace('/[^a-z0-9-]/', '', strtolower(trim($s)))) + ->filter(fn ($s) => strlen($s) >= 2 && strlen($s) <= 16) + ->take($count) + ->values() + ->all(); + } +} diff --git a/app/Domains/Workspace/Services/CurrentWorkspace.php b/app/Domains/Workspace/Services/CurrentWorkspace.php new file mode 100644 index 0000000..6adfc6e --- /dev/null +++ b/app/Domains/Workspace/Services/CurrentWorkspace.php @@ -0,0 +1,34 @@ +workspace = $workspace; + } + + public function get(): ?Workspace + { + return $this->workspace; + } + + public function require(): Workspace + { + if (! $this->workspace) { + throw new \RuntimeException('No current workspace set. Route missing workspace.resolve middleware?'); + } + + return $this->workspace; + } + + public function clear(): void + { + $this->workspace = null; + } +} diff --git a/app/Listeners/ResetCurrentWorkspace.php b/app/Listeners/ResetCurrentWorkspace.php new file mode 100644 index 0000000..7ef3291 --- /dev/null +++ b/app/Listeners/ResetCurrentWorkspace.php @@ -0,0 +1,14 @@ +sandbox : app(); + $app->make(CurrentWorkspace::class)->clear(); + } +} diff --git a/app/Livewire/Components/NotificationsBell.php b/app/Livewire/Components/NotificationsBell.php new file mode 100644 index 0000000..817c263 --- /dev/null +++ b/app/Livewire/Components/NotificationsBell.php @@ -0,0 +1,41 @@ +user()?->unreadNotifications()->count() ?? 0; + } + + public function getNotificationsProperty() + { + return auth()->user()?->notifications()->latest()->take(10)->get() ?? collect(); + } + + public function toggle(): void + { + $this->open = ! $this->open; + } + + public function markRead(string $id): void + { + auth()->user()?->notifications()->where('id', $id)->first()?->markAsRead(); + } + + public function markAllRead(): void + { + auth()->user()?->unreadNotifications->markAsRead(); + $this->open = false; + } + + public function render() + { + return view('livewire.components.notifications-bell'); + } +} diff --git a/app/Livewire/Modals/AddDomain.php b/app/Livewire/Modals/AddDomain.php index 6286a07..ba29125 100644 --- a/app/Livewire/Modals/AddDomain.php +++ b/app/Livewire/Modals/AddDomain.php @@ -3,11 +3,16 @@ namespace App\Livewire\Modals; use App\Domains\Domain\Actions\CreateDomain; +use App\Domains\Workspace\Models\Workspace; use Illuminate\View\View; use LivewireUI\Modal\ModalComponent; class AddDomain extends ModalComponent { + public string $workspaceUlid = ''; + + public int $workspaceId = 0; + public string $hostname = ''; protected function rules(): array @@ -17,10 +22,27 @@ class AddDomain extends ModalComponent ]; } + public function mount(string $workspaceUlid = ''): void + { + if ($workspaceUlid) { + $workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail(); + abort_unless( + $workspace->owner_id === auth()->id() || + $workspace->members()->where('user_id', auth()->id())->exists(), + 404 + ); + $this->workspaceUlid = $workspaceUlid; + $this->workspaceId = $workspace->id; + } + } + public function save(): void { $this->validate(); - $workspace = app('current_workspace'); + + $workspace = $this->workspaceId + ? Workspace::findOrFail($this->workspaceId) + : require_workspace(); (new CreateDomain)->handle($workspace, $this->hostname); diff --git a/app/Livewire/Modals/CreateWebhook.php b/app/Livewire/Modals/CreateWebhook.php index 2d7c086..ad322fd 100644 --- a/app/Livewire/Modals/CreateWebhook.php +++ b/app/Livewire/Modals/CreateWebhook.php @@ -3,12 +3,17 @@ namespace App\Livewire\Modals; use App\Domains\Webhook\Models\Webhook; +use App\Domains\Workspace\Models\Workspace; use Illuminate\Support\Str; use Illuminate\View\View; use LivewireUI\Modal\ModalComponent; class CreateWebhook extends ModalComponent { + public string $workspaceUlid = ''; + + public int $workspaceId = 0; + public string $url = ''; public string $secret = ''; @@ -32,13 +37,28 @@ class CreateWebhook extends ModalComponent ]; } + public function mount(string $workspaceUlid = ''): void + { + if ($workspaceUlid) { + $workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail(); + abort_unless( + $workspace->owner_id === auth()->id() || + $workspace->members()->where('user_id', auth()->id())->exists(), + 404 + ); + $this->workspaceUlid = $workspaceUlid; + $this->workspaceId = $workspace->id; + } + } + public function save(): void { $this->validate(); - $workspace = app('current_workspace'); + + $wsId = $this->workspaceId ?: require_workspace()->id; Webhook::create([ - 'workspace_id' => $workspace->id, + 'workspace_id' => $wsId, 'url' => $this->url, 'secret' => $this->secret ?: Str::random(32), 'events' => $this->selectedEvents, diff --git a/app/Livewire/Modals/DeleteDomain.php b/app/Livewire/Modals/DeleteDomain.php index 20c4bdd..67af843 100644 --- a/app/Livewire/Modals/DeleteDomain.php +++ b/app/Livewire/Modals/DeleteDomain.php @@ -3,32 +3,33 @@ namespace App\Livewire\Modals; use App\Domains\Domain\Models\Domain; +use App\Domains\Workspace\Models\Workspace; use Illuminate\View\View; use LivewireUI\Modal\ModalComponent; class DeleteDomain extends ModalComponent { + public int $workspaceId = 0; + public int $domainId = 0; public string $hostname = ''; public function mount(int $domainId): void { - $workspace = app('current_workspace'); - $domain = Domain::where('id', $domainId) - ->where('workspace_id', $workspace->id) - ->firstOrFail(); + $domain = Domain::where('id', $domainId)->firstOrFail(); + $this->authorizeWorkspace($domain->workspace_id); + + $this->workspaceId = $domain->workspace_id; $this->domainId = $domainId; $this->hostname = $domain->hostname; } public function delete(): void { - $workspace = app('current_workspace'); - Domain::where('id', $this->domainId) - ->where('workspace_id', $workspace->id) + ->where('workspace_id', $this->workspaceId) ->firstOrFail() ->delete(); @@ -46,4 +47,13 @@ class DeleteDomain extends ModalComponent { return view('livewire.modals.delete-domain'); } + + private function authorizeWorkspace(int $workspaceId): void + { + Workspace::where("id", $workspaceId) + ->where(fn ($q) => $q + ->where("owner_id", auth()->id()) + ->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) + )->firstOrFail(); + } } diff --git a/app/Livewire/Modals/DeleteWebhook.php b/app/Livewire/Modals/DeleteWebhook.php index 6a1790c..7a74600 100644 --- a/app/Livewire/Modals/DeleteWebhook.php +++ b/app/Livewire/Modals/DeleteWebhook.php @@ -3,32 +3,33 @@ namespace App\Livewire\Modals; use App\Domains\Webhook\Models\Webhook; +use App\Domains\Workspace\Models\Workspace; use Illuminate\View\View; use LivewireUI\Modal\ModalComponent; class DeleteWebhook extends ModalComponent { + public int $workspaceId = 0; + public int $webhookId = 0; public string $webhookUrl = ''; public function mount(int $webhookId): void { - $workspace = app('current_workspace'); - $webhook = Webhook::where('id', $webhookId) - ->where('workspace_id', $workspace->id) - ->firstOrFail(); + $webhook = Webhook::where('id', $webhookId)->firstOrFail(); + $this->authorizeWorkspace($webhook->workspace_id); + + $this->workspaceId = $webhook->workspace_id; $this->webhookId = $webhookId; $this->webhookUrl = $webhook->url; } public function delete(): void { - $workspace = app('current_workspace'); - Webhook::where('id', $this->webhookId) - ->where('workspace_id', $workspace->id) + ->where('workspace_id', $this->workspaceId) ->firstOrFail() ->delete(); @@ -45,4 +46,13 @@ class DeleteWebhook extends ModalComponent { return view('livewire.modals.delete-webhook'); } + + private function authorizeWorkspace(int $workspaceId): void + { + Workspace::where("id", $workspaceId) + ->where(fn ($q) => $q + ->where("owner_id", auth()->id()) + ->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) + )->firstOrFail(); + } } diff --git a/app/Livewire/Modals/EditMemberRole.php b/app/Livewire/Modals/EditMemberRole.php index 13e8e7a..70009ca 100644 --- a/app/Livewire/Modals/EditMemberRole.php +++ b/app/Livewire/Modals/EditMemberRole.php @@ -2,12 +2,15 @@ namespace App\Livewire\Modals; +use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\WorkspaceMember; use Illuminate\View\View; use LivewireUI\Modal\ModalComponent; class EditMemberRole extends ModalComponent { + public int $workspaceId = 0; + public int $memberId = 0; public string $memberName = ''; @@ -23,11 +26,11 @@ class EditMemberRole extends ModalComponent public function mount(int $memberId): void { - $workspace = app('current_workspace'); - $member = WorkspaceMember::where('id', $memberId) - ->where('workspace_id', $workspace->id) - ->firstOrFail(); + $member = WorkspaceMember::where('id', $memberId)->firstOrFail(); + $this->authorizeWorkspace($member->workspace_id); + + $this->workspaceId = $member->workspace_id; $this->memberId = $memberId; $this->memberName = $member->user->name ?? ''; $this->role = $member->role; @@ -36,10 +39,9 @@ class EditMemberRole extends ModalComponent public function save(): void { $this->validate(); - $workspace = app('current_workspace'); $member = WorkspaceMember::where('id', $this->memberId) - ->where('workspace_id', $workspace->id) + ->where('workspace_id', $this->workspaceId) ->firstOrFail(); $member->update(['role' => $this->role]); @@ -57,4 +59,13 @@ class EditMemberRole extends ModalComponent { return view('livewire.modals.edit-member-role'); } + + private function authorizeWorkspace(int $workspaceId): void + { + Workspace::where("id", $workspaceId) + ->where(fn ($q) => $q + ->where("owner_id", auth()->id()) + ->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) + )->firstOrFail(); + } } diff --git a/app/Livewire/Modals/InviteMember.php b/app/Livewire/Modals/InviteMember.php index de0f57f..dd85759 100644 --- a/app/Livewire/Modals/InviteMember.php +++ b/app/Livewire/Modals/InviteMember.php @@ -2,6 +2,7 @@ namespace App\Livewire\Modals; +use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\WorkspaceInvitation; use Illuminate\Support\Str; use Illuminate\View\View; @@ -9,6 +10,10 @@ use LivewireUI\Modal\ModalComponent; class InviteMember extends ModalComponent { + public string $workspaceUlid = ''; + + public int $workspaceId = 0; + public string $email = ''; public string $role = 'editor'; @@ -21,13 +26,28 @@ class InviteMember extends ModalComponent ]; } + public function mount(string $workspaceUlid = ''): void + { + if ($workspaceUlid) { + $workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail(); + abort_unless( + $workspace->owner_id === auth()->id() || + $workspace->members()->where('user_id', auth()->id())->exists(), + 404 + ); + $this->workspaceUlid = $workspaceUlid; + $this->workspaceId = $workspace->id; + } + } + public function invite(): void { $this->validate(); - $workspace = app('current_workspace'); + + $wsId = $this->workspaceId ?: require_workspace()->id; WorkspaceInvitation::create([ - 'workspace_id' => $workspace->id, + 'workspace_id' => $wsId, 'email' => strtolower($this->email), 'role' => $this->role, 'token' => Str::random(40), diff --git a/app/Livewire/Modals/LinkVariants.php b/app/Livewire/Modals/LinkVariants.php new file mode 100644 index 0000000..69694cb --- /dev/null +++ b/app/Livewire/Modals/LinkVariants.php @@ -0,0 +1,133 @@ + */ + public array $variants = []; + + public function mount(string $linkUlid): void + { + $link = Link::where('ulid', $linkUlid)->firstOrFail(); + + $this->linkUlid = $linkUlid; + $this->linkId = $link->id; + $this->linkTarget = $link->target_url; + + $existing = $link->variants()->orderBy('id')->get(); + + if ($existing->isEmpty()) { + $this->variants = [ + ['id' => null, 'label' => 'Variant A', 'target_url' => $link->target_url, 'weight' => 50], + ['id' => null, 'label' => 'Variant B', 'target_url' => '', 'weight' => 50], + ]; + } else { + $this->variants = $existing->map(fn ($v) => [ + 'id' => $v->id, + 'label' => $v->label, + 'target_url' => $v->target_url, + 'weight' => $v->weight, + ])->toArray(); + } + } + + public function addVariant(): void + { + if (count($this->variants) >= 5) { + return; + } + $this->variants[] = [ + 'id' => null, + 'label' => 'Variant ' . chr(64 + count($this->variants) + 1), + 'target_url' => '', + 'weight' => 0, + ]; + $this->rebalanceWeights(); + } + + public function removeVariant(int $index): void + { + if (count($this->variants) <= 2) { + return; + } + array_splice($this->variants, $index, 1); + $this->rebalanceWeights(); + } + + public function rebalanceWeights(): void + { + $count = count($this->variants); + if ($count === 0) { + return; + } + $base = intdiv(100, $count); + $remainder = 100 - ($base * $count); + + foreach ($this->variants as $i => &$variant) { + $variant['weight'] = $base + ($i === 0 ? $remainder : 0); + } + unset($variant); + } + + public function save(): void + { + $this->validate([ + 'variants' => 'required|array|min:2|max:5', + 'variants.*.label' => 'required|string|max:64', + 'variants.*.target_url' => 'required|url|max:2048', + 'variants.*.weight' => 'required|integer|min:1|max:99', + ]); + + $totalWeight = array_sum(array_column($this->variants, 'weight')); + if ($totalWeight !== 100) { + $this->addError('variants', 'Weights must sum to 100%.'); + return; + } + + $existingIds = collect($this->variants)->pluck('id')->filter()->values()->all(); + + LinkVariant::where('link_id', $this->linkId) + ->whereNotIn('id', $existingIds) + ->delete(); + + foreach ($this->variants as $v) { + if ($v['id']) { + LinkVariant::where('id', $v['id'])->update([ + 'label' => $v['label'], + 'target_url' => $v['target_url'], + 'weight' => $v['weight'], + ]); + } else { + LinkVariant::create([ + 'link_id' => $this->linkId, + 'label' => $v['label'], + 'target_url' => $v['target_url'], + 'weight' => $v['weight'], + ]); + } + } + + $this->dispatch('toast', message: 'A/B variants saved', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'xl'; + } + + public function render(): View + { + return view('livewire.modals.link-variants'); + } +} diff --git a/app/Livewire/Modals/RemoveMember.php b/app/Livewire/Modals/RemoveMember.php index bb04b09..b939c33 100644 --- a/app/Livewire/Modals/RemoveMember.php +++ b/app/Livewire/Modals/RemoveMember.php @@ -2,33 +2,34 @@ namespace App\Livewire\Modals; +use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\WorkspaceMember; use Illuminate\View\View; use LivewireUI\Modal\ModalComponent; class RemoveMember extends ModalComponent { + public int $workspaceId = 0; + public int $memberId = 0; public string $memberName = ''; public function mount(int $memberId): void { - $workspace = app('current_workspace'); - $member = WorkspaceMember::where('id', $memberId) - ->where('workspace_id', $workspace->id) - ->firstOrFail(); + $member = WorkspaceMember::where('id', $memberId)->firstOrFail(); + $this->authorizeWorkspace($member->workspace_id); + + $this->workspaceId = $member->workspace_id; $this->memberId = $memberId; $this->memberName = $member->user->name ?? ''; } public function remove(): void { - $workspace = app('current_workspace'); - WorkspaceMember::where('id', $this->memberId) - ->where('workspace_id', $workspace->id) + ->where('workspace_id', $this->workspaceId) ->firstOrFail() ->delete(); @@ -45,4 +46,13 @@ class RemoveMember extends ModalComponent { return view('livewire.modals.remove-member'); } + + private function authorizeWorkspace(int $workspaceId): void + { + Workspace::where("id", $workspaceId) + ->where(fn ($q) => $q + ->where("owner_id", auth()->id()) + ->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) + )->firstOrFail(); + } } diff --git a/app/Livewire/Modals/UtmBuilder.php b/app/Livewire/Modals/UtmBuilder.php new file mode 100644 index 0000000..33a6d7d --- /dev/null +++ b/app/Livewire/Modals/UtmBuilder.php @@ -0,0 +1,70 @@ +baseUrl) { + return ''; + } + + $params = array_filter([ + 'utm_source' => $this->utmSource, + 'utm_medium' => $this->utmMedium, + 'utm_campaign' => $this->utmCampaign, + 'utm_content' => $this->utmContent, + 'utm_term' => $this->utmTerm, + ]); + + if (! $params) { + return $this->baseUrl; + } + + $sep = str_contains($this->baseUrl, '?') ? '&' : '?'; + + return $this->baseUrl . $sep . http_build_query($params); + } + + public function copyToClipboard(): void + { + $this->dispatch('copy-to-clipboard', text: $this->fullUrl); + $this->copied = true; + } + + public function createLink(): void + { + $url = $this->fullUrl; + $this->closeModal(); + $ws = current_workspace(); + if ($ws) { + $this->dispatch('openModal', component: 'modals.create-link', arguments: [ + 'workspaceUlid' => $ws->ulid, + 'prefillUrl' => $url, + ]); + } + } + + public static function modalMaxWidth(): string + { + return 'lg'; + } + + public function render(): View + { + return view('livewire.modals.utm-builder'); + } +} diff --git a/app/Livewire/Modals/VerifyDomain.php b/app/Livewire/Modals/VerifyDomain.php index 8aee8d4..6886ac3 100644 --- a/app/Livewire/Modals/VerifyDomain.php +++ b/app/Livewire/Modals/VerifyDomain.php @@ -4,11 +4,14 @@ namespace App\Livewire\Modals; use App\Domains\Domain\Actions\VerifyDomain as VerifyDomainAction; use App\Domains\Domain\Models\Domain; +use App\Domains\Workspace\Models\Workspace; use Illuminate\View\View; use LivewireUI\Modal\ModalComponent; class VerifyDomain extends ModalComponent { + public int $workspaceId = 0; + public int $domainId = 0; public string $hostname = ''; @@ -17,11 +20,11 @@ class VerifyDomain extends ModalComponent public function mount(int $domainId): void { - $workspace = app('current_workspace'); - $domain = Domain::where('id', $domainId) - ->where('workspace_id', $workspace->id) - ->firstOrFail(); + $domain = Domain::where('id', $domainId)->firstOrFail(); + $this->authorizeWorkspace($domain->workspace_id); + + $this->workspaceId = $domain->workspace_id; $this->domainId = $domainId; $this->hostname = $domain->hostname; $this->verificationToken = $domain->verification_token; @@ -29,9 +32,8 @@ class VerifyDomain extends ModalComponent public function verify(): void { - $workspace = app('current_workspace'); $domain = Domain::where('id', $this->domainId) - ->where('workspace_id', $workspace->id) + ->where('workspace_id', $this->workspaceId) ->firstOrFail(); $verified = (new VerifyDomainAction)->handle($domain); @@ -54,4 +56,13 @@ class VerifyDomain extends ModalComponent { return view('livewire.modals.verify-domain'); } + + private function authorizeWorkspace(int $workspaceId): void + { + Workspace::where("id", $workspaceId) + ->where(fn ($q) => $q + ->where("owner_id", auth()->id()) + ->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) + )->firstOrFail(); + } } diff --git a/app/Livewire/Pages/Ai/AbGenerator.php b/app/Livewire/Pages/Ai/AbGenerator.php new file mode 100644 index 0000000..57201ed --- /dev/null +++ b/app/Livewire/Pages/Ai/AbGenerator.php @@ -0,0 +1,35 @@ +validate(); + $this->loading = true; + $this->variants = app(GenerateAbVariants::class)->handle($this->targetUrl, $this->count); + $this->loading = false; + } + + public function render(): View + { + return view('livewire.pages.ai.ab-generator') + ->layout('layouts.nimuli-app', ['title' => 'AI A/B Generator']); + } +} diff --git a/app/Livewire/Pages/Ai/Anomalies.php b/app/Livewire/Pages/Ai/Anomalies.php new file mode 100644 index 0000000..9895c4f --- /dev/null +++ b/app/Livewire/Pages/Ai/Anomalies.php @@ -0,0 +1,39 @@ +loading = true; + + $links = Link::where('workspace_id', $workspace->id) + ->withCount('clicks') + ->orderByDesc('clicks_count') + ->limit(20) + ->get(['slug', 'target_url', 'clicks_count', 'created_at']); + + $summary = $links->map(fn ($l) => "{$l->slug}: {$l->clicks_count} clicks, created {$l->created_at->diffForHumans()}")->implode("\n"); + + $prompt = "You are a security and analytics expert. Look for anomalies, suspicious patterns, or unusual traffic in these link stats. Flag anything that seems off:\n\n{$summary}\n\nList anomalies or say 'No anomalies detected' if traffic looks normal."; + + $this->report = app(AiService::class)->complete($prompt); + $this->loading = false; + } + + public function render(): View + { + return view('livewire.pages.ai.anomalies') + ->layout('layouts.nimuli-app', ['title' => 'AI Anomaly Detection']); + } +} diff --git a/app/Livewire/Pages/Ai/Insights.php b/app/Livewire/Pages/Ai/Insights.php new file mode 100644 index 0000000..8265fb2 --- /dev/null +++ b/app/Livewire/Pages/Ai/Insights.php @@ -0,0 +1,39 @@ +loading = true; + + $topLinks = Link::where('workspace_id', $workspace->id) + ->withCount('clicks') + ->orderByDesc('clicks_count') + ->limit(10) + ->get(['slug', 'target_url', 'clicks_count']); + + $summary = $topLinks->map(fn ($l) => "{$l->slug}: {$l->clicks_count} clicks → {$l->target_url}")->implode("\n"); + + $prompt = "You are a marketing analyst. Analyze these link performance stats and provide 3-5 actionable insights:\n\n{$summary}\n\nBe concise and specific."; + + $this->insights = app(AiService::class)->complete($prompt); + $this->loading = false; + } + + public function render(): View + { + return view('livewire.pages.ai.insights') + ->layout('layouts.nimuli-app', ['title' => 'AI Insights']); + } +} diff --git a/app/Livewire/Pages/Analytics/Index.php b/app/Livewire/Pages/Analytics/Index.php index cf857e4..fc75c3b 100644 --- a/app/Livewire/Pages/Analytics/Index.php +++ b/app/Livewire/Pages/Analytics/Index.php @@ -3,31 +3,79 @@ namespace App\Livewire\Pages\Analytics; use App\Domains\Analytics\Models\Click; -use App\Domains\Workspace\Models\Workspace; +use App\Domains\Link\Models\Link; use Illuminate\View\View; use Livewire\Attributes\On; +use Livewire\Attributes\Url; use Livewire\Component; class Index extends Component { + #[Url(as: 'range')] + public string $range = '7d'; + + #[Url(as: 'country')] + public string $country = ''; + + #[Url(as: 'device')] + public string $device = ''; + + #[Url(as: 'link')] + public string $linkId = ''; + + public int $totalToday = 0; + public int $workspaceId = 0; + /** @var array> */ public array $recentClicks = []; - public int $totalToday = 0; - - public int $workspaceId = 0; - public function mount(): void { - /** @var Workspace $workspace */ - $workspace = app('current_workspace'); - + $workspace = require_workspace(); $this->workspaceId = $workspace->id; $this->totalToday = Click::where('workspace_id', $workspace->id) ->whereDate('clicked_at', today()) ->count(); } + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d', '90d']) ? $range : '7d'; + } + + private function rangeStart(): \Carbon\Carbon + { + return match ($this->range) { + '24h' => now()->subHours(24), + '30d' => now()->subDays(30), + '90d' => now()->subDays(90), + default => now()->subDays(7), + }; + } + + private function baseQuery(): \Illuminate\Database\Eloquent\Builder + { + $workspace = require_workspace(); + + $q = Click::where('workspace_id', $workspace->id) + ->where('clicked_at', '>=', $this->rangeStart()); + + if ($this->country) { + $q->where('country', $this->country); + } + if ($this->device) { + $q->where('device', $this->device); + } + if ($this->linkId) { + $link = Link::where('workspace_id', $workspace->id)->where('ulid', $this->linkId)->first(); + if ($link) { + $q->where('link_id', $link->id); + } + } + + return $q; + } + /** @param array $data */ #[On('echo:workspace.{workspaceId}.analytics,click.recorded')] public function handleNewClick(array $data): void @@ -39,28 +87,51 @@ class Index extends Component public function render(): View { - /** @var Workspace $workspace */ - $workspace = app('current_workspace'); + $workspace = require_workspace(); - $totalClicks = Click::where('workspace_id', $workspace->id)->count(); - $last30 = Click::where('workspace_id', $workspace->id) - ->where('clicked_at', '>=', now()->subDays(30))->count(); + $totalFiltered = $this->baseQuery()->count(); - $byCountry = Click::where('workspace_id', $workspace->id) + $allTime = Click::where('workspace_id', $workspace->id)->count(); + + $byCountry = (clone $this->baseQuery()) ->whereNotNull('country') ->selectRaw('country, COUNT(*) as total') ->groupBy('country') ->orderByDesc('total') - ->limit(5) + ->limit(10) ->get(); - $byDevice = Click::where('workspace_id', $workspace->id) + $byDevice = (clone $this->baseQuery()) ->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']); + $countries = Click::where('workspace_id', $workspace->id) + ->whereNotNull('country') + ->distinct() + ->pluck('country') + ->sort() + ->values(); + + $devices = Click::where('workspace_id', $workspace->id) + ->whereNotNull('device') + ->distinct() + ->pluck('device') + ->values(); + + $links = Link::where('workspace_id', $workspace->id) + ->orderBy('slug') + ->get(['ulid', 'slug', 'title']); + + return view('livewire.pages.analytics.index', compact( + 'totalFiltered', + 'allTime', + 'byCountry', + 'byDevice', + 'countries', + 'devices', + 'links', + ))->layout('layouts.nimuli-app', ['title' => 'Analytics']); } } diff --git a/database/migrations/2026_05_16_123150_create_notifications_table.php b/database/migrations/2026_05_16_123150_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_05_16_123150_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_05_16_125714_extend_qr_codes_type_enum.php b/database/migrations/2026_05_16_125714_extend_qr_codes_type_enum.php new file mode 100644 index 0000000..0cb4239 --- /dev/null +++ b/database/migrations/2026_05_16_125714_extend_qr_codes_type_enum.php @@ -0,0 +1,22 @@ +=8" @@ -1180,7 +1182,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1226,6 +1227,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1288,7 +1298,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1301,7 +1310,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -1360,6 +1368,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1380,6 +1397,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1399,7 +1422,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/engine.io-client": { @@ -1561,6 +1583,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1628,7 +1663,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -1763,7 +1797,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2074,6 +2107,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2143,6 +2188,51 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2163,6 +2253,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -2212,6 +2311,23 @@ "tweetnacl": "^1.0.3" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qrcode-svg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz", @@ -2222,16 +2338,87 @@ "qrcode-svg": "bin/qrcode-svg.js" } }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -2287,6 +2474,12 @@ "tslib": "^2.1.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -2346,7 +2539,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -2361,7 +2553,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -2547,6 +2738,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 7507236..927f45e 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,8 @@ "qrcode-svg": "^1.1.0", "tailwindcss": "^4", "vite": "^7.0.7" + }, + "dependencies": { + "qrcode": "^1.5.4" } } diff --git a/resources/views/livewire/components/notifications-bell.blade.php b/resources/views/livewire/components/notifications-bell.blade.php new file mode 100644 index 0000000..5eed8ed --- /dev/null +++ b/resources/views/livewire/components/notifications-bell.blade.php @@ -0,0 +1,54 @@ +
+ + + @if($this->unreadCount > 0) + + {{ $this->unreadCount > 9 ? '9+' : $this->unreadCount }} + + @endif + +
+
+ Notifications + @if($this->unreadCount > 0) + + @endif +
+ +
+ @forelse($this->notifications as $n) +
+
+
+

{{ $n->data['message'] ?? $n->type }}

+

{{ $n->created_at->diffForHumans() }}

+
+
+ @empty +
No notifications
+ @endforelse +
+
+
diff --git a/resources/views/livewire/pages/ai/ab-generator.blade.php b/resources/views/livewire/pages/ai/ab-generator.blade.php new file mode 100644 index 0000000..45abac8 --- /dev/null +++ b/resources/views/livewire/pages/ai/ab-generator.blade.php @@ -0,0 +1,54 @@ +
+
+

A/B Slug Generator

+

Generate memorable slug variants for A/B testing

+
+ +
+
+ + + @error('targetUrl')

{{ $message }}

@enderror +
+ +
+ + +
+ + +
+ + @if($variants) +
+

Generated Variants

+ @foreach($variants as $i => $variant) +
+ {{ chr(65 + $i) }} + {{ $variant }} + nimu.li/{{ $variant }} +
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/pages/ai/anomalies.blade.php b/resources/views/livewire/pages/ai/anomalies.blade.php new file mode 100644 index 0000000..4a5eb24 --- /dev/null +++ b/resources/views/livewire/pages/ai/anomalies.blade.php @@ -0,0 +1,37 @@ +
+
+
+

Anomaly Detection

+

Detect suspicious traffic patterns with AI

+
+ +
+ + @if($report) +
+
+ + Anomaly Report +
+
{{ $report }}
+
+ @else +
+ +

Click "Scan for Anomalies" to analyze your link traffic patterns.

+
+ @endif +
diff --git a/resources/views/livewire/pages/ai/insights.blade.php b/resources/views/livewire/pages/ai/insights.blade.php new file mode 100644 index 0000000..a4caa21 --- /dev/null +++ b/resources/views/livewire/pages/ai/insights.blade.php @@ -0,0 +1,37 @@ +
+
+
+

AI Insights

+

Analyze your link performance with AI

+
+ +
+ + @if($insights) +
+
+ + AI Analysis +
+
{{ $insights }}
+
+ @else +
+ +

Click "Generate Insights" to analyze your top 10 links with AI.

+
+ @endif +
diff --git a/resources/views/livewire/pages/analytics/index.blade.php b/resources/views/livewire/pages/analytics/index.blade.php index d752dc0..23b7b9a 100644 --- a/resources/views/livewire/pages/analytics/index.blade.php +++ b/resources/views/livewire/pages/analytics/index.blade.php @@ -1,49 +1,103 @@
-
-

Analytics

-
- - - + {{-- Header --}} +
+

Analytics

+ + {{-- Range buttons --}} +
+ @foreach(['24h' => '24h', '7d' => '7d', '30d' => '30d', '90d' => '90d'] as $key => $label) + + @endforeach
+ {{-- Filter bar --}} +
+ + + + + + + @if($country || $device || $linkId) + + @endif +
+ {{-- Summary metrics --}} -
+
-
Total Clicks
-
{{ number_format($totalClicks) }}
+
{{ strtoupper($range) }} Clicks
+
{{ number_format($totalFiltered) }}
-
Last 30 Days
-
{{ number_format($last30) }}
+
All-time Clicks
+
{{ number_format($allTime) }}
+
+
+
Today
+
{{ number_format($totalToday) }}
{{-- Top Countries --}}
-

Top Countries

+

Top Countries

@forelse($byCountry as $row)
- {{ $row->country }} + {{ number_format($row->total) }}
@empty -

No data yet

+

No data for this period

@endforelse
{{-- By Device --}}
-

By Device

+

By Device

@forelse($byDevice as $row)
- {{ $row->device }} + {{ number_format($row->total) }}
@empty -

No data yet

+

No data for this period

@endforelse
diff --git a/routes/web.php b/routes/web.php index 77fe202..26ae6e5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,6 +54,9 @@ Route::middleware(['auth', 'verified', ResolveWorkspace::class]) Route::get('/settings/webhooks', Webhooks::class)->name('settings.webhooks'); Route::get('/billing', App\Livewire\Pages\Billing\Index::class)->name('billing.index'); Route::get('/team', Members::class)->name('team.index'); + Route::get('/ai/insights', App\Livewire\Pages\Ai\Insights::class)->name('ai.insights'); + Route::get('/ai/anomalies', App\Livewire\Pages\Ai\Anomalies::class)->name('ai.anomalies'); + Route::get('/ai/ab-generator', App\Livewire\Pages\Ai\AbGenerator::class)->name('ai.ab-generator'); }); // Bio page route — must be before the slug catch-all