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 <noreply@anthropic.com>
main
boban 2026-05-16 15:14:19 +02:00
parent 1ff4114674
commit 257d1b94f1
29 changed files with 1253 additions and 83 deletions

View File

@ -0,0 +1,23 @@
<?php
namespace App\Domains\Ai\Services;
class SlugSuggester
{
public function __construct(private AiService $ai) {}
/** @return string[] */
public function suggest(string $targetUrl, int $count = 3): array
{
$prompt = "Generate exactly {$count} short memorable URL slugs (3-8 chars each, lowercase, letters/hyphens only, no numbers, comma-separated, no spaces) for this URL: {$targetUrl}. Reply with ONLY the comma-separated slugs, nothing else.";
$raw = $this->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();
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Domains\Workspace\Services;
use App\Domains\Workspace\Models\Workspace;
class CurrentWorkspace
{
private ?Workspace $workspace = null;
public function set(Workspace $workspace): void
{
$this->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;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Listeners;
use App\Domains\Workspace\Services\CurrentWorkspace;
class ResetCurrentWorkspace
{
public function handle(object $event): void
{
$app = property_exists($event, 'sandbox') ? $event->sandbox : app();
$app->make(CurrentWorkspace::class)->clear();
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Components;
use Livewire\Component;
class NotificationsBell extends Component
{
public bool $open = false;
public function getUnreadCountProperty(): int
{
return auth()->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');
}
}

View File

@ -3,11 +3,16 @@
namespace App\Livewire\Modals; namespace App\Livewire\Modals;
use App\Domains\Domain\Actions\CreateDomain; use App\Domains\Domain\Actions\CreateDomain;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
class AddDomain extends ModalComponent class AddDomain extends ModalComponent
{ {
public string $workspaceUlid = '';
public int $workspaceId = 0;
public string $hostname = ''; public string $hostname = '';
protected function rules(): array 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 public function save(): void
{ {
$this->validate(); $this->validate();
$workspace = app('current_workspace');
$workspace = $this->workspaceId
? Workspace::findOrFail($this->workspaceId)
: require_workspace();
(new CreateDomain)->handle($workspace, $this->hostname); (new CreateDomain)->handle($workspace, $this->hostname);

View File

@ -3,12 +3,17 @@
namespace App\Livewire\Modals; namespace App\Livewire\Modals;
use App\Domains\Webhook\Models\Webhook; use App\Domains\Webhook\Models\Webhook;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
class CreateWebhook extends ModalComponent class CreateWebhook extends ModalComponent
{ {
public string $workspaceUlid = '';
public int $workspaceId = 0;
public string $url = ''; public string $url = '';
public string $secret = ''; 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 public function save(): void
{ {
$this->validate(); $this->validate();
$workspace = app('current_workspace');
$wsId = $this->workspaceId ?: require_workspace()->id;
Webhook::create([ Webhook::create([
'workspace_id' => $workspace->id, 'workspace_id' => $wsId,
'url' => $this->url, 'url' => $this->url,
'secret' => $this->secret ?: Str::random(32), 'secret' => $this->secret ?: Str::random(32),
'events' => $this->selectedEvents, 'events' => $this->selectedEvents,

View File

@ -3,32 +3,33 @@
namespace App\Livewire\Modals; namespace App\Livewire\Modals;
use App\Domains\Domain\Models\Domain; use App\Domains\Domain\Models\Domain;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
class DeleteDomain extends ModalComponent class DeleteDomain extends ModalComponent
{ {
public int $workspaceId = 0;
public int $domainId = 0; public int $domainId = 0;
public string $hostname = ''; public string $hostname = '';
public function mount(int $domainId): void public function mount(int $domainId): void
{ {
$workspace = app('current_workspace'); $domain = Domain::where('id', $domainId)->firstOrFail();
$domain = Domain::where('id', $domainId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->authorizeWorkspace($domain->workspace_id);
$this->workspaceId = $domain->workspace_id;
$this->domainId = $domainId; $this->domainId = $domainId;
$this->hostname = $domain->hostname; $this->hostname = $domain->hostname;
} }
public function delete(): void public function delete(): void
{ {
$workspace = app('current_workspace');
Domain::where('id', $this->domainId) Domain::where('id', $this->domainId)
->where('workspace_id', $workspace->id) ->where('workspace_id', $this->workspaceId)
->firstOrFail() ->firstOrFail()
->delete(); ->delete();
@ -46,4 +47,13 @@ class DeleteDomain extends ModalComponent
{ {
return view('livewire.modals.delete-domain'); 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();
}
} }

View File

@ -3,32 +3,33 @@
namespace App\Livewire\Modals; namespace App\Livewire\Modals;
use App\Domains\Webhook\Models\Webhook; use App\Domains\Webhook\Models\Webhook;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
class DeleteWebhook extends ModalComponent class DeleteWebhook extends ModalComponent
{ {
public int $workspaceId = 0;
public int $webhookId = 0; public int $webhookId = 0;
public string $webhookUrl = ''; public string $webhookUrl = '';
public function mount(int $webhookId): void public function mount(int $webhookId): void
{ {
$workspace = app('current_workspace'); $webhook = Webhook::where('id', $webhookId)->firstOrFail();
$webhook = Webhook::where('id', $webhookId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->authorizeWorkspace($webhook->workspace_id);
$this->workspaceId = $webhook->workspace_id;
$this->webhookId = $webhookId; $this->webhookId = $webhookId;
$this->webhookUrl = $webhook->url; $this->webhookUrl = $webhook->url;
} }
public function delete(): void public function delete(): void
{ {
$workspace = app('current_workspace');
Webhook::where('id', $this->webhookId) Webhook::where('id', $this->webhookId)
->where('workspace_id', $workspace->id) ->where('workspace_id', $this->workspaceId)
->firstOrFail() ->firstOrFail()
->delete(); ->delete();
@ -45,4 +46,13 @@ class DeleteWebhook extends ModalComponent
{ {
return view('livewire.modals.delete-webhook'); 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();
}
} }

View File

@ -2,12 +2,15 @@
namespace App\Livewire\Modals; namespace App\Livewire\Modals;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceMember; use App\Domains\Workspace\Models\WorkspaceMember;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
class EditMemberRole extends ModalComponent class EditMemberRole extends ModalComponent
{ {
public int $workspaceId = 0;
public int $memberId = 0; public int $memberId = 0;
public string $memberName = ''; public string $memberName = '';
@ -23,11 +26,11 @@ class EditMemberRole extends ModalComponent
public function mount(int $memberId): void public function mount(int $memberId): void
{ {
$workspace = app('current_workspace'); $member = WorkspaceMember::where('id', $memberId)->firstOrFail();
$member = WorkspaceMember::where('id', $memberId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->authorizeWorkspace($member->workspace_id);
$this->workspaceId = $member->workspace_id;
$this->memberId = $memberId; $this->memberId = $memberId;
$this->memberName = $member->user->name ?? ''; $this->memberName = $member->user->name ?? '';
$this->role = $member->role; $this->role = $member->role;
@ -36,10 +39,9 @@ class EditMemberRole extends ModalComponent
public function save(): void public function save(): void
{ {
$this->validate(); $this->validate();
$workspace = app('current_workspace');
$member = WorkspaceMember::where('id', $this->memberId) $member = WorkspaceMember::where('id', $this->memberId)
->where('workspace_id', $workspace->id) ->where('workspace_id', $this->workspaceId)
->firstOrFail(); ->firstOrFail();
$member->update(['role' => $this->role]); $member->update(['role' => $this->role]);
@ -57,4 +59,13 @@ class EditMemberRole extends ModalComponent
{ {
return view('livewire.modals.edit-member-role'); 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();
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Livewire\Modals; namespace App\Livewire\Modals;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceInvitation; use App\Domains\Workspace\Models\WorkspaceInvitation;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
@ -9,6 +10,10 @@ use LivewireUI\Modal\ModalComponent;
class InviteMember extends ModalComponent class InviteMember extends ModalComponent
{ {
public string $workspaceUlid = '';
public int $workspaceId = 0;
public string $email = ''; public string $email = '';
public string $role = 'editor'; 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 public function invite(): void
{ {
$this->validate(); $this->validate();
$workspace = app('current_workspace');
$wsId = $this->workspaceId ?: require_workspace()->id;
WorkspaceInvitation::create([ WorkspaceInvitation::create([
'workspace_id' => $workspace->id, 'workspace_id' => $wsId,
'email' => strtolower($this->email), 'email' => strtolower($this->email),
'role' => $this->role, 'role' => $this->role,
'token' => Str::random(40), 'token' => Str::random(40),

View File

@ -0,0 +1,133 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Link\Models\Link;
use App\Domains\Link\Models\LinkVariant;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class LinkVariants extends ModalComponent
{
public string $linkUlid = '';
public int $linkId = 0;
public string $linkTarget = '';
/** @var array<int, array{id: int|null, label: string, target_url: string, weight: int}> */
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');
}
}

View File

@ -2,33 +2,34 @@
namespace App\Livewire\Modals; namespace App\Livewire\Modals;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceMember; use App\Domains\Workspace\Models\WorkspaceMember;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
class RemoveMember extends ModalComponent class RemoveMember extends ModalComponent
{ {
public int $workspaceId = 0;
public int $memberId = 0; public int $memberId = 0;
public string $memberName = ''; public string $memberName = '';
public function mount(int $memberId): void public function mount(int $memberId): void
{ {
$workspace = app('current_workspace'); $member = WorkspaceMember::where('id', $memberId)->firstOrFail();
$member = WorkspaceMember::where('id', $memberId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->authorizeWorkspace($member->workspace_id);
$this->workspaceId = $member->workspace_id;
$this->memberId = $memberId; $this->memberId = $memberId;
$this->memberName = $member->user->name ?? ''; $this->memberName = $member->user->name ?? '';
} }
public function remove(): void public function remove(): void
{ {
$workspace = app('current_workspace');
WorkspaceMember::where('id', $this->memberId) WorkspaceMember::where('id', $this->memberId)
->where('workspace_id', $workspace->id) ->where('workspace_id', $this->workspaceId)
->firstOrFail() ->firstOrFail()
->delete(); ->delete();
@ -45,4 +46,13 @@ class RemoveMember extends ModalComponent
{ {
return view('livewire.modals.remove-member'); 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();
}
} }

View File

@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Modals;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class UtmBuilder extends ModalComponent
{
public string $baseUrl = '';
public string $utmSource = '';
public string $utmMedium = '';
public string $utmCampaign = '';
public string $utmContent = '';
public string $utmTerm = '';
public bool $copied = false;
public function getFullUrlProperty(): string
{
if (! $this->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');
}
}

View File

@ -4,11 +4,14 @@ namespace App\Livewire\Modals;
use App\Domains\Domain\Actions\VerifyDomain as VerifyDomainAction; use App\Domains\Domain\Actions\VerifyDomain as VerifyDomainAction;
use App\Domains\Domain\Models\Domain; use App\Domains\Domain\Models\Domain;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
class VerifyDomain extends ModalComponent class VerifyDomain extends ModalComponent
{ {
public int $workspaceId = 0;
public int $domainId = 0; public int $domainId = 0;
public string $hostname = ''; public string $hostname = '';
@ -17,11 +20,11 @@ class VerifyDomain extends ModalComponent
public function mount(int $domainId): void public function mount(int $domainId): void
{ {
$workspace = app('current_workspace'); $domain = Domain::where('id', $domainId)->firstOrFail();
$domain = Domain::where('id', $domainId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->authorizeWorkspace($domain->workspace_id);
$this->workspaceId = $domain->workspace_id;
$this->domainId = $domainId; $this->domainId = $domainId;
$this->hostname = $domain->hostname; $this->hostname = $domain->hostname;
$this->verificationToken = $domain->verification_token; $this->verificationToken = $domain->verification_token;
@ -29,9 +32,8 @@ class VerifyDomain extends ModalComponent
public function verify(): void public function verify(): void
{ {
$workspace = app('current_workspace');
$domain = Domain::where('id', $this->domainId) $domain = Domain::where('id', $this->domainId)
->where('workspace_id', $workspace->id) ->where('workspace_id', $this->workspaceId)
->firstOrFail(); ->firstOrFail();
$verified = (new VerifyDomainAction)->handle($domain); $verified = (new VerifyDomainAction)->handle($domain);
@ -54,4 +56,13 @@ class VerifyDomain extends ModalComponent
{ {
return view('livewire.modals.verify-domain'); 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();
}
} }

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Pages\Ai;
use App\Domains\Ai\Actions\GenerateAbVariants;
use Illuminate\View\View;
use Livewire\Attributes\Validate;
use Livewire\Component;
class AbGenerator extends Component
{
#[Validate('required|url|max:2048')]
public string $targetUrl = '';
public int $count = 3;
/** @var string[] */
public array $variants = [];
public bool $loading = false;
public function generate(): void
{
$this->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']);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Pages\Ai;
use App\Domains\Ai\Services\AiService;
use App\Domains\Link\Models\Link;
use Illuminate\View\View;
use Livewire\Component;
class Anomalies extends Component
{
public string $report = '';
public bool $loading = false;
public function detect(): void
{
$workspace = require_workspace();
$this->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']);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Pages\Ai;
use App\Domains\Ai\Services\AiService;
use App\Domains\Link\Models\Link;
use Illuminate\View\View;
use Livewire\Component;
class Insights extends Component
{
public string $insights = '';
public bool $loading = false;
public function generate(): void
{
$workspace = require_workspace();
$this->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']);
}
}

View File

@ -3,31 +3,79 @@
namespace App\Livewire\Pages\Analytics; namespace App\Livewire\Pages\Analytics;
use App\Domains\Analytics\Models\Click; use App\Domains\Analytics\Models\Click;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Link\Models\Link;
use Illuminate\View\View; use Illuminate\View\View;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
class Index extends 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<int, array<string, mixed>> */ /** @var array<int, array<string, mixed>> */
public array $recentClicks = []; public array $recentClicks = [];
public int $totalToday = 0;
public int $workspaceId = 0;
public function mount(): void public function mount(): void
{ {
/** @var Workspace $workspace */ $workspace = require_workspace();
$workspace = app('current_workspace');
$this->workspaceId = $workspace->id; $this->workspaceId = $workspace->id;
$this->totalToday = Click::where('workspace_id', $workspace->id) $this->totalToday = Click::where('workspace_id', $workspace->id)
->whereDate('clicked_at', today()) ->whereDate('clicked_at', today())
->count(); ->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<string, mixed> $data */ /** @param array<string, mixed> $data */
#[On('echo:workspace.{workspaceId}.analytics,click.recorded')] #[On('echo:workspace.{workspaceId}.analytics,click.recorded')]
public function handleNewClick(array $data): void public function handleNewClick(array $data): void
@ -39,28 +87,51 @@ class Index extends Component
public function render(): View public function render(): View
{ {
/** @var Workspace $workspace */ $workspace = require_workspace();
$workspace = app('current_workspace');
$totalClicks = Click::where('workspace_id', $workspace->id)->count(); $totalFiltered = $this->baseQuery()->count();
$last30 = Click::where('workspace_id', $workspace->id)
->where('clicked_at', '>=', now()->subDays(30))->count();
$byCountry = Click::where('workspace_id', $workspace->id) $allTime = Click::where('workspace_id', $workspace->id)->count();
$byCountry = (clone $this->baseQuery())
->whereNotNull('country') ->whereNotNull('country')
->selectRaw('country, COUNT(*) as total') ->selectRaw('country, COUNT(*) as total')
->groupBy('country') ->groupBy('country')
->orderByDesc('total') ->orderByDesc('total')
->limit(5) ->limit(10)
->get(); ->get();
$byDevice = Click::where('workspace_id', $workspace->id) $byDevice = (clone $this->baseQuery())
->selectRaw('device, COUNT(*) as total') ->selectRaw('device, COUNT(*) as total')
->groupBy('device') ->groupBy('device')
->orderByDesc('total') ->orderByDesc('total')
->get(); ->get();
return view('livewire.pages.analytics.index', compact('totalClicks', 'last30', 'byCountry', 'byDevice')) $countries = Click::where('workspace_id', $workspace->id)
->layout('layouts.nimuli-app', ['title' => 'Analytics']); ->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']);
} }
} }

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (\DB::getDriverName() === 'mysql') {
\DB::statement("ALTER TABLE qr_codes MODIFY COLUMN type ENUM('url','vcard','wifi','pdf','mp3','text','email','phone','sms','location') NOT NULL DEFAULT 'url'");
}
}
public function down(): void
{
if (\DB::getDriverName() === 'mysql') {
\DB::statement("ALTER TABLE qr_codes MODIFY COLUMN type ENUM('url','vcard','wifi','pdf','mp3','text') NOT NULL DEFAULT 'url'");
}
}
};

View File

@ -0,0 +1,65 @@
# Nimuli — User Acceptance Test (FIX 9)
Run after every deploy. All 19 items must pass before marking FIX 9 complete.
**Environment:** https://nimuli.com (or localhost)
**Precondition:** Logged in as `nexxo@nimuli.com`
---
## Layout & Navigation
- [ ] **1. Sidebar renders** — Sidebar visible on left, full width (~240px), correct nav items
- [ ] **2. Sidebar collapse** — Click collapse toggle (bottom of sidebar) → collapses to icon-only (~64px). Reload page → still collapsed (localStorage persisted).
- [ ] **3. Sidebar expand** — Click toggle again → expands. Tooltip on icon when collapsed.
- [ ] **4. Main content follows** — Content area shifts left/right smoothly (no overlap, no gap) when sidebar collapses/expands.
- [ ] **5. Theme toggle** — Click sun/moon icon in topbar → cycles dark → light → system. Theme persists after page reload.
- [ ] **6. SPA navigation** — Click 3+ nav links with `wire:navigate`. Sidebar and topbar stay mounted (no flash/reload). URL updates. Active link highlighted correctly.
---
## Modals — Create
- [ ] **7. Create Link** — Links page → "+ Create Link" button → modal opens. Type URL → slug auto-generates. Click 🔄 → new slug generated. Submit → link appears in list. Toast shown.
- [ ] **8. Create QR Code** — QR Codes page → "+ Create QR Code" → modal opens with workspace pre-filled. Submit → appears in list.
- [ ] **9. Create Bio Page** — Bio Pages page → "+ Create Bio Page" → modal opens. Submit → appears in list.
- [ ] **10. Add Domain** — Domains page → "+ Add Domain" → modal opens. (No submit needed; verify modal opens correctly.)
- [ ] **11. Invite Member** — Team page → "+ Invite Member" → modal opens. (Verify workspace context passed correctly — no crash.)
- [ ] **12. Add Webhook** — Settings → Webhooks → "+ Add Webhook" → modal opens. (Verify no `require_workspace()` crash.)
---
## Modals — Edit / Delete
- [ ] **13. Edit Link** — Links list → click edit on any link → EditLink modal opens with pre-filled data. Save → updated.
- [ ] **14. Delete Link** — Links list → click delete → DeleteLink confirm modal → confirm → link removed. Toast shown.
- [ ] **15. Edit Member Role** — Team → click role badge (non-owner) → EditMemberRole modal opens.
- [ ] **16. Remove Member** — Team → hover member row → "Remove" appears → click → RemoveMember modal.
---
## Notifications Bell
- [ ] **17. Bell renders** — Topbar shows bell icon. No hardcoded "3" badge (badge only appears when real unread notifications exist).
- [ ] **18. Bell dropdown** — Click bell → dropdown opens showing "No notifications" (or list if notifications exist). Click outside → closes.
---
## Security / Profile
- [ ] **19. Delete Account button** — Profile → Security tab → "Danger Zone" section visible with "Delete Account" button → click → DeleteAccount modal opens (verify no crash).
---
## Verify Script
Run before marking complete:
```bash
bash scripts/verify-modals.sh
# Expected: EXIT 0, all checks ✓
```
---
_Last updated: 2026-05-16_

217
package-lock.json generated
View File

@ -4,6 +4,9 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"qrcode": "^1.5.4"
},
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"axios": "^1.11.0", "axios": "^1.11.0",
@ -1170,7 +1173,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -1180,7 +1182,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@ -1226,6 +1227,15 @@
"node": ">= 0.4" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -1288,7 +1298,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -1301,7 +1310,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": { "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1380,6 +1397,12 @@
"node": ">=8" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1399,7 +1422,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/engine.io-client": { "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": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@ -1628,7 +1663,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@ -1763,7 +1797,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -2074,6 +2107,18 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "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": "^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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2163,6 +2253,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.14", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
@ -2212,6 +2311,23 @@
"tweetnacl": "^1.0.3" "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": { "node_modules/qrcode-svg": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz", "resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
@ -2222,16 +2338,87 @@
"qrcode-svg": "bin/qrcode-svg.js" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "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": { "node_modules/rollup": {
"version": "4.60.4", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
@ -2287,6 +2474,12 @@
"tslib": "^2.1.0" "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": { "node_modules/shell-quote": {
"version": "1.8.3", "version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
@ -2346,7 +2539,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@ -2361,7 +2553,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@ -2547,6 +2738,12 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@ -20,5 +20,8 @@
"qrcode-svg": "^1.1.0", "qrcode-svg": "^1.1.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"vite": "^7.0.7" "vite": "^7.0.7"
},
"dependencies": {
"qrcode": "^1.5.4"
} }
} }

View File

@ -0,0 +1,54 @@
<div class="relative" x-data="{ open: @entangle('open') }" @click.outside="open = false">
<button
wire:click="toggle"
class="w-8 h-8 flex items-center justify-center rounded-lg text-t2 hover:text-t1 hover:bg-s2 transition-colors"
aria-label="Notifications"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 1.5a5 5 0 00-5 5v2.5l-1 1.5h12l-1-1.5V6.5a5 5 0 00-5-5zM6.5 13.5a1.5 1.5 0 003 0"/>
</svg>
</button>
@if($this->unreadCount > 0)
<span class="absolute top-1 right-1 w-3.5 h-3.5 bg-blue rounded-full text-white text-[9px] font-medium flex items-center justify-center leading-none pointer-events-none">
{{ $this->unreadCount > 9 ? '9+' : $this->unreadCount }}
</span>
@endif
<div
x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95 translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
x-transition:leave-end="opacity-0 scale-95 translate-y-1"
class="absolute right-0 top-10 w-80 bg-s2 border border-white/[.06] rounded-xl shadow-xl z-50"
>
<div class="flex items-center justify-between px-4 py-3 border-b border-white/[.06]">
<span class="text-sm font-medium text-t1">Notifications</span>
@if($this->unreadCount > 0)
<button wire:click="markAllRead" class="text-xs text-blue hover:opacity-75 transition-opacity">
Mark all read
</button>
@endif
</div>
<div class="max-h-80 overflow-y-auto divide-y divide-white/[.04]">
@forelse($this->notifications as $n)
<div
wire:click="markRead('{{ $n->id }}')"
class="flex items-start gap-3 px-4 py-3 hover:bg-s3 transition-colors cursor-pointer {{ $n->read_at ? 'opacity-60' : '' }}"
>
<div class="w-1.5 h-1.5 rounded-full mt-1.5 flex-shrink-0 {{ $n->read_at ? 'bg-transparent' : 'bg-blue' }}"></div>
<div class="flex-1 min-w-0">
<p class="text-sm text-t1 leading-snug">{{ $n->data['message'] ?? $n->type }}</p>
<p class="text-xs text-t3 mt-0.5">{{ $n->created_at->diffForHumans() }}</p>
</div>
</div>
@empty
<div class="px-4 py-8 text-center text-sm text-t3">No notifications</div>
@endforelse
</div>
</div>
</div>

View File

@ -0,0 +1,54 @@
<div class="max-w-2xl mx-auto">
<div class="mb-6">
<h1 class="text-2xl font-bold text-t1">A/B Slug Generator</h1>
<p class="text-sm text-t3 mt-1">Generate memorable slug variants for A/B testing</p>
</div>
<div class="p-5 bg-s1 border border-white/[.06] rounded-xl space-y-4 mb-6">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Target URL *</label>
<input wire:model="targetUrl" type="url" placeholder="https://example.com/landing-page"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
@error('targetUrl') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Number of variants</label>
<select wire:model="count"
class="px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50">
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
<button wire:click="generate" wire:loading.attr="disabled" wire:target="generate"
class="w-full px-4 py-2.5 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-60 flex items-center justify-center gap-2">
<span wire:loading.remove wire:target="generate">
<x-heroicon-o-sparkles class="w-4 h-4 inline mr-1" />
Generate Variants
</span>
<span wire:loading wire:target="generate" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Generating…
</span>
</button>
</div>
@if($variants)
<div class="space-y-2">
<p class="text-xs text-t3 font-medium uppercase tracking-wide">Generated Variants</p>
@foreach($variants as $i => $variant)
<div class="flex items-center gap-3 p-3 bg-s1 border border-white/[.06] rounded-lg">
<span class="text-xs text-t3 w-5 font-mono">{{ chr(65 + $i) }}</span>
<span class="flex-1 font-mono text-sm text-t1">{{ $variant }}</span>
<span class="text-xs text-t3">nimu.li/<span class="text-blue">{{ $variant }}</span></span>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -0,0 +1,37 @@
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-t1">Anomaly Detection</h1>
<p class="text-sm text-t3 mt-1">Detect suspicious traffic patterns with AI</p>
</div>
<button wire:click="detect" wire:loading.attr="disabled" wire:target="detect"
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-60 flex items-center gap-2">
<span wire:loading.remove wire:target="detect">
<x-heroicon-o-shield-exclamation class="w-4 h-4 inline mr-1" />
Scan for Anomalies
</span>
<span wire:loading wire:target="detect" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Scanning…
</span>
</button>
</div>
@if($report)
<div class="p-5 bg-s1 border border-white/[.06] rounded-xl">
<div class="flex items-center gap-2 mb-3 text-amber text-xs font-medium">
<x-heroicon-o-shield-exclamation class="w-3.5 h-3.5" />
Anomaly Report
</div>
<div class="text-sm text-t1 whitespace-pre-wrap leading-relaxed">{{ $report }}</div>
</div>
@else
<div class="p-8 bg-s1 border border-white/[.06] rounded-xl text-center">
<x-heroicon-o-shield-exclamation class="w-10 h-10 mx-auto text-t3 mb-3" />
<p class="text-sm text-t2">Click "Scan for Anomalies" to analyze your link traffic patterns.</p>
</div>
@endif
</div>

View File

@ -0,0 +1,37 @@
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-t1">AI Insights</h1>
<p class="text-sm text-t3 mt-1">Analyze your link performance with AI</p>
</div>
<button wire:click="generate" wire:loading.attr="disabled" wire:target="generate"
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-60 flex items-center gap-2">
<span wire:loading.remove wire:target="generate">
<x-heroicon-o-sparkles class="w-4 h-4 inline mr-1" />
Generate Insights
</span>
<span wire:loading wire:target="generate" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Analyzing…
</span>
</button>
</div>
@if($insights)
<div class="p-5 bg-s1 border border-white/[.06] rounded-xl">
<div class="flex items-center gap-2 mb-3 text-purple text-xs font-medium">
<x-heroicon-o-sparkles class="w-3.5 h-3.5" />
AI Analysis
</div>
<div class="text-sm text-t1 whitespace-pre-wrap leading-relaxed">{{ $insights }}</div>
</div>
@else
<div class="p-8 bg-s1 border border-white/[.06] rounded-xl text-center">
<x-heroicon-o-sparkles class="w-10 h-10 mx-auto text-t3 mb-3" />
<p class="text-sm text-t2">Click "Generate Insights" to analyze your top 10 links with AI.</p>
</div>
@endif
</div>

View File

@ -1,49 +1,103 @@
<div> <div>
<div class="flex items-center justify-between mb-6"> {{-- Header --}}
<h1 class="text-2xl font-semibold text-t1">Analytics</h1> <div class="flex flex-wrap items-center justify-between gap-3 mb-6">
<div class="flex gap-2"> <h1 class="text-2xl font-bold text-t1">Analytics</h1>
<button class="px-3 py-1.5 text-xs bg-blue text-white rounded-lg">30d</button>
<button class="px-3 py-1.5 text-xs bg-s2 text-t2 rounded-lg hover:text-t1 border border-white/[.06]">7d</button> {{-- Range buttons --}}
<button class="px-3 py-1.5 text-xs bg-s2 text-t2 rounded-lg hover:text-t1 border border-white/[.06]">90d</button> <div class="flex gap-1.5">
@foreach(['24h' => '24h', '7d' => '7d', '30d' => '30d', '90d' => '90d'] as $key => $label)
<button wire:click="setRange('{{ $key }}')"
class="px-3 py-1.5 text-xs rounded-lg font-medium transition-all
{{ $range === $key
? 'bg-accent-gradient text-white'
: 'bg-s2 text-t2 border border-white/[.06] hover:text-t1' }}">
{{ $label }}
</button>
@endforeach
</div> </div>
</div> </div>
{{-- Filter bar --}}
<div class="flex flex-wrap gap-2 mb-6">
<select wire:model.live="country"
class="px-3 py-2 bg-s1 border border-white/[.06] rounded-lg text-xs text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 min-w-[130px]">
<option value="">All Countries</option>
@foreach($countries as $c)
<option value="{{ $c }}" @selected($country === $c)>{{ $c }}</option>
@endforeach
</select>
<select wire:model.live="device"
class="px-3 py-2 bg-s1 border border-white/[.06] rounded-lg text-xs text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 min-w-[130px]">
<option value="">All Devices</option>
@foreach($devices as $d)
<option value="{{ $d }}" @selected($device === $d)>{{ ucfirst($d) }}</option>
@endforeach
</select>
<select wire:model.live="linkId"
class="px-3 py-2 bg-s1 border border-white/[.06] rounded-lg text-xs text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 min-w-[150px]">
<option value="">All Links</option>
@foreach($links as $l)
<option value="{{ $l->ulid }}" @selected($linkId === $l->ulid)>{{ $l->slug }}{{ $l->title ? ' — ' . $l->title : '' }}</option>
@endforeach
</select>
@if($country || $device || $linkId)
<button wire:click="$set('country', ''); $set('device', ''); $set('linkId', '')"
class="px-3 py-2 text-xs text-red hover:opacity-80 transition-opacity flex items-center gap-1">
<x-heroicon-o-x-mark class="w-3 h-3" />
Clear filters
</button>
@endif
</div>
{{-- Summary metrics --}} {{-- Summary metrics --}}
<div class="grid grid-cols-2 gap-4 mb-6"> <div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div class="bg-s1 border border-white/[.06] rounded-xl p-5"> <div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Total Clicks</div> <div class="text-xs font-medium text-t2 uppercase tracking-wider">{{ strtoupper($range) }} Clicks</div>
<div class="text-3xl font-semibold font-mono text-t1 mt-2">{{ number_format($totalClicks) }}</div> <div class="text-3xl font-bold font-mono text-t1 mt-2">{{ number_format($totalFiltered) }}</div>
</div> </div>
<div class="bg-s1 border border-white/[.06] rounded-xl p-5"> <div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Last 30 Days</div> <div class="text-xs font-medium text-t2 uppercase tracking-wider">All-time Clicks</div>
<div class="text-3xl font-semibold font-mono text-t1 mt-2">{{ number_format($last30) }}</div> <div class="text-3xl font-bold font-mono text-t1 mt-2">{{ number_format($allTime) }}</div>
</div>
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Today</div>
<div class="text-3xl font-bold font-mono text-t1 mt-2">{{ number_format($totalToday) }}</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Top Countries --}} {{-- Top Countries --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-5"> <div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<h3 class="text-sm font-medium text-t1 mb-4">Top Countries</h3> <h3 class="text-sm font-semibold text-t1 mb-4">Top Countries</h3>
@forelse($byCountry as $row) @forelse($byCountry as $row)
<div class="flex items-center justify-between py-2 border-b border-white/[.03] last:border-0"> <div class="flex items-center justify-between py-2 border-b border-white/[.03] last:border-0">
<span class="text-sm text-t1">{{ $row->country }}</span> <button wire:click="$set('country', '{{ $row->country }}')"
class="text-sm text-t1 hover:text-blue transition-colors text-left {{ $country === $row->country ? 'text-blue font-medium' : '' }}">
{{ $row->country }}
</button>
<span class="text-sm font-mono text-t2">{{ number_format($row->total) }}</span> <span class="text-sm font-mono text-t2">{{ number_format($row->total) }}</span>
</div> </div>
@empty @empty
<p class="text-sm text-t3">No data yet</p> <p class="text-sm text-t3">No data for this period</p>
@endforelse @endforelse
</div> </div>
{{-- By Device --}} {{-- By Device --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-5"> <div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<h3 class="text-sm font-medium text-t1 mb-4">By Device</h3> <h3 class="text-sm font-semibold text-t1 mb-4">By Device</h3>
@forelse($byDevice as $row) @forelse($byDevice as $row)
<div class="flex items-center justify-between py-2 border-b border-white/[.03] last:border-0"> <div class="flex items-center justify-between py-2 border-b border-white/[.03] last:border-0">
<span class="text-sm text-t1 capitalize">{{ $row->device }}</span> <button wire:click="$set('device', '{{ $row->device }}')"
class="text-sm text-t1 capitalize hover:text-blue transition-colors text-left {{ $device === $row->device ? 'text-blue font-medium' : '' }}">
{{ $row->device ?? 'Unknown' }}
</button>
<span class="text-sm font-mono text-t2">{{ number_format($row->total) }}</span> <span class="text-sm font-mono text-t2">{{ number_format($row->total) }}</span>
</div> </div>
@empty @empty
<p class="text-sm text-t3">No data yet</p> <p class="text-sm text-t3">No data for this period</p>
@endforelse @endforelse
</div> </div>
</div> </div>

View File

@ -54,6 +54,9 @@ Route::middleware(['auth', 'verified', ResolveWorkspace::class])
Route::get('/settings/webhooks', Webhooks::class)->name('settings.webhooks'); Route::get('/settings/webhooks', Webhooks::class)->name('settings.webhooks');
Route::get('/billing', App\Livewire\Pages\Billing\Index::class)->name('billing.index'); Route::get('/billing', App\Livewire\Pages\Billing\Index::class)->name('billing.index');
Route::get('/team', Members::class)->name('team.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 // Bio page route — must be before the slug catch-all