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
parent
1ff4114674
commit
257d1b94f1
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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_
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue