completed the frontend dashboard component and bound it to the tenant admin pwa for the optimal onboarding experience.. Added a profile page.

This commit is contained in:
Codex Agent
2025-11-04 22:28:37 +01:00
parent fe380689fb
commit b32413b108
29 changed files with 1416 additions and 425 deletions

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Support\TenantAuth;
use App\Support\TenantOnboardingState;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class OnboardingController extends Controller
{
public function show(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);
}
$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;
}
}
}

View File

@@ -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,7 +91,8 @@ class DashboardController extends Controller
private function collectRecentPurchases(Tenant $tenant): Collection
{
return $tenant->purchases()
$entries = collect(
$tenant->purchases()
->with('package')
->latest('purchased_at')
->limit(6)
@@ -99,8 +107,104 @@ class DashboardController extends Controller
'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

View File

@@ -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 ? [

View File

@@ -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'),
],
];
}

View File

@@ -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,
];
}

168
lang/de/dashboard.php Normal file
View File

@@ -0,0 +1,168 @@
<?php
return [
'navigation' => [
'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.',
],
],
],
],
];

168
lang/en/dashboard.php Normal file
View File

@@ -0,0 +1,168 @@
<?php
return [
'navigation' => [
'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.',
],
],
],
],
];

View File

@@ -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",

View File

@@ -134,6 +134,42 @@ export type DashboardSummary = {
} | 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<string, unknown>): Promise<void> {
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<TenantOnboardingStatus | null> {
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;

View File

@@ -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 } });

View File

