events werden nun erfolgreich gespeichert, branding wird nun erfolgreich gespeichert, emotionen können nun angelegt werden. Task Ansicht im Event admin verbessert, Buttons in FAB umgewandelt und vereinheitlicht. Teilen-Link Guest PWA schicker gemacht, SynGoogleFonts ausgebaut (mit Einzel-Family-Download).

This commit is contained in:
Codex Agent
2025-11-27 16:08:08 +01:00
parent bfa15cc48e
commit 96f8c5d63c
39 changed files with 1970 additions and 640 deletions

View File

@@ -6,13 +6,13 @@ use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class SyncGoogleFonts extends Command class SyncGoogleFonts extends Command
{ {
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)}'; protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download a single family by name (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--dry-run : Show what would be downloaded without writing files}';
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.'; protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
@@ -32,13 +32,28 @@ class SyncGoogleFonts extends Command
$weights = $this->prepareWeights($this->option('weights')); $weights = $this->prepareWeights($this->option('weights'));
$includeItalic = (bool) $this->option('italic'); $includeItalic = (bool) $this->option('italic');
$force = (bool) $this->option('force'); $force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$familyOption = $this->normalizeFamilyOption($this->option('family'));
$categories = $this->prepareCategories($this->option('category'));
$pathOption = $this->option('path'); $pathOption = $this->option('path');
$basePath = $pathOption $basePath = $pathOption
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption)) ? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
: public_path('fonts/google'); : public_path('fonts/google');
$this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no')); if ($familyOption) {
$this->info(sprintf('Fetching Google Font family "%s" (weights: %s, italic: %s)...', $familyOption, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
} else {
$this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
}
if (count($categories)) {
$this->line('Category filter: '.implode(', ', $categories));
}
if ($dryRun) {
$this->warn('Dry run enabled: no files will be written.');
}
$response = Http::retry(2, 200) $response = Http::retry(2, 200)
->timeout(30) ->timeout(30)
@@ -60,10 +75,27 @@ class SyncGoogleFonts extends Command
return self::SUCCESS; return self::SUCCESS;
} }
$selected = array_slice($items, 0, $count); $items = $this->filterFonts($items, $familyOption, $categories);
if ($familyOption && ! count($items)) {
$this->error(sprintf('Font family "%s" was not found.', $familyOption));
return self::FAILURE;
}
if (! count($items)) {
$this->warn('No fonts matched the provided filters.');
return self::SUCCESS;
}
$selected = $familyOption ? $items : array_slice($items, 0, $count);
$manifestFonts = []; $manifestFonts = [];
$filesystem = new Filesystem(); $filesystem = new Filesystem;
File::ensureDirectoryExists($basePath);
if (! $dryRun) {
File::ensureDirectoryExists($basePath);
}
foreach ($selected as $index => $font) { foreach ($selected as $index => $font) {
if (! is_array($font) || ! isset($font['family'])) { if (! is_array($font) || ! isset($font['family'])) {
@@ -73,11 +105,14 @@ class SyncGoogleFonts extends Command
$family = (string) $font['family']; $family = (string) $font['family'];
$slug = Str::slug($family); $slug = Str::slug($family);
$familyDir = $basePath.DIRECTORY_SEPARATOR.$slug; $familyDir = $basePath.DIRECTORY_SEPARATOR.$slug;
File::ensureDirectoryExists($familyDir); if (! $dryRun) {
File::ensureDirectoryExists($familyDir);
}
$variantMap = $this->buildVariantMap($font, $weights, $includeItalic); $variantMap = $this->buildVariantMap($font, $weights, $includeItalic);
if (! count($variantMap)) { if (! count($variantMap)) {
$this->warn("Skipping {$family} (no matching variants)"); $this->warn("Skipping {$family} (no matching variants)");
continue; continue;
} }
@@ -89,7 +124,11 @@ class SyncGoogleFonts extends Command
$filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension); $filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension);
$targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename; $targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename;
if (! $force && $filesystem->exists($targetPath)) { $alreadyExists = $filesystem->exists($targetPath);
if ($dryRun) {
$this->line(sprintf('◦ DRY RUN: %s %s would %s (%s)', $family, $variantKey, $alreadyExists && ! $force ? 'reuse existing file' : 'download', $targetPath));
} elseif (! $force && $alreadyExists) {
$this->line("{$family} {$variantKey} already exists"); $this->line("{$family} {$variantKey} already exists");
} else { } else {
$this->line("↓ Downloading {$family} {$variantKey}"); $this->line("↓ Downloading {$family} {$variantKey}");
@@ -97,6 +136,7 @@ class SyncGoogleFonts extends Command
if (! $fileResponse->ok()) { if (! $fileResponse->ok()) {
$this->warn(" Skipped {$family} {$variantKey} (download failed)"); $this->warn(" Skipped {$family} {$variantKey} (download failed)");
continue; continue;
} }
@@ -124,6 +164,12 @@ class SyncGoogleFonts extends Command
]; ];
} }
if ($dryRun) {
$this->info(sprintf('Dry run complete: %d font families would be synced to %s', count($manifestFonts), $basePath));
return self::SUCCESS;
}
$this->pruneStaleFamilies($basePath, $manifestFonts); $this->pruneStaleFamilies($basePath, $manifestFonts);
$this->writeManifest($basePath, $manifestFonts); $this->writeManifest($basePath, $manifestFonts);
$this->writeCss($basePath, $manifestFonts); $this->writeCss($basePath, $manifestFonts);
@@ -134,6 +180,54 @@ class SyncGoogleFonts extends Command
return self::SUCCESS; return self::SUCCESS;
} }
private function normalizeFamilyOption(?string $family): ?string
{
$family = trim((string) $family);
return $family !== '' ? $family : null;
}
/**
* @return array<int, string>
*/
private function prepareCategories(?string $categories): array
{
$parts = array_filter(array_map('trim', explode(',', (string) $categories)));
return array_values(array_unique(array_map(static function ($category) {
$normalized = Str::of($category)->lower()->replace(' ', '-')->toString();
return (string) $normalized;
}, $parts)));
}
/**
* @param array<int, mixed> $items
* @return array<int, mixed>
*/
private function filterFonts(array $items, ?string $family, array $categories): array
{
$filtered = collect($items)
->filter(fn ($font) => is_array($font) && isset($font['family']))
->filter(function ($font) use ($categories) {
if (! count($categories)) {
return true;
}
$category = strtolower((string) ($font['category'] ?? ''));
return in_array($category, $categories, true);
});
if ($family) {
$filtered = $filtered->filter(function ($font) use ($family) {
return strcasecmp((string) $font['family'], $family) === 0;
});
}
return $filtered->values()->all();
}
/** /**
* @return array<int, int> * @return array<int, int>
*/ */
@@ -147,7 +241,7 @@ class SyncGoogleFonts extends Command
} }
/** /**
* @param array<string, mixed> $font * @param array<string, mixed> $font
* @return array<string, string> * @return array<string, string>
*/ */
private function buildVariantMap(array $font, array $weights, bool $includeItalic): array private function buildVariantMap(array $font, array $weights, bool $includeItalic): array

View File

@@ -13,6 +13,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class EmotionController extends Controller class EmotionController extends Controller
{ {
@@ -21,8 +22,15 @@ class EmotionController extends Controller
$tenantId = $this->currentTenant($request)->id; $tenantId = $this->currentTenant($request)->id;
$query = Emotion::query() $query = Emotion::query()
->whereNull('tenant_id') ->when(true, function ($builder) use ($tenantId) {
->orWhere('tenant_id', $tenantId) // Prefer tenant-specific and global emotions if the column exists
if (Schema::hasColumn('emotions', 'tenant_id')) {
$builder->where(function ($inner) use ($tenantId) {
$inner->whereNull('tenant_id')
->orWhere('tenant_id', $tenantId);
});
}
})
->with('eventTypes'); ->with('eventTypes');
if ($request->boolean('only_tenant')) { if ($request->boolean('only_tenant')) {
@@ -35,7 +43,18 @@ class EmotionController extends Controller
$query->orderByRaw('tenant_id is null desc')->orderBy('sort_order')->orderBy('id'); $query->orderByRaw('tenant_id is null desc')->orderBy('sort_order')->orderBy('id');
$emotions = $query->paginate($request->integer('per_page', 25)); $emotions = $query->paginate($request->integer('per_page', 50));
if ($emotions->isEmpty() && ! $request->boolean('only_tenant')) {
// Fallback: return any emotions regardless of tenant to avoid empty selectors
$fallback = Emotion::query()
->with('eventTypes')
->orderBy('sort_order')
->orderBy('id')
->paginate($request->integer('per_page', 50));
return EmotionResource::collection($fallback);
}
return EmotionResource::collection($emotions); return EmotionResource::collection($emotions);
} }

View File

@@ -23,7 +23,7 @@ class TaskCollectionController extends Controller
$query = TaskCollection::query() $query = TaskCollection::query()
->forTenant($tenantId) ->forTenant($tenantId)
->with('eventType') ->with('eventType')
->withCount('tasks') ->withCount(['tasks', 'events'])
->orderBy('position') ->orderBy('position')
->orderBy('id'); ->orderBy('id');
@@ -46,6 +46,27 @@ class TaskCollectionController extends Controller
$query->where('tenant_id', $tenantId); $query->where('tenant_id', $tenantId);
} }
if ($request->boolean('top_picks')) {
if ($eventTypeSlug = $request->query('event_type')) {
$query->where(function ($inner) use ($eventTypeSlug) {
$inner->whereNull('event_type_id')
->orWhereHas('eventType', fn ($q) => $q->where('slug', $eventTypeSlug));
});
}
$query->whereHas('tasks')
->orderByDesc('events_count')
->orderByDesc('updated_at')
->orderBy('position')
->orderBy('id');
$limit = $request->integer('limit', 3);
return TaskCollectionResource::collection(
$query->limit($limit)->get()
);
}
$perPage = $request->integer('per_page', 15); $perPage = $request->integer('per_page', 15);
return TaskCollectionResource::collection( return TaskCollectionResource::collection(
@@ -57,7 +78,8 @@ class TaskCollectionController extends Controller
{ {
$this->authorizeAccess($request, $collection); $this->authorizeAccess($request, $collection);
$collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')]); $collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')])
->loadCount(['tasks', 'events']);
return response()->json(new TaskCollectionResource($collection)); return response()->json(new TaskCollectionResource($collection));
} }
@@ -81,7 +103,11 @@ class TaskCollectionController extends Controller
return response()->json([ return response()->json([
'message' => __('Task-Collection erfolgreich importiert.'), 'message' => __('Task-Collection erfolgreich importiert.'),
'collection' => new TaskCollectionResource($result['collection']->load('eventType')->loadCount('tasks')), 'collection' => new TaskCollectionResource(
$result['collection']
->load('eventType')
->loadCount(['tasks', 'events'])
),
'created_task_ids' => $result['created_task_ids'], 'created_task_ids' => $result['created_task_ids'],
'attached_task_ids' => $result['attached_task_ids'], 'attached_task_ids' => $result['attached_task_ids'],
]); ]);

View File

@@ -31,7 +31,7 @@ class TaskController extends Controller
$inner->whereNull('tenant_id') $inner->whereNull('tenant_id')
->orWhere('tenant_id', $tenantId); ->orWhere('tenant_id', $tenantId);
}) })
->with(['taskCollection', 'assignedEvents', 'eventType']) ->with(['taskCollection', 'assignedEvents', 'eventType', 'emotion'])
->orderByRaw('tenant_id is null desc') ->orderByRaw('tenant_id is null desc')
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
@@ -80,7 +80,7 @@ class TaskController extends Controller
$task = Task::create($payload); $task = Task::create($payload);
$task->load(['taskCollection', 'assignedEvents', 'eventType']); $task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
return response()->json([ return response()->json([
'message' => 'Task erfolgreich erstellt.', 'message' => 'Task erfolgreich erstellt.',
@@ -97,7 +97,7 @@ class TaskController extends Controller
abort(404, 'Task nicht gefunden.'); abort(404, 'Task nicht gefunden.');
} }
$task->load(['taskCollection', 'assignedEvents', 'eventType']); $task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
return response()->json(new TaskResource($task)); return response()->json(new TaskResource($task));
} }
@@ -156,7 +156,7 @@ class TaskController extends Controller
{ {
$tenantId = $this->currentTenant($request)->id; $tenantId = $this->currentTenant($request)->id;
if ($task->tenant_id !== $tenantId || $event->tenant_id !== $tenantId) { if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
abort(404); abort(404);
} }
@@ -193,7 +193,9 @@ class TaskController extends Controller
} }
$tasks = Task::whereIn('id', $taskIds) $tasks = Task::whereIn('id', $taskIds)
->where('tenant_id', $tenantId) ->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
})
->get(); ->get();
$attached = 0; $attached = 0;
@@ -219,13 +221,63 @@ class TaskController extends Controller
} }
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id)) $tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
->with(['taskCollection', 'eventType']) ->with(['taskCollection', 'eventType', 'emotion'])
->orderBy('created_at', 'desc') ->orderBy('tasks.id')
->paginate($request->get('per_page', 15)); ->paginate($request->get('per_page', 15));
return TaskResource::collection($tasks); return TaskResource::collection($tasks);
} }
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
{
$tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {
abort(404);
}
$taskIds = $request->input('task_ids', []);
if (empty($taskIds)) {
return ApiError::response(
'task_ids_missing',
'Keine Aufgaben angegeben',
'Bitte wähle mindestens eine Aufgabe aus.',
Response::HTTP_BAD_REQUEST
);
}
$detached = $event->tasks()->whereIn('tasks.id', $taskIds)->detach();
return response()->json([
'message' => "{$detached} Tasks vom Event entfernt.",
]);
}
public function reorderForEvent(Request $request, Event $event): JsonResponse
{
$tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {
abort(404);
}
$taskIds = $request->input('task_ids', []);
if (empty($taskIds) || ! is_array($taskIds)) {
return ApiError::response(
'task_ids_missing',
'Keine Aufgaben angegeben',
'Bitte wähle mindestens eine Aufgabe aus.',
Response::HTTP_BAD_REQUEST
);
}
return response()->json([
'message' => 'Reihenfolge gespeichert.',
]);
}
/** /**
* Get tasks from a specific collection. * Get tasks from a specific collection.
*/ */

View File

@@ -42,6 +42,8 @@ class EventStoreRequest extends FormRequest
'features' => ['nullable', 'array'], 'features' => ['nullable', 'array'],
'features.*' => ['string'], 'features.*' => ['string'],
'settings' => ['nullable', 'array'], 'settings' => ['nullable', 'array'],
'settings.branding' => ['nullable', 'array'],
'settings.branding.*' => ['nullable'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])], 'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
]; ];
} }

View File

@@ -54,6 +54,7 @@ class EventResource extends JsonResource
'is_active' => (bool) ($this->is_active ?? false), 'is_active' => (bool) ($this->is_active ?? false),
'features' => $settings['features'] ?? [], 'features' => $settings['features'] ?? [],
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks', 'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
'branding' => $settings['branding'] ?? null,
'settings' => $settings, 'settings' => $settings,
'event_type_id' => $this->event_type_id, 'event_type_id' => $this->event_type_id,
'event_type' => $this->whenLoaded('eventType', function () { 'event_type' => $this->whenLoaded('eventType', function () {

View File

@@ -32,7 +32,10 @@ class TaskCollectionResource extends JsonResource
]; ];
}), }),
'tasks_count' => $this->whenCounted('tasks'), 'tasks_count' => $this->whenCounted('tasks'),
'events_count' => $this->whenCounted('events'),
'imports_count' => $this->whenCounted('events'),
'is_default' => (bool) ($this->is_default ?? false), 'is_default' => (bool) ($this->is_default ?? false),
'is_mine' => $this->tenant_id !== null,
'position' => $this->position, 'position' => $this->position,
'source_collection_id' => $this->source_collection_id, 'source_collection_id' => $this->source_collection_id,
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),

View File

@@ -41,9 +41,21 @@ class TaskResource extends JsonResource
'eventType', 'eventType',
fn () => new EventTypeResource($this->eventType) fn () => new EventTypeResource($this->eventType)
), ),
'emotion_id' => $this->emotion_id,
'emotion' => $this->whenLoaded(
'emotion',
fn () => [
'id' => $this->emotion->id,
'name' => $this->translatedText($this->normalizeTranslations($this->emotion->name), ''),
'name_translations' => $this->emotion->name,
'icon' => $this->emotion->icon,
'color' => $this->emotion->color,
]
),
'collection_id' => $this->collection_id, 'collection_id' => $this->collection_id,
'source_task_id' => $this->source_task_id, 'source_task_id' => $this->source_task_id,
'source_collection_id' => $this->source_collection_id, 'source_collection_id' => $this->source_collection_id,
'sort_order' => $this->pivot?->sort_order,
'assigned_events_count' => $assignedEventsCount, 'assigned_events_count' => $assignedEventsCount,
'assigned_events' => $this->whenLoaded( 'assigned_events' => $this->whenLoaded(
'assignedEvents', 'assignedEvents',
@@ -86,7 +98,7 @@ class TaskResource extends JsonResource
} }
/** /**
* @param array<string, string> $translations * @param array<string, string> $translations
*/ */
protected function translatedText(array $translations, string $fallback): string protected function translatedText(array $translations, string $fallback): string
{ {
@@ -109,4 +121,3 @@ class TaskResource extends JsonResource
return $first !== false ? $first : $fallback; return $first !== false ? $first : $fallback;
} }
} }

View File

@@ -98,6 +98,7 @@ class Event extends Model
public function tasks(): BelongsToMany public function tasks(): BelongsToMany
{ {
return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id') return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id')
->withPivot(['sort_order'])
->withTimestamps(); ->withTimestamps();
} }

View File

@@ -108,6 +108,7 @@ class Task extends Model
public function assignedEvents(): BelongsToMany public function assignedEvents(): BelongsToMany
{ {
return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id') return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id')
->withPivot(['sort_order'])
->withTimestamps(); ->withTimestamps();
} }
} }

View File

