dashboard/errors: Onboarding-Card „Erste Schritte" & Hub-Styling der Fehlerseiten

Neue aufklappbare "Erste Schritte"-Card als erste Card im Kunden-Dashboard,
die neue Nutzer durch Profil, Rechnungsadresse, Firma und erste PM führt und
sich ausblendet, sobald alle Schritte erledigt sind. Zusätzlich die
gemeinsame Fehlerseiten-Vorlage (404/403/419/500/503) an das Hub-Design
angepasst (Palette, Gold-Akzent, Hub-Blau-Button), weiterhin self-contained
ohne Vite-Assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-07-15 13:07:36 +00:00
parent 6f6cb13f2e
commit a54637835d
4 changed files with 307 additions and 28 deletions

View file

@ -0,0 +1,112 @@
@props([
/**
* Liste der Onboarding-Schritte.
*
* @var list<array{num: string, title: string, description: string, href: string, action: string, done: bool}>
*/
'steps' => [],
/** Anzahl bereits erledigter Schritte */
'completed' => 0,
/** Gesamtzahl der Schritte */
'total' => 0,
])
@php
$percent = $total > 0 ? (int) round(($completed / $total) * 100) : 0;
$allDone = $total > 0 && $completed >= $total;
@endphp
<article class="panel overflow-hidden" x-data="{ open: $persist(true).as('me-onboarding-open') }">
<button type="button" @click="open = !open"
class="panel-head w-full cursor-pointer select-none text-left transition-colors hover:bg-[color:var(--color-bg-elev)]"
:aria-expanded="open ? 'true' : 'false'">
<span class="flex min-w-0 items-center gap-3">
<span
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[5px]
border border-[color:var(--color-hub-soft-2)] bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.rocket-launch class="size-[17px]" />
</span>
<span class="min-w-0">
<span class="section-eyebrow">{{ __('Erste Schritte') }}</span>
<span class="mt-1 block text-[11.5px] leading-tight text-[color:var(--color-ink-3)]">
{{ __('In vier Schritten startklar — Schritt für Schritt, in Ihrem Tempo.') }}
</span>
</span>
</span>
<span class="flex flex-shrink-0 items-center gap-3">
<span class="badge {{ $allDone ? 'ok' : 'hub' }} dot">
{{ $completed }}/{{ $total }} {{ __('erledigt') }}
</span>
<flux:icon.chevron-down
class="size-[16px] text-[color:var(--color-ink-3)] transition-transform duration-200"
x-bind:class="open ? 'rotate-180' : ''" />
</span>
</button>
<div x-show="open" x-collapse x-cloak>
<div class="px-5 py-5">
{{-- Gesamtfortschritt --}}
<div class="mb-5 flex items-center gap-3">
<div class="h-[6px] flex-1 overflow-hidden rounded-full bg-[color:var(--color-bg-rule-2)]">
<span class="block h-full rounded-full bg-[color:var(--color-hub)] transition-[width] duration-500"
style="width: {{ max(3, $percent) }}%;"></span>
</div>
<span
class="font-mono text-[11px] font-semibold tabular-nums text-[color:var(--color-ink-2)]">{{ $percent }}&thinsp;%</span>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@foreach ($steps as $step)
<a href="{{ $step['href'] }}" wire:navigate
@class([
'group relative flex items-start gap-3 rounded-[5px] border px-4 py-3.5 transition-colors',
'border-[color:var(--color-ok)]/30 bg-[color:var(--color-ok-soft)]' => $step['done'],
'border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] hover:border-[color:var(--color-hub)]/50' => ! $step['done'],
])>
<span
@class([
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full font-mono text-[12px] font-bold',
'bg-[color:var(--color-ok)] text-white' => $step['done'],
'border border-[color:var(--color-hub-soft-2)] bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]' => ! $step['done'],
])>
@if ($step['done'])
<flux:icon.check class="size-[16px]" />
@else
{{ $step['num'] }}
@endif
</span>
<span class="min-w-0 flex-1">
<span
@class([
'block text-[13px] font-semibold leading-tight',
'text-[color:var(--color-ink-2)]' => $step['done'],
'text-[color:var(--color-ink)]' => ! $step['done'],
])>
{{ $step['title'] }}
</span>
<span class="mt-1 block text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
{{ $step['description'] }}
</span>
@if ($step['done'])
<span
class="mt-2 inline-flex items-center gap-1 text-[11px] font-semibold text-[color:var(--color-ok)]">
<flux:icon.check-circle class="size-[13px]" /> {{ __('Erledigt') }}
</span>
@else
<span
class="mt-2 inline-flex items-center gap-1 text-[11.5px] font-semibold text-[color:var(--color-hub)] underline-offset-[3px] group-hover:underline">
{{ $step['action'] }} →
</span>
@endif
</span>
</a>
@endforeach
</div>
</div>
</div>
</article>

