feat(sidebar+profile+buttons): sidebar active state, 3-tab profile, consistent button variants

- Sidebar JS: DOMContentLoaded + livewire:navigated updateActiveNav, data-nav-link on links
- Profile: exactly 3 tabs (Personal Info, Preferences, Security) with wire:navigate
- Buttons: all primary actions use bg-accent-gradient text-white
- UTM Builder + copy-to-clipboard Livewire event handler in app layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 15:14:04 +02:00
parent 278bc42b37
commit 1ff4114674
29 changed files with 632 additions and 108 deletions

View File

@ -3,11 +3,14 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Services\CurrentWorkspace;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ResolveWorkspace class ResolveWorkspace
{ {
public function __construct(private CurrentWorkspace $current) {}
public function handle(Request $request, Closure $next): mixed public function handle(Request $request, Closure $next): mixed
{ {
$ulid = $request->route('workspace'); $ulid = $request->route('workspace');
@ -16,7 +19,7 @@ class ResolveWorkspace
return $next($request); return $next($request);
} }
$workspace = Workspace::where('ulid', $ulid)->firstOrFail(); $workspace = Workspace::where('ulid', is_string($ulid) ? $ulid : $ulid->ulid)->firstOrFail();
// 404 instead of 403 — don't leak existence across workspace boundaries // 404 instead of 403 — don't leak existence across workspace boundaries
$user = $request->user(); $user = $request->user();
@ -26,8 +29,8 @@ class ResolveWorkspace
abort(404); abort(404);
} }
$request->merge(['current_workspace' => $workspace]); $this->current->set($workspace);
app()->instance('current_workspace', $workspace); $request->route()->setParameter('workspace', $workspace);
return $next($request); return $next($request);
} }

View File

@ -11,7 +11,7 @@ class Overview extends Component
{ {
public function render(): View public function render(): View
{ {
$workspace = app('current_workspace'); $workspace = require_workspace();
// Hero metrics // Hero metrics
$today = Click::where('workspace_id', $workspace->id) $today = Click::where('workspace_id', $workspace->id)

View File

@ -13,7 +13,7 @@ class Index extends Component
public function mount(): void public function mount(): void
{ {
$ws = app('current_workspace'); $ws = current_workspace();
$this->workspaceName = $ws->name; $this->workspaceName = $ws->name;
$this->workspaceSlug = $ws->slug; $this->workspaceSlug = $ws->slug;
} }
@ -25,9 +25,9 @@ class Index extends Component
'workspaceSlug' => 'nullable|string|max:50|alpha_dash', 'workspaceSlug' => 'nullable|string|max:50|alpha_dash',
]); ]);
app('current_workspace')->update([ require_workspace()->update([
'name' => $this->workspaceName, 'name' => $this->workspaceName,
'slug' => $this->workspaceSlug ?: app('current_workspace')->slug, 'slug' => $this->workspaceSlug ?: require_workspace()->slug,
]); ]);
session()->flash('status', 'Workspace settings saved.'); session()->flash('status', 'Workspace settings saved.');

View File