@@ -4,7 +4,6 @@ namespace App\Support;
use App\Models\Event; use App\Models\Event;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
class WatermarkConfigResolver class WatermarkConfigResolver
{ {
@@ -18,7 +17,12 @@ class WatermarkConfigResolver
$package = $event->eventPackages->first()?->package; $package = $event->eventPackages->first()?->package;
} }
return $package?->branding_allowed === true; // If no package is attached, default to allowing branding to avoid silently stripping event/tenant branding.
if (! $package) {
return true;
}
return $package->branding_allowed !== false;
} }
public static function determinePolicy(Event $event): string public static function determinePolicy(Event $event): string

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('emotions') || ! Schema::hasColumn('emotions', 'tenant_id')) {
return;
}
// Treat any emotions that aren't tied to a known tenant as global
DB::table('emotions')
->whereNotExists(function ($query) {
$query->selectRaw(1)
->from('tenants')
->whereColumn('tenants.id', 'emotions.tenant_id');
})
->update(['tenant_id' => null]);
}
public function down(): void
{
// No-op: data-only normalization cannot be safely reverted
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('emotions') || Schema::hasColumn('emotions', 'tenant_id')) {
return;
}
Schema::table('emotions', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->nullOnDelete();
$table->index('tenant_id');
});
// Treat all existing emotions as global by default
DB::table('emotions')->update(['tenant_id' => null]);
}
public function down(): void
{
if (! Schema::hasTable('emotions') || ! Schema::hasColumn('emotions', 'tenant_id')) {
return;
}
Schema::table('emotions', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->dropIndex(['tenant_id']);
$table->dropColumn('tenant_id');
});
}
};

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('emotions')) {
return;
}
$existing = DB::table('emotions')->count();
if ($existing > 0) {
return;
}
$now = now();
$defaults = [
['de' => 'Liebe', 'en' => 'Love', 'color' => '#f472b6'],
['de' => 'Freude', 'en' => 'Joy', 'color' => '#10b981'],
['de' => 'Rührung', 'en' => 'Touched', 'color' => '#60a5fa'],
['de' => 'Nostalgie', 'en' => 'Nostalgia', 'color' => '#a855f7'],
['de' => 'Überraschung', 'en' => 'Surprise', 'color' => '#f59e0b'],
];
$rows = [];
foreach ($defaults as $index => $emotion) {
$rows[] = [
'name' => json_encode($emotion),
'description' => json_encode([]),
'icon' => 'lucide-smile',
'color' => $emotion['color'],
'sort_order' => $index,
'is_active' => true,
'tenant_id' => null,
'created_at' => $now,
'updated_at' => $now,
];
}
DB::table('emotions')->insert($rows);
}
public function down(): void
{
if (! Schema::hasTable('emotions')) {
return;
}
DB::table('emotions')->truncate();
}
};

70
package-lock.json generated
View File

@@ -5,6 +5,9 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0", "@inertiajs/react": "^2.1.0",
"@jpisnice/shadcn-ui-mcp-server": "^1.1.4", "@jpisnice/shadcn-ui-mcp-server": "^1.1.4",
@@ -778,6 +781,73 @@
"kuler": "^2.0.0" "kuler": "^2.0.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.51.0", "version": "1.51.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz",

View File

@@ -42,6 +42,9 @@
"vitest": "^2.1.5" "vitest": "^2.1.5"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0", "@inertiajs/react": "^2.1.0",
"@jpisnice/shadcn-ui-mcp-server": "^1.1.4", "@jpisnice/shadcn-ui-mcp-server": "^1.1.4",

View File

