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:
parent
74f71f7a58
commit
700f5e6131
4 changed files with 307 additions and 28 deletions
112
resources/views/components/portal/onboarding-guide.blade.php
Normal file
112
resources/views/components/portal/onboarding-guide.blade.php
Normal 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 }} %</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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue