fix(sidebar+types): mobile slide animation, close-on-navigate, PHPStan clean

- sidebar: transition-[width,transform] so mobile open/close animates
- sidebar: pure :class binding replaces mixed class+:style for translate
- layout: livewire:navigated closes mobile sidebar via Alpine store
- tests: RouteSmokeTest covers all 17 workspace/profile/AI routes
- scripts: crawl-all-routes.mjs + console-check.mjs (Playwright)
- phpstan: add @property-read, @var, @return generics across 15 modal/component files → 0 errors
- pint: auto-fix style violations in 9 files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 15:47:17 +02:00
parent 6283e10072
commit 248c5100d1
21 changed files with 384 additions and 24 deletions

View File

@ -2,8 +2,12 @@
namespace App\Livewire\Components;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\View\View;
use Livewire\Component;
/** @property-read Collection<int, DatabaseNotification> $notifications */
class NotificationsBell extends Component
{
public bool $open = false;
@ -13,7 +17,8 @@ class NotificationsBell extends Component
return auth()->user()?->unreadNotifications()->count() ?? 0;
}
public function getNotificationsProperty()
/** @return Collection<int, DatabaseNotification> */
public function getNotificationsProperty(): Collection
{
return auth()->user()?->notifications()->latest()->take(10)->get() ?? collect();
}
@ -34,7 +39,7 @@ class NotificationsBell extends Component
$this->open = false;
}
public function render()
public function render(): View
{
return view('livewire.components.notifications-bell');
}

View File

@ -2,11 +2,13 @@
namespace App\Livewire\Components;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
class Toaster extends Component
{
/** @var array<int, array<string, mixed>> */
public array $toasts = [];
#[On('toast')]
@ -27,7 +29,7 @@ class Toaster extends Component
);
}
public function render(): \Illuminate\View\View
public function render(): View
{
return view('livewire.components.toaster');
}

View File