View file

@ -11,23 +11,34 @@
<meta name="robots" content="noindex">
<title>@yield('code') · @yield('title') – {{ $domainName ?? 'Pressekonto' }}</title>
<style>
/* Hub-Palette gespiegelt aus resources/css/shared/design-tokens.css.
Bewusst als Inline-Kopie, damit die Fehlerseiten auch bei kaputtem
Asset-Build zuverlässig im richtigen Erscheinungsbild rendern. */
:root {
color-scheme: light dark;
--bg: #f6f7f8;
--bg: #f6f4ef;
--panel: #ffffff;
--ink: #1c2427;
--ink-2: #5b6b70;
--rule: #e3e7e9;
--accent: #526266;
--ink: #1a1f1c;
--ink-2: #3a413d;
--ink-3: #5a6360;
--rule: #e2ddd0;
--hub: #1a2540;
--hub-hover: #243152;
--accent: #b07a3a;
--accent-deep: #8a5e27;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161b1d;
--panel: #1f2629;
--ink: #e8ecee;
--ink-2: #9fb0b5;
--rule: #313b3f;
--accent: #82a0a7;
--bg: #0e1218;
--panel: #181d27;
--ink: #ece9e0;
--ink-2: #c9c5b8;
--ink-3: #8e8b82;
--rule: #2a3142;
--hub: #5a78c2;
--hub-hover: #6d8ad3;
--accent: #d9a560;
--accent-deep: #d9a560;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -42,41 +53,65 @@
padding: 24px;
}
main {
position: relative;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 8px;
border-radius: 6px;
padding: 48px 40px;
max-width: 460px;
width: 100%;
text-align: center;
overflow: hidden;
}
main::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent);
}
.code {
font-size: 13px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--ink-2);
letter-spacing: 0.22em;
color: var(--accent-deep);
text-transform: uppercase;
}
h1 { font-size: 22px; margin: 12px 0 10px; }
p { font-size: 14.5px; line-height: 1.6; color: var(--ink-2); }
h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
margin: 14px 0 10px;
}
p {
font-size: 14px;
line-height: 1.6;
color: var(--ink-3);
max-width: 340px;
margin: 0 auto;
}
a.home {
display: inline-block;
margin-top: 24px;
margin-top: 26px;
padding: 10px 22px;
border-radius: 6px;
background: var(--accent);
border-radius: 5px;
background: var(--hub);
color: #fff;
font-size: 14px;
font-size: 13.5px;
font-weight: 600;
text-decoration: none;
transition: background-color 0.15s ease;
}
a.home:hover { background: var(--hub-hover); }
.brand {
margin-top: 28px;
margin-top: 30px;
padding-top: 18px;
border-top: 1px solid var(--rule);
font-size: 12px;
letter-spacing: 0.08em;
color: var(--ink-2);
font-size: 11px;
letter-spacing: 0.14em;
color: var(--ink-3);
text-transform: uppercase;
}
</style>

View file