@@ -433,10 +433,19 @@ export type TenantTask = {
is_completed: boolean; is_completed: boolean;
event_type_id: number | null; event_type_id: number | null;
event_type?: TenantEventType | null; event_type?: TenantEventType | null;
emotion_id?: number | null;
emotion?: {
id: number;
name: string;
name_translations: Record<string, string>;
icon: string | null;
color: string | null;
} | null;
tenant_id: number | null; tenant_id: number | null;
collection_id: number | null; collection_id: number | null;
source_task_id: number | null; source_task_id: number | null;
source_collection_id: number | null; source_collection_id: number | null;
sort_order?: number | null;
assigned_events_count: number; assigned_events_count: number;
assigned_events?: TenantEvent[]; assigned_events?: TenantEvent[];
created_at: string | null; created_at: string | null;
@@ -452,6 +461,7 @@ export type TenantTaskCollection = {
description_translations: Record<string, string | null>; description_translations: Record<string, string | null>;
tenant_id: number | null; tenant_id: number | null;
is_global: boolean; is_global: boolean;
is_mine?: boolean;
event_type?: { event_type?: {
id: number; id: number;
slug: string; slug: string;
@@ -460,6 +470,8 @@ export type TenantTaskCollection = {
icon: string | null; icon: string | null;
} | null; } | null;
tasks_count: number; tasks_count: number;
events_count?: number;
imports_count?: number;
position: number | null; position: number | null;
source_collection_id: number | null; source_collection_id: number | null;
created_at: string | null; created_at: string | null;
@@ -951,6 +963,7 @@ function normalizeTask(task: JsonValue): TenantTask {
typeof task.event_type_id === 'number' typeof task.event_type_id === 'number'
? Number(task.event_type_id) ? Number(task.event_type_id)
: eventType?.id ?? null; : eventType?.id ?? null;
const emotionRaw = task.emotion ?? null;
return { return {
id: Number(task.id ?? 0), id: Number(task.id ?? 0),
@@ -969,6 +982,25 @@ function normalizeTask(task: JsonValue): TenantTask {
is_completed: Boolean(task.is_completed ?? false), is_completed: Boolean(task.is_completed ?? false),
event_type_id: eventTypeId, event_type_id: eventTypeId,
event_type: eventType, event_type: eventType,
sort_order:
typeof task.sort_order === 'number'
? Number(task.sort_order)
: task.pivot && typeof (task.pivot as { sort_order?: unknown }).sort_order === 'number'
? Number((task.pivot as { sort_order?: number }).sort_order)
: null,
emotion_id: typeof task.emotion_id === 'number' ? Number(task.emotion_id) : null,
emotion: emotionRaw
? {
id: Number(emotionRaw.id ?? 0),
name: pickTranslatedText(
normalizeTranslationMap(emotionRaw.name_translations ?? emotionRaw.name ?? {}),
String(emotionRaw.name ?? '')
),
name_translations: normalizeTranslationMap(emotionRaw.name_translations ?? emotionRaw.name ?? {}),
icon: emotionRaw.icon ?? null,
color: emotionRaw.color ?? null,
}
: null,
tenant_id: task.tenant_id ?? null, tenant_id: task.tenant_id ?? null,
collection_id: task.collection_id ?? null, collection_id: task.collection_id ?? null,
source_task_id: task.source_task_id ?? null, source_task_id: task.source_task_id ?? null,
@@ -1010,8 +1042,11 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
description_translations: descriptionTranslations ?? {}, description_translations: descriptionTranslations ?? {},
tenant_id: raw.tenant_id ?? null, tenant_id: raw.tenant_id ?? null,
is_global: !raw.tenant_id, is_global: !raw.tenant_id,
is_mine: Boolean(raw.tenant_id),
event_type: eventType, event_type: eventType,
tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0), tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0),
events_count: raw.events_count !== undefined ? Number(raw.events_count) : undefined,
imports_count: raw.imports_count !== undefined ? Number(raw.imports_count) : undefined,
position: raw.position !== undefined ? Number(raw.position) : null, position: raw.position !== undefined ? Number(raw.position) : null,
source_collection_id: raw.source_collection_id ?? null, source_collection_id: raw.source_collection_id ?? null,
created_at: raw.created_at ?? null, created_at: raw.created_at ?? null,
@@ -1020,7 +1055,7 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
} }
function normalizeEmotion(raw: JsonValue): TenantEmotion { function normalizeEmotion(raw: JsonValue): TenantEmotion {
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}); const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}, undefined, true);
const descriptionTranslations = normalizeTranslationMap( const descriptionTranslations = normalizeTranslationMap(
raw.description_translations ?? raw.description ?? {}, raw.description_translations ?? raw.description ?? {},
undefined, undefined,
@@ -1037,11 +1072,11 @@ function normalizeEmotion(raw: JsonValue): TenantEmotion {
name_translations: nameTranslations, name_translations: nameTranslations,
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null, description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
description_translations: descriptionTranslations ?? {}, description_translations: descriptionTranslations ?? {},
icon: String(raw.icon ?? 'lucide-smile'), icon: typeof raw.icon === 'string' ? raw.icon : 'lucide-smile',
color: String(raw.color ?? '#6366f1'), color: String(raw.color ?? '#6366f1'),
sort_order: Number(raw.sort_order ?? 0), sort_order: Number(raw.sort_order ?? 0),
is_active: Boolean(raw.is_active ?? true), is_active: Boolean(raw.is_active ?? true),
is_global: !raw.tenant_id, is_global: raw.tenant_id === null || raw.tenant_id === undefined,
tenant_id: raw.tenant_id ?? null, tenant_id: raw.tenant_id ?? null,
event_types: (eventTypes as JsonValue[]).map((eventType) => { event_types: (eventTypes as JsonValue[]).map((eventType) => {
const translations = normalizeTranslationMap(eventType.name ?? {}); const translations = normalizeTranslationMap(eventType.name ?? {});
@@ -2086,6 +2121,8 @@ export async function getTaskCollections(params: {
search?: string; search?: string;
event_type?: string; event_type?: string;
scope?: 'global' | 'tenant'; scope?: 'global' | 'tenant';
top_picks?: boolean;
limit?: number;
} = {}): Promise<PaginatedResult<TenantTaskCollection>> { } = {}): Promise<PaginatedResult<TenantTaskCollection>> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', String(params.page)); if (params.page) searchParams.set('page', String(params.page));
@@ -2093,6 +2130,8 @@ export async function getTaskCollections(params: {
if (params.search) searchParams.set('search', params.search); if (params.search) searchParams.set('search', params.search);
if (params.event_type) searchParams.set('event_type', params.event_type); if (params.event_type) searchParams.set('event_type', params.event_type);
if (params.scope) searchParams.set('scope', params.scope); if (params.scope) searchParams.set('scope', params.scope);
if (params.top_picks) searchParams.set('top_picks', '1');
if (params.limit) searchParams.set('limit', String(params.limit));
const queryString = searchParams.toString(); const queryString = searchParams.toString();
const response = await authorizedFetch( const response = await authorizedFetch(
@@ -2142,6 +2181,34 @@ export async function importTaskCollection(
throw new Error('Missing collection payload'); throw new Error('Missing collection payload');
} }
export async function detachTasksFromEvent(eventId: number, taskIds: number[]): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/tasks/bulk-detach-event/${eventId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_ids: taskIds }),
});
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to detach tasks', response.status, payload);
throw new Error('Failed to detach tasks');
}
}
export async function reorderEventTasks(eventId: number, taskIds: number[]): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_ids: taskIds }),
});
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to reorder tasks', response.status, payload);
throw new Error('Failed to reorder tasks');
}
}
export async function getEmotions(): Promise<TenantEmotion[]> { export async function getEmotions(): Promise<TenantEmotion[]> {
const response = await authorizedFetch('/api/v1/tenant/emotions'); const response = await authorizedFetch('/api/v1/tenant/emotions');
if (!response.ok) { if (!response.ok) {
@@ -2176,6 +2243,17 @@ export async function updateEmotion(emotionId: number, payload: EmotionPayload):
return normalizeEmotion(json.data); return normalizeEmotion(json.data);
} }
export async function deleteEmotion(emotionId: number): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/emotions/${emotionId}`, {
method: 'DELETE',
});
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to delete emotion', response.status, payload);
throw new Error('Failed to delete emotion');
}
}
export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise<PaginatedResult<TenantTask>> { export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise<PaginatedResult<TenantTask>> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', String(params.page)); if (params.page) searchParams.set('page', String(params.page));
@@ -2240,8 +2318,12 @@ export async function assignTasksToEvent(eventId: number, taskIds: number[]): Pr
} }
} }
export async function getEventTasks(eventId: number, page = 1): Promise<PaginatedResult<TenantTask>> { export async function getEventTasks(
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}`); eventId: number,
page = 1,
perPage = 500,
): Promise<PaginatedResult<TenantTask>> {
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}&per_page=${perPage}`);
if (!response.ok) { if (!response.ok) {
const payload = await safeJson(response); const payload = await safeJson(response);
console.error('[API] Failed to load event tasks', response.status, payload); console.error('[API] Failed to load event tasks', response.status, payload);

View File

@@ -178,7 +178,7 @@ export function CommandShelf() {
}, },
{ {
key: 'invites', key: 'invites',
label: t('commandShelf.actions.invites.label', 'QR & Einladungen'), label: t('commandShelf.actions.invites.label', 'QR-Codes'),
description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'), description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'),
icon: QrCode, icon: QrCode,
href: ADMIN_EVENT_INVITES_PATH(slug), href: ADMIN_EVENT_INVITES_PATH(slug),
@@ -220,7 +220,7 @@ export function CommandShelf() {
}, },
{ {
key: 'invites', key: 'invites',
label: t('commandShelf.metrics.invites', 'Einladungen'), label: t('commandShelf.metrics.invites', 'QR-Codes'),
value: activeEvent.active_invites_count ?? activeEvent.total_invites_count, value: activeEvent.active_invites_count ?? activeEvent.total_invites_count,
hint: t('commandShelf.metrics.invitesHint', 'live'), hint: t('commandShelf.metrics.invitesHint', 'live'),
}, },
@@ -373,7 +373,7 @@ export function CommandShelf() {
{t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')} {t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
{t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und Einladungen an einem Ort.')} {t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und QR-Codes an einem Ort.')}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex flex-wrap gap-2 px-4 text-xs text-slate-500 dark:text-slate-300"> <div className="flex flex-wrap gap-2 px-4 text-xs text-slate-500 dark:text-slate-300">

View File

@@ -36,7 +36,7 @@ function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']
{ key: 'photobooth', label: t('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) }, { key: 'photobooth', label: t('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) },
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) }, { key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) }, { key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
{ key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) }, { key: 'invites', label: t('eventMenu.invites', 'QR-Codes'), href: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) }, { key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) },
]; ];
} }

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type ActionTone = 'primary' | 'secondary' | 'danger' | 'neutral';
export type FloatingAction = {
key: string;
label: string;
icon: LucideIcon;
onClick: () => void;
tone?: ActionTone;
disabled?: boolean;
loading?: boolean;
ariaLabel?: string;
};
export function FloatingActionBar({ actions, className }: { actions: FloatingAction[]; className?: string }): React.ReactElement | null {
if (!actions.length) {
return null;
}
const toneClasses: Record<ActionTone, string> = {
primary: 'bg-primary text-primary-foreground shadow-primary/25 hover:bg-primary/90 focus-visible:ring-primary/70 border border-primary/20',
secondary: 'bg-[var(--tenant-surface-strong)] text-[var(--tenant-foreground)] shadow-slate-300/60 hover:bg-[var(--tenant-surface)] focus-visible:ring-slate-200 border border-[var(--tenant-border-strong)]',
neutral: 'bg-white/90 text-slate-900 shadow-slate-200/80 hover:bg-white focus-visible:ring-slate-200 border border-slate-200 dark:bg-slate-800/80 dark:text-white dark:border-slate-700',
danger: 'bg-rose-500 text-white shadow-rose-300/50 hover:bg-rose-600 focus-visible:ring-rose-200 border border-rose-400/80',
};
return (
<div
className={cn(
'pointer-events-none fixed inset-x-4 bottom-[calc(env(safe-area-inset-bottom,0px)+72px)] z-50 sm:inset-auto sm:right-6 sm:bottom-6',
className
)}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<div className="pointer-events-auto flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
{actions.map((action) => {
const Icon = action.icon;
const tone = action.tone ?? 'primary';
return (
<Button
key={action.key}
size="lg"
className={cn(
'group flex h-11 w-11 items-center justify-center gap-0 rounded-full p-0 text-sm font-semibold shadow-lg transition-all duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2',
toneClasses[tone]
)}
onClick={action.onClick}
disabled={action.disabled || action.loading}
aria-label={action.ariaLabel ?? action.label}
>
{action.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Icon className="h-4 w-4" />
)}
<span className="hidden sm:inline">{action.label}</span>
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -94,7 +94,7 @@ export function DashboardEventFocusCard({
}, },
{ {
key: 'invites', key: 'invites',
label: t('stats.invites', 'Einladungen live'), label: t('stats.invites', 'QR-Codes live'),
value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(), value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(),
}, },
]; ];
@@ -110,7 +110,7 @@ export function DashboardEventFocusCard({
}, },
{ {
key: 'invites', key: 'invites',
label: t('actions.invites', 'QR & Einladungen'), label: t('actions.invites', 'QR-Codes'),
description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'), description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'),
icon: QrCode, icon: QrCode,
handler: onOpenInvites, handler: onOpenInvites,

View File

@@ -7,7 +7,7 @@
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen mobil wie auf dem Desktop.", "hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen mobil wie auf dem Desktop.",
"features": [ "features": [
"Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.", "Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.",
"Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.", "Erstelle Zugangs-QR-Codes und teile sie sofort.",
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus." "Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
], ],
"lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.", "lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.",

View File

@@ -36,7 +36,7 @@
"photos": "Uploads", "photos": "Uploads",
"guests": "Team & Gäste", "guests": "Team & Gäste",
"tasks": "Aufgaben", "tasks": "Aufgaben",
"invites": "Einladungen", "invites": "QR-Codes",
"toolkit": "Toolkit", "toolkit": "Toolkit",
"recap": "Nachbereitung" "recap": "Nachbereitung"
}, },
@@ -90,7 +90,7 @@
"mobile": { "mobile": {
"openActions": "Schnellaktionen öffnen", "openActions": "Schnellaktionen öffnen",
"sheetTitle": "Schnellaktionen", "sheetTitle": "Schnellaktionen",
"sheetDescription": "Moderation, Aufgaben und Einladungen an einem Ort.", "sheetDescription": "Moderation, Aufgaben und QR-Codes an einem Ort.",
"tip": "Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.", "tip": "Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.",
"tipCta": "Verstanden" "tipCta": "Verstanden"
}, },
@@ -103,8 +103,8 @@
"welcome": { "welcome": {
"eyebrow": "Event Admin", "eyebrow": "Event Admin",
"title": "Event-Branding, Aufgaben & Foto-Moderation in einer App.", "title": "Event-Branding, Aufgaben & Foto-Moderation in einer App.",
"subtitle": "Bereite dein Event vor, teile Einladungen, moderiere Uploads live und gib die Galerie danach frei.", "subtitle": "Bereite dein Event vor, teile QR-Codes, moderiere Uploads live und gib die Galerie danach frei.",
"badge": "Fotos, Aufgaben & Einladungen an einem Ort", "badge": "Fotos, Aufgaben & QR-Codes an einem Ort",
"loginPrompt": "Bereits Kunde? Login oben rechts.", "loginPrompt": "Bereits Kunde? Login oben rechts.",
"cta": { "cta": {
"login": "Login", "login": "Login",
@@ -122,7 +122,7 @@
"subtitle": "Alles an einem Ort", "subtitle": "Alles an einem Ort",
"branding": { "branding": {
"title": "Branding & Layout", "title": "Branding & Layout",
"description": "Farben, Schriften, QR-Layouts und Einladungen in einem Fluss." "description": "Farben, Schriften, QR-Layouts und QR-Zugänge in einem Fluss."
}, },
"tasks": { "tasks": {
"title": "Aufgaben & Emotion-Sets", "title": "Aufgaben & Emotion-Sets",
@@ -133,7 +133,7 @@
"description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen." "description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen."
}, },
"invites": { "invites": {
"title": "Einladungen & QR", "title": "QR-Codes & Layouts",
"description": "Links und Druckvorlagen generieren mit Paketlimits im Blick." "description": "Links und Druckvorlagen generieren mit Paketlimits im Blick."
} }
}, },
@@ -146,7 +146,7 @@
"accent": "Setup" "accent": "Setup"
}, },
"share": { "share": {
"title": "Teilen & Einladen", "title": "Teilen & QR-Codes",
"description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.", "description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.",
"accent": "Share" "accent": "Share"
}, },
@@ -159,12 +159,12 @@
"plans": { "plans": {
"title": "Pakete im Überblick", "title": "Pakete im Überblick",
"subtitle": "Wähle das passende Kontingent", "subtitle": "Wähle das passende Kontingent",
"hint": "Starter, Standard oder Reseller alles mit Moderation & Einladungen.", "hint": "Starter, Standard oder Reseller alles mit Moderation & QR-Codes.",
"starter": { "starter": {
"title": "Starter", "title": "Starter",
"badge": "Für ein Event", "badge": "Für ein Event",
"p1": "1 Event, Basis-Branding", "p1": "1 Event, Basis-Branding",
"p2": "Aufgaben & Einladungen inklusive", "p2": "Aufgaben & QR-Codes inklusive",
"p3": "Moderation & Galerie-Link" "p3": "Moderation & Galerie-Link"
}, },
"standard": { "standard": {
@@ -200,7 +200,7 @@
"preview": { "preview": {
"title": "Was dich erwartet", "title": "Was dich erwartet",
"items": [ "items": [
"Moderation, Aufgaben und Einladungen als Schnellzugriff", "Moderation, Aufgaben und QR-Codes als Schnellzugriff",
"Sticky Actions auf Mobile für den Eventtag", "Sticky Actions auf Mobile für den Eventtag",
"Paket-Status & Limits jederzeit sichtbar" "Paket-Status & Limits jederzeit sichtbar"
] ]

View File

@@ -43,7 +43,7 @@
"noDate": "Kein Datum", "noDate": "Kein Datum",
"actions": { "actions": {
"photos": "Uploads", "photos": "Uploads",
"invites": "QR & Einladungen", "invites": "QR-Codes",
"tasks": "Aufgaben" "tasks": "Aufgaben"
} }
}, },
@@ -62,8 +62,8 @@
"hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben." "hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben."
}, },
"qr": { "qr": {
"title": "QR-Einladung erstellt", "title": "QR-Code erstellt",
"hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter." "hint": "Erstelle einen QR-Code und lade die Drucklayouts herunter."
}, },
"package": { "package": {
"title": "Paket aktiv", "title": "Paket aktiv",
@@ -73,7 +73,7 @@
"actions": { "actions": {
"createEvent": "Event erstellen", "createEvent": "Event erstellen",
"openTasks": "Tasks öffnen", "openTasks": "Tasks öffnen",
"openQr": "QR-Einladungen", "openQr": "QR-Codes",
"openPackages": "Pakete ansehen" "openPackages": "Pakete ansehen"
} }
}, },
@@ -168,7 +168,7 @@
}, },
"events": { "events": {
"question": "Wie arbeite ich mit Events?", "question": "Wie arbeite ich mit Events?",
"answer": "Wähle dein aktives Event, passe Aufgaben an und teile Einladungen. Ausführliche Dokumentation folgt." "answer": "Wähle dein aktives Event, passe Aufgaben an und teile QR-Codes. Ausführliche Dokumentation folgt."
}, },
"uploads": { "uploads": {
"question": "Wie moderiere ich Uploads?", "question": "Wie moderiere ich Uploads?",

View File

@@ -232,7 +232,7 @@
"missingSlug": "Kein Event-Slug angegeben.", "missingSlug": "Kein Event-Slug angegeben.",
"load": "Mitglieder konnten nicht geladen werden.", "load": "Mitglieder konnten nicht geladen werden.",
"emailRequired": "Bitte gib eine E-Mail-Adresse ein.", "emailRequired": "Bitte gib eine E-Mail-Adresse ein.",
"invite": "Einladung konnte nicht verschickt werden.", "invite": "QR-Code konnte nicht verschickt werden.",
"remove": "Mitglied konnte nicht entfernt werden." "remove": "Mitglied konnte nicht entfernt werden."
}, },
"alerts": { "alerts": {
@@ -261,7 +261,7 @@
"namePlaceholder": "Name", "namePlaceholder": "Name",
"roleLabel": "Rolle", "roleLabel": "Rolle",
"rolePlaceholder": "Rolle wählen", "rolePlaceholder": "Rolle wählen",
"submit": "Einladung senden" "submit": "QR-Code senden"
}, },
"roles": { "roles": {
"tenantAdmin": "Kunden-Admin", "tenantAdmin": "Kunden-Admin",
@@ -282,7 +282,7 @@
"summary": "Übersicht", "summary": "Übersicht",
"photos": "Uploads", "photos": "Uploads",
"tasks": "Aufgaben", "tasks": "Aufgaben",
"invites": "Einladungen", "invites": "QR-Codes",
"branding": "Branding", "branding": "Branding",
"photobooth": "Photobooth", "photobooth": "Photobooth",
"recap": "Nachbereitung" "recap": "Nachbereitung"
@@ -372,7 +372,7 @@
}, },
"toolkit": { "toolkit": {
"titleFallback": "Event-Day Toolkit", "titleFallback": "Event-Day Toolkit",
"subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.", "subtitle": "Behalte Uploads, Aufgaben und QR-Codes am Eventtag im Blick.",
"errors": { "errors": {
"missingSlug": "Kein Event-Slug angegeben.", "missingSlug": "Kein Event-Slug angegeben.",
"loadFailed": "Toolkit konnte nicht geladen werden.", "loadFailed": "Toolkit konnte nicht geladen werden.",
@@ -388,14 +388,14 @@
"errorTitle": "Fehler", "errorTitle": "Fehler",
"attention": "Achtung", "attention": "Achtung",
"noTasks": "Noch keine Aufgaben zugewiesen aktiviere ein Paket oder lege Aufgaben fest.", "noTasks": "Noch keine Aufgaben zugewiesen aktiviere ein Paket oder lege Aufgaben fest.",
"noInvites": "Es gibt keine aktiven QR-Einladungen. Erstelle eine Einladung, um Gäste in die App zu holen.", "noInvites": "Es gibt keine aktiven QR-Codes. Erstelle eine QR-Code, um Gäste in die App zu holen.",
"pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen." "pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen."
}, },
"metrics": { "metrics": {
"uploadsTotal": "Uploads gesamt", "uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)", "uploads24h": "Uploads (24h)",
"pendingPhotos": "Unmoderierte Fotos", "pendingPhotos": "Unmoderierte Fotos",
"activeInvites": "Aktive Einladungen", "activeInvites": "Aktive QR-Codes",
"engagementMode": "Modus", "engagementMode": "Modus",
"modePhotoOnly": "Foto-Modus", "modePhotoOnly": "Foto-Modus",
"modeTasks": "Aufgaben" "modeTasks": "Aufgaben"
@@ -410,14 +410,14 @@
"statusPending": "Status: Prüfung ausstehend" "statusPending": "Status: Prüfung ausstehend"
}, },
"invites": { "invites": {
"title": "QR-Einladungen", "title": "QR-Codes",
"subtitle": "Aktive Links und Layouts im Blick behalten.", "subtitle": "Aktive Links und Layouts im Blick behalten.",
"activeCount": "{{count}} aktiv", "activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt", "totalCount": "{{count}} gesamt",
"empty": "Noch keine QR-Einladungen erstellt.", "empty": "Noch keine QR-Codes erstellt.",
"statusActive": "Aktiv", "statusActive": "Aktiv",
"statusInactive": "Inaktiv", "statusInactive": "Inaktiv",
"manage": "Einladungen verwalten" "manage": "QR-Codes verwalten"
}, },
"tasks": { "tasks": {
"title": "Aktive Aufgaben", "title": "Aktive Aufgaben",
@@ -460,8 +460,8 @@
"collectionsCta": "Mission Packs anzeigen" "collectionsCta": "Mission Packs anzeigen"
}, },
"customizer": { "customizer": {
"title": "QR-Einladung anpassen", "title": "QR-Code anpassen",
"description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.", "description": "Passe Layout, Texte, Farben und Logo deiner QR-Codeskarten an.",
"layout": "Layout", "layout": "Layout",
"selectLayout": "Layout auswählen", "selectLayout": "Layout auswählen",
"headline": "Überschrift", "headline": "Überschrift",
@@ -519,20 +519,20 @@
} }
}, },
"invites": { "invites": {
"cardTitle": "QR-Einladungen & Layouts", "cardTitle": "QR-Codes & Layouts",
"cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.", "cardDescription": "Erzeuge QR-Codes, passe Layouts an und stelle druckfertige Vorlagen bereit.",
"subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.", "subtitle": "Manage QR-Codes, Drucklayouts und Branding für deine Gäste.",
"tabs": { "tabs": {
"layout": "QR-Code-Layout anpassen", "layout": "QR-Code-Layout anpassen",
"share": "Links & QR teilen", "share": "Links & QR teilen",
"export": "Drucken & Export" "export": "Drucken & Export"
}, },
"summary": { "summary": {
"active": "Aktive Einladungen", "active": "Aktive QR-Codes",
"total": "Gesamt" "total": "Gesamt"
}, },
"workflow": { "workflow": {
"title": "Einladungs-Workflow", "title": "QR-Codes-Workflow",
"description": "Durchlaufe Layout, Links und Export Schritt für Schritt.", "description": "Durchlaufe Layout, Links und Export Schritt für Schritt.",
"badge": "Setup", "badge": "Setup",
"steps": { "steps": {
@@ -542,7 +542,7 @@
}, },
"share": { "share": {
"title": "Links & QR teilen", "title": "Links & QR teilen",
"description": "Aktiviere Einladungen, kopiere QR-Codes und teile sie mit dem Team." "description": "Aktiviere QR-Codes, kopiere QR-Codes und teile sie mit dem Team."
}, },
"export": { "export": {
"title": "Drucken & Export", "title": "Drucken & Export",
@@ -564,13 +564,13 @@
"editLayout": "Layout bearbeiten", "editLayout": "Layout bearbeiten",
"editHint": "Farben & Texte direkt im Editor anpassen.", "editHint": "Farben & Texte direkt im Editor anpassen.",
"export": "Drucken/Export", "export": "Drucken/Export",
"create": "Weitere Einladung" "create": "Weitere QR-Code"
}, },
"hint": "Teile den Link direkt im Team oder in Newslettern." "hint": "Teile den Link direkt im Team oder in Newslettern."
}, },
"actions": { "actions": {
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"create": "Neue Einladung erstellen", "create": "Neue QR-Code erstellen",
"backToList": "Zurück zur Übersicht", "backToList": "Zurück zur Übersicht",
"backToEvent": "Event öffnen", "backToEvent": "Event öffnen",
"copy": "Link kopieren", "copy": "Link kopieren",
@@ -589,8 +589,8 @@
"qrAlt": "QR-Code Vorschau" "qrAlt": "QR-Code Vorschau"
}, },
"empty": { "empty": {
"title": "Noch keine Einladungen", "title": "Noch keine QR-Codes",
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten." "copy": "Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten."
}, },
"errorTitle": "Aktion fehlgeschlagen", "errorTitle": "Aktion fehlgeschlagen",
"export": { "export": {
@@ -602,9 +602,9 @@
}, },
"previewHint": "Speichere deine Änderungen, um die Exportdateien neu zu erstellen.", "previewHint": "Speichere deine Änderungen, um die Exportdateien neu zu erstellen.",
"noLayoutPreview": "Noch keine Vorschau verfügbar. Speichere das Layout zuerst.", "noLayoutPreview": "Noch keine Vorschau verfügbar. Speichere das Layout zuerst.",
"selectPlaceholder": "Einladung auswählen", "selectPlaceholder": "QR-Code auswählen",
"noInviteSelected": "Wähle zunächst eine Einladung aus, um Downloads zu starten.", "noInviteSelected": "Wähle zunächst eine QR-Code aus, um Downloads zu starten.",
"noLayouts": "Für diese Einladung sind aktuell keine Layouts verfügbar.", "noLayouts": "Für diese QR-Code sind aktuell keine Layouts verfügbar.",
"actions": { "actions": {
"title": "Aktionen", "title": "Aktionen",
"description": "Starte deinen Testdruck oder lade die Layouts herunter.", "description": "Starte deinen Testdruck oder lade die Layouts herunter.",
@@ -685,14 +685,14 @@
"title": "Live-Vorschau", "title": "Live-Vorschau",
"subtitle": "So sieht dein Layout beim Export aus.", "subtitle": "So sieht dein Layout beim Export aus.",
"mobileOpen": "Vorschau anzeigen", "mobileOpen": "Vorschau anzeigen",
"mobileTitle": "Einladungsvorschau", "mobileTitle": "QR-Codesvorschau",
"mobileHint": "Öffnet eine Vorschau in einem Overlay", "mobileHint": "Öffnet eine Vorschau in einem Overlay",
"readyForGuests": "Bereit für Gäste", "readyForGuests": "Bereit für Gäste",
"instructions": "Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.", "instructions": "Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.",
"qrAlt": "QR-Code der Einladung" "qrAlt": "QR-Code der QR-Code"
}, },
"placeholderTitle": "Kein Layout verfügbar", "placeholderTitle": "Kein Layout verfügbar",
"placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.", "placeholderCopy": "Erstelle eine QR-Code, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",
"loadingTitle": "Layouts werden geladen", "loadingTitle": "Layouts werden geladen",
"loadingDescription": "Bitte warte einen Moment, wir bereiten die Drucklayouts vor.", "loadingDescription": "Bitte warte einen Moment, wir bereiten die Drucklayouts vor.",
"loadingError": "Layouts konnten nicht geladen werden.", "loadingError": "Layouts konnten nicht geladen werden.",
@@ -807,7 +807,7 @@
"edit": "Bearbeiten", "edit": "Bearbeiten",
"members": "Team & Rollen", "members": "Team & Rollen",
"tasks": "Aufgaben verwalten", "tasks": "Aufgaben verwalten",
"invites": "Einladungen & Layouts", "invites": "QR-Codes & Layouts",
"photos": "Fotos moderieren", "photos": "Fotos moderieren",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"buyMorePhotos": "Mehr Fotos freischalten", "buyMorePhotos": "Mehr Fotos freischalten",
@@ -815,11 +815,11 @@
"extendGallery": "Galerie verlängern" "extendGallery": "Galerie verlängern"
}, },
"workspace": { "workspace": {
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.", "detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.", "toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.",
"hero": { "hero": {
"badge": "Event", "badge": "Event",
"description": "Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.", "description": "Konzentriere dich auf Aufgaben, Moderation und QR-Codes für dieses Event.",
"liveBadge": "Live?" "liveBadge": "Live?"
}, },
"sections": { "sections": {
@@ -880,16 +880,16 @@
"uploadsTotal": "Uploads gesamt", "uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)", "uploads24h": "Uploads (24h)",
"pending": "Fotos in Moderation", "pending": "Fotos in Moderation",
"activeInvites": "Aktive Einladungen" "activeInvites": "Aktive QR-Codes"
}, },
"invites": { "invites": {
"badge": "Einladungen", "badge": "QR-Codes",
"title": "QR-Einladungen", "title": "QR-Codes",
"subtitle": "Behält aktive Einladungen und Layouts im Blick.", "subtitle": "Behält aktive QR-Codes und Layouts im Blick.",
"activeCount": "{{count}} aktiv", "activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt", "totalCount": "{{count}} gesamt",
"empty": "Noch keine Einladungen erstellt.", "empty": "Noch keine QR-Codes erstellt.",
"manage": "Layouts & Einladungen verwalten" "manage": "Layouts & QR-Codes verwalten"
}, },
"tasks": { "tasks": {
"badge": "Aufgaben", "badge": "Aufgaben",
@@ -1009,7 +1009,7 @@
"negative": "Brauch(t)e Unterstützung", "negative": "Brauch(t)e Unterstützung",
"best": { "best": {
"uploads": "Uploads & Geschwindigkeit", "uploads": "Uploads & Geschwindigkeit",
"invites": "QR-Einladungen & Layouts", "invites": "QR-Codes & Layouts",
"moderation": "Moderation & Export", "moderation": "Moderation & Export",
"experience": "Allgemeine App-Erfahrung" "experience": "Allgemeine App-Erfahrung"
}, },
@@ -1603,18 +1603,18 @@
}, },
"noEvents": { "noEvents": {
"title": "Lass uns starten", "title": "Lass uns starten",
"description": "Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.", "description": "Erstelle dein erstes Event, um Uploads, Aufgaben und QR-Codes freizuschalten.",
"cta": "Event erstellen" "cta": "Event erstellen"
}, },
"draftEvent": { "draftEvent": {
"title": "Event noch als Entwurf", "title": "Event noch als Entwurf",
"description": "Veröffentliche das Event, um Einladungen und Galerie freizugeben.", "description": "Veröffentliche das Event, um QR-Codes und Galerie freizugeben.",
"cta": "Event öffnen" "cta": "Event öffnen"
}, },
"upcomingEvent": { "upcomingEvent": {
"title": "Event startet bald", "title": "Event startet bald",
"description_today": "Heute findet ein Event statt checke Uploads und Tasks.", "description_today": "Heute findet ein Event statt checke Uploads und Tasks.",
"description_days": "Noch {{count}} Tage bereite Einladungen und Aufgaben vor.", "description_days": "Noch {{count}} Tage bereite QR-Codes und Aufgaben vor.",
"cta": "Zum Event" "cta": "Zum Event"
}, },
"pendingUploads": { "pendingUploads": {

View File

@@ -24,6 +24,13 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
const eventDate = event.event_date ? new Date(event.event_date) : null; const eventDate = event.event_date ? new Date(event.event_date) : null;
const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false; const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false;
const hasBranding = (() => {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const brandingAllowed = Boolean(settings.branding_allowed ?? true);
const packageAllowsBranding = brandingAllowed || settings.branding_allowed === undefined;
return packageAllowsBranding;
})();
const formatBadge = (value?: number | null): number | undefined => { const formatBadge = (value?: number | null): number | undefined => {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) { if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value; return value;
@@ -31,7 +38,7 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
return undefined; return undefined;
}; };
return [ const tabs = [
{ {
key: 'overview', key: 'overview',
label: translate('eventMenu.summary', 'Übersicht'), label: translate('eventMenu.summary', 'Übersicht'),
@@ -51,14 +58,9 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
}, },
{ {
key: 'invites', key: 'invites',
label: translate('eventMenu.invites', 'Einladungen'), label: translate('eventMenu.invites', 'QR-Codes'),
href: ADMIN_EVENT_INVITES_PATH(event.slug), href: ADMIN_EVENT_INVITES_PATH(event.slug),
}, },
{
key: 'branding',
label: translate('eventMenu.branding', 'Branding'),
href: ADMIN_EVENT_BRANDING_PATH(event.slug),
},
{ {
key: 'photobooth', key: 'photobooth',
label: translate('eventMenu.photobooth', 'Photobooth'), label: translate('eventMenu.photobooth', 'Photobooth'),
@@ -72,4 +74,14 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
}] }]
: []), : []),
]; ];
if (hasBranding) {
tabs.splice(4, 0, {
key: 'branding',
label: translate('eventMenu.branding', 'Branding'),
href: ADMIN_EVENT_BRANDING_PATH(event.slug),
});
}
return tabs;
} }

View File

@@ -15,8 +15,16 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { getEmotions, createEmotion, updateEmotion, TenantEmotion, EmotionPayload } from '../api'; import {
getEmotions,
createEmotion,
updateEmotion,
deleteEmotion,
TenantEmotion,
EmotionPayload,
} from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import toast from 'react-hot-toast';
type EmotionFormState = { type EmotionFormState = {
name: string; name: string;
@@ -49,6 +57,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogOpen, setDialogOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<TenantEmotion | null>(null);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE); const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE);
@@ -107,9 +116,11 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const created = await createEmotion(payload); const created = await createEmotion(payload);
setEmotions((prev) => [created, ...prev]); setEmotions((prev) => [created, ...prev]);
setDialogOpen(false); setDialogOpen(false);
toast.success(t('emotions.toast.created', 'Emotion erstellt.'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(t('emotions.errors.create')); setError(t('emotions.errors.create'));
toast.error(t('emotions.toast.error', 'Emotion konnte nicht erstellt werden.'));
} }
} finally { } finally {
setSaving(false); setSaving(false);
@@ -120,13 +131,35 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
try { try {
const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active }); const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active });
setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
toast.success(
updated.is_active
? t('emotions.toast.activated', 'Emotion aktiviert.')
: t('emotions.toast.deactivated', 'Emotion deaktiviert.')
);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(t('emotions.errors.toggle')); setError(t('emotions.errors.toggle'));
toast.error(t('emotions.toast.errorToggle', 'Emotion konnte nicht aktualisiert werden.'));
} }
} }
} }
async function handleDeleteEmotion(emotion: TenantEmotion) {
setSaving(true);
try {
await deleteEmotion(emotion.id);
setEmotions((prev) => prev.filter((item) => item.id !== emotion.id));
toast.success(t('emotions.toast.deleted', 'Emotion gelöscht.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('emotions.toast.deleteError', 'Emotion konnte nicht gelöscht werden.'));
}
} finally {
setSaving(false);
setDeleteTarget(null);
}
}
const locale = i18n.language.startsWith('en') ? enGB : de; const locale = i18n.language.startsWith('en') ? enGB : de;
const title = embedded ? t('emotions.title') : t('emotions.title'); const title = embedded ? t('emotions.title') : t('emotions.title');
const subtitle = embedded const subtitle = embedded
@@ -165,17 +198,18 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
) : emotions.length === 0 ? ( ) : emotions.length === 0 ? (
<EmptyEmotionsState onCreate={openCreateDialog} /> <EmptyEmotionsState onCreate={openCreateDialog} />
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => ( {emotions.map((emotion) => (
<EmotionCard <EmotionCard
key={emotion.id} key={emotion.id}
emotion={emotion} emotion={emotion}
onToggle={() => toggleEmotion(emotion)} onToggle={() => toggleEmotion(emotion)}
locale={locale} onDelete={() => setDeleteTarget(emotion)}
/> locale={locale}
))} />
</div> ))}
)} </div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -187,6 +221,29 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
saving={saving} saving={saving}
onSubmit={handleCreate} onSubmit={handleCreate}
/> />
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('emotions.delete.title', 'Emotion löschen?')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-slate-600">
{t('emotions.delete.confirm', { defaultValue: 'Soll "{{name}}" wirklich gelöscht werden?' , name: deleteTarget?.name ?? '' })}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t('actions.cancel', 'Abbrechen')}
</Button>
<Button
variant="destructive"
onClick={() => deleteTarget && void handleDeleteEmotion(deleteTarget)}
disabled={saving}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.delete', 'Löschen')}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -203,10 +260,12 @@ export default function EmotionsPage() {
function EmotionCard({ function EmotionCard({
emotion, emotion,
onToggle, onToggle,
onDelete,
locale, locale,
}: { }: {
emotion: TenantEmotion; emotion: TenantEmotion;
onToggle: () => void; onToggle: () => void;
onDelete: () => void;
locale: Locale; locale: Locale;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -252,7 +311,13 @@ function EmotionCard({
<Power className="mr-1 h-4 w-4" /> <Power className="mr-1 h-4 w-4" />
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')} {emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
</Button> </Button>
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} /> {!emotion.is_global ? (
<Button variant="ghost" size="sm" className="text-rose-600 hover:bg-rose-50" onClick={onDelete}>
{t('actions.delete', 'Löschen')}
</Button>
) : (
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
)}
</CardFooter> </CardFooter>
</Card> </Card>
); );

View File

@@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Loader2, Moon, Sparkles, Sun } from 'lucide-react'; import { ArrowLeft, Moon, RotateCcw, Save, Sparkles, Sun, UploadCloud } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { SectionCard, SectionHeader } from '../components/tenant'; import { SectionCard, SectionHeader } from '../components/tenant';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -14,6 +15,7 @@ import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { ADMIN_EVENT_VIEW_PATH } from '../constants'; import { ADMIN_EVENT_VIEW_PATH } from '../constants';
import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api'; import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -23,6 +25,20 @@ import { ensureFontLoaded, useTenantFonts } from '../lib/fonts';
const DEFAULT_FONT_VALUE = '__default'; const DEFAULT_FONT_VALUE = '__default';
const CUSTOM_FONT_VALUE = '__custom'; const CUSTOM_FONT_VALUE = '__custom';
const MAX_LOGO_UPLOAD_BYTES = 1024 * 1024;
const EMOTICON_GRID: string[] = [
'✨', '🎉', '🎊', '🥳', '🎈', '🎁', '🎂', '🍾', '🥂', '🍻',
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
'🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚',
'😎', '🤩', '🤗', '🤝', '👍', '🙌', '👏', '👐', '🤲', '🙏',
'🤍', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤎', '🤍',
'⭐', '🌟', '💫', '🔥', '⚡', '🌈', '☀️', '🌅', '🌠', '🌌',
'🎵', '🎶', '🎤', '🎧', '🎸', '🥁', '🎺', '🎹', '🎻', '🪩',
'🍕', '🍔', '🌮', '🌯', '🍣', '🍱', '🍰', '🍪', '🍫', '🍩',
'☕', '🍵', '🥤', '🍹', '🍸', '🍷', '🍺', '🍻', '🥂', '🍾',
'📸', '🎥', '📹', '📱', '💡', '🛎️', '🪄', '🎯', '🏆', '🥇',
];
type BrandingForm = { type BrandingForm = {
useDefault: boolean; useDefault: boolean;
@@ -225,6 +241,43 @@ function resolvePreviewBranding(form: BrandingForm, tenantBranding: BrandingForm
return form; return form;
} }
function coerceEventName(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
const preferred = record.de ?? record.en ?? Object.values(record)[0];
if (typeof preferred === 'string') {
return preferred;
}
}
return '';
}
function coerceEventDate(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
const raw = value.trim();
// If ISO with timezone, convert to local date (validation uses server local date)
const parsed = new Date(raw);
if (!Number.isNaN(parsed.valueOf())) {
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
if (raw.length >= 10) {
return raw.slice(0, 10);
}
return raw;
}
return null;
}
export default function EventBrandingPage(): React.ReactElement { export default function EventBrandingPage(): React.ReactElement {
const { slug } = useParams<{ slug?: string }>(); const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -232,6 +285,7 @@ export default function EventBrandingPage(): React.ReactElement {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [form, setForm] = useState<BrandingForm>(DEFAULT_BRANDING_FORM); const [form, setForm] = useState<BrandingForm>(DEFAULT_BRANDING_FORM);
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light'); const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light');
const [emoticonDialogOpen, setEmoticonDialogOpen] = useState(false);
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts(); const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
const title = t('branding.title', 'Branding & Fonts'); const title = t('branding.title', 'Branding & Fonts');
@@ -290,11 +344,32 @@ export default function EventBrandingPage(): React.ReactElement {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (payload: BrandingForm) => { mutationFn: async (payload: BrandingForm) => {
if (!slug) throw new Error('Missing event slug'); if (!slug) throw new Error('Missing event context');
// Fetch a fresh snapshot to ensure required fields are sent and settings are merged instead of overwritten.
const latest = await getEvent(slug);
const eventTypeId = latest.event_type_id ?? latest.event_type?.id;
const eventDate = coerceEventDate(latest.event_date ?? loadedEvent?.event_date);
const eventName = coerceEventName(latest.name ?? loadedEvent?.name);
if (!eventTypeId || !eventName || !eventDate) {
throw new Error('Missing required event fields');
}
const mergedSettings = {
...(latest.settings ?? {}),
branding: buildPayload(payload),
} as Record<string, unknown>;
const response = await updateEvent(slug, { const response = await updateEvent(slug, {
settings: { name: eventName,
branding: buildPayload(payload), slug: latest.slug,
}, event_type_id: eventTypeId,
event_date: eventDate,
status: latest.status,
is_active: latest.is_active,
package_id: latest.package?.id,
settings: mergedSettings,
}); });
return response; return response;
}, },
@@ -304,10 +379,20 @@ export default function EventBrandingPage(): React.ReactElement {
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
console.error('[branding] save failed', error); console.error('[branding] save failed', error);
if ((error as { meta?: { errors?: Record<string, string[]> } })?.meta?.errors) {
const errors = (error as { meta?: { errors?: Record<string, string[]> } }).meta?.errors ?? {};
const flat = Object.entries(errors)
.map(([key, messages]) => `${key}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`)
.join('\n');
toast.error(flat || t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
return;
}
toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.')); toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
}, },
}); });
const { mutate, isPending } = mutation;
if (!slug) { if (!slug) {
return ( return (
<AdminLayout title={title} subtitle={subtitle} tabs={[]} currentTabKey="branding"> <AdminLayout title={title} subtitle={subtitle} tabs={[]} currentTabKey="branding">
@@ -334,8 +419,61 @@ export default function EventBrandingPage(): React.ReactElement {
} }
}; };
const handleFontPreview = (family: string) => {
const font = availableFonts.find((entry) => entry.family === family);
if (font) {
void ensureFontLoaded(font);
}
};
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > MAX_LOGO_UPLOAD_BYTES) {
toast.error(t('branding.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'upload', value: dataUrl } }));
};
reader.readAsDataURL(file);
};
const handleEmoticonSelect = (value: string) => {
setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'emoticon', value } }));
};
const previewBranding = resolvePreviewBranding(form, tenantBranding); const previewBranding = resolvePreviewBranding(form, tenantBranding);
const fabActions = React.useMemo<FloatingAction[]>(() => {
if (!slug) return [];
return [
{
key: 'save',
label: isPending ? t('branding.saving', 'Speichern...') : t('branding.save', 'Branding speichern'),
icon: Save,
onClick: () => mutate(form),
loading: isPending,
disabled: isPending || eventLoading,
tone: 'primary',
},
{
key: 'reset',
label: t('branding.reset', 'Auf Standard zurücksetzen'),
icon: RotateCcw,
onClick: () => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true }),
disabled: isPending,
tone: 'secondary',
},
];
}, [slug, mutate, form, isPending, eventLoading, t, tenantBranding]);
return ( return (
<AdminLayout <AdminLayout
title={title} title={title}
@@ -349,7 +487,7 @@ export default function EventBrandingPage(): React.ReactElement {
</Button> </Button>
)} )}
> >
<div className="space-y-4"> <div className="space-y-4 pb-28">
<SectionCard className="space-y-3"> <SectionCard className="space-y-3">
<SectionHeader <SectionHeader
eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')} eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')}
@@ -451,7 +589,17 @@ export default function EventBrandingPage(): React.ReactElement {
<SelectContent> <SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem> <SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{availableFonts.map((font) => ( {availableFonts.map((font) => (
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem> <SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontPreview(font.family)}
onFocus={() => handleFontPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
</span>
</SelectItem>
))} ))}
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem> <SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
</SelectContent> </SelectContent>
@@ -461,6 +609,7 @@ export default function EventBrandingPage(): React.ReactElement {
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))} onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))}
disabled={form.useDefault} disabled={form.useDefault}
placeholder="z. B. Playfair Display" placeholder="z. B. Playfair Display"
style={form.typography.heading ? { fontFamily: form.typography.heading } : undefined}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -476,7 +625,17 @@ export default function EventBrandingPage(): React.ReactElement {
<SelectContent> <SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem> <SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{availableFonts.map((font) => ( {availableFonts.map((font) => (
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem> <SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontPreview(font.family)}
onFocus={() => handleFontPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
</span>
</SelectItem>
))} ))}
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem> <SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
</SelectContent> </SelectContent>
@@ -486,6 +645,7 @@ export default function EventBrandingPage(): React.ReactElement {
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))} onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))}
disabled={form.useDefault} disabled={form.useDefault}
placeholder="z. B. Inter, sans-serif" placeholder="z. B. Inter, sans-serif"
style={form.typography.body ? { fontFamily: form.typography.body } : undefined}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -528,6 +688,72 @@ export default function EventBrandingPage(): React.ReactElement {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{form.logo.mode === 'emoticon' && (
<div className="space-y-2">
<Label>{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}</Label>
<Dialog open={emoticonDialogOpen} onOpenChange={setEmoticonDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" disabled={form.useDefault}>
{t('branding.openEmoticons', 'Emoticon-Gitter öffnen')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl bg-white/95 p-4 text-left dark:bg-slate-950">
<DialogHeader>
<DialogTitle className="text-base font-semibold">
{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}
</DialogTitle>
</DialogHeader>
<div className="mt-3 grid grid-cols-8 gap-2 sm:grid-cols-10">
{EMOTICON_GRID.map((emoji) => {
const isActive = form.logo.value === emoji;
return (
<button
key={emoji}
type="button"
className={cn(
'flex h-12 w-full items-center justify-center rounded-lg text-3xl leading-none transition sm:h-12',
isActive
? 'bg-slate-900 text-white shadow-sm ring-2 ring-slate-900 dark:bg-white/90 dark:text-slate-900 dark:ring-white/90'
: 'bg-white text-slate-900 hover:bg-slate-100 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700',
)}
onClick={() => {
handleEmoticonSelect(emoji);
setEmoticonDialogOpen(false);
}}
disabled={form.useDefault}
title={emoji}
>
<span aria-hidden>{emoji}</span>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
</div>
)}
{form.logo.mode === 'upload' && (
<div className="space-y-2">
<Label>{t('branding.logoUpload', 'Logo hochladen')}</Label>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" disabled={form.useDefault} onClick={() => document.getElementById('branding-logo-upload')?.click()}>
<UploadCloud className="mr-2 h-4 w-4" />
{t('branding.logoUploadButton', 'Datei auswählen')}
</Button>
<input
id="branding-logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoUpload}
disabled={form.useDefault}
/>
<span className="text-xs text-slate-600 dark:text-slate-300">
{t('branding.logoUploadHint', 'Max. 1 MB, PNG/SVG/JPG. Aktueller Wert wird ersetzt.')}
</span>
</div>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('branding.logoPosition', 'Position')}</Label> <Label>{t('branding.logoPosition', 'Position')}</Label>
<Select <Select
@@ -649,33 +875,7 @@ export default function EventBrandingPage(): React.ReactElement {
</SectionCard> </SectionCard>
</div> </div>
<div className="sticky bottom-0 z-40 mt-6 bg-gradient-to-t from-white via-white to-white/70 py-3 backdrop-blur dark:from-slate-900 dark:via-slate-900 dark:to-slate-900/60"> <FloatingActionBar actions={fabActions} />
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-2 sm:px-0">
<div className="text-sm text-slate-600 dark:text-slate-200">
{form.useDefault
? t('branding.footer.default', 'Standard-Farben des Tenants aktiv.')
: t('branding.footer.custom', 'Event-spezifisches Branding aktiv.')}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true })}
>
{t('branding.reset', 'Auf Standard zurücksetzen')}
</Button>
<Button onClick={() => mutation.mutate(form)} disabled={mutation.isPending || eventLoading}>
{mutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('branding.saving', 'Speichern...')}
</>
) : (
t('branding.save', 'Branding speichern')
)}
</Button>
</div>
</div>
</div>
</AdminLayout> </AdminLayout>
); );
} }
@@ -688,6 +888,14 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
color: textColor, color: textColor,
}; };
const logoVisual = useMemo(() => {
const looksLikeImage = branding.logo.mode === 'upload' && branding.logo.value && /^(data:|https?:)/i.test(branding.logo.value);
if (looksLikeImage) {
return <img src={branding.logo.value} alt="Logo" className="h-10 w-10 rounded-full object-cover" />;
}
return <span className="text-xl">{branding.logo.value || '✨'}</span>;
}, [branding.logo.mode, branding.logo.value]);
const buttonStyle: React.CSSProperties = branding.buttons.style === 'outline' const buttonStyle: React.CSSProperties = branding.buttons.style === 'outline'
? { ? {
border: `2px solid ${branding.buttons.primary || branding.palette.primary}`, border: `2px solid ${branding.buttons.primary || branding.palette.primary}`,
@@ -705,8 +913,8 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
<CardHeader className="p-0"> <CardHeader className="p-0">
<div className="px-4 py-3" style={headerStyle}> <div className="px-4 py-3" style={headerStyle}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-xl"> <div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-white/90 text-xl">
{branding.logo.value || '✨'} {logoVisual}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}> <CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>

View File

@@ -208,7 +208,7 @@ export default function EventDetailPage() {
const toolkitData = toolkit.data; const toolkitData = toolkit.data;
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.'); const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.');
const currentTabKey = 'overview'; const currentTabKey = 'overview';
const eventTabs = React.useMemo(() => { const eventTabs = React.useMemo(() => {
@@ -221,6 +221,11 @@ export default function EventDetailPage() {
return buildEventTabs(event, translateMenu, counts); return buildEventTabs(event, translateMenu, counts);
}, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]); }, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]);
const brandingAllowed = React.useMemo(() => {
const settings = (event?.settings ?? {}) as Record<string, unknown>;
return Boolean(settings.branding_allowed ?? true);
}, [event]);
const limitWarnings = React.useMemo( const limitWarnings = React.useMemo(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []), () => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
[event?.limits, tCommon], [event?.limits, tCommon],
@@ -449,7 +454,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
</p> </p>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1> <h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')} {t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und QR-Code für dieses Event.')}
</p> </p>
<div className="flex flex-wrap gap-2 text-xs"> <div className="flex flex-wrap gap-2 text-xs">
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100"> <Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
@@ -503,6 +508,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/> />
</div> </div>
{brandingAllowed ? (
<BrandingMissionCard <BrandingMissionCard
event={event} event={event}
invites={toolkitData?.invites} invites={toolkitData?.invites}
@@ -512,6 +518,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))} onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
/> />
) : null}
{event.addons?.length ? ( {event.addons?.length ? (
<SectionCard> <SectionCard>
<SectionHeader <SectionHeader
@@ -758,9 +765,9 @@ function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['
return ( return (
<SectionCard className="space-y-3"> <SectionCard className="space-y-3">
<SectionHeader <SectionHeader
eyebrow={t('events.invites.badge', 'Einladungen')} eyebrow={t('events.invites.badge', 'QR-Codes')}
title={t('events.invites.title', 'QR-Einladungen')} title={t('events.invites.title', 'QR-Codes & Layouts')}
description={t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')} description={t('events.invites.subtitle', 'Behält aktive QR-Codes und Layouts im Blick.')}
/> />
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300"> <div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
<div className="flex gap-2 text-sm text-slate-900"> <div className="flex gap-2 text-sm text-slate-900">
@@ -782,11 +789,11 @@ function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['
))} ))}
</ul> </ul>
) : ( ) : (
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine Einladungen erstellt.')}</p> <p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine QR-Codes erstellt.')}</p>
)} )}
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50"> <Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'Layouts & Einladungen verwalten')} <QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'QR-Codes & Layouts verwalten')}
</Button> </Button>
</div> </div>
</SectionCard> </SectionCard>
@@ -999,10 +1006,10 @@ function GalleryShareCard({
<SectionHeader <SectionHeader
eyebrow={t('events.galleryShare.badge', 'Galerie')} eyebrow={t('events.galleryShare.badge', 'Galerie')}
title={t('events.galleryShare.title', 'Galerie teilen')} title={t('events.galleryShare.title', 'Galerie teilen')}
description={t('events.galleryShare.emptyDescription', 'Erstelle einen Einladungslink, um Fotos zu teilen.')} description={t('events.galleryShare.emptyDescription', 'Erstelle einen QR-Codeslink, um Fotos zu teilen.')}
/> />
<Button onClick={onManageInvites} className="w-full rounded-full bg-brand-rose text-white shadow-md shadow-rose-300/40"> <Button onClick={onManageInvites} className="w-full rounded-full bg-brand-rose text-white shadow-md shadow-rose-300/40">
{t('events.galleryShare.createInvite', 'Einladung erstellen')} {t('events.galleryShare.createInvite', 'QR-Code erstellen')}
</Button> </Button>
</SectionCard> </SectionCard>
); );

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -16,6 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { FloatingActionBar } from '../components/FloatingActionBar';
import { import {
createEvent, createEvent,
getEvent, getEvent,
@@ -67,6 +68,7 @@ export default function EventFormPage() {
const slugParam = params.slug ?? searchParams.get('slug') ?? undefined; const slugParam = params.slug ?? searchParams.get('slug') ?? undefined;
const isEdit = Boolean(slugParam); const isEdit = Boolean(slugParam);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' }); const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' }); const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
@@ -88,6 +90,7 @@ export default function EventFormPage() {
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false); const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null); const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null); const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
const formRef = React.useRef<HTMLFormElement | null>(null);
const { data: packages, isLoading: packagesLoading } = useQuery({ const { data: packages, isLoading: packagesLoading } = useQuery({
queryKey: ['packages', 'endcustomer'], queryKey: ['packages', 'endcustomer'],
@@ -143,7 +146,7 @@ export default function EventFormPage() {
queryKey: ['tenant', 'events', slugParam], queryKey: ['tenant', 'events', slugParam],
queryFn: () => getEvent(slugParam!), queryFn: () => getEvent(slugParam!),
enabled: Boolean(isEdit && slugParam), enabled: Boolean(isEdit && slugParam),
staleTime: 60_000, staleTime: 0,
}); });
React.useEffect(() => { React.useEffect(() => {
@@ -277,6 +280,16 @@ export default function EventFormPage() {
} }
} }
const handleSubmitClick = React.useCallback(() => {
if (formRef.current) {
if (typeof formRef.current.requestSubmit === 'function') {
formRef.current.requestSubmit();
} else {
formRef.current.submit();
}
}
}, []);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const trimmedName = form.name.trim(); const trimmedName = form.name.trim();
@@ -320,14 +333,20 @@ export default function EventFormPage() {
if (isEdit) { if (isEdit) {
const targetSlug = originalSlug ?? slugParam!; const targetSlug = originalSlug ?? slugParam!;
const updated = await updateEvent(targetSlug, payload); const updated = await updateEvent(targetSlug, payload);
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'events', targetSlug] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'dashboard'] });
setOriginalSlug(updated.slug); setOriginalSlug(updated.slug);
setShowUpgradeHint(false); setShowUpgradeHint(false);
setError(null); setError(null);
toast.success(tForm('actions.saved', 'Event gespeichert'));
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug)); navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
} else { } else {
const { event: created } = await createEvent(payload); const { event: created } = await createEvent(payload);
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
setShowUpgradeHint(false); setShowUpgradeHint(false);
setError(null); setError(null);
toast.success(tForm('actions.saved', 'Event gespeichert'));
navigate(ADMIN_EVENT_VIEW_PATH(created.slug)); navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
} }
} catch (err) { } catch (err) {
@@ -456,6 +475,26 @@ export default function EventFormPage() {
</Button> </Button>
); );
const fabActions = [
{
key: 'save',
label: saving ? tForm('actions.saving', 'Speichert') : tForm('actions.save', 'Speichern'),
icon: Save,
onClick: handleSubmitClick,
loading: saving,
disabled: loading || !form.name.trim() || !form.slug.trim() || !form.eventTypeId,
tone: 'primary' as const,
},
{
key: 'cancel',
label: tForm('actions.cancel', 'Abbrechen'),
icon: ArrowLeft,
onClick: () => navigate(-1),
disabled: saving,
tone: 'secondary' as const,
},
];
return ( return (
<AdminLayout <AdminLayout
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')} title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
@@ -500,7 +539,7 @@ export default function EventFormPage() {
</div> </div>
)} )}
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60"> <Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60 pb-28">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900"> <CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" /> {tForm('sections.details.title', 'Eventdetails')} <Sparkles className="h-5 w-5 text-pink-500" /> {tForm('sections.details.title', 'Eventdetails')}
@@ -513,7 +552,7 @@ export default function EventFormPage() {
{loading ? ( {loading ? (
<FormSkeleton /> <FormSkeleton />
) : ( ) : (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="space-y-6" onSubmit={handleSubmit} ref={formRef}>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2"> <div className="space-y-2 sm:col-span-2">
<Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label> <Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label>
@@ -585,26 +624,6 @@ export default function EventFormPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-3">
<Button
type="submit"
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> {tForm('actions.saving', 'Speichert')}
</>
) : (
<>
<Save className="h-4 w-4" /> {tForm('actions.save', 'Speichern')}
</>
)}
</Button>
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
{tForm('actions.cancel', 'Abbrechen')}
</Button>
</div>
<div className="sm:col-span-2 mt-6"> <div className="sm:col-span-2 mt-6">
<Accordion type="single" collapsible defaultValue="package"> <Accordion type="single" collapsible defaultValue="package">
<AccordionItem value="package" className="border-0"> <AccordionItem value="package" className="border-0">
@@ -695,6 +714,7 @@ export default function EventFormPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<FloatingActionBar actions={fabActions} />
</AdminLayout> </AdminLayout>
); );
} }

View File

@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react'; import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart, Save, Plus } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -29,8 +29,6 @@ import {
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { import {
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants'; } from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings'; import { buildLimitWarnings } from '../lib/limitWarnings';
import { buildEventTabs } from '../lib/eventTabs'; import { buildEventTabs } from '../lib/eventTabs';
@@ -57,6 +55,7 @@ import {
triggerDownloadFromDataUrl, triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils'; } from './components/invite-layout/export-utils';
import { useOnboardingProgress } from '../onboarding'; import { useOnboardingProgress } from '../onboarding';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
interface PageState { interface PageState {
event: TenantEvent | null; event: TenantEvent | null;
@@ -219,7 +218,7 @@ export default function EventInvitesPage(): React.ReactElement {
setAddonsCatalog(catalog); setAddonsCatalog(catalog);
} catch (error) { } catch (error) {
if (!isAuthError(error)) { if (!isAuthError(error)) {
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' }); setState({ event: null, invites: [], loading: false, error: 'QR-QR-Code konnten nicht geladen werden.' });
} }
} }
}, [slug]); }, [slug]);
@@ -543,9 +542,11 @@ export default function EventInvitesPage(): React.ReactElement {
try { try {
await navigator.clipboard.writeText(invite.url); await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id); setCopiedInviteId(invite.id);
toast.success(t('invites.actions.copied', 'Link kopiert'));
} catch { } catch {
// ignore clipboard failures // ignore clipboard failures
} }
toast.success(t('invites.actions.created', 'QR-Code erstellt'));
markStep({ markStep({
lastStep: 'invite', lastStep: 'invite',
serverStep: 'invite_created', serverStep: 'invite_created',
@@ -553,7 +554,8 @@ export default function EventInvitesPage(): React.ReactElement {
}); });
} catch (error) { } catch (error) {
if (!isAuthError(error)) { if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' })); setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht erstellt werden.' }));
toast.error(t('invites.actions.createFailed', 'QR-Code konnte nicht erstellt werden.'));
} }
} finally { } finally {
setCreatingInvite(false); setCreatingInvite(false);
@@ -564,8 +566,10 @@ export default function EventInvitesPage(): React.ReactElement {
try { try {
await navigator.clipboard.writeText(invite.url); await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id); setCopiedInviteId(invite.id);
toast.success(t('invites.actions.copied', 'Link kopiert'));
} catch (error) { } catch (error) {
console.warn('[Invites] Clipboard copy failed', error); console.warn('[Invites] Clipboard copy failed', error);
toast.error(t('invites.actions.copyFailed', 'Link konnte nicht kopiert werden.'));
} }
} }
@@ -591,9 +595,11 @@ export default function EventInvitesPage(): React.ReactElement {
if (selectedInviteId === invite.id && !updated.is_active) { if (selectedInviteId === invite.id && !updated.is_active) {
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId)); setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
} }
toast.success(t('invites.actions.revoked', 'QR-Code deaktiviert'));
} catch (error) { } catch (error) {
if (!isAuthError(error)) { if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' })); setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht deaktiviert werden.' }));
toast.error(t('invites.actions.revokeFailed', 'QR-Code konnte nicht deaktiviert werden.'));
} }
} finally { } finally {
setRevokingId(null); setRevokingId(null);
@@ -616,6 +622,7 @@ export default function EventInvitesPage(): React.ReactElement {
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)), invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
})); }));
setCustomizerDraft(null); setCustomizerDraft(null);
toast.success(t('invites.customizer.toastSaved', 'Layout gespeichert'));
markStep({ markStep({
lastStep: 'branding', lastStep: 'branding',
serverStep: 'branding_configured', serverStep: 'branding_configured',
@@ -627,6 +634,7 @@ export default function EventInvitesPage(): React.ReactElement {
} catch (error) { } catch (error) {
if (!isAuthError(error)) { if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' })); setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
toast.error(t('invites.customizer.toastSaveFailed', 'Layout konnte nicht gespeichert werden.'));
} }
} finally { } finally {
setCustomizerSaving(false); setCustomizerSaving(false);
@@ -699,9 +707,9 @@ export default function EventInvitesPage(): React.ReactElement {
const eventDateSegment = normalizeEventDateSegment(eventDate); const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename( const filename = buildDownloadFilename(
['Einladungslayout', eventName, exportLayout.name ?? null, eventDateSegment], ['QR-Codeslayout', eventName, exportLayout.name ?? null, eventDateSegment],
normalizedFormat, normalizedFormat,
'einladungslayout', 'QR-Codeslayout',
); );
const exportOptions = { const exportOptions = {
@@ -792,36 +800,8 @@ export default function EventInvitesPage(): React.ReactElement {
className="hover:text-foreground" className="hover:text-foreground"
> >
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> <ArrowLeft className="mr-1 h-3.5 w-3.5" />
{t('invites.actions.backToList', 'Zurück')} {t('invites.actions.backToList', 'Zurück zur Übersicht')}
</Button> </Button>
{slug ? (
<>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="hover:text-foreground"
>
{t('invites.actions.backToEvent', 'Event öffnen')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))}
className="hover:text-foreground"
>
{t('toolkit.actions.moderate', 'Fotos moderieren')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="hover:text-foreground"
>
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
</Button>
</>
) : null}
</div> </div>
); );
@@ -860,6 +840,43 @@ export default function EventInvitesPage(): React.ReactElement {
[slug], [slug],
); );
const fabActions = React.useMemo<FloatingAction[]>(() => {
const items: FloatingAction[] = [
{
key: 'create-invite',
label: creatingInvite ? t('invites.actions.creating', 'Erstellen...') : t('invites.actions.create', 'Neue QR-Code erstellen'),
icon: Plus,
onClick: () => { void handleCreateInvite(); },
loading: creatingInvite,
disabled: creatingInvite || state.event?.limits?.can_add_guests === false,
tone: 'primary',
},
{
key: 'refresh',
label: state.loading ? t('invites.actions.refreshing', 'Aktualisieren...') : t('invites.actions.refresh', 'Aktualisieren'),
icon: RefreshCw,
onClick: () => { void load(); },
loading: state.loading,
disabled: state.loading,
tone: 'secondary',
},
];
if (activeTab === 'layout' && selectedInvite && effectiveCustomization) {
items.unshift({
key: 'save-layout',
label: customizerSaving ? t('invites.customizer.actions.saving', 'Speichert...') : t('invites.customizer.actions.save', 'Layout speichern'),
icon: Save,
onClick: () => { void handleSaveCustomization(effectiveCustomization); },
loading: customizerSaving,
disabled: customizerSaving || customizerResetting,
tone: 'primary',
});
}
return items;
}, [activeTab, selectedInvite, effectiveCustomization, customizerSaving, customizerResetting, creatingInvite, state.event?.limits?.can_add_guests, state.loading, t, handleSaveCustomization, load]);
const limitScopeLabels = React.useMemo( const limitScopeLabels = React.useMemo(
() => ({ () => ({
photos: tLimits('photosTitle'), photos: tLimits('photosTitle'),
@@ -882,70 +899,71 @@ export default function EventInvitesPage(): React.ReactElement {
return ( return (
<AdminLayout <AdminLayout
title={eventName} title={eventName}
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')} subtitle={t('invites.subtitle', 'Manage QR-Codes, Drucklayouts und Branding für deine Gäste.')}
actions={actions} actions={actions}
tabs={eventTabs} tabs={eventTabs}
currentTabKey="invites" currentTabKey="invites"
> >
{limitWarnings.length > 0 && ( <div className="pb-28">
<div className="mb-6 space-y-2"> {limitWarnings.length > 0 && (
{limitWarnings.map((warning) => ( <div className="mb-6 space-y-2">
<Alert {limitWarnings.map((warning) => (
key={warning.id} <Alert
variant={warning.tone === 'danger' ? 'destructive' : 'default'} key={warning.id}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined} variant={warning.tone === 'danger' ? 'destructive' : 'default'}
> className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> >
<div> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<AlertTitle className="flex items-center gap-2 text-sm font-semibold"> <div>
<AlertTriangle className="h-4 w-4" /> <AlertTitle className="flex items-center gap-2 text-sm font-semibold">
{limitScopeLabels[warning.scope]} <AlertTriangle className="h-4 w-4" />
</AlertTitle> {limitScopeLabels[warning.scope]}
<AlertDescription className="text-sm"> </AlertTitle>
{warning.message} <AlertDescription className="text-sm">
</AlertDescription> {warning.message}
</div> </AlertDescription>
{warning.scope === 'guests' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(); }}
disabled={addonBusy === 'guests'}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
</Button>
<AddonsPicker
addons={addonsCatalog}
scope="guests"
onCheckout={(key) => { void handleAddonPurchase(key); }}
busy={addonBusy === 'guests'}
t={(key, fallback) => t(key, fallback)}
/>
</div> </div>
) : null} {warning.scope === 'guests' ? (
</div> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
</Alert> <Button
))} variant="outline"
</div> size="sm"
)} onClick={() => { void handleAddonPurchase(); }}
disabled={addonBusy === 'guests'}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
</Button>
<AddonsPicker
addons={addonsCatalog}
scope="guests"
onCheckout={(key) => { void handleAddonPurchase(key); }}
busy={addonBusy === 'guests'}
t={(key, fallback) => t(key, fallback)}
/>
</div>
) : null}
</div>
</Alert>
))}
</div>
)}
{state.event?.addons?.length ? ( {state.event?.addons?.length ? (
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50"> <Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle> <CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription> <CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} /> <AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}
<InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} /> <InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} />
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6"> <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm"> <TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
<TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground"> <TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.share', 'Links & QR teilen')} {t('invites.tabs.share', 'Links & QR teilen')}
@@ -969,7 +987,7 @@ export default function EventInvitesPage(): React.ReactElement {
<section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors"> <section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between"> <div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'Einladungslayout anpassen')}</h2> <h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'QR-Codeslayout anpassen')}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')} {t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
</p> </p>
@@ -1014,7 +1032,7 @@ export default function EventInvitesPage(): React.ReactElement {
disabled={state.invites.length === 0} disabled={state.invites.length === 0}
> >
<SelectTrigger className="h-9 w-full min-w-[200px] sm:w-60"> <SelectTrigger className="h-9 w-full min-w-[200px] sm:w-60">
<SelectValue placeholder={t('invites.export.selectPlaceholder', 'Einladung auswählen')} /> <SelectValue placeholder={t('invites.export.selectPlaceholder', 'QR-Code auswählen')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{state.invites.map((invite) => ( {state.invites.map((invite) => (
@@ -1216,7 +1234,7 @@ export default function EventInvitesPage(): React.ReactElement {
{selectedInvite.qr_code_data_url ? ( {selectedInvite.qr_code_data_url ? (
<img <img
src={selectedInvite.qr_code_data_url} src={selectedInvite.qr_code_data_url}
alt={t('invites.export.qr.alt', 'QR-Code der Einladung')} alt={t('invites.export.qr.alt', 'QR-Code der QR-Code')}
className="h-40 w-40 rounded-2xl border border-[var(--tenant-border-strong)] bg-white p-3 shadow-md" className="h-40 w-40 rounded-2xl border border-[var(--tenant-border-strong)] bg-white p-3 shadow-md"
/> />
) : ( ) : (
@@ -1263,12 +1281,12 @@ export default function EventInvitesPage(): React.ReactElement {
</div> </div>
) : ( ) : (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]"> <div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noLayouts', 'Für diese Einladung sind aktuell keine Layouts verfügbar.')} {t('invites.export.noLayouts', 'Für diese QR-Code sind aktuell keine Layouts verfügbar.')}
</div> </div>
) )
) : ( ) : (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]"> <div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noInviteSelected', 'Wähle zunächst eine Einladung aus, um Downloads zu starten.')} {t('invites.export.noInviteSelected', 'Wähle zunächst eine QR-Code aus, um Downloads zu starten.')}
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -1291,13 +1309,13 @@ export default function EventInvitesPage(): React.ReactElement {
<div className="space-y-2"> <div className="space-y-2">
<CardTitle className="flex items-center gap-2 text-lg text-foreground"> <CardTitle className="flex items-center gap-2 text-lg text-foreground">
<QrCode className="h-5 w-5 text-primary" /> <QrCode className="h-5 w-5 text-primary" />
{t('invites.cardTitle', 'QR-Einladungen & Layouts')} {t('invites.cardTitle', 'QR-QR-Code & Layouts')}
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className="text-sm text-muted-foreground">
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')} {t('invites.cardDescription', 'Erzeuge QR-Code, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
</CardDescription> </CardDescription>
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-3 py-1 text-xs text-[var(--tenant-foreground-soft)]"> <div className="inline-flex items-center gap-2 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-3 py-1 text-xs text-[var(--tenant-foreground-soft)]">
<span>{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active}</span> <span>{t('invites.summary.active', 'Aktive QR-Code')}: {inviteCountSummary.active}</span>
<span className="text-primary"></span> <span className="text-primary"></span>
<span>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span> <span>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span>
</div> </div>
@@ -1319,7 +1337,7 @@ export default function EventInvitesPage(): React.ReactElement {
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90" className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
> >
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />} {creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
{t('invites.actions.create', 'Neue Einladung erstellen')} {t('invites.actions.create', 'Neue QR-Code erstellen')}
</Button> </Button>
{!state.loading && state.event?.limits?.can_add_guests === false && ( {!state.loading && state.event?.limits?.can_add_guests === false && (
<p className="w-full text-xs text-amber-600"> <p className="w-full text-xs text-amber-600">
@@ -1353,6 +1371,8 @@ export default function EventInvitesPage(): React.ReactElement {
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div>
<FloatingActionBar actions={fabActions} />
</AdminLayout> </AdminLayout>
); );
} }
@@ -1372,7 +1392,7 @@ function InviteWorkflowSteps({ steps, onSelectStep }: { steps: InviteWorkflowSte
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<CardTitle className="text-base font-semibold text-foreground"> <CardTitle className="text-base font-semibold text-foreground">
{t('invites.workflow.title', 'Einladungs-Workflow')} {t('invites.workflow.title', 'QR-Codes-Workflow')}
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className="text-sm text-muted-foreground">
{t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge Layout gestalten, Links teilen, Export starten.')} {t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge Layout gestalten, Links teilen, Export starten.')}
@@ -1490,7 +1510,7 @@ function InviteShareSummaryCard({ invite, onCopy, onCreate, onOpenLayout, onOpen
</Button> </Button>
<Button variant="outline" onClick={onCreate} className="flex-1"> <Button variant="outline" onClick={onCreate} className="flex-1">
<Share2 className="mr-2 h-4 w-4" /> <Share2 className="mr-2 h-4 w-4" />
{t('invites.share.actions.create', 'Weitere Einladung')} {t('invites.share.actions.create', 'Weitere QR-Code')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1562,7 +1582,7 @@ function InviteListCard({
> >
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `Einladung #${invite.id}`}</span> <span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `QR-Code #${invite.id}`}</span>
<Badge variant="outline" className={statusBadgeClass(status)}> <Badge variant="outline" className={statusBadgeClass(status)}>
{status} {status}
</Badge> </Badge>
@@ -1651,11 +1671,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
return ( return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center transition-colors"> <div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center transition-colors">
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine Einladungen')}</h3> <h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine QR-Code')}</h3>
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten.')}</p> <p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten.')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white"> <Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
<Share2 className="mr-1 h-4 w-4" /> <Share2 className="mr-1 h-4 w-4" />
{t('invites.actions.create', 'Neue Einladung erstellen')} {t('invites.actions.create', 'Neue QR-Code erstellen')}
</Button> </Button>
</div> </div>
); );

