From fd788ef7701c3c2e679595ef1286a429608116b3 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 25 Nov 2025 09:47:39 +0100 Subject: [PATCH] =?UTF-8?q?neuer=20demo=20tenant=20switcher=20+=20demo=20t?= =?UTF-8?q?enants=20mit=20eigenem=20artisan=20command.=20Event=20Admin=20?= =?UTF-8?q?=C3=BCberarbeitet,=20aber=20das=20ist=20nur=20ein=20Zwischensta?= =?UTF-8?q?nd.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/SeedDemoSwitcherTenants.php | 613 ++++++++++++++++++ bootstrap/app.php | 1 + config/services.php | 4 + resources/js/admin/auth/tokens.ts | 64 +- resources/js/admin/components/AdminLayout.tsx | 42 +- .../js/admin/components/CommandShelf.tsx | 38 +- .../js/admin/components/DevTenantSwitcher.tsx | 8 +- resources/js/admin/constants.ts | 2 + resources/js/admin/context/EventContext.tsx | 31 +- resources/js/admin/dev-tools.ts | 8 +- .../js/admin/i18n/locales/de/common.json | 15 +- .../js/admin/i18n/locales/de/management.json | 14 + .../js/admin/i18n/locales/en/common.json | 15 +- .../js/admin/i18n/locales/en/management.json | 14 + resources/js/admin/lib/eventTabs.ts | 6 + resources/js/admin/pages/DashboardPage.tsx | 40 +- resources/js/admin/pages/EventDetailPage.tsx | 360 +++++----- resources/js/admin/pages/EventInvitesPage.tsx | 7 - resources/js/admin/pages/EventPhotosPage.tsx | 28 +- resources/js/admin/pages/EventTasksPage.tsx | 45 +- resources/js/admin/pages/LiveRedirectPage.tsx | 37 ++ resources/js/admin/router.tsx | 3 + 22 files changed, 1096 insertions(+), 299 deletions(-) create mode 100644 app/Console/Commands/SeedDemoSwitcherTenants.php create mode 100644 resources/js/admin/pages/LiveRedirectPage.tsx diff --git a/app/Console/Commands/SeedDemoSwitcherTenants.php b/app/Console/Commands/SeedDemoSwitcherTenants.php new file mode 100644 index 0000000..2406162 --- /dev/null +++ b/app/Console/Commands/SeedDemoSwitcherTenants.php @@ -0,0 +1,613 @@ +environment(['local', 'development', 'demo'])) { + $this->error('Cleanup/Seeding is restricted to local/development/demo environments.'); + + return self::FAILURE; + } + + if ($this->option('cleanup')) { + return $this->cleanup(); + } + + $this->info('Seeding demo tenants for switcher...'); + + $packages = $this->loadPackages(); + $eventTypes = $this->loadEventTypes(); + + DB::transaction(function () use ($packages, $eventTypes) { + $this->seedCustomerStandardEmpty($packages, $eventTypes); + $this->seedCustomerStarterWedding($packages, $eventTypes); + $this->seedResellerActive($packages, $eventTypes); + $this->seedResellerFull($packages, $eventTypes); + }); + + if ($this->option('with-photos')) { + $this->seedPhotosFromPexels((int) $this->option('photos-per-event')); + } + + $this->info('Done.'); + + return self::SUCCESS; + } + + private function cleanup(): int + { + $slugs = [ + 'demo-standard-empty', + 'demo-starter-wedding', + 'demo-reseller-active', + 'demo-reseller-full', + ]; + + $eventsDeleted = 0; + $photosDeleted = 0; + $photoLikesDeleted = 0; + $usersDeleted = 0; + + foreach ($slugs as $slug) { + $tenant = Tenant::where('slug', $slug)->first(); + + if (! $tenant) { + continue; + } + + foreach ($tenant->events as $event) { + $eventsDeleted++; + $photos = Photo::where('event_id', $event->id)->get(); + foreach ($photos as $photo) { + $deletedLikes = $photo->likes()->count(); + $photo->likes()->delete(); + $photoLikesDeleted += $deletedLikes; + + if ($photo->thumbnail_path) { + Storage::disk('public')->delete($photo->thumbnail_path); + } + + if ($photo->file_path) { + Storage::disk('public')->delete($photo->file_path); + } + + $photo->delete(); + $photosDeleted++; + } + + Storage::disk('public')->deleteDirectory("events/{$event->id}/gallery"); + Storage::disk('public')->deleteDirectory("events/{$event->id}/gallery/thumbs"); + + $event->taskCollections()->detach(); + $event->tasks()->detach(); + $event->eventPackages()->delete(); + $event->delete(); + } + + TenantPackage::where('tenant_id', $tenant->id)->delete(); + $usersDeleted += User::where('tenant_id', $tenant->id)->count(); + User::where('tenant_id', $tenant->id)->delete(); + $tenant->delete(); + } + + $this->info( + 'Cleanup completed. Tenants deleted: '.count($slugs) + .", Users deleted: {$usersDeleted}, Events deleted: {$eventsDeleted}, Photos deleted: {$photosDeleted}, Photo likes deleted: {$photoLikesDeleted}" + ); + + return self::SUCCESS; + } + + private function loadPackages(): array + { + $slugs = [ + 'starter' => 'Starter', + 'standard' => 'Standard', + 's-small-reseller' => 'Reseller S', + ]; + + $packages = []; + foreach ($slugs as $slug => $label) { + $package = Package::where('slug', $slug)->first(); + if (! $package) { + $this->error("Package {$label} ({$slug}) not found. Run PackageSeeder first."); + abort(1); + } + + $packages[$slug] = $package; + } + + return $packages; + } + + private function loadEventTypes(): array + { + $slugs = ['wedding', 'corporate', 'birthday', 'festival']; + $types = []; + + foreach ($slugs as $slug) { + $eventType = EventType::where('slug', $slug)->first(); + if ($eventType) { + $types[$slug] = $eventType; + } + } + + return $types; + } + + private function seedCustomerStandardEmpty(array $packages, array $eventTypes): void + { + $tenant = $this->upsertTenant( + slug: 'demo-standard-empty', + name: 'Demo Standard (ohne Event)', + contactEmail: 'standard-empty@demo.fotospiel', + attributes: [ + 'subscription_tier' => 'standard', + 'subscription_status' => 'active', + 'event_credits_balance' => 1, + ], + ); + + $this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel'); + + TenantPackage::updateOrCreate( + ['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id], + [ + 'price' => $packages['standard']->price, + 'purchased_at' => Carbon::now()->subDays(1), + 'expires_at' => Carbon::now()->addMonths(12), + 'used_events' => 0, + 'active' => true, + ] + ); + + $this->comment('Seeded Standard tenant without events.'); + } + + private function seedCustomerStarterWedding(array $packages, array $eventTypes): void + { + $tenant = $this->upsertTenant( + slug: 'demo-starter-wedding', + name: 'Demo Starter Wedding', + contactEmail: 'starter-wedding@demo.fotospiel', + attributes: [ + 'subscription_tier' => 'starter', + 'subscription_status' => 'active', + 'event_credits_balance' => 0, + ], + ); + + $this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel'); + + $event = $this->upsertEvent( + tenant: $tenant, + package: $packages['starter'], + eventType: $eventTypes['wedding'] ?? null, + attributes: [ + 'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'], + 'slug' => 'demo-starter-wedding', + 'status' => 'published', + 'is_active' => true, + 'date' => Carbon::now()->addWeeks(5), + ], + ); + + $this->attachDefaultCollections($event); + } + + private function seedResellerActive(array $packages, array $eventTypes): void + { + $tenant = $this->upsertTenant( + slug: 'demo-reseller-active', + name: 'Demo Reseller Active', + contactEmail: 'reseller-active@demo.fotospiel', + attributes: [ + 'subscription_tier' => 'reseller', + 'subscription_status' => 'active', + 'event_credits_balance' => 2, + ], + ); + + $this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel'); + + TenantPackage::updateOrCreate( + ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], + [ + 'price' => $packages['s-small-reseller']->price, + 'purchased_at' => Carbon::now()->subMonths(1), + 'expires_at' => Carbon::now()->addMonths(11), + 'used_events' => 3, + 'active' => true, + ] + ); + + $events = [ + [ + 'name' => ['de' => 'Corporate Summit', 'en' => 'Corporate Summit'], + 'slug' => 'demo-reseller-corporate', + 'type' => $eventTypes['corporate'] ?? null, + 'date' => Carbon::now()->addWeeks(3), + ], + [ + 'name' => ['de' => 'Sommerfestival', 'en' => 'Summer Festival'], + 'slug' => 'demo-reseller-festival', + 'type' => $eventTypes['festival'] ?? ($eventTypes['birthday'] ?? null), + 'date' => Carbon::now()->addWeeks(6), + ], + [ + 'name' => ['de' => 'Geburtstag Lisa', 'en' => 'Lisa Birthday'], + 'slug' => 'demo-reseller-birthday', + 'type' => $eventTypes['birthday'] ?? null, + 'date' => Carbon::now()->addWeeks(9), + ], + ]; + + foreach ($events as $index => $config) { + $event = $this->upsertEvent( + tenant: $tenant, + package: $packages['standard'], + eventType: $config['type'], + attributes: [ + 'name' => $config['name'], + 'slug' => $config['slug'], + 'status' => 'published', + 'is_active' => true, + 'date' => $config['date'], + ], + ); + + $this->attachDefaultCollections($event); + } + } + + private function seedResellerFull(array $packages, array $eventTypes): void + { + $tenant = $this->upsertTenant( + slug: 'demo-reseller-full', + name: 'Demo Reseller Voll', + contactEmail: 'reseller-full@demo.fotospiel', + attributes: [ + 'subscription_tier' => 'reseller', + 'subscription_status' => 'active', + 'event_credits_balance' => 0, + ], + ); + + $this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel'); + + TenantPackage::updateOrCreate( + ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], + [ + 'price' => $packages['s-small-reseller']->price, + 'purchased_at' => Carbon::now()->subMonths(6), + 'expires_at' => Carbon::now()->addMonths(6), + 'used_events' => 5, + 'active' => true, + ] + ); + + $eventConfigs = [ + ['slug' => 'demo-full-wedding', 'name' => ['de' => 'Hochzeit Clara & Ben', 'en' => 'Wedding Clara & Ben'], 'type' => $eventTypes['wedding'] ?? null], + ['slug' => 'demo-full-corporate', 'name' => ['de' => 'Jahrestagung', 'en' => 'Annual Summit'], 'type' => $eventTypes['corporate'] ?? null], + ['slug' => 'demo-full-birthday', 'name' => ['de' => 'Geburtstag Jonas', 'en' => 'Birthday Jonas'], 'type' => $eventTypes['birthday'] ?? null], + ['slug' => 'demo-full-festival', 'name' => ['de' => 'Stadtfest', 'en' => 'City Festival'], 'type' => $eventTypes['festival'] ?? null], + ['slug' => 'demo-full-christmas', 'name' => ['de' => 'Weihnachtsfeier', 'en' => 'Christmas Party'], 'type' => $eventTypes['corporate'] ?? null], + ]; + + foreach ($eventConfigs as $index => $config) { + $event = $this->upsertEvent( + tenant: $tenant, + package: $packages['standard'], + eventType: $config['type'], + attributes: [ + 'name' => $config['name'], + 'slug' => $config['slug'], + 'status' => 'archived', + 'is_active' => false, + 'date' => Carbon::now()->subWeeks(5 - $index), + ], + ); + + $this->attachDefaultCollections($event); + } + } + + private function upsertTenant(string $slug, string $name, string $contactEmail, array $attributes = []): Tenant + { + $defaults = [ + 'name' => $name, + 'contact_email' => $contactEmail, + 'subscription_expires_at' => Carbon::now()->addMonths(12), + 'is_active' => true, + 'is_suspended' => false, + 'settings_updated_at' => Carbon::now(), + 'settings' => [ + 'branding' => [ + 'logo_url' => null, + 'primary_color' => '#1D4ED8', + 'secondary_color' => '#0F172A', + 'font_family' => 'Inter, sans-serif', + ], + 'features' => [ + 'photo_likes_enabled' => true, + 'event_checklist' => true, + ], + 'contact_email' => $contactEmail, + ], + ]; + + return Tenant::updateOrCreate( + ['slug' => $slug], + array_merge($defaults, $attributes, ['slug' => $slug]) + ); + } + + private function upsertAdmin(Tenant $tenant, string $email): User + { + $password = config('seeding.demo_tenant_password', 'Demo1234!'); + + $user = User::updateOrCreate( + ['email' => $email], + [ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + 'password' => Hash::make($password), + 'first_name' => Str::headline(Str::before($tenant->slug, '-')), + 'last_name' => 'Demo', + ] + ); + + if (! $user->email_verified_at) { + $user->forceFill(['email_verified_at' => now()])->save(); + } + + return $user; + } + + private function upsertEvent(Tenant $tenant, Package $package, ?EventType $eventType, array $attributes): Event + { + $resolvedEventType = $eventType ?? $this->fallbackEventType(); + + $payload = array_merge([ + 'tenant_id' => $tenant->id, + 'event_type_id' => $resolvedEventType?->id, + 'settings' => [ + 'features' => [ + 'photo_likes_enabled' => true, + 'event_checklist' => true, + ], + ], + ], $attributes); + + /** @var Event $event */ + $event = Event::updateOrCreate( + ['slug' => $attributes['slug']], + $payload + ); + + EventPackage::updateOrCreate( + [ + 'event_id' => $event->id, + 'package_id' => $package->id, + ], + [ + 'purchased_price' => $package->price, + 'purchased_at' => Carbon::now()->subDays(2), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30), + ] + ); + + return $event; + } + + private function fallbackEventType(): ?EventType + { + $fallback = EventType::first(); + + if (! $fallback) { + $this->warn('No EventType available, events will miss type. Please run EventTypesSeeder.'); + } + + return $fallback; + } + + private function attachDefaultCollections(Event $event): void + { + if (! $event->event_type_id) { + return; + } + + $collection = TaskCollection::where('event_type_id', $event->event_type_id) + ->where('is_default', true) + ->orderBy('position') + ->first(); + + if (! $collection) { + return; + } + + $event->taskCollections()->syncWithoutDetaching([$collection->id]); + + $taskIds = $collection->tasks()->pluck('tasks.id')->all(); + if ($taskIds !== []) { + $event->tasks()->syncWithoutDetaching($taskIds); + } + } + + private function seedPhotosFromPexels(int $targetPerEvent): void + { + $apiKey = config('services.pexels.key') ?? env('PEXELS_API_KEY'); + if (! $apiKey) { + $this->warn('PEXELS_API_KEY missing, skipping photo download.'); + + return; + } + + $events = Event::whereIn('slug', [ + 'demo-starter-wedding', + 'demo-reseller-corporate', + 'demo-reseller-festival', + 'demo-reseller-birthday', + 'demo-full-wedding', + 'demo-full-corporate', + 'demo-full-birthday', + 'demo-full-festival', + 'demo-full-christmas', + ])->get(); + + foreach ($events as $event) { + $query = $this->guessQueryForEvent($event); + $this->info("Downloading photos for {$event->slug} ({$query})..."); + + $photos = $this->fetchPexels($apiKey, $query, $targetPerEvent); + if ($photos === []) { + $this->warn('No photos returned from Pexels.'); + + continue; + } + + $this->storePhotos($event, $photos); + } + } + + private function guessQueryForEvent(Event $event): string + { + $typeSlug = optional($event->eventType)->slug; + + return match ($typeSlug) { + 'wedding' => 'wedding photography couple', + 'corporate' => 'corporate event people', + 'birthday' => 'birthday party friends', + default => 'event celebration crowd', + }; + } + + private function fetchPexels(string $apiKey, string $query, int $count): array + { + $perPage = min(40, max(5, $count)); + + $response = Http::withHeaders([ + 'Authorization' => $apiKey, + ])->get('https://api.pexels.com/v1/search', [ + 'query' => $query, + 'per_page' => $perPage, + 'orientation' => 'landscape', + ]); + + if (! $response->ok()) { + $this->warn('Pexels request failed: '.$response->status()); + + return []; + } + + $data = $response->json(); + + return Arr::get($data, 'photos', []); + } + + private function storePhotos(Event $event, array $photos): void + { + $tenantId = $event->tenant_id; + $storage = Storage::disk('public'); + $storage->makeDirectory("events/{$event->id}/gallery"); + $storage->makeDirectory("events/{$event->id}/gallery/thumbs"); + + $demoPhotos = Photo::where('event_id', $event->id) + ->where('metadata->demo', true) + ->get(); + + foreach ($demoPhotos as $photo) { + $storage->delete([$photo->file_path, $photo->thumbnail_path]); + $photo->delete(); + } + + $limit = min(count($photos), (int) $this->option('photos-per-event')); + for ($i = 0; $i < $limit; $i++) { + $photo = $photos[$i]; + $src = $photo['src'] ?? []; + $originalUrl = $src['large2x'] ?? $src['large'] ?? null; + $thumbUrl = $src['medium'] ?? $src['small'] ?? $originalUrl; + + if (! $originalUrl) { + continue; + } + + $filename = sprintf('%s-demo-%02d.jpg', $event->slug, $i + 1); + $thumbFilename = sprintf('%s-demo-%02d_thumb.jpg', $event->slug, $i + 1); + + $filePath = "events/{$event->id}/gallery/{$filename}"; + $thumbPath = "events/{$event->id}/gallery/thumbs/{$thumbFilename}"; + + try { + $imageResponse = Http::get($originalUrl); + if ($imageResponse->ok()) { + $storage->put($filePath, $imageResponse->body()); + } + + if ($thumbUrl) { + $thumbResponse = Http::get($thumbUrl); + if ($thumbResponse->ok()) { + $storage->put($thumbPath, $thumbResponse->body()); + } + } + } catch (\Throwable $exception) { + $this->warn('Failed to download image: '.$exception->getMessage()); + + continue; + } + + $timestamp = Carbon::parse($event->date ?? Carbon::now())->addHours($i); + + Photo::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'event_id' => $event->id, + 'file_path' => $filePath, + ], + [ + 'thumbnail_path' => $thumbPath, + 'guest_name' => 'Demo Guest '.($i + 1), + 'likes_count' => rand(1, 25), + 'is_featured' => $i === 0, + 'metadata' => ['demo' => true, 'source' => 'pexels'], + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ] + ); + } + + EventPackage::where('event_id', $event->id)->update([ + 'used_photos' => max($limit, 0), + 'used_guests' => max(15, $event->eventPackage?->used_guests ?? 0), + ]); + + $this->info("Seeded {$limit} photos for {$event->slug}"); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 19f72ac..d7402e8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -31,6 +31,7 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\PurgeExpiredDataExports::class, \App\Console\Commands\ProcessTenantRetention::class, \App\Console\Commands\SendGuestFeedbackReminders::class, + \App\Console\Commands\SeedDemoSwitcherTenants::class, ]) ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { $schedule->command('package:check-status')->dailyAt('06:00'); diff --git a/config/services.php b/config/services.php index 9870978..4aed8e0 100644 --- a/config/services.php +++ b/config/services.php @@ -25,6 +25,10 @@ return [ 'token' => env('POSTMARK_TOKEN'), ], + 'pexels' => [ + 'key' => env('PEXELS_API_KEY'), + ], + 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), diff --git a/resources/js/admin/auth/tokens.ts b/resources/js/admin/auth/tokens.ts index bd40d25..c0e5899 100644 --- a/resources/js/admin/auth/tokens.ts +++ b/resources/js/admin/auth/tokens.ts @@ -16,6 +16,13 @@ export class AuthError extends Error { let cachedToken: StoredToken | null = null; +function getCsrfToken(): string | undefined { + const meta = typeof document !== 'undefined' + ? (document.querySelector('meta[name=\"csrf-token\"]') as HTMLMetaElement | null) + : null; + return meta?.content; +} + function decodeStoredToken(raw: string | null): StoredToken | null { if (!raw) { return null; @@ -106,6 +113,38 @@ function persistToken(token: StoredToken): void { cachedToken = token; } +async function exchangeSessionForToken(): Promise { + const csrf = getCsrfToken(); + + try { + const response = await fetch('/api/v1/tenant-auth/exchange', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}), + }, + credentials: 'same-origin', + }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { token?: string; abilities?: string[] } | null; + if (!data?.token) { + return null; + } + + return storePersonalAccessToken(data.token, Array.isArray(data.abilities) ? data.abilities : []); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Session token exchange failed', error); + } + return null; + } +} + export function storePersonalAccessToken(accessToken: string, abilities: string[]): StoredToken { const stored: StoredToken = { accessToken, @@ -167,22 +206,29 @@ export function isAuthError(value: unknown): value is AuthError { } export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise { - const stored = loadToken(); - if (!stored) { - notifyAuthFailure(); - throw new AuthError('unauthenticated', 'No active tenant admin token'); - } - const headers = new Headers(init.headers); - headers.set('Authorization', `Bearer ${stored.accessToken}`); if (!headers.has('Accept')) { headers.set('Accept', 'application/json'); } - const response = await fetch(input, { ...init, headers }); + let stored = loadToken(); + if (!stored) { + stored = await exchangeSessionForToken(); + } + if (stored?.accessToken) { + headers.set('Authorization', `Bearer ${stored.accessToken}`); + } + + const response = await fetch(input, { + ...init, + headers, + credentials: init.credentials ?? 'same-origin', + }); if (response.status === 401) { - clearTokens(); + if (stored) { + clearTokens(); + } notifyAuthFailure(); throw new AuthError('unauthorized', 'Token rejected by API'); } diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx index 819286a..73127ec 100644 --- a/resources/js/admin/components/AdminLayout.tsx +++ b/resources/js/admin/components/AdminLayout.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Link, NavLink, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { LayoutDashboard, CalendarDays, Camera, Settings } from 'lucide-react'; +import { LayoutDashboard, CalendarDays, Settings } from 'lucide-react'; import toast from 'react-hot-toast'; import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; @@ -68,8 +68,7 @@ export function AdminLayout({ title, subtitle, actions, children, disableCommand : t('navigation.events'); const photosPath = singleEvent?.slug ? ADMIN_EVENT_PHOTOS_PATH(singleEvent.slug) : ADMIN_EVENTS_PATH; - const photosLabel = t('navigation.photos', { defaultValue: 'Fotos' }); - const settingsLabel = t('navigation.settings'); + const billingLabel = t('navigation.billing', { defaultValue: 'Paket' }); const baseNavItems = React.useMemo(() => [ { @@ -90,21 +89,13 @@ export function AdminLayout({ title, subtitle, actions, children, disableCommand prefetchKey: ADMIN_EVENTS_PATH, }, { - key: 'photos', - to: photosPath, - label: photosLabel, - icon: Camera, - end: Boolean(singleEvent?.slug), - prefetchKey: singleEvent?.slug ? undefined : ADMIN_EVENTS_PATH, - }, - { - key: 'settings', - to: ADMIN_SETTINGS_PATH, - label: settingsLabel, + key: 'billing', + to: ADMIN_BILLING_PATH, + label: billingLabel, icon: Settings, - prefetchKey: ADMIN_SETTINGS_PATH, + prefetchKey: ADMIN_BILLING_PATH, }, - ], [eventsLabel, eventsPath, photosPath, photosLabel, settingsLabel, singleEvent, events.length, t]); + ], [eventsLabel, eventsPath, billingLabel, singleEvent, events.length, t]); const { user } = useAuth(); const isMember = user?.role === 'member'; @@ -114,7 +105,7 @@ export function AdminLayout({ title, subtitle, actions, children, disableCommand if (!isMember) { return true; } - return !['dashboard', 'settings'].includes(item.key); + return !['dashboard', 'billing'].includes(item.key); }), [baseNavItems, isMember], ); @@ -251,6 +242,17 @@ function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: strin const activeTab = React.useMemo(() => tabs.find((tab) => isActive(tab)), [tabs, location.pathname, currentKey]); + const handleTabClick = React.useCallback( + (tab: PageTab) => { + setMobileOpen(false); + const [path, hash] = tab.href.split('#'); + if (location.pathname === path && hash) { + window.location.hash = `#${hash}`; + } + }, + [location.pathname], + ); + return (
@@ -261,6 +263,7 @@ function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: strin handleTabClick(tab)} className={cn( 'flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60', active @@ -318,7 +321,10 @@ function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: strin setMobileOpen(false)} + onClick={() => { + handleTabClick(tab); + setMobileOpen(false); + }} className={cn( 'flex items-center justify-between rounded-2xl border px-4 py-3 text-sm font-medium shadow-sm transition', active diff --git a/resources/js/admin/components/CommandShelf.tsx b/resources/js/admin/components/CommandShelf.tsx index c21a0fb..e5e419c 100644 --- a/resources/js/admin/components/CommandShelf.tsx +++ b/resources/js/admin/components/CommandShelf.tsx @@ -56,7 +56,7 @@ function formatNumber(value?: number | null): string { } export function CommandShelf() { - const { events, activeEvent, isLoading } = useEventContext(); + const { events, activeEvent, isLoading, isError, refetch } = useEventContext(); const { t, i18n } = useTranslation('common'); const navigate = useNavigate(); const [mobileShelfOpen, setMobileShelfOpen] = React.useState(false); @@ -85,25 +85,30 @@ export function CommandShelf() { ); } - if (!events.length) { + if (isError) { return (
-
- -

- {t('commandShelf.empty.title', 'Starte mit deinem ersten Event')} -

-

- {t('commandShelf.empty.hint', 'Erstelle ein Event, dann bündeln wir hier deine wichtigsten Tools.')} -

-
+
+
+ +
+

+ {t('commandShelf.error.title', 'Events konnten nicht geladen werden')} +

+

+ {t('commandShelf.error.hint', 'Bitte versuche es erneut oder lade die Seite neu.')} +

+
+
+
+
@@ -111,6 +116,11 @@ export function CommandShelf() { ); } + if (!events.length) { + // Hide the empty hero entirely; dashboard content already handles the zero-events case. + return null; + } + if (!activeEvent) { return (
diff --git a/resources/js/admin/components/DevTenantSwitcher.tsx b/resources/js/admin/components/DevTenantSwitcher.tsx index e6d6d40..e64acb4 100644 --- a/resources/js/admin/components/DevTenantSwitcher.tsx +++ b/resources/js/admin/components/DevTenantSwitcher.tsx @@ -4,10 +4,10 @@ import { Loader2, PanelLeftClose, PanelRightOpen } from 'lucide-react'; import { Button } from '@/components/ui/button'; const DEV_TENANT_KEYS = [ - { key: 'lumen', label: 'Lumen Moments' }, - { key: 'storycraft', label: 'Storycraft Weddings' }, - { key: 'viewfinder', label: 'Viewfinder Studios' }, - { key: 'pixel', label: 'Pixel & Co (dormant)' }, + { key: 'cust-standard-empty', label: 'Endkunde – Standard (kein Event)' }, + { key: 'cust-starter-wedding', label: 'Endkunde – Starter (Hochzeit)' }, + { key: 'reseller-s-active', label: 'Reseller S – 3 aktive Events' }, + { key: 'reseller-s-full', label: 'Reseller S – voll belegt (5/5)' }, ] as const; declare global { diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 73b2474..4323d07 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -17,6 +17,7 @@ export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions' `${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`; export const ADMIN_BILLING_PATH = adminPath('/billing'); export const ADMIN_PHOTOS_PATH = adminPath('/photos'); +export const ADMIN_LIVE_PATH = adminPath('/live'); export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome'); export const ADMIN_WELCOME_PACKAGES_PATH = adminPath('/welcome/packages'); export const ADMIN_WELCOME_SUMMARY_PATH = adminPath('/welcome/summary'); @@ -31,3 +32,4 @@ export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/even export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`); export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`); export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`); +export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/recap`); diff --git a/resources/js/admin/context/EventContext.tsx b/resources/js/admin/context/EventContext.tsx index e78dcdf..b5565a3 100644 --- a/resources/js/admin/context/EventContext.tsx +++ b/resources/js/admin/context/EventContext.tsx @@ -9,8 +9,10 @@ const STORAGE_KEY = 'tenant-admin.active-event'; export interface EventContextValue { events: TenantEvent[]; isLoading: boolean; + isError: boolean; activeEvent: TenantEvent | null; selectEvent: (slug: string | null) => void; + refetch: () => void; } const EventContext = React.createContext(undefined); @@ -29,11 +31,13 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const { data: fetchedEvents = [], isLoading: queryLoading, + isError, + refetch, } = useQuery({ queryKey: ['tenant-events'], queryFn: async () => { try { - return await getEvents(); + return await getEvents({ force: true }); } catch (error) { console.warn('[EventContext] Failed to fetch events', error); throw error; @@ -48,9 +52,17 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const isLoading = authReady ? queryLoading : status === 'loading'; React.useEffect(() => { - if (!storedSlug && events.length === 1 && events[0]?.slug && typeof window !== 'undefined') { - setStoredSlug(events[0].slug); - window.localStorage.setItem(STORAGE_KEY, events[0].slug); + if (!events.length || typeof window === 'undefined') { + return; + } + + const hasStored = Boolean(storedSlug); + const slugExists = hasStored && events.some((event) => event.slug === storedSlug); + const fallbackSlug = events[0]?.slug; + + if (!slugExists && fallbackSlug) { + setStoredSlug(fallbackSlug); + window.localStorage.setItem(STORAGE_KEY, fallbackSlug); } }, [events, storedSlug]); @@ -64,11 +76,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { return matched; } - if (!storedSlug && events.length === 1) { - return events[0]; - } - - return null; + // Fallback to the first event if the stored slug is missing or stale. + return events[0]; }, [events, storedSlug]); const selectEvent = React.useCallback((slug: string | null) => { @@ -86,10 +95,12 @@ export function EventProvider({ children }: { children: React.ReactNode }) { () => ({ events, isLoading, + isError, activeEvent, selectEvent, + refetch, }), - [events, isLoading, activeEvent, selectEvent] + [events, isLoading, isError, activeEvent, selectEvent, refetch] ); return {children}; diff --git a/resources/js/admin/dev-tools.ts b/resources/js/admin/dev-tools.ts index 164e5ff..f241f19 100644 --- a/resources/js/admin/dev-tools.ts +++ b/resources/js/admin/dev-tools.ts @@ -1,9 +1,9 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true') { const CREDENTIALS: Record = { - lumen: { login: 'hello@lumen-moments.demo', password: 'Demo1234!' }, - storycraft: { login: 'storycraft-owner@demo.fotospiel', password: 'Demo1234!' }, - viewfinder: { login: 'team@viewfinder.demo', password: 'Demo1234!' }, - pixel: { login: 'support@pixelco.demo', password: 'Demo1234!' }, + 'cust-standard-empty': { login: 'standard-empty@demo.fotospiel', password: 'Demo1234!' }, + 'cust-starter-wedding': { login: 'starter-wedding@demo.fotospiel', password: 'Demo1234!' }, + 'reseller-s-active': { login: 'reseller-active@demo.fotospiel', password: 'Demo1234!' }, + 'reseller-s-full': { login: 'reseller-full@demo.fotospiel', password: 'Demo1234!' }, }; async function loginAs(key: string): Promise { diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 43bf724..5be04e1 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -16,12 +16,13 @@ "event": "Event", "events": "Events", "photos": "Fotos", + "live": "Live", "tasks": "Aufgaben", - "collections": "Aufgabenvorlagen", + "collections": "Aufgabensammlungen", "emotions": "Emotionen", - "engagement": "Aufgaben & Co.", + "engagement": "Aufgaben-Bibliothek", "toolkit": "Toolkit", - "billing": "Abrechnung", + "billing": "Paket", "settings": "Einstellungen", "tabs": { "open": "Tabs", @@ -36,7 +37,8 @@ "guests": "Team & Gäste", "tasks": "Aufgaben", "invites": "Einladungen", - "toolkit": "Toolkit" + "toolkit": "Toolkit", + "recap": "Nachbereitung" }, "eventSwitcher": { "title": "Event auswählen", @@ -91,6 +93,11 @@ "sheetDescription": "Moderation, Aufgaben und Einladungen an einem Ort.", "tip": "Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.", "tipCta": "Verstanden" + }, + "error": { + "title": "Events konnten nicht geladen werden", + "hint": "Bitte versuche es erneut oder lade die Seite neu.", + "retry": "Erneut laden" } } } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index dbf02a7..e83912c 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -208,6 +208,20 @@ } } }, + "galleryStatus": { + "badge": "Laufzeit", + "title": "Galerie-Laufzeit & Verfügbarkeit", + "subtitle": "Halte im Blick, wie lange Gäste noch auf die Galerie zugreifen können.", + "stateLabel": "Status", + "stateExpired": "Galerie abgelaufen", + "stateWarning": "Galerie läuft bald ab", + "stateOk": "Galerie aktiv", + "noExpiry": "Kein Ablaufdatum gesetzt", + "expiresAt": "Ablaufdatum: {{date}}", + "daysLabel": "Verbleibende Tage", + "expiredHint": "Gäste haben keinen Zugriff mehr – verlängere das Paket, um die Galerie zu öffnen.", + "hint": "Bei Bedarf kannst du im Paketbereich die Laufzeit verlängern." + }, "members": { "title": "Event-Mitglieder", "subtitle": "Verwalte Moderatoren, Admins und Helfer für dieses Event.", diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 7b6f373..e7ae1aa 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -16,12 +16,13 @@ "event": "Event", "events": "Events", "photos": "Photos", + "live": "Live", "tasks": "Tasks", - "collections": "Collections", + "collections": "Task collections", "emotions": "Emotions", - "engagement": "Tasks & More", + "engagement": "Task library", "toolkit": "Toolkit", - "billing": "Billing", + "billing": "Package", "settings": "Settings", "tabs": { "open": "Tabs", @@ -36,7 +37,8 @@ "guests": "Members", "tasks": "Tasks", "invites": "Invites", - "toolkit": "Toolkit" + "toolkit": "Toolkit", + "recap": "Recap" }, "eventSwitcher": { "title": "Select event", @@ -91,6 +93,11 @@ "sheetDescription": "Moderation, tasks, and invites in one place.", "tip": "Tip: Access your key event-day actions here.", "tipCta": "Got it" + }, + "error": { + "title": "Events could not be loaded", + "hint": "Please try again or refresh the page.", + "retry": "Retry" } } } diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index a4e1b70..4ee0076 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -204,6 +204,20 @@ } } }, + "galleryStatus": { + "badge": "Runtime", + "title": "Gallery runtime & availability", + "subtitle": "Keep track of how long guests can still access the gallery.", + "stateLabel": "Status", + "stateExpired": "Gallery expired", + "stateWarning": "Gallery expiring soon", + "stateOk": "Gallery active", + "noExpiry": "No expiry date set", + "expiresAt": "Expiry date: {{date}}", + "daysLabel": "Days remaining", + "expiredHint": "Guests can no longer access the gallery – extend your package to reopen it.", + "hint": "If needed, extend the runtime in your package settings." + }, "members": { "title": "Event members", "subtitle": "Manage moderators, admins, and helpers for this event.", diff --git a/resources/js/admin/lib/eventTabs.ts b/resources/js/admin/lib/eventTabs.ts index be49607..667e49b 100644 --- a/resources/js/admin/lib/eventTabs.ts +++ b/resources/js/admin/lib/eventTabs.ts @@ -5,6 +5,7 @@ import { ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH, ADMIN_EVENT_VIEW_PATH, + ADMIN_EVENT_RECAP_PATH, } from '../constants'; export type EventTabCounts = Partial<{ @@ -56,5 +57,10 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts label: translate('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(event.slug), }, + { + key: 'recap', + label: translate('eventMenu.recap', 'Nachbereitung'), + href: ADMIN_EVENT_RECAP_PATH(event.slug), + }, ]; } diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx index db0fdf2..486bd97 100644 --- a/resources/js/admin/pages/DashboardPage.tsx +++ b/resources/js/admin/pages/DashboardPage.tsx @@ -40,6 +40,7 @@ import { } from '../api'; import { isAuthError } from '../auth/tokens'; import { useAuth } from '../auth/context'; +import { useEventContext } from '../context/EventContext'; import { adminPath, ADMIN_HOME_PATH, @@ -82,6 +83,7 @@ export default function DashboardPage() { const navigate = useNavigate(); const location = useLocation(); const { user } = useAuth(); + const { events: ctxEvents, activeEvent: ctxActiveEvent } = useEventContext(); const { progress, markStep } = useOnboardingProgress(); const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' }); const { t: tc } = useTranslation('common'); @@ -132,7 +134,7 @@ export default function DashboardPage() { try { const [summary, events, packages] = await Promise.all([ getDashboardSummary().catch(() => null), - getEvents().catch(() => [] as TenantEvent[]), + getEvents({ force: true }).catch(() => [] as TenantEvent[]), getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })), ]); @@ -141,11 +143,12 @@ export default function DashboardPage() { } const fallbackSummary = buildSummaryFallback(events, packages.activePackage); - const primaryEvent = events[0] ?? null; + const eventPool = events.length ? events : ctxEvents; + const primaryEvent = ctxActiveEvent ?? eventPool[0] ?? null; const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null; setReadiness({ - hasEvent: events.length > 0, + hasEvent: eventPool.length > 0, hasTasks: primaryEvent ? (Number(primaryEvent.tasks_count ?? 0) > 0) : false, hasQrInvites: primaryEvent ? Number( @@ -162,7 +165,7 @@ export default function DashboardPage() { setState({ summary: summary ?? fallbackSummary, - events, + events: eventPool, activePackage: packages.activePackage, loading: false, errorKey: null, @@ -217,12 +220,21 @@ export default function DashboardPage() { const subtitle = translate('welcome.subtitle'); const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null; const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; + const canCreateEvent = React.useMemo(() => { + if (!activePackage) { + return true; + } + if (activePackage.remaining_events === null || activePackage.remaining_events === undefined) { + return true; + } + return activePackage.remaining_events > 0; + }, [activePackage]); - const upcomingEvents = getUpcomingEvents(events); - const publishedEvents = events.filter((event) => event.status === 'published'); - const primaryEvent = events[0] ?? null; + const upcomingEvents = getUpcomingEvents(ctxEvents.length ? ctxEvents : events); + const publishedEvents = (ctxEvents.length ? ctxEvents : events).filter((event) => event.status === 'published'); + const primaryEvent = ctxActiveEvent ?? (ctxEvents[0] ?? events[0] ?? null); const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null; - const singleEvent = events.length === 1 ? events[0] : null; + const singleEvent = ctxEvents.length === 1 ? ctxEvents[0] : (events.length === 1 ? events[0] : null); const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null; const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : null; const primaryEventLimits = primaryEvent?.limits ?? null; @@ -468,7 +480,15 @@ export default function DashboardPage() { label: translate('quickActions.createEvent.label'), description: translate('quickActions.createEvent.description'), icon: , - onClick: () => navigate(ADMIN_EVENT_CREATE_PATH), + onClick: () => { + if (!canCreateEvent) { + toast.error(tc('errors.eventLimit', 'Dein aktuelles Paket enthält keine freien Event-Slots mehr.')); + navigate(ADMIN_BILLING_PATH); + return; + } + navigate(ADMIN_EVENT_CREATE_PATH); + }, + disabled: !canCreateEvent, }, { key: 'photos', @@ -559,7 +579,7 @@ export default function DashboardPage() { ); return ( - + {errorMessage && ( {t('dashboard.alerts.errorTitle')} diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index bac8471..a3989f7 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -1,6 +1,6 @@ // @ts-nocheck import React from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, @@ -94,6 +94,7 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp const { slug: slugParam } = useParams<{ slug?: string }>(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const location = useLocation(); const { t } = useTranslation('management'); const { t: tCommon } = useTranslation('common'); @@ -217,19 +218,53 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp ? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.') : t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.'); - const tabLabels = React.useMemo( - () => ({ - overview: t('events.workspace.tabs.overview', 'Überblick'), - live: t('events.workspace.tabs.live', 'Live'), - setup: t('events.workspace.tabs.setup', 'Vorbereitung'), - recap: t('events.workspace.tabs.recap', 'Nachbereitung'), - }), - [t], - ); const limitWarnings = React.useMemo( () => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []), [event?.limits, tCommon], ); + const [dismissedWarnings, setDismissedWarnings] = React.useState>(new Set()); + + React.useEffect(() => { + const slug = event?.slug; + if (!slug || typeof window === 'undefined') { + setDismissedWarnings(new Set()); + return; + } + try { + const raw = window.localStorage.getItem(`tenant-admin:dismissed-limit-warnings:${slug}`); + if (!raw) { + setDismissedWarnings(new Set()); + return; + } + const parsed = JSON.parse(raw) as string[]; + setDismissedWarnings(new Set(parsed)); + } catch { + setDismissedWarnings(new Set()); + } + }, [event?.slug]); + + const visibleWarnings = React.useMemo( + () => limitWarnings.filter((warning) => !dismissedWarnings.has(warning.id)), + [limitWarnings, dismissedWarnings], + ); + + const dismissWarning = React.useCallback( + (id: string) => { + const slug = event?.slug; + setDismissedWarnings((prev) => { + const next = new Set(prev); + next.add(id); + if (slug && typeof window !== 'undefined') { + window.localStorage.setItem( + `tenant-admin:dismissed-limit-warnings:${slug}`, + JSON.stringify(Array.from(next)), + ); + } + return next; + }); + }, + [event?.slug], + ); const eventTabs = React.useMemo(() => { if (!event) { @@ -243,6 +278,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp }); }, [event, toolkitData?.photos?.pending?.length, toolkitData?.tasks?.summary.total, toolkitData?.invites?.summary.active, t]); + const isRecapRoute = React.useMemo( + () => location.pathname.endsWith('/recap'), + [location.pathname], + ); + const shownWarningToasts = React.useRef>(new Set()); //const [addonBusyId, setAddonBusyId] = React.useState(null); @@ -336,7 +376,12 @@ const shownWarningToasts = React.useRef>(new Set()); } return ( - + {error && ( {t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')} @@ -344,9 +389,9 @@ const shownWarningToasts = React.useRef>(new Set()); )} - {limitWarnings.length > 0 && ( + {visibleWarnings.length > 0 && (
- {limitWarnings.map((warning) => ( + {visibleWarnings.map((warning) => ( >(new Set()); {warning.message} - {(['photos', 'guests', 'gallery'] as const).includes(warning.scope) ? ( -
- - {addonsCatalog.length > 0 ? ( - { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }} - busy={addonBusyId === warning.scope} - t={(key, fallback) => t(key, fallback)} - /> - ) : null} -
- ) : null} +
+ {(['photos', 'guests', 'gallery'] as const).includes(warning.scope) ? ( + <> + + {addonsCatalog.length > 0 ? ( + { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }} + busy={addonBusyId === warning.scope} + t={(key, fallback) => t(key, fallback)} + /> + ) : null} + + ) : null} + +
))} @@ -408,12 +463,11 @@ const shownWarningToasts = React.useRef>(new Set()); navigate={navigate} /> - - - {tabLabels.overview} - {tabLabels.live} - {tabLabels.setup} - {tabLabels.recap} + + + {t('events.workspace.tabs.overview', 'Überblick')} + {t('events.workspace.tabs.setup', 'Vorbereitung')} + {t('events.workspace.tabs.recap', 'Nachbereitung')} @@ -424,31 +478,6 @@ const shownWarningToasts = React.useRef>(new Set()); - - {(toolkitData?.alerts?.length ?? 0) > 0 && } - - - -
- - -
-
- -
- navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))} - /> - -
-
-
navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} /> @@ -480,7 +509,13 @@ const shownWarningToasts = React.useRef>(new Set()); - navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} /> + navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} + /> + {event.limits?.gallery ? ( + + ) : null} @@ -1033,6 +1068,59 @@ function GalleryShareCard({ ); } +function GalleryStatusCard({ gallery }: { gallery: GallerySummary }) { + const { t } = useTranslation('management'); + + const stateLabel = + gallery.state === 'expired' + ? t('events.galleryStatus.stateExpired', 'Galerie abgelaufen') + : gallery.state === 'warning' + ? t('events.galleryStatus.stateWarning', 'Galerie läuft bald ab') + : t('events.galleryStatus.stateOk', 'Galerie aktiv'); + + const expiresLabel = + gallery.expires_at && gallery.state !== 'unlimited' + ? formatDate(gallery.expires_at) + : t('events.galleryStatus.noExpiry', 'Kein Ablaufdatum gesetzt'); + + const daysRemaining = + typeof gallery.days_remaining === 'number' && gallery.days_remaining >= 0 + ? gallery.days_remaining + : null; + + return ( + + +
+
+

+ {t('events.galleryStatus.stateLabel', 'Status')} +

+

{stateLabel}

+

+ {t('events.galleryStatus.expiresAt', { + defaultValue: 'Ablaufdatum: {{date}}', + date: expiresLabel, + })} +

+
+
+

+ {t('events.galleryStatus.daysLabel', 'Verbleibende Tage')} +

+

+ {daysRemaining !== null ? daysRemaining : '—'} +

+
+
+
+ ); +} + function extractBrandingPalette( settings: TenantEvent['settings'], @@ -1068,133 +1156,7 @@ function extractBrandingPalette( return { colors, font }; } -function PendingPhotosCard({ - slug, - photos, - navigateToModeration, -}: { - slug: string; - photos: TenantPhoto[]; - navigateToModeration: () => void; -}) { - const { t } = useTranslation('management'); - const [entries, setEntries] = React.useState(photos); - const [updatingId, setUpdatingId] = React.useState(null); - - React.useEffect(() => { - setEntries(photos); - }, [photos]); - - const handleVisibility = async (photo: TenantPhoto, visible: boolean) => { - setUpdatingId(photo.id); - try { - const updated = await updatePhotoVisibility(slug, photo.id, visible); - setEntries((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); - toast.success( - visible - ? t('events.photos.toastVisible', 'Foto wieder sichtbar gemacht.') - : t('events.photos.toastHidden', 'Foto ausgeblendet.'), - ); - } catch (err) { - toast.error( - isAuthError(err) - ? t('events.photos.errorAuth', 'Session abgelaufen. Bitte erneut anmelden.') - : t('events.photos.errorVisibility', 'Sichtbarkeit konnte nicht geändert werden.'), - ); - } finally { - setUpdatingId(null); - } - }; - - const handleFeature = async (photo: TenantPhoto, feature: boolean) => { - setUpdatingId(photo.id); - try { - const updated = feature ? await featurePhoto(slug, photo.id) : await unfeaturePhoto(slug, photo.id); - setEntries((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); - toast.success( - feature - ? t('events.photos.toastFeatured', 'Foto als Highlight markiert.') - : t('events.photos.toastUnfeatured', 'Highlight entfernt.'), - ); - } catch (err) { - toast.error(getApiErrorMessage(err, t('events.photos.errorFeature', 'Aktion fehlgeschlagen.'))); - } finally { - setUpdatingId(null); - } - }; - - return ( - - - {t('events.photos.pendingCount', { defaultValue: '{{count}} Fotos offen', count: entries.length })} - - )} - /> -
- {entries.length ? ( -
- {entries.slice(0, 4).map((photo) => { - const hidden = photo.status === 'hidden'; - return ( -
-
- {photo.caption - {photo.is_featured ? ( - - Highlight - - ) : null} -
-
- {photo.uploader_name ?? 'Gast'} - ♥ {photo.likes_count} -
-
- - -
-
- ); - })} -
- ) : ( -

{t('events.photos.pendingEmpty', 'Aktuell warten keine Fotos auf Freigabe.')}

- )} - - -
-
- ); -} +// Pending photos summary moved to the dedicated Live/Photos view. function RecentUploadsCard({ slug, photos }: { slug: string; photos: TenantPhoto[] }) { const { t } = useTranslation('management'); diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index e27f58e..36b222a 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -1205,9 +1205,6 @@ export default function EventInvitesPage(): React.ReactElement { ); })}
-

- {t('invites.export.actions.hint', 'PDF enthält Beschnittmarken, PNG ist für schnelle digitale Freigaben geeignet.')} -

@@ -1499,10 +1496,6 @@ function InviteShareSummaryCard({ invite, onCopy, onCreate, onOpenLayout, onOpen
-

- - {t('invites.share.hint', 'Teile den Link direkt im Team oder binde ihn im Newsletter ein.')} -

); diff --git a/resources/js/admin/pages/EventPhotosPage.tsx b/resources/js/admin/pages/EventPhotosPage.tsx index 31456a1..039752f 100644 --- a/resources/js/admin/pages/EventPhotosPage.tsx +++ b/resources/js/admin/pages/EventPhotosPage.tsx @@ -278,18 +278,6 @@ export default function EventPhotosPage() { - {eventAddons.length > 0 && ( - - - {t('events.sections.addons.title', 'Add-ons & Upgrades')} - {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} - - - t(key, fallback)} /> - - - )} -
@@ -351,6 +339,22 @@ export default function EventPhotosPage() { )} + + {eventAddons.length > 0 && ( + + + + {t('events.sections.addons.title', 'Add-ons & Upgrades')} + + + {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} + + + + t(key, fallback)} /> + + + )} ); } diff --git a/resources/js/admin/pages/EventTasksPage.tsx b/resources/js/admin/pages/EventTasksPage.tsx index 03d83d5..6ea6f4b 100644 --- a/resources/js/admin/pages/EventTasksPage.tsx +++ b/resources/js/admin/pages/EventTasksPage.tsx @@ -259,7 +259,10 @@ export default function EventTasksPage() { } }, [event, hydrateTasks, slug, t]); - const isPhotoOnlyMode = event?.engagement_mode === 'photo_only'; + const isPhotoOnlyMode = React.useMemo(() => { + const mode = event?.engagement_mode ?? (event?.settings as any)?.engagement_mode; + return mode === 'photo_only'; + }, [event?.engagement_mode, event?.settings]); async function handleModeChange(checked: boolean) { if (!event || !slug) return; @@ -271,10 +274,20 @@ export default function EventTasksPage() { const nextMode = checked ? 'photo_only' : 'tasks'; const updated = await updateEvent(slug, { settings: { + ...(event.settings ?? {}), engagement_mode: nextMode, }, }); - setEvent(updated); + setEvent((prev) => ({ + ...(prev ?? updated), + ...(updated ?? {}), + engagement_mode: updated?.engagement_mode ?? nextMode, + settings: { + ...(prev?.settings ?? {}), + ...(updated?.settings ?? {}), + engagement_mode: nextMode, + }, + })); } catch (err) { if (!isAuthError(err)) { setError( @@ -297,8 +310,8 @@ export default function EventTasksPage() { return (
+ + + + {t('management.tasks.library.hintTitle', 'Weitere Vorlagen in der Aufgaben-Bibliothek')} + + + + {t( + 'management.tasks.library.hintCopy', + 'Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.', + )} + + + + +
diff --git a/resources/js/admin/pages/LiveRedirectPage.tsx b/resources/js/admin/pages/LiveRedirectPage.tsx new file mode 100644 index 0000000..3f3fe02 --- /dev/null +++ b/resources/js/admin/pages/LiveRedirectPage.tsx @@ -0,0 +1,37 @@ +// @ts-nocheck +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useEventContext } from '../context/EventContext'; +import { ADMIN_EVENTS_PATH, ADMIN_EVENT_PHOTOS_PATH } from '../constants'; + +export default function LiveRedirectPage(): React.ReactElement { + const navigate = useNavigate(); + const { activeEvent, events, isLoading, refetch } = useEventContext(); + + React.useEffect(() => { + if (!isLoading && !events.length) { + void refetch(); + } + }, [isLoading, events.length, refetch]); + + React.useEffect(() => { + if (isLoading) { + return; + } + const targetEvent = activeEvent ?? events[0] ?? null; + if (targetEvent) { + navigate(ADMIN_EVENT_PHOTOS_PATH(targetEvent.slug), { replace: true }); + } else { + navigate(ADMIN_EVENTS_PATH, { replace: true }); + } + }, [isLoading, activeEvent, events, navigate]); + + if (isLoading) { + return ( +
+ Lade Events ... +
+ ); + } + return null; +} diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 91319ad..0ba0503 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -31,6 +31,7 @@ const WelcomeTeaserPage = React.lazy(() => import('./pages/WelcomeTeaserPage')); const LoginStartPage = React.lazy(() => import('./pages/LoginStartPage')); const ProfilePage = React.lazy(() => import('./pages/ProfilePage')); const LogoutPage = React.lazy(() => import('./pages/LogoutPage')); +const LiveRedirectPage = React.lazy(() => import('./pages/LiveRedirectPage')); const WelcomeLandingPage = React.lazy(() => import('./onboarding/pages/WelcomeLandingPage')); const WelcomePackagesPage = React.lazy(() => import('./onboarding/pages/WelcomePackagesPage')); const WelcomeEventSetupPage = React.lazy(() => import('./onboarding/pages/WelcomeEventSetupPage')); @@ -98,9 +99,11 @@ export const router = createBrowserRouter([ element: , children: [ { path: 'dashboard', element: }, + { path: 'live', element: }, { path: 'events', element: }, { path: 'events/new', element: }, { path: 'events/:slug', element: }, + { path: 'events/:slug/recap', element: }, { path: 'events/:slug/edit', element: }, { path: 'events/:slug/photos', element: }, { path: 'events/:slug/members', element: },