diff --git a/app/Http/Controllers/Api/Tenant/OnboardingController.php b/app/Http/Controllers/Api/Tenant/OnboardingController.php new file mode 100644 index 0000000..54b36ba --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/OnboardingController.php @@ -0,0 +1,125 @@ +resolveTenant($request); + + if (! $tenant) { + return response()->json([ + 'error' => 'tenant_context_missing', + 'message' => 'Der Tenant-Kontext konnte nicht ermittelt werden.', + ], Response::HTTP_UNAUTHORIZED); + } + + $status = TenantOnboardingState::status($tenant); + $settings = $tenant->settings ?? []; + + return response()->json([ + 'steps' => [ + 'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'), + 'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'), + 'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'), + 'branding_completed' => (bool) ($status['palette'] ?? false), + 'tasks_configured' => (bool) ($status['packages'] ?? false), + 'event_created' => (bool) ($status['event'] ?? false), + 'invite_created' => (bool) ($status['invite'] ?? false), + ], + ]); + } + + public function store(Request $request): JsonResponse + { + $tenant = $this->resolveTenant($request); + + if (! $tenant) { + return response()->json([ + 'error' => 'tenant_context_missing', + 'message' => 'Der Tenant-Kontext konnte nicht ermittelt werden.', + ], Response::HTTP_UNAUTHORIZED); + } + + $payload = $request->validate([ + 'step' => ['required', 'string'], + 'meta' => ['array'], + ]); + + $step = $payload['step']; + $meta = $payload['meta'] ?? []; + + $settings = $tenant->settings ?? []; + + switch ($step) { + case 'admin_app_opened': + Arr::set($settings, 'onboarding.admin_app_opened_at', Carbon::now()->toIso8601String()); + break; + + case 'event_created': + Arr::set($settings, 'onboarding.primary_event_id', Arr::get($meta, 'event_id')); + break; + + case 'package_selected': + Arr::set($settings, 'onboarding.selected_packages', Arr::get($meta, 'packages')); + break; + + case 'branding_configured': + Arr::set($settings, 'onboarding.branding_set', true); + break; + + case 'invite_created': + Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String()); + break; + + case 'completed': + TenantOnboardingState::markCompleted($tenant, $meta); + break; + + default: + Log::info('[TenantOnboarding] Unbekannter Schritt gemeldet', [ + 'tenant_id' => $tenant->id, + 'step' => $step, + ]); + } + + if ($step !== 'completed') { + $tenant->forceFill(['settings' => $settings])->save(); + } + + return response()->json([ + 'message' => 'Onboarding-Schritt aktualisiert.', + ]); + } + + private function resolveTenant(Request $request): ?Tenant + { + try { + $tenantId = $request->attributes->get('tenant_id'); + + if ($tenantId) { + return Tenant::query()->find($tenantId); + } + + return TenantAuth::resolveTenant($request); + } catch (\Throwable $exception) { + Log::warning('[TenantOnboarding] Tenant konnte nicht ermittelt werden', [ + 'error' => $exception->getMessage(), + ]); + + return null; + } + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 60d7622..f18a59b 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -5,9 +5,12 @@ namespace App\Http\Controllers; use App\Models\Event; use App\Models\PackagePurchase; use App\Models\Tenant; +use App\Models\TenantPackage; use App\Services\Tenant\DashboardSummaryService; +use App\Support\TenantOnboardingState; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Inertia\Inertia; use Inertia\Response; @@ -32,6 +35,9 @@ class DashboardController extends Controller : collect(); $activePackage = $summary['active_package'] ?? null; + $onboarding = $tenant instanceof Tenant + ? $this->buildOnboardingSteps($tenant, $summary ?? []) + : []; return Inertia::render('dashboard', [ 'metrics' => $summary, @@ -41,7 +47,6 @@ class DashboardController extends Controller 'tenant' => $tenant ? [ 'id' => $tenant->id, 'name' => $tenant->name, - 'eventCreditsBalance' => $tenant->event_credits_balance, 'subscriptionStatus' => $tenant->subscription_status, 'subscriptionExpiresAt' => optional($tenant->subscription_expires_at)->toIso8601String(), 'activePackage' => $activePackage ? [ @@ -49,12 +54,14 @@ class DashboardController extends Controller 'price' => $activePackage['price'] ?? null, 'expiresAt' => $activePackage['expires_at'] ?? null, 'remainingEvents' => $activePackage['remaining_events'] ?? null, + 'isActive' => (bool) ($activePackage['is_active'] ?? false), ] : null, ] : null, 'emailVerification' => [ 'mustVerify' => $user instanceof MustVerifyEmail, 'verified' => $user?->hasVerifiedEmail() ?? false, ], + 'onboarding' => $onboarding, ]); } @@ -84,23 +91,120 @@ class DashboardController extends Controller private function collectRecentPurchases(Tenant $tenant): Collection { - return $tenant->purchases() - ->with('package') - ->latest('purchased_at') - ->limit(6) - ->get() - ->map(function (PackagePurchase $purchase): array { - return [ - 'id' => $purchase->id, - 'packageName' => $purchase->package?->getNameForLocale(app()->getLocale()) - ?? $purchase->package?->name - ?? __('Unknown package'), - 'price' => $purchase->price !== null ? (float) $purchase->price : null, - 'purchasedAt' => optional($purchase->purchased_at)->toIso8601String(), - 'type' => $purchase->type, - 'provider' => $purchase->provider, - ]; - }); + $entries = collect( + $tenant->purchases() + ->with('package') + ->latest('purchased_at') + ->limit(6) + ->get() + ->map(function (PackagePurchase $purchase): array { + return [ + 'id' => $purchase->id, + 'packageName' => $purchase->package?->getNameForLocale(app()->getLocale()) + ?? $purchase->package?->name + ?? __('Unknown package'), + 'price' => $purchase->price !== null ? (float) $purchase->price : null, + 'purchasedAt' => optional($purchase->purchased_at)->toIso8601String(), + 'type' => $purchase->type, + 'provider' => $purchase->provider, + 'source' => 'purchase', + 'isActive' => null, + ]; + }) + ->all() + ); + + if ($entries->isEmpty()) { + $packageEntries = collect( + $tenant->tenantPackages() + ->with('package') + ->orderByDesc('purchased_at') + ->limit(6) + ->get() + ->map(function (TenantPackage $package): array { + return [ + 'id' => $package->id, + 'packageName' => $package->package?->getNameForLocale(app()->getLocale()) + ?? $package->package?->name + ?? __('Unknown package'), + 'price' => $package->price !== null ? (float) $package->price : null, + 'purchasedAt' => optional($package->purchased_at)->toIso8601String(), + 'type' => 'tenant_package', + 'provider' => 'internal', + 'source' => 'tenant_package', + 'isActive' => (bool) $package->active, + ]; + }) + ->all() + ); + + $entries = $entries->merge($packageEntries); + } + + return $entries + ->sortByDesc('purchasedAt') + ->values() + ->take(6); + } + + private function buildOnboardingSteps(Tenant $tenant, array $summary): array + { + $status = TenantOnboardingState::status($tenant); + $settings = $tenant->settings ?? []; + + $adminAppOpened = (bool) Arr::get($settings, 'onboarding.admin_app_opened_at') + || $tenant->last_activity_at !== null; + + $hasEvent = (bool) ($status['event'] ?? false); + $hasInvite = (bool) ($status['invite'] ?? false); + $hasBranding = (bool) ($status['palette'] ?? false) + || (bool) Arr::get($settings, 'onboarding.branding_set', false); + $hasTaskPackage = (bool) ($status['packages'] ?? false) + || ! empty(Arr::get($settings, 'onboarding.selected_packages')); + $hasPhotos = $tenant->photos()->exists() || (int) ($summary['new_photos'] ?? 0) > 0; + + return [ + [ + 'key' => 'admin_app', + 'title' => trans('dashboard.onboarding.admin_app.title'), + 'description' => trans('dashboard.onboarding.admin_app.description'), + 'done' => $adminAppOpened, + 'cta' => url('/event-admin'), + 'ctaLabel' => trans('dashboard.onboarding.admin_app.cta'), + ], + [ + 'key' => 'event_setup', + 'title' => trans('dashboard.onboarding.event_setup.title'), + 'description' => trans('dashboard.onboarding.event_setup.description'), + 'done' => $hasEvent, + 'cta' => url('/event-admin/events/new'), + 'ctaLabel' => trans('dashboard.onboarding.event_setup.cta'), + ], + [ + 'key' => 'invite_guests', + 'title' => trans('dashboard.onboarding.invite_guests.title'), + 'description' => trans('dashboard.onboarding.invite_guests.description'), + 'done' => $hasInvite, + 'cta' => url('/event-admin/events'), + 'ctaLabel' => trans('dashboard.onboarding.invite_guests.cta'), + ], + [ + 'key' => 'collect_photos', + 'title' => trans('dashboard.onboarding.collect_photos.title'), + 'description' => trans('dashboard.onboarding.collect_photos.description'), + 'done' => $hasPhotos, + 'cta' => url('/event-admin/dashboard'), + 'ctaLabel' => trans('dashboard.onboarding.collect_photos.cta'), + ], + [ + 'key' => 'branding', + 'title' => trans('dashboard.onboarding.branding.title'), + 'description' => trans('dashboard.onboarding.branding.description'), + 'done' => $hasBranding && $hasTaskPackage, + 'cta' => url('/event-admin/tasks'), + 'ctaLabel' => trans('dashboard.onboarding.branding.cta'), + ], + ]; } private function resolveEventName(Event $event): string diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index df5d503..ec7b29c 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -56,7 +56,6 @@ class ProfileController extends Controller 'tenant' => $tenant ? [ 'id' => $tenant->id, 'name' => $tenant->name, - 'eventCreditsBalance' => $tenant->event_credits_balance, 'subscriptionStatus' => $tenant->subscription_status, 'subscriptionExpiresAt' => optional($tenant->subscription_expires_at)->toIso8601String(), 'activePackage' => $activePackage ? [ diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 926efe4..db6ef5f 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -61,12 +61,13 @@ class HandleInertiaRequests extends Middleware ], 'supportedLocales' => $supportedLocales, 'appUrl' => rtrim(config('app.url'), '/'), - 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + 'sidebarOpen' => $request->cookie('sidebar_state', 'false') === 'true', 'locale' => app()->getLocale(), 'translations' => [ 'marketing' => __('marketing'), 'auth' => __('auth'), 'profile' => __('profile'), + 'dashboard' => __('dashboard'), ], ]; } diff --git a/app/Services/Tenant/DashboardSummaryService.php b/app/Services/Tenant/DashboardSummaryService.php index c23103a..d91d709 100644 --- a/app/Services/Tenant/DashboardSummaryService.php +++ b/app/Services/Tenant/DashboardSummaryService.php @@ -51,6 +51,15 @@ class DashboardSummaryService ->orderByDesc('purchased_at') ->first(); + if (! $activePackage) { + $activePackage = $tenant->tenantPackages() + ->with('package') + ->orderByDesc('active') + ->orderByDesc('expires_at') + ->orderByDesc('purchased_at') + ->first(); + } + return [ 'total_events' => $totalEvents, 'active_events' => $activeEvents, @@ -61,7 +70,6 @@ class DashboardSummaryService 'task_progress' => $totalEvents > 0 ? (int) round(($eventsWithTasks / $totalEvents) * 100) : 0, - 'credit_balance' => $tenant->event_credits_balance ?? null, 'active_package' => $activePackage ? [ 'name' => $activePackage->package?->getNameForLocale(app()->getLocale()) ?? $activePackage->package?->name @@ -69,6 +77,7 @@ class DashboardSummaryService 'expires_at' => optional($activePackage->expires_at)->toIso8601String(), 'remaining_events' => $activePackage->remaining_events ?? null, 'price' => $activePackage->price !== null ? (float) $activePackage->price : null, + 'is_active' => (bool) $activePackage->active, ] : null, ]; } diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php new file mode 100644 index 0000000..55b2433 --- /dev/null +++ b/lang/de/dashboard.php @@ -0,0 +1,168 @@ + [ + 'group_label' => 'Kundenbereich', + ], + 'hero' => [ + 'badge' => 'Kundenbereich', + 'title' => 'Willkommen zurück, :name!', + 'subtitle' => 'Dein Überblick über Pakete, Rechnungen und Fortschritt – für alle Details öffne die Admin-App.', + 'description' => 'Nutze das Dashboard für Überblick, Abrechnung und Insights – die Admin-App begleitet dich bei allen operativen Aufgaben vor Ort.', + ], + 'language_switcher' => [ + 'label' => 'Sprache', + 'change' => 'Sprache wechseln', + ], + 'email_verification' => [ + 'title' => 'Bitte bestätige deine E-Mail-Adresse', + 'description' => 'Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist. Prüfe dein Postfach oder fordere einen neuen Link an.', + 'cta' => 'Link erneut senden', + 'cta_pending' => 'Sende...', + 'success' => 'Wir haben dir gerade einen neuen Bestätigungslink geschickt.', + ], + 'stats' => [ + 'active_events' => [ + 'label' => 'Aktive Events', + 'description_positive' => 'Events sind live und für Gäste sichtbar.', + 'description_zero' => 'Noch kein Event veröffentlicht – starte heute!', + ], + 'upcoming_events' => [ + 'label' => 'Bevorstehende Events', + 'description_positive' => 'Planung läuft – behalte Checklisten und Aufgaben im Blick.', + 'description_zero' => 'Lass dich vom Assistenten beim Planen unterstützen.', + ], + 'new_photos' => [ + 'label' => 'Neue Fotos (7 Tage)', + 'description_positive' => 'Frisch eingetroffene Erinnerungen deiner Gäste.', + 'description_zero' => 'Sammle erste Uploads über QR-Code oder Direktlink.', + ], + 'task_progress' => [ + 'label' => 'Event-Checkliste', + 'description_positive' => 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.', + 'description_zero' => 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.', + 'note' => ':value% deiner Event-Checkliste erledigt.', + ], + ], + 'spotlight' => [ + 'title' => 'Admin-App als Schaltzentrale', + 'description' => 'Events planen, Uploads freigeben, Gäste begleiten – mobil und in Echtzeit.', + 'cta' => 'Admin-App öffnen', + 'items' => [ + 'live' => [ + 'title' => 'Live auf dem Event', + 'description' => 'Moderation der Uploads, Freigaben & Push-Updates jederzeit griffbereit.', + ], + 'mobile' => [ + 'title' => 'Optimiert für mobile Einsätze', + 'description' => 'PWA heute, native Apps morgen – ein Zugang für das ganze Team.', + ], + 'overview' => [ + 'title' => 'Dashboard als Überblick', + 'description' => 'Pakete, Rechnungen und Fortschritt siehst du weiterhin hier im Webportal.', + ], + ], + ], + 'onboarding' => [ + 'card' => [ + 'title' => 'Dein Start in fünf Schritten', + 'description' => 'Bearbeite die Schritte in der Admin-App – das Dashboard zeigt dir den Status.', + 'completed' => 'Alle Schritte abgeschlossen – großartig! Du kannst jederzeit zur Admin-App wechseln.', + 'cta_fallback' => 'Jetzt starten', + ], + 'admin_app' => [ + 'title' => 'Admin-App öffnen', + 'description' => 'Verwalte Events, Uploads und Gäste direkt in der Admin-App. Die mobile Oberfläche ist für Live-Einsätze optimiert.', + 'cta' => 'Admin-App starten', + ], + 'event_setup' => [ + 'title' => 'Erstes Event vorbereiten', + 'description' => 'Lege in der Admin-App Name, Datum und Aufgaben fest. So wissen Gäste, welche Fotos ihr euch wünscht.', + 'cta' => 'Event anlegen', + ], + 'invite_guests' => [ + 'title' => 'Gäste einladen', + 'description' => 'Teile QR-Codes oder Links, damit Gäste direkt mit dem Hochladen beginnen können.', + 'cta' => 'QR-Links öffnen', + ], + 'collect_photos' => [ + 'title' => 'Erste Fotos einsammeln', + 'description' => 'Sobald die ersten Uploads eintrudeln, erscheint alles in eurer Galerie. Moderation und Freigaben laufen in der Admin-App.', + 'cta' => 'Uploads prüfen', + ], + 'branding' => [ + 'title' => 'Branding & Aufgaben verfeinern', + 'description' => 'Passt Farbwelt und Aufgabenpakete an euren Anlass an – so fühlt sich alles wie aus einem Guss an.', + 'cta' => 'Branding öffnen', + ], + ], + 'events' => [ + 'card' => [ + 'title' => 'Bevorstehende Events', + 'description' => 'Status, Uploads und Aufgaben deiner nächsten Events im Überblick.', + 'badge' => [ + 'plural' => ':count geplant', + 'empty' => 'Noch kein Event geplant', + ], + 'empty' => 'Plane dein erstes Event und begleite den gesamten Ablauf – vom Briefing bis zur Nachbereitung – direkt in der Admin-App.', + ], + 'status' => [ + 'live' => 'Live', + 'upcoming' => 'In Vorbereitung', + ], + 'badges' => [ + 'photos' => 'Fotos', + 'tasks' => 'Aufgaben', + 'links' => 'Links', + ], + ], + 'cards_section' => [ + 'package' => [ + 'title' => 'Aktuelles Paket', + 'description' => 'Behalte Laufzeiten und verfügbaren Umfang stets im Blick.', + 'remaining' => ':count Events', + 'expires_at' => 'Läuft ab', + 'price' => 'Preis', + 'latest' => 'Zuletzt gebucht am :date via :provider.', + 'empty' => 'Noch kein aktives Paket.', + 'empty_cta' => 'Jetzt Paket auswählen', + 'empty_suffix' => 'und anschließend in der Admin-App Events anlegen.', + ], + 'purchases' => [ + 'title' => 'Aktuelle Buchungen', + 'description' => 'Verfolge deine gebuchten Pakete und Erweiterungen.', + 'badge' => ':count Einträge', + 'empty' => 'Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.', + 'table' => [ + 'package' => 'Paket', + 'type' => 'Typ', + 'provider' => 'Anbieter', + 'date' => 'Datum', + 'price' => 'Preis', + ], + ], + 'quick_actions' => [ + 'title' => 'Schnellzugriff', + 'description' => 'Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.', + 'cta' => 'Weiter', + 'items' => [ + 'tenant_admin' => [ + 'label' => 'Event-Admin öffnen', + 'description' => 'Detaillierte Eventverwaltung, Moderation und Live-Features.', + ], + 'profile' => [ + 'label' => 'Profil verwalten', + 'description' => 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.', + ], + 'password' => [ + 'label' => 'Passwort aktualisieren', + 'description' => 'Sichere dein Konto mit einem aktuellen Passwort.', + ], + 'packages' => [ + 'label' => 'Pakete entdecken', + 'description' => 'Mehr Events oder Speicher buchen – du bleibst flexibel.', + ], + ], + ], + ], +]; diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php new file mode 100644 index 0000000..48f483b --- /dev/null +++ b/lang/en/dashboard.php @@ -0,0 +1,168 @@ + [ + 'group_label' => 'Customer Hub', + ], + 'hero' => [ + 'badge' => 'Customer Hub', + 'title' => 'Welcome back, :name!', + 'subtitle' => 'Your overview of packages, invoices, and progress — open the Admin App for every detail.', + 'description' => 'Use the dashboard for oversight, billing, and insights — the Admin App supports every operational task on site.', + ], + 'language_switcher' => [ + 'label' => 'Language', + 'change' => 'Change language', + ], + 'email_verification' => [ + 'title' => 'Please verify your email address', + 'description' => 'You can use all features once your email address is confirmed. Check your inbox or request a new link.', + 'cta' => 'Resend link', + 'cta_pending' => 'Sending…', + 'success' => 'We just sent another verification link to your inbox.', + ], + 'stats' => [ + 'active_events' => [ + 'label' => 'Active events', + 'description_positive' => 'Events are live and visible to guests.', + 'description_zero' => 'No event published yet — start today!', + ], + 'upcoming_events' => [ + 'label' => 'Upcoming events', + 'description_positive' => 'Planning is underway — keep an eye on checklists and tasks.', + 'description_zero' => 'Let the assistant help you plan the first event.', + ], + 'new_photos' => [ + 'label' => 'New photos (7 days)', + 'description_positive' => 'Fresh memories submitted by your guests.', + 'description_zero' => 'Collect first uploads via QR code or direct link.', + ], + 'task_progress' => [ + 'label' => 'Event checklist', + 'description_positive' => 'Great progress! Keep your task list up to date.', + 'description_zero' => 'Use tasks and templates for a structured flow.', + 'note' => ':value% of your event checklist finished.', + ], + ], + 'spotlight' => [ + 'title' => 'Admin App as command center', + 'description' => 'Plan events, approve uploads, and guide guests — mobile and in real time.', + 'cta' => 'Open Admin App', + 'items' => [ + 'live' => [ + 'title' => 'Live at the event', + 'description' => 'Moderate uploads, approve content, and send push updates at any time.', + ], + 'mobile' => [ + 'title' => 'Optimised for mobile use', + 'description' => 'PWA today, native apps tomorrow — one access for the whole team.', + ], + 'overview' => [ + 'title' => 'Dashboard for overview', + 'description' => 'Keep packages, invoices, and progress in sight inside the web portal.', + ], + ], + ], + 'onboarding' => [ + 'card' => [ + 'title' => 'Your start in five steps', + 'description' => 'Complete each step inside the Admin App — the dashboard keeps track of your status.', + 'completed' => 'All steps finished — fantastic! You can switch to the Admin App at any time.', + 'cta_fallback' => 'Start now', + ], + 'admin_app' => [ + 'title' => 'Open the Admin App', + 'description' => 'Manage events, uploads, and guests inside the Admin App. The mobile interface is optimised for live operations.', + 'cta' => 'Launch Admin App', + ], + 'event_setup' => [ + 'title' => 'Prepare first event', + 'description' => 'Define name, date, and tasks inside the Admin App so guests know which photos you expect.', + 'cta' => 'Create event', + ], + 'invite_guests' => [ + 'title' => 'Invite guests', + 'description' => 'Share QR codes or links so guests can start uploading instantly.', + 'cta' => 'Open QR links', + ], + 'collect_photos' => [ + 'title' => 'Collect first photos', + 'description' => 'As soon as uploads arrive, they show up in your gallery. Moderation happens inside the Admin App.', + 'cta' => 'Review uploads', + ], + 'branding' => [ + 'title' => 'Fine-tune branding & tasks', + 'description' => 'Adjust colours and task bundles to match your occasion — everything feels tailor-made.', + 'cta' => 'Open branding', + ], + ], + 'events' => [ + 'card' => [ + 'title' => 'Upcoming events', + 'description' => 'Keep track of status, uploads, and tasks for your next events.', + 'badge' => [ + 'plural' => ':count scheduled', + 'empty' => 'No event scheduled yet', + ], + 'empty' => 'Plan your first event and steer the full journey — from briefing to follow-up — in the Admin App.', + ], + 'status' => [ + 'live' => 'Live', + 'upcoming' => 'In preparation', + ], + 'badges' => [ + 'photos' => 'Photos', + 'tasks' => 'Tasks', + 'links' => 'Links', + ], + ], + 'cards_section' => [ + 'package' => [ + 'title' => 'Current package', + 'description' => 'Stay on top of durations and available volume.', + 'remaining' => ':count events', + 'expires_at' => 'Expires on', + 'price' => 'Price', + 'latest' => 'Last booked on :date via :provider.', + 'empty' => 'No active package yet.', + 'empty_cta' => 'Choose a package now', + 'empty_suffix' => 'and then create events inside the Admin App.', + ], + 'purchases' => [ + 'title' => 'Recent purchases', + 'description' => 'Track your booked packages and add-ons.', + 'badge' => ':count entries', + 'empty' => 'No purchases visible yet. Secure your first bundle now.', + 'table' => [ + 'package' => 'Package', + 'type' => 'Type', + 'provider' => 'Provider', + 'date' => 'Date', + 'price' => 'Price', + ], + ], + 'quick_actions' => [ + 'title' => 'Quick actions', + 'description' => 'Everything you need for the next step is one click away.', + 'cta' => 'Continue', + 'items' => [ + 'tenant_admin' => [ + 'label' => 'Open Admin App', + 'description' => 'Detailed event management, moderation, and live features.', + ], + 'profile' => [ + 'label' => 'Manage profile', + 'description' => 'Update contact data, language, and email address.', + ], + 'password' => [ + 'label' => 'Update password', + 'description' => 'Protect your account with a fresh password.', + ], + 'packages' => [ + 'label' => 'Explore packages', + 'description' => 'Book more events or storage — stay flexible.', + ], + ], + ], + ], +]; diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 7d0447a..1ccea5c 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -537,7 +537,7 @@ }, "how_it_works_page": { "hero": { - "title": "So funktioniert Fotospiel", + "title": "So funktioniert die Fotospiel App", "subtitle": "Teile deinen QR-Code, sammle Fotos in Echtzeit und behalte die Moderation. Alles läuft im Browser – ganz ohne App.", "primaryCta": "Event starten", "secondaryCta": "Kontakt aufnehmen", diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 097b99a..3b052f5 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -122,18 +122,54 @@ export type PaginatedResult = { }; export type DashboardSummary = { - active_events: number; - new_photos: number; - task_progress: number; - credit_balance?: number | null; - upcoming_events?: number | null; - active_package?: { - name: string; - expires_at?: string | null; - remaining_events?: number | null; - } | null; + active_events: number; + new_photos: number; + task_progress: number; + credit_balance?: number | null; + upcoming_events?: number | null; + active_package?: { + name: string; + expires_at?: string | null; + remaining_events?: number | null; + } | null; }; +export type TenantOnboardingStatus = { + steps: { + admin_app_opened_at?: string | null; + primary_event_id?: number | string | null; + selected_packages?: unknown; + branding_completed?: boolean; + tasks_configured?: boolean; + event_created?: boolean; + invite_created?: boolean; + }; +}; + +export async function trackOnboarding(step: string, meta?: Record): Promise { + try { + await authorizedFetch('/api/v1/tenant/onboarding', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ step, meta }), + }); + } catch (error) { + emitApiErrorEvent(new ApiError('onboarding.track_failed', i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'), error)); + } +} + +export async function fetchOnboardingStatus(): Promise { + try { + const response = await authorizedFetch('/api/v1/tenant/onboarding'); + return (await response.json()) as TenantOnboardingStatus; + } catch (error) { + emitApiErrorEvent(new ApiError('onboarding.fetch_failed', i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'), error)); + return null; + } +} + export type TenantPackageSummary = { id: number; package_id: number; diff --git a/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx b/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx index 4536482..1a35922 100644 --- a/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx +++ b/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx @@ -78,6 +78,13 @@ export default function WelcomePackagesPage() { isSubscription: Boolean(active.package_limits?.subscription), } : null, + serverStep: active ? "package_selected" : undefined, + meta: active + ? { + packages: [active.package_id], + is_active: active.active, + } + : undefined, }); } }, [packagesState, markStep, currencyFormatter, t]); @@ -96,6 +103,8 @@ export default function WelcomePackagesPage() { priceText, isSubscription: Boolean(pkg.features?.subscription), }, + serverStep: "package_selected", + meta: { packages: [pkg.id] }, }); navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } }); diff --git a/resources/js/admin/onboarding/store.tsx b/resources/js/admin/onboarding/store.tsx index 3a6a71b..f0beb54 100644 --- a/resources/js/admin/onboarding/store.tsx +++ b/resources/js/admin/onboarding/store.tsx @@ -1,10 +1,12 @@ import React from 'react'; +import { fetchOnboardingStatus, trackOnboarding } from '../api'; export type OnboardingProgress = { welcomeSeen: boolean; packageSelected: boolean; eventCreated: boolean; lastStep?: string | null; + adminAppOpenedAt?: string | null; selectedPackage?: { id: number; name: string; @@ -13,10 +15,15 @@ export type OnboardingProgress = { } | null; }; +type OnboardingUpdate = Partial & { + serverStep?: string; + meta?: Record; +}; + type OnboardingContextValue = { progress: OnboardingProgress; setProgress: (updater: (prev: OnboardingProgress) => OnboardingProgress) => void; - markStep: (step: Partial) => void; + markStep: (step: OnboardingUpdate) => void; reset: () => void; }; @@ -25,6 +32,7 @@ const DEFAULT_PROGRESS: OnboardingProgress = { packageSelected: false, eventCreated: false, lastStep: null, + adminAppOpenedAt: null, selectedPackage: null, }; @@ -65,6 +73,45 @@ function writeStoredProgress(progress: OnboardingProgress) { export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) { const [progress, setProgressState] = React.useState(() => readStoredProgress()); + const [synced, setSynced] = React.useState(false); + + React.useEffect(() => { + if (synced) { + return; + } + + fetchOnboardingStatus().then((status) => { + if (!status) { + setSynced(true); + return; + } + + setProgressState((prev) => { + const next: OnboardingProgress = { + ...prev, + adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null, + eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated), + packageSelected: Boolean(status.steps.selected_packages ?? prev.packageSelected), + }; + + writeStoredProgress(next); + + return next; + }); + + if (!status.steps.admin_app_opened_at) { + const timestamp = new Date().toISOString(); + trackOnboarding('admin_app_opened').catch(() => {}); + setProgressState((prev) => { + const next = { ...prev, adminAppOpenedAt: timestamp }; + writeStoredProgress(next); + return next; + }); + } + + setSynced(true); + }); + }, [synced]); const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => { setProgressState((prev) => { @@ -74,12 +121,18 @@ export function OnboardingProgressProvider({ children }: { children: React.React }); }, []); - const markStep = React.useCallback((step: Partial) => { + const markStep = React.useCallback((step: OnboardingUpdate) => { + const { serverStep, meta, ...rest } = step; + setProgress((prev) => ({ ...prev, - ...step, - lastStep: typeof step.lastStep === 'undefined' ? prev.lastStep : step.lastStep, + ...rest, + lastStep: typeof rest.lastStep === 'undefined' ? prev.lastStep : rest.lastStep, })); + + if (serverStep) { + trackOnboarding(serverStep, meta).catch(() => {}); + } }, [setProgress]); const reset = React.useCallback(() => { diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx index 921d15a..21dd477 100644 --- a/resources/js/admin/pages/DashboardPage.tsx +++ b/resources/js/admin/pages/DashboardPage.tsx @@ -180,7 +180,12 @@ export default function DashboardPage() { return; } if (events.length > 0 && !progress.eventCreated) { - markStep({ eventCreated: true }); + const primary = events[0]; + markStep({ + eventCreated: true, + serverStep: 'event_created', + meta: primary ? { event_id: primary.id } : undefined, + }); } }, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]); diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index 22ee36c..58b695c 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -48,6 +48,7 @@ import { triggerDownloadFromBlob, triggerDownloadFromDataUrl, } from './components/invite-layout/export-utils'; +import { useOnboardingProgress } from '../onboarding'; interface PageState { event: TenantEvent | null; @@ -180,6 +181,7 @@ export default function EventInvitesPage(): React.ReactElement { const [exportError, setExportError] = React.useState(null); const exportPreviewContainerRef = React.useRef(null); const [exportScale, setExportScale] = React.useState(0.34); + const { markStep } = useOnboardingProgress(); const load = React.useCallback(async () => { if (!slug) { @@ -479,6 +481,11 @@ export default function EventInvitesPage(): React.ReactElement { } catch { // ignore clipboard failures } + markStep({ + lastStep: 'invite', + serverStep: 'invite_created', + meta: { invite_id: invite.id }, + }); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' })); @@ -544,6 +551,14 @@ export default function EventInvitesPage(): React.ReactElement { invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)), })); setCustomizerDraft(null); + markStep({ + lastStep: 'branding', + serverStep: 'branding_configured', + meta: { + invite_id: selectedInvite.id, + has_custom_branding: true, + }, + }); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' })); diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/app-logo.tsx index 69bdcb8..8fb2d12 100644 --- a/resources/js/components/app-logo.tsx +++ b/resources/js/components/app-logo.tsx @@ -1,14 +1,19 @@ -import AppLogoIcon from './app-logo-icon'; +import { usePage } from '@inertiajs/react'; + +import { type SharedData } from '@/types'; export default function AppLogo() { + const { translations } = usePage().props; + const areaLabel = + (translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich'; + return ( - <> -
- +
+ Fotospiel +
+ Fotospiel + {areaLabel}
-
- Laravel Starter Kit -
- +
); } diff --git a/resources/js/components/app-shell.tsx b/resources/js/components/app-shell.tsx index 0d5cdb9..fac7796 100644 --- a/resources/js/components/app-shell.tsx +++ b/resources/js/components/app-shell.tsx @@ -8,11 +8,11 @@ interface AppShellProps { } export function AppShell({ children, variant = 'header' }: AppShellProps) { - const isOpen = usePage().props.sidebarOpen; + const { sidebarOpen = false } = usePage().props; if (variant === 'header') { return
{children}
; } - return {children}; + return {children}; } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 94eae5b..9d1fc15 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,11 +1,10 @@ -import { NavFooter } from '@/components/nav-footer'; import { NavMain } from '@/components/nav-main'; import { NavUser } from '@/components/nav-user'; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; import { dashboard } from '@/routes'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; -import { BookOpen, Folder, LayoutGrid, UserRound } from 'lucide-react'; +import { LayoutGrid, UserRound } from 'lucide-react'; import AppLogo from './app-logo'; const mainNavItems: NavItem[] = [ @@ -21,19 +20,6 @@ const mainNavItems: NavItem[] = [ }, ]; -const footerNavItems: NavItem[] = [ - { - title: 'Repository', - href: 'https://github.com/laravel/react-starter-kit', - icon: Folder, - }, - { - title: 'Documentation', - href: 'https://laravel.com/docs/starter-kits#react', - icon: BookOpen, - }, -]; - export function AppSidebar() { return ( @@ -54,7 +40,6 @@ export function AppSidebar() { - diff --git a/resources/js/components/dashboard-language-switcher.tsx b/resources/js/components/dashboard-language-switcher.tsx new file mode 100644 index 0000000..09a7964 --- /dev/null +++ b/resources/js/components/dashboard-language-switcher.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { router, usePage } from '@inertiajs/react'; +import { Check, Languages } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { type SharedData } from '@/types'; +import { setLocale } from '@/routes'; + +export function DashboardLanguageSwitcher() { + const page = usePage(); + const { locale, supportedLocales, translations } = page.props; + const locales = supportedLocales && supportedLocales.length > 0 ? supportedLocales : ['de', 'en']; + const activeLocale = locales.includes(locale as string) && typeof locale === 'string' ? locale : locales[0]; + const languageCopy = (translations?.dashboard?.language_switcher as Record) ?? {}; + const label = languageCopy.label ?? 'Sprache'; + const changeLabel = languageCopy.change ?? 'Sprache wechseln'; + + const [pendingLocale, setPendingLocale] = useState(null); + + const handleChange = (nextLocale: string) => { + if (nextLocale === activeLocale || pendingLocale === nextLocale || !locales.includes(nextLocale)) { + return; + } + + setPendingLocale(nextLocale); + + router.post( + setLocale().url, + { locale: nextLocale }, + { + preserveScroll: true, + preserveState: true, + onFinish: () => setPendingLocale(null), + }, + ); + }; + + return ( + + + + + + + {changeLabel} + + + {locales.map((code) => { + const isActive = code === activeLocale; + const isPending = code === pendingLocale; + + return ( + { + event.preventDefault(); + handleChange(code); + }} + disabled={isPending} + className="flex items-center justify-between gap-3" + > + {code} + {(isActive || isPending) && } + + ); + })} + + + ); +} diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx index 6fcbd7c..7afe105 100644 --- a/resources/js/components/nav-main.tsx +++ b/resources/js/components/nav-main.tsx @@ -1,12 +1,16 @@ import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; -import { type NavItem } from '@/types'; +import { type NavItem, type SharedData } from '@/types'; import { Link, usePage } from '@inertiajs/react'; export function NavMain({ items = [] }: { items: NavItem[] }) { - const page = usePage(); + const page = usePage(); + const { translations } = page.props; + const groupLabel = + (translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich'; + return ( - Platform + {groupLabel} {items.map((item) => ( diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index 459c8d1..3994d86 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -2,10 +2,10 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSep import { UserInfo } from '@/components/user-info'; import { useMobileNavigation } from '@/hooks/use-mobile-navigation'; import { logout } from '@/routes'; -import { edit } from '@/routes/settings/profile'; +import profileRoutes from '@/routes/profile'; import { type User } from '@/types'; import { Link, router } from '@inertiajs/react'; -import { LogOut, Settings } from 'lucide-react'; +import { LogOut, UserRound } from 'lucide-react'; interface UserMenuContentProps { user: User; @@ -29,9 +29,9 @@ export function UserMenuContent({ user }: UserMenuContentProps) { - - - Settings + + + Profil @@ -39,7 +39,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) { - Log out + Abmelden diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index cafd0bd..e727369 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -236,12 +236,14 @@ function getErrorContent( function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) { return ( -
-
-
- {children} + +
+
+
+ {children} +
+
- -
+ ); } diff --git a/resources/js/layouts/app/Header.tsx b/resources/js/layouts/app/Header.tsx index 14ba346..2e6f53e 100644 --- a/resources/js/layouts/app/Header.tsx +++ b/resources/js/layouts/app/Header.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import i18n from '@/i18n'; import { useAppearance } from '@/hooks/use-appearance'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; +import profileRoutes from '@/routes/profile'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -249,7 +250,7 @@ const Header: React.FC = () => { - + Profil @@ -376,7 +377,7 @@ const Header: React.FC = () => { <> diff --git a/resources/js/pages/Profile/Index.tsx b/resources/js/pages/Profile/Index.tsx index 750cb7c..5887136 100644 --- a/resources/js/pages/Profile/Index.tsx +++ b/resources/js/pages/Profile/Index.tsx @@ -32,7 +32,6 @@ type ProfilePageProps = { tenant: { id: number; name: string; - eventCreditsBalance: number | null; subscriptionStatus: string | null; subscriptionExpiresAt: string | null; activePackage: { @@ -98,7 +97,7 @@ export default function ProfileIndex() { -
+
@@ -167,7 +166,7 @@ export default function ProfileIndex() {

{tenant.activePackage.name}

-

{tenant.eventCreditsBalance ?? 0} Credits verfügbar · {tenant.activePackage.remainingEvents ?? 0} Events inklusive

+

{tenant.activePackage.remainingEvents ?? 0} Events inklusive

@@ -187,7 +186,7 @@ export default function ProfileIndex() { ) : ( - Du hast aktuell kein aktives Paket. Sichere dir jetzt Credits oder ein Komplettpaket, um neue Events zu planen. + Du hast aktuell kein aktives Paket. Wähle ein Paket, um ohne Unterbrechung neue Events zu planen. )} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 3d59f47..f9be8c7 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -1,21 +1,20 @@ -import { useMemo, useState } from 'react'; -import { AlertTriangle, CalendarDays, Camera, ClipboardList, Package, Sparkles, TrendingUp, UserRound, Key } from 'lucide-react'; +import { useCallback, useMemo, useState, type ReactNode } from 'react'; +import { AlertTriangle, CalendarDays, Camera, ClipboardList, Key, Package, Smartphone, Sparkles, TrendingUp, UserRound } from 'lucide-react'; import { Head, Link, router, usePage } from '@inertiajs/react'; import AppLayout from '@/layouts/app-layout'; -import { dashboard } from '@/routes'; import { edit as passwordSettings } from '@/routes/password'; import profileRoutes from '@/routes/profile'; import { send as resendVerificationRoute } from '@/routes/verification'; -import { type BreadcrumbItem, type SharedData } from '@/types'; +import { type SharedData } from '@/types'; +import { DashboardLanguageSwitcher } from '@/components/dashboard-language-switcher'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Progress } from '@/components/ui/progress'; -import { Separator } from '@/components/ui/separator'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; type DashboardMetrics = { @@ -26,11 +25,12 @@ type DashboardMetrics = { upcoming_events: number; new_photos: number; task_progress: number; - credit_balance: number | null; active_package: { name: string; expires_at: string | null; remaining_events: number | null; + price: number | null; + is_active?: boolean; } | null; }; @@ -53,12 +53,21 @@ type DashboardPurchase = { purchasedAt: string | null; type: string | null; provider: string | null; + source?: string | null; +}; + +type DashboardOnboardingStep = { + key: string; + title: string; + description: string; + done: boolean; + cta?: string | null; + ctaLabel?: string | null; }; type TenantSummary = { id: number; name: string; - eventCreditsBalance: number | null; subscriptionStatus: string | null; subscriptionExpiresAt: string | null; activePackage: { @@ -66,6 +75,7 @@ type TenantSummary = { price: number | null; expiresAt: string | null; remainingEvents: number | null; + isActive?: boolean; } | null; } | null; @@ -79,21 +89,66 @@ type DashboardPageProps = { mustVerify: boolean; verified: boolean; }; + onboarding: DashboardOnboardingStep[]; }; -const breadcrumbs: BreadcrumbItem[] = [ - { - title: 'Dashboard', - href: dashboard().url, - }, -]; +const getNestedValue = (source: unknown, path: string): unknown => { + if (!source || typeof source !== 'object') { + return undefined; + } + + return path.split('.').reduce((accumulator, segment) => { + if (accumulator && typeof accumulator === 'object' && segment in (accumulator as Record)) { + return (accumulator as Record)[segment]; + } + + return undefined; + }, source); +}; export default function Dashboard() { - const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale } = usePage().props; + const page = usePage(); + const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale, onboarding } = page.props; + const { auth, supportedLocales } = page.props; + const translations = (page.props.translations?.dashboard ?? {}) as Record; + const [verificationSent, setVerificationSent] = useState(false); const [sendingVerification, setSendingVerification] = useState(false); + const translate = useCallback( + (path: string, fallback: string): string => { + const value = getNestedValue(translations, path); + return typeof value === 'string' && value.length > 0 ? value : fallback; + }, + [translations], + ); + + const formatMessage = useCallback( + (path: string, fallback: string, replacements: Record = {}) => { + const template = translate(path, fallback); + + return Object.entries(replacements).reduce((carry, [token, value]) => { + return carry.replace(new RegExp(`:${token}`, 'g'), String(value)); + }, template); + }, + [translate], + ); + + const user = auth?.user; + const greetingName = typeof user?.name === 'string' && user.name.trim() !== '' ? user.name : 'Fotospiel-Team'; + const greetingTitle = formatMessage('hero.title', 'Willkommen zurück, :name!', { name: greetingName }); + const heroBadge = translate('hero.badge', 'Kundenbereich'); + const heroSubtitle = translate( + 'hero.subtitle', + 'Dein Überblick über Pakete, Rechnungen und Fortschritt – für alle Details öffne die Admin-App.', + ); + const heroDescription = translate( + 'hero.description', + 'Nutze das Dashboard für Überblick, Abrechnung und Insights – die Admin-App begleitet dich bei allen operativen Aufgaben vor Ort.', + ); + const needsEmailVerification = emailVerification.mustVerify && !emailVerification.verified; + const taskProgress = metrics?.task_progress ?? 0; const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', { day: '2-digit', @@ -107,120 +162,110 @@ export default function Dashboard() { maximumFractionDigits: 2, }), [locale]); - const taskProgress = metrics?.task_progress ?? 0; + const onboardingSteps = useMemo(() => onboarding ?? [], [onboarding]); - const checklistItems = [ - { - key: 'verify-email', - title: 'E-Mail-Adresse bestätigen', - description: 'Bestätige deine Adresse, um Einladungen zu versenden und Benachrichtigungen zu erhalten.', - done: !needsEmailVerification, - }, - { - key: 'create-event', - title: 'Dein erstes Event erstellen', - description: 'Starte mit einem Event-Blueprint und passe Agenda, Uploadregeln und Branding an.', - done: (metrics?.total_events ?? 0) > 0, - }, - { - key: 'publish-event', - title: 'Event veröffentlichen', - description: 'Schalte dein Event frei, damit Gäste über den Link Fotos hochladen können.', - done: (metrics?.published_events ?? 0) > 0, - }, - { - key: 'invite-guests', - title: 'Gästelink teilen', - description: 'Nutze QR-Code oder Link, um Gäste einzuladen und erste Uploads zu sammeln.', - done: upcomingEvents.some((event) => event.joinTokensCount > 0), - }, - { - key: 'collect-photos', - title: 'Fotos sammeln', - description: 'Spare Zeit bei der Nachbereitung: neue Uploads erscheinen direkt in deinem Event.', - done: (metrics?.new_photos ?? 0) > 0, - }, - ]; + const stats = useMemo(() => { + const activeEvents = metrics?.active_events ?? 0; + const upcomingEventsCount = metrics?.upcoming_events ?? 0; + const newPhotos = metrics?.new_photos ?? 0; - const quickActions = [ - { - key: 'tenant-admin', - label: 'Event-Admin öffnen', - description: 'Detaillierte Eventverwaltung, Moderation und Live-Features.', - href: '/event-admin', - icon: Sparkles, - }, - { - key: 'profile', - label: 'Profil verwalten', - description: 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.', - href: profileRoutes.index().url, - icon: UserRound, - }, - { - key: 'password', - label: 'Passwort aktualisieren', - description: 'Sichere dein Konto mit einem aktuellen Passwort.', - href: passwordSettings().url, - icon: Key, - }, - { - key: 'packages', - label: 'Pakete entdecken', - description: 'Mehr Events oder Speicher buchen – du bleibst flexibel.', - href: `/${locale ?? 'de'}/packages`, - icon: Package, - }, - ]; + return [ + { + key: 'active-events', + label: translate('stats.active_events.label', 'Aktive Events'), + value: activeEvents, + description: activeEvents > 0 + ? translate('stats.active_events.description_positive', 'Events sind live und für Gäste sichtbar.') + : translate('stats.active_events.description_zero', 'Noch kein Event veröffentlicht – starte heute!'), + icon: CalendarDays, + }, + { + key: 'upcoming-events', + label: translate('stats.upcoming_events.label', 'Bevorstehende Events'), + value: upcomingEventsCount, + description: upcomingEventsCount > 0 + ? translate('stats.upcoming_events.description_positive', 'Planung läuft – behalte Checklisten und Aufgaben im Blick.') + : translate('stats.upcoming_events.description_zero', 'Lass dich vom Assistenten beim Planen unterstützen.'), + icon: TrendingUp, + }, + { + key: 'new-photos', + label: translate('stats.new_photos.label', 'Neue Fotos (7 Tage)'), + value: newPhotos, + description: newPhotos > 0 + ? translate('stats.new_photos.description_positive', 'Frisch eingetroffene Erinnerungen deiner Gäste.') + : translate('stats.new_photos.description_zero', 'Sammle erste Uploads über QR-Code oder Direktlink.'), + icon: Camera, + }, + { + key: 'task-progress', + label: translate('stats.task_progress.label', 'Event-Checkliste'), + value: `${taskProgress}%`, + description: taskProgress > 0 + ? translate('stats.task_progress.description_positive', 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.') + : translate('stats.task_progress.description_zero', 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.'), + icon: ClipboardList, + }, + ]; + }, [metrics, taskProgress, translate]); - const stats = [ - { - key: 'active-events', - label: 'Aktive Events', - value: metrics?.active_events ?? 0, - description: metrics?.active_events - ? 'Events sind live und für Gäste sichtbar.' - : 'Noch kein Event veröffentlicht – starte heute!', - icon: CalendarDays, - }, - { - key: 'upcoming-events', - label: 'Bevorstehende Events', - value: metrics?.upcoming_events ?? 0, - description: metrics?.upcoming_events - ? 'Planung läuft – behalte Checklisten und Aufgaben im Blick.' - : 'Lass dich vom Assistenten beim Planen unterstützen.', - icon: TrendingUp, - }, - { - key: 'new-photos', - label: 'Neue Fotos (7 Tage)', - value: metrics?.new_photos ?? 0, - description: metrics && metrics.new_photos > 0 - ? 'Frisch eingetroffene Erinnerungen deiner Gäste.' - : 'Sammle erste Uploads über QR-Code oder Direktlink.', - icon: Camera, - }, - { - key: 'credit-balance', - label: 'Event Credits', - value: tenant?.eventCreditsBalance ?? 0, - description: tenant?.eventCreditsBalance - ? 'Verfügbare Credits für neue Events.' - : 'Buche Pakete oder Credits, um weitere Events zu planen.', - icon: Package, - extra: metrics?.active_package?.remaining_events ?? null, - }, - { - key: 'task-progress', - label: 'Event-Checkliste', - value: `${taskProgress}%`, - description: taskProgress > 0 - ? 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.' - : 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.', - icon: ClipboardList, - }, - ]; + const quickActions = useMemo(() => { + const languageAwarePackagesHref = `/${locale ?? supportedLocales?.[0] ?? 'de'}/packages`; + + return [ + { + key: 'tenant-admin', + label: translate('cards_section.quick_actions.items.tenant_admin.label', 'Event-Admin öffnen'), + description: translate('cards_section.quick_actions.items.tenant_admin.description', 'Detaillierte Eventverwaltung, Moderation und Live-Features.'), + href: '/event-admin', + icon: Sparkles, + }, + { + key: 'profile', + label: translate('cards_section.quick_actions.items.profile.label', 'Profil verwalten'), + description: translate('cards_section.quick_actions.items.profile.description', 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.'), + href: profileRoutes.index().url, + icon: UserRound, + }, + { + key: 'password', + label: translate('cards_section.quick_actions.items.password.label', 'Passwort aktualisieren'), + description: translate('cards_section.quick_actions.items.password.description', 'Sichere dein Konto mit einem aktuellen Passwort.'), + href: passwordSettings().url, + icon: Key, + }, + { + key: 'packages', + label: translate('cards_section.quick_actions.items.packages.label', 'Pakete entdecken'), + description: translate('cards_section.quick_actions.items.packages.description', 'Mehr Events oder Speicher buchen – du bleibst flexibel.'), + href: languageAwarePackagesHref, + icon: Package, + }, + ]; + }, [locale, supportedLocales, translate]); + + const spotlight = useMemo(() => ({ + title: translate('spotlight.title', 'Admin-App als Schaltzentrale'), + description: translate('spotlight.description', 'Events planen, Uploads freigeben, Gäste begleiten – mobil und in Echtzeit.'), + cta: translate('spotlight.cta', 'Admin-App öffnen'), + items: [ + { + key: 'live', + title: translate('spotlight.items.live.title', 'Live auf dem Event'), + description: translate('spotlight.items.live.description', 'Moderation der Uploads, Freigaben & Push-Updates jederzeit griffbereit.'), + }, + { + key: 'mobile', + title: translate('spotlight.items.mobile.title', 'Optimiert für mobile Einsätze'), + description: translate('spotlight.items.mobile.description', 'PWA heute, native Apps morgen – ein Zugang für das ganze Team.'), + }, + { + key: 'overview', + title: translate('spotlight.items.overview.title', 'Dashboard als Überblick'), + description: translate('spotlight.items.overview.description', 'Pakete, Rechnungen und Fortschritt siehst du weiterhin hier im Webportal.'), + }, + ], + }), [translate]); const handleResendVerification = () => { setSendingVerification(true); @@ -247,236 +292,381 @@ export default function Dashboard() { const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—'); + const emailTitle = translate('email_verification.title', 'Bitte bestätige deine E-Mail-Adresse'); + const emailDescription = translate( + 'email_verification.description', + 'Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist. Prüfe dein Postfach oder fordere einen neuen Link an.', + ); + const emailSuccess = translate('email_verification.success', 'Wir haben dir gerade einen neuen Bestätigungslink geschickt.'); + const resendIdleLabel = translate('email_verification.cta', 'Link erneut senden'); + const resendPendingLabel = translate('email_verification.cta_pending', 'Sende...'); + + const onboardingCardTitle = translate('onboarding.card.title', 'Dein Start in fünf Schritten'); + const onboardingCardDescription = translate( + 'onboarding.card.description', + 'Bearbeite die Schritte in der Admin-App – das Dashboard zeigt dir den Status.', + ); + const onboardingCompletedCopy = translate( + 'onboarding.card.completed', + 'Alle Schritte abgeschlossen – großartig! Du kannst jederzeit zur Admin-App wechseln.', + ); + const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten'); + + const upcomingCardTitle = translate('events.card.title', 'Bevorstehende Events'); + const upcomingCardDescription = translate( + 'events.card.description', + 'Status, Uploads und Aufgaben deiner nächsten Events im Überblick.', + ); + const upcomingEmptyCopy = translate( + 'events.card.empty', + 'Plane dein erstes Event und begleite den gesamten Ablauf – vom Briefing bis zur Nachbereitung – direkt in der Admin-App.', + ); + const upcomingBadgeLabel = upcomingEvents.length > 0 + ? formatMessage('events.card.badge.plural', `${upcomingEvents.length} geplant`, { count: upcomingEvents.length }) + : translate('events.card.badge.empty', 'Noch kein Event geplant'); + const eventLiveStatus = translate('events.status.live', 'Live'); + const eventUpcomingStatus = translate('events.status.upcoming', 'In Vorbereitung'); + const eventPhotoLabel = translate('events.badges.photos', 'Fotos'); + const eventTaskLabel = translate('events.badges.tasks', 'Aufgaben'); + const eventLinksLabel = translate('events.badges.links', 'Links'); + + const packageCardTitle = translate('cards_section.package.title', 'Aktuelles Paket'); + const packageCardDescription = translate('cards_section.package.description', 'Behalte Laufzeiten und verfügbaren Umfang stets im Blick.'); + const packageExpiresLabel = translate('cards_section.package.expires_at', 'Läuft ab'); + const packagePriceLabel = translate('cards_section.package.price', 'Preis'); + const packageEmptyCopy = translate('cards_section.package.empty', 'Noch kein aktives Paket.'); + const packageEmptyCta = translate('cards_section.package.empty_cta', 'Jetzt Paket auswählen'); + const packageEmptySuffix = translate('cards_section.package.empty_suffix', 'und anschließend in der Admin-App Events anlegen.'); + + const purchasesCardTitle = translate('cards_section.purchases.title', 'Aktuelle Buchungen'); + const purchasesCardDescription = translate('cards_section.purchases.description', 'Verfolge deine gebuchten Pakete und Erweiterungen.'); + const purchasesBadge = formatMessage('cards_section.purchases.badge', `${recentPurchases.length} Einträge`, { + count: recentPurchases.length, + }); + const purchasesEmptyCopy = translate( + 'cards_section.purchases.empty', + 'Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.', + ); + const purchasesHeaders = { + package: translate('cards_section.purchases.table.package', 'Paket'), + type: translate('cards_section.purchases.table.type', 'Typ'), + provider: translate('cards_section.purchases.table.provider', 'Anbieter'), + date: translate('cards_section.purchases.table.date', 'Datum'), + price: translate('cards_section.purchases.table.price', 'Preis'), + }; + + const quickActionsCardTitle = translate('cards_section.quick_actions.title', 'Schnellzugriff'); + const quickActionsCardDescription = translate( + 'cards_section.quick_actions.description', + 'Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.', + ); + const quickActionsCta = translate('cards_section.quick_actions.cta', 'Weiter'); + + const taskProgressNote = formatMessage( + 'stats.task_progress.note', + ':value% deiner Event-Checkliste erledigt.', + { value: taskProgress }, + ); + + const latestPurchaseInfo = latestPurchase + ? formatMessage( + 'cards_section.package.latest', + `Zuletzt gebucht am ${formatDate(latestPurchase.purchasedAt)} via ${latestPurchase.provider?.toUpperCase() ?? 'Checkout'}.`, + { + date: formatDate(latestPurchase.purchasedAt), + provider: latestPurchase.provider?.toUpperCase() ?? 'Checkout', + }, + ) + : null; + return ( - + -
- {needsEmailVerification && ( - - -
-
- Bitte bestätige deine E-Mail-Adresse - - Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist. - Prüfe dein Postfach oder fordere einen neuen Link an. - -
-
- - {verificationSent && Wir haben dir gerade einen neuen Bestätigungslink geschickt.} -
-
-
- )} - -
- {stats.map((stat) => ( - - -
-

{stat.label}

-
{stat.value}
-
- - - -
- -

{stat.description}

- {stat.key === 'credit-balance' && stat.extra !== null && ( -
{stat.extra} weitere Events im aktuellen Paket enthalten.
- )} - {stat.key === 'task-progress' && ( -
- - {taskProgress}% deiner Event-Checkliste erledigt. +
+
+
+
+
+
+
+
+
+ + {heroBadge} + +
+

{greetingTitle}

+
+

{heroSubtitle}

+

{heroDescription}

+
+
+
+
+ + Fotospiel
- )} - - - ))} -
- -
- - -
- Bevorstehende Events -

Status, Uploads und Aufgaben deiner nächsten Events im Überblick.

-
- 0 ? 'secondary' : 'outline'}> - {upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'} - -
- - {upcomingEvents.length === 0 && ( -
-

- Plane dein erstes Event und begleite den gesamten Ablauf – vom Briefing bis zur Nachbereitung – direkt hier im Dashboard. -

+
+ + {needsEmailVerification && ( + + +
+
+ {emailTitle} + {emailDescription} +
+
+ + {verificationSent && {emailSuccess}} +
+
+
)} - {upcomingEvents.map((event) => ( -
-
-
-

{event.name}

-

- {formatDate(event.date)} · {event.status === 'published' || event.isActive ? 'Live' : 'In Vorbereitung'} -

-
-
- {event.photosCount} Fotos - {event.tasksCount} Aufgaben - {event.joinTokensCount} Links -
-
-
- ))} - - - -
- - - Nächstes Paket & Credits -

Behalte Laufzeiten und verfügbaren Umfang stets im Blick.

-
- - {tenant?.activePackage ? ( -
-
- {tenant.activePackage.name} - {tenant.activePackage.remainingEvents ?? 0} Events übrig -
-
-
- Läuft ab - {formatDate(tenant.activePackage.expiresAt)} -
-
- Preis - {renderPrice(tenant.activePackage.price)} -
-
- {latestPurchase && ( -
- Zuletzt gebucht am {formatDate(latestPurchase.purchasedAt)} via {latestPurchase.provider?.toUpperCase() ?? 'Checkout'}. -
- )} -
- ) : ( -
- Noch kein aktives Paket. Jetzt Paket auswählen und direkt Events planen. -
- )} - -
- Event Credits insgesamt - {tenant?.eventCreditsBalance ?? 0} -
-
- Credits werden bei neuen Events automatisch verbraucht. Zusätzliche Kontingente kannst du jederzeit buchen. -
-
-
- - - - Dein Start in 5 Schritten -

Folge den wichtigsten Schritten, um dein Event reibungslos aufzusetzen.

-
- -
- {checklistItems.map((item) => ( -
- +
+ {stats.map((stat) => ( + +
-

{item.title}

-

{item.description}

+

{stat.label}

+
{stat.value}
-
- ))} -
- - -
-
- - - -
- Aktuelle Buchungen -

Verfolge deine gebuchten Pakete und Erweiterungen.

-
- {recentPurchases.length} Einträge -
- - {recentPurchases.length === 0 ? ( -
- Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent. + + + + + +

{stat.description}

+ {stat.key === 'task-progress' && ( +
+ + {taskProgressNote} +
+ )} +
+ + ))}
- ) : ( - - - - Paket - Typ - Anbieter - Datum - Preis - - - - {recentPurchases.map((purchase) => ( - - {purchase.packageName} - {purchase.type ?? '—'} - {purchase.provider ?? 'Checkout'} - {formatDate(purchase.purchasedAt)} - {renderPrice(purchase.price)} - - ))} - -
- )} -
-
- - - Schnellzugriff -

Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.

-
- - {quickActions.map((action) => ( -
-
- - - -
-

{action.label}

-

{action.description}

+ + +
+ {spotlight.title} +

{spotlight.description}

-
-
- -
+ + + {spotlight.items.map((item) => ( +
+

{item.title}

+

{item.description}

+
+ ))} +
+ + +
+ + + {onboardingCardTitle} +

{onboardingCardDescription}

+
+ +
+ {onboardingSteps.map((step) => ( +
+
+ +
+
+

{step.title}

+

{step.description}

+
+ {!step.done && step.cta ? ( + + ) : null} +
+
+
+ ))} + {onboardingSteps.length === 0 && ( +

{onboardingCompletedCopy}

+ )} +
+
+
+ + + +
+ {upcomingCardTitle} +

{upcomingCardDescription}

+
+ 0 ? 'secondary' : 'outline'}>{upcomingBadgeLabel} +
+ + {upcomingEvents.length === 0 && ( +
+

{upcomingEmptyCopy}

+
+ )} + + {upcomingEvents.map((event) => ( +
+
+
+

{event.name}

+

+ {formatDate(event.date)} · {event.status === 'published' || event.isActive ? eventLiveStatus : eventUpcomingStatus} +

+
+
+ {event.photosCount} {eventPhotoLabel} + {event.tasksCount} {eventTaskLabel} + {event.joinTokensCount} {eventLinksLabel} +
+
+
+ ))} +
+
- ))} - - + +
+ + + {packageCardTitle} +

{packageCardDescription}

+
+ + {tenant?.activePackage ? ( +
+
+ {tenant.activePackage.name} + + {formatMessage('cards_section.package.remaining', `${tenant.activePackage.remainingEvents ?? 0} Events`, { + count: tenant.activePackage.remainingEvents ?? 0, + })} + +
+
+
+ {packageExpiresLabel} + {formatDate(tenant.activePackage.expiresAt)} +
+
+ {packagePriceLabel} + {renderPrice(tenant.activePackage.price)} +
+
+ {latestPurchaseInfo && ( +
{latestPurchaseInfo}
+ )} +
+ ) : ( +
+ {packageEmptyCopy}{' '} + + {packageEmptyCta} + {' '} + {packageEmptySuffix} +
+ )} +
+
+ + + +
+ {purchasesCardTitle} +

{purchasesCardDescription}

+
+ {purchasesBadge} +
+ + {recentPurchases.length === 0 ? ( +
+ {purchasesEmptyCopy} +
+ ) : ( + + + + {purchasesHeaders.package} + {purchasesHeaders.type} + {purchasesHeaders.provider} + {purchasesHeaders.date} + {purchasesHeaders.price} + + + + {recentPurchases.map((purchase) => ( + + {purchase.packageName} + {purchase.type ?? '—'} + {purchase.provider ?? 'Checkout'} + {formatDate(purchase.purchasedAt)} + {renderPrice(purchase.price)} + + ))} + +
+ )} +
+
+
+ + + + {quickActionsCardTitle} +

{quickActionsCardDescription}

+
+ + {quickActions.map((action) => ( +
+
+ + + +
+

{action.label}

+

{action.description}

+
+
+
+ +
+
+ ))} +
+
+
+
+
); } + +(Dashboard as any).layout = (page: ReactNode) => page; diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 5e928b6..a997d1a 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -29,6 +29,7 @@ export interface SharedData { sidebarOpen: boolean; supportedLocales?: string[]; locale?: string; + translations?: Record>; security?: { csp?: { scriptNonce?: string; diff --git a/resources/views/filament/tenant/pages/onboarding.blade.php b/resources/views/filament/tenant/pages/onboarding.blade.php index 34852a9..c4877f4 100644 --- a/resources/views/filament/tenant/pages/onboarding.blade.php +++ b/resources/views/filament/tenant/pages/onboarding.blade.php @@ -45,7 +45,7 @@ @if ($step === 'intro')
-

So funktioniert Fotospiel

+

So funktioniert die Fotospiel App

1 · Aufgaben auswählen

diff --git a/routes/api.php b/routes/api.php index b30e0db..0305b13 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenController; use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController; use App\Http\Controllers\Api\Tenant\EventTypeController; use App\Http\Controllers\Api\Tenant\NotificationLogController; +use App\Http\Controllers\Api\Tenant\OnboardingController; use App\Http\Controllers\Api\Tenant\PhotoController; use App\Http\Controllers\Api\Tenant\ProfileController; use App\Http\Controllers\Api\Tenant\SettingsController; @@ -63,6 +64,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { Route::get('profile', [ProfileController::class, 'show'])->name('tenant.profile.show'); Route::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update'); + Route::get('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show'); + Route::post('onboarding', [OnboardingController::class, 'store'])->name('tenant.onboarding.store'); Route::get('me', [OAuthController::class, 'me'])->name('tenant.me'); Route::get('dashboard', DashboardController::class)->name('tenant.dashboard'); Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index'); diff --git a/routes/web.php b/routes/web.php index c851fe3..1482bb3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -134,6 +134,11 @@ Route::prefix('{locale}') ->name('buy.packages') ->defaults('locale', config('app.locale', 'de')); + Route::middleware('auth')->group(function () { + Route::get('/profile', [ProfileController::class, 'index']) + ->name('marketing.profile.index'); + }); + Route::fallback(function () { return Inertia::render('marketing/NotFound', [ 'requestedPath' => request()->path(), diff --git a/tests/Feature/Dashboard/DashboardPageTest.php b/tests/Feature/Dashboard/DashboardPageTest.php index 7fe4af7..c05b887 100644 --- a/tests/Feature/Dashboard/DashboardPageTest.php +++ b/tests/Feature/Dashboard/DashboardPageTest.php @@ -90,11 +90,15 @@ class DashboardPageTest extends TestCase ->where('task_progress', 100) ->where('upcoming_events', 1) ->where('new_photos', 1) - ->where('credit_balance', 4) ->etc() ) ->where('tenant.activePackage.name', 'Premium Paket') ->where('tenant.activePackage.remainingEvents', 9) + ->has('onboarding', fn (AssertableInertia $steps) => $steps + ->where('0.key', 'admin_app') + ->where('1.done', true) + ->etc() + ) ->has('upcomingEvents', 1) ->has('recentPurchases', 1) ->where('emailVerification.mustVerify', true) diff --git a/tests/Feature/Profile/ProfilePageTest.php b/tests/Feature/Profile/ProfilePageTest.php index 3a096cd..187516e 100644 --- a/tests/Feature/Profile/ProfilePageTest.php +++ b/tests/Feature/Profile/ProfilePageTest.php @@ -73,4 +73,18 @@ class ProfilePageTest extends TestCase ) ); } + + public function test_localized_profile_route_resolves_to_profile_page(): void + { + $user = User::factory()->create([ + 'preferred_locale' => 'de', + ]); + + $this->actingAs($user); + + $response = $this->get(route('marketing.profile.index', ['locale' => 'de'])); + + $response->assertStatus(200) + ->assertInertia(fn (AssertableInertia $page) => $page->component('Profile/Index')); + } }