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\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
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.';
@@ -32,13 +32,28 @@ class SyncGoogleFonts extends Command
$weights = $this->prepareWeights($this->option('weights'));
$includeItalic = (bool) $this->option('italic');
$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');
$basePath = $pathOption
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
: public_path('fonts/google');
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)
->timeout(30)
@@ -60,10 +75,27 @@ class SyncGoogleFonts extends Command
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 = [];
$filesystem = new Filesystem();
$filesystem = new Filesystem;
if (! $dryRun) {
File::ensureDirectoryExists($basePath);
}
foreach ($selected as $index => $font) {
if (! is_array($font) || ! isset($font['family'])) {
@@ -73,11 +105,14 @@ class SyncGoogleFonts extends Command
$family = (string) $font['family'];
$slug = Str::slug($family);
$familyDir = $basePath.DIRECTORY_SEPARATOR.$slug;
if (! $dryRun) {
File::ensureDirectoryExists($familyDir);
}
$variantMap = $this->buildVariantMap($font, $weights, $includeItalic);
if (! count($variantMap)) {
$this->warn("Skipping {$family} (no matching variants)");
continue;
}
@@ -89,7 +124,11 @@ class SyncGoogleFonts extends Command
$filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension);
$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");
} else {
$this->line("↓ Downloading {$family} {$variantKey}");
@@ -97,6 +136,7 @@ class SyncGoogleFonts extends Command
if (! $fileResponse->ok()) {
$this->warn(" Skipped {$family} {$variantKey} (download failed)");
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->writeManifest($basePath, $manifestFonts);
$this->writeCss($basePath, $manifestFonts);
@@ -134,6 +180,54 @@ class SyncGoogleFonts extends Command
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>
*/

View File

@@ -13,6 +13,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class EmotionController extends Controller
{
@@ -21,8 +22,15 @@ class EmotionController extends Controller
$tenantId = $this->currentTenant($request)->id;
$query = Emotion::query()
->whereNull('tenant_id')
->orWhere('tenant_id', $tenantId)
->when(true, function ($builder) use ($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');
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');
$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);
}

View File

@@ -23,7 +23,7 @@ class TaskCollectionController extends Controller
$query = TaskCollection::query()
->forTenant($tenantId)
->with('eventType')
->withCount('tasks')
->withCount(['tasks', 'events'])
->orderBy('position')
->orderBy('id');
@@ -46,6 +46,27 @@ class TaskCollectionController extends Controller
$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);
return TaskCollectionResource::collection(
@@ -57,7 +78,8 @@ class TaskCollectionController extends Controller
{
$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));
}
@@ -81,7 +103,11 @@ class TaskCollectionController extends Controller
return response()->json([
'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'],
'attached_task_ids' => $result['attached_task_ids'],
]);

View File

@@ -31,7 +31,7 @@ class TaskController extends Controller
$inner->whereNull('tenant_id')
->orWhere('tenant_id', $tenantId);
})
->with(['taskCollection', 'assignedEvents', 'eventType'])
->with(['taskCollection', 'assignedEvents', 'eventType', 'emotion'])
->orderByRaw('tenant_id is null desc')
->orderBy('sort_order')
->orderBy('created_at', 'desc');
@@ -80,7 +80,7 @@ class TaskController extends Controller
$task = Task::create($payload);
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
$task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
return response()->json([
'message' => 'Task erfolgreich erstellt.',
@@ -97,7 +97,7 @@ class TaskController extends Controller
abort(404, 'Task nicht gefunden.');
}
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
$task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
return response()->json(new TaskResource($task));
}
@@ -156,7 +156,7 @@ class TaskController extends Controller
{
$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);
}
@@ -193,7 +193,9 @@ class TaskController extends Controller
}
$tasks = Task::whereIn('id', $taskIds)
->where('tenant_id', $tenantId)
->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
})
->get();
$attached = 0;
@@ -219,13 +221,63 @@ class TaskController extends Controller
}
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
->with(['taskCollection', 'eventType'])
->orderBy('created_at', 'desc')
->with(['taskCollection', 'eventType', 'emotion'])
->orderBy('tasks.id')
->paginate($request->get('per_page', 15));
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.
*/

View File