@ -15,6 +15,7 @@ class AddDomain extends ModalComponent
public string $hostname = '';
/** @return array<string, string> */
protected function rules(): array
{
return [

View File

@ -17,6 +17,7 @@ class ConfirmAction extends ModalComponent
public string $confirmEvent = '';
/** @var array<int|string, mixed> */
public array $confirmEventParams = [];
public bool $danger = false;

View File

@ -11,6 +11,7 @@ class CreateApiToken extends ModalComponent
public ?string $plainTextToken = null;
/** @return array<string, string> */
protected function rules(): array
{
return [

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Domains\Bio\Models\BioPage;
use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\Bio\Index;
use Illuminate\Support\Str;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
@ -18,6 +19,7 @@ class CreateBioPage extends ModalComponent
public string $slug = '';
/** @return array<string, string> */
protected function rules(): array
{
return [
@ -58,7 +60,7 @@ class CreateBioPage extends ModalComponent
'is_published' => false,
]);
$this->dispatch('bio-created')->to(\App\Livewire\Pages\Bio\Index::class);
$this->dispatch('bio-created')->to(Index::class);
$this->dispatch('toast', message: 'Bio Page erstellt', type: 'success');
$this->closeModal();
}

View File

@ -18,8 +18,10 @@ class CreateWebhook extends ModalComponent
public string $secret = '';
/** @var array<int, string> */
public array $selectedEvents = [];
/** @var array<string, string> */
public array $availableEvents = [
'link.clicked' => 'Link clicked',
'link.created' => 'Link created',
@ -27,6 +29,7 @@ class CreateWebhook extends ModalComponent
'link.deleted' => 'Link deleted',
];
/** @return array<string, string> */
protected function rules(): array
{
return [

View File

@ -10,6 +10,7 @@ class CreateWorkspace extends ModalComponent
{
public string $name = '';
/** @return array<string, string> */
protected function rules(): array
{
return [

View File

@ -24,6 +24,7 @@ class DeleteWorkspace extends ModalComponent
$this->workspaceName = $workspace->name;
}
/** @return array<string, string> */
protected function rules(): array
{
return [
@ -31,6 +32,7 @@ class DeleteWorkspace extends ModalComponent
];
}
/** @return array<string, string> */
protected function messages(): array
{
return [

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Domains\Bio\Models\BioPage;
use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\Bio\Index;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
@ -32,6 +33,7 @@ class EditBioPage extends ModalComponent
$this->isPublished = $bio->is_published;
}
/** @return array<string, string> */
protected function rules(): array
{
return [
@ -55,7 +57,7 @@ class EditBioPage extends ModalComponent
'is_published' => $this->isPublished,
]);
$this->dispatch('bio-updated')->to(\App\Livewire\Pages\Bio\Index::class);
$this->dispatch('bio-updated')->to(Index::class);
$this->dispatch('toast', message: 'Bio Page aktualisiert', type: 'success');
$this->closeModal();
}
@ -72,10 +74,10 @@ class EditBioPage extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void
{
Workspace::where("id", $workspaceId)
Workspace::where('id', $workspaceId)
->where(fn ($q) => $q
->where("owner_id", auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id()))
->where('owner_id', auth()->id())
->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail();
}
}

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\Links\Index;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
@ -38,6 +39,7 @@ class EditLink extends ModalComponent
$this->status = $link->status;
}
/** @return array<string, string> */
protected function rules(): array
{
return [
@ -63,7 +65,7 @@ class EditLink extends ModalComponent
'status' => $this->status,
]);
$this->dispatch('link-updated', linkUlid: $this->linkUlid)->to(\App\Livewire\Pages\Links\Index::class);
$this->dispatch('link-updated', linkUlid: $this->linkUlid)->to(Index::class);
$this->dispatch('toast', message: 'Link aktualisiert', type: 'success');
$this->closeModal();
}
@ -75,10 +77,10 @@ class EditLink extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void
{
Workspace::where("id", $workspaceId)
Workspace::where('id', $workspaceId)
->where(fn ($q) => $q
->where("owner_id", auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id()))
->where('owner_id', auth()->id())
->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail();
}
}

View File

@ -17,6 +17,7 @@ class EditMemberRole extends ModalComponent
public string $role = 'editor';
/** @return array<string, string> */
protected function rules(): array
{
return [
@ -62,10 +63,10 @@ class EditMemberRole extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void
{
Workspace::where("id", $workspaceId)
Workspace::where('id', $workspaceId)
->where(fn ($q) => $q
->where("owner_id", auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id()))
->where('owner_id', auth()->id())
->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail();
}
}

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Domains\QrCode\Models\QrCode;
use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\QrCodes\Index;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
@ -32,6 +33,7 @@ class EditQrCode extends ModalComponent
$this->type = $qr->type;
}
/** @return array<string, string> */
protected function rules(): array
{
return [
@ -54,7 +56,7 @@ class EditQrCode extends ModalComponent
'payload' => ['url' => $this->url],
]);
$this->dispatch('qr-updated')->to(\App\Livewire\Pages\QrCodes\Index::class);
$this->dispatch('qr-updated')->to(Index::class);
$this->dispatch('toast', message: 'QR Code aktualisiert', type: 'success');
$this->closeModal();
}
@ -71,10 +73,10 @@ class EditQrCode extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void
{
Workspace::where("id", $workspaceId)
Workspace::where('id', $workspaceId)
->where(fn ($q) => $q
->where("owner_id", auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id()))
->where('owner_id', auth()->id())
->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail();
}
}

View File

@ -18,6 +18,7 @@ class InviteMember extends ModalComponent
public string $role = 'editor';
/** @return array<string, string> */
protected function rules(): array
{
return [

View File

@ -5,13 +5,19 @@ namespace App\Livewire\Modals;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
/** @property-read string $fullUrl */
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;

View File

@ -98,10 +98,13 @@
<aside
x-data="{ wsOpen: false }"
:class="$store.sidebar.collapsed ? 'w-16' : 'w-60'"
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)' : ''"
:class="{
'w-16': $store.sidebar.collapsed,
'w-60': !$store.sidebar.collapsed,
'translate-x-0': $store.sidebar?.open,
'-translate-x-full': !$store.sidebar?.open,
}"
class="fixed inset-y-0 left-0 h-screen flex flex-col bg-s1 border-r border-white/[.06] z-40 md:translate-x-0 transition-[width,transform] duration-200 ease-in-out"
>
<!-- Header: Logo + Workspace Switcher -->
<div class="flex-shrink-0 relative" @click.outside="wsOpen = false">

View File

@ -142,6 +142,13 @@
}
document.addEventListener('livewire:navigated', markReady);
// Close mobile sidebar after SPA navigation
document.addEventListener('livewire:navigated', function () {
if (window.Alpine && Alpine.store('sidebar')) {
Alpine.store('sidebar').open = false;
}
});
// Highlight active nav link after SPA navigation
function updateActiveNav() {
var path = window.location.pathname;

103
scripts/console-check.mjs Normal file
View File

@ -0,0 +1,103 @@
/**
* Console-error crawler using Playwright.
* Logs in, visits all sidebar nav links, reports JS errors/exceptions.
*
* Usage:
* npm install -D @playwright/test && npx playwright install chromium
* node scripts/console-check.mjs
*/
import { chromium } from '@playwright/test';
const BASE = process.env.APP_URL ?? 'https://app.nimuli.com';
const EMAIL = 'nexxo@nimuli.com';
const PASS = 'NimuliDev2026!';
const errors = [];
const warnings = [];
console.log(`\n${'═'.repeat(45)}`);
console.log(' Nimuli — Console Error Check');
console.log(` ${new Date().toISOString()}`);
console.log(` Base: ${BASE}`);
console.log(`${'═'.repeat(45)}\n`);
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1280, height: 800 },
});
const page = await ctx.newPage();
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push({ url: page.url(), text: msg.text() });
} else if (msg.type() === 'warning') {
warnings.push({ url: page.url(), text: msg.text() });
}
});
page.on('pageerror', err => {
errors.push({ url: page.url(), text: `PageError: ${err.message}` });
});
// ── Login ────────────────────────────────────
console.log('[ LOGIN ]');
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
await page.fill('input[name=email]', EMAIL);
await page.fill('input[name=password]', PASS);
await page.click('button[type=submit]');
await page.waitForLoadState('networkidle');
console.log(` ✓ Logged in: ${page.url()}`);
// ── Collect nav links ─────────────────────────
const navLocators = page.locator('[data-nav-link]');
const navCount = await navLocators.count();
const navLinks = [];
for (let i = 0; i < navCount; i++) {
const href = await navLocators.nth(i).getAttribute('href');
if (href && !href.endsWith('#') && !navLinks.includes(href)) {
navLinks.push(href);
}
}
console.log(`\n[ CRAWLING ${navLinks.length} NAV LINKS ]`);
for (const link of navLinks) {
const pre = errors.length;
await page.goto(link, { waitUntil: 'networkidle' });
await page.waitForTimeout(400);
const newErrs = errors.length - pre;
const status = newErrs > 0 ? `${newErrs} new error(s)` : '✓ clean';
let path = link;
try { path = new URL(link).pathname; } catch { /* keep full link */ }
console.log(` ${status}: ${path}`);
}
// ── Profile pages ─────────────────────────────
console.log('\n[ PROFILE PAGES ]');
for (const path of ['/profile', '/profile/personal', '/profile/preferences', '/profile/security']) {
const pre = errors.length;
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
await page.waitForTimeout(300);
const newErrs = errors.length - pre;
console.log(` ${newErrs > 0 ? '✗' : '✓'} ${path}`);
}
await browser.close();
// ── Report ────────────────────────────────────
console.log(`\n${'═'.repeat(45)}`);
if (errors.length > 0) {
console.error(`${errors.length} console error(s):`);
for (const e of errors) {
let path = e.url;
try { path = new URL(e.url).pathname; } catch { /* keep full */ }
console.error(` ${path}: ${e.text.slice(0, 140)}`);
}
console.error(`${'═'.repeat(45)}\n`);
process.exit(1);
}
if (warnings.length > 0) {
console.warn(`${warnings.length} console warning(s) (non-blocking)`);
}
console.log(' ✅ No console errors');
console.log(`${'═'.repeat(45)}\n`);