@ -50,9 +50,20 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
$profile = $user->profile;
$billingAddress = $user->billingAddress;
$profilePercent = $this->profileCompleteness($profile);
$billingPercent = $this->billingCompleteness($billingAddress);
$pressReleaseTotal = (int) ($stats->total ?? 0);
$companiesTotal = $context->companyCountFor($user);
$onboardingSteps = $this->onboardingSteps($profilePercent, $billingPercent, $selectedCompany, $companiesTotal, $pressReleaseTotal);
$onboardingCompleted = collect($onboardingSteps)->where('done', true)->count();
return [
'user' => $user,
'selectedCompany' => $selectedCompany,
'onboardingSteps' => $onboardingSteps,
'onboardingCompleted' => $onboardingCompleted,
'onboardingTotal' => count($onboardingSteps),
// Launch-Gate (WS-5): Solange billing.enforce_booking aus ist, ist
// dies immer false und der Hinweis bleibt unsichtbar. Bei scharfem
// Gate sehen Bestandskunden (grandfathered) den Banner nicht.
@ -64,12 +75,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
'draft' => (int) ($stats->draft ?? 0),
'deltaMonth' => (int) ($stats->current_month ?? 0) - (int) ($stats->previous_month ?? 0),
],
'profileCompleteness' => $this->profileCompleteness($profile),
'billingCompleteness' => $this->billingCompleteness($billingAddress),
'profileCompleteness' => $profilePercent,
'billingCompleteness' => $billingPercent,
'qualityHints' => $this->qualityHints($user, $profile, $billingAddress, $selectedCompany, $pressReleaseQuery),
'recent' => $recent,
'companies' => $context->latestCompaniesFor($user),
'companiesTotal' => $context->companyCountFor($user),
'companiesTotal' => $companiesTotal,
'bridgeStatus' => [
/* Heute hardcoded — perspektivisch aus echtem Sync-Service. */
'presseecho' => ['state' => 'connected', 'subline' => __('Archiv · Branchen-Tiefe')],
@ -104,6 +115,54 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
return $address?->completenessPercent() ?? 0;
}
/**
* Geführte „Erste Schritte" für neue Kunden. Jeder Schritt gilt als
* erledigt, sobald die zugehörigen Stammdaten vorhanden sind.
*
* @return list<array{num: string, icon: string, title: string, description: string, href: string, action: string, done: bool}>
*/
private function onboardingSteps(int $profilePercent, int $billingPercent, ?Company $selectedCompany, int $companiesTotal, int $pressReleaseTotal): array
{
return [
[
'num' => '01',
'icon' => 'user',
'title' => __('Profil vervollständigen'),
'description' => __('Hinterlegen Sie Anrede, Telefon und Adresse für eine saubere Kundenakte.'),
'href' => route('me.profile') . '#profil',
'action' => __('Profil öffnen'),
'done' => $profilePercent >= 100,
],
[
'num' => '02',
'icon' => 'archive-box',
'title' => __('Rechnungsadresse hinterlegen'),
'description' => __('Damit spätere Buchungen sauber abgerechnet werden können.'),
'href' => route('me.profile') . '#rechnungsadresse',
'action' => __('Adresse ergänzen'),
'done' => $billingPercent >= 100,
],
[
'num' => '03',
'icon' => 'building-office',
'title' => __('Firma zuordnen'),
'description' => __('Ordnen Sie Ihrem Konto eine Firma bzw. Pressestelle zu, für die Sie veröffentlichen.'),
'href' => route('me.press-kits.index'),
'action' => __('Firmen öffnen'),
'done' => $selectedCompany !== null || $companiesTotal > 0,
],
[
'num' => '04',
'icon' => 'newspaper',
'title' => __('Erste Pressemitteilung erstellen'),
'description' => __('Verfassen Sie Ihre erste Mitteilung und senden Sie sie zur Prüfung.'),
'href' => route('me.press-releases.create'),
'action' => __('Mitteilung erstellen'),
'done' => $pressReleaseTotal > 0,
],
];
}
/**
* @return list<array{icon: string, title: string, description: string, href: string, action: string, percent?: int}>
*/
@ -205,6 +264,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
</div>
</header>
{{-- ============== ERSTE SCHRITTE (Onboarding, aufklappbar) ============== --}}
@if ($onboardingCompleted < $onboardingTotal)
<x-portal.onboarding-guide :steps="$onboardingSteps" :completed="$onboardingCompleted" :total="$onboardingTotal" />
@endif
{{-- ============== BUCHUNGS-GATE (WS-5) ============== --}}
@if ($bookingRequired)
<section

View file

@ -50,6 +50,74 @@ test('empty-state with three-step intro shows when no press releases exist', fun
->assertSee(__('Zur Prüfung senden'));
});
test('the first-steps onboarding card appears for a fresh customer with all steps open', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$this->actingAs($customer);
LivewireVolt::test('customer.dashboard')
->assertSee(__('Erste Schritte'))
->assertSee('0/4 '.__('erledigt'))
->assertSee(__('Profil vervollständigen'))
->assertSee(__('Rechnungsadresse hinterlegen'))
->assertSee(__('Firma zuordnen'))
->assertSee(__('Erste Pressemitteilung erstellen'));
});
test('the first-steps onboarding card reflects completed steps in the counter', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
Company::factory()->create(['owner_user_id' => $customer->id]);
$this->actingAs($customer);
LivewireVolt::test('customer.dashboard')
->assertSee(__('Erste Schritte'))
->assertSee('1/4 '.__('erledigt'))
->assertSee(__('Erledigt'));
});
test('the first-steps onboarding card disappears once every step is complete', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
Profile::factory()->create([
'user_id' => $customer->id,
'salutation_key' => 'mr',
'first_name' => 'Test',
'last_name' => 'Customer',
'phone' => '+49 30 1234567',
'address' => 'Teststr. 1',
'country_code' => 'DE',
]);
BillingAddress::query()->create([
'user_id' => $customer->id,
'name' => 'Test Customer GmbH',
'address1' => 'Teststr. 1',
'postal_code' => '10115',
'city' => 'Berlin',
'country_code' => 'DE',
]);
$company = Company::factory()->create(['owner_user_id' => $customer->id]);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
]);
$this->actingAs($customer);
LivewireVolt::test('customer.dashboard')
->assertDontSee(__('Erste Schritte'));
});
test('press release list and KPI counts render when data exists', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);