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\Filesystem\Filesystem;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class SyncGoogleFonts extends Command
|
class SyncGoogleFonts extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)}';
|
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download a single family by name (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--dry-run : Show what would be downloaded without writing files}';
|
||||||
|
|
||||||
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
||||||
|
|
||||||
@@ -32,13 +32,28 @@ class SyncGoogleFonts extends Command
|
|||||||
$weights = $this->prepareWeights($this->option('weights'));
|
$weights = $this->prepareWeights($this->option('weights'));
|
||||||
$includeItalic = (bool) $this->option('italic');
|
$includeItalic = (bool) $this->option('italic');
|
||||||
$force = (bool) $this->option('force');
|
$force = (bool) $this->option('force');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$familyOption = $this->normalizeFamilyOption($this->option('family'));
|
||||||
|
$categories = $this->prepareCategories($this->option('category'));
|
||||||
|
|
||||||
$pathOption = $this->option('path');
|
$pathOption = $this->option('path');
|
||||||
$basePath = $pathOption
|
$basePath = $pathOption
|
||||||
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
||||||
: public_path('fonts/google');
|
: public_path('fonts/google');
|
||||||
|
|
||||||
$this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
if ($familyOption) {
|
||||||
|
$this->info(sprintf('Fetching Google Font family "%s" (weights: %s, italic: %s)...', $familyOption, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
||||||
|
} else {
|
||||||
|
$this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($categories)) {
|
||||||
|
$this->line('Category filter: '.implode(', ', $categories));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('Dry run enabled: no files will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
$response = Http::retry(2, 200)
|
$response = Http::retry(2, 200)
|
||||||
->timeout(30)
|
->timeout(30)
|
||||||
@@ -60,10 +75,27 @@ class SyncGoogleFonts extends Command
|
|||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$selected = array_slice($items, 0, $count);
|
$items = $this->filterFonts($items, $familyOption, $categories);
|
||||||
|
|
||||||
|
if ($familyOption && ! count($items)) {
|
||||||
|
$this->error(sprintf('Font family "%s" was not found.', $familyOption));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! count($items)) {
|
||||||
|
$this->warn('No fonts matched the provided filters.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selected = $familyOption ? $items : array_slice($items, 0, $count);
|
||||||
$manifestFonts = [];
|
$manifestFonts = [];
|
||||||
$filesystem = new Filesystem();
|
$filesystem = new Filesystem;
|
||||||
File::ensureDirectoryExists($basePath);
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
File::ensureDirectoryExists($basePath);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($selected as $index => $font) {
|
foreach ($selected as $index => $font) {
|
||||||
if (! is_array($font) || ! isset($font['family'])) {
|
if (! is_array($font) || ! isset($font['family'])) {
|
||||||
@@ -73,11 +105,14 @@ class SyncGoogleFonts extends Command
|
|||||||
$family = (string) $font['family'];
|
$family = (string) $font['family'];
|
||||||
$slug = Str::slug($family);
|
$slug = Str::slug($family);
|
||||||
$familyDir = $basePath.DIRECTORY_SEPARATOR.$slug;
|
$familyDir = $basePath.DIRECTORY_SEPARATOR.$slug;
|
||||||
File::ensureDirectoryExists($familyDir);
|
if (! $dryRun) {
|
||||||
|
File::ensureDirectoryExists($familyDir);
|
||||||
|
}
|
||||||
|
|
||||||
$variantMap = $this->buildVariantMap($font, $weights, $includeItalic);
|
$variantMap = $this->buildVariantMap($font, $weights, $includeItalic);
|
||||||
if (! count($variantMap)) {
|
if (! count($variantMap)) {
|
||||||
$this->warn("Skipping {$family} (no matching variants)");
|
$this->warn("Skipping {$family} (no matching variants)");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +124,11 @@ class SyncGoogleFonts extends Command
|
|||||||
$filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension);
|
$filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension);
|
||||||
$targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename;
|
$targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename;
|
||||||
|
|
||||||
if (! $force && $filesystem->exists($targetPath)) {
|
$alreadyExists = $filesystem->exists($targetPath);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(sprintf('◦ DRY RUN: %s %s would %s (%s)', $family, $variantKey, $alreadyExists && ! $force ? 'reuse existing file' : 'download', $targetPath));
|
||||||
|
} elseif (! $force && $alreadyExists) {
|
||||||
$this->line("✔ {$family} {$variantKey} already exists");
|
$this->line("✔ {$family} {$variantKey} already exists");
|
||||||
} else {
|
} else {
|
||||||
$this->line("↓ Downloading {$family} {$variantKey}");
|
$this->line("↓ Downloading {$family} {$variantKey}");
|
||||||
@@ -97,6 +136,7 @@ class SyncGoogleFonts extends Command
|
|||||||
|
|
||||||
if (! $fileResponse->ok()) {
|
if (! $fileResponse->ok()) {
|
||||||
$this->warn(" Skipped {$family} {$variantKey} (download failed)");
|
$this->warn(" Skipped {$family} {$variantKey} (download failed)");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +164,12 @@ class SyncGoogleFonts extends Command
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info(sprintf('Dry run complete: %d font families would be synced to %s', count($manifestFonts), $basePath));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
$this->pruneStaleFamilies($basePath, $manifestFonts);
|
$this->pruneStaleFamilies($basePath, $manifestFonts);
|
||||||
$this->writeManifest($basePath, $manifestFonts);
|
$this->writeManifest($basePath, $manifestFonts);
|
||||||
$this->writeCss($basePath, $manifestFonts);
|
$this->writeCss($basePath, $manifestFonts);
|
||||||
@@ -134,6 +180,54 @@ class SyncGoogleFonts extends Command
|
|||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeFamilyOption(?string $family): ?string
|
||||||
|
{
|
||||||
|
$family = trim((string) $family);
|
||||||
|
|
||||||
|
return $family !== '' ? $family : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function prepareCategories(?string $categories): array
|
||||||
|
{
|
||||||
|
$parts = array_filter(array_map('trim', explode(',', (string) $categories)));
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map(static function ($category) {
|
||||||
|
$normalized = Str::of($category)->lower()->replace(' ', '-')->toString();
|
||||||
|
|
||||||
|
return (string) $normalized;
|
||||||
|
}, $parts)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $items
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
|
private function filterFonts(array $items, ?string $family, array $categories): array
|
||||||
|
{
|
||||||
|
$filtered = collect($items)
|
||||||
|
->filter(fn ($font) => is_array($font) && isset($font['family']))
|
||||||
|
->filter(function ($font) use ($categories) {
|
||||||
|
if (! count($categories)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = strtolower((string) ($font['category'] ?? ''));
|
||||||
|
|
||||||
|
return in_array($category, $categories, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($family) {
|
||||||
|
$filtered = $filtered->filter(function ($font) use ($family) {
|
||||||
|
return strcasecmp((string) $font['family'], $family) === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filtered->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, int>
|
* @return array<int, int>
|
||||||
*/
|
*/
|
||||||
@@ -147,7 +241,7 @@ class SyncGoogleFonts extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $font
|
* @param array<string, mixed> $font
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private function buildVariantMap(array $font, array $weights, bool $includeItalic): array
|
private function buildVariantMap(array $font, array $weights, bool $includeItalic): array
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Illuminate\Http\JsonResponse;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class EmotionController extends Controller
|
class EmotionController extends Controller
|
||||||
{
|
{
|
||||||
@@ -21,8 +22,15 @@ class EmotionController extends Controller
|
|||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
$query = Emotion::query()
|
$query = Emotion::query()
|
||||||
->whereNull('tenant_id')
|
->when(true, function ($builder) use ($tenantId) {
|
||||||
->orWhere('tenant_id', $tenantId)
|
// Prefer tenant-specific and global emotions if the column exists
|
||||||
|
if (Schema::hasColumn('emotions', 'tenant_id')) {
|
||||||
|
$builder->where(function ($inner) use ($tenantId) {
|
||||||
|
$inner->whereNull('tenant_id')
|
||||||
|
->orWhere('tenant_id', $tenantId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
->with('eventTypes');
|
->with('eventTypes');
|
||||||
|
|
||||||
if ($request->boolean('only_tenant')) {
|
if ($request->boolean('only_tenant')) {
|
||||||
@@ -35,7 +43,18 @@ class EmotionController extends Controller
|
|||||||
|
|
||||||
$query->orderByRaw('tenant_id is null desc')->orderBy('sort_order')->orderBy('id');
|
$query->orderByRaw('tenant_id is null desc')->orderBy('sort_order')->orderBy('id');
|
||||||
|
|
||||||
$emotions = $query->paginate($request->integer('per_page', 25));
|
$emotions = $query->paginate($request->integer('per_page', 50));
|
||||||
|
|
||||||
|
if ($emotions->isEmpty() && ! $request->boolean('only_tenant')) {
|
||||||
|
// Fallback: return any emotions regardless of tenant to avoid empty selectors
|
||||||
|
$fallback = Emotion::query()
|
||||||
|
->with('eventTypes')
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate($request->integer('per_page', 50));
|
||||||
|
|
||||||
|
return EmotionResource::collection($fallback);
|
||||||
|
}
|
||||||
|
|
||||||
return EmotionResource::collection($emotions);
|
return EmotionResource::collection($emotions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class TaskCollectionController extends Controller
|
|||||||
$query = TaskCollection::query()
|
$query = TaskCollection::query()
|
||||||
->forTenant($tenantId)
|
->forTenant($tenantId)
|
||||||
->with('eventType')
|
->with('eventType')
|
||||||
->withCount('tasks')
|
->withCount(['tasks', 'events'])
|
||||||
->orderBy('position')
|
->orderBy('position')
|
||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
|
|
||||||
@@ -46,6 +46,27 @@ class TaskCollectionController extends Controller
|
|||||||
$query->where('tenant_id', $tenantId);
|
$query->where('tenant_id', $tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('top_picks')) {
|
||||||
|
if ($eventTypeSlug = $request->query('event_type')) {
|
||||||
|
$query->where(function ($inner) use ($eventTypeSlug) {
|
||||||
|
$inner->whereNull('event_type_id')
|
||||||
|
->orWhereHas('eventType', fn ($q) => $q->where('slug', $eventTypeSlug));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereHas('tasks')
|
||||||
|
->orderByDesc('events_count')
|
||||||
|
->orderByDesc('updated_at')
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('id');
|
||||||
|
|
||||||
|
$limit = $request->integer('limit', 3);
|
||||||
|
|
||||||
|
return TaskCollectionResource::collection(
|
||||||
|
$query->limit($limit)->get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$perPage = $request->integer('per_page', 15);
|
$perPage = $request->integer('per_page', 15);
|
||||||
|
|
||||||
return TaskCollectionResource::collection(
|
return TaskCollectionResource::collection(
|
||||||
@@ -57,7 +78,8 @@ class TaskCollectionController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeAccess($request, $collection);
|
$this->authorizeAccess($request, $collection);
|
||||||
|
|
||||||
$collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')]);
|
$collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')])
|
||||||
|
->loadCount(['tasks', 'events']);
|
||||||
|
|
||||||
return response()->json(new TaskCollectionResource($collection));
|
return response()->json(new TaskCollectionResource($collection));
|
||||||
}
|
}
|
||||||
@@ -81,7 +103,11 @@ class TaskCollectionController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => __('Task-Collection erfolgreich importiert.'),
|
'message' => __('Task-Collection erfolgreich importiert.'),
|
||||||
'collection' => new TaskCollectionResource($result['collection']->load('eventType')->loadCount('tasks')),
|
'collection' => new TaskCollectionResource(
|
||||||
|
$result['collection']
|
||||||
|
->load('eventType')
|
||||||
|
->loadCount(['tasks', 'events'])
|
||||||
|
),
|
||||||
'created_task_ids' => $result['created_task_ids'],
|
'created_task_ids' => $result['created_task_ids'],
|
||||||
'attached_task_ids' => $result['attached_task_ids'],
|
'attached_task_ids' => $result['attached_task_ids'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class TaskController extends Controller
|
|||||||
$inner->whereNull('tenant_id')
|
$inner->whereNull('tenant_id')
|
||||||
->orWhere('tenant_id', $tenantId);
|
->orWhere('tenant_id', $tenantId);
|
||||||
})
|
})
|
||||||
->with(['taskCollection', 'assignedEvents', 'eventType'])
|
->with(['taskCollection', 'assignedEvents', 'eventType', 'emotion'])
|
||||||
->orderByRaw('tenant_id is null desc')
|
->orderByRaw('tenant_id is null desc')
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
@@ -80,7 +80,7 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
$task = Task::create($payload);
|
$task = Task::create($payload);
|
||||||
|
|
||||||
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
|
$task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Task erfolgreich erstellt.',
|
'message' => 'Task erfolgreich erstellt.',
|
||||||
@@ -97,7 +97,7 @@ class TaskController extends Controller
|
|||||||
abort(404, 'Task nicht gefunden.');
|
abort(404, 'Task nicht gefunden.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
|
$task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
|
||||||
|
|
||||||
return response()->json(new TaskResource($task));
|
return response()->json(new TaskResource($task));
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ class TaskController extends Controller
|
|||||||
{
|
{
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($task->tenant_id !== $tenantId || $event->tenant_id !== $tenantId) {
|
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +193,9 @@ class TaskController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tasks = Task::whereIn('id', $taskIds)
|
$tasks = Task::whereIn('id', $taskIds)
|
||||||
->where('tenant_id', $tenantId)
|
->where(function ($query) use ($tenantId) {
|
||||||
|
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
|
||||||
|
})
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$attached = 0;
|
$attached = 0;
|
||||||
@@ -219,13 +221,63 @@ class TaskController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
|
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
|
||||||
->with(['taskCollection', 'eventType'])
|
->with(['taskCollection', 'eventType', 'emotion'])
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('tasks.id')
|
||||||
->paginate($request->get('per_page', 15));
|
->paginate($request->get('per_page', 15));
|
||||||
|
|
||||||
return TaskResource::collection($tasks);
|
return TaskResource::collection($tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
|
if ($event->tenant_id !== $tenantId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskIds = $request->input('task_ids', []);
|
||||||
|
|
||||||
|
if (empty($taskIds)) {
|
||||||
|
return ApiError::response(
|
||||||
|
'task_ids_missing',
|
||||||
|
'Keine Aufgaben angegeben',
|
||||||
|
'Bitte wähle mindestens eine Aufgabe aus.',
|
||||||
|
Response::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$detached = $event->tasks()->whereIn('tasks.id', $taskIds)->detach();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => "{$detached} Tasks vom Event entfernt.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
|
if ($event->tenant_id !== $tenantId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskIds = $request->input('task_ids', []);
|
||||||
|
|
||||||
|
if (empty($taskIds) || ! is_array($taskIds)) {
|
||||||
|
return ApiError::response(
|
||||||
|
'task_ids_missing',
|
||||||
|
'Keine Aufgaben angegeben',
|
||||||
|
'Bitte wähle mindestens eine Aufgabe aus.',
|
||||||
|
Response::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Reihenfolge gespeichert.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tasks from a specific collection.
|
* Get tasks from a specific collection.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class EventStoreRequest extends FormRequest
|
|||||||
'features' => ['nullable', 'array'],
|
'features' => ['nullable', 'array'],
|
||||||
'features.*' => ['string'],
|
'features.*' => ['string'],
|
||||||
'settings' => ['nullable', 'array'],
|
'settings' => ['nullable', 'array'],
|
||||||
|
'settings.branding' => ['nullable', 'array'],
|
||||||
|
'settings.branding.*' => ['nullable'],
|
||||||
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class EventResource extends JsonResource
|
|||||||
'is_active' => (bool) ($this->is_active ?? false),
|
'is_active' => (bool) ($this->is_active ?? false),
|
||||||
'features' => $settings['features'] ?? [],
|
'features' => $settings['features'] ?? [],
|
||||||
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
|
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
|
||||||
|
'branding' => $settings['branding'] ?? null,
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
'event_type_id' => $this->event_type_id,
|
'event_type_id' => $this->event_type_id,
|
||||||
'event_type' => $this->whenLoaded('eventType', function () {
|
'event_type' => $this->whenLoaded('eventType', function () {
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ class TaskCollectionResource extends JsonResource
|
|||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
'tasks_count' => $this->whenCounted('tasks'),
|
'tasks_count' => $this->whenCounted('tasks'),
|
||||||
|
'events_count' => $this->whenCounted('events'),
|
||||||
|
'imports_count' => $this->whenCounted('events'),
|
||||||
'is_default' => (bool) ($this->is_default ?? false),
|
'is_default' => (bool) ($this->is_default ?? false),
|
||||||
|
'is_mine' => $this->tenant_id !== null,
|
||||||
'position' => $this->position,
|
'position' => $this->position,
|
||||||
'source_collection_id' => $this->source_collection_id,
|
'source_collection_id' => $this->source_collection_id,
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
|||||||
@@ -41,9 +41,21 @@ class TaskResource extends JsonResource
|
|||||||
'eventType',
|
'eventType',
|
||||||
fn () => new EventTypeResource($this->eventType)
|
fn () => new EventTypeResource($this->eventType)
|
||||||
),
|
),
|
||||||
|
'emotion_id' => $this->emotion_id,
|
||||||
|
'emotion' => $this->whenLoaded(
|
||||||
|
'emotion',
|
||||||
|
fn () => [
|
||||||
|
'id' => $this->emotion->id,
|
||||||
|
'name' => $this->translatedText($this->normalizeTranslations($this->emotion->name), ''),
|
||||||
|
'name_translations' => $this->emotion->name,
|
||||||
|
'icon' => $this->emotion->icon,
|
||||||
|
'color' => $this->emotion->color,
|
||||||
|
]
|
||||||
|
),
|
||||||
'collection_id' => $this->collection_id,
|
'collection_id' => $this->collection_id,
|
||||||
'source_task_id' => $this->source_task_id,
|
'source_task_id' => $this->source_task_id,
|
||||||
'source_collection_id' => $this->source_collection_id,
|
'source_collection_id' => $this->source_collection_id,
|
||||||
|
'sort_order' => $this->pivot?->sort_order,
|
||||||
'assigned_events_count' => $assignedEventsCount,
|
'assigned_events_count' => $assignedEventsCount,
|
||||||
'assigned_events' => $this->whenLoaded(
|
'assigned_events' => $this->whenLoaded(
|
||||||
'assignedEvents',
|
'assignedEvents',
|
||||||
@@ -86,7 +98,7 @@ class TaskResource extends JsonResource
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $translations
|
* @param array<string, string> $translations
|
||||||
*/
|
*/
|
||||||
protected function translatedText(array $translations, string $fallback): string
|
protected function translatedText(array $translations, string $fallback): string
|
||||||
{
|
{
|
||||||
@@ -109,4 +121,3 @@ class TaskResource extends JsonResource
|
|||||||
return $first !== false ? $first : $fallback;
|
return $first !== false ? $first : $fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class Event extends Model
|
|||||||
public function tasks(): BelongsToMany
|
public function tasks(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id')
|
return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id')
|
||||||
|
->withPivot(['sort_order'])
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class Task extends Model
|
|||||||
public function assignedEvents(): BelongsToMany
|
public function assignedEvents(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id')
|
return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id')
|
||||||
|
->withPivot(['sort_order'])
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Support;
|
|||||||
|
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
class WatermarkConfigResolver
|
class WatermarkConfigResolver
|
||||||
{
|
{
|
||||||
@@ -18,7 +17,12 @@ class WatermarkConfigResolver
|
|||||||
$package = $event->eventPackages->first()?->package;
|
$package = $event->eventPackages->first()?->package;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $package?->branding_allowed === true;
|
// If no package is attached, default to allowing branding to avoid silently stripping event/tenant branding.
|
||||||
|
if (! $package) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $package->branding_allowed !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function determinePolicy(Event $event): string
|
public static function determinePolicy(Event $event): string
|
||||||
|
|||||||
@@ -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": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@inertiajs/react": "^2.1.0",
|
"@inertiajs/react": "^2.1.0",
|
||||||
"@jpisnice/shadcn-ui-mcp-server": "^1.1.4",
|
"@jpisnice/shadcn-ui-mcp-server": "^1.1.4",
|
||||||
@@ -778,6 +781,73 @@
|
|||||||
"kuler": "^2.0.0"
|
"kuler": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/modifiers": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.51.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz",
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
"vitest": "^2.1.5"
|
"vitest": "^2.1.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@inertiajs/react": "^2.1.0",
|
"@inertiajs/react": "^2.1.0",
|
||||||
"@jpisnice/shadcn-ui-mcp-server": "^1.1.4",
|
"@jpisnice/shadcn-ui-mcp-server": "^1.1.4",
|
||||||
|
|||||||
@@ -433,10 +433,19 @@ export type TenantTask = {
|
|||||||
is_completed: boolean;
|
is_completed: boolean;
|
||||||
event_type_id: number | null;
|
event_type_id: number | null;
|
||||||
event_type?: TenantEventType | null;
|
event_type?: TenantEventType | null;
|
||||||
|
emotion_id?: number | null;
|
||||||
|
emotion?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
name_translations: Record<string, string>;
|
||||||
|
icon: string | null;
|
||||||
|
color: string | null;
|
||||||
|
} | null;
|
||||||
tenant_id: number | null;
|
tenant_id: number | null;
|
||||||
collection_id: number | null;
|
collection_id: number | null;
|
||||||
source_task_id: number | null;
|
source_task_id: number | null;
|
||||||
source_collection_id: number | null;
|
source_collection_id: number | null;
|
||||||
|
sort_order?: number | null;
|
||||||
assigned_events_count: number;
|
assigned_events_count: number;
|
||||||
assigned_events?: TenantEvent[];
|
assigned_events?: TenantEvent[];
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
@@ -452,6 +461,7 @@ export type TenantTaskCollection = {
|
|||||||
description_translations: Record<string, string | null>;
|
description_translations: Record<string, string | null>;
|
||||||
tenant_id: number | null;
|
tenant_id: number | null;
|
||||||
is_global: boolean;
|
is_global: boolean;
|
||||||
|
is_mine?: boolean;
|
||||||
event_type?: {
|
event_type?: {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -460,6 +470,8 @@ export type TenantTaskCollection = {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
tasks_count: number;
|
tasks_count: number;
|
||||||
|
events_count?: number;
|
||||||
|
imports_count?: number;
|
||||||
position: number | null;
|
position: number | null;
|
||||||
source_collection_id: number | null;
|
source_collection_id: number | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
@@ -951,6 +963,7 @@ function normalizeTask(task: JsonValue): TenantTask {
|
|||||||
typeof task.event_type_id === 'number'
|
typeof task.event_type_id === 'number'
|
||||||
? Number(task.event_type_id)
|
? Number(task.event_type_id)
|
||||||
: eventType?.id ?? null;
|
: eventType?.id ?? null;
|
||||||
|
const emotionRaw = task.emotion ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(task.id ?? 0),
|
id: Number(task.id ?? 0),
|
||||||
@@ -969,6 +982,25 @@ function normalizeTask(task: JsonValue): TenantTask {
|
|||||||
is_completed: Boolean(task.is_completed ?? false),
|
is_completed: Boolean(task.is_completed ?? false),
|
||||||
event_type_id: eventTypeId,
|
event_type_id: eventTypeId,
|
||||||
event_type: eventType,
|
event_type: eventType,
|
||||||
|
sort_order:
|
||||||
|
typeof task.sort_order === 'number'
|
||||||
|
? Number(task.sort_order)
|
||||||
|
: task.pivot && typeof (task.pivot as { sort_order?: unknown }).sort_order === 'number'
|
||||||
|
? Number((task.pivot as { sort_order?: number }).sort_order)
|
||||||
|
: null,
|
||||||
|
emotion_id: typeof task.emotion_id === 'number' ? Number(task.emotion_id) : null,
|
||||||
|
emotion: emotionRaw
|
||||||
|
? {
|
||||||
|
id: Number(emotionRaw.id ?? 0),
|
||||||
|
name: pickTranslatedText(
|
||||||
|
normalizeTranslationMap(emotionRaw.name_translations ?? emotionRaw.name ?? {}),
|
||||||
|
String(emotionRaw.name ?? '')
|
||||||
|
),
|
||||||
|
name_translations: normalizeTranslationMap(emotionRaw.name_translations ?? emotionRaw.name ?? {}),
|
||||||
|
icon: emotionRaw.icon ?? null,
|
||||||
|
color: emotionRaw.color ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
tenant_id: task.tenant_id ?? null,
|
tenant_id: task.tenant_id ?? null,
|
||||||
collection_id: task.collection_id ?? null,
|
collection_id: task.collection_id ?? null,
|
||||||
source_task_id: task.source_task_id ?? null,
|
source_task_id: task.source_task_id ?? null,
|
||||||
@@ -1010,8 +1042,11 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
|
|||||||
description_translations: descriptionTranslations ?? {},
|
description_translations: descriptionTranslations ?? {},
|
||||||
tenant_id: raw.tenant_id ?? null,
|
tenant_id: raw.tenant_id ?? null,
|
||||||
is_global: !raw.tenant_id,
|
is_global: !raw.tenant_id,
|
||||||
|
is_mine: Boolean(raw.tenant_id),
|
||||||
event_type: eventType,
|
event_type: eventType,
|
||||||
tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0),
|
tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0),
|
||||||
|
events_count: raw.events_count !== undefined ? Number(raw.events_count) : undefined,
|
||||||
|
imports_count: raw.imports_count !== undefined ? Number(raw.imports_count) : undefined,
|
||||||
position: raw.position !== undefined ? Number(raw.position) : null,
|
position: raw.position !== undefined ? Number(raw.position) : null,
|
||||||
source_collection_id: raw.source_collection_id ?? null,
|
source_collection_id: raw.source_collection_id ?? null,
|
||||||
created_at: raw.created_at ?? null,
|
created_at: raw.created_at ?? null,
|
||||||
@@ -1020,7 +1055,7 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEmotion(raw: JsonValue): TenantEmotion {
|
function normalizeEmotion(raw: JsonValue): TenantEmotion {
|
||||||
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
|
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}, undefined, true);
|
||||||
const descriptionTranslations = normalizeTranslationMap(
|
const descriptionTranslations = normalizeTranslationMap(
|
||||||
raw.description_translations ?? raw.description ?? {},
|
raw.description_translations ?? raw.description ?? {},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1037,11 +1072,11 @@ function normalizeEmotion(raw: JsonValue): TenantEmotion {
|
|||||||
name_translations: nameTranslations,
|
name_translations: nameTranslations,
|
||||||
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
|
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
|
||||||
description_translations: descriptionTranslations ?? {},
|
description_translations: descriptionTranslations ?? {},
|
||||||
icon: String(raw.icon ?? 'lucide-smile'),
|
icon: typeof raw.icon === 'string' ? raw.icon : 'lucide-smile',
|
||||||
color: String(raw.color ?? '#6366f1'),
|
color: String(raw.color ?? '#6366f1'),
|
||||||
sort_order: Number(raw.sort_order ?? 0),
|
sort_order: Number(raw.sort_order ?? 0),
|
||||||
is_active: Boolean(raw.is_active ?? true),
|
is_active: Boolean(raw.is_active ?? true),
|
||||||
is_global: !raw.tenant_id,
|
is_global: raw.tenant_id === null || raw.tenant_id === undefined,
|
||||||
tenant_id: raw.tenant_id ?? null,
|
tenant_id: raw.tenant_id ?? null,
|
||||||
event_types: (eventTypes as JsonValue[]).map((eventType) => {
|
event_types: (eventTypes as JsonValue[]).map((eventType) => {
|
||||||
const translations = normalizeTranslationMap(eventType.name ?? {});
|
const translations = normalizeTranslationMap(eventType.name ?? {});
|
||||||
@@ -2086,6 +2121,8 @@ export async function getTaskCollections(params: {
|
|||||||
search?: string;
|
search?: string;
|
||||||
event_type?: string;
|
event_type?: string;
|
||||||
scope?: 'global' | 'tenant';
|
scope?: 'global' | 'tenant';
|
||||||
|
top_picks?: boolean;
|
||||||
|
limit?: number;
|
||||||
} = {}): Promise<PaginatedResult<TenantTaskCollection>> {
|
} = {}): Promise<PaginatedResult<TenantTaskCollection>> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params.page) searchParams.set('page', String(params.page));
|
if (params.page) searchParams.set('page', String(params.page));
|
||||||
@@ -2093,6 +2130,8 @@ export async function getTaskCollections(params: {
|
|||||||
if (params.search) searchParams.set('search', params.search);
|
if (params.search) searchParams.set('search', params.search);
|
||||||
if (params.event_type) searchParams.set('event_type', params.event_type);
|
if (params.event_type) searchParams.set('event_type', params.event_type);
|
||||||
if (params.scope) searchParams.set('scope', params.scope);
|
if (params.scope) searchParams.set('scope', params.scope);
|
||||||
|
if (params.top_picks) searchParams.set('top_picks', '1');
|
||||||
|
if (params.limit) searchParams.set('limit', String(params.limit));
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
const response = await authorizedFetch(
|
const response = await authorizedFetch(
|
||||||
@@ -2142,6 +2181,34 @@ export async function importTaskCollection(
|
|||||||
throw new Error('Missing collection payload');
|
throw new Error('Missing collection payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function detachTasksFromEvent(eventId: number, taskIds: number[]): Promise<void> {
|
||||||
|
const response = await authorizedFetch(`/api/v1/tenant/tasks/bulk-detach-event/${eventId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ task_ids: taskIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await safeJson(response);
|
||||||
|
console.error('[API] Failed to detach tasks', response.status, payload);
|
||||||
|
throw new Error('Failed to detach tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderEventTasks(eventId: number, taskIds: number[]): Promise<void> {
|
||||||
|
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}/reorder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ task_ids: taskIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await safeJson(response);
|
||||||
|
console.error('[API] Failed to reorder tasks', response.status, payload);
|
||||||
|
throw new Error('Failed to reorder tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEmotions(): Promise<TenantEmotion[]> {
|
export async function getEmotions(): Promise<TenantEmotion[]> {
|
||||||
const response = await authorizedFetch('/api/v1/tenant/emotions');
|
const response = await authorizedFetch('/api/v1/tenant/emotions');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -2176,6 +2243,17 @@ export async function updateEmotion(emotionId: number, payload: EmotionPayload):
|
|||||||
return normalizeEmotion(json.data);
|
return normalizeEmotion(json.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteEmotion(emotionId: number): Promise<void> {
|
||||||
|
const response = await authorizedFetch(`/api/v1/tenant/emotions/${emotionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await safeJson(response);
|
||||||
|
console.error('[API] Failed to delete emotion', response.status, payload);
|
||||||
|
throw new Error('Failed to delete emotion');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise<PaginatedResult<TenantTask>> {
|
export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise<PaginatedResult<TenantTask>> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params.page) searchParams.set('page', String(params.page));
|
if (params.page) searchParams.set('page', String(params.page));
|
||||||
@@ -2240,8 +2318,12 @@ export async function assignTasksToEvent(eventId: number, taskIds: number[]): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventTasks(eventId: number, page = 1): Promise<PaginatedResult<TenantTask>> {
|
export async function getEventTasks(
|
||||||
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}`);
|
eventId: number,
|
||||||
|
page = 1,
|
||||||
|
perPage = 500,
|
||||||
|
): Promise<PaginatedResult<TenantTask>> {
|
||||||
|
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}&per_page=${perPage}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await safeJson(response);
|
const payload = await safeJson(response);
|
||||||
console.error('[API] Failed to load event tasks', response.status, payload);
|
console.error('[API] Failed to load event tasks', response.status, payload);
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export function CommandShelf() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'invites',
|
key: 'invites',
|
||||||
label: t('commandShelf.actions.invites.label', 'QR & Einladungen'),
|
label: t('commandShelf.actions.invites.label', 'QR-Codes'),
|
||||||
description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'),
|
description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'),
|
||||||
icon: QrCode,
|
icon: QrCode,
|
||||||
href: ADMIN_EVENT_INVITES_PATH(slug),
|
href: ADMIN_EVENT_INVITES_PATH(slug),
|
||||||
@@ -220,7 +220,7 @@ export function CommandShelf() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'invites',
|
key: 'invites',
|
||||||
label: t('commandShelf.metrics.invites', 'Einladungen'),
|
label: t('commandShelf.metrics.invites', 'QR-Codes'),
|
||||||
value: activeEvent.active_invites_count ?? activeEvent.total_invites_count,
|
value: activeEvent.active_invites_count ?? activeEvent.total_invites_count,
|
||||||
hint: t('commandShelf.metrics.invitesHint', 'live'),
|
hint: t('commandShelf.metrics.invitesHint', 'live'),
|
||||||
},
|
},
|
||||||
@@ -373,7 +373,7 @@ export function CommandShelf() {
|
|||||||
{t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')}
|
{t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und Einladungen an einem Ort.')}
|
{t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und QR-Codes an einem Ort.')}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex flex-wrap gap-2 px-4 text-xs text-slate-500 dark:text-slate-300">
|
<div className="flex flex-wrap gap-2 px-4 text-xs text-slate-500 dark:text-slate-300">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']
|
|||||||
{ key: 'photobooth', label: t('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) },
|
{ key: 'photobooth', label: t('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) },
|
||||||
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
|
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
|
||||||
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
|
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
|
||||||
{ key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) },
|
{ key: 'invites', label: t('eventMenu.invites', 'QR-Codes'), href: ADMIN_EVENT_INVITES_PATH(slug) },
|
||||||
{ key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) },
|
{ key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
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',
|
key: 'invites',
|
||||||
label: t('stats.invites', 'Einladungen live'),
|
label: t('stats.invites', 'QR-Codes live'),
|
||||||
value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(),
|
value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -110,7 +110,7 @@ export function DashboardEventFocusCard({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'invites',
|
key: 'invites',
|
||||||
label: t('actions.invites', 'QR & Einladungen'),
|
label: t('actions.invites', 'QR-Codes'),
|
||||||
description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'),
|
description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'),
|
||||||
icon: QrCode,
|
icon: QrCode,
|
||||||
handler: onOpenInvites,
|
handler: onOpenInvites,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen – mobil wie auf dem Desktop.",
|
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen – mobil wie auf dem Desktop.",
|
||||||
"features": [
|
"features": [
|
||||||
"Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.",
|
"Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.",
|
||||||
"Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.",
|
"Erstelle Zugangs-QR-Codes und teile sie sofort.",
|
||||||
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
|
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
|
||||||
],
|
],
|
||||||
"lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.",
|
"lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"photos": "Uploads",
|
"photos": "Uploads",
|
||||||
"guests": "Team & Gäste",
|
"guests": "Team & Gäste",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"invites": "Einladungen",
|
"invites": "QR-Codes",
|
||||||
"toolkit": "Toolkit",
|
"toolkit": "Toolkit",
|
||||||
"recap": "Nachbereitung"
|
"recap": "Nachbereitung"
|
||||||
},
|
},
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
"mobile": {
|
"mobile": {
|
||||||
"openActions": "Schnellaktionen öffnen",
|
"openActions": "Schnellaktionen öffnen",
|
||||||
"sheetTitle": "Schnellaktionen",
|
"sheetTitle": "Schnellaktionen",
|
||||||
"sheetDescription": "Moderation, Aufgaben und Einladungen an einem Ort.",
|
"sheetDescription": "Moderation, Aufgaben und QR-Codes an einem Ort.",
|
||||||
"tip": "Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.",
|
"tip": "Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.",
|
||||||
"tipCta": "Verstanden"
|
"tipCta": "Verstanden"
|
||||||
},
|
},
|
||||||
@@ -103,8 +103,8 @@
|
|||||||
"welcome": {
|
"welcome": {
|
||||||
"eyebrow": "Event Admin",
|
"eyebrow": "Event Admin",
|
||||||
"title": "Event-Branding, Aufgaben & Foto-Moderation in einer App.",
|
"title": "Event-Branding, Aufgaben & Foto-Moderation in einer App.",
|
||||||
"subtitle": "Bereite dein Event vor, teile Einladungen, moderiere Uploads live und gib die Galerie danach frei.",
|
"subtitle": "Bereite dein Event vor, teile QR-Codes, moderiere Uploads live und gib die Galerie danach frei.",
|
||||||
"badge": "Fotos, Aufgaben & Einladungen an einem Ort",
|
"badge": "Fotos, Aufgaben & QR-Codes an einem Ort",
|
||||||
"loginPrompt": "Bereits Kunde? Login oben rechts.",
|
"loginPrompt": "Bereits Kunde? Login oben rechts.",
|
||||||
"cta": {
|
"cta": {
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
"subtitle": "Alles an einem Ort",
|
"subtitle": "Alles an einem Ort",
|
||||||
"branding": {
|
"branding": {
|
||||||
"title": "Branding & Layout",
|
"title": "Branding & Layout",
|
||||||
"description": "Farben, Schriften, QR-Layouts und Einladungen in einem Fluss."
|
"description": "Farben, Schriften, QR-Layouts und QR-Zugänge in einem Fluss."
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Aufgaben & Emotion-Sets",
|
"title": "Aufgaben & Emotion-Sets",
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen."
|
"description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen."
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
"title": "Einladungen & QR",
|
"title": "QR-Codes & Layouts",
|
||||||
"description": "Links und Druckvorlagen generieren – mit Paketlimits im Blick."
|
"description": "Links und Druckvorlagen generieren – mit Paketlimits im Blick."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
"accent": "Setup"
|
"accent": "Setup"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"title": "Teilen & Einladen",
|
"title": "Teilen & QR-Codes",
|
||||||
"description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.",
|
"description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.",
|
||||||
"accent": "Share"
|
"accent": "Share"
|
||||||
},
|
},
|
||||||
@@ -159,12 +159,12 @@
|
|||||||
"plans": {
|
"plans": {
|
||||||
"title": "Pakete im Überblick",
|
"title": "Pakete im Überblick",
|
||||||
"subtitle": "Wähle das passende Kontingent",
|
"subtitle": "Wähle das passende Kontingent",
|
||||||
"hint": "Starter, Standard oder Reseller – alles mit Moderation & Einladungen.",
|
"hint": "Starter, Standard oder Reseller – alles mit Moderation & QR-Codes.",
|
||||||
"starter": {
|
"starter": {
|
||||||
"title": "Starter",
|
"title": "Starter",
|
||||||
"badge": "Für ein Event",
|
"badge": "Für ein Event",
|
||||||
"p1": "1 Event, Basis-Branding",
|
"p1": "1 Event, Basis-Branding",
|
||||||
"p2": "Aufgaben & Einladungen inklusive",
|
"p2": "Aufgaben & QR-Codes inklusive",
|
||||||
"p3": "Moderation & Galerie-Link"
|
"p3": "Moderation & Galerie-Link"
|
||||||
},
|
},
|
||||||
"standard": {
|
"standard": {
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
"preview": {
|
"preview": {
|
||||||
"title": "Was dich erwartet",
|
"title": "Was dich erwartet",
|
||||||
"items": [
|
"items": [
|
||||||
"Moderation, Aufgaben und Einladungen als Schnellzugriff",
|
"Moderation, Aufgaben und QR-Codes als Schnellzugriff",
|
||||||
"Sticky Actions auf Mobile für den Eventtag",
|
"Sticky Actions auf Mobile für den Eventtag",
|
||||||
"Paket-Status & Limits jederzeit sichtbar"
|
"Paket-Status & Limits jederzeit sichtbar"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
"noDate": "Kein Datum",
|
"noDate": "Kein Datum",
|
||||||
"actions": {
|
"actions": {
|
||||||
"photos": "Uploads",
|
"photos": "Uploads",
|
||||||
"invites": "QR & Einladungen",
|
"invites": "QR-Codes",
|
||||||
"tasks": "Aufgaben"
|
"tasks": "Aufgaben"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -62,8 +62,8 @@
|
|||||||
"hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben."
|
"hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben."
|
||||||
},
|
},
|
||||||
"qr": {
|
"qr": {
|
||||||
"title": "QR-Einladung erstellt",
|
"title": "QR-Code erstellt",
|
||||||
"hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter."
|
"hint": "Erstelle einen QR-Code und lade die Drucklayouts herunter."
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"title": "Paket aktiv",
|
"title": "Paket aktiv",
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"createEvent": "Event erstellen",
|
"createEvent": "Event erstellen",
|
||||||
"openTasks": "Tasks öffnen",
|
"openTasks": "Tasks öffnen",
|
||||||
"openQr": "QR-Einladungen",
|
"openQr": "QR-Codes",
|
||||||
"openPackages": "Pakete ansehen"
|
"openPackages": "Pakete ansehen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"question": "Wie arbeite ich mit Events?",
|
"question": "Wie arbeite ich mit Events?",
|
||||||
"answer": "Wähle dein aktives Event, passe Aufgaben an und teile Einladungen. Ausführliche Dokumentation folgt."
|
"answer": "Wähle dein aktives Event, passe Aufgaben an und teile QR-Codes. Ausführliche Dokumentation folgt."
|
||||||
},
|
},
|
||||||
"uploads": {
|
"uploads": {
|
||||||
"question": "Wie moderiere ich Uploads?",
|
"question": "Wie moderiere ich Uploads?",
|
||||||
|
|||||||
@@ -232,7 +232,7 @@
|
|||||||
"missingSlug": "Kein Event-Slug angegeben.",
|
"missingSlug": "Kein Event-Slug angegeben.",
|
||||||
"load": "Mitglieder konnten nicht geladen werden.",
|
"load": "Mitglieder konnten nicht geladen werden.",
|
||||||
"emailRequired": "Bitte gib eine E-Mail-Adresse ein.",
|
"emailRequired": "Bitte gib eine E-Mail-Adresse ein.",
|
||||||
"invite": "Einladung konnte nicht verschickt werden.",
|
"invite": "QR-Code konnte nicht verschickt werden.",
|
||||||
"remove": "Mitglied konnte nicht entfernt werden."
|
"remove": "Mitglied konnte nicht entfernt werden."
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
"namePlaceholder": "Name",
|
"namePlaceholder": "Name",
|
||||||
"roleLabel": "Rolle",
|
"roleLabel": "Rolle",
|
||||||
"rolePlaceholder": "Rolle wählen",
|
"rolePlaceholder": "Rolle wählen",
|
||||||
"submit": "Einladung senden"
|
"submit": "QR-Code senden"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"tenantAdmin": "Kunden-Admin",
|
"tenantAdmin": "Kunden-Admin",
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
"summary": "Übersicht",
|
"summary": "Übersicht",
|
||||||
"photos": "Uploads",
|
"photos": "Uploads",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"invites": "Einladungen",
|
"invites": "QR-Codes",
|
||||||
"branding": "Branding",
|
"branding": "Branding",
|
||||||
"photobooth": "Photobooth",
|
"photobooth": "Photobooth",
|
||||||
"recap": "Nachbereitung"
|
"recap": "Nachbereitung"
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
},
|
},
|
||||||
"toolkit": {
|
"toolkit": {
|
||||||
"titleFallback": "Event-Day Toolkit",
|
"titleFallback": "Event-Day Toolkit",
|
||||||
"subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.",
|
"subtitle": "Behalte Uploads, Aufgaben und QR-Codes am Eventtag im Blick.",
|
||||||
"errors": {
|
"errors": {
|
||||||
"missingSlug": "Kein Event-Slug angegeben.",
|
"missingSlug": "Kein Event-Slug angegeben.",
|
||||||
"loadFailed": "Toolkit konnte nicht geladen werden.",
|
"loadFailed": "Toolkit konnte nicht geladen werden.",
|
||||||
@@ -388,14 +388,14 @@
|
|||||||
"errorTitle": "Fehler",
|
"errorTitle": "Fehler",
|
||||||
"attention": "Achtung",
|
"attention": "Achtung",
|
||||||
"noTasks": "Noch keine Aufgaben zugewiesen – aktiviere ein Paket oder lege Aufgaben fest.",
|
"noTasks": "Noch keine Aufgaben zugewiesen – aktiviere ein Paket oder lege Aufgaben fest.",
|
||||||
"noInvites": "Es gibt keine aktiven QR-Einladungen. Erstelle eine Einladung, um Gäste in die App zu holen.",
|
"noInvites": "Es gibt keine aktiven QR-Codes. Erstelle eine QR-Code, um Gäste in die App zu holen.",
|
||||||
"pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen."
|
"pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen."
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"uploadsTotal": "Uploads gesamt",
|
"uploadsTotal": "Uploads gesamt",
|
||||||
"uploads24h": "Uploads (24h)",
|
"uploads24h": "Uploads (24h)",
|
||||||
"pendingPhotos": "Unmoderierte Fotos",
|
"pendingPhotos": "Unmoderierte Fotos",
|
||||||
"activeInvites": "Aktive Einladungen",
|
"activeInvites": "Aktive QR-Codes",
|
||||||
"engagementMode": "Modus",
|
"engagementMode": "Modus",
|
||||||
"modePhotoOnly": "Foto-Modus",
|
"modePhotoOnly": "Foto-Modus",
|
||||||
"modeTasks": "Aufgaben"
|
"modeTasks": "Aufgaben"
|
||||||
@@ -410,14 +410,14 @@
|
|||||||
"statusPending": "Status: Prüfung ausstehend"
|
"statusPending": "Status: Prüfung ausstehend"
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
"title": "QR-Einladungen",
|
"title": "QR-Codes",
|
||||||
"subtitle": "Aktive Links und Layouts im Blick behalten.",
|
"subtitle": "Aktive Links und Layouts im Blick behalten.",
|
||||||
"activeCount": "{{count}} aktiv",
|
"activeCount": "{{count}} aktiv",
|
||||||
"totalCount": "{{count}} gesamt",
|
"totalCount": "{{count}} gesamt",
|
||||||
"empty": "Noch keine QR-Einladungen erstellt.",
|
"empty": "Noch keine QR-Codes erstellt.",
|
||||||
"statusActive": "Aktiv",
|
"statusActive": "Aktiv",
|
||||||
"statusInactive": "Inaktiv",
|
"statusInactive": "Inaktiv",
|
||||||
"manage": "Einladungen verwalten"
|
"manage": "QR-Codes verwalten"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Aktive Aufgaben",
|
"title": "Aktive Aufgaben",
|
||||||
@@ -460,8 +460,8 @@
|
|||||||
"collectionsCta": "Mission Packs anzeigen"
|
"collectionsCta": "Mission Packs anzeigen"
|
||||||
},
|
},
|
||||||
"customizer": {
|
"customizer": {
|
||||||
"title": "QR-Einladung anpassen",
|
"title": "QR-Code anpassen",
|
||||||
"description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.",
|
"description": "Passe Layout, Texte, Farben und Logo deiner QR-Codeskarten an.",
|
||||||
"layout": "Layout",
|
"layout": "Layout",
|
||||||
"selectLayout": "Layout auswählen",
|
"selectLayout": "Layout auswählen",
|
||||||
"headline": "Überschrift",
|
"headline": "Überschrift",
|
||||||
@@ -519,20 +519,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
"cardTitle": "QR-Einladungen & Layouts",
|
"cardTitle": "QR-Codes & Layouts",
|
||||||
"cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.",
|
"cardDescription": "Erzeuge QR-Codes, passe Layouts an und stelle druckfertige Vorlagen bereit.",
|
||||||
"subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.",
|
"subtitle": "Manage QR-Codes, Drucklayouts und Branding für deine Gäste.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"layout": "QR-Code-Layout anpassen",
|
"layout": "QR-Code-Layout anpassen",
|
||||||
"share": "Links & QR teilen",
|
"share": "Links & QR teilen",
|
||||||
"export": "Drucken & Export"
|
"export": "Drucken & Export"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"active": "Aktive Einladungen",
|
"active": "Aktive QR-Codes",
|
||||||
"total": "Gesamt"
|
"total": "Gesamt"
|
||||||
},
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"title": "Einladungs-Workflow",
|
"title": "QR-Codes-Workflow",
|
||||||
"description": "Durchlaufe Layout, Links und Export Schritt für Schritt.",
|
"description": "Durchlaufe Layout, Links und Export Schritt für Schritt.",
|
||||||
"badge": "Setup",
|
"badge": "Setup",
|
||||||
"steps": {
|
"steps": {
|
||||||
@@ -542,7 +542,7 @@
|
|||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"title": "Links & QR teilen",
|
"title": "Links & QR teilen",
|
||||||
"description": "Aktiviere Einladungen, kopiere QR-Codes und teile sie mit dem Team."
|
"description": "Aktiviere QR-Codes, kopiere QR-Codes und teile sie mit dem Team."
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"title": "Drucken & Export",
|
"title": "Drucken & Export",
|
||||||
@@ -564,13 +564,13 @@
|
|||||||
"editLayout": "Layout bearbeiten",
|
"editLayout": "Layout bearbeiten",
|
||||||
"editHint": "Farben & Texte direkt im Editor anpassen.",
|
"editHint": "Farben & Texte direkt im Editor anpassen.",
|
||||||
"export": "Drucken/Export",
|
"export": "Drucken/Export",
|
||||||
"create": "Weitere Einladung"
|
"create": "Weitere QR-Code"
|
||||||
},
|
},
|
||||||
"hint": "Teile den Link direkt im Team oder in Newslettern."
|
"hint": "Teile den Link direkt im Team oder in Newslettern."
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
"create": "Neue Einladung erstellen",
|
"create": "Neue QR-Code erstellen",
|
||||||
"backToList": "Zurück zur Übersicht",
|
"backToList": "Zurück zur Übersicht",
|
||||||
"backToEvent": "Event öffnen",
|
"backToEvent": "Event öffnen",
|
||||||
"copy": "Link kopieren",
|
"copy": "Link kopieren",
|
||||||
@@ -589,8 +589,8 @@
|
|||||||
"qrAlt": "QR-Code Vorschau"
|
"qrAlt": "QR-Code Vorschau"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Noch keine Einladungen",
|
"title": "Noch keine QR-Codes",
|
||||||
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten."
|
"copy": "Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten."
|
||||||
},
|
},
|
||||||
"errorTitle": "Aktion fehlgeschlagen",
|
"errorTitle": "Aktion fehlgeschlagen",
|
||||||
"export": {
|
"export": {
|
||||||
@@ -602,9 +602,9 @@
|
|||||||
},
|
},
|
||||||
"previewHint": "Speichere deine Änderungen, um die Exportdateien neu zu erstellen.",
|
"previewHint": "Speichere deine Änderungen, um die Exportdateien neu zu erstellen.",
|
||||||
"noLayoutPreview": "Noch keine Vorschau verfügbar. Speichere das Layout zuerst.",
|
"noLayoutPreview": "Noch keine Vorschau verfügbar. Speichere das Layout zuerst.",
|
||||||
"selectPlaceholder": "Einladung auswählen",
|
"selectPlaceholder": "QR-Code auswählen",
|
||||||
"noInviteSelected": "Wähle zunächst eine Einladung aus, um Downloads zu starten.",
|
"noInviteSelected": "Wähle zunächst eine QR-Code aus, um Downloads zu starten.",
|
||||||
"noLayouts": "Für diese Einladung sind aktuell keine Layouts verfügbar.",
|
"noLayouts": "Für diese QR-Code sind aktuell keine Layouts verfügbar.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"title": "Aktionen",
|
"title": "Aktionen",
|
||||||
"description": "Starte deinen Testdruck oder lade die Layouts herunter.",
|
"description": "Starte deinen Testdruck oder lade die Layouts herunter.",
|
||||||
@@ -685,14 +685,14 @@
|
|||||||
"title": "Live-Vorschau",
|
"title": "Live-Vorschau",
|
||||||
"subtitle": "So sieht dein Layout beim Export aus.",
|
"subtitle": "So sieht dein Layout beim Export aus.",
|
||||||
"mobileOpen": "Vorschau anzeigen",
|
"mobileOpen": "Vorschau anzeigen",
|
||||||
"mobileTitle": "Einladungsvorschau",
|
"mobileTitle": "QR-Codesvorschau",
|
||||||
"mobileHint": "Öffnet eine Vorschau in einem Overlay",
|
"mobileHint": "Öffnet eine Vorschau in einem Overlay",
|
||||||
"readyForGuests": "Bereit für Gäste",
|
"readyForGuests": "Bereit für Gäste",
|
||||||
"instructions": "Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.",
|
"instructions": "Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.",
|
||||||
"qrAlt": "QR-Code der Einladung"
|
"qrAlt": "QR-Code der QR-Code"
|
||||||
},
|
},
|
||||||
"placeholderTitle": "Kein Layout verfügbar",
|
"placeholderTitle": "Kein Layout verfügbar",
|
||||||
"placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",
|
"placeholderCopy": "Erstelle eine QR-Code, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",
|
||||||
"loadingTitle": "Layouts werden geladen",
|
"loadingTitle": "Layouts werden geladen",
|
||||||
"loadingDescription": "Bitte warte einen Moment, wir bereiten die Drucklayouts vor.",
|
"loadingDescription": "Bitte warte einen Moment, wir bereiten die Drucklayouts vor.",
|
||||||
"loadingError": "Layouts konnten nicht geladen werden.",
|
"loadingError": "Layouts konnten nicht geladen werden.",
|
||||||
@@ -807,7 +807,7 @@
|
|||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"members": "Team & Rollen",
|
"members": "Team & Rollen",
|
||||||
"tasks": "Aufgaben verwalten",
|
"tasks": "Aufgaben verwalten",
|
||||||
"invites": "Einladungen & Layouts",
|
"invites": "QR-Codes & Layouts",
|
||||||
"photos": "Fotos moderieren",
|
"photos": "Fotos moderieren",
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
"buyMorePhotos": "Mehr Fotos freischalten",
|
"buyMorePhotos": "Mehr Fotos freischalten",
|
||||||
@@ -815,11 +815,11 @@
|
|||||||
"extendGallery": "Galerie verlängern"
|
"extendGallery": "Galerie verlängern"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.",
|
"detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.",
|
||||||
"toolkitSubtitle": "Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.",
|
"toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.",
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "Event",
|
"badge": "Event",
|
||||||
"description": "Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.",
|
"description": "Konzentriere dich auf Aufgaben, Moderation und QR-Codes für dieses Event.",
|
||||||
"liveBadge": "Live?"
|
"liveBadge": "Live?"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -880,16 +880,16 @@
|
|||||||
"uploadsTotal": "Uploads gesamt",
|
"uploadsTotal": "Uploads gesamt",
|
||||||
"uploads24h": "Uploads (24h)",
|
"uploads24h": "Uploads (24h)",
|
||||||
"pending": "Fotos in Moderation",
|
"pending": "Fotos in Moderation",
|
||||||
"activeInvites": "Aktive Einladungen"
|
"activeInvites": "Aktive QR-Codes"
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
"badge": "Einladungen",
|
"badge": "QR-Codes",
|
||||||
"title": "QR-Einladungen",
|
"title": "QR-Codes",
|
||||||
"subtitle": "Behält aktive Einladungen und Layouts im Blick.",
|
"subtitle": "Behält aktive QR-Codes und Layouts im Blick.",
|
||||||
"activeCount": "{{count}} aktiv",
|
"activeCount": "{{count}} aktiv",
|
||||||
"totalCount": "{{count}} gesamt",
|
"totalCount": "{{count}} gesamt",
|
||||||
"empty": "Noch keine Einladungen erstellt.",
|
"empty": "Noch keine QR-Codes erstellt.",
|
||||||
"manage": "Layouts & Einladungen verwalten"
|
"manage": "Layouts & QR-Codes verwalten"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"badge": "Aufgaben",
|
"badge": "Aufgaben",
|
||||||
@@ -1009,7 +1009,7 @@
|
|||||||
"negative": "Brauch(t)e Unterstützung",
|
"negative": "Brauch(t)e Unterstützung",
|
||||||
"best": {
|
"best": {
|
||||||
"uploads": "Uploads & Geschwindigkeit",
|
"uploads": "Uploads & Geschwindigkeit",
|
||||||
"invites": "QR-Einladungen & Layouts",
|
"invites": "QR-Codes & Layouts",
|
||||||
"moderation": "Moderation & Export",
|
"moderation": "Moderation & Export",
|
||||||
"experience": "Allgemeine App-Erfahrung"
|
"experience": "Allgemeine App-Erfahrung"
|
||||||
},
|
},
|
||||||
@@ -1603,18 +1603,18 @@
|
|||||||
},
|
},
|
||||||
"noEvents": {
|
"noEvents": {
|
||||||
"title": "Lass uns starten",
|
"title": "Lass uns starten",
|
||||||
"description": "Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.",
|
"description": "Erstelle dein erstes Event, um Uploads, Aufgaben und QR-Codes freizuschalten.",
|
||||||
"cta": "Event erstellen"
|
"cta": "Event erstellen"
|
||||||
},
|
},
|
||||||
"draftEvent": {
|
"draftEvent": {
|
||||||
"title": "Event noch als Entwurf",
|
"title": "Event noch als Entwurf",
|
||||||
"description": "Veröffentliche das Event, um Einladungen und Galerie freizugeben.",
|
"description": "Veröffentliche das Event, um QR-Codes und Galerie freizugeben.",
|
||||||
"cta": "Event öffnen"
|
"cta": "Event öffnen"
|
||||||
},
|
},
|
||||||
"upcomingEvent": {
|
"upcomingEvent": {
|
||||||
"title": "Event startet bald",
|
"title": "Event startet bald",
|
||||||
"description_today": "Heute findet ein Event statt – checke Uploads und Tasks.",
|
"description_today": "Heute findet ein Event statt – checke Uploads und Tasks.",
|
||||||
"description_days": "Noch {{count}} Tage – bereite Einladungen und Aufgaben vor.",
|
"description_days": "Noch {{count}} Tage – bereite QR-Codes und Aufgaben vor.",
|
||||||
"cta": "Zum Event"
|
"cta": "Zum Event"
|
||||||
},
|
},
|
||||||
"pendingUploads": {
|
"pendingUploads": {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
|
|||||||
const eventDate = event.event_date ? new Date(event.event_date) : null;
|
const eventDate = event.event_date ? new Date(event.event_date) : null;
|
||||||
const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false;
|
const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false;
|
||||||
|
|
||||||
|
const hasBranding = (() => {
|
||||||
|
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||||
|
const brandingAllowed = Boolean(settings.branding_allowed ?? true);
|
||||||
|
const packageAllowsBranding = brandingAllowed || settings.branding_allowed === undefined;
|
||||||
|
return packageAllowsBranding;
|
||||||
|
})();
|
||||||
|
|
||||||
const formatBadge = (value?: number | null): number | undefined => {
|
const formatBadge = (value?: number | null): number | undefined => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
return value;
|
return value;
|
||||||
@@ -31,7 +38,7 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
const tabs = [
|
||||||
{
|
{
|
||||||
key: 'overview',
|
key: 'overview',
|
||||||
label: translate('eventMenu.summary', 'Übersicht'),
|
label: translate('eventMenu.summary', 'Übersicht'),
|
||||||
@@ -51,14 +58,9 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'invites',
|
key: 'invites',
|
||||||
label: translate('eventMenu.invites', 'Einladungen'),
|
label: translate('eventMenu.invites', 'QR-Codes'),
|
||||||
href: ADMIN_EVENT_INVITES_PATH(event.slug),
|
href: ADMIN_EVENT_INVITES_PATH(event.slug),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'branding',
|
|
||||||
label: translate('eventMenu.branding', 'Branding'),
|
|
||||||
href: ADMIN_EVENT_BRANDING_PATH(event.slug),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'photobooth',
|
key: 'photobooth',
|
||||||
label: translate('eventMenu.photobooth', 'Photobooth'),
|
label: translate('eventMenu.photobooth', 'Photobooth'),
|
||||||
@@ -72,4 +74,14 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
|
|||||||
}]
|
}]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (hasBranding) {
|
||||||
|
tabs.splice(4, 0, {
|
||||||
|
key: 'branding',
|
||||||
|
label: translate('eventMenu.branding', 'Branding'),
|
||||||
|
href: ADMIN_EVENT_BRANDING_PATH(event.slug),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import { getEmotions, createEmotion, updateEmotion, TenantEmotion, EmotionPayload } from '../api';
|
import {
|
||||||
|
getEmotions,
|
||||||
|
createEmotion,
|
||||||
|
updateEmotion,
|
||||||
|
deleteEmotion,
|
||||||
|
TenantEmotion,
|
||||||
|
EmotionPayload,
|
||||||
|
} from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
type EmotionFormState = {
|
type EmotionFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -49,6 +57,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
|
|||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = React.useState<TenantEmotion | null>(null);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE);
|
const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE);
|
||||||
|
|
||||||
@@ -107,9 +116,11 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
|
|||||||
const created = await createEmotion(payload);
|
const created = await createEmotion(payload);
|
||||||
setEmotions((prev) => [created, ...prev]);
|
setEmotions((prev) => [created, ...prev]);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
|
toast.success(t('emotions.toast.created', 'Emotion erstellt.'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError(t('emotions.errors.create'));
|
setError(t('emotions.errors.create'));
|
||||||
|
toast.error(t('emotions.toast.error', 'Emotion konnte nicht erstellt werden.'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -120,13 +131,35 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
|
|||||||
try {
|
try {
|
||||||
const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active });
|
const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active });
|
||||||
setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
|
setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
|
||||||
|
toast.success(
|
||||||
|
updated.is_active
|
||||||
|
? t('emotions.toast.activated', 'Emotion aktiviert.')
|
||||||
|
: t('emotions.toast.deactivated', 'Emotion deaktiviert.')
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError(t('emotions.errors.toggle'));
|
setError(t('emotions.errors.toggle'));
|
||||||
|
toast.error(t('emotions.toast.errorToggle', 'Emotion konnte nicht aktualisiert werden.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEmotion(emotion: TenantEmotion) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await deleteEmotion(emotion.id);
|
||||||
|
setEmotions((prev) => prev.filter((item) => item.id !== emotion.id));
|
||||||
|
toast.success(t('emotions.toast.deleted', 'Emotion gelöscht.'));
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
toast.error(t('emotions.toast.deleteError', 'Emotion konnte nicht gelöscht werden.'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const locale = i18n.language.startsWith('en') ? enGB : de;
|
const locale = i18n.language.startsWith('en') ? enGB : de;
|
||||||
const title = embedded ? t('emotions.title') : t('emotions.title');
|
const title = embedded ? t('emotions.title') : t('emotions.title');
|
||||||
const subtitle = embedded
|
const subtitle = embedded
|
||||||
@@ -165,17 +198,18 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
|
|||||||
) : emotions.length === 0 ? (
|
) : emotions.length === 0 ? (
|
||||||
<EmptyEmotionsState onCreate={openCreateDialog} />
|
<EmptyEmotionsState onCreate={openCreateDialog} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{emotions.map((emotion) => (
|
{emotions.map((emotion) => (
|
||||||
<EmotionCard
|
<EmotionCard
|
||||||
key={emotion.id}
|
key={emotion.id}
|
||||||
emotion={emotion}
|
emotion={emotion}
|
||||||
onToggle={() => toggleEmotion(emotion)}
|
onToggle={() => toggleEmotion(emotion)}
|
||||||
locale={locale}
|
onDelete={() => setDeleteTarget(emotion)}
|
||||||
/>
|
locale={locale}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -187,6 +221,29 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
|
|||||||
saving={saving}
|
saving={saving}
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('emotions.delete.title', 'Emotion löschen?')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{t('emotions.delete.confirm', { defaultValue: 'Soll "{{name}}" wirklich gelöscht werden?' , name: deleteTarget?.name ?? '' })}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
|
{t('actions.cancel', 'Abbrechen')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteTarget && void handleDeleteEmotion(deleteTarget)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.delete', 'Löschen')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -203,10 +260,12 @@ export default function EmotionsPage() {
|
|||||||
function EmotionCard({
|
function EmotionCard({
|
||||||
emotion,
|
emotion,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onDelete,
|
||||||
locale,
|
locale,
|
||||||
}: {
|
}: {
|
||||||
emotion: TenantEmotion;
|
emotion: TenantEmotion;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -252,7 +311,13 @@ function EmotionCard({
|
|||||||
<Power className="mr-1 h-4 w-4" />
|
<Power className="mr-1 h-4 w-4" />
|
||||||
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
|
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
|
{!emotion.is_global ? (
|
||||||
|
<Button variant="ghost" size="sm" className="text-rose-600 hover:bg-rose-50" onClick={onDelete}>
|
||||||
|
{t('actions.delete', 'Löschen')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
|
||||||
|
)}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ArrowLeft, Loader2, Moon, Sparkles, Sun } from 'lucide-react';
|
import { ArrowLeft, Moon, RotateCcw, Save, Sparkles, Sun, UploadCloud } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import { SectionCard, SectionHeader } from '../components/tenant';
|
import { SectionCard, SectionHeader } from '../components/tenant';
|
||||||
|
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -14,6 +15,7 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { ADMIN_EVENT_VIEW_PATH } from '../constants';
|
import { ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||||
import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api';
|
import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -23,6 +25,20 @@ import { ensureFontLoaded, useTenantFonts } from '../lib/fonts';
|
|||||||
|
|
||||||
const DEFAULT_FONT_VALUE = '__default';
|
const DEFAULT_FONT_VALUE = '__default';
|
||||||
const CUSTOM_FONT_VALUE = '__custom';
|
const CUSTOM_FONT_VALUE = '__custom';
|
||||||
|
const MAX_LOGO_UPLOAD_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
const EMOTICON_GRID: string[] = [
|
||||||
|
'✨', '🎉', '🎊', '🥳', '🎈', '🎁', '🎂', '🍾', '🥂', '🍻',
|
||||||
|
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
|
||||||
|
'🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚',
|
||||||
|
'😎', '🤩', '🤗', '🤝', '👍', '🙌', '👏', '👐', '🤲', '🙏',
|
||||||
|
'🤍', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤎', '🤍',
|
||||||
|
'⭐', '🌟', '💫', '🔥', '⚡', '🌈', '☀️', '🌅', '🌠', '🌌',
|
||||||
|
'🎵', '🎶', '🎤', '🎧', '🎸', '🥁', '🎺', '🎹', '🎻', '🪩',
|
||||||
|
'🍕', '🍔', '🌮', '🌯', '🍣', '🍱', '🍰', '🍪', '🍫', '🍩',
|
||||||
|
'☕', '🍵', '🥤', '🍹', '🍸', '🍷', '🍺', '🍻', '🥂', '🍾',
|
||||||
|
'📸', '🎥', '📹', '📱', '💡', '🛎️', '🪄', '🎯', '🏆', '🥇',
|
||||||
|
];
|
||||||
|
|
||||||
type BrandingForm = {
|
type BrandingForm = {
|
||||||
useDefault: boolean;
|
useDefault: boolean;
|
||||||
@@ -225,6 +241,43 @@ function resolvePreviewBranding(form: BrandingForm, tenantBranding: BrandingForm
|
|||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coerceEventName(value: unknown): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const preferred = record.de ?? record.en ?? Object.values(record)[0];
|
||||||
|
if (typeof preferred === 'string') {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceEventDate(value: unknown): string | null {
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const raw = value.trim();
|
||||||
|
|
||||||
|
// If ISO with timezone, convert to local date (validation uses server local date)
|
||||||
|
const parsed = new Date(raw);
|
||||||
|
if (!Number.isNaN(parsed.valueOf())) {
|
||||||
|
const year = parsed.getFullYear();
|
||||||
|
const month = String(parsed.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(parsed.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.length >= 10) {
|
||||||
|
return raw.slice(0, 10);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function EventBrandingPage(): React.ReactElement {
|
export default function EventBrandingPage(): React.ReactElement {
|
||||||
const { slug } = useParams<{ slug?: string }>();
|
const { slug } = useParams<{ slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -232,6 +285,7 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [form, setForm] = useState<BrandingForm>(DEFAULT_BRANDING_FORM);
|
const [form, setForm] = useState<BrandingForm>(DEFAULT_BRANDING_FORM);
|
||||||
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light');
|
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light');
|
||||||
|
const [emoticonDialogOpen, setEmoticonDialogOpen] = useState(false);
|
||||||
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
|
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
|
||||||
|
|
||||||
const title = t('branding.title', 'Branding & Fonts');
|
const title = t('branding.title', 'Branding & Fonts');
|
||||||
@@ -290,11 +344,32 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (payload: BrandingForm) => {
|
mutationFn: async (payload: BrandingForm) => {
|
||||||
if (!slug) throw new Error('Missing event slug');
|
if (!slug) throw new Error('Missing event context');
|
||||||
|
|
||||||
|
// Fetch a fresh snapshot to ensure required fields are sent and settings are merged instead of overwritten.
|
||||||
|
const latest = await getEvent(slug);
|
||||||
|
const eventTypeId = latest.event_type_id ?? latest.event_type?.id;
|
||||||
|
const eventDate = coerceEventDate(latest.event_date ?? loadedEvent?.event_date);
|
||||||
|
const eventName = coerceEventName(latest.name ?? loadedEvent?.name);
|
||||||
|
|
||||||
|
if (!eventTypeId || !eventName || !eventDate) {
|
||||||
|
throw new Error('Missing required event fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedSettings = {
|
||||||
|
...(latest.settings ?? {}),
|
||||||
|
branding: buildPayload(payload),
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
|
||||||
const response = await updateEvent(slug, {
|
const response = await updateEvent(slug, {
|
||||||
settings: {
|
name: eventName,
|
||||||
branding: buildPayload(payload),
|
slug: latest.slug,
|
||||||
},
|
event_type_id: eventTypeId,
|
||||||
|
event_date: eventDate,
|
||||||
|
status: latest.status,
|
||||||
|
is_active: latest.is_active,
|
||||||
|
package_id: latest.package?.id,
|
||||||
|
settings: mergedSettings,
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
@@ -304,10 +379,20 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
console.error('[branding] save failed', error);
|
console.error('[branding] save failed', error);
|
||||||
|
if ((error as { meta?: { errors?: Record<string, string[]> } })?.meta?.errors) {
|
||||||
|
const errors = (error as { meta?: { errors?: Record<string, string[]> } }).meta?.errors ?? {};
|
||||||
|
const flat = Object.entries(errors)
|
||||||
|
.map(([key, messages]) => `${key}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`)
|
||||||
|
.join('\n');
|
||||||
|
toast.error(flat || t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
|
toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mutate, isPending } = mutation;
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
return (
|
return (
|
||||||
<AdminLayout title={title} subtitle={subtitle} tabs={[]} currentTabKey="branding">
|
<AdminLayout title={title} subtitle={subtitle} tabs={[]} currentTabKey="branding">
|
||||||
@@ -334,8 +419,61 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFontPreview = (family: string) => {
|
||||||
|
const font = availableFonts.find((entry) => entry.family === family);
|
||||||
|
if (font) {
|
||||||
|
void ensureFontLoaded(font);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > MAX_LOGO_UPLOAD_BYTES) {
|
||||||
|
toast.error(t('branding.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
|
||||||
|
setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'upload', value: dataUrl } }));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmoticonSelect = (value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'emoticon', value } }));
|
||||||
|
};
|
||||||
|
|
||||||
const previewBranding = resolvePreviewBranding(form, tenantBranding);
|
const previewBranding = resolvePreviewBranding(form, tenantBranding);
|
||||||
|
|
||||||
|
const fabActions = React.useMemo<FloatingAction[]>(() => {
|
||||||
|
if (!slug) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'save',
|
||||||
|
label: isPending ? t('branding.saving', 'Speichern...') : t('branding.save', 'Branding speichern'),
|
||||||
|
icon: Save,
|
||||||
|
onClick: () => mutate(form),
|
||||||
|
loading: isPending,
|
||||||
|
disabled: isPending || eventLoading,
|
||||||
|
tone: 'primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reset',
|
||||||
|
label: t('branding.reset', 'Auf Standard zurücksetzen'),
|
||||||
|
icon: RotateCcw,
|
||||||
|
onClick: () => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true }),
|
||||||
|
disabled: isPending,
|
||||||
|
tone: 'secondary',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [slug, mutate, form, isPending, eventLoading, t, tenantBranding]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title={title}
|
title={title}
|
||||||
@@ -349,7 +487,7 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 pb-28">
|
||||||
<SectionCard className="space-y-3">
|
<SectionCard className="space-y-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')}
|
eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')}
|
||||||
@@ -451,7 +589,17 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
|
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
|
||||||
{availableFonts.map((font) => (
|
{availableFonts.map((font) => (
|
||||||
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
<SelectItem
|
||||||
|
key={font.family}
|
||||||
|
value={font.family}
|
||||||
|
onMouseEnter={() => handleFontPreview(font.family)}
|
||||||
|
onFocus={() => handleFontPreview(font.family)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
|
||||||
|
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
|
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -461,6 +609,7 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))}
|
||||||
disabled={form.useDefault}
|
disabled={form.useDefault}
|
||||||
placeholder="z. B. Playfair Display"
|
placeholder="z. B. Playfair Display"
|
||||||
|
style={form.typography.heading ? { fontFamily: form.typography.heading } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -476,7 +625,17 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
|
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
|
||||||
{availableFonts.map((font) => (
|
{availableFonts.map((font) => (
|
||||||
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
<SelectItem
|
||||||
|
key={font.family}
|
||||||
|
value={font.family}
|
||||||
|
onMouseEnter={() => handleFontPreview(font.family)}
|
||||||
|
onFocus={() => handleFontPreview(font.family)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
|
||||||
|
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
|
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -486,6 +645,7 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))}
|
||||||
disabled={form.useDefault}
|
disabled={form.useDefault}
|
||||||
placeholder="z. B. Inter, sans-serif"
|
placeholder="z. B. Inter, sans-serif"
|
||||||
|
style={form.typography.body ? { fontFamily: form.typography.body } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -528,6 +688,72 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{form.logo.mode === 'emoticon' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}</Label>
|
||||||
|
<Dialog open={emoticonDialogOpen} onOpenChange={setEmoticonDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={form.useDefault}>
|
||||||
|
{t('branding.openEmoticons', 'Emoticon-Gitter öffnen')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-xl bg-white/95 p-4 text-left dark:bg-slate-950">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base font-semibold">
|
||||||
|
{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-3 grid grid-cols-8 gap-2 sm:grid-cols-10">
|
||||||
|
{EMOTICON_GRID.map((emoji) => {
|
||||||
|
const isActive = form.logo.value === emoji;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex h-12 w-full items-center justify-center rounded-lg text-3xl leading-none transition sm:h-12',
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-900 text-white shadow-sm ring-2 ring-slate-900 dark:bg-white/90 dark:text-slate-900 dark:ring-white/90'
|
||||||
|
: 'bg-white text-slate-900 hover:bg-slate-100 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
handleEmoticonSelect(emoji);
|
||||||
|
setEmoticonDialogOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
title={emoji}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{emoji}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.logo.mode === 'upload' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.logoUpload', 'Logo hochladen')}</Label>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={form.useDefault} onClick={() => document.getElementById('branding-logo-upload')?.click()}>
|
||||||
|
<UploadCloud className="mr-2 h-4 w-4" />
|
||||||
|
{t('branding.logoUploadButton', 'Datei auswählen')}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
id="branding-logo-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
{t('branding.logoUploadHint', 'Max. 1 MB, PNG/SVG/JPG. Aktueller Wert wird ersetzt.')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('branding.logoPosition', 'Position')}</Label>
|
<Label>{t('branding.logoPosition', 'Position')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -649,33 +875,7 @@ export default function EventBrandingPage(): React.ReactElement {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky bottom-0 z-40 mt-6 bg-gradient-to-t from-white via-white to-white/70 py-3 backdrop-blur dark:from-slate-900 dark:via-slate-900 dark:to-slate-900/60">
|
<FloatingActionBar actions={fabActions} />
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-2 sm:px-0">
|
|
||||||
<div className="text-sm text-slate-600 dark:text-slate-200">
|
|
||||||
{form.useDefault
|
|
||||||
? t('branding.footer.default', 'Standard-Farben des Tenants aktiv.')
|
|
||||||
: t('branding.footer.custom', 'Event-spezifisches Branding aktiv.')}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true })}
|
|
||||||
>
|
|
||||||
{t('branding.reset', 'Auf Standard zurücksetzen')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => mutation.mutate(form)} disabled={mutation.isPending || eventLoading}>
|
|
||||||
{mutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t('branding.saving', 'Speichern...')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('branding.save', 'Branding speichern')
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -688,6 +888,14 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
|
|||||||
color: textColor,
|
color: textColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logoVisual = useMemo(() => {
|
||||||
|
const looksLikeImage = branding.logo.mode === 'upload' && branding.logo.value && /^(data:|https?:)/i.test(branding.logo.value);
|
||||||
|
if (looksLikeImage) {
|
||||||
|
return <img src={branding.logo.value} alt="Logo" className="h-10 w-10 rounded-full object-cover" />;
|
||||||
|
}
|
||||||
|
return <span className="text-xl">{branding.logo.value || '✨'}</span>;
|
||||||
|
}, [branding.logo.mode, branding.logo.value]);
|
||||||
|
|
||||||
const buttonStyle: React.CSSProperties = branding.buttons.style === 'outline'
|
const buttonStyle: React.CSSProperties = branding.buttons.style === 'outline'
|
||||||
? {
|
? {
|
||||||
border: `2px solid ${branding.buttons.primary || branding.palette.primary}`,
|
border: `2px solid ${branding.buttons.primary || branding.palette.primary}`,
|
||||||
@@ -705,8 +913,8 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
|
|||||||
<CardHeader className="p-0">
|
<CardHeader className="p-0">
|
||||||
<div className="px-4 py-3" style={headerStyle}>
|
<div className="px-4 py-3" style={headerStyle}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-xl">
|
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-white/90 text-xl">
|
||||||
{branding.logo.value || '✨'}
|
{logoVisual}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>
|
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export default function EventDetailPage() {
|
|||||||
const toolkitData = toolkit.data;
|
const toolkitData = toolkit.data;
|
||||||
|
|
||||||
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||||
const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
|
const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.');
|
||||||
const currentTabKey = 'overview';
|
const currentTabKey = 'overview';
|
||||||
|
|
||||||
const eventTabs = React.useMemo(() => {
|
const eventTabs = React.useMemo(() => {
|
||||||
@@ -221,6 +221,11 @@ export default function EventDetailPage() {
|
|||||||
return buildEventTabs(event, translateMenu, counts);
|
return buildEventTabs(event, translateMenu, counts);
|
||||||
}, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]);
|
}, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]);
|
||||||
|
|
||||||
|
const brandingAllowed = React.useMemo(() => {
|
||||||
|
const settings = (event?.settings ?? {}) as Record<string, unknown>;
|
||||||
|
return Boolean(settings.branding_allowed ?? true);
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
const limitWarnings = React.useMemo(
|
const limitWarnings = React.useMemo(
|
||||||
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
|
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
|
||||||
[event?.limits, tCommon],
|
[event?.limits, tCommon],
|
||||||
@@ -449,7 +454,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
|||||||
</p>
|
</p>
|
||||||
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
|
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
|
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und QR-Code für dieses Event.')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
|
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
|
||||||
@@ -503,6 +508,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
|||||||
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{brandingAllowed ? (
|
||||||
<BrandingMissionCard
|
<BrandingMissionCard
|
||||||
event={event}
|
event={event}
|
||||||
invites={toolkitData?.invites}
|
invites={toolkitData?.invites}
|
||||||
@@ -512,6 +518,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
|||||||
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
{event.addons?.length ? (
|
{event.addons?.length ? (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -758,9 +765,9 @@ function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['
|
|||||||
return (
|
return (
|
||||||
<SectionCard className="space-y-3">
|
<SectionCard className="space-y-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow={t('events.invites.badge', 'Einladungen')}
|
eyebrow={t('events.invites.badge', 'QR-Codes')}
|
||||||
title={t('events.invites.title', 'QR-Einladungen')}
|
title={t('events.invites.title', 'QR-Codes & Layouts')}
|
||||||
description={t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
|
description={t('events.invites.subtitle', 'Behält aktive QR-Codes und Layouts im Blick.')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<div className="flex gap-2 text-sm text-slate-900">
|
<div className="flex gap-2 text-sm text-slate-900">
|
||||||
@@ -782,11 +789,11 @@ function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine Einladungen erstellt.')}</p>
|
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine QR-Codes erstellt.')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
|
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
|
||||||
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'Layouts & Einladungen verwalten')}
|
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'QR-Codes & Layouts verwalten')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -999,10 +1006,10 @@ function GalleryShareCard({
|
|||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow={t('events.galleryShare.badge', 'Galerie')}
|
eyebrow={t('events.galleryShare.badge', 'Galerie')}
|
||||||
title={t('events.galleryShare.title', 'Galerie teilen')}
|
title={t('events.galleryShare.title', 'Galerie teilen')}
|
||||||
description={t('events.galleryShare.emptyDescription', 'Erstelle einen Einladungslink, um Fotos zu teilen.')}
|
description={t('events.galleryShare.emptyDescription', 'Erstelle einen QR-Codeslink, um Fotos zu teilen.')}
|
||||||
/>
|
/>
|
||||||
<Button onClick={onManageInvites} className="w-full rounded-full bg-brand-rose text-white shadow-md shadow-rose-300/40">
|
<Button onClick={onManageInvites} className="w-full rounded-full bg-brand-rose text-white shadow-md shadow-rose-300/40">
|
||||||
{t('events.galleryShare.createInvite', 'Einladung erstellen')}
|
{t('events.galleryShare.createInvite', 'QR-Code erstellen')}
|
||||||
</Button>
|
</Button>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -16,6 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
|
import { FloatingActionBar } from '../components/FloatingActionBar';
|
||||||
import {
|
import {
|
||||||
createEvent,
|
createEvent,
|
||||||
getEvent,
|
getEvent,
|
||||||
@@ -67,6 +68,7 @@ export default function EventFormPage() {
|
|||||||
const slugParam = params.slug ?? searchParams.get('slug') ?? undefined;
|
const slugParam = params.slug ?? searchParams.get('slug') ?? undefined;
|
||||||
const isEdit = Boolean(slugParam);
|
const isEdit = Boolean(slugParam);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
|
const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
|
||||||
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
|
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
|
||||||
@@ -88,6 +90,7 @@ export default function EventFormPage() {
|
|||||||
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
|
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
|
||||||
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
|
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
|
||||||
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
|
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
|
||||||
|
const formRef = React.useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
const { data: packages, isLoading: packagesLoading } = useQuery({
|
const { data: packages, isLoading: packagesLoading } = useQuery({
|
||||||
queryKey: ['packages', 'endcustomer'],
|
queryKey: ['packages', 'endcustomer'],
|
||||||
@@ -143,7 +146,7 @@ export default function EventFormPage() {
|
|||||||
queryKey: ['tenant', 'events', slugParam],
|
queryKey: ['tenant', 'events', slugParam],
|
||||||
queryFn: () => getEvent(slugParam!),
|
queryFn: () => getEvent(slugParam!),
|
||||||
enabled: Boolean(isEdit && slugParam),
|
enabled: Boolean(isEdit && slugParam),
|
||||||
staleTime: 60_000,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -277,6 +280,16 @@ export default function EventFormPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmitClick = React.useCallback(() => {
|
||||||
|
if (formRef.current) {
|
||||||
|
if (typeof formRef.current.requestSubmit === 'function') {
|
||||||
|
formRef.current.requestSubmit();
|
||||||
|
} else {
|
||||||
|
formRef.current.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmedName = form.name.trim();
|
const trimmedName = form.name.trim();
|
||||||
@@ -320,14 +333,20 @@ export default function EventFormPage() {
|
|||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
const targetSlug = originalSlug ?? slugParam!;
|
const targetSlug = originalSlug ?? slugParam!;
|
||||||
const updated = await updateEvent(targetSlug, payload);
|
const updated = await updateEvent(targetSlug, payload);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tenant', 'events', targetSlug] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tenant', 'dashboard'] });
|
||||||
setOriginalSlug(updated.slug);
|
setOriginalSlug(updated.slug);
|
||||||
setShowUpgradeHint(false);
|
setShowUpgradeHint(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
toast.success(tForm('actions.saved', 'Event gespeichert'));
|
||||||
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
|
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
|
||||||
} else {
|
} else {
|
||||||
const { event: created } = await createEvent(payload);
|
const { event: created } = await createEvent(payload);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
|
||||||
setShowUpgradeHint(false);
|
setShowUpgradeHint(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
toast.success(tForm('actions.saved', 'Event gespeichert'));
|
||||||
navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
|
navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -456,6 +475,26 @@ export default function EventFormPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fabActions = [
|
||||||
|
{
|
||||||
|
key: 'save',
|
||||||
|
label: saving ? tForm('actions.saving', 'Speichert') : tForm('actions.save', 'Speichern'),
|
||||||
|
icon: Save,
|
||||||
|
onClick: handleSubmitClick,
|
||||||
|
loading: saving,
|
||||||
|
disabled: loading || !form.name.trim() || !form.slug.trim() || !form.eventTypeId,
|
||||||
|
tone: 'primary' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cancel',
|
||||||
|
label: tForm('actions.cancel', 'Abbrechen'),
|
||||||
|
icon: ArrowLeft,
|
||||||
|
onClick: () => navigate(-1),
|
||||||
|
disabled: saving,
|
||||||
|
tone: 'secondary' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
|
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
|
||||||
@@ -500,7 +539,7 @@ export default function EventFormPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
|
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60 pb-28">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
<Sparkles className="h-5 w-5 text-pink-500" /> {tForm('sections.details.title', 'Eventdetails')}
|
<Sparkles className="h-5 w-5 text-pink-500" /> {tForm('sections.details.title', 'Eventdetails')}
|
||||||
@@ -513,7 +552,7 @@ export default function EventFormPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<FormSkeleton />
|
<FormSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="space-y-6" onSubmit={handleSubmit} ref={formRef}>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2 sm:col-span-2">
|
<div className="space-y-2 sm:col-span-2">
|
||||||
<Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label>
|
<Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label>
|
||||||
@@ -585,26 +624,6 @@ export default function EventFormPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
|
|
||||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> {tForm('actions.saving', 'Speichert')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="h-4 w-4" /> {tForm('actions.save', 'Speichern')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
|
|
||||||
{tForm('actions.cancel', 'Abbrechen')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2 mt-6">
|
<div className="sm:col-span-2 mt-6">
|
||||||
<Accordion type="single" collapsible defaultValue="package">
|
<Accordion type="single" collapsible defaultValue="package">
|
||||||
<AccordionItem value="package" className="border-0">
|
<AccordionItem value="package" className="border-0">
|
||||||
@@ -695,6 +714,7 @@ export default function EventFormPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<FloatingActionBar actions={fabActions} />
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
|
import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart, Save, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -29,8 +29,6 @@ import {
|
|||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import {
|
import {
|
||||||
ADMIN_EVENTS_PATH,
|
ADMIN_EVENTS_PATH,
|
||||||
ADMIN_EVENT_VIEW_PATH,
|
|
||||||
ADMIN_EVENT_PHOTOS_PATH,
|
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
import { buildEventTabs } from '../lib/eventTabs';
|
import { buildEventTabs } from '../lib/eventTabs';
|
||||||
@@ -57,6 +55,7 @@ import {
|
|||||||
triggerDownloadFromDataUrl,
|
triggerDownloadFromDataUrl,
|
||||||
} from './components/invite-layout/export-utils';
|
} from './components/invite-layout/export-utils';
|
||||||
import { useOnboardingProgress } from '../onboarding';
|
import { useOnboardingProgress } from '../onboarding';
|
||||||
|
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
|
||||||
|
|
||||||
interface PageState {
|
interface PageState {
|
||||||
event: TenantEvent | null;
|
event: TenantEvent | null;
|
||||||
@@ -219,7 +218,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
setAddonsCatalog(catalog);
|
setAddonsCatalog(catalog);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAuthError(error)) {
|
if (!isAuthError(error)) {
|
||||||
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
|
setState({ event: null, invites: [], loading: false, error: 'QR-QR-Code konnten nicht geladen werden.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
@@ -543,9 +542,11 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(invite.url);
|
await navigator.clipboard.writeText(invite.url);
|
||||||
setCopiedInviteId(invite.id);
|
setCopiedInviteId(invite.id);
|
||||||
|
toast.success(t('invites.actions.copied', 'Link kopiert'));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore clipboard failures
|
// ignore clipboard failures
|
||||||
}
|
}
|
||||||
|
toast.success(t('invites.actions.created', 'QR-Code erstellt'));
|
||||||
markStep({
|
markStep({
|
||||||
lastStep: 'invite',
|
lastStep: 'invite',
|
||||||
serverStep: 'invite_created',
|
serverStep: 'invite_created',
|
||||||
@@ -553,7 +554,8 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAuthError(error)) {
|
if (!isAuthError(error)) {
|
||||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' }));
|
setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht erstellt werden.' }));
|
||||||
|
toast.error(t('invites.actions.createFailed', 'QR-Code konnte nicht erstellt werden.'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingInvite(false);
|
setCreatingInvite(false);
|
||||||
@@ -564,8 +566,10 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(invite.url);
|
await navigator.clipboard.writeText(invite.url);
|
||||||
setCopiedInviteId(invite.id);
|
setCopiedInviteId(invite.id);
|
||||||
|
toast.success(t('invites.actions.copied', 'Link kopiert'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Invites] Clipboard copy failed', error);
|
console.warn('[Invites] Clipboard copy failed', error);
|
||||||
|
toast.error(t('invites.actions.copyFailed', 'Link konnte nicht kopiert werden.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,9 +595,11 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
if (selectedInviteId === invite.id && !updated.is_active) {
|
if (selectedInviteId === invite.id && !updated.is_active) {
|
||||||
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
|
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
|
||||||
}
|
}
|
||||||
|
toast.success(t('invites.actions.revoked', 'QR-Code deaktiviert'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAuthError(error)) {
|
if (!isAuthError(error)) {
|
||||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
|
setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht deaktiviert werden.' }));
|
||||||
|
toast.error(t('invites.actions.revokeFailed', 'QR-Code konnte nicht deaktiviert werden.'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRevokingId(null);
|
setRevokingId(null);
|
||||||
@@ -616,6 +622,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
||||||
}));
|
}));
|
||||||
setCustomizerDraft(null);
|
setCustomizerDraft(null);
|
||||||
|
toast.success(t('invites.customizer.toastSaved', 'Layout gespeichert'));
|
||||||
markStep({
|
markStep({
|
||||||
lastStep: 'branding',
|
lastStep: 'branding',
|
||||||
serverStep: 'branding_configured',
|
serverStep: 'branding_configured',
|
||||||
@@ -627,6 +634,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAuthError(error)) {
|
if (!isAuthError(error)) {
|
||||||
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
|
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
|
||||||
|
toast.error(t('invites.customizer.toastSaveFailed', 'Layout konnte nicht gespeichert werden.'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setCustomizerSaving(false);
|
setCustomizerSaving(false);
|
||||||
@@ -699,9 +707,9 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
|
|
||||||
const eventDateSegment = normalizeEventDateSegment(eventDate);
|
const eventDateSegment = normalizeEventDateSegment(eventDate);
|
||||||
const filename = buildDownloadFilename(
|
const filename = buildDownloadFilename(
|
||||||
['Einladungslayout', eventName, exportLayout.name ?? null, eventDateSegment],
|
['QR-Codeslayout', eventName, exportLayout.name ?? null, eventDateSegment],
|
||||||
normalizedFormat,
|
normalizedFormat,
|
||||||
'einladungslayout',
|
'QR-Codeslayout',
|
||||||
);
|
);
|
||||||
|
|
||||||
const exportOptions = {
|
const exportOptions = {
|
||||||
@@ -792,36 +800,8 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
className="hover:text-foreground"
|
className="hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
|
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
|
||||||
{t('invites.actions.backToList', 'Zurück')}
|
{t('invites.actions.backToList', 'Zurück zur Übersicht')}
|
||||||
</Button>
|
</Button>
|
||||||
{slug ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
|
|
||||||
className="hover:text-foreground"
|
|
||||||
>
|
|
||||||
{t('invites.actions.backToEvent', 'Event öffnen')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))}
|
|
||||||
className="hover:text-foreground"
|
|
||||||
>
|
|
||||||
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
|
|
||||||
className="hover:text-foreground"
|
|
||||||
>
|
|
||||||
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -860,6 +840,43 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
[slug],
|
[slug],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fabActions = React.useMemo<FloatingAction[]>(() => {
|
||||||
|
const items: FloatingAction[] = [
|
||||||
|
{
|
||||||
|
key: 'create-invite',
|
||||||
|
label: creatingInvite ? t('invites.actions.creating', 'Erstellen...') : t('invites.actions.create', 'Neue QR-Code erstellen'),
|
||||||
|
icon: Plus,
|
||||||
|
onClick: () => { void handleCreateInvite(); },
|
||||||
|
loading: creatingInvite,
|
||||||
|
disabled: creatingInvite || state.event?.limits?.can_add_guests === false,
|
||||||
|
tone: 'primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'refresh',
|
||||||
|
label: state.loading ? t('invites.actions.refreshing', 'Aktualisieren...') : t('invites.actions.refresh', 'Aktualisieren'),
|
||||||
|
icon: RefreshCw,
|
||||||
|
onClick: () => { void load(); },
|
||||||
|
loading: state.loading,
|
||||||
|
disabled: state.loading,
|
||||||
|
tone: 'secondary',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (activeTab === 'layout' && selectedInvite && effectiveCustomization) {
|
||||||
|
items.unshift({
|
||||||
|
key: 'save-layout',
|
||||||
|
label: customizerSaving ? t('invites.customizer.actions.saving', 'Speichert...') : t('invites.customizer.actions.save', 'Layout speichern'),
|
||||||
|
icon: Save,
|
||||||
|
onClick: () => { void handleSaveCustomization(effectiveCustomization); },
|
||||||
|
loading: customizerSaving,
|
||||||
|
disabled: customizerSaving || customizerResetting,
|
||||||
|
tone: 'primary',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [activeTab, selectedInvite, effectiveCustomization, customizerSaving, customizerResetting, creatingInvite, state.event?.limits?.can_add_guests, state.loading, t, handleSaveCustomization, load]);
|
||||||
|
|
||||||
const limitScopeLabels = React.useMemo(
|
const limitScopeLabels = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
photos: tLimits('photosTitle'),
|
photos: tLimits('photosTitle'),
|
||||||
@@ -882,70 +899,71 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title={eventName}
|
title={eventName}
|
||||||
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
subtitle={t('invites.subtitle', 'Manage QR-Codes, Drucklayouts und Branding für deine Gäste.')}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
tabs={eventTabs}
|
tabs={eventTabs}
|
||||||
currentTabKey="invites"
|
currentTabKey="invites"
|
||||||
>
|
>
|
||||||
{limitWarnings.length > 0 && (
|
<div className="pb-28">
|
||||||
<div className="mb-6 space-y-2">
|
{limitWarnings.length > 0 && (
|
||||||
{limitWarnings.map((warning) => (
|
<div className="mb-6 space-y-2">
|
||||||
<Alert
|
{limitWarnings.map((warning) => (
|
||||||
key={warning.id}
|
<Alert
|
||||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
key={warning.id}
|
||||||
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
>
|
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
>
|
||||||
<div>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
<div>
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
{limitScopeLabels[warning.scope]}
|
<AlertTriangle className="h-4 w-4" />
|
||||||
</AlertTitle>
|
{limitScopeLabels[warning.scope]}
|
||||||
<AlertDescription className="text-sm">
|
</AlertTitle>
|
||||||
{warning.message}
|
<AlertDescription className="text-sm">
|
||||||
</AlertDescription>
|
{warning.message}
|
||||||
</div>
|
</AlertDescription>
|
||||||
{warning.scope === 'guests' ? (
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { void handleAddonPurchase(); }}
|
|
||||||
disabled={addonBusy === 'guests'}
|
|
||||||
>
|
|
||||||
<ShoppingCart className="mr-2 h-4 w-4" />
|
|
||||||
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
|
|
||||||
</Button>
|
|
||||||
<AddonsPicker
|
|
||||||
addons={addonsCatalog}
|
|
||||||
scope="guests"
|
|
||||||
onCheckout={(key) => { void handleAddonPurchase(key); }}
|
|
||||||
busy={addonBusy === 'guests'}
|
|
||||||
t={(key, fallback) => t(key, fallback)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
{warning.scope === 'guests' ? (
|
||||||
</div>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
</Alert>
|
<Button
|
||||||
))}
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
)}
|
onClick={() => { void handleAddonPurchase(); }}
|
||||||
|
disabled={addonBusy === 'guests'}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||||
|
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
|
||||||
|
</Button>
|
||||||
|
<AddonsPicker
|
||||||
|
addons={addonsCatalog}
|
||||||
|
scope="guests"
|
||||||
|
onCheckout={(key) => { void handleAddonPurchase(key); }}
|
||||||
|
busy={addonBusy === 'guests'}
|
||||||
|
t={(key, fallback) => t(key, fallback)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{state.event?.addons?.length ? (
|
{state.event?.addons?.length ? (
|
||||||
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
|
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
|
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
|
||||||
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
|
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
|
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} />
|
<InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} />
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||||
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
||||||
<TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
<TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||||
{t('invites.tabs.share', 'Links & QR teilen')}
|
{t('invites.tabs.share', 'Links & QR teilen')}
|
||||||
@@ -969,7 +987,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
<section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
|
<section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
|
||||||
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'Einladungslayout anpassen')}</h2>
|
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'QR-Codeslayout anpassen')}</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
|
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
|
||||||
</p>
|
</p>
|
||||||
@@ -1014,7 +1032,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
disabled={state.invites.length === 0}
|
disabled={state.invites.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 w-full min-w-[200px] sm:w-60">
|
<SelectTrigger className="h-9 w-full min-w-[200px] sm:w-60">
|
||||||
<SelectValue placeholder={t('invites.export.selectPlaceholder', 'Einladung auswählen')} />
|
<SelectValue placeholder={t('invites.export.selectPlaceholder', 'QR-Code auswählen')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{state.invites.map((invite) => (
|
{state.invites.map((invite) => (
|
||||||
@@ -1216,7 +1234,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
{selectedInvite.qr_code_data_url ? (
|
{selectedInvite.qr_code_data_url ? (
|
||||||
<img
|
<img
|
||||||
src={selectedInvite.qr_code_data_url}
|
src={selectedInvite.qr_code_data_url}
|
||||||
alt={t('invites.export.qr.alt', 'QR-Code der Einladung')}
|
alt={t('invites.export.qr.alt', 'QR-Code der QR-Code')}
|
||||||
className="h-40 w-40 rounded-2xl border border-[var(--tenant-border-strong)] bg-white p-3 shadow-md"
|
className="h-40 w-40 rounded-2xl border border-[var(--tenant-border-strong)] bg-white p-3 shadow-md"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1263,12 +1281,12 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
|
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
|
||||||
{t('invites.export.noLayouts', 'Für diese Einladung sind aktuell keine Layouts verfügbar.')}
|
{t('invites.export.noLayouts', 'Für diese QR-Code sind aktuell keine Layouts verfügbar.')}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
|
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
|
||||||
{t('invites.export.noInviteSelected', 'Wähle zunächst eine Einladung aus, um Downloads zu starten.')}
|
{t('invites.export.noInviteSelected', 'Wähle zunächst eine QR-Code aus, um Downloads zu starten.')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1291,13 +1309,13 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
|
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
|
||||||
<QrCode className="h-5 w-5 text-primary" />
|
<QrCode className="h-5 w-5 text-primary" />
|
||||||
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
|
{t('invites.cardTitle', 'QR-QR-Code & Layouts')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-muted-foreground">
|
<CardDescription className="text-sm text-muted-foreground">
|
||||||
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
|
{t('invites.cardDescription', 'Erzeuge QR-Code, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-3 py-1 text-xs text-[var(--tenant-foreground-soft)]">
|
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-3 py-1 text-xs text-[var(--tenant-foreground-soft)]">
|
||||||
<span>{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active}</span>
|
<span>{t('invites.summary.active', 'Aktive QR-Code')}: {inviteCountSummary.active}</span>
|
||||||
<span className="text-primary">•</span>
|
<span className="text-primary">•</span>
|
||||||
<span>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span>
|
<span>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1319,7 +1337,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
|
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
|
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
|
||||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
{t('invites.actions.create', 'Neue QR-Code erstellen')}
|
||||||
</Button>
|
</Button>
|
||||||
{!state.loading && state.event?.limits?.can_add_guests === false && (
|
{!state.loading && state.event?.limits?.can_add_guests === false && (
|
||||||
<p className="w-full text-xs text-amber-600">
|
<p className="w-full text-xs text-amber-600">
|
||||||
@@ -1353,6 +1371,8 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<FloatingActionBar actions={fabActions} />
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1372,7 +1392,7 @@ function InviteWorkflowSteps({ steps, onSelectStep }: { steps: InviteWorkflowSte
|
|||||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base font-semibold text-foreground">
|
<CardTitle className="text-base font-semibold text-foreground">
|
||||||
{t('invites.workflow.title', 'Einladungs-Workflow')}
|
{t('invites.workflow.title', 'QR-Codes-Workflow')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-muted-foreground">
|
<CardDescription className="text-sm text-muted-foreground">
|
||||||
{t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge – Layout gestalten, Links teilen, Export starten.')}
|
{t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge – Layout gestalten, Links teilen, Export starten.')}
|
||||||
@@ -1490,7 +1510,7 @@ function InviteShareSummaryCard({ invite, onCopy, onCreate, onOpenLayout, onOpen
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={onCreate} className="flex-1">
|
<Button variant="outline" onClick={onCreate} className="flex-1">
|
||||||
<Share2 className="mr-2 h-4 w-4" />
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
{t('invites.share.actions.create', 'Weitere Einladung')}
|
{t('invites.share.actions.create', 'Weitere QR-Code')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1562,7 +1582,7 @@ function InviteListCard({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `QR-Code #${invite.id}`}</span>
|
||||||
<Badge variant="outline" className={statusBadgeClass(status)}>
|
<Badge variant="outline" className={statusBadgeClass(status)}>
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -1651,11 +1671,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
|
|||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center transition-colors">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center transition-colors">
|
||||||
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
|
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine QR-Code')}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten.')}</p>
|
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten.')}</p>
|
||||||
<Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
|
<Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
|
||||||
<Share2 className="mr-1 h-4 w-4" />
|
<Share2 className="mr-1 h-4 w-4" />
|
||||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
{t('invites.actions.create', 'Neue QR-Code erstellen')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export default function EventRecapPage() {
|
|||||||
return (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title={eventName}
|
title={eventName}
|
||||||
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.')}
|
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.')}
|
||||||
tabs={eventTabs}
|
tabs={eventTabs}
|
||||||
currentTabKey="recap"
|
currentTabKey="recap"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,16 +9,33 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
CSS,
|
||||||
|
} from '@dnd-kit/utilities';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import {
|
import {
|
||||||
assignTasksToEvent,
|
assignTasksToEvent,
|
||||||
|
detachTasksFromEvent,
|
||||||
getEvent,
|
getEvent,
|
||||||
getEventTasks,
|
getEventTasks,
|
||||||
|
createTask,
|
||||||
getTasks,
|
getTasks,
|
||||||
getTaskCollections,
|
getTaskCollections,
|
||||||
importTaskCollection,
|
importTaskCollection,
|
||||||
@@ -29,11 +46,12 @@ import {
|
|||||||
TenantTaskCollection,
|
TenantTaskCollection,
|
||||||
TenantEmotion,
|
TenantEmotion,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
import { EmotionsSection } from './EmotionsPage';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants';
|
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants';
|
||||||
import { extractBrandingPalette } from '../lib/branding';
|
|
||||||
import { filterEmotionsByEventType } from '../lib/emotions';
|
import { filterEmotionsByEventType } from '../lib/emotions';
|
||||||
import { buildEventTabs } from '../lib/eventTabs';
|
import { buildEventTabs } from '../lib/eventTabs';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function EventTasksPage() {
|
export default function EventTasksPage() {
|
||||||
const { t } = useTranslation('management', { keyPrefix: 'eventTasks' });
|
const { t } = useTranslation('management', { keyPrefix: 'eventTasks' });
|
||||||
@@ -46,7 +64,6 @@ export default function EventTasksPage() {
|
|||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
|
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
|
||||||
const [availableTasks, setAvailableTasks] = React.useState<TenantTask[]>([]);
|
const [availableTasks, setAvailableTasks] = React.useState<TenantTask[]>([]);
|
||||||
const [selected, setSelected] = React.useState<number[]>([]);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
const [modeSaving, setModeSaving] = React.useState(false);
|
const [modeSaving, setModeSaving] = React.useState(false);
|
||||||
@@ -60,12 +77,33 @@ export default function EventTasksPage() {
|
|||||||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
||||||
const [emotionsLoading, setEmotionsLoading] = React.useState(false);
|
const [emotionsLoading, setEmotionsLoading] = React.useState(false);
|
||||||
const [emotionsError, setEmotionsError] = React.useState<string | null>(null);
|
const [emotionsError, setEmotionsError] = React.useState<string | null>(null);
|
||||||
|
const [emotionFilter, setEmotionFilter] = React.useState<number[]>([]);
|
||||||
|
const [emotionsModalOpen, setEmotionsModalOpen] = React.useState(false);
|
||||||
|
const [newTaskTitle, setNewTaskTitle] = React.useState('');
|
||||||
|
const [newTaskDescription, setNewTaskDescription] = React.useState('');
|
||||||
|
const [newTaskEmotionId, setNewTaskEmotionId] = React.useState<number | null>(null);
|
||||||
|
const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>('');
|
||||||
|
const [creatingTask, setCreatingTask] = React.useState(false);
|
||||||
|
const [draggingId, setDraggingId] = React.useState<number | null>(null);
|
||||||
const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => {
|
const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => {
|
||||||
try {
|
try {
|
||||||
const refreshed = await getEventTasks(targetEvent.id, 1);
|
const [refreshed, libraryTasks] = await Promise.all([
|
||||||
|
getEventTasks(targetEvent.id, 1),
|
||||||
|
getTasks({ per_page: 200 }),
|
||||||
|
]);
|
||||||
const assignedIds = new Set(refreshed.data.map((task) => task.id));
|
const assignedIds = new Set(refreshed.data.map((task) => task.id));
|
||||||
setAssignedTasks(refreshed.data);
|
setAssignedTasks(refreshed.data);
|
||||||
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
|
const eventTypeId = targetEvent.event_type_id ?? null;
|
||||||
|
const filteredLibraryTasks = libraryTasks.data.filter((task) => {
|
||||||
|
if (assignedIds.has(task.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setAvailableTasks(filteredLibraryTasks);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError(t('errors.assign', 'Tasks konnten nicht geladen werden.'));
|
setError(t('errors.assign', 'Tasks konnten nicht geladen werden.'));
|
||||||
@@ -73,19 +111,31 @@ export default function EventTasksPage() {
|
|||||||
}
|
}
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const statusLabels = React.useMemo(
|
const relevantEmotions = React.useMemo(() => {
|
||||||
() => ({
|
const filtered = filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null);
|
||||||
published: t('management.members.statuses.published', 'Veröffentlicht'),
|
return filtered.length > 0 ? filtered : emotions;
|
||||||
draft: t('management.members.statuses.draft', 'Entwurf'),
|
}, [emotions, event?.event_type_id, event?.event_type?.id]);
|
||||||
}),
|
const emotionChips = React.useMemo(() => {
|
||||||
[t]
|
const map: Record<number, TenantEmotion> = {};
|
||||||
);
|
assignedTasks.forEach((task) => {
|
||||||
|
if (task.emotion) {
|
||||||
const palette = React.useMemo(() => extractBrandingPalette(event?.settings), [event?.settings]);
|
map[task.emotion.id] = {
|
||||||
const relevantEmotions = React.useMemo(
|
...task.emotion,
|
||||||
() => filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null),
|
name_translations: task.emotion.name_translations ?? {},
|
||||||
[emotions, event?.event_type_id, event?.event_type?.id],
|
description: null,
|
||||||
);
|
description_translations: {},
|
||||||
|
sort_order: 0,
|
||||||
|
is_active: true,
|
||||||
|
tenant_id: null,
|
||||||
|
is_global: false,
|
||||||
|
event_types: [],
|
||||||
|
created_at: null,
|
||||||
|
updated_at: null,
|
||||||
|
} as TenantEmotion;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Object.values(map);
|
||||||
|
}, [assignedTasks]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -101,7 +151,7 @@ export default function EventTasksPage() {
|
|||||||
const eventData = await getEvent(slug);
|
const eventData = await getEvent(slug);
|
||||||
const [eventTasksResponse, libraryTasks] = await Promise.all([
|
const [eventTasksResponse, libraryTasks] = await Promise.all([
|
||||||
getEventTasks(eventData.id, 1),
|
getEventTasks(eventData.id, 1),
|
||||||
getTasks({ per_page: 50 }),
|
getTasks({ per_page: 200 }),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setEvent(eventData);
|
setEvent(eventData);
|
||||||
@@ -135,36 +185,140 @@ export default function EventTasksPage() {
|
|||||||
};
|
};
|
||||||
}, [slug, t]);
|
}, [slug, t]);
|
||||||
|
|
||||||
async function handleAssign() {
|
|
||||||
if (!event || selected.length === 0) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await assignTasksToEvent(event.id, selected);
|
|
||||||
const refreshed = await getEventTasks(event.id, 1);
|
|
||||||
const assignedIds = new Set(refreshed.data.map((task) => task.id));
|
|
||||||
setAssignedTasks(refreshed.data);
|
|
||||||
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
|
|
||||||
setSelected([]);
|
|
||||||
} catch (err) {
|
|
||||||
if (!isAuthError(err)) {
|
|
||||||
setError(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setSelected((current) => current.filter((taskId) => availableTasks.some((task) => task.id === taskId)));
|
|
||||||
}, [availableTasks]);
|
|
||||||
|
|
||||||
const filteredAssignedTasks = React.useMemo(() => {
|
const filteredAssignedTasks = React.useMemo(() => {
|
||||||
|
let list = assignedTasks;
|
||||||
|
if (emotionFilter.length > 0) {
|
||||||
|
const set = new Set(emotionFilter);
|
||||||
|
list = list.filter((task) => (task.emotion_id ? set.has(task.emotion_id) : false));
|
||||||
|
}
|
||||||
if (!taskSearch.trim()) {
|
if (!taskSearch.trim()) {
|
||||||
return assignedTasks;
|
return list;
|
||||||
}
|
}
|
||||||
const term = taskSearch.toLowerCase();
|
const term = taskSearch.toLowerCase();
|
||||||
return assignedTasks.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
|
return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
|
||||||
}, [assignedTasks, taskSearch]);
|
}, [assignedTasks, taskSearch, emotionFilter]);
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||||
|
|
||||||
|
const handleAssignSingle = React.useCallback(
|
||||||
|
async (taskId: number) => {
|
||||||
|
if (!event) return;
|
||||||
|
const task = availableTasks.find((t) => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
setAvailableTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||||
|
setAssignedTasks((prev) => [...prev, task]);
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await assignTasksToEvent(event.id, [taskId]);
|
||||||
|
toast.success(t('actions.assignedToast', 'Tasks wurden zugewiesen.'));
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
toast.error(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
|
||||||
|
}
|
||||||
|
// revert optimistic change
|
||||||
|
if (task) {
|
||||||
|
setAssignedTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||||
|
setAvailableTasks((prev) => [...prev, task]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[availableTasks, event, hydrateTasks, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDetachSingle = React.useCallback(
|
||||||
|
async (taskId: number) => {
|
||||||
|
if (!event) return;
|
||||||
|
const task = assignedTasks.find((t) => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
setAssignedTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||||
|
setAvailableTasks((prev) => [...prev, task]);
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await detachTasksFromEvent(event.id, [taskId]);
|
||||||
|
toast.success(t('actions.removedToast', 'Tasks wurden entfernt.'));
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
toast.error(t('errors.remove', 'Tasks konnten nicht entfernt werden.'));
|
||||||
|
}
|
||||||
|
// revert optimistic change
|
||||||
|
if (task) {
|
||||||
|
setAvailableTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||||
|
setAssignedTasks((prev) => [...prev, task]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[assignedTasks, event, hydrateTasks, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || !active?.data?.current) {
|
||||||
|
setDraggingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originList = active.data.current.list as 'assigned' | 'library';
|
||||||
|
const overList = (over.data?.current?.list as 'assigned' | 'library' | undefined) ?? null;
|
||||||
|
const targetList =
|
||||||
|
overList ??
|
||||||
|
(over.id === 'assigned-dropzone'
|
||||||
|
? 'assigned'
|
||||||
|
: over.id === 'library-dropzone'
|
||||||
|
? 'library'
|
||||||
|
: null);
|
||||||
|
|
||||||
|
setDraggingId(null);
|
||||||
|
if (!targetList || targetList === originList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = Number(active.id);
|
||||||
|
if (Number.isNaN(taskId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetList === 'assigned') {
|
||||||
|
void handleAssignSingle(taskId);
|
||||||
|
} else {
|
||||||
|
void handleDetachSingle(taskId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateQuickTask = React.useCallback(async () => {
|
||||||
|
if (!event || !newTaskTitle.trim()) return;
|
||||||
|
setCreatingTask(true);
|
||||||
|
const emotion = emotions.find((e) => e.id === newTaskEmotionId) ?? null;
|
||||||
|
try {
|
||||||
|
const created = await createTask({
|
||||||
|
title: newTaskTitle.trim(),
|
||||||
|
description: newTaskDescription.trim() || null,
|
||||||
|
emotion_id: newTaskEmotionId ?? undefined,
|
||||||
|
event_type_id: event.event_type_id ?? undefined,
|
||||||
|
difficulty: newTaskDifficulty || undefined,
|
||||||
|
});
|
||||||
|
setAssignedTasks((prev) => [...prev, { ...created, emotion: emotion ?? created.emotion ?? null }]);
|
||||||
|
setAvailableTasks((prev) => prev.filter((task) => task.id !== created.id));
|
||||||
|
await assignTasksToEvent(event.id, [created.id]);
|
||||||
|
toast.success(t('actions.created', 'Aufgabe erstellt und zugewiesen.'));
|
||||||
|
setNewTaskTitle('');
|
||||||
|
setNewTaskDescription('');
|
||||||
|
setNewTaskEmotionId(null);
|
||||||
|
setNewTaskDifficulty('');
|
||||||
|
await hydrateTasks(event);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
toast.error(t('errors.create', 'Aufgabe konnte nicht erstellt werden.'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setCreatingTask(false);
|
||||||
|
}
|
||||||
|
}, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, hydrateTasks, t]);
|
||||||
|
|
||||||
const eventTabs = React.useMemo(() => {
|
const eventTabs = React.useMemo(() => {
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -183,7 +337,9 @@ export default function EventTasksPage() {
|
|||||||
setCollectionsLoading(true);
|
setCollectionsLoading(true);
|
||||||
setCollectionsError(null);
|
setCollectionsError(null);
|
||||||
const eventTypeSlug = event?.event_type?.slug ?? null;
|
const eventTypeSlug = event?.event_type?.slug ?? null;
|
||||||
const query = eventTypeSlug ? { per_page: 6, event_type: eventTypeSlug } : { per_page: 6 };
|
const query = eventTypeSlug
|
||||||
|
? { top_picks: true, limit: 6, event_type: eventTypeSlug }
|
||||||
|
: { top_picks: true, limit: 6 };
|
||||||
|
|
||||||
getTaskCollections(query)
|
getTaskCollections(query)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -264,6 +420,31 @@ export default function EventTasksPage() {
|
|||||||
return mode !== 'photo_only';
|
return mode !== 'photo_only';
|
||||||
}, [event?.engagement_mode, event?.settings]);
|
}, [event?.engagement_mode, event?.settings]);
|
||||||
|
|
||||||
|
const summaryBadges = !loading && event ? (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
<Badge className="flex items-center gap-2 rounded-full bg-slate-900 text-white">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-white/80">
|
||||||
|
{t('summary.assigned', 'Zugeordnete Tasks')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">{assignedTasks.length}</span>
|
||||||
|
</Badge>
|
||||||
|
<Badge className="flex items-center gap-2 rounded-full bg-emerald-600/90 text-white">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-white/80">
|
||||||
|
{t('summary.library', 'Bibliothek')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">{availableTasks.length}</span>
|
||||||
|
</Badge>
|
||||||
|
<Badge className="flex items-center gap-2 rounded-full bg-pink-500/90 text-white">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-white/80">
|
||||||
|
{t('summary.mode', 'Aktiver Modus')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
async function handleModeChange(checked: boolean) {
|
async function handleModeChange(checked: boolean) {
|
||||||
if (!event || !slug) return;
|
if (!event || !slug) return;
|
||||||
|
|
||||||
@@ -316,6 +497,8 @@ export default function EventTasksPage() {
|
|||||||
tabs={eventTabs}
|
tabs={eventTabs}
|
||||||
currentTabKey="tasks"
|
currentTabKey="tasks"
|
||||||
>
|
>
|
||||||
|
{summaryBadges}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
|
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||||
@@ -340,12 +523,6 @@ export default function EventTasksPage() {
|
|||||||
<TabsContent value="tasks" className="space-y-6">
|
<TabsContent value="tasks" className="space-y-6">
|
||||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
|
|
||||||
<CardDescription className="text-sm text-slate-600">
|
|
||||||
{t('eventStatus', {
|
|
||||||
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
|
|
||||||
})}
|
|
||||||
</CardDescription>
|
|
||||||
<div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
|
<div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -376,20 +553,6 @@ export default function EventTasksPage() {
|
|||||||
{t('modes.updating', 'Einstellung wird gespeichert ...')}
|
{t('modes.updating', 'Einstellung wird gespeichert ...')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid gap-3 text-xs sm:grid-cols-3">
|
|
||||||
<SummaryPill
|
|
||||||
label={t('summary.assigned', 'Zugeordnete Tasks')}
|
|
||||||
value={assignedTasks.length}
|
|
||||||
/>
|
|
||||||
<SummaryPill
|
|
||||||
label={t('summary.library', 'Bibliothek')}
|
|
||||||
value={availableTasks.length}
|
|
||||||
/>
|
|
||||||
<SummaryPill
|
|
||||||
label={t('summary.mode', 'Aktiver Modus')}
|
|
||||||
value={tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-0">
|
<CardContent className="pb-0">
|
||||||
@@ -413,8 +576,14 @@ export default function EventTasksPage() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
<DndContext
|
||||||
<section className="space-y-3">
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={(event) => setDraggingId(Number(event.active.id))}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<section className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||||
<Sparkles className="h-4 w-4 text-pink-500" />
|
<Sparkles className="h-4 w-4 text-pink-500" />
|
||||||
@@ -431,21 +600,65 @@ export default function EventTasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredAssignedTasks.length === 0 ? (
|
{emotionChips.length > 0 ? (
|
||||||
<EmptyState
|
<div className="flex flex-wrap gap-2">
|
||||||
message={
|
<Button
|
||||||
taskSearch.trim()
|
size="sm"
|
||||||
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
|
variant={emotionFilter.length === 0 ? 'default' : 'outline'}
|
||||||
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
|
onClick={() => setEmotionFilter([])}
|
||||||
}
|
className="rounded-full"
|
||||||
/>
|
>
|
||||||
) : (
|
{t('filters.allEmotions', 'Alle Emotionen')}
|
||||||
<div className="space-y-2">
|
</Button>
|
||||||
{filteredAssignedTasks.map((task) => (
|
{emotionChips.map((emotion) => {
|
||||||
<AssignedTaskRow key={task.id} task={task} />
|
const active = emotionFilter.includes(emotion.id);
|
||||||
))}
|
return (
|
||||||
|
<Button
|
||||||
|
key={emotion.id}
|
||||||
|
size="sm"
|
||||||
|
variant={active ? 'default' : 'outline'}
|
||||||
|
onClick={() =>
|
||||||
|
setEmotionFilter((prev) =>
|
||||||
|
active ? prev.filter((id) => id !== emotion.id) : [...prev, emotion.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-full"
|
||||||
|
style={
|
||||||
|
active
|
||||||
|
? { backgroundColor: emotion.color ?? '#e0f2fe', color: '#0f172a' }
|
||||||
|
: { borderColor: emotion.color ?? undefined, color: '#0f172a' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{emotion.icon ? <span className="mr-1">{emotion.icon}</span> : null}
|
||||||
|
{emotion.name}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
|
<DropZone id="assigned-dropzone">
|
||||||
|
{filteredAssignedTasks.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
message={
|
||||||
|
taskSearch.trim()
|
||||||
|
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
|
||||||
|
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredAssignedTasks.map((task) => (
|
||||||
|
<DraggableTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
origin="assigned"
|
||||||
|
onRemove={() => void handleDetachSingle(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DropZone>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -453,52 +666,102 @@ export default function EventTasksPage() {
|
|||||||
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
||||||
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
|
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2 rounded-2xl border border-emerald-100 bg-white/90 p-3 shadow-sm max-h-72 overflow-y-auto">
|
<div className="rounded-2xl border border-emerald-100 bg-emerald-50/60 p-3 shadow-inner">
|
||||||
{availableTasks.length === 0 ? (
|
<p className="text-xs font-semibold text-emerald-700">{t('sections.library.quickCreate', 'Schnell neue Aufgabe anlegen')}</p>
|
||||||
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
|
<div className="mt-2 grid gap-2">
|
||||||
) : (
|
<Input
|
||||||
availableTasks.map((task) => (
|
value={newTaskTitle}
|
||||||
<label key={task.id} className="flex items-start gap-3 rounded-xl border border-transparent p-2 transition hover:border-emerald-200">
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||||
<Checkbox
|
placeholder={t('sections.library.quickTitle', 'Titel der Aufgabe')}
|
||||||
checked={selected.includes(task.id)}
|
disabled={!tasksEnabled || creatingTask}
|
||||||
onCheckedChange={(checked) =>
|
/>
|
||||||
setSelected((prev) =>
|
<Textarea
|
||||||
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
|
value={newTaskDescription}
|
||||||
)
|
onChange={(e) => setNewTaskDescription(e.target.value)}
|
||||||
}
|
placeholder={t('sections.library.quickDescription', 'Beschreibung (optional)')}
|
||||||
|
disabled={!tasksEnabled || creatingTask}
|
||||||
|
className="min-h-[70px]"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-700">{t('sections.library.quickEmotion', 'Emotion')}</label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
||||||
|
value={newTaskEmotionId ?? ''}
|
||||||
|
onChange={(e) => setNewTaskEmotionId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
disabled={!tasksEnabled || creatingTask}
|
||||||
|
>
|
||||||
|
<option value="">{t('sections.library.quickEmotionNone', 'Keine')}</option>
|
||||||
|
{relevantEmotions.map((emotion) => (
|
||||||
|
<option key={emotion.id} value={emotion.id}>
|
||||||
|
{emotion.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-700">{t('sections.library.quickDifficulty', 'Schwierigkeit')}</label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
||||||
|
disabled={!tasksEnabled || creatingTask}
|
||||||
|
value={newTaskDifficulty}
|
||||||
|
onChange={(e) => setNewTaskDifficulty(e.target.value as TenantTask['difficulty'] | '')}
|
||||||
|
>
|
||||||
|
<option value="">{t('sections.library.quickDifficultyNone', 'Keine')}</option>
|
||||||
|
<option value="easy">{t('sections.library.difficulty.easy', 'Leicht')}</option>
|
||||||
|
<option value="medium">{t('sections.library.difficulty.medium', 'Mittel')}</option>
|
||||||
|
<option value="hard">{t('sections.library.difficulty.hard', 'Schwer')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleCreateQuickTask()}
|
||||||
|
disabled={!newTaskTitle.trim() || creatingTask || !tasksEnabled}
|
||||||
|
>
|
||||||
|
{creatingTask ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t('sections.library.quickCreateCta', 'Erstellen & zuweisen')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropZone id="library-dropzone">
|
||||||
|
<div className="space-y-2 max-h-72 overflow-y-auto">
|
||||||
|
{availableTasks.length === 0 ? (
|
||||||
|
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
|
||||||
|
) : (
|
||||||
|
availableTasks.map((task) => (
|
||||||
|
<DraggableTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
origin="library"
|
||||||
|
onAdd={() => void handleAssignSingle(task.id)}
|
||||||
disabled={!tasksEnabled}
|
disabled={!tasksEnabled}
|
||||||
/>
|
/>
|
||||||
<div>
|
))
|
||||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
)}
|
||||||
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
|
</div>
|
||||||
</div>
|
</DropZone>
|
||||||
</label>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => void handleAssign()}
|
|
||||||
disabled={saving || selected.length === 0 || !tasksEnabled}
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.assign', 'Ausgewählte Tasks zuweisen')}
|
|
||||||
</Button>
|
|
||||||
</section>
|
</section>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<DragOverlay>
|
||||||
|
{draggingId ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/90 px-4 py-3 shadow-sm opacity-80">
|
||||||
|
{filteredAssignedTasks.find((t) => t.id === draggingId)?.title ??
|
||||||
|
availableTasks.find((t) => t.id === draggingId)?.title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<BrandingStoryPanel
|
<EmotionsCard
|
||||||
event={event}
|
|
||||||
palette={palette}
|
|
||||||
emotions={relevantEmotions}
|
emotions={relevantEmotions}
|
||||||
emotionsLoading={emotionsLoading}
|
emotionsLoading={emotionsLoading}
|
||||||
emotionsError={emotionsError}
|
emotionsError={emotionsError}
|
||||||
collections={collections}
|
onOpenEmotions={() => setEmotionsModalOpen(true)}
|
||||||
onOpenBranding={() => {
|
|
||||||
if (!slug) return;
|
|
||||||
navigate(ADMIN_EVENT_BRANDING_PATH(slug));
|
|
||||||
}}
|
|
||||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
|
||||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="packs">
|
<TabsContent value="packs">
|
||||||
@@ -514,6 +777,15 @@ export default function EventTasksPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={emotionsModalOpen} onOpenChange={setEmotionsModalOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('tasks.emotions.manage', 'Emotionen verwalten')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<EmotionsSection embedded />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -536,17 +808,107 @@ function TaskSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssignedTaskRow({ task }: { task: TenantTask }) {
|
function DropZone({ id, children }: { id: string; children: React.ReactNode }) {
|
||||||
const { t } = useTranslation('management');
|
const zone = id === 'assigned-dropzone' ? 'assigned' : 'library';
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id, data: { list: zone } });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm">
|
<div
|
||||||
<div className="flex items-center justify-between">
|
ref={setNodeRef}
|
||||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
className={`rounded-2xl border border-dashed p-2 ${isOver ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200/70'}`}
|
||||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
>
|
||||||
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))}
|
{children}
|
||||||
</Badge>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableTaskCard({
|
||||||
|
task,
|
||||||
|
origin,
|
||||||
|
onRemove,
|
||||||
|
onAdd,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
task: TenantTask;
|
||||||
|
origin: 'assigned' | 'library';
|
||||||
|
onRemove?: () => void;
|
||||||
|
onAdd?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({
|
||||||
|
id: task.id,
|
||||||
|
data: { list: origin },
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: transform ? CSS.Translate.toString(transform) : undefined,
|
||||||
|
transition: transition || undefined,
|
||||||
|
opacity: isDragging ? 0.8 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<button
|
||||||
|
className="mt-1 h-7 w-7 rounded-md border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 disabled:opacity-50"
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('library.dragHandle', 'Task verschieben')}
|
||||||
|
>
|
||||||
|
⋮⋮
|
||||||
|
</button>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||||
|
{task.description ? <p className="text-xs text-slate-600">{task.description}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{task.emotion ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-transparent text-[11px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${task.emotion.color ?? '#eef2ff'}20`,
|
||||||
|
color: task.emotion.color ?? '#4338ca',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.emotion.icon ? <span className="mr-1">{task.emotion.icon}</span> : null}
|
||||||
|
{task.emotion.name}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||||
|
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))}
|
||||||
|
</Badge>
|
||||||
|
{origin === 'assigned' ? (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('actions.remove', 'Vom Event entfernen')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-slate-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('actions.assign', 'Zum Event hinzufügen')}
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-4 w-4 text-emerald-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -640,145 +1002,71 @@ function MissionPackGrid({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrandingStoryPanelProps = {
|
type EmotionsCardProps = {
|
||||||
event: TenantEvent;
|
|
||||||
palette: ReturnType<typeof extractBrandingPalette>;
|
|
||||||
emotions: TenantEmotion[];
|
emotions: TenantEmotion[];
|
||||||
emotionsLoading: boolean;
|
emotionsLoading: boolean;
|
||||||
emotionsError: string | null;
|
emotionsError: string | null;
|
||||||
collections: TenantTaskCollection[];
|
|
||||||
onOpenBranding: () => void;
|
|
||||||
onOpenEmotions: () => void;
|
onOpenEmotions: () => void;
|
||||||
onOpenCollections: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function BrandingStoryPanel({
|
function EmotionsCard({ emotions, emotionsLoading, emotionsError, onOpenEmotions }: EmotionsCardProps) {
|
||||||
event,
|
|
||||||
palette,
|
|
||||||
emotions,
|
|
||||||
emotionsLoading,
|
|
||||||
emotionsError,
|
|
||||||
collections,
|
|
||||||
onOpenBranding,
|
|
||||||
onOpenEmotions,
|
|
||||||
onOpenCollections,
|
|
||||||
}: BrandingStoryPanelProps) {
|
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const fallbackColors = palette.colors.length ? palette.colors : ['#f472b6', '#fde68a', '#312e81'];
|
const spotlightEmotions = emotions.slice(0, 6);
|
||||||
const spotlightEmotions = emotions.slice(0, 4);
|
|
||||||
const recommendedCollections = React.useMemo(() => collections.slice(0, 2), [collections]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50">
|
<Card className="border border-rose-100 bg-rose-50/70 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-base text-rose-900">
|
||||||
{t('tasks.story.title', 'Branding & Story')}
|
<Sparkles className="h-5 w-5 text-rose-500" />
|
||||||
</CardTitle>
|
{t('tasks.story.emotionsTitle', 'Emotionen')}
|
||||||
<CardDescription className="text-sm text-slate-600">
|
</CardTitle>
|
||||||
{t('tasks.story.description', 'Verbinde Farben, Emotionen und Mission Packs für ein stimmiges Gäste-Erlebnis.')}
|
<CardDescription className="text-sm text-rose-800">
|
||||||
|
{t('tasks.story.description', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
<CardContent className="space-y-3">
|
||||||
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/80 p-4 text-sm text-indigo-900 shadow-inner shadow-indigo-100">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs uppercase tracking-[0.3em]">
|
<Badge variant="outline" className="border-rose-200 text-rose-700">
|
||||||
{t('events.branding.brandingTitle', 'Branding')}
|
{t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiv', count: emotions.length })}
|
||||||
</p>
|
</Badge>
|
||||||
<p className="mt-1 text-base font-semibold">{palette.font ?? t('events.branding.brandingFallback', 'Aktuelle Auswahl')}</p>
|
<Button
|
||||||
<p className="text-xs text-indigo-900/70">
|
size="sm"
|
||||||
{t('events.branding.brandingCopy', 'Passe Farben & Schriftarten im Layout-Editor an.')}
|
className="bg-rose-600 text-white hover:bg-rose-700"
|
||||||
</p>
|
onClick={onOpenEmotions}
|
||||||
<div className="mt-3 flex gap-2">
|
>
|
||||||
{fallbackColors.slice(0, 4).map((color) => (
|
{t('tasks.story.emotionsCta', 'Emotionen verwalten')}
|
||||||
<span key={color} className="h-10 w-10 rounded-xl border border-white/70 shadow" style={{ backgroundColor: color }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="secondary" className="mt-4 rounded-full bg-white/80 text-indigo-900 hover:bg-white" onClick={onOpenBranding}>
|
|
||||||
{t('events.branding.brandingCta', 'Branding anpassen')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 rounded-2xl border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-900 shadow-inner shadow-rose-100">
|
{emotionsLoading ? (
|
||||||
<div>
|
<div className="h-10 animate-pulse rounded-xl bg-white/70" />
|
||||||
<div className="flex items-center justify-between">
|
) : emotionsError ? (
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-rose-400">
|
<p className="text-xs text-rose-900/70">{emotionsError}</p>
|
||||||
{t('tasks.story.emotionsTitle', 'Emotionen')}
|
) : spotlightEmotions.length ? (
|
||||||
</p>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant="outline" className="border-rose-200 text-rose-600">
|
{spotlightEmotions.map((emotion) => (
|
||||||
{t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiviert', count: emotions.length })}
|
<span
|
||||||
</Badge>
|
key={emotion.id}
|
||||||
</div>
|
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
|
||||||
{emotionsLoading ? (
|
style={{
|
||||||
<div className="mt-3 h-10 animate-pulse rounded-xl bg-white/70" />
|
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
|
||||||
) : emotionsError ? (
|
color: emotion.color ?? '#be123c',
|
||||||
<p className="mt-3 text-xs text-rose-900/70">{emotionsError}</p>
|
}}
|
||||||
) : spotlightEmotions.length ? (
|
>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
{emotion.icon ? <span>{emotion.icon}</span> : null}
|
||||||
{spotlightEmotions.map((emotion) => (
|
{emotion.name}
|
||||||
<span
|
</span>
|
||||||
key={emotion.id}
|
))}
|
||||||
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
|
|
||||||
color: emotion.color ?? '#be123c',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emotion.icon ? <span>{emotion.icon}</span> : null}
|
|
||||||
{emotion.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-3 text-xs text-rose-900/70">
|
|
||||||
{t('tasks.story.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button size="sm" variant="ghost" className="mt-3 h-8 px-0 text-rose-700 hover:bg-rose-100/80" onClick={onOpenEmotions}>
|
|
||||||
{t('tasks.story.emotionsCta', 'Emotionen verwalten')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-white/60 bg-white/80 p-3 text-sm text-slate-700">
|
) : (
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">
|
<p className="text-xs text-rose-900/70">
|
||||||
{t('tasks.story.collectionsTitle', 'Mission Packs')}
|
{t('tasks.story.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
|
||||||
</p>
|
</p>
|
||||||
{recommendedCollections.length ? (
|
)}
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{recommendedCollections.map((collection) => (
|
|
||||||
<div key={collection.id} className="flex items-center justify-between rounded-xl border border-slate-200 bg-white/90 px-3 py-2 text-xs">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-slate-900">{collection.name}</p>
|
|
||||||
{collection.event_type?.name ? (
|
|
||||||
<p className="text-[11px] text-slate-500">{collection.event_type.name}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="border-slate-200 text-slate-600">
|
|
||||||
{t('tasks.story.collectionsCount', { defaultValue: '{{count}} Aufgaben', count: collection.tasks_count })}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-3 text-xs text-slate-500">
|
|
||||||
{t('tasks.story.collectionsEmpty', 'Noch keine empfohlenen Mission Packs.')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button size="sm" variant="outline" className="mt-3 border-rose-200 text-rose-700 hover:bg-rose-50" onClick={onOpenCollections}>
|
|
||||||
{t('tasks.story.collectionsCta', 'Mission Packs anzeigen')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryPill({ label, value }: { label: string; value: string | number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-3 text-center">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold text-slate-900">{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string {
|
function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'low':
|
case 'low':
|
||||||
@@ -791,10 +1079,3 @@ function mapPriority(priority: TenantTask['priority'], translate: (key: string,
|
|||||||
return translate('management.eventTasks.priorities.medium', 'Mittel');
|
return translate('management.eventTasks.priorities.medium', 'Mittel');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderName(name: TenantEvent['name'], translate: (key: string, defaultValue: string) => string): string {
|
|
||||||
if (typeof name === 'string') {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? translate('management.members.events.untitled', 'Unbenanntes Event');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../api';
|
} from '../api';
|
||||||
import { buildEngagementTabPath } from '../constants';
|
import { buildEngagementTabPath } from '../constants';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 12;
|
const DEFAULT_PAGE_SIZE = 12;
|
||||||
|
|
||||||
@@ -51,7 +52,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
|
|||||||
const [scope, setScope] = React.useState<ScopeFilter>('all');
|
const [scope, setScope] = React.useState<ScopeFilter>('all');
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null);
|
const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null);
|
||||||
@@ -86,6 +86,7 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError(t('collections.notifications.error'));
|
setError(t('collections.notifications.error'));
|
||||||
|
toast.error(t('collections.notifications.error'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -101,14 +102,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
|
|||||||
};
|
};
|
||||||
}, [page, search, scopeParam, reloadToken, t]);
|
}, [page, search, scopeParam, reloadToken, t]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (successMessage) {
|
|
||||||
const timeout = setTimeout(() => setSuccessMessage(null), 4000);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [successMessage]);
|
|
||||||
|
|
||||||
async function ensureEventsLoaded() {
|
async function ensureEventsLoaded() {
|
||||||
if (events.length > 0 || eventsLoading) {
|
if (events.length > 0 || eventsLoading) {
|
||||||
return;
|
return;
|
||||||
@@ -144,12 +137,13 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
|
|||||||
setEventError(null);
|
setEventError(null);
|
||||||
try {
|
try {
|
||||||
await importTaskCollection(selectedCollection.id, selectedEventSlug);
|
await importTaskCollection(selectedCollection.id, selectedEventSlug);
|
||||||
setSuccessMessage(t('collections.notifications.imported'));
|
toast.success(t('collections.notifications.imported'));
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setReloadToken((token) => token + 1);
|
setReloadToken((token) => token + 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setEventError(t('collections.notifications.error'));
|
setEventError(t('collections.notifications.error'));
|
||||||
|
toast.error(t('collections.notifications.error'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
@@ -181,13 +175,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{successMessage && (
|
|
||||||
<Alert className="border-l-4 border-green-500 bg-green-50 text-sm text-green-900">
|
|
||||||
<AlertTitle>{t('collections.notifications.imported')}</AlertTitle>
|
|
||||||
<AlertDescription>{successMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
AlignCenter,
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
|
AlignRight,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Download,
|
Download,
|
||||||
@@ -611,6 +613,16 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
[availableFonts, updateElement]
|
[availableFonts, updateElement]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleFontOptionPreview = React.useCallback(
|
||||||
|
(family: string) => {
|
||||||
|
const font = availableFonts.find((entry) => entry.family === family);
|
||||||
|
if (font) {
|
||||||
|
void ensureFontLoaded(font);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[availableFonts]
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!invite) {
|
if (!invite) {
|
||||||
setAvailableLayouts([]);
|
setAvailableLayouts([]);
|
||||||
@@ -1315,14 +1327,17 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
value={element.align ?? 'left'}
|
value={element.align ?? 'left'}
|
||||||
onValueChange={(value) => value && updateElementAlign(element.id, value as 'left' | 'center' | 'right')}
|
onValueChange={(value) => value && updateElementAlign(element.id, value as 'left' | 'center' | 'right')}
|
||||||
>
|
>
|
||||||
<ToggleGroupItem value="left" className="px-3">
|
<ToggleGroupItem value="left" className="px-3" title={t('invites.customizer.elements.alignLeft', 'Links')} aria-label={t('invites.customizer.elements.alignLeft', 'Links')}>
|
||||||
{t('invites.customizer.elements.alignLeft', 'Links')}
|
<AlignLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t('invites.customizer.elements.alignLeft', 'Links')}</span>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
<ToggleGroupItem value="center" className="px-3">
|
<ToggleGroupItem value="center" className="px-3" title={t('invites.customizer.elements.alignCenter', 'Zentriert')} aria-label={t('invites.customizer.elements.alignCenter', 'Zentriert')}>
|
||||||
{t('invites.customizer.elements.alignCenter', 'Zentriert')}
|
<AlignCenter className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t('invites.customizer.elements.alignCenter', 'Zentriert')}</span>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
<ToggleGroupItem value="right" className="px-3">
|
<ToggleGroupItem value="right" className="px-3" title={t('invites.customizer.elements.alignRight', 'Rechts')} aria-label={t('invites.customizer.elements.alignRight', 'Rechts')}>
|
||||||
{t('invites.customizer.elements.alignRight', 'Rechts')}
|
<AlignRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t('invites.customizer.elements.alignRight', 'Rechts')}</span>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
@@ -1352,7 +1367,21 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={DEFAULT_FONT_VALUE}>{t('invites.customizer.elements.fontPlaceholder', 'Standard')}</SelectItem>
|
<SelectItem value={DEFAULT_FONT_VALUE}>{t('invites.customizer.elements.fontPlaceholder', 'Standard')}</SelectItem>
|
||||||
{availableFonts.map((font) => (
|
{availableFonts.map((font) => (
|
||||||
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
<SelectItem
|
||||||
|
key={font.family}
|
||||||
|
value={font.family}
|
||||||
|
onMouseEnter={() => handleFontOptionPreview(font.family)}
|
||||||
|
onFocus={() => handleFontOptionPreview(font.family)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate" style={{ fontFamily: font.family }}>
|
||||||
|
{font.family}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>
|
||||||
|
AaBb
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -1360,6 +1389,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
value={element.fontFamily ?? ''}
|
value={element.fontFamily ?? ''}
|
||||||
onChange={(event) => handleElementFontChange(element.id, event.target.value)}
|
onChange={(event) => handleElementFontChange(element.id, event.target.value)}
|
||||||
placeholder="z. B. Playfair Display"
|
placeholder="z. B. Playfair Display"
|
||||||
|
style={element.fontFamily ? { fontFamily: element.fontFamily } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1503,7 +1533,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
const normalizedFormat = format.toLowerCase();
|
const normalizedFormat = format.toLowerCase();
|
||||||
const eventDateSegment = normalizeEventDateSegment(eventDate);
|
const eventDateSegment = normalizeEventDateSegment(eventDate);
|
||||||
const filename = buildDownloadFilename(
|
const filename = buildDownloadFilename(
|
||||||
['Einladungslayout', eventName, activeLayout?.name ?? null, eventDateSegment],
|
['QR-Layout', eventName, activeLayout?.name ?? null, eventDateSegment],
|
||||||
normalizedFormat,
|
normalizedFormat,
|
||||||
'einladungslayout',
|
'einladungslayout',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,10 +53,16 @@ const normalizeImageUrl = (src?: string | null) => {
|
|||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
const { token } = useParams<{ token?: string }>();
|
const { token } = useParams<{ token?: string }>();
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
|
const { branding } = useEventBranding();
|
||||||
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '', locale);
|
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '', locale);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const photoIdParam = searchParams.get('photoId');
|
const photoIdParam = searchParams.get('photoId');
|
||||||
const modeParam = searchParams.get('mode');
|
const modeParam = searchParams.get('mode');
|
||||||
|
const radius = branding.buttons?.radius ?? 12;
|
||||||
|
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||||||
|
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
||||||
|
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||||
|
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||||||
const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
|
const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||||
@@ -178,7 +184,11 @@ export default function GalleryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildShareText = (fallback?: string) => t('share.shareText', { event: event?.name ?? fallback ?? 'Fotospiel' });
|
const buildShareText = (fallback?: string) => {
|
||||||
|
const eventName = event?.name ?? fallback ?? 'Fotospiel';
|
||||||
|
const base = t('share.shareText', 'Schau dir diesen Moment bei Fotospiel an.');
|
||||||
|
return `${eventName} – ${base}`;
|
||||||
|
};
|
||||||
|
|
||||||
async function onShare(photo: GalleryPhoto) {
|
async function onShare(photo: GalleryPhoto) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -436,83 +446,94 @@ export default function GalleryPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{shareSheet.photo && (
|
{shareSheet.photo && (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-t-3xl bg-white p-4 shadow-xl dark:bg-slate-900">
|
<div
|
||||||
<div className="mb-3 flex items-center justify-between">
|
className="w-full max-w-md rounded-t-3xl border border-border bg-white/98 p-4 text-slate-900 shadow-2xl ring-1 ring-black/10 backdrop-blur-md dark:border-white/10 dark:bg-slate-900/98 dark:text-white"
|
||||||
<div>
|
style={{ ...(bodyFont ? { fontFamily: bodyFont } : {}), borderRadius: radius }}
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{t('share.title', 'Geteiltes Foto')}
|
{t('share.title', 'Geteiltes Foto')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold text-foreground">#{shareSheet.photo.id}</p>
|
<p className="text-base font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||||||
|
#{shareSheet.photo.id}
|
||||||
|
</p>
|
||||||
|
{event?.name && <p className="text-xs text-muted-foreground line-clamp-2">{event.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full border border-muted px-3 py-1 text-xs font-semibold"
|
className="rounded-full border border-muted px-3 py-1 text-xs font-semibold text-foreground transition hover:bg-muted/80 dark:border-white/20 dark:text-white"
|
||||||
|
style={{ borderRadius: radius }}
|
||||||
onClick={closeShareSheet}
|
onClick={closeShareSheet}
|
||||||
>
|
>
|
||||||
{t('lightbox.close', 'Schließen')}
|
{t('lightbox.close', 'Schließen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 rounded-2xl border border-muted bg-muted/40 px-3 py-3 text-left text-sm font-semibold transition hover:bg-muted/60"
|
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-800 disabled:opacity-100 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/10 dark:disabled:text-white/80"
|
||||||
onClick={() => shareNative(shareSheet.url)}
|
onClick={() => shareNative(shareSheet.url)}
|
||||||
disabled={shareSheet.loading}
|
disabled={shareSheet.loading}
|
||||||
>
|
style={{ borderRadius: radius }}
|
||||||
<Share2 className="h-4 w-4" />
|
>
|
||||||
<div>
|
<Share2 className="h-4 w-4" aria-hidden />
|
||||||
<div>{t('share.button', 'Teilen')}</div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">{t('share.title', 'Geteiltes Foto')}</div>
|
<div>{t('share.button', 'Teilen')}</div>
|
||||||
</div>
|
<div className="text-xs text-slate-600 dark:text-white/70">{t('share.title', 'Geteiltes Foto')}</div>
|
||||||
</button>
|
</div>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
className="flex items-center gap-2 rounded-2xl border border-muted bg-emerald-50 px-3 py-3 text-left text-sm font-semibold text-emerald-700 transition hover:bg-emerald-100 dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-200"
|
type="button"
|
||||||
onClick={() => shareWhatsApp(shareSheet.url)}
|
className="flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-emerald-600 disabled:opacity-60 dark:border-emerald-400/40"
|
||||||
disabled={shareSheet.loading}
|
onClick={() => shareWhatsApp(shareSheet.url)}
|
||||||
>
|
disabled={shareSheet.loading}
|
||||||
<WhatsAppIcon className="h-5 w-5" />
|
style={{ borderRadius: radius }}
|
||||||
<div>
|
>
|
||||||
<div>{t('share.whatsapp', 'WhatsApp')}</div>
|
<WhatsAppIcon className="h-5 w-5" />
|
||||||
<div className="text-xs text-muted-foreground">{shareSheet.loading ? '…' : ''}</div>
|
<div>
|
||||||
</div>
|
<div>{t('share.whatsapp', 'WhatsApp')}</div>
|
||||||
</button>
|
<div className="text-xs text-white/80">{shareSheet.loading ? '…' : ''}</div>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</button>
|
||||||
className="flex items-center gap-2 rounded-2xl border border-muted bg-sky-50 px-3 py-3 text-left text-sm font-semibold text-sky-700 transition hover:bg-sky-100 dark:border-sky-900/40 dark:bg-sky-900/20 dark:text-sky-200"
|
<button
|
||||||
onClick={() => shareMessages(shareSheet.url)}
|
type="button"
|
||||||
disabled={shareSheet.loading}
|
className="flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-sky-600 disabled:opacity-60 dark:border-sky-400/40"
|
||||||
>
|
onClick={() => shareMessages(shareSheet.url)}
|
||||||
<MessageSquare className="h-5 w-5" />
|
disabled={shareSheet.loading}
|
||||||
<div>
|
style={{ borderRadius: radius }}
|
||||||
<div>{t('share.imessage', 'Nachrichten')}</div>
|
>
|
||||||
<div className="text-xs text-muted-foreground">{shareSheet.loading ? '…' : ''}</div>
|
<MessageSquare className="h-5 w-5" />
|
||||||
</div>
|
<div>
|
||||||
</button>
|
<div>{t('share.imessage', 'Nachrichten')}</div>
|
||||||
<button
|
<div className="text-xs text-white/80">{shareSheet.loading ? '…' : ''}</div>
|
||||||
type="button"
|
</div>
|
||||||
className="flex items-center gap-2 rounded-2xl border border-muted bg-muted/40 px-3 py-3 text-left text-sm font-semibold transition hover:bg-muted/60"
|
</button>
|
||||||
onClick={() => copyLink(shareSheet.url)}
|
<button
|
||||||
disabled={shareSheet.loading}
|
type="button"
|
||||||
>
|
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/5 dark:disabled:text-white/50"
|
||||||
<Copy className="h-4 w-4" />
|
onClick={() => copyLink(shareSheet.url)}
|
||||||
<div>
|
disabled={shareSheet.loading}
|
||||||
<div>{t('share.copyLink', 'Link kopieren')}</div>
|
style={{ borderRadius: radius }}
|
||||||
<div className="text-xs text-muted-foreground">{shareSheet.loading ? t('share.loading', 'Lädt…') : ''}</div>
|
>
|
||||||
</div>
|
<Copy className="h-4 w-4" aria-hidden />
|
||||||
</button>
|
<div>
|
||||||
</div>
|
<div className="text-slate-900 dark:text-white">{t('share.copyLink', 'Link kopieren')}</div>
|
||||||
|
<div className="text-xs text-slate-600 dark:text-white/80">{shareSheet.loading ? t('share.loading', 'Lädt…') : ''}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shareSheet.url && (
|
||||||
|
<p className="mt-3 truncate text-xs text-slate-700 dark:text-white/80" title={shareSheet.url}>
|
||||||
|
{shareSheet.url}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { branding } = useEventBranding();
|
|
||||||
const radius = branding.buttons?.radius ?? 12;
|
|
||||||
const buttonStyle = branding.buttons?.style ?? 'filled';
|
|
||||||
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
|
||||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
|
||||||
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ function EventBoundary({ token }: { token: string }) {
|
|||||||
|
|
||||||
const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
||||||
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
|
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
|
||||||
const branding = mapEventBranding(event.branding);
|
const branding = mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||||
@@ -139,7 +139,7 @@ function SetupLayout() {
|
|||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
||||||
const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`;
|
const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`;
|
||||||
const branding = event ? mapEventBranding(event.branding) : null;
|
const branding = event ? mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null) : null;
|
||||||
return (
|
return (
|
||||||
<GuestIdentityProvider eventKey={token}>
|
<GuestIdentityProvider eventKey={token}>
|
||||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->name('tenant.tasks.for-event');
|
->name('tenant.tasks.for-event');
|
||||||
Route::get('tasks/collection/{collection}', [TaskController::class, 'fromCollection'])
|
Route::get('tasks/collection/{collection}', [TaskController::class, 'fromCollection'])
|
||||||
->name('tenant.tasks.from-collection');
|
->name('tenant.tasks.from-collection');
|
||||||
|
Route::post('tasks/bulk-detach-event/{event}', [TaskController::class, 'bulkDetachFromEvent'])
|
||||||
|
->name('tenant.tasks.bulk-detach-from-event');
|
||||||
|
Route::post('tasks/event/{event}/reorder', [TaskController::class, 'reorderForEvent'])
|
||||||
|
->name('tenant.tasks.reorder-for-event');
|
||||||
|
|
||||||
Route::get('task-collections', [TaskCollectionController::class, 'index'])
|
Route::get('task-collections', [TaskCollectionController::class, 'index'])
|
||||||
->name('tenant.task-collections.index');
|
->name('tenant.task-collections.index');
|
||||||
|
|||||||
@@ -72,5 +72,121 @@ class SyncGoogleFontsTest extends TestCase
|
|||||||
|
|
||||||
File::deleteDirectory($targetPath);
|
File::deleteDirectory($targetPath);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
public function test_it_filters_by_category(): void
|
||||||
|
{
|
||||||
|
$targetPath = storage_path('app/test-fonts');
|
||||||
|
File::deleteDirectory($targetPath);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://www.googleapis.com/webfonts/v1/webfonts*' => Http::response([
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'family' => 'Alpha Sans',
|
||||||
|
'category' => 'sans-serif',
|
||||||
|
'files' => [
|
||||||
|
'regular' => 'https://fonts.gstatic.com/s/alpha-regular.woff2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'family' => 'Beta Serif',
|
||||||
|
'category' => 'serif',
|
||||||
|
'files' => [
|
||||||
|
'regular' => 'https://fonts.gstatic.com/s/beta-regular.woff2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'https://fonts.gstatic.com/*' => Http::response('font-binary', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Artisan::call('fonts:sync-google', [
|
||||||
|
'--count' => 5,
|
||||||
|
'--category' => 'serif',
|
||||||
|
'--path' => 'storage/app/test-fonts',
|
||||||
|
'--force' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$manifestPath = $targetPath.'/manifest.json';
|
||||||
|
$manifest = json_decode(File::get($manifestPath), true);
|
||||||
|
|
||||||
|
$this->assertSame(1, $manifest['count']);
|
||||||
|
$this->assertSame('Beta Serif', $manifest['fonts'][0]['family']);
|
||||||
|
|
||||||
|
File::deleteDirectory($targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dry_run_does_not_write_files_or_download_fonts(): void
|
||||||
|
{
|
||||||
|
$targetPath = storage_path('app/test-fonts');
|
||||||
|
File::deleteDirectory($targetPath);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://www.googleapis.com/webfonts/v1/webfonts*' => Http::response([
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'family' => 'Alpha Sans',
|
||||||
|
'category' => 'sans-serif',
|
||||||
|
'files' => [
|
||||||
|
'regular' => 'https://fonts.gstatic.com/s/alpha-regular.woff2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'https://fonts.gstatic.com/*' => function () {
|
||||||
|
$this->fail('Font files should not be requested during dry run.');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
Artisan::call('fonts:sync-google', [
|
||||||
|
'--count' => 1,
|
||||||
|
'--path' => 'storage/app/test-fonts',
|
||||||
|
'--dry-run' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDirectoryDoesNotExist($targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_specific_family_even_when_count_is_smaller(): void
|
||||||
|
{
|
||||||
|
$targetPath = storage_path('app/test-fonts');
|
||||||
|
File::deleteDirectory($targetPath);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://www.googleapis.com/webfonts/v1/webfonts*' => Http::response([
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'family' => 'Alpha Sans',
|
||||||
|
'category' => 'sans-serif',
|
||||||
|
'files' => [
|
||||||
|
'regular' => 'https://fonts.gstatic.com/s/alpha-regular.woff2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'family' => 'Beta Serif',
|
||||||
|
'category' => 'serif',
|
||||||
|
'files' => [
|
||||||
|
'regular' => 'https://fonts.gstatic.com/s/beta-regular.woff2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'https://fonts.gstatic.com/*' => Http::response('font-binary', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Artisan::call('fonts:sync-google', [
|
||||||
|
'--count' => 1,
|
||||||
|
'--family' => 'Beta Serif',
|
||||||
|
'--path' => 'storage/app/test-fonts',
|
||||||
|
'--force' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$manifestPath = $targetPath.'/manifest.json';
|
||||||
|
$manifest = json_decode(File::get($manifestPath), true);
|
||||||
|
|
||||||
|
$this->assertSame(1, $manifest['count']);
|
||||||
|
$this->assertSame('Beta Serif', $manifest['fonts'][0]['family']);
|
||||||
|
|
||||||
|
File::deleteDirectory($targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user