View File

@@ -218,7 +218,7 @@ export default function EventRecapPage() {
return ( return (
<AdminLayout <AdminLayout
title={eventName} title={eventName}
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.')} subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.')}
tabs={eventTabs} tabs={eventTabs}
currentTabKey="recap" currentTabKey="recap"
> >

View File

@@ -9,16 +9,33 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverlay,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
import {
CSS,
} from '@dnd-kit/utilities';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { import {
assignTasksToEvent, assignTasksToEvent,
detachTasksFromEvent,
getEvent, getEvent,
getEventTasks, getEventTasks,
createTask,
getTasks, getTasks,
getTaskCollections, getTaskCollections,
importTaskCollection, importTaskCollection,
@@ -29,11 +46,12 @@ import {
TenantTaskCollection, TenantTaskCollection,
TenantEmotion, TenantEmotion,
} from '../api'; } from '../api';
import { EmotionsSection } from './EmotionsPage';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants';
import { extractBrandingPalette } from '../lib/branding';
import { filterEmotionsByEventType } from '../lib/emotions'; import { filterEmotionsByEventType } from '../lib/emotions';
import { buildEventTabs } from '../lib/eventTabs'; import { buildEventTabs } from '../lib/eventTabs';
import { Trash2 } from 'lucide-react';
export default function EventTasksPage() { export default function EventTasksPage() {
const { t } = useTranslation('management', { keyPrefix: 'eventTasks' }); const { t } = useTranslation('management', { keyPrefix: 'eventTasks' });
@@ -46,7 +64,6 @@ export default function EventTasksPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null); const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]); const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [availableTasks, setAvailableTasks] = React.useState<TenantTask[]>([]); const [availableTasks, setAvailableTasks] = React.useState<TenantTask[]>([]);
const [selected, setSelected] = React.useState<number[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [modeSaving, setModeSaving] = React.useState(false); const [modeSaving, setModeSaving] = React.useState(false);
@@ -60,12 +77,33 @@ export default function EventTasksPage() {
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]); const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const [emotionsLoading, setEmotionsLoading] = React.useState(false); const [emotionsLoading, setEmotionsLoading] = React.useState(false);
const [emotionsError, setEmotionsError] = React.useState<string | null>(null); const [emotionsError, setEmotionsError] = React.useState<string | null>(null);
const [emotionFilter, setEmotionFilter] = React.useState<number[]>([]);
const [emotionsModalOpen, setEmotionsModalOpen] = React.useState(false);
const [newTaskTitle, setNewTaskTitle] = React.useState('');
const [newTaskDescription, setNewTaskDescription] = React.useState('');
const [newTaskEmotionId, setNewTaskEmotionId] = React.useState<number | null>(null);
const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>('');
const [creatingTask, setCreatingTask] = React.useState(false);
const [draggingId, setDraggingId] = React.useState<number | null>(null);
const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => { const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => {
try { try {
const refreshed = await getEventTasks(targetEvent.id, 1); const [refreshed, libraryTasks] = await Promise.all([
getEventTasks(targetEvent.id, 1),
getTasks({ per_page: 200 }),
]);
const assignedIds = new Set(refreshed.data.map((task) => task.id)); const assignedIds = new Set(refreshed.data.map((task) => task.id));
setAssignedTasks(refreshed.data); setAssignedTasks(refreshed.data);
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id))); const eventTypeId = targetEvent.event_type_id ?? null;
const filteredLibraryTasks = libraryTasks.data.filter((task) => {
if (assignedIds.has(task.id)) {
return false;
}
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) {
return false;
}
return true;
});
setAvailableTasks(filteredLibraryTasks);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(t('errors.assign', 'Tasks konnten nicht geladen werden.')); setError(t('errors.assign', 'Tasks konnten nicht geladen werden.'));
@@ -73,19 +111,31 @@ export default function EventTasksPage() {
} }
}, [t]); }, [t]);
const statusLabels = React.useMemo( const relevantEmotions = React.useMemo(() => {
() => ({ const filtered = filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null);
published: t('management.members.statuses.published', 'Veröffentlicht'), return filtered.length > 0 ? filtered : emotions;
draft: t('management.members.statuses.draft', 'Entwurf'), }, [emotions, event?.event_type_id, event?.event_type?.id]);
}), const emotionChips = React.useMemo(() => {
[t] const map: Record<number, TenantEmotion> = {};
); assignedTasks.forEach((task) => {
if (task.emotion) {
const palette = React.useMemo(() => extractBrandingPalette(event?.settings), [event?.settings]); map[task.emotion.id] = {
const relevantEmotions = React.useMemo( ...task.emotion,
() => filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null), name_translations: task.emotion.name_translations ?? {},
[emotions, event?.event_type_id, event?.event_type?.id], description: null,
); description_translations: {},
sort_order: 0,
is_active: true,
tenant_id: null,
is_global: false,
event_types: [],
created_at: null,
updated_at: null,
} as TenantEmotion;
}
});
return Object.values(map);
}, [assignedTasks]);
React.useEffect(() => { React.useEffect(() => {
if (!slug) { if (!slug) {
@@ -101,7 +151,7 @@ export default function EventTasksPage() {
const eventData = await getEvent(slug); const eventData = await getEvent(slug);
const [eventTasksResponse, libraryTasks] = await Promise.all([ const [eventTasksResponse, libraryTasks] = await Promise.all([
getEventTasks(eventData.id, 1), getEventTasks(eventData.id, 1),
getTasks({ per_page: 50 }), getTasks({ per_page: 200 }),
]); ]);
if (cancelled) return; if (cancelled) return;
setEvent(eventData); setEvent(eventData);
@@ -135,36 +185,140 @@ export default function EventTasksPage() {
}; };
}, [slug, t]); }, [slug, t]);
async function handleAssign() {
if (!event || selected.length === 0) return;
setSaving(true);
try {
await assignTasksToEvent(event.id, selected);
const refreshed = await getEventTasks(event.id, 1);
const assignedIds = new Set(refreshed.data.map((task) => task.id));
setAssignedTasks(refreshed.data);
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
setSelected([]);
} catch (err) {
if (!isAuthError(err)) {
setError(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
}
} finally {
setSaving(false);
}
}
React.useEffect(() => {
setSelected((current) => current.filter((taskId) => availableTasks.some((task) => task.id === taskId)));
}, [availableTasks]);
const filteredAssignedTasks = React.useMemo(() => { const filteredAssignedTasks = React.useMemo(() => {
let list = assignedTasks;
if (emotionFilter.length > 0) {
const set = new Set(emotionFilter);
list = list.filter((task) => (task.emotion_id ? set.has(task.emotion_id) : false));
}
if (!taskSearch.trim()) { if (!taskSearch.trim()) {
return assignedTasks; return list;
} }
const term = taskSearch.toLowerCase(); const term = taskSearch.toLowerCase();
return assignedTasks.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term)); return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
}, [assignedTasks, taskSearch]); }, [assignedTasks, taskSearch, emotionFilter]);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const handleAssignSingle = React.useCallback(
async (taskId: number) => {
if (!event) return;
const task = availableTasks.find((t) => t.id === taskId);
if (task) {
setAvailableTasks((prev) => prev.filter((t) => t.id !== taskId));
setAssignedTasks((prev) => [...prev, task]);
}
setSaving(true);
try {
await assignTasksToEvent(event.id, [taskId]);
toast.success(t('actions.assignedToast', 'Tasks wurden zugewiesen.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
}
// revert optimistic change
if (task) {
setAssignedTasks((prev) => prev.filter((t) => t.id !== taskId));
setAvailableTasks((prev) => [...prev, task]);
}
} finally {
setSaving(false);
}
},
[availableTasks, event, hydrateTasks, t],
);
const handleDetachSingle = React.useCallback(
async (taskId: number) => {
if (!event) return;
const task = assignedTasks.find((t) => t.id === taskId);
if (task) {
setAssignedTasks((prev) => prev.filter((t) => t.id !== taskId));
setAvailableTasks((prev) => [...prev, task]);
}
setSaving(true);
try {
await detachTasksFromEvent(event.id, [taskId]);
toast.success(t('actions.removedToast', 'Tasks wurden entfernt.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('errors.remove', 'Tasks konnten nicht entfernt werden.'));
}
// revert optimistic change
if (task) {
setAvailableTasks((prev) => prev.filter((t) => t.id !== taskId));
setAssignedTasks((prev) => [...prev, task]);
}
} finally {
setSaving(false);
}
},
[assignedTasks, event, hydrateTasks, t],
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || !active?.data?.current) {
setDraggingId(null);
return;
}
const originList = active.data.current.list as 'assigned' | 'library';
const overList = (over.data?.current?.list as 'assigned' | 'library' | undefined) ?? null;
const targetList =
overList ??
(over.id === 'assigned-dropzone'
? 'assigned'
: over.id === 'library-dropzone'
? 'library'
: null);
setDraggingId(null);
if (!targetList || targetList === originList) {
return;
}
const taskId = Number(active.id);
if (Number.isNaN(taskId)) {
return;
}
if (targetList === 'assigned') {
void handleAssignSingle(taskId);
} else {
void handleDetachSingle(taskId);
}
};
const handleCreateQuickTask = React.useCallback(async () => {
if (!event || !newTaskTitle.trim()) return;
setCreatingTask(true);
const emotion = emotions.find((e) => e.id === newTaskEmotionId) ?? null;
try {
const created = await createTask({
title: newTaskTitle.trim(),
description: newTaskDescription.trim() || null,
emotion_id: newTaskEmotionId ?? undefined,
event_type_id: event.event_type_id ?? undefined,
difficulty: newTaskDifficulty || undefined,
});
setAssignedTasks((prev) => [...prev, { ...created, emotion: emotion ?? created.emotion ?? null }]);
setAvailableTasks((prev) => prev.filter((task) => task.id !== created.id));
await assignTasksToEvent(event.id, [created.id]);
toast.success(t('actions.created', 'Aufgabe erstellt und zugewiesen.'));
setNewTaskTitle('');
setNewTaskDescription('');
setNewTaskEmotionId(null);
setNewTaskDifficulty('');
await hydrateTasks(event);
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('errors.create', 'Aufgabe konnte nicht erstellt werden.'));
}
} finally {
setCreatingTask(false);
}
}, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, hydrateTasks, t]);
const eventTabs = React.useMemo(() => { const eventTabs = React.useMemo(() => {
if (!event) { if (!event) {
@@ -183,7 +337,9 @@ export default function EventTasksPage() {
setCollectionsLoading(true); setCollectionsLoading(true);
setCollectionsError(null); setCollectionsError(null);
const eventTypeSlug = event?.event_type?.slug ?? null; const eventTypeSlug = event?.event_type?.slug ?? null;
const query = eventTypeSlug ? { per_page: 6, event_type: eventTypeSlug } : { per_page: 6 }; const query = eventTypeSlug
? { top_picks: true, limit: 6, event_type: eventTypeSlug }
: { top_picks: true, limit: 6 };
getTaskCollections(query) getTaskCollections(query)
.then((result) => { .then((result) => {
@@ -264,6 +420,31 @@ export default function EventTasksPage() {
return mode !== 'photo_only'; return mode !== 'photo_only';
}, [event?.engagement_mode, event?.settings]); }, [event?.engagement_mode, event?.settings]);
const summaryBadges = !loading && event ? (
<div className="mb-4 flex flex-wrap gap-2">
<Badge className="flex items-center gap-2 rounded-full bg-slate-900 text-white">
<span className="text-xs uppercase tracking-wide text-white/80">
{t('summary.assigned', 'Zugeordnete Tasks')}
</span>
<span className="text-sm font-semibold">{assignedTasks.length}</span>
</Badge>
<Badge className="flex items-center gap-2 rounded-full bg-emerald-600/90 text-white">
<span className="text-xs uppercase tracking-wide text-white/80">
{t('summary.library', 'Bibliothek')}
</span>
<span className="text-sm font-semibold">{availableTasks.length}</span>
</Badge>
<Badge className="flex items-center gap-2 rounded-full bg-pink-500/90 text-white">
<span className="text-xs uppercase tracking-wide text-white/80">
{t('summary.mode', 'Aktiver Modus')}
</span>
<span className="text-sm font-semibold">
{tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
</span>
</Badge>
</div>
) : null;
async function handleModeChange(checked: boolean) { async function handleModeChange(checked: boolean) {
if (!event || !slug) return; if (!event || !slug) return;
@@ -316,6 +497,8 @@ export default function EventTasksPage() {
tabs={eventTabs} tabs={eventTabs}
currentTabKey="tasks" currentTabKey="tasks"
> >
{summaryBadges}
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle> <AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
@@ -340,12 +523,6 @@ export default function EventTasksPage() {
<TabsContent value="tasks" className="space-y-6"> <TabsContent value="tasks" className="space-y-6">
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60"> <Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader> <CardHeader>
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('eventStatus', {
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
})}
</CardDescription>
<div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700"> <div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
@@ -376,20 +553,6 @@ export default function EventTasksPage() {
{t('modes.updating', 'Einstellung wird gespeichert ...')} {t('modes.updating', 'Einstellung wird gespeichert ...')}
</div> </div>
) : null} ) : null}
<div className="grid gap-3 text-xs sm:grid-cols-3">
<SummaryPill
label={t('summary.assigned', 'Zugeordnete Tasks')}
value={assignedTasks.length}
/>
<SummaryPill
label={t('summary.library', 'Bibliothek')}
value={availableTasks.length}
/>
<SummaryPill
label={t('summary.mode', 'Aktiver Modus')}
value={tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
/>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pb-0"> <CardContent className="pb-0">
@@ -413,8 +576,14 @@ export default function EventTasksPage() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</CardContent> </CardContent>
<CardContent className="grid gap-4 lg:grid-cols-2"> <DndContext
<section className="space-y-3"> sensors={sensors}
collisionDetection={closestCenter}
onDragStart={(event) => setDraggingId(Number(event.active.id))}
onDragEnd={handleDragEnd}
>
<CardContent className="grid gap-4 lg:grid-cols-2">
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900"> <h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Sparkles className="h-4 w-4 text-pink-500" /> <Sparkles className="h-4 w-4 text-pink-500" />
@@ -431,21 +600,65 @@ export default function EventTasksPage() {
</div> </div>
</div> </div>
{filteredAssignedTasks.length === 0 ? ( {emotionChips.length > 0 ? (
<EmptyState <div className="flex flex-wrap gap-2">
message={ <Button
taskSearch.trim() size="sm"
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.') variant={emotionFilter.length === 0 ? 'default' : 'outline'}
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.') onClick={() => setEmotionFilter([])}
} className="rounded-full"
/> >
) : ( {t('filters.allEmotions', 'Alle Emotionen')}
<div className="space-y-2"> </Button>
{filteredAssignedTasks.map((task) => ( {emotionChips.map((emotion) => {
<AssignedTaskRow key={task.id} task={task} /> const active = emotionFilter.includes(emotion.id);
))} return (
<Button
key={emotion.id}
size="sm"
variant={active ? 'default' : 'outline'}
onClick={() =>
setEmotionFilter((prev) =>
active ? prev.filter((id) => id !== emotion.id) : [...prev, emotion.id]
)
}
className="rounded-full"
style={
active
? { backgroundColor: emotion.color ?? '#e0f2fe', color: '#0f172a' }
: { borderColor: emotion.color ?? undefined, color: '#0f172a' }
}
>
{emotion.icon ? <span className="mr-1">{emotion.icon}</span> : null}
{emotion.name}
</Button>
);
})}
</div> </div>
)} ) : null}
<DropZone id="assigned-dropzone">
{filteredAssignedTasks.length === 0 ? (
<EmptyState
message={
taskSearch.trim()
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
}
/>
) : (
<div className="space-y-2">
{filteredAssignedTasks.map((task) => (
<DraggableTaskCard
key={task.id}
task={task}
origin="assigned"
onRemove={() => void handleDetachSingle(task.id)}
/>
))}
</div>
)}
</DropZone>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
@@ -453,52 +666,102 @@ export default function EventTasksPage() {
<PlusCircle className="h-4 w-4 text-emerald-500" /> <PlusCircle className="h-4 w-4 text-emerald-500" />
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')} {t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
</h3> </h3>
<div className="space-y-2 rounded-2xl border border-emerald-100 bg-white/90 p-3 shadow-sm max-h-72 overflow-y-auto"> <div className="rounded-2xl border border-emerald-100 bg-emerald-50/60 p-3 shadow-inner">
{availableTasks.length === 0 ? ( <p className="text-xs font-semibold text-emerald-700">{t('sections.library.quickCreate', 'Schnell neue Aufgabe anlegen')}</p>
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} /> <div className="mt-2 grid gap-2">
) : ( <Input
availableTasks.map((task) => ( value={newTaskTitle}
<label key={task.id} className="flex items-start gap-3 rounded-xl border border-transparent p-2 transition hover:border-emerald-200"> onChange={(e) => setNewTaskTitle(e.target.value)}
<Checkbox placeholder={t('sections.library.quickTitle', 'Titel der Aufgabe')}
checked={selected.includes(task.id)} disabled={!tasksEnabled || creatingTask}
onCheckedChange={(checked) => />
setSelected((prev) => <Textarea
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id) value={newTaskDescription}
) onChange={(e) => setNewTaskDescription(e.target.value)}
} placeholder={t('sections.library.quickDescription', 'Beschreibung (optional)')}
disabled={!tasksEnabled || creatingTask}
className="min-h-[70px]"
/>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-700">{t('sections.library.quickEmotion', 'Emotion')}</label>
<select
className="h-9 rounded-lg border border-slate-200 bg-white px-2 text-sm"
value={newTaskEmotionId ?? ''}
onChange={(e) => setNewTaskEmotionId(e.target.value ? Number(e.target.value) : null)}
disabled={!tasksEnabled || creatingTask}
>
<option value="">{t('sections.library.quickEmotionNone', 'Keine')}</option>
{relevantEmotions.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-700">{t('sections.library.quickDifficulty', 'Schwierigkeit')}</label>
<select
className="h-9 rounded-lg border border-slate-200 bg-white px-2 text-sm"
disabled={!tasksEnabled || creatingTask}
value={newTaskDifficulty}
onChange={(e) => setNewTaskDifficulty(e.target.value as TenantTask['difficulty'] | '')}
>
<option value="">{t('sections.library.quickDifficultyNone', 'Keine')}</option>
<option value="easy">{t('sections.library.difficulty.easy', 'Leicht')}</option>
<option value="medium">{t('sections.library.difficulty.medium', 'Mittel')}</option>
<option value="hard">{t('sections.library.difficulty.hard', 'Schwer')}</option>
</select>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => void handleCreateQuickTask()}
disabled={!newTaskTitle.trim() || creatingTask || !tasksEnabled}
>
{creatingTask ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t('sections.library.quickCreateCta', 'Erstellen & zuweisen')
)}
</Button>
</div>
</div>
</div>
<DropZone id="library-dropzone">
<div className="space-y-2 max-h-72 overflow-y-auto">
{availableTasks.length === 0 ? (
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
) : (
availableTasks.map((task) => (
<DraggableTaskCard
key={task.id}
task={task}
origin="library"
onAdd={() => void handleAssignSingle(task.id)}
disabled={!tasksEnabled} disabled={!tasksEnabled}
/> />
<div> ))
<p className="text-sm font-medium text-slate-900">{task.title}</p> )}
{task.description && <p className="text-xs text-slate-600">{task.description}</p>} </div>
</div> </DropZone>
</label>
))
)}
</div>
<Button
onClick={() => void handleAssign()}
disabled={saving || selected.length === 0 || !tasksEnabled}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.assign', 'Ausgewählte Tasks zuweisen')}
</Button>
</section> </section>
</CardContent> </CardContent>
<DragOverlay>
{draggingId ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/90 px-4 py-3 shadow-sm opacity-80">
{filteredAssignedTasks.find((t) => t.id === draggingId)?.title ??
availableTasks.find((t) => t.id === draggingId)?.title}
</div>
) : null}
</DragOverlay>
</DndContext>
</Card> </Card>
<BrandingStoryPanel <EmotionsCard
event={event}
palette={palette}
emotions={relevantEmotions} emotions={relevantEmotions}
emotionsLoading={emotionsLoading} emotionsLoading={emotionsLoading}
emotionsError={emotionsError} emotionsError={emotionsError}
collections={collections} onOpenEmotions={() => setEmotionsModalOpen(true)}
onOpenBranding={() => {
if (!slug) return;
navigate(ADMIN_EVENT_BRANDING_PATH(slug));
}}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
/> />
</TabsContent> </TabsContent>
<TabsContent value="packs"> <TabsContent value="packs">
@@ -514,6 +777,15 @@ export default function EventTasksPage() {
</Tabs> </Tabs>
</> </>
)} )}
<Dialog open={emotionsModalOpen} onOpenChange={setEmotionsModalOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('tasks.emotions.manage', 'Emotionen verwalten')}</DialogTitle>
</DialogHeader>
<EmotionsSection embedded />
</DialogContent>
</Dialog>
</AdminLayout> </AdminLayout>
); );
} }
@@ -536,17 +808,107 @@ function TaskSkeleton() {
); );
} }
function AssignedTaskRow({ task }: { task: TenantTask }) { function DropZone({ id, children }: { id: string; children: React.ReactNode }) {
const { t } = useTranslation('management'); const zone = id === 'assigned-dropzone' ? 'assigned' : 'library';
const { setNodeRef, isOver } = useDroppable({ id, data: { list: zone } });
return ( return (
<div className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm"> <div
<div className="flex items-center justify-between"> ref={setNodeRef}
<p className="text-sm font-medium text-slate-900">{task.title}</p> className={`rounded-2xl border border-dashed p-2 ${isOver ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200/70'}`}
<Badge variant="outline" className="border-pink-200 text-pink-600"> >
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))} {children}
</Badge> </div>
);
}
function DraggableTaskCard({
task,
origin,
onRemove,
onAdd,
disabled,
}: {
task: TenantTask;
origin: 'assigned' | 'library';
onRemove?: () => void;
onAdd?: () => void;
disabled?: boolean;
}) {
const { t } = useTranslation('management');
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({
id: task.id,
data: { list: origin },
});
const style = {
transform: transform ? CSS.Translate.toString(transform) : undefined,
transition: transition || undefined,
opacity: isDragging ? 0.8 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<button
className="mt-1 h-7 w-7 rounded-md border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 disabled:opacity-50"
{...listeners}
{...attributes}
disabled={disabled}
aria-label={t('library.dragHandle', 'Task verschieben')}
>
</button>
<div className="space-y-1">
<p className="text-sm font-medium text-slate-900">{task.title}</p>
{task.description ? <p className="text-xs text-slate-600">{task.description}</p> : null}
</div>
</div>
<div className="flex items-center gap-2">
{task.emotion ? (
<Badge
variant="outline"
className="border-transparent text-[11px]"
style={{
backgroundColor: `${task.emotion.color ?? '#eef2ff'}20`,
color: task.emotion.color ?? '#4338ca',
}}
>
{task.emotion.icon ? <span className="mr-1">{task.emotion.icon}</span> : null}
{task.emotion.name}
</Badge>
) : null}
<Badge variant="outline" className="border-pink-200 text-pink-600">
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))}
</Badge>
{origin === 'assigned' ? (
<Button
size="icon"
variant="ghost"
onClick={onRemove}
disabled={disabled}
aria-label={t('actions.remove', 'Vom Event entfernen')}
>
<Trash2 className="h-4 w-4 text-slate-500" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
onClick={onAdd}
disabled={disabled}
aria-label={t('actions.assign', 'Zum Event hinzufügen')}
>
<PlusCircle className="h-4 w-4 text-emerald-600" />
</Button>
)}
</div>
</div> </div>
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
</div> </div>
); );
} }
@@ -640,145 +1002,71 @@ function MissionPackGrid({
); );
} }
type BrandingStoryPanelProps = { type EmotionsCardProps = {
event: TenantEvent;
palette: ReturnType<typeof extractBrandingPalette>;
emotions: TenantEmotion[]; emotions: TenantEmotion[];
emotionsLoading: boolean; emotionsLoading: boolean;
emotionsError: string | null; emotionsError: string | null;
collections: TenantTaskCollection[];
onOpenBranding: () => void;
onOpenEmotions: () => void; onOpenEmotions: () => void;
onOpenCollections: () => void;
}; };
function BrandingStoryPanel({ function EmotionsCard({ emotions, emotionsLoading, emotionsError, onOpenEmotions }: EmotionsCardProps) {
event,
palette,
emotions,
emotionsLoading,
emotionsError,
collections,
onOpenBranding,
onOpenEmotions,
onOpenCollections,
}: BrandingStoryPanelProps) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const fallbackColors = palette.colors.length ? palette.colors : ['#f472b6', '#fde68a', '#312e81']; const spotlightEmotions = emotions.slice(0, 6);
const spotlightEmotions = emotions.slice(0, 4);
const recommendedCollections = React.useMemo(() => collections.slice(0, 2), [collections]);
return ( return (
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50"> <Card className="border border-rose-100 bg-rose-50/70 shadow-sm">
<CardHeader> <CardHeader>
<CardTitle className="text-xl text-slate-900"> <CardTitle className="flex items-center gap-2 text-base text-rose-900">
{t('tasks.story.title', 'Branding & Story')} <Sparkles className="h-5 w-5 text-rose-500" />
</CardTitle> {t('tasks.story.emotionsTitle', 'Emotionen')}
<CardDescription className="text-sm text-slate-600"> </CardTitle>
{t('tasks.story.description', 'Verbinde Farben, Emotionen und Mission Packs für ein stimmiges Gäste-Erlebnis.')} <CardDescription className="text-sm text-rose-800">
{t('tasks.story.description', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="space-y-3">
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/80 p-4 text-sm text-indigo-900 shadow-inner shadow-indigo-100"> <div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.3em]"> <Badge variant="outline" className="border-rose-200 text-rose-700">
{t('events.branding.brandingTitle', 'Branding')} {t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiv', count: emotions.length })}
</p> </Badge>
<p className="mt-1 text-base font-semibold">{palette.font ?? t('events.branding.brandingFallback', 'Aktuelle Auswahl')}</p> <Button
<p className="text-xs text-indigo-900/70"> size="sm"
{t('events.branding.brandingCopy', 'Passe Farben & Schriftarten im Layout-Editor an.')} className="bg-rose-600 text-white hover:bg-rose-700"
</p> onClick={onOpenEmotions}
<div className="mt-3 flex gap-2"> >
{fallbackColors.slice(0, 4).map((color) => ( {t('tasks.story.emotionsCta', 'Emotionen verwalten')}
<span key={color} className="h-10 w-10 rounded-xl border border-white/70 shadow" style={{ backgroundColor: color }} />
))}
</div>
<Button size="sm" variant="secondary" className="mt-4 rounded-full bg-white/80 text-indigo-900 hover:bg-white" onClick={onOpenBranding}>
{t('events.branding.brandingCta', 'Branding anpassen')}
</Button> </Button>
</div> </div>
<div className="space-y-4 rounded-2xl border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-900 shadow-inner shadow-rose-100"> {emotionsLoading ? (
<div> <div className="h-10 animate-pulse rounded-xl bg-white/70" />
<div className="flex items-center justify-between"> ) : emotionsError ? (
<p className="text-xs uppercase tracking-[0.3em] text-rose-400"> <p className="text-xs text-rose-900/70">{emotionsError}</p>
{t('tasks.story.emotionsTitle', 'Emotionen')} ) : spotlightEmotions.length ? (
</p> <div className="flex flex-wrap gap-2">
<Badge variant="outline" className="border-rose-200 text-rose-600"> {spotlightEmotions.map((emotion) => (
{t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiviert', count: emotions.length })} <span
</Badge> key={emotion.id}
</div> className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
{emotionsLoading ? ( style={{
<div className="mt-3 h-10 animate-pulse rounded-xl bg-white/70" /> backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
) : emotionsError ? ( color: emotion.color ?? '#be123c',
<p className="mt-3 text-xs text-rose-900/70">{emotionsError}</p> }}
) : spotlightEmotions.length ? ( >
<div className="mt-3 flex flex-wrap gap-2"> {emotion.icon ? <span>{emotion.icon}</span> : null}
{spotlightEmotions.map((emotion) => ( {emotion.name}
<span </span>
key={emotion.id} ))}
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
style={{
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
color: emotion.color ?? '#be123c',
}}
>
{emotion.icon ? <span>{emotion.icon}</span> : null}
{emotion.name}
</span>
))}
</div>
) : (
<p className="mt-3 text-xs text-rose-900/70">
{t('tasks.story.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
</p>
)}
<Button size="sm" variant="ghost" className="mt-3 h-8 px-0 text-rose-700 hover:bg-rose-100/80" onClick={onOpenEmotions}>
{t('tasks.story.emotionsCta', 'Emotionen verwalten')}
</Button>
</div> </div>
<div className="rounded-xl border border-white/60 bg-white/80 p-3 text-sm text-slate-700"> ) : (
<p className="text-xs uppercase tracking-[0.3em] text-slate-500"> <p className="text-xs text-rose-900/70">
{t('tasks.story.collectionsTitle', 'Mission Packs')} {t('tasks.story.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
</p> </p>
{recommendedCollections.length ? ( )}
<div className="mt-3 space-y-2">
{recommendedCollections.map((collection) => (
<div key={collection.id} className="flex items-center justify-between rounded-xl border border-slate-200 bg-white/90 px-3 py-2 text-xs">
<div>
<p className="text-sm font-semibold text-slate-900">{collection.name}</p>
{collection.event_type?.name ? (
<p className="text-[11px] text-slate-500">{collection.event_type.name}</p>
) : null}
</div>
<Badge variant="outline" className="border-slate-200 text-slate-600">
{t('tasks.story.collectionsCount', { defaultValue: '{{count}} Aufgaben', count: collection.tasks_count })}
</Badge>
</div>
))}
</div>
) : (
<p className="mt-3 text-xs text-slate-500">
{t('tasks.story.collectionsEmpty', 'Noch keine empfohlenen Mission Packs.')}
</p>
)}
<Button size="sm" variant="outline" className="mt-3 border-rose-200 text-rose-700 hover:bg-rose-50" onClick={onOpenCollections}>
{t('tasks.story.collectionsCta', 'Mission Packs anzeigen')}
</Button>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function SummaryPill({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-2xl border border-slate-200 bg-white/80 p-3 text-center">
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-1 text-lg font-semibold text-slate-900">{value}</p>
</div>
);
}
function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string { function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string {
switch (priority) { switch (priority) {
case 'low': case 'low':
@@ -791,10 +1079,3 @@ function mapPriority(priority: TenantTask['priority'], translate: (key: string,
return translate('management.eventTasks.priorities.medium', 'Mittel'); return translate('management.eventTasks.priorities.medium', 'Mittel');
} }
} }
function renderName(name: TenantEvent['name'], translate: (key: string, defaultValue: string) => string): string {
if (typeof name === 'string') {
return name;
}
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? translate('management.members.events.untitled', 'Unbenanntes Event');
}

View File

@@ -26,6 +26,7 @@ import {
} from '../api'; } from '../api';
import { buildEngagementTabPath } from '../constants'; import { buildEngagementTabPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import toast from 'react-hot-toast';
const DEFAULT_PAGE_SIZE = 12; const DEFAULT_PAGE_SIZE = 12;
@@ -51,7 +52,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
const [scope, setScope] = React.useState<ScopeFilter>('all'); const [scope, setScope] = React.useState<ScopeFilter>('all');
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogOpen, setDialogOpen] = React.useState(false);
const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null); const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null);
@@ -86,6 +86,7 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
if (cancelled) return; if (cancelled) return;
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(t('collections.notifications.error')); setError(t('collections.notifications.error'));
toast.error(t('collections.notifications.error'));
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -101,14 +102,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
}; };
}, [page, search, scopeParam, reloadToken, t]); }, [page, search, scopeParam, reloadToken, t]);
React.useEffect(() => {
if (successMessage) {
const timeout = setTimeout(() => setSuccessMessage(null), 4000);
return () => clearTimeout(timeout);
}
return undefined;
}, [successMessage]);
async function ensureEventsLoaded() { async function ensureEventsLoaded() {
if (events.length > 0 || eventsLoading) { if (events.length > 0 || eventsLoading) {
return; return;
@@ -144,12 +137,13 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
setEventError(null); setEventError(null);
try { try {
await importTaskCollection(selectedCollection.id, selectedEventSlug); await importTaskCollection(selectedCollection.id, selectedEventSlug);
setSuccessMessage(t('collections.notifications.imported')); toast.success(t('collections.notifications.imported'));
setDialogOpen(false); setDialogOpen(false);
setReloadToken((token) => token + 1); setReloadToken((token) => token + 1);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setEventError(t('collections.notifications.error')); setEventError(t('collections.notifications.error'));
toast.error(t('collections.notifications.error'));
} }
} finally { } finally {
setImporting(false); setImporting(false);
@@ -181,13 +175,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
</Alert> </Alert>
)} )}
{successMessage && (
<Alert className="border-l-4 border-green-500 bg-green-50 text-sm text-green-900">
<AlertTitle>{t('collections.notifications.imported')}</AlertTitle>
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60"> <Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AlignCenter,
AlignLeft, AlignLeft,
AlignRight,
BadgeCheck, BadgeCheck,
ChevronDown, ChevronDown,
Download, Download,
@@ -611,6 +613,16 @@ export function InviteLayoutCustomizerPanel({
[availableFonts, updateElement] [availableFonts, updateElement]
); );
const handleFontOptionPreview = React.useCallback(
(family: string) => {
const font = availableFonts.find((entry) => entry.family === family);
if (font) {
void ensureFontLoaded(font);
}
},
[availableFonts]
);
React.useEffect(() => { React.useEffect(() => {
if (!invite) { if (!invite) {
setAvailableLayouts([]); setAvailableLayouts([]);
@@ -1315,14 +1327,17 @@ export function InviteLayoutCustomizerPanel({
value={element.align ?? 'left'} value={element.align ?? 'left'}
onValueChange={(value) => value && updateElementAlign(element.id, value as 'left' | 'center' | 'right')} onValueChange={(value) => value && updateElementAlign(element.id, value as 'left' | 'center' | 'right')}
> >
<ToggleGroupItem value="left" className="px-3"> <ToggleGroupItem value="left" className="px-3" title={t('invites.customizer.elements.alignLeft', 'Links')} aria-label={t('invites.customizer.elements.alignLeft', 'Links')}>
{t('invites.customizer.elements.alignLeft', 'Links')} <AlignLeft className="h-4 w-4" />
<span className="sr-only">{t('invites.customizer.elements.alignLeft', 'Links')}</span>
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem value="center" className="px-3"> <ToggleGroupItem value="center" className="px-3" title={t('invites.customizer.elements.alignCenter', 'Zentriert')} aria-label={t('invites.customizer.elements.alignCenter', 'Zentriert')}>
{t('invites.customizer.elements.alignCenter', 'Zentriert')} <AlignCenter className="h-4 w-4" />
<span className="sr-only">{t('invites.customizer.elements.alignCenter', 'Zentriert')}</span>
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem value="right" className="px-3"> <ToggleGroupItem value="right" className="px-3" title={t('invites.customizer.elements.alignRight', 'Rechts')} aria-label={t('invites.customizer.elements.alignRight', 'Rechts')}>
{t('invites.customizer.elements.alignRight', 'Rechts')} <AlignRight className="h-4 w-4" />
<span className="sr-only">{t('invites.customizer.elements.alignRight', 'Rechts')}</span>
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
</div> </div>
@@ -1352,7 +1367,21 @@ export function InviteLayoutCustomizerPanel({
<SelectContent> <SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('invites.customizer.elements.fontPlaceholder', 'Standard')}</SelectItem> <SelectItem value={DEFAULT_FONT_VALUE}>{t('invites.customizer.elements.fontPlaceholder', 'Standard')}</SelectItem>
{availableFonts.map((font) => ( {availableFonts.map((font) => (
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem> <SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontOptionPreview(font.family)}
onFocus={() => handleFontOptionPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>
{font.family}
</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>
AaBb
</span>
</span>
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1360,6 +1389,7 @@ export function InviteLayoutCustomizerPanel({
value={element.fontFamily ?? ''} value={element.fontFamily ?? ''}
onChange={(event) => handleElementFontChange(element.id, event.target.value)} onChange={(event) => handleElementFontChange(element.id, event.target.value)}
placeholder="z. B. Playfair Display" placeholder="z. B. Playfair Display"
style={element.fontFamily ? { fontFamily: element.fontFamily } : undefined}
/> />
</div> </div>
</div> </div>
@@ -1503,7 +1533,7 @@ export function InviteLayoutCustomizerPanel({
const normalizedFormat = format.toLowerCase(); const normalizedFormat = format.toLowerCase();
const eventDateSegment = normalizeEventDateSegment(eventDate); const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename( const filename = buildDownloadFilename(
['Einladungslayout', eventName, activeLayout?.name ?? null, eventDateSegment], ['QR-Layout', eventName, activeLayout?.name ?? null, eventDateSegment],
normalizedFormat, normalizedFormat,
'einladungslayout', 'einladungslayout',
); );

View File

@@ -53,10 +53,16 @@ const normalizeImageUrl = (src?: string | null) => {
export default function GalleryPage() { export default function GalleryPage() {
const { token } = useParams<{ token?: string }>(); const { token } = useParams<{ token?: string }>();
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const { branding } = useEventBranding();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '', locale); const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '', locale);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const photoIdParam = searchParams.get('photoId'); const photoIdParam = searchParams.get('photoId');
const modeParam = searchParams.get('mode'); const modeParam = searchParams.get('mode');
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const [filter, setFilterState] = React.useState<GalleryFilter>('latest'); const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null); const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
@@ -178,7 +184,11 @@ export default function GalleryPage() {
} }
} }
const buildShareText = (fallback?: string) => t('share.shareText', { event: event?.name ?? fallback ?? 'Fotospiel' }); const buildShareText = (fallback?: string) => {
const eventName = event?.name ?? fallback ?? 'Fotospiel';
const base = t('share.shareText', 'Schau dir diesen Moment bei Fotospiel an.');
return `${eventName} ${base}`;
};
async function onShare(photo: GalleryPhoto) { async function onShare(photo: GalleryPhoto) {
if (!token) return; if (!token) return;
@@ -436,83 +446,94 @@ export default function GalleryPage() {
)} )}
{shareSheet.photo && ( {shareSheet.photo && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md rounded-t-3xl bg-white p-4 shadow-xl dark:bg-slate-900"> <div
<div className="mb-3 flex items-center justify-between"> className="w-full max-w-md rounded-t-3xl border border-border bg-white/98 p-4 text-slate-900 shadow-2xl ring-1 ring-black/10 backdrop-blur-md dark:border-white/10 dark:bg-slate-900/98 dark:text-white"
<div> style={{ ...(bodyFont ? { fontFamily: bodyFont } : {}), borderRadius: radius }}
<p className="text-xs uppercase tracking-wide text-muted-foreground"> >
<div className="mb-4 flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('share.title', 'Geteiltes Foto')} {t('share.title', 'Geteiltes Foto')}
</p> </p>
<p className="text-sm font-semibold text-foreground">#{shareSheet.photo.id}</p> <p className="text-base font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
#{shareSheet.photo.id}
</p>
{event?.name && <p className="text-xs text-muted-foreground line-clamp-2">{event.name}</p>}
</div> </div>
<button <button
type="button" type="button"
className="rounded-full border border-muted px-3 py-1 text-xs font-semibold" className="rounded-full border border-muted px-3 py-1 text-xs font-semibold text-foreground transition hover:bg-muted/80 dark:border-white/20 dark:text-white"
style={{ borderRadius: radius }}
onClick={closeShareSheet} onClick={closeShareSheet}
> >
{t('lightbox.close', 'Schließen')} {t('lightbox.close', 'Schließen')}
</button> </button>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
className="flex items-center gap-2 rounded-2xl border border-muted bg-muted/40 px-3 py-3 text-left text-sm font-semibold transition hover:bg-muted/60" className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-800 disabled:opacity-100 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/10 dark:disabled:text-white/80"
onClick={() => shareNative(shareSheet.url)} onClick={() => shareNative(shareSheet.url)}
disabled={shareSheet.loading} disabled={shareSheet.loading}
> style={{ borderRadius: radius }}
<Share2 className="h-4 w-4" /> >
<div> <Share2 className="h-4 w-4" aria-hidden />
<div>{t('share.button', 'Teilen')}</div> <div>
<div className="text-xs text-muted-foreground">{t('share.title', 'Geteiltes Foto')}</div> <div>{t('share.button', 'Teilen')}</div>
</div> <div className="text-xs text-slate-600 dark:text-white/70">{t('share.title', 'Geteiltes Foto')}</div>
</button> </div>
<button </button>
type="button" <button
className="flex items-center gap-2 rounded-2xl border border-muted bg-emerald-50 px-3 py-3 text-left text-sm font-semibold text-emerald-700 transition hover:bg-emerald-100 dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-200" type="button"
onClick={() => shareWhatsApp(shareSheet.url)} className="flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-emerald-600 disabled:opacity-60 dark:border-emerald-400/40"
disabled={shareSheet.loading} onClick={() => shareWhatsApp(shareSheet.url)}
> disabled={shareSheet.loading}
<WhatsAppIcon className="h-5 w-5" /> style={{ borderRadius: radius }}
<div> >
<div>{t('share.whatsapp', 'WhatsApp')}</div> <WhatsAppIcon className="h-5 w-5" />
<div className="text-xs text-muted-foreground">{shareSheet.loading ? '…' : ''}</div> <div>
</div> <div>{t('share.whatsapp', 'WhatsApp')}</div>
</button> <div className="text-xs text-white/80">{shareSheet.loading ? '…' : ''}</div>
<button </div>
type="button" </button>
className="flex items-center gap-2 rounded-2xl border border-muted bg-sky-50 px-3 py-3 text-left text-sm font-semibold text-sky-700 transition hover:bg-sky-100 dark:border-sky-900/40 dark:bg-sky-900/20 dark:text-sky-200" <button
onClick={() => shareMessages(shareSheet.url)} type="button"
disabled={shareSheet.loading} className="flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-sky-600 disabled:opacity-60 dark:border-sky-400/40"
> onClick={() => shareMessages(shareSheet.url)}
<MessageSquare className="h-5 w-5" /> disabled={shareSheet.loading}
<div> style={{ borderRadius: radius }}
<div>{t('share.imessage', 'Nachrichten')}</div> >
<div className="text-xs text-muted-foreground">{shareSheet.loading ? '…' : ''}</div> <MessageSquare className="h-5 w-5" />
</div> <div>
</button> <div>{t('share.imessage', 'Nachrichten')}</div>
<button <div className="text-xs text-white/80">{shareSheet.loading ? '…' : ''}</div>
type="button" </div>
className="flex items-center gap-2 rounded-2xl border border-muted bg-muted/40 px-3 py-3 text-left text-sm font-semibold transition hover:bg-muted/60" </button>
onClick={() => copyLink(shareSheet.url)} <button
disabled={shareSheet.loading} type="button"
> className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/5 dark:disabled:text-white/50"
<Copy className="h-4 w-4" /> onClick={() => copyLink(shareSheet.url)}
<div> disabled={shareSheet.loading}
<div>{t('share.copyLink', 'Link kopieren')}</div> style={{ borderRadius: radius }}
<div className="text-xs text-muted-foreground">{shareSheet.loading ? t('share.loading', 'Lädt…') : ''}</div> >
</div> <Copy className="h-4 w-4" aria-hidden />
</button> <div>
</div> <div className="text-slate-900 dark:text-white">{t('share.copyLink', 'Link kopieren')}</div>
<div className="text-xs text-slate-600 dark:text-white/80">{shareSheet.loading ? t('share.loading', 'Lädt…') : ''}</div>
</div>
</button>
</div>
{shareSheet.url && (
<p className="mt-3 truncate text-xs text-slate-700 dark:text-white/80" title={shareSheet.url}>
{shareSheet.url}
</p>
)}
</div> </div>
</div> </div>
)} )}
</Page> </Page>
); );
} }
const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;

View File

@@ -112,7 +112,7 @@ function EventBoundary({ token }: { token: string }) {
const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE; const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
const localeStorageKey = `guestLocale_event_${event.id ?? token}`; const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
const branding = mapEventBranding(event.branding); const branding = mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null);
return ( return (
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}> <LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
@@ -139,7 +139,7 @@ function SetupLayout() {
if (!token) return null; if (!token) return null;
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE; const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`; const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`;
const branding = event ? mapEventBranding(event.branding) : null; const branding = event ? mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null) : null;
return ( return (
<GuestIdentityProvider eventKey={token}> <GuestIdentityProvider eventKey={token}>
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}> <LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>

View File

@@ -223,6 +223,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name('tenant.tasks.for-event'); ->name('tenant.tasks.for-event');
Route::get('tasks/collection/{collection}', [TaskController::class, 'fromCollection']) Route::get('tasks/collection/{collection}', [TaskController::class, 'fromCollection'])
->name('tenant.tasks.from-collection'); ->name('tenant.tasks.from-collection');
Route::post('tasks/bulk-detach-event/{event}', [TaskController::class, 'bulkDetachFromEvent'])
->name('tenant.tasks.bulk-detach-from-event');
Route::post('tasks/event/{event}/reorder', [TaskController::class, 'reorderForEvent'])
->name('tenant.tasks.reorder-for-event');
Route::get('task-collections', [TaskCollectionController::class, 'index']) Route::get('task-collections', [TaskCollectionController::class, 'index'])
->name('tenant.task-collections.index'); ->name('tenant.task-collections.index');

View File

@@ -72,5 +72,121 @@ class SyncGoogleFontsTest extends TestCase
File::deleteDirectory($targetPath); File::deleteDirectory($targetPath);
} }
}
public function test_it_filters_by_category(): void
{
$targetPath = storage_path('app/test-fonts');
File::deleteDirectory($targetPath);
Http::fake([
'https://www.googleapis.com/webfonts/v1/webfonts*' => Http::response([
'items' => [
[
'family' => 'Alpha Sans',
'category' => 'sans-serif',
'files' => [
'regular' => 'https://fonts.gstatic.com/s/alpha-regular.woff2',
],
],
[
'family' => 'Beta Serif',
'category' => 'serif',
'files' => [
'regular' => 'https://fonts.gstatic.com/s/beta-regular.woff2',
],
],
],
]),
'https://fonts.gstatic.com/*' => Http::response('font-binary', 200),
]);
Artisan::call('fonts:sync-google', [
'--count' => 5,
'--category' => 'serif',
'--path' => 'storage/app/test-fonts',
'--force' => true,
]);
$manifestPath = $targetPath.'/manifest.json';
$manifest = json_decode(File::get($manifestPath), true);
$this->assertSame(1, $manifest['count']);
$this->assertSame('Beta Serif', $manifest['fonts'][0]['family']);
File::deleteDirectory($targetPath);
}
public function test_dry_run_does_not_write_files_or_download_fonts(): void
{
$targetPath = storage_path('app/test-fonts');
File::deleteDirectory($targetPath);
Http::fake([
'https://www.googleapis.com/webfonts/v1/webfonts*' => Http::response([
'items' => [
[
'family' => 'Alpha Sans',
'category' => 'sans-serif',
'files' => [
'regular' => 'https://fonts.gstatic.com/s/alpha-regular.woff2',
],
],
],
]),
'https://fonts.gstatic.com/*' => function () {
$this->fail('Font files should not be requested during dry run.');
},
]);
Artisan::call('fonts:sync-google', [
'--count' => 1,
'--path' => 'storage/app/test-fonts',
'--dry-run' => true,
]);
$this->assertDirectoryDoesNotExist($targetPath);
}
public function test_it_downloads_specific_family_even_when_count_is_smaller(): void
{
$targetPath = storage_path('app/test-fonts');
File::deleteDirectory($targetPath);
Http::fake([
'https://www.googleapis.com/webfonts/v1/webfonts*' => Http::response([
'items' => [
[
'family' => 'Alpha Sans',
'category' => 'sans-serif',
'files' => [
'regular' => 'https://fonts.gstatic.com/s/alpha-regular.woff2',
],
],
[
'family' => 'Beta Serif',
'category' => 'serif',
'files' => [
'regular' => 'https://fonts.gstatic.com/s/beta-regular.woff2',
],
],
],
]),
'https://fonts.gstatic.com/*' => Http::response('font-binary', 200),
]);
Artisan::call('fonts:sync-google', [
'--count' => 1,
'--family' => 'Beta Serif',
'--path' => 'storage/app/test-fonts',
'--force' => true,
]);
$manifestPath = $targetPath.'/manifest.json';
$manifest = json_decode(File::get($manifestPath), true);
$this->assertSame(1, $manifest['count']);
$this->assertSame('Beta Serif', $manifest['fonts'][0]['family']);
File::deleteDirectory($targetPath);
}
}