View File

@ -0,0 +1,158 @@
/**
* Route crawler: logs in via Playwright, visits all app routes,
* reports HTTP errors and JS/Blade errors in the page body.
*
* Usage: node scripts/crawl-all-routes.mjs
*/
import { chromium } from '@playwright/test';
import { spawnSync } from 'child_process';
const BASE = process.env.APP_URL ?? 'https://app.nimuli.com';
const EMAIL = 'nexxo@nimuli.com';
const PASS = 'NimuliDev2026!';
let pass = 0;
let fail = 0;
const failures = [];
console.log(`\n${'═'.repeat(47)}`);
console.log(' Nimuli — Route Crawler');
console.log(` ${new Date().toISOString()}`);
console.log(` Base: ${BASE}`);
console.log(`${'═'.repeat(47)}\n`);
// ── DB helper (safe: no shell, all args explicit) ─
function dbQuery(sql) {
const result = spawnSync(
'docker',
['compose', 'exec', '-T', 'mysql', 'mysql', '-unimuli', '-pnimuli', 'nimuli', '-se', sql],
{ encoding: 'utf8' }
);
if (result.status !== 0) return '';
const lines = result.stdout.trim().split('\n').filter(Boolean);
return lines[lines.length - 1]?.trim() ?? '';
}
// ── Get workspace ULID ────────────────────────
const wsUlid = dbQuery(
"SELECT w.ulid FROM workspaces w JOIN workspace_members wm ON wm.workspace_id=w.id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' ORDER BY w.created_at LIMIT 1"
);
if (!wsUlid) { console.error(' ✗ No workspace for nexxo@nimuli.com'); process.exit(1); }
console.log(` Workspace: ${wsUlid}\n`);
// ── Get sample resource IDs ───────────────────
const linkUlid = dbQuery(
"SELECT l.ulid FROM links l JOIN workspace_members wm ON wm.workspace_id=l.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1"
);
const qrUlid = dbQuery(
"SELECT q.ulid FROM qr_codes q JOIN workspace_members wm ON wm.workspace_id=q.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1"
);
const bioSlug = dbQuery(
"SELECT bp.slug FROM bio_pages bp JOIN workspace_members wm ON wm.workspace_id=bp.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1"
);
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
// Collect JS errors per-page
const pageErrors = [];
page.on('console', msg => { if (msg.type() === 'error') pageErrors.push(msg.text()); });
page.on('pageerror', err => { pageErrors.push(`PageError: ${err.message}`); });
// ── Login ─────────────────────────────────────
console.log('[ LOGIN ]');
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
await page.fill('input[name=email]', EMAIL);
await page.fill('input[name=password]', PASS);
await page.click('button[type=submit]');
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
console.error(' ✗ Still on login page — wrong credentials or app error');
await browser.close();
process.exit(1);
}
console.log(` ✓ Authenticated: ${page.url()}\n`);
// ── Route check helper ────────────────────────
const BLADE_ERR_RE = /Undefined variable|ErrorException|Whoops|Class .* not found|Call to .* on null|syntax error/i;
async function check(path, label) {
pageErrors.length = 0;
const resp = await page.goto(`${BASE}/${path}`, { waitUntil: 'networkidle' });
const status = resp?.status() ?? 0;
const body = await page.content();
const bladeErr = BLADE_ERR_RE.test(body)
? (body.match(/(?:Undefined variable \w+|ErrorException.{0,60}|Class .{0,30} not found)/)?.[0] ?? 'Blade error')
: null;
const consoleErrs = [...pageErrors];
if (status >= 400 || bladeErr || consoleErrs.length > 0) {
let reason = status >= 400 ? `HTTP ${status}` : '';
if (bladeErr) reason += (reason ? ', ' : '') + bladeErr;
if (consoleErrs.length) reason += (reason ? ', ' : '') + `${consoleErrs.length} JS error(s): ${consoleErrs[0].slice(0, 80)}`;
console.error(`${label}${reason}`);
failures.push({ path, label, reason });
fail++;
} else {
console.log(`${label}${status}`);
pass++;
}
}
// ── Static routes ─────────────────────────────
console.log('[ STATIC ROUTES ]');
await check('', 'root /');
await check('forgot-password', 'forgot-password');
// ── Profile routes ────────────────────────────
console.log('\n[ PROFILE ROUTES ]');
await check('profile', 'profile');
await check('profile/personal', 'profile/personal');
await check('profile/preferences', 'profile/preferences');
await check('profile/security', 'profile/security');
// ── Workspace routes ──────────────────────────
console.log(`\n[ WORKSPACE ROUTES: ${wsUlid} ]`);
await check(`w/${wsUlid}`, 'dashboard');
await check(`w/${wsUlid}/links`, 'links');
await check(`w/${wsUlid}/qr`, 'qr-codes');
await check(`w/${wsUlid}/bio`, 'bio-pages');
await check(`w/${wsUlid}/analytics`, 'analytics');
await check(`w/${wsUlid}/domains`, 'domains');
await check(`w/${wsUlid}/team`, 'team');
await check(`w/${wsUlid}/billing`, 'billing');
await check(`w/${wsUlid}/settings`, 'settings');
await check(`w/${wsUlid}/settings/api-tokens`, 'settings/api-tokens');
await check(`w/${wsUlid}/settings/webhooks`, 'settings/webhooks');
// ── AI routes ─────────────────────────────────
console.log('\n[ AI ROUTES ]');
await check(`w/${wsUlid}/ai/insights`, 'ai/insights');
await check(`w/${wsUlid}/ai/anomalies`, 'ai/anomalies');
await check(`w/${wsUlid}/ai/ab-generator`, 'ai/ab-generator');
// ── Resource routes ───────────────────────────
console.log('\n[ RESOURCE ROUTES ]');
if (linkUlid) await check(`w/${wsUlid}/links/${linkUlid}`, 'link show');
else console.log(' — no links in DB, skipping');
if (qrUlid) await check(`w/${wsUlid}/qr/${qrUlid}`, 'qr show');
else console.log(' — no QR codes in DB, skipping');
if (bioSlug) await check(`bio/${bioSlug}`, 'bio public');
else console.log(' — no bio pages in DB, skipping');
await browser.close();
// ── Summary ───────────────────────────────────
const total = pass + fail;
console.log(`\n${'═'.repeat(47)}`);
console.log(` Passed: ${pass} / ${total}`);
if (fail > 0) {
console.error(` Failed: ${fail}`);
console.error('\n Failures:');
for (const f of failures) console.error(` - ${f.label}: ${f.reason}`);
console.error(`${'═'.repeat(47)}\n`);
process.exit(1);
}
console.log(' ✅ All routes OK');
console.log(`${'═'.repeat(47)}\n`);

