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:
125
app/Http/Controllers/Api/Tenant/OnboardingController.php
Normal file
125
app/Http/Controllers/Api/Tenant/OnboardingController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? [
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
168
lang/de/dashboard.php
Normal 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
168
lang/en/dashboard.php
Normal 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.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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",
|
||||
|
||||
@@ -122,18 +122,54 @@ export type PaginatedResult<T> = {
|
||||
};
|
||||
|
||||
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<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;
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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.' }));
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
86
resources/js/components/dashboard-language-switcher.tsx
Normal file
86
resources/js/components/dashboard-language-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -236,12 +236,14 @@ function getErrorContent(
|
||||
|
||||
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header title={title} />
|
||||
<div className="px-4 py-3">
|
||||
{children}
|
||||
<EventBrandingProvider>
|
||||
<div className="pb-16">
|
||||
<Header title={title} />
|
||||
<div className="px-4 py-3">
|
||||
{children}
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
</EventBrandingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
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 (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<AppLayout>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<div className="mt-8 flex flex-1 flex-col gap-8 pb-16">
|
||||
{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">
|
||||
<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>
|
||||
</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'}
|
||||
</Button>
|
||||
{verificationSent && <span className="text-xs text-muted-foreground">Wir haben dir gerade einen neuen Bestätigungslink geschickt.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<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>
|
||||
</div>
|
||||
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>
|
||||
{upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsEmailVerification && (
|
||||
<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>{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 ? resendPendingLabel : resendIdleLabel}
|
||||
</Button>
|
||||
{verificationSent && <span className="text-xs text-muted-foreground">{emailSuccess}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{upcomingEvents.map((event) => (
|
||||
<div key={event.id} className="rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm">
|
||||
<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'}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nächstes Paket & Credits</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Behalte Laufzeiten und verfügbaren Umfang stets im Blick.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{tenant?.activePackage ? (
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Läuft ab</span>
|
||||
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Preis</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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
<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 className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<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 leading-tight">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
|
||||
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<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>
|
||||
</div>
|
||||
<Badge variant="outline">{recentPurchases.length} Einträge</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.
|
||||
<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 === 'task-progress' && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={taskProgress} />
|
||||
<span className="text-xs text-muted-foreground">{taskProgressNote}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentPurchases.map((purchase) => (
|
||||
<TableRow key={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>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{renderPrice(purchase.price)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Schnellzugriff</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.</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 className="flex items-center gap-3">
|
||||
<span className="rounded-full bg-muted p-2 text-muted-foreground">
|
||||
<action.icon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{action.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{action.description}</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={action.href} prefetch>
|
||||
Weiter
|
||||
<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>
|
||||
</div>
|
||||
</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="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>{upcomingCardTitle}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{upcomingCardDescription}</p>
|
||||
</div>
|
||||
<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">{upcomingEmptyCopy}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcomingEvents.map((event) => (
|
||||
<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 ? 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} {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>
|
||||
))}
|
||||
</CardContent>
|
||||
</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>{packageCardTitle}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{packageCardDescription}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{tenant?.activePackage ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<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>{packageExpiresLabel}</span>
|
||||
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{packagePriceLabel}</span>
|
||||
<span>{renderPrice(tenant.activePackage.price)}</span>
|
||||
</div>
|
||||
</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/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>
|
||||
)}
|
||||
</CardContent>
|
||||
</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>{purchasesCardTitle}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{purchasesCardDescription}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{purchasesBadge}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentPurchases.length === 0 ? (
|
||||
<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>{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.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>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{renderPrice(purchase.price)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<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>{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-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" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{action.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={action.href} prefetch>
|
||||
{quickActionsCta}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
(Dashboard as any).layout = (page: ReactNode) => page;
|
||||
|
||||
1
resources/js/types/index.d.ts
vendored
1
resources/js/types/index.d.ts
vendored
@@ -29,6 +29,7 @@ export interface SharedData {
|
||||
sidebarOpen: boolean;
|
||||
supportedLocales?: string[];
|
||||
locale?: string;
|
||||
translations?: Record<string, Record<string, string>>;
|
||||
security?: {
|
||||
csp?: {
|
||||
scriptNonce?: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user