@ -10,7 +10,7 @@ class Members extends Component
{ {
public function render(): View public function render(): View
{ {
$workspace = app('current_workspace'); $workspace = require_workspace();
$members = WorkspaceMember::with('user') $members = WorkspaceMember::with('user')
->where('workspace_id', $workspace->id) ->where('workspace_id', $workspace->id)

View File

@ -5,6 +5,7 @@ namespace App\Providers;
use App\Domains\Translation\Providers\DatabaseTranslationLoader; use App\Domains\Translation\Providers\DatabaseTranslationLoader;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Policies\WorkspacePolicy; use App\Domains\Workspace\Policies\WorkspacePolicy;
use App\Domains\Workspace\Services\CurrentWorkspace;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier; use Laravel\Cashier\Cashier;
@ -16,6 +17,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->singleton(CurrentWorkspace::class);
// TranslationServiceProvider is deferred and re-binds translation.loader when it // TranslationServiceProvider is deferred and re-binds translation.loader when it
// resolves. We extend 'translator' to swap in our DB loader after the instance is // resolves. We extend 'translator' to swap in our DB loader after the instance is
// created, then freeze it in the container so app('translation.loader') is consistent. // created, then freeze it in the container so app('translation.loader') is consistent.

37
app/helpers.php Normal file
View File

@ -0,0 +1,37 @@
<?php
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Services\CurrentWorkspace;
if (! function_exists('current_workspace')) {
function current_workspace(): ?Workspace
{
$ws = app(CurrentWorkspace::class)->get();
if ($ws) {
return $ws;
}
// Backward-compat fallback: tests and legacy code may bind via app()->instance()
if (app()->bound('current_workspace')) {
/** @var Workspace $ws */
$ws = app('current_workspace');
app(CurrentWorkspace::class)->set($ws);
return $ws;
}
return null;
}
}
if (! function_exists('require_workspace')) {
function require_workspace(): Workspace
{
$ws = current_workspace();
if (! $ws) {
throw new \RuntimeException('No current workspace set. Route missing workspace.resolve middleware?');
}
return $ws;
}
}

183
config/blade-icons.php Normal file
View File

@ -0,0 +1,183 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Icons Sets
|--------------------------------------------------------------------------
|
| With this config option you can define a couple of
| default icon sets. Provide a key name for your icon
| set and a combination from the options below.
|
*/
'sets' => [
// 'default' => [
//
// /*
// |-----------------------------------------------------------------
// | Icons Path
// |-----------------------------------------------------------------
// |
// | Provide the relative path from your app root to your SVG icons
// | directory. Icons are loaded recursively so there's no need to
// | list every sub-directory.
// |
// | Relative to the disk root when the disk option is set.
// |
// */
//
// 'path' => 'resources/svg',
//
// /*
// |-----------------------------------------------------------------
// | Filesystem Disk
// |-----------------------------------------------------------------
// |
// | Optionally, provide a specific filesystem disk to read
// | icons from. When defining a disk, the "path" option
// | starts relatively from the disk root.
// |
// */
//
// 'disk' => '',
//
// /*
// |-----------------------------------------------------------------
// | Default Prefix
// |-----------------------------------------------------------------
// |
// | This config option allows you to define a default prefix for
// | your icons. The dash separator will be applied automatically
// | to every icon name. It's required and needs to be unique.
// |
// */
//
// 'prefix' => 'icon',
//
// /*
// |-----------------------------------------------------------------
// | Fallback Icon
// |-----------------------------------------------------------------
// |
// | This config option allows you to define a fallback
// | icon when an icon in this set cannot be found.
// |
// */
//
// 'fallback' => '',
//
// /*
// |-----------------------------------------------------------------
// | Default Set Classes
// |-----------------------------------------------------------------
// |
// | This config option allows you to define some classes which
// | will be applied by default to all icons within this set.
// |
// */
//
// 'class' => '',
//
// /*
// |-----------------------------------------------------------------
// | Default Set Attributes
// |-----------------------------------------------------------------
// |
// | This config option allows you to define some attributes which
// | will be applied by default to all icons within this set.
// |
// */
//
// 'attributes' => [
// // 'width' => 50,
// // 'height' => 50,
// ],
//
// ],
],
/*
|--------------------------------------------------------------------------
| Global Default Classes
|--------------------------------------------------------------------------
|
| This config option allows you to define some classes which
| will be applied by default to all icons.
|
*/
'class' => '',
/*
|--------------------------------------------------------------------------
| Global Default Attributes
|--------------------------------------------------------------------------
|
| This config option allows you to define some attributes which
| will be applied by default to all icons.
|
*/
'attributes' => [
// 'width' => 50,
// 'height' => 50,
],
/*
|--------------------------------------------------------------------------
| Global Fallback Icon
|--------------------------------------------------------------------------
|
| This config option allows you to define a global fallback
| icon when an icon in any set cannot be found. It can
| reference any icon from any configured set.
|
*/
'fallback' => '',
/*
|--------------------------------------------------------------------------
| Components
|--------------------------------------------------------------------------
|
| These config options allow you to define some
| settings related to Blade Components.
|
*/
'components' => [
/*
|----------------------------------------------------------------------
| Disable Components
|----------------------------------------------------------------------
|
| This config option allows you to disable Blade components
| completely. It's useful to avoid performance problems
| when working with large icon libraries.
|
*/
'disabled' => false,
/*
|----------------------------------------------------------------------
| Default Icon Component Name
|----------------------------------------------------------------------
|
| This config option allows you to define the name
| for the default Icon class component.
|
*/
'default' => 'icon',
],
];

View File

@ -73,7 +73,7 @@ return [
RequestReceived::class => [ RequestReceived::class => [
...Octane::prepareApplicationForNextOperation(), ...Octane::prepareApplicationForNextOperation(),
...Octane::prepareApplicationForNextRequest(), ...Octane::prepareApplicationForNextRequest(),
// \App\Listeners\ResetCurrentWorkspace::class,
], ],
RequestHandled::class => [ RequestHandled::class => [
@ -82,6 +82,7 @@ return [
RequestTerminated::class => [ RequestTerminated::class => [
// FlushUploadedFiles::class, // FlushUploadedFiles::class,
\App\Listeners\ResetCurrentWorkspace::class,
], ],
TaskReceived::class => [ TaskReceived::class => [

View File

@ -53,5 +53,13 @@ document.addEventListener('alpine:init', () => {
}, },
}); });
Alpine.store('sidebar', { open: false }); Alpine.store('sidebar', {
open: false,
collapsed: localStorage.getItem('sidebarCollapsed') === 'true',
toggle() {
this.collapsed = ! this.collapsed;
localStorage.setItem('sidebarCollapsed', this.collapsed);
window.dispatchEvent(new CustomEvent('sidebar-toggled', { detail: { collapsed: this.collapsed } }));
},
});
}); });

View File

@ -1,5 +1,5 @@
@php @php
$workspace = app()->bound('current_workspace') ? app('current_workspace') : null; $workspace = current_workspace();
$workspaces = auth()->check() ? auth()->user()->workspaces()->orderBy('name')->get() : collect(); $workspaces = auth()->check() ? auth()->user()->workspaces()->orderBy('name')->get() : collect();
$wsUlid = $workspace?->ulid; $wsUlid = $workspace?->ulid;
@ -68,6 +68,13 @@
'match' => 'w.team.*', 'match' => 'w.team.*',
'icon' => 'team', 'icon' => 'team',
], ],
[
'label' => 'AI',
'route' => 'w.ai.insights',
'params' => [$wsUlid],
'match' => 'w.ai.*',
'icon' => 'ai',
],
] : [ ] : [
[ [
'label' => 'Dashboard', 'label' => 'Dashboard',
@ -90,12 +97,23 @@
@endphp @endphp
<aside <aside
x-show="$store.sidebar.open || $el.closest('[data-desktop-sidebar]') !== null" x-data="{ wsOpen: false }"
class="w-60 flex-shrink-0 bg-s1 border-r border-white/[.06] flex flex-col h-full" :class="$store.sidebar.collapsed ? 'w-16' : 'w-60'"
data-desktop-sidebar class="fixed inset-y-0 left-0 h-screen flex flex-col bg-s1 border-r border-white/[.06] z-40 transition-[width] duration-200 ease-in-out
-translate-x-full md:translate-x-0"
:style="$store.sidebar?.open ? 'transform: translateX(0)' : ''"
> >
<!-- Header: Logo + Workspace Switcher --> <!-- Header: Logo + Workspace Switcher -->
<div class="flex-shrink-0" x-data="{ wsOpen: false }"> <div class="flex-shrink-0 relative" @click.outside="wsOpen = false">
<!-- Collapsed state: centered logo acts as expand toggle -->
<div x-show="$store.sidebar.collapsed" class="flex items-center justify-center py-4 border-b border-white/[.06]">
<button @click="$store.sidebar.toggle()" title="Expand sidebar" class="flex items-center justify-center w-7 h-7 hover:opacity-75 transition-opacity">
<img src="/brand/logo-mark.svg" alt="Nimuli" class="w-7 h-7">
</button>
</div>
<!-- Expanded state: full header with workspace switcher -->
<div x-show="!$store.sidebar.collapsed">
<button <button
@click="wsOpen = !wsOpen" @click="wsOpen = !wsOpen"
class="w-full flex items-center gap-3 px-4 py-4 hover:bg-s2 transition-colors border-b border-white/[.06]" class="w-full flex items-center gap-3 px-4 py-4 hover:bg-s2 transition-colors border-b border-white/[.06]"
@ -124,7 +142,6 @@
x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1" x-transition:leave-end="opacity-0 -translate-y-1"
class="bg-s2 border-b border-white/[.06] py-1" class="bg-s2 border-b border-white/[.06] py-1"
@click.outside="wsOpen = false"
> >
@forelse($workspaces as $ws) @forelse($workspaces as $ws)
<a <a
@ -144,7 +161,7 @@
@endforelse @endforelse
<div class="border-t border-white/[.06] mt-1 pt-1"> <div class="border-t border-white/[.06] mt-1 pt-1">
<button <button
wire:click="$dispatch('openModal', {component: 'modals.create-workspace'})" @click="$dispatch('openModal', {component: 'modals.create-workspace'})"
class="flex items-center gap-2 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors w-full text-left"> class="flex items-center gap-2 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors w-full text-left">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5"> <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 3v10M3 8h10"/> <path stroke-linecap="round" stroke-linejoin="round" d="M8 3v10M3 8h10"/>
@ -154,6 +171,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Navigation --> <!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5"> <nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
@ -165,6 +183,8 @@
<a <a
href="{{ $url }}" href="{{ $url }}"
wire:navigate wire:navigate
data-nav-link="{{ $item['route'] }}"
:title="$store.sidebar.collapsed ? '{{ $item['label'] }}' : ''"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors group class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors group
{{ $isActive {{ $isActive
? 'bg-blue/10 text-blue' ? 'bg-blue/10 text-blue'
@ -207,16 +227,36 @@
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5"> <svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="5" r="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M1.5 14c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"/><circle cx="11.5" cy="5" r="1.5"/><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 13c0-1.8-1-3-2.5-3.5"/> <circle cx="6" cy="5" r="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M1.5 14c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"/><circle cx="11.5" cy="5" r="1.5"/><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 13c0-1.8-1-3-2.5-3.5"/>
</svg> </svg>
@elseif($item['icon'] === 'ai')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 1.5l1.5 3 3.5.5-2.5 2.5.5 3.5L8 9.5 5 11l.5-3.5L3 5l3.5-.5L8 1.5z"/>
</svg>
@endif @endif
</span> </span>
{{ $item['label'] }} <span x-show="!$store.sidebar.collapsed" class="truncate">{{ $item['label'] }}</span>
</a> </a>
@endforeach @endforeach
</nav> </nav>
<!-- Collapse Toggle -->
<div class="flex-shrink-0 border-t border-white/[.06] px-2 py-2">
<button
@click="$store.sidebar.toggle()"
:title="$store.sidebar.collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
class="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-t3 hover:text-t1 hover:bg-s2 transition-colors"
>
<span class="w-4 h-4 flex-shrink-0 transition-transform duration-200" :class="$store.sidebar.collapsed ? 'rotate-180' : ''">
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 3L5 8l5 5"/>
</svg>
</span>
<span x-show="!$store.sidebar.collapsed" class="text-xs">Collapse</span>
</button>
</div>
<!-- Plan Usage Box --> <!-- Plan Usage Box -->
@if($workspace && $plan) @if($workspace && $plan)
<div class="mx-3 mb-3 p-3 bg-s2 border border-white/[.06] rounded-lg"> <div x-show="!$store.sidebar.collapsed" class="mx-3 mb-3 p-3 bg-s2 border border-white/[.06] rounded-lg">
<div class="flex items-center justify-between mb-1.5"> <div class="flex items-center justify-between mb-1.5">
<span class="text-xs font-medium text-t2">{{ ucfirst($plan->name) }} Plan</span> <span class="text-xs font-medium text-t2">{{ ucfirst($plan->name) }} Plan</span>
@if(str_contains(strtolower($plan->name), 'free')) @if(str_contains(strtolower($plan->name), 'free'))
@ -235,15 +275,15 @@
<!-- User Footer --> <!-- User Footer -->
@auth @auth
<div class="flex-shrink-0 border-t border-white/[.06] p-3 flex items-center gap-3"> <div class="flex-shrink-0 border-t border-white/[.06] p-3 flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-full bg-blue flex items-center justify-center text-white text-xs font-semibold flex-shrink-0"> <div class="w-8 h-8 rounded-full bg-blue flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
{{ $initials ?: strtoupper(substr(auth()->user()->email, 0, 2)) }} {{ $initials ?: strtoupper(substr(auth()->user()->email, 0, 2)) }}
</div> </div>
<div class="flex-1 min-w-0"> <div x-show="!$store.sidebar.collapsed" class="flex-1 min-w-0">
<div class="text-xs font-medium text-t1 truncate">{{ auth()->user()->name }}</div> <div class="text-xs font-medium text-t1 truncate">{{ auth()->user()->name }}</div>
<div class="text-xs text-t3 truncate">{{ auth()->user()->email }}</div> <div class="text-xs text-t3 truncate">{{ auth()->user()->email }}</div>
</div> </div>
<form method="POST" action="{{ route('logout') }}"> <form x-show="!$store.sidebar.collapsed" method="POST" action="{{ route('logout') }}">
@csrf @csrf
<button <button
type="submit" type="submit"

View File

@ -19,7 +19,7 @@
{{ $cancelLabel }} {{ $cancelLabel }}
</button> </button>
<button wire:click="confirm" <button wire:click="confirm"
class="px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 {{ $danger ? 'bg-red text-white' : 'bg-blue text-white' }}"> class="px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 {{ $danger ? 'bg-red text-white' : 'bg-accent-gradient text-white' }}">
{{ $confirmLabel }} {{ $confirmLabel }}
</button> </button>
</div> </div>

View File

@ -15,7 +15,7 @@
<div class="flex justify-end mt-6"> <div class="flex justify-end mt-6">
<button wire:click="closeModal" <button wire:click="closeModal"
class="px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"> class="px-4 py-2 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Done Done
</button> </button>
</div> </div>
@ -31,7 +31,7 @@
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button wire:click="create" <button wire:click="create"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"> class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Token Create Token
</button> </button>
<button wire:click="closeModal" <button wire:click="closeModal"

View File

@ -33,7 +33,7 @@
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button wire:click="save" <button wire:click="save"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"> class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Webhook Create Webhook
</button> </button>
<button wire:click="closeModal" <button wire:click="closeModal"

View File

@ -10,7 +10,7 @@
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button wire:click="create" <button wire:click="create"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"> class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Workspace Create Workspace
</button> </button>
<button wire:click="closeModal" <button wire:click="closeModal"

View File

@ -37,7 +37,7 @@
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button wire:click="save" <button wire:click="save"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"> class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Save Changes Save Changes
</button> </button>
<button wire:click="closeModal" <button wire:click="closeModal"

View File

@ -18,7 +18,7 @@
Cancel Cancel
</button> </button>
<button wire:click="save" <button wire:click="save"
class="px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"> class="px-4 py-2 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Save Save
</button> </button>
</div> </div>

View File

@ -24,7 +24,7 @@
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button wire:click="invite" <button wire:click="invite"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"> class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Send Invite Send Invite
</button> </button>
<button wire:click="closeModal" <button wire:click="closeModal"

View File

@ -0,0 +1,80 @@
<div class="p-6">
<div class="flex items-center justify-between mb-5">
<div>
<h3 class="text-lg font-semibold text-t1">A/B Test Variants</h3>
<p class="text-xs text-t3 mt-0.5 font-mono truncate max-w-xs">{{ $linkTarget }}</p>
</div>
<button wire:click="addVariant" type="button"
class="px-3 py-1.5 bg-s2 border border-white/[.06] text-xs font-medium text-t1 rounded-lg hover:bg-s3 transition-colors flex items-center gap-1.5 {{ count($variants) >= 5 ? 'opacity-40 cursor-not-allowed' : '' }}"
@disabled(count($variants) >= 5)>
<x-heroicon-o-plus class="w-3.5 h-3.5" />
Add Variant
</button>
</div>
@error('variants') <p class="mb-4 text-xs text-red">{{ $message }}</p> @enderror
<div class="space-y-3">
@foreach($variants as $i => $variant)
<div class="p-3 bg-s2 border border-white/[.06] rounded-lg space-y-2">
<div class="flex items-center gap-2">
<div class="flex-1 min-w-0">
<input wire:model.live="variants.{{ $i }}.label" type="text" placeholder="Variant label"
class="block w-full px-2.5 py-1.5 bg-s3 border border-white/[.06] rounded text-xs text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50">
@error("variants.{$i}.label") <p class="mt-0.5 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<input wire:model.live="variants.{{ $i }}.weight" type="number" min="1" max="99"
class="w-14 px-2 py-1.5 bg-s3 border border-white/[.06] rounded text-xs text-t1 text-center focus:outline-none focus:ring-1 focus:ring-blue/50 font-mono">
<span class="text-xs text-t3">%</span>
</div>
@if(count($variants) > 2)
<button wire:click="removeVariant({{ $i }})" type="button"
class="p-1 text-t3 hover:text-red transition-colors flex-shrink-0">
<x-heroicon-o-x-mark class="w-3.5 h-3.5" />
</button>
@endif
</div>
<div>
<input wire:model="variants.{{ $i }}.target_url" type="url" placeholder="https://example.com/variant"
class="block w-full px-2.5 py-1.5 bg-s3 border border-white/[.06] rounded text-xs text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 font-mono">
@error("variants.{$i}.target_url") <p class="mt-0.5 text-xs text-red">{{ $message }}</p> @enderror
</div>
</div>
@endforeach
</div>
{{-- Weight summary --}}
<div class="mt-3 flex items-center justify-between">
<button wire:click="rebalanceWeights" type="button"
class="text-xs text-t2 hover:text-t1 transition-colors flex items-center gap-1">
<x-heroicon-o-arrow-path class="w-3 h-3" />
Auto-balance weights
</button>
@php $total = array_sum(array_column($variants, 'weight')); @endphp
<span class="text-xs font-mono {{ $total === 100 ? 'text-green' : 'text-red' }}">
{{ $total }}% total
</span>
</div>
<div class="flex gap-3 mt-5">
<button wire:click="save" wire:loading.attr="disabled" wire:target="save"
class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity disabled:opacity-60">
<span wire:loading.remove wire:target="save">Save Variants</span>
<span wire:loading wire:target="save" class="flex items-center justify-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>
Saving…
</span>
</button>
<button wire:click="closeModal"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
</div>
</div>

View File

@ -0,0 +1,70 @@
<div class="p-6 space-y-5">
<h3 class="text-lg font-semibold text-t1">UTM Builder</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Base URL *</label>
<input wire:model.live="baseUrl" type="url" placeholder="https://example.com/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">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">utm_source</label>
<input wire:model.live="utmSource" type="text" placeholder="google, newsletter…"
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">
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">utm_medium</label>
<input wire:model.live="utmMedium" type="text" placeholder="email, cpc…"
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">
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">utm_campaign</label>
<input wire:model.live="utmCampaign" type="text" placeholder="spring_sale…"
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">
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">utm_content</label>
<input wire:model.live="utmContent" type="text" placeholder="banner_v1…"
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">
</div>
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">utm_term</label>
<input wire:model.live="utmTerm" type="text" placeholder="running+shoes…"
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">
</div>
</div>
@if($fullUrl)
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Generated URL</label>
<div class="flex items-start gap-2 p-3 bg-s2 border border-white/[.06] rounded-lg">
<span class="flex-1 text-xs text-t1 font-mono break-all">{{ $fullUrl }}</span>
<button wire:click="copyToClipboard" type="button"
class="flex-shrink-0 p-1 text-t2 hover:text-t1 transition-colors">
@if($copied)
<x-heroicon-o-check class="w-4 h-4 text-green" />
@else
<x-heroicon-o-clipboard-document class="w-4 h-4" />
@endif
</button>
</div>
</div>
@endif
<div class="flex gap-3 pt-1">
<button wire:click="copyToClipboard" type="button" @disabled(!$fullUrl)
class="flex-1 px-4 py-2.5 bg-s2 border border-white/[.06] text-sm font-medium text-t1 rounded-lg hover:bg-s3 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2">
<x-heroicon-o-clipboard-document class="w-4 h-4" />
{{ $copied ? 'Copied!' : 'Copy URL' }}
</button>
<button wire:click="createLink" type="button" @disabled(!$fullUrl)
class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2">
<x-heroicon-o-plus class="w-4 h-4" />
Create Short Link
</button>
</div>
</div>

View File

@ -12,7 +12,7 @@
<div class="text-xl font-semibold text-t1">Free</div> <div class="text-xl font-semibold text-t1">Free</div>
<div class="text-sm text-t2 mt-1">20 / 50 links used</div> <div class="text-sm text-t2 mt-1">20 / 50 links used</div>
</div> </div>
<button class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90"> <button class="px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90">
Upgrade Plan Upgrade Plan
</button> </button>
</div> </div>
@ -48,7 +48,7 @@
@endforeach @endforeach
</ul> </ul>
@if(!$plan['current']) @if(!$plan['current'])
<button class="w-full px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90"> <button class="w-full px-4 py-2 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90">
Upgrade to {{ $plan['name'] }} Upgrade to {{ $plan['name'] }}
</button> </button>
@endif @endif

View File

@ -2,7 +2,7 @@
{{-- Quick Actions --}} {{-- Quick Actions --}}
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<a href="{{ route('w.links.index', request()->route('workspace')) }}" <a href="{{ route('w.links.index', request()->route('workspace')) }}"
class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"> class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
+ Create Link + Create Link
</a> </a>
<a href="{{ route('w.qr.index', request()->route('workspace')) }}" <a href="{{ route('w.qr.index', request()->route('workspace')) }}"

View File

@ -1,9 +1,14 @@
<div> <div>
@php $ws = current_workspace(); @endphp
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold text-t1">Custom Domains</h1> <h1 class="text-2xl font-semibold text-t1">Custom Domains</h1>
<button class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90"> @if($ws)
<button
@click="$dispatch('openModal', {component: 'modals.add-domain', arguments: {workspaceUlid: '{{ $ws->ulid }}'}})"
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
+ Add Domain + Add Domain
</button> </button>
@endif
</div> </div>
<div class="bg-s1 border border-white/[.06] rounded-xl overflow-hidden"> <div class="bg-s1 border border-white/[.06] rounded-xl overflow-hidden">
@ -18,9 +23,13 @@
</div> </div>
<h3 class="text-lg font-medium text-t1 mb-2">No custom domains</h3> <h3 class="text-lg font-medium text-t1 mb-2">No custom domains</h3>
<p class="text-sm text-t2 mb-6">Add a custom domain to brand your short links.</p> <p class="text-sm text-t2 mb-6">Add a custom domain to brand your short links.</p>
<button class="px-4 py-2.5 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90"> @if($ws)
<button
@click="$dispatch('openModal', {component: 'modals.add-domain', arguments: {workspaceUlid: '{{ $ws->ulid }}'}})"
class="px-4 py-2.5 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
Add your first domain Add your first domain
</button> </button>
@endif
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,9 +2,9 @@
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Profile</h1> <h1 class="text-2xl font-semibold text-t1">Profile</h1>
<nav class="flex gap-1 mt-4"> <nav class="flex gap-1 mt-4">
<a href="{{ route('profile.personal') }}" class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Personal Info</a> <a href="{{ route('profile.personal') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Personal Info</a>
<a href="{{ route('profile.preferences') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Preferences</a> <a href="{{ route('profile.preferences') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Preferences</a>
<a href="{{ route('profile.security') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Security</a> <a href="{{ route('profile.security') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Security</a>
</nav> </nav>
</div> </div>
@ -28,7 +28,7 @@
@error('email') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror @error('email') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div> </div>
<button wire:click="save" <button wire:click="save"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"> class="px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Save Changes Save Changes
</button> </button>
</div> </div>

View File

@ -2,9 +2,9 @@
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Profile</h1> <h1 class="text-2xl font-semibold text-t1">Profile</h1>
<nav class="flex gap-1 mt-4"> <nav class="flex gap-1 mt-4">
<a href="{{ route('profile.personal') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Personal Info</a> <a href="{{ route('profile.personal') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Personal Info</a>
<a href="{{ route('profile.preferences') }}" class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Preferences</a> <a href="{{ route('profile.preferences') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Preferences</a>
<a href="{{ route('profile.security') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Security</a> <a href="{{ route('profile.security') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Security</a>
</nav> </nav>
</div> </div>
@ -38,7 +38,7 @@
</div> </div>
</div> </div>
<button wire:click="save" <button wire:click="save"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90"> class="px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90">
Save Preferences Save Preferences
</button> </button>
</div> </div>

View File

@ -2,9 +2,9 @@
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Profile</h1> <h1 class="text-2xl font-semibold text-t1">Profile</h1>
<nav class="flex gap-1 mt-4"> <nav class="flex gap-1 mt-4">
<a href="{{ route('profile.personal') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Personal Info</a> <a href="{{ route('profile.personal') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Personal Info</a>
<a href="{{ route('profile.preferences') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Preferences</a> <a href="{{ route('profile.preferences') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Preferences</a>
<a href="{{ route('profile.security') }}" class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Security</a> <a href="{{ route('profile.security') }}" wire:navigate class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Security</a>
</nav> </nav>
</div> </div>
@ -12,7 +12,7 @@
<div class="mb-4 p-3 rounded-lg bg-green/10 border border-green/20 text-sm text-green">{{ session('status') }}</div> <div class="mb-4 p-3 rounded-lg bg-green/10 border border-green/20 text-sm text-green">{{ session('status') }}</div>
@endif @endif
<div class="bg-s1 border border-white/[.06] rounded-xl p-6"> <div class="bg-s1 border border-white/[.06] rounded-xl p-6 mb-6">
<h3 class="text-sm font-medium text-t1 mb-5">Change Password</h3> <h3 class="text-sm font-medium text-t1 mb-5">Change Password</h3>
<div class="space-y-4 max-w-sm"> <div class="space-y-4 max-w-sm">
<div> <div>
@ -33,9 +33,20 @@
class="block w-full 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"> class="block w-full 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">
</div> </div>
<button wire:click="changePassword" <button wire:click="changePassword"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90"> class="px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90">
Change Password Change Password
</button> </button>
</div> </div>
</div> </div>
<!-- Danger Zone -->
<div class="bg-s1 border border-red/20 rounded-xl p-6">
<h3 class="text-sm font-medium text-red mb-2">Danger Zone</h3>
<p class="text-sm text-t2 mb-4">Permanently delete your account and all associated data. This cannot be undone.</p>
<button
@click="$dispatch('openModal', {component: 'modals.delete-account'})"
class="px-4 py-2.5 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Delete Account
</button>
</div>
</div> </div>

View File

@ -34,7 +34,7 @@
</div> </div>
</div> </div>
<button wire:click="saveWorkspace" <button wire:click="saveWorkspace"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90"> class="px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90">
Save Changes Save Changes
</button> </button>
</div> </div>

View File

@ -1,4 +1,5 @@
<div class="max-w-3xl"> <div class="max-w-3xl">
@php $ws = current_workspace(); @endphp
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Settings</h1> <h1 class="text-2xl font-semibold text-t1">Settings</h1>
<nav class="flex gap-1 mt-4"> <nav class="flex gap-1 mt-4">
@ -13,9 +14,13 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<p class="text-sm text-t2">Receive HTTP POST notifications for workspace events</p> <p class="text-sm text-t2">Receive HTTP POST notifications for workspace events</p>
<button class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90"> @if($ws)
<button
@click="$dispatch('openModal', {component: 'modals.create-webhook', arguments: {workspaceUlid: '{{ $ws->ulid }}'}})"
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
+ Add Webhook + Add Webhook
</button> </button>
@endif
</div> </div>
<div class="bg-s1 border border-white/[.06] rounded-xl p-12 text-center"> <div class="bg-s1 border border-white/[.06] rounded-xl p-12 text-center">

View File

@ -1,12 +1,17 @@
<div> <div>
@php $ws = current_workspace(); @endphp
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h1 class="text-2xl font-semibold text-t1">Team</h1> <h1 class="text-2xl font-semibold text-t1">Team</h1>
<p class="text-sm text-t2 mt-1">Manage workspace members and permissions</p> <p class="text-sm text-t2 mt-1">Manage workspace members and permissions</p>
</div> </div>
<button class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90"> @if($ws)
<button
@click="$dispatch('openModal', {component: 'modals.invite-member', arguments: {workspaceUlid: '{{ $ws->ulid }}'}})"
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
+ Invite Member + Invite Member
</button> </button>
@endif
</div> </div>
<div class="bg-s1 border border-white/[.06] rounded-xl overflow-hidden"> <div class="bg-s1 border border-white/[.06] rounded-xl overflow-hidden">
@ -16,7 +21,7 @@
<div class="divide-y divide-white/[.04]"> <div class="divide-y divide-white/[.04]">
@forelse($members as $member) @forelse($members as $member)
<div class="flex items-center gap-4 px-5 py-4"> <div class="flex items-center gap-4 px-5 py-4 group">
{{-- Avatar --}} {{-- Avatar --}}
<div class="w-9 h-9 rounded-full bg-blue flex items-center justify-center text-white text-sm font-semibold flex-shrink-0"> <div class="w-9 h-9 rounded-full bg-blue flex items-center justify-center text-white text-sm font-semibold flex-shrink-0">
{{ strtoupper(substr($member->user->name ?? $member->user->email, 0, 1)) }} {{ strtoupper(substr($member->user->name ?? $member->user->email, 0, 1)) }}
@ -27,13 +32,21 @@
<div class="text-xs text-t2">{{ $member->user->email }}</div> <div class="text-xs text-t2">{{ $member->user->email }}</div>
</div> </div>
{{-- Role --}} {{-- Role --}}
<span class="px-2.5 py-1 rounded-full text-xs font-medium <button
{{ $member->role === 'owner' ? 'bg-amber/10 text-amber' : ($member->role === 'admin' ? 'bg-blue/10 text-blue' : 'bg-white/[.06] text-t2') }}">
{{ ucfirst($member->role) }}
</span>
{{-- Actions --}}
@if($member->role !== 'owner') @if($member->role !== 'owner')
<button class="text-t3 text-xs hover:text-red transition-colors">Remove</button> @click="$dispatch('openModal', {component: 'modals.edit-member-role', arguments: {memberId: {{ $member->id }}}})"
@endif
class="px-2.5 py-1 rounded-full text-xs font-medium transition-opacity
{{ $member->role === 'owner' ? 'bg-amber/10 text-amber cursor-default' : ($member->role === 'admin' ? 'bg-blue/10 text-blue hover:opacity-75 cursor-pointer' : 'bg-white/[.06] text-t2 hover:opacity-75 cursor-pointer') }}">
{{ ucfirst($member->role) }}
</button>
{{-- Remove --}}
@if($member->role !== 'owner')
<button
@click="$dispatch('openModal', {component: 'modals.remove-member', arguments: {memberId: {{ $member->id }}}})"
class="opacity-0 group-hover:opacity-100 text-t3 text-xs hover:text-red transition-all">
Remove
</button>
@endif @endif
</div> </div>
@empty @empty

View File

@ -147,6 +147,67 @@ else
fail "nexxo@nimuli.com not found — run: php artisan db:seed" fail "nexxo@nimuli.com not found — run: php artisan db:seed"
fi fi
# ── 10. Modal trigger coverage ────────────────
echo ""
echo "[ 10 ] Modal trigger coverage"
declare -A TRIGGERS=(
["resources/views/livewire/pages/links/index.blade.php"]="create-link"
["resources/views/livewire/pages/qr-codes/index.blade.php"]="create-qr-code"
["resources/views/livewire/pages/bio/index.blade.php"]="create-bio-page"
["resources/views/livewire/pages/domains/index.blade.php"]="add-domain"
["resources/views/livewire/pages/team/members.blade.php"]="invite-member"
["resources/views/livewire/pages/settings/webhooks.blade.php"]="create-webhook"
["resources/views/livewire/pages/profile/security.blade.php"]="delete-account"
)
for view in "${!TRIGGERS[@]}"; do
modal="${TRIGGERS[$view]}"
if [ -f "$view" ] && grep -q "$modal" "$view"; then
ok "$(basename "$view" .blade.php)$modal"
elif [ ! -f "$view" ]; then
fail "VIEW MISSING: $view"
else
fail "$(basename "$view" .blade.php) missing trigger for $modal"
fi
done
# ── 11. Sidebar collapse store ─────────────────
echo ""
echo "[ 11 ] Sidebar Alpine store"
if grep -q "collapsed" "resources/js/bootstrap.js"; then
ok "sidebar.collapsed in Alpine store"
else
fail "sidebar.collapsed missing from Alpine store"
fi
if grep -q "sidebarCollapsed" "resources/js/bootstrap.js"; then
ok "localStorage.sidebarCollapsed persisted"
else
fail "localStorage persistence missing from sidebar store"
fi
# ── 12. CurrentWorkspace service ──────────────
echo ""
echo "[ 12 ] Octane-safe CurrentWorkspace"
if [ -f "app/Domains/Workspace/Services/CurrentWorkspace.php" ]; then
ok "CurrentWorkspace service exists"
else
fail "CurrentWorkspace service MISSING"
fi
if [ -f "app/Listeners/ResetCurrentWorkspace.php" ]; then
ok "ResetCurrentWorkspace listener exists"
else
fail "ResetCurrentWorkspace listener MISSING"
fi
if [ -f "app/helpers.php" ]; then
ok "app/helpers.php exists"
else
fail "app/helpers.php MISSING"
fi
if grep -q "helpers.php" "composer.json"; then
ok "helpers.php autoloaded in composer.json"
else
fail "helpers.php not autoloaded in composer.json"
fi
# ── Summary ─────────────────────────────────── # ── Summary ───────────────────────────────────
echo "" echo ""
echo "═══════════════════════════════════════════" echo "═══════════════════════════════════════════"