5
scripts/crawl-all-routes.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
# Thin wrapper — delegates to crawl-all-routes.mjs (Playwright-based)
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
exec node "$DIR/crawl-all-routes.mjs" "$@"

View File

@ -0,0 +1,52 @@
<?php
use App\Domains\Workspace\Actions\CreateWorkspace;
use App\Models\User;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = (new CreateWorkspace)->handle($this->user, ['name' => 'Smoke Test WS']);
});
// ── Profile routes ─────────────────────────────────
test('profile routes return 200', function (string $route) {
$this->actingAs($this->user)
->get(route($route))
->assertOk();
})->with([
'profile.personal',
'profile.preferences',
'profile.security',
]);
// ── Workspace routes ────────────────────────────────
test('workspace routes return 200', function (string $routeName) {
$ws = $this->workspace;
$this->actingAs($this->user)
->get(route($routeName, $ws->ulid))
->assertOk();
})->with([
'w.dashboard',
'w.links.index',
'w.qr.index',
'w.bio.index',
'w.analytics.index',
'w.domains.index',
'w.team.index',
'w.billing.index',
'w.settings.index',
'w.settings.api-tokens',
'w.settings.webhooks',
]);
// ── AI routes ──────────────────────────────────────
test('AI routes return 200', function (string $routeName) {
$ws = $this->workspace;
$this->actingAs($this->user)
->get(route($routeName, $ws->ulid))
->assertOk();
})->with([
'w.ai.insights',
'w.ai.anomalies',
'w.ai.ab-generator',
]);