@@ -42,6 +42,8 @@ class EventStoreRequest extends FormRequest
'features' => ['nullable', 'array'],
'features.*' => ['string'],
'settings' => ['nullable', 'array'],
'settings.branding' => ['nullable', 'array'],
'settings.branding.*' => ['nullable'],
'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),
'features' => $settings['features'] ?? [],
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
'branding' => $settings['branding'] ?? null,
'settings' => $settings,
'event_type_id' => $this->event_type_id,
'event_type' => $this->whenLoaded('eventType', function () {

View File

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

View File

@@ -41,9 +41,21 @@ class TaskResource extends JsonResource
'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,
'source_task_id' => $this->source_task_id,
'source_collection_id' => $this->source_collection_id,
'sort_order' => $this->pivot?->sort_order,
'assigned_events_count' => $assignedEventsCount,
'assigned_events' => $this->whenLoaded(
'assignedEvents',
@@ -109,4 +121,3 @@ class TaskResource extends JsonResource
return $first !== false ? $first : $fallback;
}
}

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ namespace App\Support;
use App\Models\Event;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
class WatermarkConfigResolver
{
@@ -18,7 +17,12 @@ class WatermarkConfigResolver
$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

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": {
"": {
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0",
"@jpisnice/shadcn-ui-mcp-server": "^1.1.4",
@@ -778,6 +781,73 @@
"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": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz",

View File

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

View File

@@ -433,10 +433,19 @@ export type TenantTask = {
is_completed: boolean;
event_type_id: number | 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;
collection_id: number | null;
source_task_id: number | null;
source_collection_id: number | null;
sort_order?: number | null;
assigned_events_count: number;
assigned_events?: TenantEvent[];
created_at: string | null;
@@ -452,6 +461,7 @@ export type TenantTaskCollection = {
description_translations: Record<string, string | null>;
tenant_id: number | null;
is_global: boolean;
is_mine?: boolean;
event_type?: {
id: number;
slug: string;
@@ -460,6 +470,8 @@ export type TenantTaskCollection = {
icon: string | null;
} | null;
tasks_count: number;
events_count?: number;
imports_count?: number;
position: number | null;
source_collection_id: number | null;
created_at: string | null;
@@ -951,6 +963,7 @@ function normalizeTask(task: JsonValue): TenantTask {
typeof task.event_type_id === 'number'
? Number(task.event_type_id)
: eventType?.id ?? null;
const emotionRaw = task.emotion ?? null;
return {
id: Number(task.id ?? 0),
@@ -969,6 +982,25 @@ function normalizeTask(task: JsonValue): TenantTask {
is_completed: Boolean(task.is_completed ?? false),
event_type_id: eventTypeId,
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,
collection_id: task.collection_id ?? null,
source_task_id: task.source_task_id ?? null,
@@ -1010,8 +1042,11 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
description_translations: descriptionTranslations ?? {},
tenant_id: raw.tenant_id ?? null,
is_global: !raw.tenant_id,
is_mine: Boolean(raw.tenant_id),
event_type: eventType,
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,
source_collection_id: raw.source_collection_id ?? null,
created_at: raw.created_at ?? null,
@@ -1020,7 +1055,7 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
}
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(
raw.description_translations ?? raw.description ?? {},
undefined,
@@ -1037,11 +1072,11 @@ function normalizeEmotion(raw: JsonValue): TenantEmotion {
name_translations: nameTranslations,
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
description_translations: descriptionTranslations ?? {},
icon: String(raw.icon ?? 'lucide-smile'),
icon: typeof raw.icon === 'string' ? raw.icon : 'lucide-smile',
color: String(raw.color ?? '#6366f1'),
sort_order: Number(raw.sort_order ?? 0),
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,
event_types: (eventTypes as JsonValue[]).map((eventType) => {
const translations = normalizeTranslationMap(eventType.name ?? {});
@@ -2086,6 +2121,8 @@ export async function getTaskCollections(params: {
search?: string;
event_type?: string;
scope?: 'global' | 'tenant';
top_picks?: boolean;
limit?: number;
} = {}): Promise<PaginatedResult<TenantTaskCollection>> {
const searchParams = new URLSearchParams();
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.event_type) searchParams.set('event_type', params.event_type);
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 response = await authorizedFetch(
@@ -2142,6 +2181,34 @@ export async function importTaskCollection(
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[]> {
const response = await authorizedFetch('/api/v1/tenant/emotions');
if (!response.ok) {
@@ -2176,6 +2243,17 @@ export async function updateEmotion(emotionId: number, payload: EmotionPayload):
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>> {
const searchParams = new URLSearchParams();
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>> {
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}`);
export async function getEventTasks(
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) {
const payload = await safeJson(response);
console.error('[API] Failed to load event tasks', response.status, payload);

View File

@@ -178,7 +178,7 @@ export function CommandShelf() {
},
{
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.'),
icon: QrCode,
href: ADMIN_EVENT_INVITES_PATH(slug),
@@ -220,7 +220,7 @@ export function CommandShelf() {
},
{
key: 'invites',
label: t('commandShelf.metrics.invites', 'Einladungen'),
label: t('commandShelf.metrics.invites', 'QR-Codes'),
value: activeEvent.active_invites_count ?? activeEvent.total_invites_count,
hint: t('commandShelf.metrics.invitesHint', 'live'),
},
@@ -373,7 +373,7 @@ export function CommandShelf() {
{t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')}
</SheetTitle>
<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>
</SheetHeader>
<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: '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: '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) },
];
}

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',
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(),
},
];
@@ -110,7 +110,7 @@ export function DashboardEventFocusCard({
},
{
key: 'invites',
label: t('actions.invites', 'QR & Einladungen'),
label: t('actions.invites', 'QR-Codes'),
description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'),
icon: QrCode,
handler: onOpenInvites,

View File

@@ -7,7 +7,7 @@
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen mobil wie auf dem Desktop.",
"features": [
"Ü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."
],
"lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.",

View File

@@ -36,7 +36,7 @@
"photos": "Uploads",
"guests": "Team & Gäste",
"tasks": "Aufgaben",
"invites": "Einladungen",
"invites": "QR-Codes",
"toolkit": "Toolkit",
"recap": "Nachbereitung"
},
@@ -90,7 +90,7 @@
"mobile": {
"openActions": "Schnellaktionen öffnen",
"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.",
"tipCta": "Verstanden"
},
@@ -103,8 +103,8 @@
"welcome": {
"eyebrow": "Event Admin",
"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.",
"badge": "Fotos, Aufgaben & Einladungen an einem Ort",
"subtitle": "Bereite dein Event vor, teile QR-Codes, moderiere Uploads live und gib die Galerie danach frei.",
"badge": "Fotos, Aufgaben & QR-Codes an einem Ort",
"loginPrompt": "Bereits Kunde? Login oben rechts.",
"cta": {
"login": "Login",
@@ -122,7 +122,7 @@
"subtitle": "Alles an einem Ort",
"branding": {
"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": {
"title": "Aufgaben & Emotion-Sets",
@@ -133,7 +133,7 @@
"description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen."
},
"invites": {
"title": "Einladungen & QR",
"title": "QR-Codes & Layouts",
"description": "Links und Druckvorlagen generieren mit Paketlimits im Blick."
}
},
@@ -146,7 +146,7 @@
"accent": "Setup"
},
"share": {
"title": "Teilen & Einladen",
"title": "Teilen & QR-Codes",
"description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.",
"accent": "Share"
},
@@ -159,12 +159,12 @@
"plans": {
"title": "Pakete im Überblick",
"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": {
"title": "Starter",
"badge": "Für ein Event",
"p1": "1 Event, Basis-Branding",
"p2": "Aufgaben & Einladungen inklusive",
"p2": "Aufgaben & QR-Codes inklusive",
"p3": "Moderation & Galerie-Link"
},
"standard": {
@@ -200,7 +200,7 @@
"preview": {
"title": "Was dich erwartet",
"items": [
"Moderation, Aufgaben und Einladungen als Schnellzugriff",
"Moderation, Aufgaben und QR-Codes als Schnellzugriff",
"Sticky Actions auf Mobile für den Eventtag",
"Paket-Status & Limits jederzeit sichtbar"
]

View File

@@ -43,7 +43,7 @@
"noDate": "Kein Datum",
"actions": {
"photos": "Uploads",
"invites": "QR & Einladungen",
"invites": "QR-Codes",
"tasks": "Aufgaben"
}
},
@@ -62,8 +62,8 @@
"hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben."
},
"qr": {
"title": "QR-Einladung erstellt",
"hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter."
"title": "QR-Code erstellt",
"hint": "Erstelle einen QR-Code und lade die Drucklayouts herunter."
},
"package": {
"title": "Paket aktiv",
@@ -73,7 +73,7 @@
"actions": {
"createEvent": "Event erstellen",
"openTasks": "Tasks öffnen",
"openQr": "QR-Einladungen",
"openQr": "QR-Codes",
"openPackages": "Pakete ansehen"
}
},
@@ -168,7 +168,7 @@
},
"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": {
"question": "Wie moderiere ich Uploads?",

View File

@@ -232,7 +232,7 @@
"missingSlug": "Kein Event-Slug angegeben.",
"load": "Mitglieder konnten nicht geladen werden.",
"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."
},
"alerts": {
@@ -261,7 +261,7 @@
"namePlaceholder": "Name",
"roleLabel": "Rolle",
"rolePlaceholder": "Rolle wählen",
"submit": "Einladung senden"
"submit": "QR-Code senden"
},
"roles": {
"tenantAdmin": "Kunden-Admin",
@@ -282,7 +282,7 @@
"summary": "Übersicht",
"photos": "Uploads",
"tasks": "Aufgaben",
"invites": "Einladungen",
"invites": "QR-Codes",
"branding": "Branding",
"photobooth": "Photobooth",
"recap": "Nachbereitung"
@@ -372,7 +372,7 @@
},
"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": {
"missingSlug": "Kein Event-Slug angegeben.",
"loadFailed": "Toolkit konnte nicht geladen werden.",
@@ -388,14 +388,14 @@
"errorTitle": "Fehler",
"attention": "Achtung",
"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."
},
"metrics": {
"uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)",
"pendingPhotos": "Unmoderierte Fotos",
"activeInvites": "Aktive Einladungen",
"activeInvites": "Aktive QR-Codes",
"engagementMode": "Modus",
"modePhotoOnly": "Foto-Modus",
"modeTasks": "Aufgaben"
@@ -410,14 +410,14 @@
"statusPending": "Status: Prüfung ausstehend"
},
"invites": {
"title": "QR-Einladungen",
"title": "QR-Codes",
"subtitle": "Aktive Links und Layouts im Blick behalten.",
"activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt",
"empty": "Noch keine QR-Einladungen erstellt.",
"empty": "Noch keine QR-Codes erstellt.",
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"manage": "Einladungen verwalten"
"manage": "QR-Codes verwalten"
},
"tasks": {
"title": "Aktive Aufgaben",
@@ -460,8 +460,8 @@
"collectionsCta": "Mission Packs anzeigen"
},
"customizer": {
"title": "QR-Einladung anpassen",
"description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.",
"title": "QR-Code anpassen",
"description": "Passe Layout, Texte, Farben und Logo deiner QR-Codeskarten an.",
"layout": "Layout",
"selectLayout": "Layout auswählen",
"headline": "Überschrift",
@@ -519,20 +519,20 @@
}
},
"invites": {
"cardTitle": "QR-Einladungen & Layouts",
"cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.",
"subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.",
"cardTitle": "QR-Codes & Layouts",
"cardDescription": "Erzeuge QR-Codes, passe Layouts an und stelle druckfertige Vorlagen bereit.",
"subtitle": "Manage QR-Codes, Drucklayouts und Branding für deine Gäste.",
"tabs": {
"layout": "QR-Code-Layout anpassen",
"share": "Links & QR teilen",
"export": "Drucken & Export"
},
"summary": {
"active": "Aktive Einladungen",
"active": "Aktive QR-Codes",
"total": "Gesamt"
},
"workflow": {
"title": "Einladungs-Workflow",
"title": "QR-Codes-Workflow",
"description": "Durchlaufe Layout, Links und Export Schritt für Schritt.",
"badge": "Setup",
"steps": {
@@ -542,7 +542,7 @@
},
"share": {
"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": {
"title": "Drucken & Export",
@@ -564,13 +564,13 @@
"editLayout": "Layout bearbeiten",
"editHint": "Farben & Texte direkt im Editor anpassen.",
"export": "Drucken/Export",
"create": "Weitere Einladung"
"create": "Weitere QR-Code"
},
"hint": "Teile den Link direkt im Team oder in Newslettern."
},
"actions": {
"refresh": "Aktualisieren",
"create": "Neue Einladung erstellen",
"create": "Neue QR-Code erstellen",
"backToList": "Zurück zur Übersicht",
"backToEvent": "Event öffnen",
"copy": "Link kopieren",
@@ -589,8 +589,8 @@
"qrAlt": "QR-Code Vorschau"
},
"empty": {
"title": "Noch keine Einladungen",
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten."
"title": "Noch keine QR-Codes",
"copy": "Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten."
},
"errorTitle": "Aktion fehlgeschlagen",
"export": {
@@ -602,9 +602,9 @@
},
"previewHint": "Speichere deine Änderungen, um die Exportdateien neu zu erstellen.",
"noLayoutPreview": "Noch keine Vorschau verfügbar. Speichere das Layout zuerst.",
"selectPlaceholder": "Einladung auswählen",
"noInviteSelected": "Wähle zunächst eine Einladung aus, um Downloads zu starten.",
"noLayouts": "Für diese Einladung sind aktuell keine Layouts verfügbar.",
"selectPlaceholder": "QR-Code auswählen",
"noInviteSelected": "Wähle zunächst eine QR-Code aus, um Downloads zu starten.",
"noLayouts": "Für diese QR-Code sind aktuell keine Layouts verfügbar.",
"actions": {
"title": "Aktionen",
"description": "Starte deinen Testdruck oder lade die Layouts herunter.",
@@ -685,14 +685,14 @@
"title": "Live-Vorschau",
"subtitle": "So sieht dein Layout beim Export aus.",
"mobileOpen": "Vorschau anzeigen",
"mobileTitle": "Einladungsvorschau",
"mobileTitle": "QR-Codesvorschau",
"mobileHint": "Öffnet eine Vorschau in einem Overlay",
"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.",
"qrAlt": "QR-Code der Einladung"
"qrAlt": "QR-Code der QR-Code"
},
"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",
"loadingDescription": "Bitte warte einen Moment, wir bereiten die Drucklayouts vor.",
"loadingError": "Layouts konnten nicht geladen werden.",
@@ -807,7 +807,7 @@
"edit": "Bearbeiten",
"members": "Team & Rollen",
"tasks": "Aufgaben verwalten",
"invites": "Einladungen & Layouts",
"invites": "QR-Codes & Layouts",
"photos": "Fotos moderieren",
"refresh": "Aktualisieren",
"buyMorePhotos": "Mehr Fotos freischalten",
@@ -815,11 +815,11 @@
"extendGallery": "Galerie verlängern"
},
"workspace": {
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.",
"detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.",
"hero": {
"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?"
},
"sections": {
@@ -880,16 +880,16 @@
"uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)",
"pending": "Fotos in Moderation",
"activeInvites": "Aktive Einladungen"
"activeInvites": "Aktive QR-Codes"
},
"invites": {
"badge": "Einladungen",
"title": "QR-Einladungen",
"subtitle": "Behält aktive Einladungen und Layouts im Blick.",
"badge": "QR-Codes",
"title": "QR-Codes",
"subtitle": "Behält aktive QR-Codes und Layouts im Blick.",
"activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt",
"empty": "Noch keine Einladungen erstellt.",
"manage": "Layouts & Einladungen verwalten"
"empty": "Noch keine QR-Codes erstellt.",
"manage": "Layouts & QR-Codes verwalten"
},
"tasks": {
"badge": "Aufgaben",
@@ -1009,7 +1009,7 @@
"negative": "Brauch(t)e Unterstützung",
"best": {
"uploads": "Uploads & Geschwindigkeit",
"invites": "QR-Einladungen & Layouts",
"invites": "QR-Codes & Layouts",
"moderation": "Moderation & Export",
"experience": "Allgemeine App-Erfahrung"
},
@@ -1603,18 +1603,18 @@
},
"noEvents": {
"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"
},
"draftEvent": {
"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"
},
"upcomingEvent": {
"title": "Event startet bald",
"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"
},
"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 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 => {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
@@ -31,7 +38,7 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
return undefined;
};
return [
const tabs = [
{
key: 'overview',
label: translate('eventMenu.summary', 'Übersicht'),
@@ -51,14 +58,9 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
},
{
key: 'invites',
label: translate('eventMenu.invites', 'Einladungen'),
label: translate('eventMenu.invites', 'QR-Codes'),
href: ADMIN_EVENT_INVITES_PATH(event.slug),
},
{
key: 'branding',
label: translate('eventMenu.branding', 'Branding'),
href: ADMIN_EVENT_BRANDING_PATH(event.slug),
},
{
key: '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 { 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 toast from 'react-hot-toast';
type EmotionFormState = {
name: string;
@@ -49,6 +57,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<TenantEmotion | null>(null);
const [saving, setSaving] = React.useState(false);
const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE);
@@ -107,9 +116,11 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const created = await createEmotion(payload);
setEmotions((prev) => [created, ...prev]);
setDialogOpen(false);
toast.success(t('emotions.toast.created', 'Emotion erstellt.'));
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.create'));
toast.error(t('emotions.toast.error', 'Emotion konnte nicht erstellt werden.'));
}
} finally {
setSaving(false);
@@ -120,13 +131,35 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
try {
const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active });
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) {
if (!isAuthError(err)) {
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 title = embedded ? t('emotions.title') : t('emotions.title');
const subtitle = embedded
@@ -171,6 +204,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
key={emotion.id}
emotion={emotion}
onToggle={() => toggleEmotion(emotion)}
onDelete={() => setDeleteTarget(emotion)}
locale={locale}
/>
))}
@@ -187,6 +221,29 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
saving={saving}
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>
);
}
@@ -203,10 +260,12 @@ export default function EmotionsPage() {
function EmotionCard({
emotion,
onToggle,
onDelete,
locale,
}: {
emotion: TenantEmotion;
onToggle: () => void;
onDelete: () => void;
locale: Locale;
}) {
const { t } = useTranslation('management');
@@ -252,7 +311,13 @@ function EmotionCard({
<Power className="mr-1 h-4 w-4" />
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
</Button>
{!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>
</Card>
);

View File

@@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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 { AdminLayout } from '../components/AdminLayout';
import { SectionCard, SectionHeader } from '../components/tenant';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { ADMIN_EVENT_VIEW_PATH } from '../constants';
import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api';
import { cn } from '@/lib/utils';
@@ -23,6 +25,20 @@ import { ensureFontLoaded, useTenantFonts } from '../lib/fonts';
const DEFAULT_FONT_VALUE = '__default';
const CUSTOM_FONT_VALUE = '__custom';
const MAX_LOGO_UPLOAD_BYTES = 1024 * 1024;
const EMOTICON_GRID: string[] = [
'✨', '🎉', '🎊', '🥳', '🎈', '🎁', '🎂', '🍾', '🥂', '🍻',
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
'🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚',
'😎', '🤩', '🤗', '🤝', '👍', '🙌', '👏', '👐', '🤲', '🙏',
'🤍', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤎', '🤍',
'⭐', '🌟', '💫', '🔥', '⚡', '🌈', '☀️', '🌅', '🌠', '🌌',
'🎵', '🎶', '🎤', '🎧', '🎸', '🥁', '🎺', '🎹', '🎻', '🪩',
'🍕', '🍔', '🌮', '🌯', '🍣', '🍱', '🍰', '🍪', '🍫', '🍩',
'☕', '🍵', '🥤', '🍹', '🍸', '🍷', '🍺', '🍻', '🥂', '🍾',
'📸', '🎥', '📹', '📱', '💡', '🛎️', '🪄', '🎯', '🏆', '🥇',
];
type BrandingForm = {
useDefault: boolean;
@@ -225,6 +241,43 @@ function resolvePreviewBranding(form: BrandingForm, tenantBranding: BrandingForm
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 {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
@@ -232,6 +285,7 @@ export default function EventBrandingPage(): React.ReactElement {
const queryClient = useQueryClient();
const [form, setForm] = useState<BrandingForm>(DEFAULT_BRANDING_FORM);
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light');
const [emoticonDialogOpen, setEmoticonDialogOpen] = useState(false);
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
const title = t('branding.title', 'Branding & Fonts');
@@ -290,11 +344,32 @@ export default function EventBrandingPage(): React.ReactElement {
const mutation = useMutation({
mutationFn: async (payload: BrandingForm) => {
if (!slug) throw new Error('Missing event slug');
const response = await updateEvent(slug, {
settings: {
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, {
name: eventName,
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;
},
@@ -304,10 +379,20 @@ export default function EventBrandingPage(): React.ReactElement {
},
onError: (error: unknown) => {
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.'));
},
});
const { mutate, isPending } = mutation;
if (!slug) {
return (
<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 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 (
<AdminLayout
title={title}
@@ -349,7 +487,7 @@ export default function EventBrandingPage(): React.ReactElement {
</Button>
)}
>
<div className="space-y-4">
<div className="space-y-4 pb-28">
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')}
@@ -451,7 +589,17 @@ export default function EventBrandingPage(): React.ReactElement {
<SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{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>
</SelectContent>
@@ -461,6 +609,7 @@ export default function EventBrandingPage(): React.ReactElement {
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))}
disabled={form.useDefault}
placeholder="z. B. Playfair Display"
style={form.typography.heading ? { fontFamily: form.typography.heading } : undefined}
/>
</div>
<div className="space-y-2">
@@ -476,7 +625,17 @@ export default function EventBrandingPage(): React.ReactElement {
<SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{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>
</SelectContent>
@@ -486,6 +645,7 @@ export default function EventBrandingPage(): React.ReactElement {
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))}
disabled={form.useDefault}
placeholder="z. B. Inter, sans-serif"
style={form.typography.body ? { fontFamily: form.typography.body } : undefined}
/>
</div>
<div className="space-y-2">
@@ -528,6 +688,72 @@ export default function EventBrandingPage(): React.ReactElement {
</SelectContent>
</Select>
</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">
<Label>{t('branding.logoPosition', 'Position')}</Label>
<Select
@@ -649,33 +875,7 @@ export default function EventBrandingPage(): React.ReactElement {
</SectionCard>
</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">
<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>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}
@@ -688,6 +888,14 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
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'
? {
border: `2px solid ${branding.buttons.primary || branding.palette.primary}`,
@@ -705,8 +913,8 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
<CardHeader className="p-0">
<div className="px-4 py-3" style={headerStyle}>
<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">
{branding.logo.value || '✨'}
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-white/90 text-xl">
{logoVisual}
</div>
<div className="flex flex-col">
<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 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 eventTabs = React.useMemo(() => {
@@ -221,6 +221,11 @@ export default function EventDetailPage() {
return buildEventTabs(event, translateMenu, counts);
}, [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(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
[event?.limits, tCommon],
@@ -449,7 +454,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
</p>
<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">
{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>
<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">
@@ -503,6 +508,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/>
</div>
{brandingAllowed ? (
<BrandingMissionCard
event={event}
invites={toolkitData?.invites}
@@ -512,6 +518,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
/>
) : null}
{event.addons?.length ? (
<SectionCard>
<SectionHeader
@@ -758,9 +765,9 @@ function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.invites.badge', 'Einladungen')}
title={t('events.invites.title', 'QR-Einladungen')}
description={t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
eyebrow={t('events.invites.badge', 'QR-Codes')}
title={t('events.invites.title', 'QR-Codes & Layouts')}
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="flex gap-2 text-sm text-slate-900">
@@ -782,11 +789,11 @@ function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['
))}
</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">
<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>
</div>
</SectionCard>
@@ -999,10 +1006,10 @@ function GalleryShareCard({
<SectionHeader
eyebrow={t('events.galleryShare.badge', 'Galerie')}
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">
{t('events.galleryShare.createInvite', 'Einladung erstellen')}
{t('events.galleryShare.createInvite', 'QR-Code erstellen')}
</Button>
</SectionCard>
);

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
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 { 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 { AdminLayout } from '../components/AdminLayout';
import { FloatingActionBar } from '../components/FloatingActionBar';
import {
createEvent,
getEvent,
@@ -67,6 +68,7 @@ export default function EventFormPage() {
const slugParam = params.slug ?? searchParams.get('slug') ?? undefined;
const isEdit = Boolean(slugParam);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
@@ -88,6 +90,7 @@ export default function EventFormPage() {
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
const formRef = React.useRef<HTMLFormElement | null>(null);
const { data: packages, isLoading: packagesLoading } = useQuery({
queryKey: ['packages', 'endcustomer'],
@@ -143,7 +146,7 @@ export default function EventFormPage() {
queryKey: ['tenant', 'events', slugParam],
queryFn: () => getEvent(slugParam!),
enabled: Boolean(isEdit && slugParam),
staleTime: 60_000,
staleTime: 0,
});
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>) {
event.preventDefault();
const trimmedName = form.name.trim();
@@ -320,14 +333,20 @@ export default function EventFormPage() {
if (isEdit) {
const targetSlug = originalSlug ?? slugParam!;
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);
setShowUpgradeHint(false);
setError(null);
toast.success(tForm('actions.saved', 'Event gespeichert'));
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
} else {
const { event: created } = await createEvent(payload);
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
setShowUpgradeHint(false);
setError(null);
toast.success(tForm('actions.saved', 'Event gespeichert'));
navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
}
} catch (err) {
@@ -456,6 +475,26 @@ export default function EventFormPage() {
</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 (
<AdminLayout
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
@@ -500,7 +539,7 @@ export default function EventFormPage() {
</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>
<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')}
@@ -513,7 +552,7 @@ export default function EventFormPage() {
{loading ? (
<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="space-y-2 sm:col-span-2">
<Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label>
@@ -585,26 +624,6 @@ export default function EventFormPage() {
</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">
<Accordion type="single" collapsible defaultValue="package">
<AccordionItem value="package" className="border-0">
@@ -695,6 +714,7 @@ export default function EventFormPage() {
)}
</CardContent>
</Card>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
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 { Badge } from '@/components/ui/badge';
@@ -29,8 +29,6 @@ import {
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { buildEventTabs } from '../lib/eventTabs';
@@ -57,6 +55,7 @@ import {
triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils';
import { useOnboardingProgress } from '../onboarding';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
interface PageState {
event: TenantEvent | null;
@@ -219,7 +218,7 @@ export default function EventInvitesPage(): React.ReactElement {
setAddonsCatalog(catalog);
} catch (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]);
@@ -543,9 +542,11 @@ export default function EventInvitesPage(): React.ReactElement {
try {
await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id);
toast.success(t('invites.actions.copied', 'Link kopiert'));
} catch {
// ignore clipboard failures
}
toast.success(t('invites.actions.created', 'QR-Code erstellt'));
markStep({
lastStep: 'invite',
serverStep: 'invite_created',
@@ -553,7 +554,8 @@ export default function EventInvitesPage(): React.ReactElement {
});
} catch (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 {
setCreatingInvite(false);
@@ -564,8 +566,10 @@ export default function EventInvitesPage(): React.ReactElement {
try {
await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id);
toast.success(t('invites.actions.copied', 'Link kopiert'));
} catch (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) {
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
}
toast.success(t('invites.actions.revoked', 'QR-Code deaktiviert'));
} catch (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 {
setRevokingId(null);
@@ -616,6 +622,7 @@ export default function EventInvitesPage(): React.ReactElement {
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerDraft(null);
toast.success(t('invites.customizer.toastSaved', 'Layout gespeichert'));
markStep({
lastStep: 'branding',
serverStep: 'branding_configured',
@@ -627,6 +634,7 @@ export default function EventInvitesPage(): React.ReactElement {
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
toast.error(t('invites.customizer.toastSaveFailed', 'Layout konnte nicht gespeichert werden.'));
}
} finally {
setCustomizerSaving(false);
@@ -699,9 +707,9 @@ export default function EventInvitesPage(): React.ReactElement {
const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename(
['Einladungslayout', eventName, exportLayout.name ?? null, eventDateSegment],
['QR-Codeslayout', eventName, exportLayout.name ?? null, eventDateSegment],
normalizedFormat,
'einladungslayout',
'QR-Codeslayout',
);
const exportOptions = {
@@ -792,36 +800,8 @@ export default function EventInvitesPage(): React.ReactElement {
className="hover:text-foreground"
>
<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>
{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>
);
@@ -860,6 +840,43 @@ export default function EventInvitesPage(): React.ReactElement {
[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(
() => ({
photos: tLimits('photosTitle'),
@@ -882,11 +899,12 @@ export default function EventInvitesPage(): React.ReactElement {
return (
<AdminLayout
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}
tabs={eventTabs}
currentTabKey="invites"
>
<div className="pb-28">
{limitWarnings.length > 0 && (
<div className="mb-6 space-y-2">
{limitWarnings.map((warning) => (
@@ -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">
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<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">
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
</p>
@@ -1014,7 +1032,7 @@ export default function EventInvitesPage(): React.ReactElement {
disabled={state.invites.length === 0}
>
<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>
<SelectContent>
{state.invites.map((invite) => (
@@ -1216,7 +1234,7 @@ export default function EventInvitesPage(): React.ReactElement {
{selectedInvite.qr_code_data_url ? (
<img
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"
/>
) : (
@@ -1263,12 +1281,12 @@ export default function EventInvitesPage(): React.ReactElement {
</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)]">
{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 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>
)}
</CardContent>
@@ -1291,13 +1309,13 @@ export default function EventInvitesPage(): React.ReactElement {
<div className="space-y-2">
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
<QrCode className="h-5 w-5 text-primary" />
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
{t('invites.cardTitle', 'QR-QR-Code & Layouts')}
</CardTitle>
<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>
<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>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span>
</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"
>
{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>
{!state.loading && state.event?.limits?.can_add_guests === false && (
<p className="w-full text-xs text-amber-600">
@@ -1353,6 +1371,8 @@ export default function EventInvitesPage(): React.ReactElement {
</Card>
</TabsContent>
</Tabs>
</div>
<FloatingActionBar actions={fabActions} />
</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">
<div>
<CardTitle className="text-base font-semibold text-foreground">
{t('invites.workflow.title', 'Einladungs-Workflow')}
{t('invites.workflow.title', 'QR-Codes-Workflow')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{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 variant="outline" onClick={onCreate} className="flex-1">
<Share2 className="mr-2 h-4 w-4" />
{t('invites.share.actions.create', 'Weitere Einladung')}
{t('invites.share.actions.create', 'Weitere QR-Code')}
</Button>
</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-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)}>
{status}
</Badge>
@@ -1651,11 +1671,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
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">
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten.')}</p>
<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 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">
<Share2 className="mr-1 h-4 w-4" />
{t('invites.actions.create', 'Neue Einladung erstellen')}
{t('invites.actions.create', 'Neue QR-Code erstellen')}
</Button>
</div>
);

View File

@@ -218,7 +218,7 @@ export default function EventRecapPage() {
return (
<AdminLayout
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}
currentTabKey="recap"
>

View File

@@ -9,16 +9,33 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
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 {
assignTasksToEvent,
detachTasksFromEvent,
getEvent,
getEventTasks,
createTask,
getTasks,
getTaskCollections,
importTaskCollection,
@@ -29,11 +46,12 @@ import {
TenantTaskCollection,
TenantEmotion,
} from '../api';
import { EmotionsSection } from './EmotionsPage';
import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants';
import { extractBrandingPalette } from '../lib/branding';
import { filterEmotionsByEventType } from '../lib/emotions';
import { buildEventTabs } from '../lib/eventTabs';
import { Trash2 } from 'lucide-react';
export default function EventTasksPage() {
const { t } = useTranslation('management', { keyPrefix: 'eventTasks' });
@@ -46,7 +64,6 @@ export default function EventTasksPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [availableTasks, setAvailableTasks] = React.useState<TenantTask[]>([]);
const [selected, setSelected] = React.useState<number[]>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [modeSaving, setModeSaving] = React.useState(false);
@@ -60,12 +77,33 @@ export default function EventTasksPage() {
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const [emotionsLoading, setEmotionsLoading] = React.useState(false);
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) => {
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));
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) {
if (!isAuthError(err)) {
setError(t('errors.assign', 'Tasks konnten nicht geladen werden.'));
@@ -73,19 +111,31 @@ export default function EventTasksPage() {
}
}, [t]);
const statusLabels = React.useMemo(
() => ({
published: t('management.members.statuses.published', 'Veröffentlicht'),
draft: t('management.members.statuses.draft', 'Entwurf'),
}),
[t]
);
const palette = React.useMemo(() => extractBrandingPalette(event?.settings), [event?.settings]);
const relevantEmotions = React.useMemo(
() => filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null),
[emotions, event?.event_type_id, event?.event_type?.id],
);
const relevantEmotions = React.useMemo(() => {
const filtered = filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null);
return filtered.length > 0 ? filtered : emotions;
}, [emotions, event?.event_type_id, event?.event_type?.id]);
const emotionChips = React.useMemo(() => {
const map: Record<number, TenantEmotion> = {};
assignedTasks.forEach((task) => {
if (task.emotion) {
map[task.emotion.id] = {
...task.emotion,
name_translations: task.emotion.name_translations ?? {},
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(() => {
if (!slug) {
@@ -101,7 +151,7 @@ export default function EventTasksPage() {
const eventData = await getEvent(slug);
const [eventTasksResponse, libraryTasks] = await Promise.all([
getEventTasks(eventData.id, 1),
getTasks({ per_page: 50 }),
getTasks({ per_page: 200 }),
]);
if (cancelled) return;
setEvent(eventData);
@@ -135,36 +185,140 @@ export default function EventTasksPage() {
};
}, [slug, t]);
async function handleAssign() {
if (!event || selected.length === 0) return;
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()) {
return list;
}
const term = taskSearch.toLowerCase();
return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
}, [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, 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([]);
await assignTasksToEvent(event.id, [taskId]);
toast.success(t('actions.assignedToast', 'Tasks wurden zugewiesen.'));
} catch (err) {
if (!isAuthError(err)) {
setError(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
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;
}
React.useEffect(() => {
setSelected((current) => current.filter((taskId) => availableTasks.some((task) => task.id === taskId)));
}, [availableTasks]);
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);
const filteredAssignedTasks = React.useMemo(() => {
if (!taskSearch.trim()) {
return assignedTasks;
setDraggingId(null);
if (!targetList || targetList === originList) {
return;
}
const term = taskSearch.toLowerCase();
return assignedTasks.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
}, [assignedTasks, taskSearch]);
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(() => {
if (!event) {
@@ -183,7 +337,9 @@ export default function EventTasksPage() {
setCollectionsLoading(true);
setCollectionsError(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)
.then((result) => {
@@ -264,6 +420,31 @@ export default function EventTasksPage() {
return mode !== 'photo_only';
}, [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) {
if (!event || !slug) return;
@@ -316,6 +497,8 @@ export default function EventTasksPage() {
tabs={eventTabs}
currentTabKey="tasks"
>
{summaryBadges}
{error && (
<Alert variant="destructive">
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
@@ -340,12 +523,6 @@ export default function EventTasksPage() {
<TabsContent value="tasks" className="space-y-6">
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<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="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
@@ -376,20 +553,6 @@ export default function EventTasksPage() {
{t('modes.updating', 'Einstellung wird gespeichert ...')}
</div>
) : 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>
</CardHeader>
<CardContent className="pb-0">
@@ -413,6 +576,12 @@ export default function EventTasksPage() {
</AlertDescription>
</Alert>
</CardContent>
<DndContext
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">
@@ -431,6 +600,44 @@ export default function EventTasksPage() {
</div>
</div>
{emotionChips.length > 0 ? (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={emotionFilter.length === 0 ? 'default' : 'outline'}
onClick={() => setEmotionFilter([])}
className="rounded-full"
>
{t('filters.allEmotions', 'Alle Emotionen')}
</Button>
{emotionChips.map((emotion) => {
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>
) : null}
<DropZone id="assigned-dropzone">
{filteredAssignedTasks.length === 0 ? (
<EmptyState
message={
@@ -442,10 +649,16 @@ export default function EventTasksPage() {
) : (
<div className="space-y-2">
{filteredAssignedTasks.map((task) => (
<AssignedTaskRow key={task.id} task={task} />
<DraggableTaskCard
key={task.id}
task={task}
origin="assigned"
onRemove={() => void handleDetachSingle(task.id)}
/>
))}
</div>
)}
</DropZone>
</section>
<section className="space-y-3">
@@ -453,52 +666,102 @@ export default function EventTasksPage() {
<PlusCircle className="h-4 w-4 text-emerald-500" />
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
</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">
<p className="text-xs font-semibold text-emerald-700">{t('sections.library.quickCreate', 'Schnell neue Aufgabe anlegen')}</p>
<div className="mt-2 grid gap-2">
<Input
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
placeholder={t('sections.library.quickTitle', 'Titel der Aufgabe')}
disabled={!tasksEnabled || creatingTask}
/>
<Textarea
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) => (
<label key={task.id} className="flex items-start gap-3 rounded-xl border border-transparent p-2 transition hover:border-emerald-200">
<Checkbox
checked={selected.includes(task.id)}
onCheckedChange={(checked) =>
setSelected((prev) =>
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
)
}
<DraggableTaskCard
key={task.id}
task={task}
origin="library"
onAdd={() => void handleAssignSingle(task.id)}
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>
</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>
</DropZone>
</section>
</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>
<BrandingStoryPanel
event={event}
palette={palette}
<EmotionsCard
emotions={relevantEmotions}
emotionsLoading={emotionsLoading}
emotionsError={emotionsError}
collections={collections}
onOpenBranding={() => {
if (!slug) return;
navigate(ADMIN_EVENT_BRANDING_PATH(slug));
}}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
onOpenEmotions={() => setEmotionsModalOpen(true)}
/>
</TabsContent>
<TabsContent value="packs">
@@ -514,6 +777,15 @@ export default function EventTasksPage() {
</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>
);
}
@@ -536,17 +808,107 @@ function TaskSkeleton() {
);
}
function AssignedTaskRow({ task }: { task: TenantTask }) {
const { t } = useTranslation('management');
function DropZone({ id, children }: { id: string; children: React.ReactNode }) {
const zone = id === 'assigned-dropzone' ? 'assigned' : 'library';
const { setNodeRef, isOver } = useDroppable({ id, data: { list: zone } });
return (
<div className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div
ref={setNodeRef}
className={`rounded-2xl border border-dashed p-2 ${isOver ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200/70'}`}
>
{children}
</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>
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
</div>
);
}
@@ -640,78 +1002,47 @@ function MissionPackGrid({
);
}
type BrandingStoryPanelProps = {
event: TenantEvent;
palette: ReturnType<typeof extractBrandingPalette>;
type EmotionsCardProps = {
emotions: TenantEmotion[];
emotionsLoading: boolean;
emotionsError: string | null;
collections: TenantTaskCollection[];
onOpenBranding: () => void;
onOpenEmotions: () => void;
onOpenCollections: () => void;
};
function BrandingStoryPanel({
event,
palette,
emotions,
emotionsLoading,
emotionsError,
collections,
onOpenBranding,
onOpenEmotions,
onOpenCollections,
}: BrandingStoryPanelProps) {
function EmotionsCard({ emotions, emotionsLoading, emotionsError, onOpenEmotions }: EmotionsCardProps) {
const { t } = useTranslation('management');
const fallbackColors = palette.colors.length ? palette.colors : ['#f472b6', '#fde68a', '#312e81'];
const spotlightEmotions = emotions.slice(0, 4);
const recommendedCollections = React.useMemo(() => collections.slice(0, 2), [collections]);
const spotlightEmotions = emotions.slice(0, 6);
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>
<CardTitle className="text-xl text-slate-900">
{t('tasks.story.title', 'Branding & Story')}
<CardTitle className="flex items-center gap-2 text-base text-rose-900">
<Sparkles className="h-5 w-5 text-rose-500" />
{t('tasks.story.emotionsTitle', 'Emotionen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{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>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/80 p-4 text-sm text-indigo-900 shadow-inner shadow-indigo-100">
<p className="text-xs uppercase tracking-[0.3em]">
{t('events.branding.brandingTitle', 'Branding')}
</p>
<p className="mt-1 text-base font-semibold">{palette.font ?? t('events.branding.brandingFallback', 'Aktuelle Auswahl')}</p>
<p className="text-xs text-indigo-900/70">
{t('events.branding.brandingCopy', 'Passe Farben & Schriftarten im Layout-Editor an.')}
</p>
<div className="mt-3 flex gap-2">
{fallbackColors.slice(0, 4).map((color) => (
<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')}
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="border-rose-200 text-rose-700">
{t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiv', count: emotions.length })}
</Badge>
<Button
size="sm"
className="bg-rose-600 text-white hover:bg-rose-700"
onClick={onOpenEmotions}
>
{t('tasks.story.emotionsCta', 'Emotionen verwalten')}
</Button>
</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">
<div>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.3em] text-rose-400">
{t('tasks.story.emotionsTitle', 'Emotionen')}
</p>
<Badge variant="outline" className="border-rose-200 text-rose-600">
{t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiviert', count: emotions.length })}
</Badge>
</div>
{emotionsLoading ? (
<div className="mt-3 h-10 animate-pulse rounded-xl bg-white/70" />
<div className="h-10 animate-pulse rounded-xl bg-white/70" />
) : emotionsError ? (
<p className="mt-3 text-xs text-rose-900/70">{emotionsError}</p>
<p className="text-xs text-rose-900/70">{emotionsError}</p>
) : spotlightEmotions.length ? (
<div className="mt-3 flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2">
{spotlightEmotions.map((emotion) => (
<span
key={emotion.id}
@@ -727,58 +1058,15 @@ function BrandingStoryPanel({
))}
</div>
) : (
<p className="mt-3 text-xs text-rose-900/70">
<p className="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 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">
{t('tasks.story.collectionsTitle', 'Mission Packs')}
</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>
</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 {
switch (priority) {
case 'low':
@@ -791,10 +1079,3 @@ function mapPriority(priority: TenantTask['priority'], translate: (key: string,
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';
import { buildEngagementTabPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import toast from 'react-hot-toast';
const DEFAULT_PAGE_SIZE = 12;
@@ -51,7 +52,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
const [scope, setScope] = React.useState<ScopeFilter>('all');
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null);
@@ -86,6 +86,7 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
if (cancelled) return;
if (!isAuthError(err)) {
setError(t('collections.notifications.error'));
toast.error(t('collections.notifications.error'));
}
} finally {
if (!cancelled) {
@@ -101,14 +102,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
};
}, [page, search, scopeParam, reloadToken, t]);
React.useEffect(() => {
if (successMessage) {
const timeout = setTimeout(() => setSuccessMessage(null), 4000);
return () => clearTimeout(timeout);
}
return undefined;
}, [successMessage]);
async function ensureEventsLoaded() {
if (events.length > 0 || eventsLoading) {
return;
@@ -144,12 +137,13 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
setEventError(null);
try {
await importTaskCollection(selectedCollection.id, selectedEventSlug);
setSuccessMessage(t('collections.notifications.imported'));
toast.success(t('collections.notifications.imported'));
setDialogOpen(false);
setReloadToken((token) => token + 1);
} catch (err) {
if (!isAuthError(err)) {
setEventError(t('collections.notifications.error'));
toast.error(t('collections.notifications.error'));
}
} finally {
setImporting(false);
@@ -181,13 +175,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
</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">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>

View File

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

View File

@@ -53,10 +53,16 @@ const normalizeImageUrl = (src?: string | null) => {
export default function GalleryPage() {
const { token } = useParams<{ token?: string }>();
const { t, locale } = useTranslation();
const { branding } = useEventBranding();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '', locale);
const [searchParams, setSearchParams] = useSearchParams();
const photoIdParam = searchParams.get('photoId');
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 [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
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) {
if (!token) return;
@@ -436,18 +446,25 @@ export default function GalleryPage() {
)}
{shareSheet.photo && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-t-3xl bg-white p-4 shadow-xl dark:bg-slate-900">
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
<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 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"
style={{ ...(bodyFont ? { fontFamily: bodyFont } : {}), borderRadius: radius }}
>
<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')}
</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>
<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}
>
{t('lightbox.close', 'Schließen')}
@@ -457,62 +474,66 @@ export default function GalleryPage() {
<div className="grid grid-cols-2 gap-3">
<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)}
disabled={shareSheet.loading}
style={{ borderRadius: radius }}
>
<Share2 className="h-4 w-4" />
<Share2 className="h-4 w-4" aria-hidden />
<div>
<div>{t('share.button', 'Teilen')}</div>
<div className="text-xs text-muted-foreground">{t('share.title', 'Geteiltes Foto')}</div>
<div className="text-xs text-slate-600 dark:text-white/70">{t('share.title', 'Geteiltes Foto')}</div>
</div>
</button>
<button
type="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"
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"
onClick={() => shareWhatsApp(shareSheet.url)}
disabled={shareSheet.loading}
style={{ borderRadius: radius }}
>
<WhatsAppIcon className="h-5 w-5" />
<div>
<div>{t('share.whatsapp', 'WhatsApp')}</div>
<div className="text-xs text-muted-foreground">{shareSheet.loading ? '…' : ''}</div>
<div className="text-xs text-white/80">{shareSheet.loading ? '…' : ''}</div>
</div>
</button>
<button
type="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"
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)}
disabled={shareSheet.loading}
style={{ borderRadius: radius }}
>
<MessageSquare className="h-5 w-5" />
<div>
<div>{t('share.imessage', 'Nachrichten')}</div>
<div className="text-xs text-muted-foreground">{shareSheet.loading ? '…' : ''}</div>
<div className="text-xs text-white/80">{shareSheet.loading ? '…' : ''}</div>
</div>
</button>
<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-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"
onClick={() => copyLink(shareSheet.url)}
disabled={shareSheet.loading}
style={{ borderRadius: radius }}
>
<Copy className="h-4 w-4" />
<Copy className="h-4 w-4" aria-hidden />
<div>
<div>{t('share.copyLink', 'Link kopieren')}</div>
<div className="text-xs text-muted-foreground">{shareSheet.loading ? t('share.loading', 'Lädt…') : ''}</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>
)}
</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 localeStorageKey = `guestLocale_event_${event.id ?? token}`;
const branding = mapEventBranding(event.branding);
const branding = mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null);
return (
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
@@ -139,7 +139,7 @@ function SetupLayout() {
if (!token) return null;
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
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 (
<GuestIdentityProvider eventKey={token}>
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>

View File

@@ -223,6 +223,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name('tenant.tasks.for-event');
Route::get('tasks/collection/{collection}', [TaskController::class, 'fromCollection'])
->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'])
->name('tenant.task-collections.index');

View File

@@ -72,5 +72,121 @@ class SyncGoogleFontsTest extends TestCase
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);
}
}