@@ -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<OnboardingProgress> & {
serverStep?: string;
meta?: Record<string, unknown>;
};
type OnboardingContextValue = {
progress: OnboardingProgress;
setProgress: (updater: (prev: OnboardingProgress) => OnboardingProgress) => void;
markStep: (step: Partial<OnboardingProgress>) => 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<OnboardingProgress>(() => 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<OnboardingProgress>) => {
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(() => {

View File

@@ -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]);

View File

@@ -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<string | null>(null);
const exportPreviewContainerRef = React.useRef<HTMLDivElement | null>(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.' }));

View File

@@ -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<SharedData>().props;
const areaLabel =
(translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich';
return (
<>
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
<div className="flex items-center gap-3">
<img src="/logo-transparent-md.png" alt="Fotospiel" className="h-10 w-auto" />
<div className="grid text-left leading-tight">
<span className="text-sm font-semibold text-sidebar-foreground">Fotospiel</span>
<span className="text-xs text-sidebar-foreground/70">{areaLabel}</span>
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">Laravel Starter Kit</span>
</div>
</>
);
}

View File

@@ -8,11 +8,11 @@ interface AppShellProps {
}
export function AppShell({ children, variant = 'header' }: AppShellProps) {
const isOpen = usePage<SharedData>().props.sidebarOpen;
const { sidebarOpen = false } = usePage<SharedData>().props;
if (variant === 'header') {
return <div className="flex min-h-screen w-full flex-col">{children}</div>;
}
return <SidebarProvider defaultOpen={isOpen}>{children}</SidebarProvider>;
return <SidebarProvider defaultOpen={sidebarOpen}>{children}</SidebarProvider>;
}

View File

@@ -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 (
<Sidebar collapsible="icon" variant="inset">
@@ -54,7 +40,6 @@ export function AppSidebar() {
</SidebarContent>
<SidebarFooter>
<NavFooter items={footerNavItems} className="mt-auto" />
<NavUser />
</SidebarFooter>
</Sidebar>

View File

@@ -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<SharedData>();
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<string, string | undefined>) ?? {};
const label = languageCopy.label ?? 'Sprache';
const changeLabel = languageCopy.change ?? 'Sprache wechseln';
const [pendingLocale, setPendingLocale] = useState<string | null>(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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2 rounded-full border-white/30 bg-white/80 px-3 text-xs font-semibold uppercase tracking-wide text-slate-900 backdrop-blur transition hover:bg-white dark:border-white/20 dark:bg-white/10 dark:text-white"
aria-label={label}
>
<Languages className="size-4" />
<span>{label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[9rem]">
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{changeLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{locales.map((code) => {
const isActive = code === activeLocale;
const isPending = code === pendingLocale;
return (
<DropdownMenuItem
key={code}
onSelect={(event) => {
event.preventDefault();
handleChange(code);
}}
disabled={isPending}
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium uppercase tracking-wide">{code}</span>
{(isActive || isPending) && <Check className="size-4 text-pink-500" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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<SharedData>();
const { translations } = page.props;
const groupLabel =
(translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich';
return (
<SidebarGroup className="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>{groupLabel}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>

View File

@@ -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) {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link className="block w-full" href={edit()} as="button" prefetch onClick={cleanup}>
<Settings className="mr-2" />
Settings
<Link className="block w-full" href={profileRoutes.index().url} as="button" prefetch onClick={cleanup}>
<UserRound className="mr-2" />
Profil
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
@@ -39,7 +39,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
<DropdownMenuItem asChild>
<Link className="block w-full" href={logout()} as="button" onClick={handleLogout}>
<LogOut className="mr-2" />
Log out
Abmelden
</Link>
</DropdownMenuItem>
</>

View File

@@ -236,6 +236,7 @@ function getErrorContent(
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
return (
<EventBrandingProvider>
<div className="pb-16">
<Header title={title} />
<div className="px-4 py-3">
@@ -243,5 +244,6 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
</div>
<BottomNav />
</div>
</EventBrandingProvider>
);
}

View File

@@ -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 = () => {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="font-sans-marketing">
<Link href={localizedPath('/profile')}>
<Link href={profileRoutes.index().url}>
Profil
</Link>
</DropdownMenuItem>
@@ -376,7 +377,7 @@ const Header: React.FC = () => {
<>
<SheetClose asChild>
<Link
href={localizedPath('/profile')}
href={profileRoutes.index().url}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>

View File

@@ -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() {
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Profil" />
<div className="flex flex-1 flex-col gap-6 pb-12">
<div className="flex flex-1 flex-col gap-6 pb-12 pt-24 lg:pt-28">
<Card className="border-border/60 bg-gradient-to-br from-background to-muted/50 shadow-sm">
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
@@ -167,7 +166,7 @@ export default function ProfileIndex() {
<div className="grid gap-3 rounded-lg border border-dashed border-muted-foreground/30 bg-muted/30 p-4 md:grid-cols-2">
<div>
<h3 className="text-base font-medium leading-tight">{tenant.activePackage.name}</h3>
<p className="text-xs text-muted-foreground">{tenant.eventCreditsBalance ?? 0} Credits verfügbar · {tenant.activePackage.remainingEvents ?? 0} Events inklusive</p>
<p className="text-xs text-muted-foreground">{tenant.activePackage.remainingEvents ?? 0} Events inklusive</p>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
@@ -187,7 +186,7 @@ export default function ProfileIndex() {
) : (
<Alert className="border border-dashed border-muted-foreground/40 bg-muted/20">
<AlertDescription className="text-sm text-muted-foreground">
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.
</AlertDescription>
</Alert>
)}

View File

@@ -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<unknown>((accumulator, segment) => {
if (accumulator && typeof accumulator === 'object' && segment in (accumulator as Record<string, unknown>)) {
return (accumulator as Record<string, unknown>)[segment];
}
return undefined;
}, source);
};
export default function Dashboard() {
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale } = usePage<SharedData & DashboardPageProps>().props;
const page = usePage<SharedData & DashboardPageProps>();
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale, onboarding } = page.props;
const { auth, supportedLocales } = page.props;
const translations = (page.props.translations?.dashboard ?? {}) as Record<string, unknown>;
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<string, string | number> = {}) => {
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 = [
const stats = useMemo(() => {
const activeEvents = metrics?.active_events ?? 0;
const upcomingEventsCount = metrics?.upcoming_events ?? 0;
const newPhotos = metrics?.new_photos ?? 0;
return [
{
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: '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: '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: '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: '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: '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: '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,
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 quickActions = [
const quickActions = useMemo(() => {
const languageAwarePackagesHref = `/${locale ?? supportedLocales?.[0] ?? 'de'}/packages`;
return [
{
key: 'tenant-admin',
label: 'Event-Admin öffnen',
description: 'Detaillierte Eventverwaltung, Moderation und Live-Features.',
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: 'Profil verwalten',
description: 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.',
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: 'Passwort aktualisieren',
description: 'Sichere dein Konto mit einem aktuellen Passwort.',
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: 'Pakete entdecken',
description: 'Mehr Events oder Speicher buchen du bleibst flexibel.',
href: `/${locale ?? 'de'}/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 stats = [
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: '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: '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: '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: '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: '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: '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.'),
},
{
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,
},
];
],
}), [translate]);
const handleResendVerification = () => {
setSendingVerification(true);
@@ -247,27 +292,140 @@ 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 (
<AppLayout breadcrumbs={breadcrumbs}>
<AppLayout>
<Head title="Dashboard" />
<div className="mt-8 flex flex-1 flex-col gap-8 pb-16">
<div className="relative pb-16">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.25),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_60%)] blur-3xl"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/85 to-[#1d1130]" />
<div className="relative z-10 mx-auto w-full max-w-6xl px-4 sm:px-6">
<div className="overflow-hidden rounded-3xl border border-white/15 bg-white/95 shadow-2xl shadow-fuchsia-500/10 backdrop-blur-2xl dark:border-gray-800/70 dark:bg-gray-950/85">
<div className="flex flex-1 flex-col gap-8 p-6 sm:p-10">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
<div className="space-y-4">
<span className="inline-flex items-center gap-2 rounded-full border border-pink-200/60 bg-pink-50/80 px-4 py-1 text-xs font-semibold uppercase tracking-[0.4em] text-pink-600 shadow-sm dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-200">
{heroBadge}
</span>
<div className="space-y-3">
<h1 className="font-display text-3xl tracking-tight text-gray-900 sm:text-4xl dark:text-white">{greetingTitle}</h1>
<div className="space-y-2 text-sm text-muted-foreground sm:text-base">
<p>{heroSubtitle}</p>
<p>{heroDescription}</p>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-4 md:items-end">
<DashboardLanguageSwitcher />
<img src="/logo-transparent-lg.png" alt="Fotospiel" className="h-24 w-auto drop-shadow-xl" />
</div>
</div>
</div>
{needsEmailVerification && (
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-950 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-100">
<Alert variant="warning" className="border-amber-300/60 bg-white/95 text-amber-900 shadow-sm shadow-amber-200/40 dark:border-amber-500/50 dark:bg-amber-950/60 dark:text-amber-100">
<AlertTriangle className="size-5" />
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle>Bitte bestätige deine E-Mail-Adresse</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
<AlertTitle>{emailTitle}</AlertTitle>
<AlertDescription>{emailDescription}</AlertDescription>
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button size="sm" variant="outline" onClick={handleResendVerification} disabled={sendingVerification}>
{sendingVerification ? 'Sende...' : 'Link erneut senden'}
{sendingVerification ? resendPendingLabel : resendIdleLabel}
</Button>
{verificationSent && <span className="text-xs text-muted-foreground">Wir haben dir gerade einen neuen Bestätigungslink geschickt.</span>}
{verificationSent && <span className="text-xs text-muted-foreground">{emailSuccess}</span>}
</div>
</div>
</Alert>
@@ -275,25 +433,22 @@ export default function Dashboard() {
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.key} className="bg-gradient-to-br from-background to-muted/60 shadow-sm">
<Card key={stat.key} className="border border-white/20 bg-white/90 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader className="flex flex-row items-start justify-between gap-2 pb-2">
<div>
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
</div>
<span className="rounded-full border bg-background/70 p-2 text-muted-foreground">
<span className="rounded-full bg-gradient-to-br from-pink-500/20 to-purple-500/30 p-2 text-pink-600 dark:text-pink-300">
<stat.icon className="size-5" />
</span>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>{stat.description}</p>
{stat.key === 'credit-balance' && stat.extra !== null && (
<div className="text-xs text-muted-foreground">{stat.extra} weitere Events im aktuellen Paket enthalten.</div>
)}
{stat.key === 'task-progress' && (
<div className="space-y-1">
<Progress value={taskProgress} />
<span className="text-xs text-muted-foreground">{taskProgress}% deiner Event-Checkliste erledigt.</span>
<span className="text-xs text-muted-foreground">{taskProgressNote}</span>
</div>
)}
</CardContent>
@@ -301,140 +456,169 @@ export default function Dashboard() {
))}
</div>
<Card className="overflow-hidden border border-white/20 bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] text-white shadow-xl shadow-fuchsia-500/20">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<CardTitle className="text-white">{spotlight.title}</CardTitle>
<p className="text-sm text-white/80">{spotlight.description}</p>
</div>
<Button asChild size="lg" className="h-11 rounded-full bg-white px-6 text-sm font-semibold text-gray-900 shadow-lg shadow-fuchsia-500/30 transition hover:bg-white/90">
<Link href="/event-admin" prefetch>
<Smartphone className="mr-2 size-4" /> {spotlight.cta}
</Link>
</Button>
</CardHeader>
<CardContent className="grid gap-4 text-sm text-white/80 md:grid-cols-3">
{spotlight.items.map((item) => (
<div key={item.key}>
<p className="font-semibold text-white">{item.title}</p>
<p>{item.description}</p>
</div>
))}
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-3">
<Card className="xl:col-span-2">
<Card className="border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader>
<CardTitle>{onboardingCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{onboardingCardDescription}</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
{onboardingSteps.map((step) => (
<div key={step.key} className="rounded-2xl border border-muted/40 bg-white/90 p-4 shadow-sm dark:border-gray-800/60 dark:bg-gray-950/70">
<div className="flex items-start gap-3">
<Checkbox checked={step.done} className="mt-1" readOnly />
<div className="space-y-2">
<div>
<p className="text-sm font-medium leading-tight text-foreground">{step.title}</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
{!step.done && step.cta ? (
<Button asChild size="sm" variant="outline">
<Link href={step.cta} prefetch>
{step.ctaLabel ?? onboardingFallbackCta}
</Link>
</Button>
) : null}
</div>
</div>
</div>
))}
{onboardingSteps.length === 0 && (
<p className="text-sm text-muted-foreground">{onboardingCompletedCopy}</p>
)}
</div>
</CardContent>
</Card>
<Card className="xl:col-span-2 border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Bevorstehende Events</CardTitle>
<p className="text-sm text-muted-foreground">Status, Uploads und Aufgaben deiner nächsten Events im Überblick.</p>
<CardTitle>{upcomingCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{upcomingCardDescription}</p>
</div>
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>
{upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'}
</Badge>
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>{upcomingBadgeLabel}</Badge>
</CardHeader>
<CardContent className="space-y-4">
{upcomingEvents.length === 0 && (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-6 text-center">
<p className="text-sm text-muted-foreground">
Plane dein erstes Event und begleite den gesamten Ablauf vom Briefing bis zur Nachbereitung direkt hier im Dashboard.
</p>
<p className="text-sm text-muted-foreground">{upcomingEmptyCopy}</p>
</div>
)}
{upcomingEvents.map((event) => (
<div key={event.id} className="rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm">
<div key={event.id} className="rounded-xl border border-muted/40 bg-white/90 p-4 shadow-sm dark:border-gray-800/60 dark:bg-gray-950/70">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold leading-tight">{event.name}</h3>
<p className="text-sm text-muted-foreground">
{formatDate(event.date)} · {event.status === 'published' || event.isActive ? 'Live' : 'In Vorbereitung'}
{formatDate(event.date)} · {event.status === 'published' || event.isActive ? eventLiveStatus : eventUpcomingStatus}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-1">{event.photosCount} Fotos</span>
<span className="rounded-full bg-muted px-2 py-1">{event.tasksCount} Aufgaben</span>
<span className="rounded-full bg-muted px-2 py-1">{event.joinTokensCount} Links</span>
<span className="rounded-full bg-muted px-2 py-1">{event.photosCount} {eventPhotoLabel}</span>
<span className="rounded-full bg-muted px-2 py-1">{event.tasksCount} {eventTaskLabel}</span>
<span className="rounded-full bg-muted px-2 py-1">{event.joinTokensCount} {eventLinksLabel}</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-6">
<Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader>
<CardTitle>Nächstes Paket &amp; Credits</CardTitle>
<p className="text-sm text-muted-foreground">Behalte Laufzeiten und verfügbaren Umfang stets im Blick.</p>
<CardTitle>{packageCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{packageCardDescription}</p>
</CardHeader>
<CardContent className="space-y-3">
{tenant?.activePackage ? (
<div className="space-y-2">
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{tenant.activePackage.name}</span>
<Badge variant="outline">{tenant.activePackage.remainingEvents ?? 0} Events übrig</Badge>
<span className="font-medium text-foreground">{tenant.activePackage.name}</span>
<Badge variant="outline">
{formatMessage('cards_section.package.remaining', `${tenant.activePackage.remainingEvents ?? 0} Events`, {
count: tenant.activePackage.remainingEvents ?? 0,
})}
</Badge>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Läuft ab</span>
<span>{packageExpiresLabel}</span>
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>Preis</span>
<span>{packagePriceLabel}</span>
<span>{renderPrice(tenant.activePackage.price)}</span>
</div>
</div>
{latestPurchase && (
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
Zuletzt gebucht am {formatDate(latestPurchase.purchasedAt)} via {latestPurchase.provider?.toUpperCase() ?? 'Checkout'}.
</div>
{latestPurchaseInfo && (
<div className="rounded-md bg-muted/40 p-3 text-xs text-muted-foreground">{latestPurchaseInfo}</div>
)}
</div>
) : (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
Noch kein aktives Paket. <Link href={`/${locale ?? 'de'}/packages`} className="font-medium underline underline-offset-4">Jetzt Paket auswählen</Link> und direkt Events planen.
<div className="rounded-md border border-dashed border-muted-foreground/40 p-4 text-sm text-muted-foreground">
{packageEmptyCopy}{' '}
<Link href={`/${locale ?? 'de'}/packages`} className="font-medium underline underline-offset-4">
{packageEmptyCta}
</Link>{' '}
{packageEmptySuffix}
</div>
)}
<Separator />
<div className="flex items-center justify-between text-sm">
<span>Event Credits insgesamt</span>
<span className="font-medium">{tenant?.eventCreditsBalance ?? 0}</span>
</div>
<div className="text-xs text-muted-foreground">
Credits werden bei neuen Events automatisch verbraucht. Zusätzliche Kontingente kannst du jederzeit buchen.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Dein Start in 5 Schritten</CardTitle>
<p className="text-sm text-muted-foreground">Folge den wichtigsten Schritten, um dein Event reibungslos aufzusetzen.</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-3">
{checklistItems.map((item) => (
<div key={item.key} className="flex gap-3 rounded-md border border-border/60 bg-background/50 p-3">
<Checkbox checked={item.done} className="mt-1" readOnly />
<div>
<p className="text-sm font-medium leading-tight">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<Card>
<Card className="border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Aktuelle Buchungen</CardTitle>
<p className="text-sm text-muted-foreground">Verfolge deine gebuchten Pakete und Erweiterungen.</p>
<CardTitle>{purchasesCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{purchasesCardDescription}</p>
</div>
<Badge variant="outline">{recentPurchases.length} Einträge</Badge>
<Badge variant="outline">{purchasesBadge}</Badge>
</CardHeader>
<CardContent>
{recentPurchases.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-center text-sm text-muted-foreground">
Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.
<div className="rounded-md border border-dashed border-muted-foreground/40 p-6 text-center text-sm text-muted-foreground">
{purchasesEmptyCopy}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead className="hidden sm:table-cell">Typ</TableHead>
<TableHead className="hidden md:table-cell">Anbieter</TableHead>
<TableHead className="hidden md:table-cell">Datum</TableHead>
<TableHead className="text-right">Preis</TableHead>
<TableHead>{purchasesHeaders.package}</TableHead>
<TableHead className="hidden sm:table-cell">{purchasesHeaders.type}</TableHead>
<TableHead className="hidden md:table-cell">{purchasesHeaders.provider}</TableHead>
<TableHead className="hidden md:table-cell">{purchasesHeaders.date}</TableHead>
<TableHead className="text-right">{purchasesHeaders.price}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentPurchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableRow key={`${purchase.source ?? 'entry'}-${purchase.id}`}>
<TableCell className="font-medium">{purchase.packageName}</TableCell>
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
@@ -447,15 +631,16 @@ export default function Dashboard() {
)}
</CardContent>
</Card>
</div>
<Card>
<Card className="border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader>
<CardTitle>Schnellzugriff</CardTitle>
<p className="text-sm text-muted-foreground">Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.</p>
<CardTitle>{quickActionsCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{quickActionsCardDescription}</p>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{quickActions.map((action) => (
<div key={action.key} className="flex flex-col justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-4">
<div key={action.key} className="flex flex-col justify-between gap-3 rounded-xl border border-muted/40 bg-white/90 p-4 shadow-sm dark:border-gray-800/60 dark:bg-gray-950/70">
<div className="flex items-center gap-3">
<span className="rounded-full bg-muted p-2 text-muted-foreground">
<action.icon className="size-5" />
@@ -468,7 +653,7 @@ export default function Dashboard() {
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm">
<Link href={action.href} prefetch>
Weiter
{quickActionsCta}
</Link>
</Button>
</div>
@@ -477,6 +662,11 @@ export default function Dashboard() {
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</AppLayout>
);
}
(Dashboard as any).layout = (page: ReactNode) => page;

View File

@@ -29,6 +29,7 @@ export interface SharedData {
sidebarOpen: boolean;
supportedLocales?: string[];
locale?: string;
translations?: Record<string, Record<string, string>>;
security?: {
csp?: {
scriptNonce?: string;

View File

@@ -45,7 +45,7 @@
@if ($step === 'intro')
<section class="rounded-3xl border border-white/70 bg-gradient-to-br from-rose-50 via-white to-sky-50 p-10 shadow-lg shadow-rose-100/50">
<div class="space-y-6 text-center">
<h2 class="text-3xl font-semibold text-slate-900">So funktioniert Fotospiel</h2>
<h2 class="text-3xl font-semibold text-slate-900">So funktioniert die Fotospiel App</h2>
<div class="grid gap-6 md:grid-cols-3">
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
<h3 class="font-semibold text-slate-900">1 · Aufgaben auswählen</h3>

View File

@@ -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');

View File

@@ -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(),

View File

@@ -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)

View File

@@ -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'));
}
}