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:
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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'])],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
70
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
}
|
||||
|
||||
70
resources/js/admin/components/FloatingActionBar.tsx
Normal file
70
resources/js/admin/components/FloatingActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user