zu fabricjs gewechselt, noch nicht funktionsfähig

This commit is contained in:
Codex Agent
2025-10-31 20:19:09 +01:00
parent 06df61f706
commit eb0c31c90b
33 changed files with 7718 additions and 2062 deletions

View File

@@ -97,11 +97,23 @@ class InviteLayoutResource extends Resource
}
$layoutOptions = $data['layout_options'] ?? [];
$formats = $layoutOptions['formats'] ?? ['pdf', 'svg'];
$formats = $layoutOptions['formats'] ?? ['pdf', 'png'];
if (is_string($formats)) {
$formats = array_values(array_filter(array_map('trim', explode(',', $formats))));
}
$layoutOptions['formats'] = $formats ?: ['pdf', 'svg'];
$normalizedFormats = [];
foreach ($formats ?: ['pdf', 'png'] as $format) {
$value = strtolower((string) $format);
if ($value === 'svg') {
$value = 'png';
}
if (in_array($value, ['pdf', 'png'], true) && ! in_array($value, $normalizedFormats, true)) {
$normalizedFormats[] = $value;
}
}
$layoutOptions['formats'] = $normalizedFormats ?: ['pdf', 'png'];
$data['layout_options'] = array_filter([
'badge_label' => $layoutOptions['badge_label'] ?? null,

View File

@@ -11,6 +11,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class EventJoinTokenController extends Controller
{
@@ -140,6 +141,24 @@ class EventJoinTokenController extends Controller
'metadata.layout_customization.background_gradient.angle' => ['nullable', 'numeric'],
'metadata.layout_customization.background_gradient.stops' => ['nullable', 'array', 'max:5'],
'metadata.layout_customization.background_gradient.stops.*' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.mode' => ['nullable', Rule::in(['standard', 'advanced'])],
'metadata.layout_customization.elements' => ['nullable', 'array', 'max:50'],
'metadata.layout_customization.elements.*.id' => ['required_with:metadata.layout_customization.elements', 'string', 'max:120'],
'metadata.layout_customization.elements.*.type' => ['required_with:metadata.layout_customization.elements', Rule::in(['qr', 'headline', 'subtitle', 'description', 'link', 'badge', 'logo', 'cta', 'text'])],
'metadata.layout_customization.elements.*.x' => ['nullable', 'numeric', 'min:0'],
'metadata.layout_customization.elements.*.y' => ['nullable', 'numeric', 'min:0'],
'metadata.layout_customization.elements.*.width' => ['nullable', 'numeric', 'min:40'],
'metadata.layout_customization.elements.*.height' => ['nullable', 'numeric', 'min:40'],
'metadata.layout_customization.elements.*.rotation' => ['nullable', 'numeric'],
'metadata.layout_customization.elements.*.font_size' => ['nullable', 'numeric', 'min:8', 'max:160'],
'metadata.layout_customization.elements.*.align' => ['nullable', Rule::in(['left', 'center', 'right'])],
'metadata.layout_customization.elements.*.content' => ['nullable', 'string', 'max:400'],
'metadata.layout_customization.elements.*.font_family' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.elements.*.letter_spacing' => ['nullable', 'numeric', 'min:-5', 'max:20'],
'metadata.layout_customization.elements.*.line_height' => ['nullable', 'numeric', 'min:0.5', 'max:3'],
'metadata.layout_customization.elements.*.fill' => ['nullable', 'string', 'max:20'],
'metadata.layout_customization.elements.*.locked' => ['nullable', 'boolean'],
'metadata.layout_customization.elements.*.initial' => ['nullable', 'boolean'],
];
$validated = $request->validate($rules);
@@ -156,6 +175,36 @@ class EventJoinTokenController extends Controller
unset($validated['metadata']['layout_customization']['logo_data_url']);
}
if (isset($validated['metadata']['layout_customization']['elements'])
&& is_array($validated['metadata']['layout_customization']['elements'])) {
$validated['metadata']['layout_customization']['elements'] = array_values(array_filter(array_map(
static function ($element) {
if (! is_array($element) || empty($element['id']) || empty($element['type'])) {
return null;
}
return array_filter([
'id' => (string) $element['id'],
'type' => (string) $element['type'],
'x' => array_key_exists('x', $element) ? (float) $element['x'] : null,
'y' => array_key_exists('y', $element) ? (float) $element['y'] : null,
'width' => array_key_exists('width', $element) ? (float) $element['width'] : null,
'height' => array_key_exists('height', $element) ? (float) $element['height'] : null,
'rotation' => array_key_exists('rotation', $element) ? (float) $element['rotation'] : null,
'font_size' => array_key_exists('font_size', $element) ? (float) $element['font_size'] : null,
'align' => $element['align'] ?? null,
'content' => array_key_exists('content', $element) ? (string) $element['content'] : null,
'font_family' => $element['font_family'] ?? null,
'letter_spacing' => array_key_exists('letter_spacing', $element) ? (float) $element['letter_spacing'] : null,
'line_height' => array_key_exists('line_height', $element) ? (float) $element['line_height'] : null,
'fill' => $element['fill'] ?? null,
'locked' => array_key_exists('locked', $element) ? (bool) $element['locked'] : null,
], static fn ($value) => $value !== null && $value !== '');
},
$validated['metadata']['layout_customization']['elements']
)));
}
return $validated;
}
}

View File

@@ -42,7 +42,7 @@ class EventJoinTokenLayoutController extends Controller
abort(404, 'Layout nicht gefunden.');
}
if (! in_array($format, ['pdf', 'svg'], true)) {
if (! in_array($format, ['pdf', 'png'], true)) {
abort(404, 'Unbekanntes Exportformat.');
}
@@ -69,18 +69,15 @@ class EventJoinTokenLayoutController extends Controller
'qrPngDataUri' => $qrPngDataUri,
'backgroundStyle' => $backgroundStyle,
'customization' => $joinToken->metadata['layout_customization'] ?? null,
'advancedLayout' => $this->buildAdvancedLayout(
$layoutConfig,
$joinToken->metadata['layout_customization'] ?? null,
$qrPngDataUri,
$tokenUrl,
$eventName
),
];
$filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format);
if ($format === 'svg') {
$svg = view('layouts.join-token.svg', $viewData)->render();
return response($svg)
->header('Content-Type', 'image/svg+xml')
->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
}
$html = view('layouts.join-token.pdf', $viewData)->render();
$options = new Options;
@@ -93,9 +90,46 @@ class EventJoinTokenLayoutController extends Controller
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->render();
return response($dompdf->output())
$pdfBinary = $dompdf->output();
$filenameStem = sprintf('%s-%s', Str::slug($eventName ?: 'event'), $layoutConfig['id']);
if ($format === 'png') {
if (! class_exists(\Imagick::class)) {
abort(500, 'PNG-Export erfordert Imagick.');
}
try {
$imagick = new \Imagick;
$imagick->setResolution(300, 300);
$imagick->setBackgroundColor(new \ImagickPixel('white'));
$imagick->setOption('pdf:use-cropbox', 'true');
$imagick->readImageBlob($pdfBinary);
$imagick->setIteratorIndex(0);
$flattened = $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
$flattened->setImageFormat('png');
$flattened->setImageUnits(\Imagick::RESOLUTION_PIXELSPERINCH);
$flattened->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
$pngBinary = $flattened->getImagesBlob();
$flattened->clear();
$flattened->destroy();
$imagick->clear();
$imagick->destroy();
} catch (\Throwable $exception) {
report($exception);
abort(500, 'PNG-Export konnte nicht erzeugt werden.');
}
return response($pngBinary)
->header('Content-Type', 'image/png')
->header('Content-Disposition', 'attachment; filename="'.$filenameStem.'.png"');
}
return response($pdfBinary)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
->header('Content-Disposition', 'attachment; filename="'.$filenameStem.'.pdf"');
}
private function ensureBelongsToEvent(Event $event, EventJoinToken $joinToken): void
@@ -182,4 +216,168 @@ class EventJoinTokenLayoutController extends Controller
return $layout['background'] ?? '#FFFFFF';
}
private function buildAdvancedLayout(array $layout, $customization, string $qrPngDataUri, string $tokenUrl, string $eventName): ?array
{
if (! is_array($customization)) {
return null;
}
if (($customization['mode'] ?? null) !== 'advanced') {
return null;
}
$elements = $customization['elements'] ?? null;
if (! is_array($elements) || empty($elements)) {
return null;
}
$width = (int) ($layout['svg']['width'] ?? 1080);
$height = (int) ($layout['svg']['height'] ?? 1520);
$accent = $layout['accent'] ?? '#6366F1';
$text = $layout['text'] ?? '#0F172A';
$secondary = $layout['secondary'] ?? '#1F2937';
$badge = $layout['badge'] ?? $accent;
$resolved = [];
foreach ($elements as $element) {
if (! is_array($element) || empty($element['id']) || empty($element['type'])) {
continue;
}
$type = (string) $element['type'];
$dimensions = $this->normalizeElementDimensions($type, $element, $width, $height);
$content = $this->resolveElementContent($type, $customization, $layout, $eventName, $tokenUrl, $element['content'] ?? null);
$style = [
'position' => 'absolute',
'left' => sprintf('%dpx', $dimensions['x']),
'top' => sprintf('%dpx', $dimensions['y']),
'width' => sprintf('%dpx', $dimensions['width']),
'height' => sprintf('%dpx', $dimensions['height']),
];
if (isset($element['rotation']) && is_numeric($element['rotation']) && (float) $element['rotation'] !== 0.0) {
$style['transform'] = sprintf('rotate(%sdeg)', (float) $element['rotation']);
$style['transform-origin'] = 'center center';
}
$fontSize = isset($element['font_size']) && is_numeric($element['font_size'])
? max(8, min(160, (float) $element['font_size']))
: null;
$lineHeight = isset($element['line_height']) && is_numeric($element['line_height'])
? max(0.5, min(3.0, (float) $element['line_height']))
: null;
$letterSpacing = isset($element['letter_spacing']) && is_numeric($element['letter_spacing'])
? (float) $element['letter_spacing']
: null;
$resolved[] = [
'id' => (string) $element['id'],
'type' => $type,
'content' => $content,
'align' => $element['align'] ?? null,
'font_size' => $fontSize,
'line_height' => $lineHeight,
'letter_spacing' => $letterSpacing,
'font_family' => $element['font_family'] ?? null,
'fill' => $element['fill'] ?? null,
'style' => $style,
'style_string' => $this->styleToString($style),
'width' => $dimensions['width'],
'height' => $dimensions['height'],
'asset' => match ($type) {
'qr' => $qrPngDataUri,
'logo' => $customization['logo_data_url'] ?? $customization['logo_url'] ?? $layout['logo_url'] ?? null,
default => null,
},
];
}
if (empty($resolved)) {
return null;
}
return [
'width' => $width,
'height' => $height,
'background' => $layout['background'] ?? '#FFFFFF',
'background_gradient' => $layout['background_gradient'] ?? null,
'accent' => $accent,
'text' => $text,
'secondary' => $secondary,
'badge' => $badge,
'qr_src' => $qrPngDataUri,
'logo_src' => $customization['logo_data_url'] ?? $customization['logo_url'] ?? $layout['logo_url'] ?? null,
'elements' => $resolved,
];
}
private function resolveElementContent(string $type, array $customization, array $layout, string $eventName, string $tokenUrl, $fallback = null): ?string
{
return match ($type) {
'headline' => $customization['headline'] ?? $eventName,
'subtitle' => $customization['subtitle'] ?? ($layout['subtitle'] ?? ''),
'description' => $customization['description'] ?? ($layout['description'] ?? ''),
'link' => (isset($customization['link_label']) && trim($customization['link_label']) !== '')
? $customization['link_label']
: $tokenUrl,
'badge' => $customization['badge_label'] ?? ($layout['badge_label'] ?? 'Digitale Gästebox'),
'cta' => $customization['cta_label'] ?? ($layout['cta_label'] ?? 'Scan mich & starte direkt'),
default => $fallback !== null ? (string) $fallback : null,
};
}
private function styleToString(array $style): string
{
return implode(';', array_map(
static fn ($key, $value) => sprintf('%s:%s', $key, $value),
array_keys($style),
$style
));
}
private function normalizeElementDimensions(string $type, array $element, int $canvasWidth, int $canvasHeight): array
{
$minDimensions = match ($type) {
'qr' => ['width' => 240, 'height' => 240],
'logo' => ['width' => 140, 'height' => 100],
'badge' => ['width' => 160, 'height' => 60],
'cta' => ['width' => 200, 'height' => 80],
'text' => ['width' => 200, 'height' => 120],
default => ['width' => 160, 'height' => 80],
};
$width = (int) round((float) ($element['width'] ?? $minDimensions['width']));
$height = (int) round((float) ($element['height'] ?? $minDimensions['height']));
$width = max($minDimensions['width'], min($width, $canvasWidth));
$height = max($minDimensions['height'], min($height, $canvasHeight));
if ($type === 'qr') {
$size = (int) round(max($width, $height));
$size = max(240, min($size, min($canvasWidth, $canvasHeight)));
$width = $height = $size;
}
$maxX = max($canvasWidth - $width, 0);
$maxY = max($canvasHeight - $height, 0);
$x = (int) round((float) ($element['x'] ?? 0));
$y = (int) round((float) ($element['y'] ?? 0));
$x = max(0, min($x, $maxX));
$y = max(0, min($y, $maxY));
return [
'x' => $x,
'y' => $y,
'width' => $width,
'height' => $height,
];
}
}

View File

@@ -46,16 +46,16 @@ class EventJoinTokenResource extends JsonResource
if ($qrCodeUrl) {
try {
$svg = QrCode::format('svg')
$png = QrCode::format('png')
->size(360)
->margin(1)
->errorCorrection('M')
->generate($qrCodeUrl);
$svgString = (string) $svg;
$pngBinary = (string) $png;
if ($svgString !== '') {
$qrCodeDataUrl = 'data:image/svg+xml;base64,'.base64_encode($svgString);
if ($pngBinary !== '') {
$qrCodeDataUrl = 'data:image/png;base64,'.base64_encode($pngBinary);
}
} catch (\Throwable $exception) {
report($exception);

View File

@@ -12,108 +12,149 @@ class JoinTokenLayoutRegistry
* @var array<string, array>
*/
private const LAYOUTS = [
'modern-poster' => [
'id' => 'modern-poster',
'name' => 'Modern Poster',
'subtitle' => 'Große, auffällige Fläche perfekt für den Eingangsbereich.',
'description' => 'Helle Posteroptik mit diagonalem Farbband und deutlicher Call-to-Action.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#F8FAFC',
'text' => '#0F172A',
'accent' => '#6366F1',
'secondary' => '#CBD5F5',
'badge' => '#0EA5E9',
'qr' => ['size_px' => 500],
'svg' => ['width' => 1080, 'height' => 1520],
'instructions' => [
'Scanne den Code und tritt dem Event direkt bei.',
'Speichere deine Lieblingsmomente mit Foto-Uploads.',
'Merke dir dein Gäste-Pseudonym für Likes und Badges.',
],
],
'elegant-frame' => [
'id' => 'elegant-frame',
'name' => 'Elegant Frame',
'subtitle' => 'Ein ruhiges Layout mit Fokus auf Eleganz.',
'description' => 'Serifen-Schrift, pastellige Flächen und dezente Rahmen für elegante Anlässe.',
'evergreen-vows' => [
'id' => 'evergreen-vows',
'name' => 'Evergreen Vows',
'subtitle' => 'Romantische Einladung für Trauung & Empfang.',
'description' => 'Weiche Pastelltöne, florale Akzente und viel Raum für eine herzliche Begrüßung.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#FBF7F2',
'text' => '#2B1B13',
'accent' => '#C08457',
'secondary' => '#E6D5C3',
'badge' => '#8B5CF6',
'qr' => ['size_px' => 460],
'svg' => ['width' => 1080, 'height' => 1520],
'instructions' => [
'QR-Code scannen oder Link im Browser eingeben.',
'Name eingeben, Lieblingssprache auswählen und loslegen.',
'Zeige diesen Druck am Empfang als Orientierung für Gäste.',
],
],
'bold-gradient' => [
'id' => 'bold-gradient',
'name' => 'Bold Gradient',
'subtitle' => 'Farbverlauf mit starkem Kontrast.',
'description' => 'Ein kraftvolles Farbstatement mit großem QR-Code ideal für Partys.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#F97316',
'background_gradient' => [
'angle' => 190,
'stops' => ['#F97316', '#EC4899', '#8B5CF6'],
'angle' => 165,
'stops' => ['#FBF7F2', '#FDECEF', '#F4F0FF'],
],
'text' => '#FFFFFF',
'accent' => '#FFFFFF',
'secondary' => 'rgba(255,255,255,0.72)',
'badge' => '#1E293B',
'qr' => ['size_px' => 540],
'svg' => ['width' => 1080, 'height' => 1520],
'text' => '#2C1A27',
'accent' => '#B85C76',
'secondary' => '#E7D6DC',
'badge' => '#7A9375',
'badge_label' => 'Unsere Gästegalerie',
'instructions_heading' => 'So seid ihr dabei',
'link_heading' => 'Falls der Scan nicht klappt',
'cta_label' => 'Gästegalerie öffnen',
'cta_caption' => 'Jetzt Erinnerungen sammeln',
'qr' => ['size_px' => 520],
'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [
'Sofort scannen der QR-Code führt direkt zum Event.',
'Fotos knipsen, Challenges lösen und Likes sammeln.',
'Teile den Link mit Freund:innen, falls kein Scan möglich ist.',
'QR-Code scannen und mit eurem Lieblingsnamen anmelden.',
'Ein paar Schnappschüsse teilen gern auch Behind-the-Scenes!',
'Likes vergeben und Grüße für das Brautpaar schreiben.',
],
],
'photo-strip' => [
'id' => 'photo-strip',
'name' => 'Photo Strip',
'subtitle' => 'Layout mit Fotostreifen-Anmutung und Checkliste.',
'description' => 'Horizontale Teilung, Platz für Hinweise und Storytelling.',
'midnight-gala' => [
'id' => 'midnight-gala',
'name' => 'Midnight Gala',
'subtitle' => 'Eleganter Auftritt für Corporate Events & Galas.',
'description' => 'Dunkle Bühne mit goldenen Akzenten und kräftiger Typografie.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#FFFFFF',
'text' => '#111827',
'accent' => '#0EA5E9',
'secondary' => '#94A3B8',
'badge' => '#334155',
'qr' => ['size_px' => 500],
'svg' => ['width' => 1080, 'height' => 1520],
'background' => '#0B132B',
'background_gradient' => [
'angle' => 200,
'stops' => ['#0B132B', '#1C2541', '#274690'],
],
'text' => '#F8FAFC',
'accent' => '#F9C74F',
'secondary' => '#4E5D8F',
'badge' => '#F94144',
'badge_label' => 'Team Lounge Access',
'instructions_heading' => 'In drei Schritten bereit',
'link_heading' => 'Link teilen statt scannen',
'cta_label' => 'Jetzt Event-Hub öffnen',
'cta_caption' => 'Programm, Uploads & Highlights',
'qr' => ['size_px' => 560],
'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [
'Schritt 1: QR-Code scannen oder Kurzlink nutzen.',
'Schritt 2: Profilname eingeben kreativ sein!',
'Schritt 3: Fotos hochladen und Teamaufgaben lösen.',
'QR-Code scannen oder Kurzlink eingeben.',
'Mit Firmen-E-Mail anmelden und Zugang bestätigen.',
'Agenda verfolgen, Fotos teilen und Highlights voten.',
],
],
'minimal-card' => [
'id' => 'minimal-card',
'name' => 'Minimal Card',
'subtitle' => 'Kleine Karte mehrfach druckbar als Tischaufsteller.',
'description' => 'Schlichtes Kartenformat mit klarer Typografie und viel Weißraum.',
'garden-brunch' => [
'id' => 'garden-brunch',
'name' => 'Garden Brunch',
'subtitle' => 'Luftiges Layout für Tages-Events & Familienfeiern.',
'description' => 'Sanfte Grüntöne, natürliche Formen und Platz für Hinweise.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#F9FAFB',
'text' => '#111827',
'accent' => '#9333EA',
'secondary' => '#E0E7FF',
'badge' => '#64748B',
'qr' => ['size_px' => 440],
'svg' => ['width' => 1080, 'height' => 1520],
'background' => '#F6F9F4',
'background_gradient' => [
'angle' => 120,
'stops' => ['#F6F9F4', '#EEF5E7', '#F8FAF0'],
],
'text' => '#2F4030',
'accent' => '#6BAA75',
'secondary' => '#DDE9D8',
'badge' => '#F1C376',
'badge_label' => 'Brunch Fotostation',
'instructions_heading' => 'So funktionierts',
'link_heading' => 'Alternativ zum Scannen',
'cta_label' => 'Gästebuch öffnen',
'cta_caption' => 'Eure Grüße festhalten',
'qr' => ['size_px' => 520],
'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [
'Code scannen, Profil erstellen, Erinnerungen festhalten.',
'Halte diese Karte an mehreren Stellen bereit.',
'Für Ausdrucke auf 200g/m² Kartenpapier empfohlen.',
'QR-Code scannen und Namen eintragen.',
'Lieblingsfoto hochladen oder neue Momente festhalten.',
'Aufgaben ausprobieren und anderen ein Herz dalassen.',
],
],
'sparkler-soiree' => [
'id' => 'sparkler-soiree',
'name' => 'Sparkler Soirée',
'subtitle' => 'Abendliches Layout mit funkelndem Verlauf.',
'description' => 'Dynamische Typografie mit zentralem Fokus auf dem QR-Code.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#1B1A44',
'background_gradient' => [
'angle' => 205,
'stops' => ['#1B1A44', '#42275A', '#734B8F'],
],
'text' => '#FDF7FF',
'accent' => '#F9A826',
'secondary' => '#DDB7FF',
'badge' => '#FF6F61',
'badge_label' => 'Night Shots',
'instructions_heading' => 'Step-by-Step',
'link_heading' => 'QR funktioniert nicht?',
'cta_label' => 'Partyfeed starten',
'cta_caption' => 'Momente live teilen',
'qr' => ['size_px' => 560],
'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [
'Code scannen und kurz registrieren.',
'Spotlights & Challenges entdecken.',
'Fotos hochladen und die besten Shots voten.',
],
],
'confetti-bash' => [
'id' => 'confetti-bash',
'name' => 'Confetti Bash',
'subtitle' => 'Verspielter Look für Geburtstage & Jubiläen.',
'description' => 'Konfetti-Sprenkel, fröhliche Farben und viel Platz für Hinweise.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#FFF9F0',
'background_gradient' => [
'angle' => 145,
'stops' => ['#FFF9F0', '#FFEFEF', '#FFF5D6'],
],
'text' => '#31291F',
'accent' => '#FF6F61',
'secondary' => '#F9D6A5',
'badge' => '#4E88FF',
'badge_label' => 'Party-Schnappschüsse',
'instructions_heading' => 'Leg direkt los',
'link_heading' => 'Kurzlink für Gäste',
'cta_label' => 'Zur Geburtstagswand',
'cta_caption' => 'Fotos & Grüße posten',
'qr' => ['size_px' => 520],
'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [
'QR-Code scannen und Wunschname auswählen.',
'Dein erstes Foto oder Video hochladen.',
'Freunde einladen, Likes vergeben und gemeinsam feiern!',
],
],
];
@@ -182,18 +223,39 @@ class JoinTokenLayoutRegistry
'link_label' => null,
'logo_url' => null,
'qr' => [
'size_px' => 320,
'size_px' => 360,
],
'svg' => [
'width' => 1080,
'height' => 1520,
'width' => 1240,
'height' => 1754,
],
'background_gradient' => null,
'instructions' => [],
'formats' => ['pdf', 'svg'],
'formats' => ['pdf', 'png'],
];
return array_replace_recursive($defaults, $layout);
$normalized = array_replace_recursive($defaults, $layout);
$formats = $normalized['formats'] ?? ['pdf', 'png'];
if (! is_array($formats)) {
$formats = [$formats];
}
$normalizedFormats = [];
foreach ($formats as $format) {
$value = strtolower((string) $format);
if ($value === 'svg') {
$value = 'png';
}
if (in_array($value, ['pdf', 'png'], true) && ! in_array($value, $normalizedFormats, true)) {
$normalizedFormats[] = $value;
}
}
$normalized['formats'] = $normalizedFormats ?: ['pdf', 'png'];
return $normalized;
}
private static function fromModel(InviteLayout $layout): array
@@ -229,7 +291,7 @@ class JoinTokenLayoutRegistry
'width' => $preview['svg']['width'] ?? $options['svg']['width'] ?? $preview['svg_width'] ?? $options['svg_width'] ?? null,
'height' => $preview['svg']['height'] ?? $options['svg']['height'] ?? $preview['svg_height'] ?? $options['svg_height'] ?? null,
]),
'formats' => $options['formats'] ?? ['pdf', 'svg'],
'formats' => $options['formats'] ?? ['pdf', 'png'],
'instructions' => $instructions,
], fn ($value) => $value !== null && $value !== []);
}
@@ -243,13 +305,19 @@ class JoinTokenLayoutRegistry
public static function toResponse(callable $urlResolver): array
{
return array_map(function (array $layout) use ($urlResolver) {
$formats = $layout['formats'] ?? ['pdf', 'svg'];
$formats = $layout['formats'] ?? ['pdf', 'png'];
return [
'id' => $layout['id'],
'name' => $layout['name'],
'description' => $layout['description'],
'subtitle' => $layout['subtitle'],
'badge_label' => $layout['badge_label'] ?? null,
'instructions_heading' => $layout['instructions_heading'] ?? null,
'link_heading' => $layout['link_heading'] ?? null,
'cta_label' => $layout['cta_label'] ?? null,
'cta_caption' => $layout['cta_caption'] ?? null,
'instructions' => $layout['instructions'] ?? [],
'preview' => [
'background' => $layout['background'],
'background_gradient' => $layout['background_gradient'],

View File

@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('task_collections') && ! Schema::hasColumn('task_collections', 'source_collection_id')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->foreignId('source_collection_id')
->nullable()
->after('event_type_id')
->constrained('task_collections')
->nullOnDelete();
});
}
if (Schema::hasTable('tasks')) {
Schema::table('tasks', function (Blueprint $table) {
if (! Schema::hasColumn('tasks', 'source_collection_id')) {
$table->foreignId('source_collection_id')
->nullable()
->after('collection_id')
->constrained('task_collections')
->nullOnDelete();
}
if (! Schema::hasColumn('tasks', 'source_task_id')) {
$table->foreignId('source_task_id')
->nullable()
->after('tenant_id')
->constrained('tasks')
->nullOnDelete();
}
});
}
}
public function down(): void
{
if (Schema::hasTable('tasks')) {
Schema::table('tasks', function (Blueprint $table) {
if (Schema::hasColumn('tasks', 'source_collection_id')) {
$table->dropForeign(['source_collection_id']);
$table->dropColumn('source_collection_id');
}
if (Schema::hasColumn('tasks', 'source_task_id')) {
$table->dropForeign(['source_task_id']);
$table->dropColumn('source_task_id');
}
});
}
if (Schema::hasTable('task_collections') && Schema::hasColumn('task_collections', 'source_collection_id')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->dropForeign(['source_collection_id']);
$table->dropColumn('source_collection_id');
});
}
}
};

View File

@@ -24,7 +24,7 @@ class InviteLayoutSeeder extends Seeder
'text' => $layout['text'] ?? null,
'badge' => $layout['badge'] ?? null,
'qr' => $layout['qr'] ?? ['size_px' => 500],
'svg' => $layout['svg'] ?? ['width' => 1080, 'height' => 1520],
'svg' => $layout['svg'] ?? ['width' => 1240, 'height' => 1754],
];
$options = [
@@ -35,7 +35,7 @@ class InviteLayoutSeeder extends Seeder
'cta_caption' => $layout['cta_caption'] ?? 'Scan mich & starte direkt',
'link_label' => $layout['link_label'] ?? null,
'logo_url' => $layout['logo_url'] ?? null,
'formats' => $layout['formats'] ?? ['pdf', 'svg'],
'formats' => $layout['formats'] ?? ['pdf', 'png'],
];
InviteLayout::updateOrCreate(

View File

@@ -7,23 +7,72 @@ use App\Models\EventType;
use App\Models\Task;
use App\Models\TaskCollection;
use Illuminate\Database\Seeder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TaskCollectionsSeeder extends Seeder
{
private const MIN_TASKS_PER_EVENT_TYPE = 50;
public function run(): void
{
$collections = [
$definitions = $this->definitions();
$collectionMap = [];
DB::transaction(function () use ($definitions, &$collectionMap) {
foreach ($definitions as $eventTypeSlug => $definition) {
$eventType = $this->ensureEventType($definition['event_type']);
$collection = TaskCollection::updateOrCreate(
['slug' => $definition['collection']['slug']],
[
'slug' => 'wedding-classics',
'tenant_id' => null,
'event_type_id' => $eventType->id,
'name_translations' => $definition['collection']['name'],
'description_translations' => $definition['collection']['description'],
'is_default' => $definition['collection']['is_default'] ?? false,
'position' => $definition['collection']['position'] ?? 0,
'source_collection_id' => null,
]
);
$syncPayload = [];
foreach ($definition['base_tasks'] as $index => $taskDefinition) {
$sortOrder = $taskDefinition['sort_order'] ?? (($index + 1) * 10);
$task = $this->upsertTask($collection, $eventType, $taskDefinition, $sortOrder);
$syncPayload[$task->id] = ['sort_order' => $sortOrder];
}
$this->ensureMinimumTasks(
$collection,
$eventType,
$syncPayload,
count($syncPayload),
self::MIN_TASKS_PER_EVENT_TYPE,
$eventTypeSlug
);
$collection->tasks()->sync($syncPayload);
$collectionMap[$eventType->id] = $collection;
}
});
$this->assignOrphanTasks($collectionMap);
}
private function definitions(): array
{
return [
'wedding' => [
'event_type' => [
'slug' => 'wedding',
'name' => [
'de' => 'Hochzeit',
'en' => 'Wedding',
],
'name' => ['de' => 'Hochzeit', 'en' => 'Wedding'],
'icon' => 'lucide-heart',
],
'collection' => [
'slug' => 'wedding-classics',
'name' => [
'de' => 'Hochzeitsklassiker',
'en' => 'Wedding Classics',
@@ -34,97 +83,63 @@ class TaskCollectionsSeeder extends Seeder
],
'is_default' => true,
'position' => 10,
'tasks' => [
[
'slug' => 'wedding-first-look',
'title' => [
'de' => 'Erster Blick des Brautpaares festhalten',
'en' => 'Capture the couples first look',
],
'description' => [
'de' => 'Halte den Moment fest, in dem sich Braut und Bräutigam zum ersten Mal sehen.',
'en' => 'Capture the moment when the bride and groom see each other for the first time.',
],
'example' => [
'de' => 'Fotografiere die Reaktionen aus verschiedenen Blickwinkeln.',
'en' => 'Photograph their reactions from different angles.',
],
'emotion' => [
'name' => [
'de' => 'Romantik',
'en' => 'Romance',
],
'icon' => 'lucide-heart',
'color' => '#ec4899',
'sort_order' => 10,
],
'difficulty' => 'easy',
'sort_order' => 10,
],
[
'slug' => 'wedding-family-hug',
'title' => [
'de' => 'Familienumarmung organisieren',
'en' => 'Organise a family group hug',
],
'description' => [
'de' => 'Bitte die wichtigsten Menschen, das Paar gleichzeitig zu umarmen.',
'en' => 'Ask the closest friends and family to hug the couple at the same time.',
],
'example' => [
'de' => 'Kombiniere die Umarmung mit einem Toast.',
'en' => 'Combine the hug with a heartfelt toast.',
],
'emotion' => [
'name' => [
'de' => 'Freude',
'en' => 'Joy',
],
'icon' => 'lucide-smile',
'color' => '#f59e0b',
'sort_order' => 20,
],
'difficulty' => 'medium',
'sort_order' => 20,
],
[
'slug' => 'wedding-midnight-sparkler',
'title' => [
'de' => 'Mitternachtsfunkeln mit Wunderkerzen',
'en' => 'Midnight sparkler moment',
],
'description' => [
'de' => 'Verteile Wunderkerzen und schafft ein leuchtendes Spalier für das Paar.',
'en' => 'Hand out sparklers and form a glowing aisle for the couple.',
],
'example' => [
'de' => 'Koordiniere die Musik und kündige den Countdown an.',
'en' => 'Coordinate music and announce a countdown.',
],
'emotion' => [
'name' => [
'de' => 'Ekstase',
'en' => 'Euphoria',
],
'icon' => 'lucide-stars',
'color' => '#6366f1',
'sort_order' => 30,
],
'difficulty' => 'medium',
'sort_order' => 30,
'base_tasks' => [
$this->taskDefinition(
'wedding-first-look',
['de' => 'Erster Blick des Brautpaares festhalten', 'en' => 'Capture the couples first look'],
['de' => 'Halte den Moment fest, in dem sich Braut und Bräutigam zum ersten Mal sehen.', 'en' => 'Capture the instant when the couple sees each other for the first time.'],
['de' => 'Fotografiere die Reaktionen aus verschiedenen Blickwinkeln.', 'en' => 'Shoot reactions from multiple angles.'],
['name' => ['de' => 'Romantik', 'en' => 'Romance'], 'icon' => 'lucide-heart', 'color' => '#ec4899', 'sort_order' => 10],
'easy',
10
),
$this->taskDefinition(
'wedding-family-hug',
['de' => 'Familienumarmung organisieren', 'en' => 'Organise a family group hug'],
['de' => 'Bitte die wichtigsten Menschen, das Paar gleichzeitig zu umarmen.', 'en' => 'Ask the closest friends and family to hug the couple all at once.'],
['de' => 'Kombiniere die Umarmung mit einem Toast.', 'en' => 'Combine the hug with a heartfelt toast.'],
['name' => ['de' => 'Freude', 'en' => 'Joy'], 'icon' => 'lucide-smile', 'color' => '#f59e0b', 'sort_order' => 20],
'medium',
20
),
$this->taskDefinition(
'wedding-midnight-sparkler',
['de' => 'Mitternachtsfunkeln mit Wunderkerzen', 'en' => 'Midnight sparkler moment'],
['de' => 'Verteile Wunderkerzen und schafft ein leuchtendes Spalier für das Paar.', 'en' => 'Hand out sparklers and form a glowing aisle for the couple.'],
['de' => 'Koordiniere die Musik und kündige den Countdown an.', 'en' => 'Coordinate music and announce a countdown.'],
['name' => ['de' => 'Ekstase', 'en' => 'Euphoria'], 'icon' => 'lucide-stars', 'color' => '#6366f1', 'sort_order' => 30],
'medium',
30
),
$this->taskDefinition(
'wedding-vow-whisper',
['de' => 'Flüstert die liebsten Gelübde erneut', 'en' => 'Whisper your vows again'],
['de' => 'Lasst das Paar sich gegenseitig einen Satz aus den Gelübden zuflüstern.', 'en' => 'Let the couple whisper a favourite line from their vows to each other.'],
['de' => 'Nahaufnahme der Gesichter, während sie lächeln.', 'en' => 'Capture a close-up of the smiles while they whisper.'],
['name' => ['de' => 'Verbundenheit', 'en' => 'Connection'], 'icon' => 'lucide-infinity', 'color' => '#a855f7', 'sort_order' => 35],
'easy',
40
),
$this->taskDefinition(
'wedding-generations-portrait',
['de' => 'Generationenportrait', 'en' => 'Generations portrait'],
['de' => 'Bringe drei Generationen gleichzeitig aufs Foto vom jüngsten zum ältesten Familienmitglied.', 'en' => 'Bring three generations into a single photograph youngest to oldest.'],
['de' => 'Stellt sie gestaffelt auf einer Treppe auf.', 'en' => 'Arrange them on steps for a layered look.'],
['name' => ['de' => 'Familie', 'en' => 'Family'], 'icon' => 'lucide-users', 'color' => '#0ea5e9', 'sort_order' => 45],
'medium',
50
),
],
],
],
[
'slug' => 'birthday-celebration',
'birthday' => [
'event_type' => [
'slug' => 'birthday',
'name' => [
'de' => 'Geburtstag',
'en' => 'Birthday',
],
'name' => ['de' => 'Geburtstag', 'en' => 'Birthday'],
'icon' => 'lucide-cake',
],
'collection' => [
'slug' => 'birthday-celebration',
'name' => [
'de' => 'Geburtstags-Highlights',
'en' => 'Birthday Highlights',
@@ -135,122 +150,403 @@ class TaskCollectionsSeeder extends Seeder
],
'is_default' => false,
'position' => 20,
'tasks' => [
[
'slug' => 'birthday-surprise-wall',
'title' => [
'de' => 'Überraschungswand mit Polaroids gestalten',
'en' => 'Create a surprise wall filled with instant photos',
],
'base_tasks' => [
$this->taskDefinition(
'birthday-surprise-wall',
['de' => 'Überraschungswand mit Polaroids gestalten', 'en' => 'Create a surprise wall filled with instant photos'],
['de' => 'Sammle Schnappschüsse der Gäste und befestige sie als Fotowand.', 'en' => 'Collect snapshots from guests and display them as a photo wall.'],
['de' => 'Bitte jeden Gast um einen kurzen Gruß zum Bild.', 'en' => 'Invite each guest to add a short note to the photo.'],
['name' => ['de' => 'Nostalgie', 'en' => 'Nostalgia'], 'icon' => 'lucide-images', 'color' => '#f97316', 'sort_order' => 40],
'easy',
10
),
$this->taskDefinition(
'birthday-toast-circle',
['de' => 'Gratulationskreis mit kurzen Toasts', 'en' => 'Circle of toasts'],
['de' => 'Bildet einen Kreis und bittet jede Person um einen 10-Sekunden-Toast.', 'en' => 'Gather in a circle and ask each guest for a 10-second toast.'],
['de' => 'Nimm die Lautstärke nach jedem Toast auf wer jubelt am lautesten?', 'en' => 'Capture the volume of each cheer—who is the loudest?'],
['name' => ['de' => 'Dankbarkeit', 'en' => 'Gratitude'], 'icon' => 'lucide-hands', 'color' => '#22c55e', 'sort_order' => 50],
'easy',
20
),
$this->taskDefinition(
'birthday-memory-lane',
['de' => 'Memory-Lane Foto-Story', 'en' => 'Memory lane photo story'],
['de' => 'Suche drei Gegenstände, die zum Geburtstagsgast passen, und erzähle eine Mini-Story mit Fotos.', 'en' => 'Find three objects that represent the guest of honour and photograph them in a mini story.'],
['de' => 'Nutze unterschiedliche Bildausschnitte für die Story.', 'en' => 'Use different framings to tell the story.'],
['name' => ['de' => 'Erinnerung', 'en' => 'Reminiscence'], 'icon' => 'lucide-book-open', 'color' => '#6366f1', 'sort_order' => 55],
'medium',
30
),
],
],
'christmas' => [
'event_type' => [
'slug' => 'christmas',
'name' => ['de' => 'Weihnachten', 'en' => 'Christmas'],
'icon' => 'lucide-sparkles',
],
'collection' => [
'slug' => 'christmas-magic',
'name' => [
'de' => 'Festliche Highlights',
'en' => 'Festive Highlights',
],
'description' => [
'de' => 'Sammle Schnappschüsse der Gäste und befestige sie als Fotowand.',
'en' => 'Collect snapshots from guests and mount them on a photo wall.',
'de' => 'Wärmende Aufgaben für Glühwein-Momente und gemeinsames Staunen.',
'en' => 'Cozy prompts for mulled wine moments and shared wonder.',
],
'example' => [
'de' => 'Schreibe zu jedem Bild einen kurzen Gruß.',
'en' => 'Add a short message to each picture.',
'is_default' => false,
'position' => 30,
],
'emotion' => [
'base_tasks' => [
$this->taskDefinition(
'christmas-light-hunt',
['de' => 'Finde das schönste Lichterdekor', 'en' => 'Spot the brightest decoration'],
['de' => 'Halte das stimmungsvollste Licht im Raum fest.', 'en' => 'Capture the coziest lights around you.'],
['de' => 'Zeige die Lichtquelle plus Reaktion eines Gastes.', 'en' => 'Show the light source together with a guest reacting to it.'],
['name' => ['de' => 'Besinnlichkeit', 'en' => 'Serenity'], 'icon' => 'lucide-candle', 'color' => '#f97316', 'sort_order' => 20],
'easy',
10
),
$this->taskDefinition(
'christmas-cookie-cheers',
['de' => 'Plätzchen-Toast', 'en' => 'Cookie cheers'],
['de' => 'Stoßt mit Lieblingsplätzchen an und haltet das gemeinsame Lachen fest.', 'en' => 'Clink your favourite cookies together and capture the laughter.'],
['de' => 'Fotografiere in einer Reihe Hände vorne, Gesichter dahinter.', 'en' => 'Frame hands with cookies in front, smiling faces behind.'],
['name' => ['de' => 'Wärme', 'en' => 'Warmth'], 'icon' => 'lucide-mug-hot', 'color' => '#ef4444', 'sort_order' => 30],
'easy',
20
),
$this->taskDefinition(
'christmas-snow-story',
['de' => 'Schneegestöber im Innenraum', 'en' => 'Indoor snow story'],
['de' => 'Nutze Deko oder Papier, um Schneeflocken zu simulieren, und halte den Zauber fest.', 'en' => 'Use props or paper to fake snow and capture the magic.'],
['de' => 'Lass die Schneeflocken im Vordergrund unscharf erscheinen.', 'en' => 'Keep the fake snow in the foreground out of focus for a dreamy look.'],
['name' => ['de' => 'Staunen', 'en' => 'Wonder'], 'icon' => 'lucide-sparkles', 'color' => '#22d3ee', 'sort_order' => 35],
'medium',
30
),
],
],
'corporate' => [
'event_type' => [
'slug' => 'corporate',
'name' => ['de' => 'Firma', 'en' => 'Corporate'],
'icon' => 'lucide-briefcase',
],
'collection' => [
'slug' => 'corporate-connect',
'name' => [
'de' => 'Nostalgie',
'en' => 'Nostalgia',
],
'icon' => 'lucide-images',
'color' => '#f97316',
'sort_order' => 40,
],
'difficulty' => 'easy',
'sort_order' => 10,
],
[
'slug' => 'birthday-toast-circle',
'title' => [
'de' => 'Gratulationskreis mit kurzen Toasts',
'en' => 'Circle of toasts',
'de' => 'Team-Verbindungen',
'en' => 'Team Connections',
],
'description' => [
'de' => 'Bildet einen Kreis und bittet jede Person um einen 10-Sekunden-Toast.',
'en' => 'Form a circle and ask everyone for a 10-second toast.',
'de' => 'Interaktive Aufgaben für produktive Offsites und Firmenfeiern.',
'en' => 'Interactive prompts for productive offsites and company celebrations.',
],
'example' => [
'de' => 'Nimm die Reaktionen als Video auf.',
'en' => 'Record the reactions on video.',
],
'emotion' => [
'name' => [
'de' => 'Dankbarkeit',
'en' => 'Gratitude',
],
'icon' => 'lucide-hands',
'color' => '#22c55e',
'sort_order' => 50,
],
'difficulty' => 'easy',
'sort_order' => 20,
'is_default' => false,
'position' => 40,
],
'base_tasks' => [
$this->taskDefinition(
'corporate-high-five-chain',
['de' => 'High-Five-Kette starten', 'en' => 'Start a high-five chain'],
['de' => 'Platziert euch im Kreis und gebt reihum High-Fives haltet den Moment fest.', 'en' => 'Form a circle and pass on high-fives capture the energy.'],
['de' => 'Nutze Serienaufnahmen, um alle Bewegungen einzufangen.', 'en' => 'Use burst mode to catch every high-five.'],
['name' => ['de' => 'Teamgeist', 'en' => 'Team Spirit'], 'icon' => 'lucide-users', 'color' => '#0ea5e9', 'sort_order' => 10],
'easy',
10
),
$this->taskDefinition(
'corporate-goal-toast',
['de' => 'Ziele feiern', 'en' => 'Celebrate milestones'],
['de' => 'Ruft gemeinsam ein erreichte Ziel in die Kamera und haltet die Euphorie fest.', 'en' => 'Shout a recent team achievement into the camera together.'],
['de' => 'Lasst Konfetti oder Luftballons in den Rahmen fallen.', 'en' => 'Drop confetti or balloons into the frame.'],
['name' => ['de' => 'Stolz', 'en' => 'Pride'], 'icon' => 'lucide-trophy', 'color' => '#22c55e', 'sort_order' => 20],
'medium',
20
),
$this->taskDefinition(
'corporate-coffee-pose',
['de' => 'Kaffee-Powerpose', 'en' => 'Coffee power pose'],
['de' => 'Lass das Team mit Kaffeebechern posieren jede Person zeigt eine andere Emotion.', 'en' => 'Have the team pose with coffee mugs, each showing a different emotion.'],
['de' => 'Nutze eine niedrige Perspektive, um Power auszudrücken.', 'en' => 'Shoot from a low angle to emphasise power.'],
['name' => ['de' => 'Motivation', 'en' => 'Motivation'], 'icon' => 'lucide-rocket', 'color' => '#8b5cf6', 'sort_order' => 30],
'easy',
30
),
],
],
];
}
DB::transaction(function () use ($collections) {
foreach ($collections as $definition) {
$eventType = $this->ensureEventType($definition['event_type']);
private function autoTaskSeeds(): array
{
return [
'wedding' => [
$this->autoSeed('dancefloor',
['de' => 'Dancefloor-Glück #{n}', 'en' => 'Dancefloor joy #{n}'],
['de' => 'Fange eine wilde Tanzszene ein, die die Energie der Gäste zeigt.', 'en' => 'Capture a wild dance scene packed with guest energy.'],
['de' => 'Nutze eine lange Belichtungszeit für Lichtstreifen.', 'en' => 'Use a longer exposure to create light trails.'],
['name' => ['de' => 'Ekstase', 'en' => 'Euphoria'], 'icon' => 'lucide-stars', 'color' => '#a855f7'],
'medium'
),
$this->autoSeed('sparkler',
['de' => 'Wunderkerzen-Story #{n}', 'en' => 'Sparkler story #{n}'],
['de' => 'Erzähle die Geschichte eines funkelnden Moments mit mindestens drei Bildern.', 'en' => 'Tell the story of a sparkling moment in three frames.'],
['de' => 'Variiere zwischen Detailaufnahme, Halbtotaler und Reaktion.', 'en' => 'Mix a detail shot, a medium frame, and a reaction close-up.'],
['name' => ['de' => 'Staunen', 'en' => 'Awe'], 'icon' => 'lucide-wand', 'color' => '#f97316'],
'medium'
),
$this->autoSeed('first-dance-detail',
['de' => 'Detail beim Hochzeitstanz #{n}', 'en' => 'First-dance detail #{n}'],
['de' => 'Suche ein kleines Detail während des Hochzeitstanzes Hände, Schuhe, Accessoires.', 'en' => 'Spot a tiny detail during the first dance hands, shoes or accessories.'],
['de' => 'Nutze starkes Bokeh für den Hintergrund.', 'en' => 'Use heavy bokeh to blur the background.'],
['name' => ['de' => 'Zärtlichkeit', 'en' => 'Tenderness'], 'icon' => 'lucide-feather', 'color' => '#fb7185'],
'easy'
),
$this->autoSeed('guest-story',
['de' => 'Gästegeschichte #{n}', 'en' => 'Guest story #{n}'],
['de' => 'Dokumentiere eine Gruppe von Gästen, wie sie miteinander interagiert.', 'en' => 'Document a group of guests interacting naturally.'],
['de' => 'Fotografiere die Gruppe aus zwei Perspektiven.', 'en' => 'Capture the group from two perspectives.'],
['name' => ['de' => 'Freundschaft', 'en' => 'Friendship'], 'icon' => 'lucide-users', 'color' => '#38bdf8'],
'easy'
),
],
'birthday' => [
$this->autoSeed('candle-wish',
['de' => 'Kerzenwunsch #{n}', 'en' => 'Candle wish #{n}'],
['de' => 'Halte den Moment fest, in dem der Wunsch ausgesprochen wird.', 'en' => 'Capture the second the wish is made.'],
['de' => 'Lass das Motiv in den Kerzenlichtschein eintauchen.', 'en' => 'Bathe the scene in candlelight glow.'],
['name' => ['de' => 'Freude', 'en' => 'Joy'], 'icon' => 'lucide-sparkles', 'color' => '#facc15'],
'easy'
),
$this->autoSeed('gift-reaction',
['de' => 'Geschenk-Reaktion #{n}', 'en' => 'Gift reaction #{n}'],
['de' => 'Fange die spontanste Reaktion auf ein Geschenk ein.', 'en' => 'Capture the most spontaneous gift reaction.'],
['de' => 'Versuche eine Serienaufnahme für echte Emotionen.', 'en' => 'Use burst mode for authentic emotion.'],
['name' => ['de' => 'Überraschung', 'en' => 'Surprise'], 'icon' => 'lucide-gift', 'color' => '#f472b6'],
'easy'
),
$this->autoSeed('birthday-anthem',
['de' => 'Geburtstagshymne #{n}', 'en' => 'Birthday anthem #{n}'],
['de' => 'Dokumentiere das Mitsingen eines Liedes inklusive Stimmung im Raum.', 'en' => 'Document everyone singing a song and the overall mood.'],
['de' => 'Fokussiere auf die Person, die am lautesten singt.', 'en' => 'Focus on whoever sings the loudest.'],
['name' => ['de' => 'Lebensfreude', 'en' => 'Delight'], 'icon' => 'lucide-music', 'color' => '#60a5fa'],
'medium'
),
],
'christmas' => [
$this->autoSeed('ornament-closeup',
['de' => 'Ornament-Kunstwerk #{n}', 'en' => 'Ornament art #{n}'],
['de' => 'Inszeniere ein besonderes Ornament in Nahaufnahme.', 'en' => 'Stage a special ornament in a moody close-up.'],
['de' => 'Lass warme Lichter im Hintergrund bokeh-artig wirken.', 'en' => 'Let warm lights blur softly in the background.'],
['name' => ['de' => 'Staunen', 'en' => 'Wonder'], 'icon' => 'lucide-sparkles', 'color' => '#38bdf8'],
'easy'
),
$this->autoSeed('carol-chorus',
['de' => 'Singender Chor #{n}', 'en' => 'Carolling chorus #{n}'],
['de' => 'Zeige eine Gruppe beim Singen eines Weihnachtsliedes.', 'en' => 'Highlight a group singing a seasonal song.'],
['de' => 'Nutze eine Panorama-Aufnahme für alle Beteiligten.', 'en' => 'Use a panorama framing to include everyone.'],
['name' => ['de' => 'Gemeinschaft', 'en' => 'Togetherness'], 'icon' => 'lucide-users', 'color' => '#22c55e'],
'medium'
),
$this->autoSeed('wishlist-doodle',
['de' => 'Wunschlisten-Doodle #{n}', 'en' => 'Wishlist doodle #{n}'],
['de' => 'Fotografiere eine improvisierte Wunschliste inklusive Zeichnungen.', 'en' => 'Photograph an improvised wishlist complete with doodles.'],
['de' => 'Lege die Liste auf Holz- oder Stoffoberflächen für Wärme.', 'en' => 'Place the list on wood or fabric for extra warmth.'],
['name' => ['de' => 'Hoffnung', 'en' => 'Hope'], 'icon' => 'lucide-heart', 'color' => '#f97316'],
'easy'
),
],
'corporate' => [
$this->autoSeed('team-brainstorm',
['de' => 'Brainstorm-Blitzlicht #{n}', 'en' => 'Brainstorm spotlight #{n}'],
['de' => 'Halte eine kreative Idee auf einem Whiteboard plus das Team dahinter fest.', 'en' => 'Capture a clever whiteboard idea with the team behind it.'],
['de' => 'Arbeite mit Spiegelungen im Glas oder Bildschirm.', 'en' => 'Play with reflections on glass or screens.'],
['name' => ['de' => 'Innovation', 'en' => 'Innovation'], 'icon' => 'lucide-lightbulb', 'color' => '#facc15'],
'medium'
),
$this->autoSeed('watercooler-moment',
['de' => 'Watercooler-Moment #{n}', 'en' => 'Watercooler moment #{n}'],
['de' => 'Zeige Kollegen beim lockeren Austausch abseits des Meetings.', 'en' => 'Show teammates chatting casually away from the meeting table.'],
['de' => 'Fokussiere auf Gestik und Körpersprache.', 'en' => 'Focus on gestures and body language.'],
['name' => ['de' => 'Leichtigkeit', 'en' => 'Ease'], 'icon' => 'lucide-coffee', 'color' => '#60a5fa'],
'easy'
),
$this->autoSeed('celebration-confetti',
['de' => 'Konfetti-Erfolg #{n}', 'en' => 'Confetti success #{n}'],
['de' => 'Inszeniere einen kleinen Konfettiwurf als Symbol für Team-Erfolg.', 'en' => 'Stage a mini confetti throw to celebrate team success.'],
['de' => 'Arbeite mit schneller Verschlusszeit für eingefrorene Partikel.', 'en' => 'Use a fast shutter to freeze the confetti.'],
['name' => ['de' => 'Erfolg', 'en' => 'Success'], 'icon' => 'lucide-flag', 'color' => '#a855f7'],
'medium'
),
],
'default' => [
$this->autoSeed('story-fragments',
['de' => 'Story-Fragmente #{n}', 'en' => 'Story fragments #{n}'],
['de' => 'Finde drei kleine Details, die zusammen eine Geschichte ergeben.', 'en' => 'Find three small details that build one story.'],
['de' => 'Kombiniere Nahaufnahmen mit einem Übersichtsfoto.', 'en' => 'Combine close-ups with one establishing shot.'],
['name' => ['de' => 'Neugier', 'en' => 'Curiosity'], 'icon' => 'lucide-eye', 'color' => '#38bdf8'],
'easy'
),
],
];
}
$collection = TaskCollection::updateOrCreate(
['slug' => $definition['slug']],
[
'tenant_id' => null,
'event_type_id' => $eventType->id,
'name_translations' => $definition['name'],
'description_translations' => $definition['description'],
'is_default' => $definition['is_default'] ?? false,
'position' => $definition['position'] ?? 0,
]
);
private function taskDefinition(
string $slug,
array $title,
array $description,
array $example,
?array $emotion,
string $difficulty,
int $sortOrder
): array {
return [
'slug' => $slug,
'title' => $title,
'description' => $description,
'example' => $example,
'emotion' => $emotion,
'difficulty' => $difficulty,
'sort_order' => $sortOrder,
];
}
$syncPayload = [];
private function autoSeed(
string $slug,
array $title,
array $description,
array $example,
?array $emotion,
string $difficulty
): array {
return [
'slug' => $slug,
'title' => $title,
'description' => $description,
'example' => $example,
'emotion' => $emotion,
'difficulty' => $difficulty,
];
}
foreach ($definition['tasks'] as $taskDefinition) {
$emotion = $this->ensureEmotion($taskDefinition['emotion'] ?? [], $eventType->id);
private function upsertTask(TaskCollection $collection, EventType $eventType, array $definition, int $sortOrder): Task
{
$emotion = $this->ensureEmotion($definition['emotion'] ?? [], $eventType->id);
$task = Task::updateOrCreate(
['slug' => $taskDefinition['slug']],
['slug' => $definition['slug']],
[
'tenant_id' => null,
'event_type_id' => $eventType->id,
'collection_id' => $collection->id,
'emotion_id' => $emotion?->id,
'title' => $taskDefinition['title'],
'description' => $taskDefinition['description'] ?? null,
'example_text' => $taskDefinition['example'] ?? null,
'difficulty' => $taskDefinition['difficulty'] ?? 'easy',
'priority' => 'medium',
'sort_order' => $taskDefinition['sort_order'] ?? 0,
'title' => $definition['title'],
'description' => $definition['description'] ?? null,
'example_text' => $definition['example'] ?? null,
'difficulty' => $definition['difficulty'] ?? 'easy',
'priority' => $definition['priority'] ?? 'medium',
'sort_order' => $definition['sort_order'] ?? $sortOrder,
'is_active' => true,
'is_completed' => false,
]
);
$syncPayload[$task->id] = ['sort_order' => $taskDefinition['sort_order'] ?? 0];
if ($task->collection_id !== $collection->id) {
$task->collection_id = $collection->id;
$task->save();
}
if (! empty($syncPayload)) {
$collection->tasks()->sync($syncPayload);
return $task;
}
private function ensureMinimumTasks(
TaskCollection $collection,
EventType $eventType,
array &$syncPayload,
int $existingCount,
int $minimum,
string $eventTypeSlug
): void {
$needed = $minimum - $existingCount;
if ($needed <= 0) {
return;
}
$seeds = $this->autoTaskSeeds()[$eventTypeSlug] ?? $this->autoTaskSeeds()['default'];
if (empty($seeds)) {
$seeds = $this->autoTaskSeeds()['default'];
}
$baseCount = $existingCount;
for ($i = 0; $i < $needed; $i++) {
$seed = $seeds[$i % count($seeds)];
$sequence = $baseCount + $i + 1;
$slug = Str::slug(sprintf('%s-%s-%s', $eventType->slug ?? $eventTypeSlug, $seed['slug'], $sequence));
$taskDefinition = [
'slug' => $slug,
'title' => [
'de' => str_replace('{n}', (string) $sequence, $seed['title']['de']),
'en' => str_replace('{n}', (string) $sequence, $seed['title']['en']),
],
'description' => [
'de' => str_replace('{n}', (string) $sequence, $seed['description']['de']),
'en' => str_replace('{n}', (string) $sequence, $seed['description']['en']),
],
'example' => [
'de' => str_replace('{n}', (string) $sequence, $seed['example']['de']),
'en' => str_replace('{n}', (string) $sequence, $seed['example']['en']),
],
'emotion' => $seed['emotion'] ?? null,
'difficulty' => $seed['difficulty'] ?? 'easy',
'priority' => $seed['priority'] ?? 'medium',
'sort_order' => 500 + ($sequence * 5),
];
$task = $this->upsertTask($collection, $eventType, $taskDefinition, $taskDefinition['sort_order']);
$syncPayload[$task->id] = ['sort_order' => $taskDefinition['sort_order']];
}
}
private function assignOrphanTasks(array $collectionMap): void
{
if (empty($collectionMap)) {
return;
}
Task::whereNull('collection_id')
->orderBy('id')
->chunkById(100, function ($tasks) use ($collectionMap) {
foreach ($tasks as $task) {
$collection = $collectionMap[$task->event_type_id] ?? Arr::first($collectionMap);
if (! $collection) {
continue;
}
$task->collection_id = $collection->id;
$task->save();
$collection->tasks()->syncWithoutDetaching([
$task->id => ['sort_order' => $task->sort_order ?? 0],
]);
}
});
}
protected function ensureEventType(array $definition): EventType
{
$payload = [
'name' => $definition['name'],
'icon' => $definition['icon'] ?? null,
];
return EventType::updateOrCreate(
['slug' => $definition['slug']],
$payload
[
'name' => $definition['name'],
'icon' => $definition['icon'] ?? null,
]
);
}
@@ -261,7 +557,6 @@ class TaskCollectionsSeeder extends Seeder
}
$query = Emotion::query();
$name = $definition['name'] ?? [];
if (isset($name['en'])) {

2159
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.5.2",
"@types/fabric": "^5.3.9",
"@types/node": "^22.13.5",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1",
@@ -82,6 +83,9 @@
"i18next-http-backend": "^3.0.2",
"laravel-vite-plugin": "^2.0",
"lucide-react": "^0.475.0",
"fabric": "^6.0.1",
"fabricjs-design-tool": "github:rifrocket/fabricjs-design-tool#main",
"pdf-lib": "^1.17.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-rnd": "^10.4.12",

1
public/hot.bak Normal file
View File

@@ -0,0 +1 @@
http://localhost:5173

View File

@@ -8,6 +8,12 @@ export type EventQrInviteLayout = {
name: string;
description: string;
subtitle: string;
badge_label?: string | null;
instructions_heading?: string | null;
link_heading?: string | null;
cta_label?: string | null;
cta_caption?: string | null;
instructions?: string[];
preview: {
background: string | null;
background_gradient: { angle: number; stops: string[] } | null;
@@ -16,7 +22,7 @@ export type EventQrInviteLayout = {
qr_size_px?: number | null;
};
formats: string[];
download_urls: Record<string, string>;
download_urls?: Record<string, string>;
};
export type TenantEventType = {
@@ -675,6 +681,12 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
name: String(layout.name ?? ''),
description: String(layout.description ?? ''),
subtitle: String(layout.subtitle ?? ''),
badge_label: layout.badge_label ?? null,
instructions_heading: layout.instructions_heading ?? null,
link_heading: layout.link_heading ?? null,
cta_label: layout.cta_label ?? null,
cta_caption: layout.cta_caption ?? null,
instructions: Array.isArray(layout.instructions) ? (layout.instructions as string[]) : [],
preview: {
background: layout.preview?.background ?? null,
background_gradient: layout.preview?.background_gradient ?? null,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import { Loader2, PanelLeftClose, PanelRightOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -22,11 +22,48 @@ declare global {
export function DevTenantSwitcher() {
const helper = window.fotospielDemoAuth;
const [loggingIn, setLoggingIn] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
try {
return window.localStorage.getItem('fotospiel-dev-switcher-collapsed') === '1';
} catch (error) {
console.warn('[DevAuth] Failed to read collapse state', error);
return false;
}
});
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem('fotospiel-dev-switcher-collapsed', collapsed ? '1' : '0');
} catch (error) {
console.warn('[DevAuth] Failed to persist collapse state', error);
}
}, [collapsed]);
if (!helper) {
return null;
}
if (collapsed) {
return (
<button
type="button"
className="pointer-events-auto fixed bottom-4 right-4 z-[1000] flex items-center gap-2 rounded-full border border-amber-200 bg-white/95 px-4 py-2 text-sm font-medium text-amber-700 shadow-lg shadow-amber-200/60 transition hover:bg-amber-50"
onClick={() => setCollapsed(false)}
>
<PanelRightOpen className="h-4 w-4" />
Demo tenants
</button>
);
}
async function handleLogin(key: string) {
if (!helper) return;
setLoggingIn(key);
@@ -41,9 +78,19 @@ export function DevTenantSwitcher() {
return (
<div className="pointer-events-auto fixed bottom-4 right-4 z-[1000] flex max-w-xs flex-col gap-2 rounded-xl border border-amber-200 bg-white/95 p-3 text-sm shadow-xl shadow-amber-200/60">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<strong className="text-amber-800">Demo tenants</strong>
<span className="text-xs uppercase tracking-wide text-amber-600">Dev mode</span>
</div>
<button
type="button"
onClick={() => setCollapsed(true)}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-600 transition hover:bg-amber-50"
aria-label="Switcher minimieren"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-amber-700">
Select a seeded tenant to mint OAuth tokens and jump straight into their admin space. Available only in development builds.
</p>

View File

@@ -115,33 +115,39 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
globalThis.fotospielDemoAuth = api;
}
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
async function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
const requestUrl = new URL(url, window.location.origin);
let response: Response;
try {
response = await fetch(requestUrl.toString(), {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json, text/plain, */*',
'X-Requested-With': 'XMLHttpRequest',
},
redirect: 'manual',
});
} catch (error) {
throw new Error('Authorize request failed');
}
const contentType = xhr.getResponseHeader('Content-Type') ?? '';
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) {
if (responseUrl) {
resolve(new URL(responseUrl, window.location.origin));
return;
const status = response.status;
const isSuccess = (status >= 200 && status < 400) || status === 0;
if (!isSuccess) {
throw new Error(`Authorize failed with ${status}`);
}
const contentType = response.headers.get('Content-Type') ?? '';
if (contentType.includes('application/json')) {
try {
const payload = JSON.parse(xhr.responseText ?? '{}') as {
const payload = (await response.json()) as {
code?: string;
state?: string | null;
redirect_url?: string | null;
};
const target = payload.redirect_url ?? fallbackRedirect;
if (!target) {
throw new Error('Authorize response missing redirect target');
@@ -155,20 +161,26 @@ function requestAuthorization(url: string, fallbackRedirect?: string): Promise<U
finalUrl.searchParams.set('state', payload.state);
}
resolve(finalUrl);
return;
return finalUrl;
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
throw error instanceof Error ? error : new Error(String(error));
}
}
reject(new Error(`Authorize failed with ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Authorize request failed'));
xhr.send();
});
const locationHeader = response.headers.get('Location');
if (locationHeader) {
return new URL(locationHeader, window.location.origin);
}
if (response.url && response.url !== requestUrl.toString()) {
return new URL(response.url, window.location.origin);
}
if (fallbackRedirect) {
return new URL(fallbackRedirect, window.location.origin);
}
throw new Error('Authorize response missing redirect target');
}
function verifyState(returnedState: string | null, expectedState: string): void {

View File

@@ -1,10 +1,21 @@
{
"login": {
"title": "Tenant-Admin",
"lead": "Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und anschließend zur Admin-Oberfläche zurückgebracht.",
"cta": "Mit Tenant-Account anmelden",
"badge": "Fotospiel Tenant Admin",
"hero_title": "Event-Steuerung, die sich wie Zuhause anfühlt.",
"hero_subtitle": "Wechsle mühelos zwischen Mandanten, behalte Live-Uploads im Blick und teile elegante Einladungen alles in einer ruhigen Oberfläche.",
"features": [
"Gestalte QR-Einladungen und druckfertige Layouts in wenigen Klicks passend zu eurer Marke.",
"Organisiere Aufgaben, Emotionen und Sammlungen für jeden Eventtyp ohne Excel-Chaos.",
"Bleib am Eventtag souverän mit Dashboards, Live-Statistiken und sofortiger Moderation."
],
"lead": "Die Anmeldung erfolgt über unseren sicheren OAuth-Login und bringt dich direkt wieder zurück.",
"panel_copy": "Melde dich mit deinen Fotospiel-Admin-Zugangsdaten an. Wir schützen dein Konto mit OAuth 2.1 und mandantenbewussten Berechtigungen.",
"cta": "Mit Fotospiel-Login fortfahren",
"loading": "Bitte warten …",
"oauth_error_title": "Login aktuell nicht möglich",
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}",
"support": "Du brauchst Zugriff? Wende dich an den Tenant-Inhaber oder schreibe an support@fotospiel.de wir helfen gern weiter.",
"appearance_label": "Darstellung"
}
}

View File

@@ -283,12 +283,12 @@
},
"logo": {
"label": "Logo",
"hint": "PNG oder SVG, max. 1 MB. Wird oben rechts platziert.",
"hint": "PNG, max. 1 MB. Wird oben rechts platziert.",
"remove": "Logo entfernen"
},
"preview": {
"title": "Vorschau",
"hint": "Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten."
"hint": "Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/PNGs zu erhalten."
},
"actions": {
"save": "Speichern",
@@ -299,7 +299,9 @@
"actionLabel": "Layout anpassen",
"errors": {
"logoTooLarge": "Das Logo darf maximal 1 MB groß sein.",
"noLayout": "Bitte wähle ein Layout aus."
"noLayout": "Bitte wähle ein Layout aus.",
"downloadFailed": "Download fehlgeschlagen. Bitte versuche es erneut.",
"printFailed": "Drucken konnte nicht gestartet werden."
},
"defaults": {
"badgeLabel": "Digitale Gästebox",
@@ -310,7 +312,8 @@
"QR-Code scannen",
"Profil anlegen",
"Fotos teilen"
]
],
"textBlock": "Neuer Textblock hier kannst du eigene Hinweise ergänzen."
}
}
},
@@ -354,24 +357,35 @@
"export": {
"title": "Drucken & Export",
"description": "Lade druckfertige Dateien herunter oder starte direkt einen Testdruck.",
"mode": {
"standard": "Standardlayout",
"advanced": "Freier Editor"
},
"previewHint": "Speichere deine Änderungen, um die Exportdateien neu zu erstellen.",
"noLayoutPreview": "Noch keine Vorschau verfügbar. Speichere das Layout zuerst.",
"selectPlaceholder": "Einladung auswählen",
"noInviteSelected": "Wähle zunächst eine Einladung aus, um Downloads zu starten.",
"noLayouts": "Für diese Einladung sind aktuell keine Layouts verfügbar.",
"actions": {
"print": "Direkt drucken"
"title": "Aktionen",
"description": "Starte deinen Testdruck oder lade die Layouts herunter.",
"printNow": "Direkt drucken",
"hint": "PDF enthält Beschnittmarken, PNG ist ideal für digitale Freigaben."
},
"errorTitle": "Download fehlgeschlagen"
},
"customizer": {
"heading": "Layout anpassen",
"copy": "Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.",
"copy": "Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.",
"actions": {
"save": "Layout speichern",
"reset": "Zurücksetzen",
"print": "Drucken",
"removeLogo": "Logo entfernen",
"uploadLogo": "Logo hochladen (max. 1 MB)",
"addInstruction": "Punkt hinzufügen"
"addInstruction": "Punkt hinzufügen",
"undo": "Rückgängig",
"redo": "Wiederholen"
},
"sections": {
"layouts": "Layouts",
@@ -381,6 +395,37 @@
"instructionsHint": "Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.",
"branding": "Farbgebung"
},
"elements": {
"title": "Elemente & Positionierung",
"hint": "Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.",
"headline": "Überschrift",
"subtitle": "Untertitel",
"description": "Beschreibung",
"badge": "Badge",
"link": "Linkfeld",
"cta": "Call-to-Action",
"qr": "QR-Code",
"logo": "Logo",
"text": "Freier Textblock",
"remove": "Element entfernen",
"details": "Element-Details",
"detailsHint": "Passe Text, Ausrichtung und weitere Eigenschaften an. Änderungen werden sofort sichtbar.",
"customText": "Textinhalt",
"qrHint": "Der QR-Code lässt sich im Canvas in Größe und Position verändern.",
"logoHint": "Logos bearbeitest du im Bereich „Branding“. Hier kannst du das Element nur verschieben oder skalieren.",
"align": "Ausrichtung",
"alignLeft": "Links",
"alignCenter": "Zentriert",
"alignRight": "Rechts",
"fontSize": "Schriftgröße",
"selectHint": "Tippe ein Element im Canvas oder in der Liste an, um Details zu bearbeiten.",
"listHint": "Wähle ein Element aus, damit seine Einstellungen direkt darunter erscheinen.",
"addSubtitle": "Untertitel einblenden",
"addBadge": "Badge anzeigen",
"addLink": "Linkfeld hinzufügen",
"addCta": "Call-to-Action einfügen",
"addText": "Freien Textblock hinzufügen"
},
"fields": {
"headline": "Überschrift",
"subtitle": "Unterzeile",
@@ -415,6 +460,113 @@
"layoutFallback": "Layout"
}
},
"events": {
"errors": {
"missingSlug": "Kein Event ausgewählt.",
"loadFailed": "Event konnte nicht geladen werden.",
"notFoundTitle": "Event nicht gefunden",
"notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.",
"toggleFailed": "Status konnte nicht angepasst werden."
},
"alerts": {
"failedTitle": "Aktion fehlgeschlagen"
},
"placeholders": {
"untitled": "Unbenanntes Event"
},
"actions": {
"backToList": "Zurück zur Liste",
"edit": "Bearbeiten",
"members": "Team & Rollen",
"tasks": "Aufgaben verwalten",
"invites": "Einladungen & Layouts",
"photos": "Fotos moderieren",
"refresh": "Aktualisieren"
},
"workspace": {
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.",
"sections": {
"statusTitle": "Eventstatus & Sichtbarkeit",
"statusSubtitle": "Aktiviere dein Event für Gäste oder verstecke es vorübergehend."
},
"fields": {
"status": "Status",
"active": "Aktiv für Gäste",
"date": "Eventdatum",
"eventType": "Event-Typ",
"insights": "Letzte Aktivität",
"uploadsTotal": "{{count}} Uploads gesamt",
"uploadsToday": "{{count}} Uploads (24h)",
"likesTotal": "{{count}} Likes vergeben"
},
"actions": {
"pause": "Event pausieren",
"activate": "Event aktivieren"
},
"activeYes": "Ja",
"activeNo": "Nein"
},
"status": {
"published": "Veröffentlicht",
"draft": "Entwurf",
"archived": "Archiviert"
},
"quickActions": {
"title": "Schnellaktionen",
"subtitle": "Nutze die wichtigsten Schritte vor und während deines Events.",
"moderate": "Fotos moderieren",
"tasks": "Aufgaben bearbeiten",
"invites": "Layouts & QR verwalten",
"roles": "Team & Rollen anpassen",
"print": "Layouts als PDF drucken",
"toggle": "Status ändern"
},
"metrics": {
"uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)",
"pending": "Fotos in Moderation",
"activeInvites": "Aktive Einladungen"
},
"invites": {
"title": "QR-Einladungen",
"subtitle": "Behält aktive Einladungen und Layouts im Blick.",
"activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt",
"empty": "Noch keine Einladungen erstellt.",
"manage": "Layouts & Einladungen verwalten"
},
"tasks": {
"title": "Aktive Aufgaben",
"subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.",
"summary": "{{completed}} von {{total}} erledigt",
"empty": "Noch keine Aufgaben zugewiesen.",
"manage": "Aufgabenbereich öffnen"
},
"photos": {
"pendingTitle": "Fotos in Moderation",
"pendingSubtitle": "Schnell prüfen, bevor Gäste live gehen.",
"pendingCount": "{{count}} Fotos offen",
"pendingEmpty": "Aktuell warten keine Fotos auf Freigabe.",
"openModeration": "Moderation öffnen",
"recentTitle": "Neueste Uploads",
"recentSubtitle": "Halte Ausschau nach Highlight-Momenten der Gäste.",
"recentEmpty": "Noch keine neuen Uploads."
},
"feedback": {
"title": "Wie läuft dein Event?",
"subtitle": "Feedback hilft uns, neue Features zu priorisieren.",
"positive": "Super Lauf!",
"neutral": "Läuft",
"negative": "Braucht Support",
"placeholder": "Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.",
"errorTitle": "Feedback konnte nicht gesendet werden.",
"authError": "Deine Session ist abgelaufen. Bitte melde dich erneut an.",
"genericError": "Feedback konnte nicht gesendet werden.",
"submit": "Feedback senden",
"submitted": "Danke!"
}
},
"collections": {
"title": "Aufgabenvorlagen",
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",

View File

@@ -1,10 +1,21 @@
{
"login": {
"title": "Tenant Admin",
"lead": "Sign in with your Fotospiel account. We will redirect you to the secure OAuth login and bring you back to the admin dashboard afterwards.",
"cta": "Sign in with tenant account",
"loading": "Please wait …",
"badge": "Fotospiel Tenant Admin",
"hero_title": "Event control that feels at home.",
"hero_subtitle": "Switch between tenants, monitor live uploads, and share beautiful invites — all in one calm workspace.",
"features": [
"Design QR invites and print-ready layouts that match your brand in minutes.",
"Coordinate tasks, emotions, and achievements for every event flow.",
"Stay confident on event day with dashboards, live stats, and instant moderation."
],
"lead": "You will be redirected to our secure OAuth login and come right back afterwards.",
"panel_copy": "Sign in with your Fotospiel admin credentials to continue. We secure your account with OAuth 2.1 and tenant-aware permissions.",
"cta": "Continue with Fotospiel login",
"loading": "Signing you in …",
"oauth_error_title": "Login not possible right now",
"oauth_error": "Sign-in failed: {{message}}",
"support": "Need access? Contact your tenant owner or email support@fotospiel.de — we're happy to help.",
"appearance_label": "Appearance"
}
}

View File

@@ -283,12 +283,12 @@
},
"logo": {
"label": "Logo",
"hint": "PNG or SVG, max. 1 MB. Appears in the top right corner.",
"hint": "PNG, max. 1 MB. Appears in the top right corner.",
"remove": "Remove logo"
},
"preview": {
"title": "Preview",
"hint": "Visual reference for colours and texts. Save to generate new PDFs/SVGs."
"hint": "Visual reference for colours and texts. Save to generate new PDFs/PNGs."
},
"actions": {
"save": "Save",
@@ -299,7 +299,9 @@
"actionLabel": "Customize layout",
"errors": {
"logoTooLarge": "Logo must not exceed 1 MB.",
"noLayout": "Please select a layout."
"noLayout": "Please select a layout.",
"downloadFailed": "Download failed. Please try again.",
"printFailed": "Printing could not be started."
},
"defaults": {
"badgeLabel": "Digital guest box",
@@ -310,7 +312,8 @@
"Scan the QR code",
"Create your profile",
"Share your photos"
]
],
"textBlock": "New text block add your own notes here."
}
}
},
@@ -354,24 +357,35 @@
"export": {
"title": "Print & export",
"description": "Download print-ready files or launch a test print right away.",
"mode": {
"standard": "Standard layout",
"advanced": "Advanced editor"
},
"previewHint": "Save after making changes to regenerate the export files.",
"noLayoutPreview": "No preview available yet. Save your layout first.",
"selectPlaceholder": "Select invite",
"noInviteSelected": "Select an invite first to start downloads.",
"noLayouts": "There are currently no layouts available for this invite.",
"actions": {
"print": "Print now"
"title": "Actions",
"description": "Start a test print or download the layouts.",
"printNow": "Print now",
"hint": "PDF includes crop marks; PNG is perfect for quick digital approvals."
},
"errorTitle": "Download failed"
},
"customizer": {
"heading": "Customise layout",
"copy": "Make the invite your own adjust copy, colours, and logos in real time.",
"copy": "Edit texts, colours, and positions right next to the live preview. Changes appear instantly.",
"actions": {
"save": "Save layout",
"reset": "Reset",
"print": "Print",
"removeLogo": "Remove logo",
"uploadLogo": "Upload logo (max. 1 MB)",
"addInstruction": "Add step"
"addInstruction": "Add step",
"undo": "Undo",
"redo": "Redo"
},
"sections": {
"layouts": "Layouts",
@@ -381,6 +395,37 @@
"instructionsHint": "Guide guests with clear steps. Maximum of five.",
"branding": "Colors"
},
"elements": {
"title": "Elements & positioning",
"hint": "Select an element to move, edit, or remove it.",
"headline": "Headline",
"subtitle": "Subheading",
"description": "Description",
"badge": "Badge",
"link": "Link field",
"cta": "Call-to-action",
"qr": "QR code",
"logo": "Logo",
"text": "Free text block",
"remove": "Remove element",
"details": "Element details",
"detailsHint": "Adjust copy, alignment, and more. Changes appear instantly in the preview.",
"customText": "Text content",
"qrHint": "Resize and reposition the QR code directly on the canvas.",
"logoHint": "Adjust logos in the branding section. Use the canvas to resize or move the element.",
"align": "Alignment",
"alignLeft": "Left",
"alignCenter": "Center",
"alignRight": "Right",
"fontSize": "Font size",
"selectHint": "Tap an element on the canvas or in the list to edit its details.",
"listHint": "Select an element to reveal its settings directly under the entry.",
"addSubtitle": "Show subtitle",
"addBadge": "Show badge",
"addLink": "Add link field",
"addCta": "Insert call-to-action",
"addText": "Add free text block"
},
"fields": {
"headline": "Headline",
"subtitle": "Subheading",
@@ -415,6 +460,113 @@
"layoutFallback": "Layout"
}
},
"events": {
"errors": {
"missingSlug": "No event selected.",
"loadFailed": "Event could not be loaded.",
"notFoundTitle": "Event not found",
"notFoundBody": "Without a valid identifier we cant load the data. Return to the list and choose an event.",
"toggleFailed": "Status could not be updated."
},
"alerts": {
"failedTitle": "Action failed"
},
"placeholders": {
"untitled": "Untitled event"
},
"actions": {
"backToList": "Back to list",
"edit": "Edit",
"members": "Team & roles",
"tasks": "Manage tasks",
"invites": "Invites & layouts",
"photos": "Moderate photos",
"refresh": "Refresh"
},
"workspace": {
"detailSubtitle": "Keep status, tasks, and invites of your event in one view.",
"toolkitSubtitle": "Bundle moderation, tasks, and invites for the event day.",
"sections": {
"statusTitle": "Event status & visibility",
"statusSubtitle": "Activate the event for guests or hide it temporarily."
},
"fields": {
"status": "Status",
"active": "Active for guests",
"date": "Event date",
"eventType": "Event type",
"insights": "Recent activity",
"uploadsTotal": "{{count}} uploads total",
"uploadsToday": "{{count}} uploads (24h)",
"likesTotal": "{{count}} likes in total"
},
"actions": {
"pause": "Pause event",
"activate": "Activate event"
},
"activeYes": "Yes",
"activeNo": "No"
},
"status": {
"published": "Published",
"draft": "Draft",
"archived": "Archived"
},
"quickActions": {
"title": "Quick actions",
"subtitle": "Jump into the most important flows before and during the event.",
"moderate": "Moderate photos",
"tasks": "Edit tasks",
"invites": "Manage layouts & QR",
"roles": "Adjust team & roles",
"print": "Print layouts as PDF",
"toggle": "Change status"
},
"metrics": {
"uploadsTotal": "Uploads total",
"uploads24h": "Uploads (24h)",
"pending": "Photos in moderation",
"activeInvites": "Active invites"
},
"invites": {
"title": "QR invites",
"subtitle": "Keep an eye on active links and layouts.",
"activeCount": "{{count}} active",
"totalCount": "{{count}} total",
"empty": "No invites created yet.",
"manage": "Manage layouts & invites"
},
"tasks": {
"title": "Active tasks",
"subtitle": "Motivate guests with clear prompts & highlights.",
"summary": "{{completed}} of {{total}} complete",
"empty": "No tasks assigned yet.",
"manage": "Open task workspace"
},
"photos": {
"pendingTitle": "Photos awaiting review",
"pendingSubtitle": "Check uploads before they go live.",
"pendingCount": "{{count}} photos pending",
"pendingEmpty": "No photos waiting for moderation.",
"openModeration": "Open moderation",
"recentTitle": "Latest uploads",
"recentSubtitle": "Spot the latest guest highlights.",
"recentEmpty": "No new uploads yet."
},
"feedback": {
"title": "How is your event running?",
"subtitle": "Your feedback helps us prioritise improvements.",
"positive": "Going great!",
"neutral": "All right",
"negative": "Needs support",
"placeholder": "Optional: tell us what works well or where you need help.",
"errorTitle": "Feedback could not be sent.",
"authError": "Your session expired. Please sign in again.",
"genericError": "Feedback could not be sent.",
"submit": "Send feedback",
"submitted": "Thanks!"
}
},
"collections": {
"title": "Task collections",
"subtitle": "Browse curated task bundles or activate them for your events.",

View File

@@ -1,162 +1,195 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Sparkles, QrCode } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
ArrowLeft,
Camera,
CheckCircle2,
ChevronRight,
Circle,
Download,
Loader2,
MessageSquare,
Printer,
QrCode,
RefreshCw,
Smile,
Sparkles,
Users,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import {
EventStats as TenantEventStats,
EventToolkit,
EventToolkitTask,
TenantEvent,
TenantPhoto,
TenantEventStats,
getEvent,
getEventStats,
TenantEvent,
getEventToolkit,
toggleEvent,
submitTenantFeedback,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
} from '../constants';
interface State {
type EventDetailPageProps = {
mode?: 'detail' | 'toolkit';
};
type ToolkitState = {
data: EventToolkit | null;
loading: boolean;
error: string | null;
};
type WorkspaceState = {
event: TenantEvent | null;
stats: TenantEventStats | null;
error: string | null;
loading: boolean;
busy: boolean;
}
error: string | null;
};
export default function EventDetailPage() {
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const slug = params.slug ?? searchParams.get('slug') ?? null;
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps): JSX.Element {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const [state, setState] = React.useState<State>({
const slug = slugParam ?? null;
const [state, setState] = React.useState<WorkspaceState>({
event: null,
stats: null,
error: null,
loading: true,
busy: false,
error: null,
});
const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null });
const load = React.useCallback(async () => {
if (!slug) {
setState((prev) => ({ ...prev, loading: false, error: 'Kein Event-Slug angegeben.' }));
setState({ event: null, stats: null, loading: false, busy: false, error: t('events.errors.missingSlug', 'Kein Event ausgewählt.') });
setToolkit({ data: null, loading: false, error: null });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
setToolkit((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData] = await Promise.all([
getEvent(slug),
getEventStats(slug),
]);
setState((prev) => ({
...prev,
event: eventData,
stats: statsData,
loading: false,
}));
} catch (err) {
if (isAuthError(err)) return;
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false }));
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]);
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: t('events.errors.loadFailed', 'Event konnte nicht geladen werden.'), loading: false }));
}
}, [slug]);
}
try {
const toolkitData = await getEventToolkit(slug);
setToolkit({ data: toolkitData, loading: false, error: null });
} catch (error) {
if (!isAuthError(error)) {
setToolkit({ data: null, loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.') });
}
}
}, [slug, t]);
React.useEffect(() => {
load();
void load();
}, [load]);
async function handleToggle() {
if (!slug) return;
async function handleToggle(): Promise<void> {
if (!slug) {
return;
}
setState((prev) => ({ ...prev, busy: true, error: null }));
try {
const updated = await toggleEvent(slug);
setState((prev) => ({
...prev,
event: updated,
stats: prev.stats ? { ...prev.stats, status: updated.status, is_active: Boolean(updated.is_active) } : prev.stats,
busy: false,
event: updated,
stats: prev.stats
? {
...prev.stats,
status: updated.status,
is_active: Boolean(updated.is_active),
}
: prev.stats,
}));
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Status konnte nicht angepasst werden.', busy: false }));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, busy: false, error: t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.') }));
} else {
setState((prev) => ({ ...prev, busy: false }));
}
}
}
const { event, stats, error, loading, busy } = state;
const eventDisplayName = event ? renderName(event.name) : '';
const activeInvitesCount = event?.active_invites_count ?? 0;
const totalInvitesCount = event?.total_invites_count ?? activeInvitesCount;
const { event, stats, loading, busy, error } = state;
const toolkitData = toolkit.data;
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const subtitle = mode === 'toolkit'
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const actions = (
<>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
</Button>
{event && (
<>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
>
Bearbeiten
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))}
className="border-sky-200 text-sky-700 hover:bg-sky-50"
>
Mitglieder
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
<Users className="h-4 w-4" /> {t('events.actions.members', 'Team & Rollen')}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
className="border-amber-200 text-amber-600 hover:bg-amber-50"
>
Tasks
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.tasks', 'Aufgaben verwalten')}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
className="border-amber-200 text-amber-600 hover:bg-amber-50"
>
QR &amp; Einladungen
<Button variant="outline" onClick={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} className="border-amber-200 text-amber-600 hover:bg-amber-50">
<QrCode className="h-4 w-4" /> {t('events.actions.invites', 'Einladungen & Layouts')}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
className="border-emerald-200 text-emerald-600 hover:bg-emerald-50"
>
Event-Day Toolkit
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Camera className="h-4 w-4" /> {t('events.actions.photos', 'Fotos moderieren')}
</Button>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</>
)}
</>
</div>
);
if (!slug) {
return (
<AdminLayout title="Event nicht gefunden" subtitle="Bitte wähle ein Event aus der Übersicht." actions={actions}>
<AdminLayout title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')} actions={actions}>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
Ohne gültigen Slug können wir keine Daten laden. Kehre zur Event-Liste zurück und wähle dort ein Event aus.
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</CardContent>
</Card>
</AdminLayout>
@@ -164,159 +197,591 @@ export default function EventDetailPage() {
}
return (
<AdminLayout
title={event ? renderName(event.name) : 'Event wird geladen'}
subtitle="Verwalte Status, Einladungen und Statistiken deines Events."
actions={actions}
>
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
{error && (
<Alert variant="destructive">
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{toolkit.error && (
<Alert variant="default">
<AlertTitle>{toolkit.error}</AlertTitle>
</Alert>
)}
{loading ? (
<DetailSkeleton />
<WorkspaceSkeleton />
) : event ? (
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdaten
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Grundlegende Informationen für Gäste und Moderation.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<InfoRow label="Slug" value={event.slug} />
<InfoRow label="Status" value={event.status === 'published' ? 'Veröffentlicht' : event.status} />
<InfoRow label="Datum" value={formatDate(event.event_date)} />
<InfoRow label="Aktiv" value={event.is_active ? 'Aktiv' : 'Deaktiviert'} />
<div className="flex flex-wrap gap-3 pt-2">
<Button
onClick={handleToggle}
disabled={busy}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{event.is_active ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
className="border-sky-200 text-sky-700 hover:bg-sky-50"
>
<Camera className="h-4 w-4" /> Fotos moderieren
</Button>
</div>
</CardContent>
</Card>
<div className="space-y-6">
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<QrCode className="h-5 w-5 text-amber-500" /> Einladungen &amp; QR
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Steuere QR-Einladungen, Layouts und Branding gesammelt auf einer eigenen Seite.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
<p>
Aktive QR-Einladungen: {activeInvitesCount} · Gesamt erstellt: {totalInvitesCount}
</p>
<p>
Bereite deine Drucklayouts vor, personalisiere Texte und Logos und drucke sie direkt aus.
</p>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
<QuickActionsCard
slug={event.slug}
busy={busy}
onToggle={handleToggle}
navigate={navigate}
/>
</div>
<div className="space-y-2">
<Button
className="w-full bg-amber-500 text-white shadow-lg shadow-amber-500/20 hover:bg-amber-500/90"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
>
Einladungen &amp; Layouts verwalten
</Button>
<p className="text-xs text-slate-500">
Du kannst bestehende Layouts duplizieren, Farben anpassen und neue PDFs generieren.
</p>
</div>
</CardContent>
</Card>
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
<Card className="border-0 bg-white/90 shadow-xl shadow-sky-100/60 lg:col-span-2">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Heart className="h-5 w-5 text-sky-500" /> Performance
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Kennzahlen zu Uploads, Highlights und Interaktion.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 md:grid-cols-4">
<StatChip label="Fotos" value={stats?.total ?? 0} />
<StatChip label="Featured" value={stats?.featured ?? 0} />
<StatChip label="Likes" value={stats?.likes ?? 0} />
<StatChip label="Uploads (7 Tage)" value={stats?.recent_uploads ?? 0} />
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
<InviteSummary
invites={toolkitData?.invites}
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<PendingPhotosCard
photos={toolkitData?.photos.pending ?? []}
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
/>
<RecentUploadsCard photos={toolkitData?.photos.recent ?? []} />
</div>
<FeedbackCard slug={event.slug} />
</div>
) : (
<Alert variant="destructive">
<AlertTitle>Event nicht gefunden</AlertTitle>
<AlertDescription>Bitte prüfe den Slug und versuche es erneut.</AlertDescription>
</Alert>
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</CardContent>
</Card>
)}
</AdminLayout>
);
}
function DetailSkeleton() {
function resolveName(name: TenantEvent['name']): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name.trim();
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: TenantEventStats | null; busy: boolean; onToggle: () => void }) {
const { t } = useTranslation('management');
const statusLabel = event.status === 'published'
? t('events.status.published', 'Veröffentlicht')
: event.status === 'draft'
? t('events.status.draft', 'Entwurf')
: t('events.status.archived', 'Archiviert');
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" />
{t('events.workspace.sections.statusTitle', 'Eventstatus & Sichtbarkeit')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.workspace.sections.statusSubtitle', 'Aktiviere dein Event für Gäste oder verstecke es vorübergehend.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<InfoRow icon={<Sparkles className="h-4 w-4 text-pink-500" />} label={t('events.workspace.fields.status', 'Status')} value={statusLabel} />
<InfoRow icon={<Circle className="h-4 w-4 text-amber-500" />} label={t('events.workspace.fields.active', 'Aktiv für Gäste')} value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')} />
<InfoRow icon={<CalendarIcon />} label={t('events.workspace.fields.date', 'Eventdatum')} value={formatDate(event.event_date)} />
<InfoRow icon={<Smile className="h-4 w-4 text-rose-500" />} label={t('events.workspace.fields.eventType', 'Event-Typ')} value={resolveEventType(event)} />
{stats && (
<div className="rounded-xl border border-pink-100 bg-pink-50/60 p-4 text-xs text-pink-900">
<p className="font-semibold text-pink-700">{t('events.workspace.fields.insights', 'Letzte Aktivität')}</p>
<p>
{t('events.workspace.fields.uploadsTotal', { defaultValue: '{{count}} Uploads gesamt', count: stats.uploads_total })}
{' · '}
{t('events.workspace.fields.uploadsToday', { defaultValue: '{{count}} Uploads (24h)', count: stats.uploads_24h })}
</p>
<p>
{t('events.workspace.fields.likesTotal', { defaultValue: '{{count}} Likes vergeben', count: stats.likes_total })}
</p>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2">
<Button onClick={onToggle} disabled={busy} className="bg-gradient-to-r from-pink-500 via-rose-500 to-purple-500 text-white shadow-md shadow-rose-200/60">
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : event.is_active ? <Circle className="mr-2 h-4 w-4" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
</Button>
</div>
</CardContent>
</Card>
);
}
function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; busy: boolean; onToggle: () => void | Promise<void>; navigate: ReturnType<typeof useNavigate> }) {
const { t } = useTranslation('management');
const actions = [
{
icon: <Camera className="h-4 w-4" />,
label: t('events.quickActions.moderate', 'Fotos moderieren'),
onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)),
},
{
icon: <Sparkles className="h-4 w-4" />,
label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'),
onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)),
},
{
icon: <QrCode className="h-4 w-4" />,
label: t('events.quickActions.invites', 'Layouts & QR verwalten'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`),
},
{
icon: <Users className="h-4 w-4" />,
label: t('events.quickActions.roles', 'Team & Rollen anpassen'),
onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)),
},
{
icon: <Printer className="h-4 w-4" />,
label: t('events.quickActions.print', 'Layouts als PDF drucken'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`),
},
{
icon: <CheckCircle2 className="h-4 w-4" />,
label: t('events.quickActions.toggle', 'Status ändern'),
onClick: () => { void onToggle(); },
disabled: busy,
},
];
return (
<Card className="border-0 bg-white/90 shadow-xl shadow-violet-100/60">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Sparkles className="h-5 w-5 text-violet-500" />
{t('events.quickActions.title', 'Schnellaktionen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.quickActions.subtitle', 'Nutze die wichtigsten Schritte vor und während deines Events.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{actions.map((action, index) => (
<button
key={index}
type="button"
onClick={() => {
if (action.disabled) {
return;
}
const result = action.onClick();
if (result instanceof Promise) {
void result;
}
}}
disabled={action.disabled}
className="flex w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3 text-left text-sm text-slate-700 transition hover:border-violet-200 hover:bg-violet-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-violet-100 text-violet-600">
{action.icon}
</span>
{action.label}
</span>
<ChevronRight className="h-4 w-4 text-slate-400" />
</button>
))}
</CardContent>
</Card>
);
}
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: TenantEventStats | null }) {
const { t } = useTranslation('management');
const cards = [
{
icon: <Camera className="h-5 w-5 text-emerald-500" />,
label: t('events.metrics.uploadsTotal', 'Uploads gesamt'),
value: metrics?.uploads_total ?? stats?.uploads_total ?? 0,
},
{
icon: <Camera className="h-5 w-5 text-sky-500" />,
label: t('events.metrics.uploads24h', 'Uploads (24h)'),
value: metrics?.uploads_24h ?? stats?.uploads_24h ?? 0,
},
{
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
label: t('events.metrics.pending', 'Fotos in Moderation'),
value: metrics?.pending_photos ?? stats?.pending_photos ?? 0,
},
{
icon: <QrCode className="h-5 w-5 text-indigo-500" />,
label: t('events.metrics.activeInvites', 'Aktive Einladungen'),
value: metrics?.active_invites ?? 0,
},
];
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card, index) => (
<Card key={index} className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
<CardContent className="flex items-center gap-4 p-5">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100">{card.icon}</span>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
</div>
</CardContent>
</Card>
))}
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['invites'] | undefined; navigateToInvites: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col gap-1 rounded-xl border border-pink-100 bg-white/70 px-3 py-2">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<span className="text-sm font-medium text-slate-800">{value}</span>
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<QrCode className="h-5 w-5 text-amber-500" />
{t('events.invites.title', 'QR-Einladungen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
<div className="flex gap-2 text-sm text-slate-900">
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('events.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites?.summary.active ?? 0 })}
</Badge>
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('events.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites?.summary.total ?? 0 })}
</Badge>
</div>
{invites?.items?.length ? (
<ul className="space-y-2 text-xs">
{invites.items.slice(0, 3).map((invite) => (
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
<p className="truncate text-[11px] text-amber-700">{invite.url}</p>
</li>
))}
</ul>
) : (
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine Einladungen erstellt.')}</p>
)}
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'Layouts & Einladungen verwalten')}
</Button>
</CardContent>
</Card>
);
}
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks'] | undefined; navigateToTasks: () => void }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" />
{t('events.tasks.title', 'Aktive Aufgaben')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{t('events.tasks.summary', {
defaultValue: '{{completed}} von {{total}} erledigt',
completed: tasks?.summary.completed ?? 0,
total: tasks?.summary.total ?? 0,
})}
</Badge>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
{tasks?.items?.length ? (
<div className="space-y-2">
{tasks.items.slice(0, 4).map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
)}
<Button variant="outline" onClick={navigateToTasks} className="border-pink-200 text-pink-600 hover:bg-pink-50">
<Sparkles className="mr-2 h-4 w-4" /> {t('events.tasks.manage', 'Aufgabenbereich öffnen')}
</Button>
</CardContent>
</Card>
);
}
function TaskRow({ task }: { task: EventToolkitTask }) {
return (
<div className="flex items-start justify-between rounded-lg border border-pink-100 bg-white/80 px-3 py-2 text-xs text-slate-600">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{task.title}</p>
{task.description ? <p>{task.description}</p> : null}
</div>
<Badge variant={task.is_completed ? 'default' : 'outline'} className={task.is_completed ? 'bg-emerald-500/20 text-emerald-600' : 'border-pink-200 text-pink-600'}>
{task.is_completed ? 'Erledigt' : 'Offen'}
</Badge>
</div>
);
}
function StatChip({ label, value }: { label: string; value: string | number }) {
function PendingPhotosCard({ photos, navigateToModeration }: { photos: TenantPhoto[]; navigateToModeration: () => void }) {
const { t } = useTranslation('management');
return (
<div className="rounded-2xl border border-sky-100 bg-white/80 px-4 py-3 text-center shadow-sm">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div className="text-lg font-semibold text-slate-900">{value}</div>
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Camera className="h-5 w-5 text-emerald-500" />
{t('events.photos.pendingTitle', 'Fotos in Moderation')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.photos.pendingSubtitle', 'Schnell prüfen, bevor Gäste live gehen.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-emerald-200 text-emerald-600">
{t('events.photos.pendingCount', { defaultValue: '{{count}} Fotos offen', count: photos.length })}
</Badge>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
{photos.length ? (
<div className="grid grid-cols-3 gap-2">
{photos.slice(0, 6).map((photo) => (
<img key={photo.id} src={photo.thumbnail_url ?? photo.url} alt={photo.caption ?? 'Foto'} className="h-24 w-full rounded-lg object-cover" />
))}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.photos.pendingEmpty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
)}
<Button variant="outline" onClick={navigateToModeration} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Camera className="mr-2 h-4 w-4" /> {t('events.photos.openModeration', 'Moderation öffnen')}
</Button>
</CardContent>
</Card>
);
}
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Camera className="h-5 w-5 text-sky-500" />
{t('events.photos.recentTitle', 'Neueste Uploads')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.photos.recentSubtitle', 'Halte Ausschau nach Highlight-Momenten der Gäste.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-slate-700">
{photos.length ? (
<div className="grid grid-cols-3 gap-2">
{photos.slice(0, 6).map((photo) => (
<img key={photo.id} src={photo.thumbnail_url ?? photo.url} alt={photo.caption ?? 'Foto'} className="h-24 w-full rounded-lg object-cover" />
))}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.photos.recentEmpty', 'Noch keine neuen Uploads.')}</p>
)}
</CardContent>
</Card>
);
}
function FeedbackCard({ slug }: { slug: string }) {
const { t } = useTranslation('management');
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [message, setMessage] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const copy = {
positive: t('events.feedback.positive', 'Super Lauf!'),
neutral: t('events.feedback.neutral', 'Läuft'),
negative: t('events.feedback.negative', 'Braucht Support'),
};
return (
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<MessageSquare className="h-5 w-5 text-slate-500" />
{t('events.feedback.title', 'Wie läuft dein Event?')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.feedback.subtitle', 'Feedback hilft uns, neue Features zu priorisieren.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-wrap gap-2">
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
<Button
key={key}
type="button"
variant={sentiment === key ? 'default' : 'outline'}
className={sentiment === key ? 'bg-slate-900 text-white' : 'border-slate-300 text-slate-700'}
onClick={() => setSentiment(key)}
>
{copy[key]}
</Button>
))}
</div>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder={t('events.feedback.placeholder', 'Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.')}
className="min-h-[120px] w-full rounded-md border border-slate-200 bg-white p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
/>
<Button
type="button"
className="bg-slate-900 text-white hover:bg-slate-800"
disabled={busy || submitted}
onClick={async () => {
if (busy || submitted) return;
setBusy(true);
setError(null);
try {
await submitTenantFeedback({
category: 'event_workspace',
event_slug: slug,
sentiment: sentiment ?? undefined,
message: message.trim() ? message.trim() : undefined,
});
setSubmitted(true);
} catch (err) {
setError(isAuthError(err)
? t('events.feedback.authError', 'Deine Session ist abgelaufen. Bitte melde dich erneut an.')
: t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.'));
} finally {
setBusy(false);
}
}}
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />} {submitted ? t('events.feedback.submitted', 'Danke!') : t('events.feedback.submit', 'Feedback senden')}
</Button>
</CardContent>
</Card>
);
}
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-3 rounded-lg border border-slate-100 bg-white/70 px-3 py-2 text-sm text-slate-700">
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600">{icon}</span>
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
<p className="text-sm font-semibold text-slate-900">{value || '—'}</p>
</div>
</div>
);
}
function formatDate(iso: string | null): string {
if (!iso) return 'Noch kein Datum';
const date = new Date(iso);
function formatDate(value: string | null | undefined): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Unbekanntes Datum';
return '';
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
return date.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;
function resolveEventType(event: TenantEvent): string {
if (event.event_type?.name) {
if (typeof event.event_type.name === 'string') {
return event.event_type.name;
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
return event.event_type.name.de ?? event.event_type.name.en ?? Object.values(event.event_type.name)[0] ?? '—';
}
return 'Unbenanntes Event';
return '';
}
function AlertList({ alerts }: { alerts: string[] }) {
if (!alerts.length) {
return null;
}
return (
<div className="space-y-2">
{alerts.map((alert, index) => (
<Alert key={`workspace-alert-${index}`} variant="default">
<AlertTitle>{alert}</AlertTitle>
</Alert>
))}
</div>
);
}
function CalendarIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
}
function WorkspaceSkeleton(): JSX.Element {
return (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<SkeletonCard key={`metric-skeleton-${index}`} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<SkeletonCard />
</div>
);
}
function SkeletonCard(): JSX.Element {
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
}

View File

@@ -73,6 +73,7 @@ export default function EventFormPage() {
});
const [autoSlug, setAutoSlug] = React.useState(true);
const [originalSlug, setOriginalSlug] = React.useState<string | null>(null);
const slugSuffixRef = React.useRef<string | null>(null);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
@@ -172,6 +173,7 @@ export default function EventFormPage() {
}
: null);
setAutoSlug(false);
slugSuffixRef.current = null;
}, [isEdit, loadedEvent]);
React.useEffect(() => {
@@ -186,31 +188,41 @@ export default function EventFormPage() {
const loading = isEdit ? eventLoading : false;
function ensureSlugSuffix(): string {
if (!slugSuffixRef.current) {
slugSuffixRef.current = Math.random().toString(36).slice(2, 7);
}
return slugSuffixRef.current;
}
function buildAutoSlug(value: string): string {
const base = slugify(value).replace(/^-+|-+$/g, '');
const suffix = ensureSlugSuffix();
const safeBase = base || 'event';
return `${safeBase}-${suffix}`;
}
function handleNameChange(value: string) {
setForm((prev) => ({ ...prev, name: value }));
if (autoSlug) {
setForm((prev) => ({ ...prev, slug: slugify(value) }));
setForm((prev) => ({ ...prev, slug: buildAutoSlug(value) }));
}
}
function handleSlugChange(value: string) {
setAutoSlug(false);
setForm((prev) => ({ ...prev, slug: slugify(value) }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedName = form.name.trim();
const trimmedSlug = form.slug.trim();
if (!trimmedName) {
setError('Bitte gib einen Eventnamen ein.');
return;
}
if (!trimmedSlug) {
setError('Bitte wähle einen Slug für die Event-URL.');
return;
let finalSlug = form.slug.trim();
if (!finalSlug || autoSlug) {
finalSlug = buildAutoSlug(trimmedName);
}
if (!form.eventTypeId) {
@@ -230,7 +242,7 @@ export default function EventFormPage() {
const payload = {
name: trimmedName,
slug: trimmedSlug,
slug: finalSlug,
event_type_id: form.eventTypeId,
event_date: form.date || undefined,
status,
@@ -376,18 +388,8 @@ export default function EventFormPage() {
onChange={(e) => handleNameChange(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-slug">Slug / interne Kennung</Label>
<Input
id="event-slug"
placeholder="sommerfest-2025"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
/>
<p className="text-xs text-slate-500">
Diese Kennung wird intern verwendet. Gäste betreten dein Event ausschließlich über ihre
Einladungslinks und die dazugehörigen QR-Layouts.
Die Kennung und Event-URL werden automatisch aus dem Namen generiert.
</p>
</div>
<div className="space-y-2">

View File

@@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { AdminLayout } from '../components/AdminLayout';
import {
@@ -22,7 +21,7 @@ import {
updateEventQrInvite,
EventQrInviteLayout,
} from '../api';
import { authorizedFetch, isAuthError } from '../auth/tokens';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
@@ -30,6 +29,20 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
import {
buildDefaultElements,
normalizeElements,
payloadToElements,
LayoutElement,
} from './components/invite-layout/schema';
import {
generatePdfBytes,
generatePngDataUrl,
openPdfInNewTab,
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils';
interface PageState {
event: TenantEvent | null;
@@ -40,6 +53,105 @@ interface PageState {
type TabKey = 'layout' | 'export' | 'links';
const HEX_COLOR_FULL = /^#([0-9A-Fa-f]{6})$/;
const HEX_COLOR_SHORT = /^#([0-9A-Fa-f]{3})$/;
function normalizeHexColor(value?: string | null): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (HEX_COLOR_FULL.test(trimmed)) {
return trimmed.toUpperCase();
}
if (HEX_COLOR_SHORT.test(trimmed)) {
const [, shorthand] = HEX_COLOR_SHORT.exec(trimmed)!;
const expanded = shorthand
.split('')
.map((char) => char + char)
.join('');
return `#${expanded}`.toUpperCase();
}
return null;
}
function normalizeGradient(value: unknown): { angle: number; stops: string[] } | null {
if (!value || typeof value !== 'object') {
return null;
}
const gradient = value as { angle?: unknown; stops?: unknown };
const angle = typeof gradient.angle === 'number' ? gradient.angle : 180;
const stops = Array.isArray(gradient.stops)
? gradient.stops
.map((stop) => normalizeHexColor(typeof stop === 'string' ? stop : null))
.filter((stop): stop is string => Boolean(stop))
: [];
return stops.length ? { angle, stops } : null;
}
function buildBackgroundStyle(background: string | null, gradient: { angle: number; stops: string[] } | null): React.CSSProperties {
if (gradient) {
return { backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(', ')})` };
}
return { backgroundColor: background ?? '#F8FAFC' };
}
function toStringList(value: unknown): string[] {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>)
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
return [];
}
function ensureInstructionList(value: unknown, fallback: string[]): string[] {
const source = toStringList(value);
const base = source.length ? source : fallback;
return base
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0)
.slice(0, 5);
}
function formatPaperLabel(paper?: string | null): string {
if (!paper) {
return 'A4';
}
return paper.toUpperCase();
}
function formatQrSizeLabel(sizePx: number | null, fallback: string): string {
if (!sizePx || Number.isNaN(sizePx)) {
return fallback;
}
return `${sizePx}px`;
}
export default function EventInvitesPage(): JSX.Element {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
@@ -52,7 +164,6 @@ export default function EventInvitesPage(): JSX.Element {
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
const [customizerSaving, setCustomizerSaving] = React.useState(false);
const [customizerResetting, setCustomizerResetting] = React.useState(false);
const [designerMode, setDesignerMode] = React.useState<'standard' | 'advanced'>('standard');
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const initialTab = tabParam === 'export' || tabParam === 'links' ? (tabParam as TabKey) : 'layout';
@@ -112,6 +223,7 @@ export default function EventInvitesPage(): JSX.Element {
[state.invites, selectedInviteId]
);
React.useEffect(() => {
setExportError(null);
setExportDownloadBusy(null);
@@ -140,13 +252,133 @@ export default function EventInvitesPage(): JSX.Element {
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
}, [selectedInvite]);
React.useEffect(() => {
if (currentCustomization?.mode === 'advanced') {
setDesignerMode('advanced');
} else if (designerMode !== 'standard' && currentCustomization) {
setDesignerMode('standard');
const exportLayout = React.useMemo(() => {
if (!selectedInvite || selectedInvite.layouts.length === 0) {
return null;
}
}, [currentCustomization?.mode]);
const targetId = currentCustomization?.layout_id;
if (targetId) {
const match = selectedInvite.layouts.find((layout) => layout.id === targetId);
if (match) {
return match;
}
}
return selectedInvite.layouts[0];
}, [selectedInvite, currentCustomization?.layout_id]);
const exportPreview = React.useMemo(() => {
if (!exportLayout || !selectedInvite) {
return null;
}
const customization = currentCustomization ?? null;
const layoutPreview = exportLayout.preview ?? {};
const backgroundColor = normalizeHexColor(customization?.background_color ?? (layoutPreview.background as string | undefined)) ?? '#F8FAFC';
const accentColor = normalizeHexColor(customization?.accent_color ?? (layoutPreview.accent as string | undefined)) ?? '#6366F1';
const textColor = normalizeHexColor(customization?.text_color ?? (layoutPreview.text as string | undefined)) ?? '#111827';
const secondaryColor = normalizeHexColor(customization?.secondary_color ?? (layoutPreview.secondary as string | undefined)) ?? '#1F2937';
const badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor;
const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null);
const instructions = ensureInstructionList(customization?.instructions, exportLayout.instructions ?? []);
const workflowSteps = toStringList(t('invites.export.workflow.steps', { returnObjects: true }));
const tips = toStringList(t('invites.export.tips.items', { returnObjects: true }));
const formatKeys = exportLayout.formats ?? [];
const formatBadges = formatKeys.map((format) => String(format).toUpperCase());
const formatLabel = formatBadges.length ? formatBadges.join(' · ') : t('invites.export.meta.formatsNone', 'Keine Formate hinterlegt');
const qrSizePx = (layoutPreview.qr_size_px as number | undefined) ?? (exportLayout.qr?.size_px ?? null);
return {
backgroundStyle: buildBackgroundStyle(backgroundColor, gradient),
backgroundColor,
backgroundGradient: gradient,
badgeLabel: customization?.badge_label?.trim() || t('tasks.customizer.defaults.badgeLabel'),
badgeColor,
badgeTextColor: '#FFFFFF',
accentColor,
textColor,
secondaryColor,
headline: customization?.headline?.trim() || eventName,
subtitle: customization?.subtitle?.trim() || exportLayout.subtitle || '',
description: customization?.description?.trim() || exportLayout.description || '',
instructionsHeading: customization?.instructions_heading?.trim() || t('tasks.customizer.defaults.instructionsHeading'),
instructions: instructions.slice(0, 4),
linkHeading: customization?.link_heading?.trim() || t('tasks.customizer.defaults.linkHeading'),
linkLabel: (customization?.link_label?.trim() || selectedInvite.url || ''),
ctaLabel: customization?.cta_label?.trim() || t('tasks.customizer.defaults.ctaLabel'),
layoutLabel: exportLayout.name || t('invites.customizer.layoutFallback', 'Layout'),
layoutSubtitle: exportLayout.subtitle || '',
formatLabel,
formatBadges,
formats: formatKeys,
paperLabel: formatPaperLabel(exportLayout.paper),
orientationLabel:
exportLayout.orientation === 'landscape'
? t('invites.export.meta.orientationLandscape', 'Querformat')
: t('invites.export.meta.orientationPortrait', 'Hochformat'),
qrSizeLabel: formatQrSizeLabel(qrSizePx, t('invites.export.meta.qrSizeFallback', 'Automatisch')),
lastUpdated: selectedInvite.created_at ? formatDateTime(selectedInvite.created_at) : null,
mode: customization?.mode === 'advanced' ? 'advanced' : 'standard',
workflowSteps: workflowSteps.length
? workflowSteps
: [
t('invites.export.workflow.default1', 'Testdruck ausführen und Farben prüfen.'),
t('invites.export.workflow.default2', 'Ausdrucke laminieren oder in Schutzfolien stecken.'),
t('invites.export.workflow.default3', 'Mehrere QR-Codes im Eingangsbereich und an Hotspots platzieren.'),
],
tips: tips.length
? tips
: [
t('invites.export.tips.default1', 'Nutze Papier mit mindestens 160 g/m² für langlebige Ausdrucke.'),
t('invites.export.tips.default2', 'Drucke einen QR-Code zur Sicherheit in Reserve aus.'),
t('invites.export.tips.default3', 'Fotografiere den gedruckten QR-Code testweise, um die Lesbarkeit zu prüfen.'),
],
};
}, [exportLayout, currentCustomization, selectedInvite, eventName, t]);
const exportElements = React.useMemo<LayoutElement[]>(() => {
if (!exportLayout) {
return [];
}
if (currentCustomization?.mode === 'advanced' && Array.isArray(currentCustomization.elements) && currentCustomization.elements.length) {
return normalizeElements(payloadToElements(currentCustomization.elements));
}
const baseForm: QrLayoutCustomization = {
...currentCustomization,
layout_id: exportLayout.id,
link_label: currentCustomization?.link_label ?? selectedInvite?.url ?? '',
badge_label: currentCustomization?.badge_label ?? exportLayout.badge_label ?? undefined,
instructions: ensureInstructionList(currentCustomization?.instructions, exportLayout.instructions ?? []),
instructions_heading: currentCustomization?.instructions_heading ?? exportLayout.instructions_heading ?? undefined,
logo_data_url: currentCustomization?.logo_data_url ?? undefined,
logo_url: currentCustomization?.logo_url ?? undefined,
};
return buildDefaultElements(
exportLayout,
baseForm,
eventName,
exportLayout.preview?.qr_size_px ?? exportLayout.qr?.size_px ?? 480
);
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
const exportCanvasKey = React.useMemo(
() => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`,
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
);
const exportLogo = currentCustomization?.logo_data_url ?? currentCustomization?.logo_url ?? exportLayout?.logo_url ?? null;
const exportQr = selectedInvite?.qr_code_data_url ?? null;
const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []);
const handlePreviewChange = React.useCallback((_id: string, _patch: Partial<LayoutElement>) => undefined, []);
const inviteCountSummary = React.useMemo(() => {
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
@@ -271,113 +503,118 @@ export default function EventInvitesPage(): JSX.Element {
}
}
const handleExportDownload = React.useCallback(
async (layout: EventQrInviteLayout, format: string, rawUrl?: string | null) => {
if (!selectedInvite) {
const handleQrDownload = React.useCallback(async () => {
if (!selectedInvite?.qr_code_data_url) {
return;
}
const normalizedFormat = format.toLowerCase();
const sourceUrl = rawUrl ?? layout.download_urls?.[normalizedFormat];
if (!sourceUrl) {
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
return;
}
const busyKey = `${layout.id}-${normalizedFormat}`;
setExportDownloadBusy(busyKey);
setExportError(null);
try {
const response = await authorizedFetch(resolveInternalUrl(sourceUrl), {
headers: {
Accept: normalizedFormat === 'pdf' ? 'application/pdf' : 'image/svg+xml',
},
});
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`);
}
const response = await fetch(selectedInvite.qr_code_data_url);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
const filenameStem = `${selectedInvite.token || 'invite'}-${layout.id}`;
link.href = objectUrl;
link.download = `${filenameStem}.${normalizedFormat}`;
link.download = `${selectedInvite.token || 'invite'}-qr.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (error) {
console.error('[Invites] Export download failed', error);
setExportError(
isAuthError(error)
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
: t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'),
console.error('[Invites] QR download failed', error);
setExportError(t('invites.export.qr.error', 'QR-Code konnte nicht gespeichert werden.'));
}
}, [selectedInvite, t]);
const handleExportDownload = React.useCallback(
async (format: string) => {
if (!selectedInvite || !exportLayout || !exportPreview) {
return;
}
const normalizedFormat = format.toLowerCase();
const busyKey = `${exportLayout.id}-${normalizedFormat}`;
setExportDownloadBusy(busyKey);
setExportError(null);
const exportOptions = {
elements: exportElements,
accentColor: exportPreview.accentColor,
textColor: exportPreview.textColor,
secondaryColor: exportPreview.secondaryColor ?? '#1F2937',
badgeColor: exportPreview.badgeColor,
qrCodeDataUrl: exportQr,
logoDataUrl: exportLogo,
backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF',
backgroundGradient: exportPreview.backgroundGradient ?? null,
readOnly: true,
selectedId: null,
} as const;
const filenameStem = `${selectedInvite.token || 'invite'}-${exportLayout.id}`;
try {
if (normalizedFormat === 'png') {
const dataUrl = await generatePngDataUrl(exportOptions);
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
exportLayout.paper ?? 'a4',
exportLayout.orientation ?? 'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
} else {
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
}
} catch (error) {
console.error('[Invites] Export download failed', error);
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
} finally {
setExportDownloadBusy(null);
}
},
[selectedInvite, t]
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
);
const handleExportPrint = React.useCallback(
async (layout: EventQrInviteLayout) => {
if (!selectedInvite) {
async () => {
if (!selectedInvite || !exportLayout || !exportPreview) {
return;
}
const rawUrl = layout.download_urls?.pdf ?? layout.download_urls?.a4 ?? null;
if (!rawUrl) {
setExportError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
return;
}
setExportPrintBusy(layout.id);
setExportPrintBusy(exportLayout.id);
setExportError(null);
const exportOptions = {
elements: exportElements,
accentColor: exportPreview.accentColor,
textColor: exportPreview.textColor,
secondaryColor: exportPreview.secondaryColor ?? '#1F2937',
badgeColor: exportPreview.badgeColor,
qrCodeDataUrl: exportQr,
logoDataUrl: exportLogo,
backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF',
backgroundGradient: exportPreview.backgroundGradient ?? null,
readOnly: true,
selectedId: null,
} as const;
try {
const response = await authorizedFetch(resolveInternalUrl(rawUrl), {
headers: { Accept: 'application/pdf' },
});
const pdfBytes = await generatePdfBytes(
exportOptions,
exportLayout.paper ?? 'a4',
exportLayout.orientation ?? 'portrait',
);
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
if (!printWindow) {
throw new Error('window-blocked');
}
printWindow.onload = () => {
try {
printWindow.focus();
printWindow.print();
} catch (printError) {
console.error('[Invites] Export print window failed', printError);
}
};
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
await openPdfInNewTab(pdfBytes);
} catch (error) {
console.error('[Invites] Export print failed', error);
setExportError(
isAuthError(error)
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
: t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'),
);
setExportError(t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'));
} finally {
setExportPrintBusy(null);
}
},
[selectedInvite, t]
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
);
const actions = (
@@ -452,29 +689,16 @@ export default function EventInvitesPage(): JSX.Element {
<section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">{t('invites.designer.heading', 'Einladungslayout anpassen')}</h2>
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'Einladungslayout anpassen')}</h2>
<p className="text-sm text-muted-foreground">
{t('invites.designer.subheading', 'Standardlayouts sind direkt startklar. Für individuelle Gestaltung kannst du in den freien Editor wechseln.')}
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
</p>
</div>
<ToggleGroup
type="single"
value={designerMode}
onValueChange={(value) => value && setDesignerMode(value as 'standard' | 'advanced')}
className="self-start rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm"
>
<ToggleGroupItem value="standard" className="rounded-full px-4 py-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
{t('invites.designer.mode.standard', 'Standard-Layoutraster')}
</ToggleGroupItem>
<ToggleGroupItem value="advanced" className="rounded-full px-4 py-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
{t('invites.designer.mode.advanced', 'Freier Editor (Beta)')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{state.loading ? (
<InviteCustomizerSkeleton />
) : designerMode === 'standard' ? (
) : (
<InviteLayoutCustomizerPanel
invite={selectedInvite ?? null}
eventName={eventName}
@@ -484,8 +708,6 @@ export default function EventInvitesPage(): JSX.Element {
onReset={handleResetCustomization}
initialCustomization={currentCustomization}
/>
) : (
<AdvancedDesignerPlaceholder onBack={() => setDesignerMode('standard')} />
)}
</section>
</TabsContent>
@@ -499,7 +721,7 @@ export default function EventInvitesPage(): JSX.Element {
{t('invites.export.title', 'Drucken & Export')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.export.description', 'Lade druckfertige Dateien herunter oder starte direkt einen Testdruck.')}
{t('invites.export.description', 'Überprüfe das Layout, starte Testdrucke und exportiere alle Formate.')}
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
@@ -533,72 +755,225 @@ export default function EventInvitesPage(): JSX.Element {
<CardContent className="space-y-6">
{exportError ? (
<Alert variant="destructive">
<AlertTitle>{t('invites.export.errorTitle', 'Download fehlgeschlagen')}</AlertTitle>
<AlertTitle>{t('invites.export.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{exportError}</AlertDescription>
</Alert>
) : null}
{selectedInvite ? (
selectedInvite.layouts.length ? (
<div className="grid gap-4 md:grid-cols-2">
{selectedInvite.layouts.map((layout) => {
const printBusy = exportPrintBusy === layout.id;
return (
<div
key={layout.id}
className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-5 shadow-sm transition-colors"
>
<div className="flex items-start justify-between gap-3">
exportPreview && exportLayout ? (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<div className="space-y-6">
<div className="rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/80 p-6 shadow-inner transition-colors">
<div className="flex flex-col gap-3 border-b border-[var(--tenant-border-strong)] pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</h3>
{layout.subtitle ? (
<p className="text-xs text-muted-foreground">{layout.subtitle}</p>
<h3 className="text-base font-semibold text-foreground">{exportPreview.layoutLabel}</h3>
{exportPreview.layoutSubtitle ? (
<p className="text-xs text-muted-foreground">{exportPreview.layoutSubtitle}</p>
) : null}
</div>
{layout.formats?.length ? (
<Badge className="bg-amber-500/15 text-amber-700">
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="border-primary/40 bg-primary/10 text-primary">
{exportPreview.mode === 'advanced'
? t('invites.export.mode.advanced', 'Freier Editor')
: t('invites.export.mode.standard', 'Standardlayout')}
</Badge>
) : null}
<span>{exportPreview.paperLabel}</span>
<span></span>
<span>{exportPreview.orientationLabel}</span>
</div>
</div>
<p className="text-xs text-muted-foreground sm:max-w-[220px]">
{t('invites.export.previewHint', 'Speichere nach Änderungen, um neue Exportdateien zu erzeugen.')}
</p>
</div>
<div className="mt-6 flex justify-center">
{exportElements.length ? (
<div className="pointer-events-none">
<DesignerCanvas
elements={exportElements}
selectedId={null}
onSelect={handlePreviewSelect}
onChange={handlePreviewChange}
background={exportPreview.backgroundColor}
gradient={exportPreview.backgroundGradient}
accent={exportPreview.accentColor}
text={exportPreview.textColor}
secondary={exportPreview.secondaryColor}
badge={exportPreview.badgeColor}
qrCodeDataUrl={exportQr}
logoDataUrl={exportLogo}
scale={0.34}
layoutKey={exportCanvasKey}
/>
</div>
) : (
<div className="rounded-3xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)]/80 px-10 py-20 text-center text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noLayoutPreview', 'Für diese Kombination liegt noch keine Vorschau vor. Speichere das Layout zuerst.')}
</div>
)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.meta.title', 'Layout-Details')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.meta.description', 'Wichtige Kennzahlen für den Druck.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.paper', 'Papierformat')}</span>
<span className="font-medium text-foreground">{exportPreview.paperLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.orientation', 'Ausrichtung')}</span>
<span className="font-medium text-foreground">{exportPreview.orientationLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.qrSize', 'QR-Code-Größe')}</span>
<span className="font-medium text-foreground">{exportPreview.qrSizeLabel}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('invites.export.meta.formats', 'Verfügbare Formate')}</span>
<div className="flex flex-wrap gap-1">
{exportPreview.formatBadges.map((item) => (
<Badge key={item} className="bg-primary/10 text-primary">
{item}
</Badge>
))}
</div>
</div>
{exportPreview.lastUpdated ? (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t('invites.export.meta.updated', 'Zuletzt aktualisiert')}</span>
<span>{exportPreview.lastUpdated}</span>
</div>
{layout.description ? (
<p className="text-sm leading-relaxed text-muted-foreground">{layout.description}</p>
) : null}
<div className="flex flex-wrap gap-2">
</CardContent>
</Card>
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.workflow.title', 'Ablauf vor dem Event')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.workflow.description', 'So stellst du sicher, dass Gäste den QR-Code finden.')}</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3 text-sm text-muted-foreground">
{exportPreview.workflowSteps.map((step, index) => (
<li key={`workflow-step-${index}`} className="flex items-start gap-3">
<span className="mt-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{index + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
</CardContent>
</Card>
</div>
</div>
<div className="space-y-6">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.actions.title', 'Aktionen')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.actions.description', 'Starte deinen Testdruck oder lade die Layouts herunter.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleExportPrint(layout)}
disabled={printBusy || Boolean(exportDownloadBusy)}
size="lg"
className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/30 hover:from-amber-400 hover:via-orange-500 hover:to-rose-500"
onClick={() => void handleExportPrint()}
disabled={exportPrintBusy === exportLayout.id || Boolean(exportDownloadBusy)}
>
{printBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Printer className="mr-1 h-4 w-4" />}
{t('invites.export.actions.print', 'Direkt drucken')}
{exportPrintBusy === exportLayout.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Printer className="mr-2 h-4 w-4" />
)}
{t('invites.export.actions.printNow', 'Direkt drucken')}
</Button>
{layout.formats?.map((format) => {
const key = String(format ?? '').toLowerCase();
const url = layout.download_urls?.[key];
if (!url) return null;
const busyKey = `${layout.id}-${key}`;
<div className="grid gap-2 sm:grid-cols-2">
{exportPreview.formats.map((format) => {
const key = format.toLowerCase();
const busyKey = `${exportLayout.id}-${key}`;
const isBusy = exportDownloadBusy === busyKey;
return (
<Button
key={`${layout.id}-${key}`}
type="button"
size="sm"
key={busyKey}
variant="outline"
disabled={(!!exportDownloadBusy && !isBusy) || printBusy}
onClick={() => void handleExportDownload(layout, key, url)}
onClick={() => void handleExportDownload(key)}
disabled={(!!exportDownloadBusy && !isBusy) || exportPrintBusy === exportLayout.id}
className="justify-between"
>
{isBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Download className="mr-1 h-4 w-4" />}
{key.toUpperCase()}
<span>{format.toUpperCase()}</span>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
</Button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t('invites.export.actions.hint', 'PDF enthält Beschnittmarken, PNG ist für schnelle digitale Freigaben geeignet.')}
</p>
</CardContent>
</Card>
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.qr.title', 'QR-Code & Link')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.qr.description', 'Verteile den Link digital oder erstelle weitere Auszüge.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-center">
{selectedInvite.qr_code_data_url ? (
<img
src={selectedInvite.qr_code_data_url}
alt={t('invites.export.qr.alt', 'QR-Code der Einladung')}
className="h-40 w-40 rounded-2xl border border-[var(--tenant-border-strong)] bg-white p-3 shadow-md"
/>
) : (
<div className="flex h-40 w-40 items-center justify-center rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] text-xs text-muted-foreground">
{t('invites.export.qr.placeholder', 'QR-Code wird nach dem Speichern generiert.')}
</div>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" className="w-full" onClick={() => void handleQrDownload()} disabled={!selectedInvite.qr_code_data_url}>
<Download className="mr-2 h-4 w-4" />
{t('invites.export.qr.download', 'QR-Code speichern')}
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => handleCopy(selectedInvite)}
>
<Copy className="mr-2 h-4 w-4" />
{t('invites.export.qr.copyLink', 'Link kopieren')}
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] px-3 py-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{selectedInvite.url}</span>
</div>
</CardContent>
</Card>
<Alert className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)]/80">
<AlertTitle>{t('invites.export.tips.title', 'Tipps für perfekte Ausdrucke')}</AlertTitle>
<AlertDescription>
<ul className="mt-2 space-y-1 text-sm text-muted-foreground">
{exportPreview.tips.map((tip, index) => (
<li key={`export-tip-${index}`} className="flex gap-2">
<span></span>
<span>{tip}</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
</div>
</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)]">
@@ -681,19 +1056,6 @@ export default function EventInvitesPage(): JSX.Element {
);
}
function resolveInternalUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl, window.location.origin);
if (parsed.origin === window.location.origin) {
return parsed.pathname + parsed.search;
}
} catch (error) {
console.warn('[Invites] Unable to resolve download url', error);
}
return rawUrl;
}
function InviteCustomizerSkeleton(): JSX.Element {
return (
<div className="space-y-6">
@@ -709,30 +1071,6 @@ function InviteCustomizerSkeleton(): JSX.Element {
</div>
);
}
function AdvancedDesignerPlaceholder({ onBack }: { onBack: () => void }): JSX.Element {
return (
<div className="space-y-6 rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/80 p-10 text-sm text-muted-foreground transition-colors">
<div className="space-y-2">
<h3 className="text-xl font-semibold text-foreground">Freier Editor bald verfügbar</h3>
<p>
Wir arbeiten gerade an einem drag-&-drop-Designer, mit dem du Elemente wie QR-Code, Texte und Logos frei platzieren
kannst. In der Zwischenzeit kannst du unsere optimierten Standardlayouts mit vergrößertem QR-Code nutzen.
</p>
<p>
Wenn du Vorschläge für zusätzliche Layouts oder Funktionen hast, schreib uns gern über den Support wir sammeln Feedback
für die nächste Ausbaustufe.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={onBack} className="bg-primary text-primary-foreground hover:bg-primary/90">
Zurück zum Standard-Layout
</Button>
</div>
</div>
);
}
function InviteListCard({
invite,
selected,

View File

@@ -1,569 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Camera,
CheckCircle2,
Circle,
Loader2,
MessageSquare,
RefreshCw,
Send,
Sparkles,
ThumbsDown,
ThumbsUp,
QrCode,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { AdminLayout } from '../components/AdminLayout';
import {
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_INVITES_PATH,
} from '../constants';
import {
EventToolkit,
EventToolkitTask,
getEventToolkit,
submitTenantFeedback,
TenantPhoto,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
interface ToolkitState {
loading: boolean;
error: string | null;
data: EventToolkit | null;
}
import EventDetailPage from './EventDetailPage';
export default function EventToolkitPage(): JSX.Element {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const [state, setState] = React.useState<ToolkitState>({ loading: true, error: null, data: null });
const [feedbackSentiment, setFeedbackSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [feedbackMessage, setFeedbackMessage] = React.useState('');
const [feedbackSubmitting, setFeedbackSubmitting] = React.useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
const load = React.useCallback(async () => {
if (!slug) {
setState({ loading: false, error: t('toolkit.errors.missingSlug', 'Kein Event-Slug angegeben.'), data: null });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const toolkit = await getEventToolkit(slug);
setState({ loading: false, error: null, data: toolkit });
} catch (error) {
if (!isAuthError(error)) {
setState({ loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit konnte nicht geladen werden.'), data: null });
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
const { data, loading } = state;
const eventName = data?.event ? resolveEventName(data.event.name, i18n.language) : '';
const actions = (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))}>
<ArrowLeft className="h-4 w-4" />
{t('toolkit.actions.backToEvent', 'Zurück zum Event')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}>
<Camera className="h-4 w-4" />
{t('toolkit.actions.moderate', 'Fotos moderieren')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))}>
<Sparkles className="h-4 w-4" />
{t('toolkit.actions.manageTasks', 'Tasks öffnen')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(slug ?? ''))}>
<QrCode className="h-4 w-4" />
{t('toolkit.actions.manageInvites', 'QR-Einladungen')}
</Button>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('toolkit.actions.refresh', 'Aktualisieren')}
</Button>
</div>
);
return (
<AdminLayout
title={eventName || t('toolkit.titleFallback', 'Event-Day Toolkit')}
subtitle={t('toolkit.subtitle', 'Behalte Uploads, Aufgaben und Einladungen am Eventtag im Blick.')}
actions={actions}
>
{state.error && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t('toolkit.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertDescription>{state.error}</AlertDescription>
</Alert>
)}
{loading ? (
<ToolkitSkeleton />
) : data ? (
<div className="space-y-6">
<AlertList alerts={data.alerts} />
<MetricsGrid metrics={data.metrics} />
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<PendingPhotosCard
photos={data.photos.pending}
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}
/>
<InviteSummary invites={data.invites} navigateToEvent={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))} />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<TaskOverviewCard tasks={data.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))} />
<RecentUploadsCard photos={data.photos.recent} />
</div>
<FeedbackCard
submitting={feedbackSubmitting}
submitted={feedbackSubmitted}
sentiment={feedbackSentiment}
message={feedbackMessage}
onSelectSentiment={setFeedbackSentiment}
onMessageChange={setFeedbackMessage}
onSubmit={async () => {
if (!slug) return;
setFeedbackSubmitting(true);
try {
await submitTenantFeedback({
category: 'event_toolkit',
sentiment: feedbackSentiment ?? undefined,
message: feedbackMessage ? feedbackMessage.trim() : undefined,
event_slug: slug,
});
setFeedbackSentiment(null);
setFeedbackMessage('');
setFeedbackSubmitted(true);
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
error: t('toolkit.errors.feedbackFailed', 'Feedback konnte nicht gesendet werden.'),
}));
}
} finally {
setFeedbackSubmitting(false);
}
}}
/>
</div>
) : null}
</AdminLayout>
);
}
function resolveEventName(name: TenantEvent['name'], locale?: string): string {
if (typeof name === 'string') {
return name;
}
if (name && typeof name === 'object') {
if (locale && name[locale]) {
return name[locale];
}
const short = locale && locale.includes('-') ? locale.split('-')[0] : null;
if (short && name[short]) {
return name[short];
}
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function AlertList({ alerts }: { alerts: string[] }) {
const { t } = useTranslation('management');
if (!alerts.length) {
return null;
}
const alertMap: Record<string, string> = {
no_tasks: t('toolkit.alerts.noTasks', 'Noch keine Tasks zugeordnet.'),
no_invites: t('toolkit.alerts.noInvites', 'Es gibt keine aktiven QR-Einladungen.'),
pending_photos: t('toolkit.alerts.pendingPhotos', 'Es warten Fotos auf Moderation.'),
};
return (
<div className="space-y-2">
{alerts.map((code) => (
<Alert key={code} variant="warning" className="border-amber-200 bg-amber-50 text-amber-900">
<AlertTitle>{t('toolkit.alerts.attention', 'Achtung')}</AlertTitle>
<AlertDescription>{alertMap[code] ?? code}</AlertDescription>
</Alert>
))}
</div>
);
}
function MetricsGrid({
metrics,
}: {
metrics: EventToolkit['metrics'];
}) {
const { t } = useTranslation('management');
const cards = [
{
label: t('toolkit.metrics.uploadsTotal', 'Uploads gesamt'),
value: metrics.uploads_total,
},
{
label: t('toolkit.metrics.uploads24h', 'Uploads (24h)'),
value: metrics.uploads_24h,
},
{
label: t('toolkit.metrics.pendingPhotos', 'Unmoderierte Fotos'),
value: metrics.pending_photos,
},
{
label: t('toolkit.metrics.activeInvites', 'Aktive Einladungen'),
value: metrics.active_invites,
},
{
label: t('toolkit.metrics.engagementMode', 'Modus'),
value:
metrics.engagement_mode === 'photo_only'
? t('toolkit.metrics.modePhotoOnly', 'Foto-Modus')
: t('toolkit.metrics.modeTasks', 'Aufgaben'),
},
];
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{cards.map((card) => (
<Card key={card.label} className="border-0 bg-white/90 shadow-sm shadow-amber-100/50">
<CardContent className="space-y-1 p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
</CardContent>
</Card>
))}
</div>
);
}
function PendingPhotosCard({
photos,
navigateToModeration,
}: {
photos: TenantPhoto[];
navigateToModeration: () => void;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-xl shadow-slate-100/70">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Camera className="h-5 w-5 text-amber-500" />
{t('toolkit.pending.title', 'Wartende Fotos')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.pending.subtitle', 'Moderationsempfehlung für neue Uploads.')}
</CardDescription>
</div>
<Button variant="outline" onClick={navigateToModeration}>
{t('toolkit.pending.cta', 'Zur Moderation')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{photos.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.pending.empty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{photos.map((photo) => (
<div key={photo.id} className="flex gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
<img
src={photo.thumbnail_url}
alt={photo.filename}
className="h-16 w-16 rounded-lg object-cover"
/>
<div className="space-y-1 text-xs text-slate-600">
<p className="font-semibold text-slate-800">{photo.uploader_name ?? t('toolkit.pending.unknownUploader', 'Unbekannter Gast')}</p>
<p>{t('toolkit.pending.uploadedAt', 'Hochgeladen:')} {formatDateTime(photo.uploaded_at)}</p>
<p className="text-[11px] text-amber-700">{t('toolkit.pending.statusPending', 'Status: Prüfung ausstehend')}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function InviteSummary({
invites,
navigateToEvent,
}: {
invites: EventToolkit['invites'];
navigateToEvent: () => void;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Sparkles className="h-5 w-5 text-amber-500" />
{t('toolkit.invites.title', 'QR-Einladungen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.invites.subtitle', 'Aktive Links und Layouts im Blick behalten.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-xs text-slate-600">
<div className="flex gap-2 text-sm text-slate-900">
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('toolkit.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites.summary.active })}
</Badge>
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('toolkit.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites.summary.total })}
</Badge>
</div>
{invites.items.length === 0 ? (
<p>{t('toolkit.invites.empty', 'Noch keine QR-Einladungen erstellt.')}</p>
) : (
<ul className="space-y-2">
{invites.items.map((invite) => (
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
<p className="truncate text-xs text-slate-500">{invite.url}</p>
<p className="text-[11px] text-amber-700">
{invite.is_active
? t('toolkit.invites.statusActive', 'Aktiv')
: t('toolkit.invites.statusInactive', 'Inaktiv')}
</p>
</li>
))}
</ul>
)}
<Button variant="outline" onClick={navigateToEvent}>
{t('toolkit.invites.manage', 'Einladungen verwalten')}
</Button>
</CardContent>
</Card>
);
}
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks']; navigateToTasks: () => void }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" />
{t('toolkit.tasks.title', 'Aktive Aufgaben')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{t('toolkit.tasks.summary', {
defaultValue: '{{completed}} von {{total}} erledigt',
completed: tasks.summary.completed,
total: tasks.summary.total,
})}
</Badge>
</CardHeader>
<CardContent className="space-y-2">
{tasks.items.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
) : (
<div className="space-y-2">
{tasks.items.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
)}
<Button variant="outline" onClick={navigateToTasks}>
{t('toolkit.tasks.manage', 'Tasks verwalten')}
</Button>
</CardContent>
</Card>
);
}
function TaskRow({ task }: { task: EventToolkitTask }) {
const { t } = useTranslation('management');
return (
<div className="flex items-start justify-between gap-3 rounded-xl border border-pink-100 bg-white/80 p-3">
<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> : null}
</div>
<span className={`flex items-center gap-1 text-xs font-medium ${task.is_completed ? 'text-emerald-600' : 'text-slate-500'}`}>
{task.is_completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{task.is_completed ? t('toolkit.tasks.completed', 'Erledigt') : t('toolkit.tasks.open', 'Offen')}
</span>
</div>
);
}
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-sky-100/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Camera className="h-5 w-5 text-sky-500" />
{t('toolkit.recent.title', 'Neueste Uploads')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.recent.subtitle', 'Ein Blick auf die letzten Fotos der Gäste.')}
</CardDescription>
</CardHeader>
<CardContent>
{photos.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.recent.empty', 'Noch keine freigegebenen Fotos vorhanden.')}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{photos.map((photo) => (
<img
key={photo.id}
src={photo.thumbnail_url}
alt={photo.filename}
className="h-24 w-full rounded-lg object-cover"
/>
))}
</div>
)}
</CardContent>
</Card>
);
}
function FeedbackCard({
submitting,
submitted,
sentiment,
message,
onSelectSentiment,
onMessageChange,
onSubmit,
}: {
submitting: boolean;
submitted: boolean;
sentiment: 'positive' | 'neutral' | 'negative' | null;
message: string;
onSelectSentiment: (value: 'positive' | 'neutral' | 'negative') => void;
onMessageChange: (value: string) => void;
onSubmit: () => Promise<void>;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/95 shadow-lg shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<MessageSquare className="h-5 w-5 text-amber-500" />
{t('toolkit.feedback.title', 'Wie hilfreich ist dieses Toolkit?')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.feedback.subtitle', 'Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{submitted ? (
<Alert variant="success">
<AlertTitle>{t('toolkit.feedback.thanksTitle', 'Danke!')}</AlertTitle>
<AlertDescription>{t('toolkit.feedback.thanksDescription', 'Wir haben dein Feedback erhalten.')}</AlertDescription>
</Alert>
) : (
<>
<div className="flex flex-wrap gap-3">
<Button
type="button"
variant={sentiment === 'positive' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('positive')}
disabled={submitting}
>
<ThumbsUp className="mr-2 h-4 w-4" /> {t('toolkit.feedback.positive', 'Hilfreich')}
</Button>
<Button
type="button"
variant={sentiment === 'neutral' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('neutral')}
disabled={submitting}
>
<Sparkles className="mr-2 h-4 w-4" /> {t('toolkit.feedback.neutral', 'Ganz okay')}
</Button>
<Button
type="button"
variant={sentiment === 'negative' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('negative')}
disabled={submitting}
>
<ThumbsDown className="mr-2 h-4 w-4" /> {t('toolkit.feedback.negative', 'Verbesserungsbedarf')}
</Button>
</div>
<div className="space-y-2">
<Textarea
rows={3}
placeholder={t('toolkit.feedback.placeholder', 'Erzähle uns kurz, was dir gefallen hat oder was fehlt …')}
value={message}
onChange={(event) => onMessageChange(event.target.value)}
disabled={submitting}
/>
<p className="text-[11px] text-slate-500">
{t('toolkit.feedback.disclaimer', 'Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.')}
</p>
</div>
<div className="flex justify-end">
<Button onClick={() => void onSubmit()} disabled={submitting || (!sentiment && message.trim() === '')}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
{t('toolkit.feedback.submit', 'Feedback senden')}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}
function ToolkitSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function formatDateTime(value: string | null): string {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString();
return <EventDetailPage mode="toolkit" />;
}

View File

@@ -114,7 +114,6 @@ function EventCard({ event }: { event: TenantEvent }) {
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
<p className="text-sm text-slate-600">Slug: {event.slug}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-3 py-1 font-medium text-pink-700">
<CalendarDays className="h-3.5 w-3.5" />

View File

@@ -1,16 +1,23 @@
import React from 'react';
import { Location, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles, ShieldCheck, Images, ArrowRight, Loader2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import AppLogoIcon from '@/components/app-logo-icon';
import { useAuth } from '../auth/context';
import { ADMIN_HOME_PATH } from '../constants';
import { useTranslation } from 'react-i18next';
interface LocationState {
from?: Location;
}
export default function LoginPage() {
const featureIcons = [Sparkles, ShieldCheck, Images];
export default function LoginPage(): JSX.Element {
const { status, login } = useAuth();
const { t } = useTranslation('auth');
const location = useLocation();
@@ -35,26 +42,120 @@ export default function LoginPage() {
return ADMIN_HOME_PATH;
}, [location.state]);
const featureList = React.useMemo(() => {
const raw = t('login.features', { returnObjects: true }) as unknown;
if (!Array.isArray(raw)) {
return [] as Array<{ text: string; Icon: typeof Sparkles }>;
}
return (raw as string[]).map((entry, index) => ({
text: entry,
Icon: featureIcons[index % featureIcons.length],
}));
}, [t]);
const isLoading = status === 'loading';
return (
<div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-lg font-semibold">{t('login.title')}</h1>
<div className="relative min-h-screen overflow-hidden bg-[var(--brand-navy)] text-white">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,var(--brand-rose-soft)_0%,rgba(3,7,18,0.65)_55%,rgba(15,76,117,0.9)_100%)] opacity-95" />
<div className="pointer-events-none absolute inset-y-0 right-[-25%] w-[55%] bg-[radial-gradient(circle_at_center,var(--brand-sky)_0%,rgba(255,255,255,0)_70%)] opacity-40" />
<div className="pointer-events-none absolute inset-y-0 left-[-20%] w-[45%] bg-[radial-gradient(circle_at_center,var(--brand-rose)_0%,rgba(255,255,255,0)_65%)] opacity-35" />
<div className="relative z-10 flex min-h-screen flex-col">
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 pt-10">
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/10 backdrop-blur">
<AppLogoIcon className="h-7 w-7 text-white" />
</span>
<div>
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge')}</p>
<p className="text-lg font-semibold">Fotospiel</p>
</div>
</div>
<AppearanceToggleDropdown />
</header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-6 pb-16 pt-12">
<div className="grid flex-1 gap-12 lg:grid-cols-[0.95fr_1.05fr]" data-testid="tenant-login-layout">
<section className="order-2 space-y-10 lg:order-1">
<div className="space-y-5">
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1 text-sm font-medium text-white/80 backdrop-blur">
<Sparkles className="h-4 w-4 text-[var(--brand-gold)]" />
{t('login.badge')}
</span>
<h1 className="text-4xl font-semibold leading-tight sm:text-5xl">
{t('login.hero_title')}
</h1>
<p className="max-w-xl text-base text-white/80 sm:text-lg">
{t('login.hero_subtitle')}
</p>
</div>
<div className="space-y-4 text-sm text-muted-foreground">
<p>{t('login.lead')}</p>
{oauthError && (
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
{t('login.oauth_error', { message: oauthError })}
{featureList.length ? (
<div className="grid gap-4 sm:grid-cols-2">
{featureList.map(({ text, Icon }, index) => (
<div
key={`login-feature-${index}`}
className="group relative overflow-hidden rounded-2xl border border-white/15 bg-white/10 p-5 shadow-lg shadow-black/5 backdrop-blur transition hover:border-white/35"
>
<Icon className="mb-3 h-5 w-5 text-[var(--brand-gold)] transition group-hover:text-white" />
<p className="text-sm text-white/90">{text}</p>
</div>
)}
))}
</div>
) : null}
<p className="flex items-center gap-2 text-sm text-white/70">
<ArrowRight className="h-4 w-4" />
{t('login.lead')}
</p>
</section>
<section className="order-1 lg:order-2">
<div className="relative">
<div className="absolute inset-0 -translate-y-4 translate-x-6 scale-95 rounded-3xl bg-white/20 opacity-50 blur-2xl" />
<div className="relative overflow-hidden rounded-3xl border border-white/20 bg-white/90 p-10 text-slate-900 shadow-2xl shadow-black/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/90 dark:text-slate-50">
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">{t('login.title')}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{t('login.panel_copy')}</p>
</div>
{oauthError ? (
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
</Alert>
) : null}
<Button
className="w-full"
disabled={status === 'loading'}
size="lg"
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--brand-rose)] via-[var(--brand-gold)] to-[var(--brand-sky)] px-8 py-3 text-base font-semibold text-slate-900 shadow-lg shadow-rose-400/30 transition hover:opacity-90 focus-visible:ring-4 focus-visible:ring-brand-rose/40 dark:text-slate-900"
disabled={isLoading}
onClick={() => login(redirectTarget)}
>
{status === 'loading' ? t('login.loading') : t('login.cta')}
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
{t('login.loading')}
</>
) : (
<>
{t('login.cta')}
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
</>
)}
</Button>
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-300">
{t('login.support')}
</p>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</div>
);

View File

@@ -0,0 +1,719 @@
import React from 'react';
import * as fabric from 'fabric';
import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
LayoutElement,
clamp,
LayoutElementType,
} from './schema';
type DesignerCanvasProps = {
elements: LayoutElement[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onChange: (id: string, patch: Partial<LayoutElement>) => void;
background: string;
gradient: { angle?: number; stops?: string[] } | null;
accent: string;
text: string;
secondary: string;
badge: string;
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
scale: number;
layoutKey?: string;
readOnly?: boolean;
};
type FabricObjectWithId = fabric.Object & { elementId?: string };
const DEFAULT_MIN_SCALE = 0.15;
const DEFAULT_MAX_SCALE = 0.85;
export function DesignerCanvas({
elements,
selectedId,
onSelect,
onChange,
background,
gradient,
accent,
text,
secondary,
badge,
qrCodeDataUrl,
logoDataUrl,
scale,
layoutKey,
readOnly = false,
}: DesignerCanvasProps): React.JSX.Element {
const canvasElementRef = React.useRef<HTMLCanvasElement | null>(null);
const fabricCanvasRef = React.useRef<fabric.Canvas | null>(null);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const disposeTokenRef = React.useRef(0);
const pendingDisposeRef = React.useRef<number | null>(null);
const pendingTimeoutRef = React.useRef<number | null>(null);
const destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => {
if (!canvas) {
return;
}
if (fabricCanvasRef.current === canvas) {
fabricCanvasRef.current = null;
}
const upperEl = canvas.upperCanvasEl as (HTMLElement & Record<string, unknown>) | undefined;
if (upperEl) {
if (upperEl.__canvas === canvas) {
delete upperEl.__canvas;
}
if (upperEl.__fabricCanvas === canvas) {
delete upperEl.__fabricCanvas;
}
}
const lowerEl = canvas.lowerCanvasEl as (HTMLElement & Record<string, unknown>) | undefined;
if (lowerEl) {
if (lowerEl.__canvas === canvas) {
delete lowerEl.__canvas;
}
if (lowerEl.__fabricCanvas === canvas) {
delete lowerEl.__fabricCanvas;
}
}
const targetEl = canvas.getElement() as (HTMLCanvasElement & Record<string, unknown>) | undefined;
if (targetEl) {
if (targetEl.__canvas === canvas) {
delete targetEl.__canvas;
}
if (targetEl.__fabricCanvas === canvas) {
delete targetEl.__fabricCanvas;
}
}
const wrapper = (canvas as unknown as { wrapperEl?: (HTMLElement & Record<string, unknown>) }).wrapperEl;
if (wrapper) {
if (wrapper.__fabricCanvas === canvas) {
delete wrapper.__fabricCanvas;
}
if (Object.getOwnPropertyDescriptor(wrapper, '__canvas')) {
try {
delete wrapper.__canvas;
} catch (error) {
console.warn('[Invites][Fabric] failed to delete wrapper __canvas', error);
}
}
delete wrapper.dataset?.fabric;
}
if ((window as unknown as Record<string, unknown>).__inviteCanvas === canvas) {
delete (window as unknown as Record<string, unknown>).__inviteCanvas;
}
try {
canvas.dispose();
} catch (error) {
console.warn('[Invites][Fabric] dispose failed', error);
}
}, []);
React.useLayoutEffect(() => {
const element = canvasElementRef.current;
if (!element) {
console.warn('[Invites][Fabric] canvas element missing');
return undefined;
}
if (pendingTimeoutRef.current !== null) {
window.clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
pendingDisposeRef.current = null;
}
destroyCanvas(fabricCanvasRef.current);
console.warn('[Invites][Fabric] initializing canvas element');
const canvas = new fabric.Canvas(element, {
selection: !readOnly,
preserveObjectStacking: true,
perPixelTargetFind: true,
});
fabricCanvasRef.current = canvas;
const disposeToken = ++disposeTokenRef.current;
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
(element as unknown as { __fabricCanvas?: fabric.Canvas }).__fabricCanvas = canvas;
if (containerRef.current) {
const wrapper = containerRef.current as (HTMLElement & Record<string, unknown>);
wrapper.__fabricCanvas = canvas;
Object.defineProperty(wrapper, '__canvas', {
configurable: true,
enumerable: false,
writable: true,
value: canvas,
});
wrapper.dataset.fabric = 'ready';
}
return () => {
const timeoutId = window.setTimeout(() => {
if (disposeTokenRef.current !== disposeToken) {
return;
}
destroyCanvas(canvas);
pendingTimeoutRef.current = null;
pendingDisposeRef.current = null;
}, 0);
pendingTimeoutRef.current = timeoutId;
pendingDisposeRef.current = disposeToken;
};
}, [destroyCanvas, readOnly]);
React.useEffect(() => {
const canvas = fabricCanvasRef.current;
if (!canvas) {
return;
}
canvas.selection = !readOnly;
canvas.forEachObject((object) => {
object.set({
selectable: !readOnly,
hoverCursor: readOnly ? 'default' : 'move',
});
});
}, [readOnly]);
React.useEffect(() => {
const canvas = fabricCanvasRef.current;
if (!canvas) {
return;
}
const handleSelection = () => {
if (readOnly) {
return;
}
const active = canvas.getActiveObject() as FabricObjectWithId | null;
if (!active || typeof active.elementId !== 'string') {
onSelect(null);
return;
}
onSelect(active.elementId);
};
const handleSelectionCleared = () => {
if (readOnly) {
return;
}
onSelect(null);
};
const handleObjectModified = (event: fabric.IEvent<fabric.Object>) => {
if (readOnly) {
return;
}
const target = event.target as FabricObjectWithId | undefined;
if (!target || typeof target.elementId !== 'string') {
return;
}
const elementId = target.elementId;
const bounds = target.getBoundingRect(true, true);
const nextPatch: Partial<LayoutElement> = {
x: clamp(Math.round(bounds.left ?? 0), 0, CANVAS_WIDTH),
y: clamp(Math.round(bounds.top ?? 0), 0, CANVAS_HEIGHT),
width: clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH),
height: clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT),
};
target.set({
scaleX: 1,
scaleY: 1,
left: nextPatch.x,
top: nextPatch.y,
width: nextPatch.width,
height: nextPatch.height,
});
onChange(elementId, nextPatch);
canvas.requestRenderAll();
};
canvas.on('selection:created', handleSelection);
canvas.on('selection:updated', handleSelection);
canvas.on('selection:cleared', handleSelectionCleared);
canvas.on('object:modified', handleObjectModified);
return () => {
canvas.off('selection:created', handleSelection);
canvas.off('selection:updated', handleSelection);
canvas.off('selection:cleared', handleSelectionCleared);
canvas.off('object:modified', handleObjectModified);
};
}, [onChange, onSelect, readOnly]);
React.useEffect(() => {
const canvas = fabricCanvasRef.current;
if (!canvas) {
return;
}
renderFabricLayout(canvas, {
elements,
accentColor: accent,
textColor: text,
secondaryColor: secondary,
badgeColor: badge,
qrCodeDataUrl,
logoDataUrl,
backgroundColor: background,
backgroundGradient: gradient,
readOnly,
selectedId,
}).catch((error) => {
console.error('[Fabric] Failed to render layout', error);
});
}, [
elements,
accent,
text,
secondary,
badge,
qrCodeDataUrl,
logoDataUrl,
background,
gradient,
selectedId,
readOnly,
]);
React.useEffect(() => {
const canvas = fabricCanvasRef.current;
if (!canvas) {
return;
}
canvas.setZoom(scale);
canvas.setDimensions(
{
width: CANVAS_WIDTH * scale,
height: CANVAS_HEIGHT * scale,
},
{ cssOnly: true },
);
canvas.requestRenderAll();
}, [scale]);
return (
<div ref={containerRef} className="relative inline-block max-w-full">
<canvas
ref={canvasElementRef}
width={CANVAS_WIDTH}
height={CANVAS_HEIGHT}
style={{ display: 'block' }}
/>
</div>
);
}
export type FabricRenderOptions = {
elements: LayoutElement[];
accentColor: string;
textColor: string;
secondaryColor: string;
badgeColor: string;
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
backgroundColor: string;
backgroundGradient: { angle?: number; stops?: string[] } | null;
readOnly: boolean;
selectedId?: string | null;
};
export async function renderFabricLayout(
canvas: fabric.Canvas,
options: FabricRenderOptions,
): Promise<void> {
const {
elements,
accentColor,
textColor,
secondaryColor,
badgeColor,
qrCodeDataUrl,
logoDataUrl,
backgroundColor,
backgroundGradient,
readOnly,
selectedId,
} = options;
canvas.discardActiveObject();
canvas.getObjects().forEach((object) => canvas.remove(object));
applyBackground(canvas, backgroundColor, backgroundGradient);
console.debug('[Invites][Fabric] render', {
elementCount: elements.length,
backgroundColor,
hasGradient: Boolean(backgroundGradient),
readOnly,
});
const objectPromises = elements.map((element) =>
createFabricObject({
element,
accentColor,
textColor,
secondaryColor,
badgeColor,
qrCodeDataUrl,
logoDataUrl,
readOnly,
}),
);
const fabricObjects = await Promise.all(objectPromises);
fabricObjects.forEach((object) => {
if (!object) {
console.debug('[Invites][Fabric] Skip null fabric object');
return;
}
if (readOnly) {
object.set({
selectable: false,
hoverCursor: 'default',
});
}
try {
canvas.add(object);
if (typeof object.setCoords === 'function') {
object.setCoords();
}
const bounds = object.getBoundingRect(true, true);
console.warn('[Invites][Fabric] added object', {
elementId: (object as FabricObjectWithId).elementId,
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
});
} catch (error) {
console.error('[Invites][Fabric] failed to add object', error);
}
});
console.warn('[Invites][Fabric] object count', canvas.getObjects().length);
if (!readOnly && selectedId) {
const match = canvas
.getObjects()
.find((object) => (object as FabricObjectWithId).elementId === selectedId);
if (match) {
canvas.setActiveObject(match);
}
}
canvas.renderAll();
}
export function applyBackground(
canvas: fabric.Canvas,
color: string,
gradient: { angle?: number; stops?: string[] } | null,
): void {
let background: string | fabric.Gradient = color;
if (gradient?.stops?.length) {
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
const halfWidth = CANVAS_WIDTH / 2;
const halfHeight = CANVAS_HEIGHT / 2;
const x = Math.cos(angle);
const y = Math.sin(angle);
background = new fabric.Gradient({
type: 'linear',
coords: {
x1: halfWidth - x * halfWidth,
y1: halfHeight - y * halfHeight,
x2: halfWidth + x * halfWidth,
y2: halfHeight + y * halfHeight,
},
colorStops: gradient.stops.map((stop, index) => ({
offset: gradient.stops.length === 1 ? 0 : index / (gradient.stops.length - 1),
color: stop,
})),
});
}
const canvasWithBackgroundFn = canvas as fabric.Canvas & {
setBackgroundColor?: (value: string | fabric.Gradient, callback?: () => void) => void;
};
if (typeof canvasWithBackgroundFn.setBackgroundColor === 'function') {
canvasWithBackgroundFn.setBackgroundColor(background, () => canvas.requestRenderAll());
} else {
canvas.backgroundColor = background;
canvas.requestRenderAll();
}
}
export type FabricObjectFactoryContext = {
element: LayoutElement;
accentColor: string;
textColor: string;
secondaryColor: string;
badgeColor: string;
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
readOnly: boolean;
};
export async function createFabricObject({
element,
accentColor,
textColor,
secondaryColor,
badgeColor,
qrCodeDataUrl,
logoDataUrl,
readOnly,
}: FabricObjectFactoryContext): Promise<fabric.Object | null> {
console.debug('[Invites][Fabric] create element', {
id: element.id,
type: element.type,
width: element.width,
height: element.height,
content: element.content,
});
const baseConfig = {
left: element.x,
top: element.y,
elementId: element.id,
selectable: !readOnly,
hasBorders: !readOnly,
hasControls: !readOnly,
} as FabricObjectWithId;
switch (element.type) {
case 'headline':
case 'subtitle':
case 'description':
case 'text':
return new fabric.Textbox(element.content ?? '', {
...baseConfig,
width: element.width,
height: element.height,
fontSize: element.fontSize ?? 26,
fill: textColor,
textAlign: mapTextAlign(element.align),
});
case 'link':
return new fabric.Textbox(element.content ?? '', {
...baseConfig,
width: element.width,
height: element.height,
fontSize: element.fontSize ?? 24,
fill: accentColor,
underline: true,
textAlign: mapTextAlign(element.align),
});
case 'badge':
return createTextBadge({
baseConfig,
text: element.content ?? '',
width: element.width,
height: element.height,
backgroundColor: badgeColor,
textColor: '#ffffff',
fontSize: element.fontSize ?? 22,
});
case 'cta':
return createTextBadge({
baseConfig,
text: element.content ?? '',
width: element.width,
height: element.height,
backgroundColor: accentColor,
textColor: '#ffffff',
fontSize: element.fontSize ?? 24,
cornerRadius: 18,
});
case 'logo':
if (logoDataUrl) {
return loadImageObject(logoDataUrl, element, baseConfig, {
objectFit: 'contain',
});
}
return null;
case 'qr':
if (qrCodeDataUrl) {
return loadImageObject(qrCodeDataUrl, element, baseConfig, {
shadow: 'rgba(15,23,42,0.25)',
});
}
return new fabric.Rect({
...baseConfig,
width: element.width,
height: element.height,
fill: secondaryColor,
rx: 20,
ry: 20,
});
default:
return new fabric.Textbox(element.content ?? '', {
...baseConfig,
width: element.width,
height: element.height,
fontSize: element.fontSize ?? 24,
fill: secondaryColor,
textAlign: mapTextAlign(element.align),
});
}
}
export function createTextBadge({
baseConfig,
text,
width,
height,
backgroundColor,
textColor,
fontSize,
cornerRadius = 12,
}: {
baseConfig: FabricObjectWithId;
text: string;
width: number;
height: number;
backgroundColor: string;
textColor: string;
fontSize: number;
cornerRadius?: number;
}): fabric.Group {
const rect = new fabric.Rect({
width,
height,
rx: cornerRadius,
ry: cornerRadius,
fill: backgroundColor,
left: 0,
top: 0,
selectable: false,
evented: false,
});
const label = new fabric.Textbox(text, {
width: width - 32,
left: 16,
top: height / 2,
fontSize,
fill: textColor,
originY: 'center',
textAlign: 'center',
selectable: false,
evented: false,
});
return new fabric.Group([rect, label], {
...baseConfig,
width,
height,
originX: 'left',
originY: 'top',
}) as fabric.Group & FabricObjectWithId;
}
export async function loadImageObject(
source: string,
element: LayoutElement,
baseConfig: FabricObjectWithId,
options?: { objectFit?: 'contain' | 'cover'; shadow?: string },
): Promise<fabric.Object | null> {
return new Promise((resolve) => {
fabric.Image.fromURL(
source,
(image) => {
if (!image) {
resolve(null);
return;
}
const scaleX = element.width / (image.width ?? element.width);
const scaleY = element.height / (image.height ?? element.height);
image.set({
...baseConfig,
width: element.width,
height: element.height,
scaleX,
scaleY,
});
if (options?.shadow) {
image.set('shadow', options.shadow);
}
if (options?.objectFit === 'contain') {
const ratio = Math.min(scaleX, scaleY);
image.set({
scaleX: ratio,
scaleY: ratio,
left: element.x + (element.width - (image.width ?? 0) * ratio) / 2,
top: element.y + (element.height - (image.height ?? 0) * ratio) / 2,
});
}
resolve(image);
},
{ crossOrigin: 'anonymous' },
);
});
}
export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center' | 'right' {
switch (align) {
case 'center':
return 'center';
case 'right':
return 'right';
default:
return 'left';
}
}
export function CanvasScaleControl({
scale,
min = DEFAULT_MIN_SCALE,
max = DEFAULT_MAX_SCALE,
onChange,
}: {
scale: number;
min?: number;
max?: number;
onChange: (value: number) => void;
}): React.JSX.Element {
return (
<div className="flex items-center gap-3 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] px-4 py-2 text-xs">
<span className="font-medium text-muted-foreground">Zoom</span>
<input
type="range"
min={min}
max={max}
step={0.025}
value={scale}
onChange={(event) => onChange(Number(event.target.value))}
className="h-1 w-32 overflow-hidden rounded-full"
/>
<span className="tabular-nums text-muted-foreground">{Math.round(scale * 100)}%</span>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import * as fabric from 'fabric';
import { PDFDocument } from 'pdf-lib';
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './schema';
import { FabricRenderOptions, renderFabricLayout } from './DesignerCanvas';
const PDF_PAGE_SIZES: Record<string, { width: number; height: number }> = {
a4: { width: 595.28, height: 841.89 },
letter: { width: 612, height: 792 },
};
export async function withFabricCanvas<T>(
options: FabricRenderOptions,
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
): Promise<T> {
const canvasElement = document.createElement('canvas');
canvasElement.width = CANVAS_WIDTH;
canvasElement.height = CANVAS_HEIGHT;
const canvas = new fabric.Canvas(canvasElement, {
selection: false,
});
try {
await renderFabricLayout(canvas, {
...options,
readOnly: true,
selectedId: null,
});
return await handler(canvas, canvasElement);
} finally {
canvas.dispose();
canvasElement.remove();
}
}
export async function generatePngDataUrl(
options: FabricRenderOptions,
multiplier = 2,
): Promise<string> {
return withFabricCanvas(options, async (canvas) =>
canvas.toDataURL({ format: 'png', multiplier }),
);
}
export async function generatePdfBytes(
options: FabricRenderOptions,
paper: string,
orientation: string,
multiplier = 2,
): Promise<Uint8Array> {
const dataUrl = await generatePngDataUrl(options, multiplier);
return createPdfFromPng(dataUrl, paper, orientation);
}
export async function createPdfFromPng(
dataUrl: string,
paper: string,
orientation: string,
): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.create();
const baseSize = PDF_PAGE_SIZES[paper.toLowerCase()] ?? PDF_PAGE_SIZES.a4;
const landscape = orientation === 'landscape';
const pageWidth = landscape ? baseSize.height : baseSize.width;
const pageHeight = landscape ? baseSize.width : baseSize.height;
const page = pdfDoc.addPage([pageWidth, pageHeight]);
const pngBytes = dataUrlToUint8Array(dataUrl);
const pngImage = await pdfDoc.embedPng(pngBytes);
const imageWidth = pngImage.width;
const imageHeight = pngImage.height;
const scale = Math.min(pageWidth / imageWidth, pageHeight / imageHeight);
const drawWidth = imageWidth * scale;
const drawHeight = imageHeight * scale;
page.drawImage(pngImage, {
x: (pageWidth - drawWidth) / 2,
y: (pageHeight - drawHeight) / 2,
width: drawWidth,
height: drawHeight,
});
return pdfDoc.save();
}
export function triggerDownloadFromDataUrl(dataUrl: string, filename: string): Promise<void> {
return fetch(dataUrl)
.then((response) => response.blob())
.then((blob) => triggerDownloadFromBlob(blob, filename));
}
export function triggerDownloadFromBlob(blob: Blob, filename: string): void {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
export async function openPdfInNewTab(pdfBytes: Uint8Array): Promise<void> {
const blobUrl = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
if (!printWindow) {
URL.revokeObjectURL(blobUrl);
throw new Error('window-blocked');
}
printWindow.onload = () => {
try {
printWindow.focus();
printWindow.print();
} catch (error) {
console.error('[FabricExport] Browser print failed', error);
}
};
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function dataUrlToUint8Array(dataUrl: string): Uint8Array {
const [, base64] = dataUrl.split(',');
const decoded = atob(base64 ?? '');
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) {
bytes[index] = decoded.charCodeAt(index);
}
return bytes;
}

View File

@@ -0,0 +1,530 @@
import type { EventQrInviteLayout } from '../../api';
export const CANVAS_WIDTH = 1240;
export const CANVAS_HEIGHT = 1754;
export type LayoutElementType =
| 'qr'
| 'headline'
| 'subtitle'
| 'description'
| 'link'
| 'badge'
| 'logo'
| 'cta'
| 'text';
export type LayoutTextAlign = 'left' | 'center' | 'right';
export interface LayoutElement {
id: string;
type: LayoutElementType;
x: number;
y: number;
width: number;
height: number;
rotation?: number;
fontSize?: number;
align?: LayoutTextAlign;
content?: string | null;
fontFamily?: string | null;
letterSpacing?: number;
lineHeight?: number;
fill?: string | null;
locked?: boolean;
initial?: boolean;
}
type PresetValue = number | ((context: LayoutPresetContext) => number);
type LayoutPresetElement = {
id: string;
type: LayoutElementType;
x: PresetValue;
y: PresetValue;
width?: PresetValue;
height?: PresetValue;
fontSize?: number;
align?: LayoutTextAlign;
locked?: boolean;
initial?: boolean;
};
type LayoutPreset = LayoutPresetElement[];
interface LayoutPresetContext {
qrSize: number;
canvasWidth: number;
canvasHeight: number;
}
export interface LayoutElementPayload {
id: string;
type: LayoutElementType;
x: number;
y: number;
width: number;
height: number;
rotation?: number;
font_size?: number;
align?: LayoutTextAlign;
content?: string | null;
font_family?: string | null;
letter_spacing?: number;
line_height?: number;
fill?: string | null;
locked?: boolean;
initial?: boolean;
}
export interface LayoutSerializationContext {
form: QrLayoutCustomization;
eventName: string;
inviteUrl: string;
instructions: string[];
qrSize: number;
badgeFallback: string;
logoUrl: string | null;
}
export type QrLayoutCustomization = {
layout_id?: string;
headline?: string;
subtitle?: string;
description?: string;
badge_label?: string;
instructions_heading?: string;
instructions?: string[];
link_heading?: string;
link_label?: string;
cta_label?: string;
accent_color?: string;
text_color?: string;
background_color?: string;
secondary_color?: string;
badge_color?: string;
background_gradient?: { angle?: number; stops?: string[] } | null;
logo_data_url?: string | null;
logo_url?: string | null;
mode?: 'standard' | 'advanced';
elements?: LayoutElementPayload[];
};
export const MIN_QR_SIZE = 240;
export const MAX_QR_SIZE = 720;
export const MIN_TEXT_WIDTH = 160;
export const MIN_TEXT_HEIGHT = 80;
export function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
}
export function clampElement(element: LayoutElement): LayoutElement {
return {
...element,
x: clamp(element.x, 0, CANVAS_WIDTH - element.width),
y: clamp(element.y, 0, CANVAS_HEIGHT - element.height),
width: clamp(element.width, 40, CANVAS_WIDTH),
height: clamp(element.height, 40, CANVAS_HEIGHT),
};
}
const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
headline: { width: 620, height: 200, fontSize: 68, align: 'left' },
subtitle: { width: 580, height: 140, fontSize: 34, align: 'left' },
description: { width: 620, height: 280, fontSize: 28, align: 'left' },
link: { width: 400, height: 110, fontSize: 28, align: 'center' },
badge: { width: 280, height: 80, fontSize: 24, align: 'center' },
logo: { width: 240, height: 180, align: 'center' },
cta: { width: 400, height: 110, fontSize: 26, align: 'center' },
qr: { width: 520, height: 520 },
text: { width: 560, height: 200, fontSize: 26, align: 'left' },
};
const DEFAULT_PRESET: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 120, y: 140, width: 320, height: 80, align: 'center', fontSize: 24 },
{ id: 'headline', type: 'headline', x: 120, y: 260, width: 620, height: 200, fontSize: 68, align: 'left' },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 440, width: 600, height: 140, fontSize: 34, align: 'left' },
{ id: 'description', type: 'description', x: 120, y: 600, width: 620, height: 280, fontSize: 28, align: 'left' },
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 140,
y: 360,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 420,
y: (context) => 400 + context.qrSize,
width: 400,
height: 110,
fontSize: 28,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 420,
y: (context) => 420 + context.qrSize + 140,
width: 400,
height: 110,
fontSize: 26,
align: 'center',
},
];
const evergreenVowsPreset: LayoutPreset = [
{ id: 'logo', type: 'logo', x: 120, y: 140, width: 240, height: 180 },
{ id: 'badge', type: 'badge', x: 400, y: 160, width: 320, height: 80, align: 'center', fontSize: 24 },
{ id: 'headline', type: 'headline', x: 120, y: 360, width: 620, height: 220, fontSize: 70, align: 'left' },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 560, width: 600, height: 140, fontSize: 34, align: 'left' },
{ id: 'description', type: 'description', x: 120, y: 720, width: 620, height: 280, fontSize: 28, align: 'left' },
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 160,
y: 460,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 420,
y: (context) => 500 + context.qrSize,
width: 400,
height: 110,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 420,
y: (context) => 520 + context.qrSize + 150,
width: 400,
height: 110,
align: 'center',
},
];
const midnightGalaPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 360, y: 160, width: 520, height: 90, align: 'center', fontSize: 26 },
{ id: 'headline', type: 'headline', x: 220, y: 300, width: 800, height: 220, fontSize: 76, align: 'center' },
{ id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 36, align: 'center' },
{
id: 'qr',
type: 'qr',
x: (context) => (context.canvasWidth - context.qrSize) / 2,
y: 700,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
},
{
id: 'link',
type: 'link',
x: (context) => (context.canvasWidth - 420) / 2,
y: (context) => 740 + context.qrSize,
width: 420,
height: 120,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => (context.canvasWidth - 420) / 2,
y: (context) => 770 + context.qrSize + 150,
width: 420,
height: 120,
align: 'center',
},
{ id: 'description', type: 'description', x: 200, y: 1040, width: 840, height: 260, fontSize: 28, align: 'center' },
];
const gardenBrunchPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 160, y: 160, width: 360, height: 80, align: 'center', fontSize: 24 },
{ id: 'headline', type: 'headline', x: 160, y: 300, width: 560, height: 200, fontSize: 66, align: 'left' },
{ id: 'description', type: 'description', x: 160, y: 520, width: 560, height: 260, fontSize: 28, align: 'left' },
{
id: 'qr',
type: 'qr',
x: 160,
y: 840,
width: (context) => Math.min(context.qrSize, 520),
height: (context) => Math.min(context.qrSize, 520),
},
{
id: 'link',
type: 'link',
x: 160,
y: (context) => 880 + Math.min(context.qrSize, 520),
width: 420,
height: 110,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: 160,
y: (context) => 910 + Math.min(context.qrSize, 520) + 140,
width: 420,
height: 110,
align: 'center',
},
{ id: 'subtitle', type: 'subtitle', x: 780, y: 320, width: 320, height: 140, fontSize: 32, align: 'left' },
{ id: 'text-strip', type: 'text', x: 780, y: 480, width: 320, height: 320, fontSize: 24, align: 'left' },
];
const sparklerSoireePreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 360, y: 150, width: 520, height: 90, align: 'center', fontSize: 26 },
{ id: 'headline', type: 'headline', x: 200, y: 300, width: 840, height: 220, fontSize: 72, align: 'center' },
{ id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 34, align: 'center' },
{ id: 'description', type: 'description', x: 220, y: 680, width: 800, height: 240, fontSize: 28, align: 'center' },
{
id: 'qr',
type: 'qr',
x: (context) => (context.canvasWidth - context.qrSize) / 2,
y: 960,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
},
{
id: 'link',
type: 'link',
x: (context) => (context.canvasWidth - 420) / 2,
y: (context) => 1000 + context.qrSize,
width: 420,
height: 110,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => (context.canvasWidth - 420) / 2,
y: (context) => 1030 + context.qrSize + 140,
width: 420,
height: 110,
align: 'center',
},
];
const confettiBashPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 140, y: 180, width: 360, height: 90, align: 'center', fontSize: 24 },
{ id: 'headline', type: 'headline', x: 140, y: 320, width: 520, height: 220, fontSize: 68, align: 'left' },
{ id: 'subtitle', type: 'subtitle', x: 140, y: 520, width: 520, height: 140, fontSize: 34, align: 'left' },
{ id: 'description', type: 'description', x: 140, y: 680, width: 520, height: 240, fontSize: 26, align: 'left' },
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 200,
y: 360,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 420,
y: (context) => 400 + context.qrSize,
width: 400,
height: 110,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 420,
y: (context) => 430 + context.qrSize + 140,
width: 400,
height: 110,
align: 'center',
},
{ id: 'text-strip', type: 'text', x: 140, y: 960, width: 860, height: 220, fontSize: 26, align: 'left' },
];
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
'default': DEFAULT_PRESET,
'evergreen-vows': evergreenVowsPreset,
'midnight-gala': midnightGalaPreset,
'garden-brunch': gardenBrunchPreset,
'sparkler-soiree': sparklerSoireePreset,
'confetti-bash': confettiBashPreset,
};
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
if (typeof value === 'function') {
const resolved = value(context);
return typeof resolved === 'number' ? resolved : fallback;
}
if (typeof value === 'number') {
return value;
}
return fallback;
}
export function buildDefaultElements(
layout: EventQrInviteLayout,
form: QrLayoutCustomization,
eventName: string,
qrSize: number
): LayoutElement[] {
const size = clamp(qrSize, MIN_QR_SIZE, MAX_QR_SIZE);
const context: LayoutPresetContext = {
qrSize: size,
canvasWidth: CANVAS_WIDTH,
canvasHeight: CANVAS_HEIGHT,
};
const preset = LAYOUT_PRESETS[layout.id ?? ''] ?? DEFAULT_PRESET;
const instructionsHeading = form.instructions_heading ?? layout.instructions_heading ?? "So funktioniert's";
const instructionsList = Array.isArray(form.instructions) && form.instructions.length
? form.instructions
: (layout.instructions ?? []);
const baseContent: Record<string, string | null> = {
headline: form.headline ?? eventName,
subtitle: form.subtitle ?? layout.subtitle ?? '',
description: form.description ?? layout.description ?? '',
badge: form.badge_label ?? layout.badge_label ?? 'Digitale Gästebox',
link: form.link_label ?? '',
cta: form.cta_label ?? layout.cta_label ?? 'Scan mich & starte direkt',
instructions_heading: instructionsHeading,
instructions_text: instructionsList[0] ?? null,
};
const elements = preset.map((config) => {
const typeStyle = DEFAULT_TYPE_STYLES[config.type] ?? { width: 420, height: 160 };
const widthFallback = config.type === 'qr' ? size : typeStyle.width;
const heightFallback = config.type === 'qr' ? size : typeStyle.height;
const element: LayoutElement = {
id: config.id,
type: config.type,
x: resolvePresetValue(config.x, context, 0),
y: resolvePresetValue(config.y, context, 0),
width: resolvePresetValue(config.width, context, widthFallback),
height: resolvePresetValue(config.height, context, heightFallback),
fontSize: config.fontSize ?? typeStyle.fontSize,
align: config.align ?? typeStyle.align ?? 'left',
content: null,
locked: config.locked ?? typeStyle.locked ?? false,
initial: config.initial ?? true,
};
if (config.type === 'description') {
element.lineHeight = 1.4;
}
switch (config.id) {
case 'headline':
element.content = baseContent.headline;
break;
case 'subtitle':
element.content = baseContent.subtitle;
break;
case 'description':
element.content = baseContent.description;
break;
case 'badge':
element.content = baseContent.badge;
break;
case 'link':
element.content = baseContent.link;
break;
case 'cta':
element.content = baseContent.cta;
break;
case 'text-strip':
element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.';
break;
case 'logo':
element.content = form.logo_data_url ?? form.logo_url ?? layout.logo_url ?? null;
break;
default:
if (config.type === 'text') {
element.content = element.content ?? 'Individualisiere diesen Textblock mit eigenen Infos.';
}
break;
}
if (config.type === 'qr') {
element.locked = false;
}
const clamped = clampElement(element);
return {
...clamped,
initial: element.initial ?? true,
};
});
return elements;
}
export function payloadToElements(payload?: LayoutElementPayload[] | null): LayoutElement[] {
if (!Array.isArray(payload)) {
return [];
}
return payload.map((entry) =>
clampElement({
id: entry.id,
type: entry.type,
x: Number(entry.x ?? 0),
y: Number(entry.y ?? 0),
width: Number(entry.width ?? 100),
height: Number(entry.height ?? 100),
rotation: typeof entry.rotation === 'number' ? entry.rotation : 0,
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
align: entry.align ?? 'left',
content: entry.content ?? null,
fontFamily: entry.font_family ?? null,
letterSpacing: entry.letter_spacing ?? undefined,
lineHeight: entry.line_height ?? undefined,
fill: entry.fill ?? null,
locked: Boolean(entry.locked),
initial: Boolean(entry.initial),
})
);
}
export function elementsToPayload(elements: LayoutElement[]): LayoutElementPayload[] {
return elements.map((element) => ({
id: element.id,
type: element.type,
x: element.x,
y: element.y,
width: element.width,
height: element.height,
rotation: element.rotation ?? 0,
font_size: element.fontSize,
align: element.align,
content: element.content ?? null,
font_family: element.fontFamily ?? null,
letter_spacing: element.letterSpacing,
line_height: element.lineHeight,
fill: element.fill ?? null,
locked: element.locked ?? false,
initial: element.initial ?? false,
}));
}
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
const seen = new Set<string>();
return elements
.filter((element) => {
if (!element.id) {
return false;
}
if (seen.has(element.id)) {
return false;
}
seen.add(element.id);
return true;
})
.map(clampElement);
}

View File

@@ -5,6 +5,18 @@
<title>{{ $eventName }} Einladungs-QR</title>
@php
$qrSize = $layout['qr']['size_px'] ?? 500;
$isAdvanced = ! empty($advancedLayout['elements'] ?? null);
$advancedBackground = null;
if ($isAdvanced) {
$gradient = $advancedLayout['background_gradient'] ?? null;
if (is_array($gradient) && ! empty($gradient['stops'])) {
$angle = (int) ($gradient['angle'] ?? 180);
$stops = implode(',', $gradient['stops']);
$advancedBackground = "linear-gradient({$angle}deg,{$stops})";
} else {
$advancedBackground = $advancedLayout['background'] ?? '#FFFFFF';
}
}
@endphp
<style>
:root {
@@ -169,9 +181,105 @@
.footer strong {
color: var(--accent);
}
.advanced-wrapper {
width: 100%;
padding: 48px;
background: var(--background);
}
.advanced-canvas {
position: relative;
width: {{ $advancedLayout['width'] ?? 1080 }}px;
height: {{ $advancedLayout['height'] ?? 1520 }}px;
border-radius: 32px;
overflow: hidden;
background: {{ $advancedBackground ?? '#FFFFFF' }};
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
}
.advanced-element {
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.advanced-element--badge {
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.advanced-element--cta {
border-radius: 20px;
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.2);
font-weight: 600;
letter-spacing: 0.05em;
}
.advanced-element--qr img,
.advanced-element--logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
</head>
<body>
@if($isAdvanced)
<div class="advanced-wrapper">
<div class="advanced-canvas">
@foreach($advancedLayout['elements'] as $element)
@php
$style = $element['style_string'];
$textColor = $element['fill'] ?? ($element['type'] === 'badge' ? '#FFFFFF' : ($advancedLayout['text'] ?? '#111827'));
$fontSize = $element['font_size'] ? 'font-size:'.$element['font_size'].'px;' : '';
$lineHeight = $element['line_height'] ? 'line-height:'.$element['line_height'].';' : '';
$letterSpacing = $element['letter_spacing'] !== null ? 'letter-spacing:'.$element['letter_spacing'].'px;' : '';
$fontFamily = $element['font_family'] ? 'font-family:'.$element['font_family'].';' : '';
$textAlign = $element['align'] ? 'text-align:'.$element['align'].';' : '';
$contentStyle = $fontSize.$lineHeight.$letterSpacing.$fontFamily.$textAlign.'color:'.$textColor.';';
if ($element['type'] === 'badge') {
$style .= ';background:'.$advancedLayout['badge'].';color:#fff;border-radius:999px;padding:16px 26px;display:flex;align-items:center;justify-content:center;';
}
if ($element['type'] === 'cta') {
$style .= ';background:'.$advancedLayout['accent'].';color:#fff;display:flex;align-items:center;justify-content:center;padding:24px 28px;';
}
if ($element['type'] === 'link') {
$style .= ';border:2px solid '.$advancedLayout['accent'].';border-radius:18px;padding:16px 18px;background:rgba(255,255,255,0.8);display:flex;align-items:center;justify-content:center;';
}
if (in_array($element['type'], ['headline', 'subtitle', 'description'], true)) {
$style .= ';padding:16px 20px;';
}
@endphp
<div class="advanced-element advanced-element--{{ $element['type'] }}" style="{{ $style }}">
@switch($element['type'])
@case('qr')
@if($element['asset'])
<img src="{{ $element['asset'] }}" alt="QR Code" />
@endif
@break
@case('logo')
@if($element['asset'])
<img src="{{ $element['asset'] }}" alt="Logo" />
@endif
@break
@default
@if($element['content'])
<div style="{{ $contentStyle }}">{{ $element['content'] }}</div>
@endif
@endswitch
</div>
@endforeach
</div>
</div>
@else
<div class="layout-wrapper">
<div class="header">
<div style="display:flex; align-items:center; justify-content:space-between; gap:24px;">
@@ -213,8 +321,9 @@
<div>
<strong>{{ config('app.name', 'Fotospiel') }}</strong> Gästebox & Fotochallenges
</div>
<div>Einladungsgültigkeit: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }}</div>
<div>Einladungsgültigkeit: {{ $token->expires_at ? $token->expires_at->locale(app()->getLocale())->isoFormat('LLL') : 'bis Widerruf' }}</div>
</div>
</div>
@endif
</body>
</html>

View File

@@ -174,6 +174,6 @@
&nbsp; Gästebox & Fotochallenges
</text>
<text x="{{ $width - 120 }}" y="{{ $height - 160 }}" text-anchor="end" fill="rgba(17,24,39,0.6)" class="footer-text">
Einladung gültig: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }}
Einladung gültig: {{ $token->expires_at ? $token->expires_at->locale(app()->getLocale())->isoFormat('LLL') : 'bis Widerruf' }}
</text>
</svg>

View File

@@ -82,7 +82,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name('tenant.events.join-tokens.layouts.index');
Route::get('{joinToken}/layouts/{layout}.{format}', [EventJoinTokenLayoutController::class, 'download'])
->whereNumber('joinToken')
->where('format', 'pdf|svg')
->where('format', 'pdf|png')
->name('tenant.events.join-tokens.layouts.download');
Route::patch('{joinToken}', [EventJoinTokenController::class, 'update'])
->whereNumber('joinToken')

View File

@@ -0,0 +1,116 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use Illuminate\Support\Arr;
class EventInviteAdvancedLayoutTest extends TenantTestCase
{
public function test_advanced_layout_elements_render_in_exports(): void
{
$event = Event::factory()
->for($this->tenant)
->create([
'name' => ['de' => 'Canvas Test Event'],
'slug' => 'canvas-test-event',
]);
$joinToken = $event->joinTokens()->firstOrFail();
$payload = [
'metadata' => [
'layout_customization' => [
'mode' => 'advanced',
'layout_id' => 'evergreen-vows',
'headline' => 'Konva Test Headline',
'link_label' => 'fotospiel.de/einladung',
'badge_label' => 'Fotobox',
'cta_label' => 'Los gehts',
'accent_color' => '#FF5A8D',
'text_color' => '#111111',
'background_color' => '#FFFFFF',
'elements' => [
[
'id' => 'headline',
'type' => 'headline',
'x' => 140,
'y' => 280,
'width' => 620,
'height' => 200,
'font_size' => 68,
'align' => 'left',
],
[
'id' => 'description',
'type' => 'description',
'x' => 140,
'y' => 540,
'width' => 620,
'height' => 280,
'font_size' => 28,
'content' => 'Zeig uns deine Lieblingsmomente & erfülle die Tages-Challenges!',
],
[
'id' => 'qr',
'type' => 'qr',
'x' => 720,
'y' => 460,
'width' => 500,
'height' => 500,
],
[
'id' => 'link',
'type' => 'link',
'x' => 820,
'y' => 1000,
'width' => 380,
'height' => 120,
'align' => 'center',
],
[
'id' => 'cta',
'type' => 'cta',
'x' => 820,
'y' => 1150,
'width' => 380,
'height' => 120,
],
],
],
],
];
$this->authenticatedRequest(
'PATCH',
"/api/v1/tenant/events/{$event->slug}/join-tokens/{$joinToken->id}",
$payload
)->assertOk();
$response = $this->authenticatedRequest(
'GET',
"/api/v1/tenant/events/{$event->slug}/join-tokens/{$joinToken->id}/layouts/evergreen-vows.pdf",
[],
['Accept' => 'application/pdf']
);
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
$pngResponse = $this->authenticatedRequest(
'GET',
"/api/v1/tenant/events/{$event->slug}/join-tokens/{$joinToken->id}/layouts/evergreen-vows.png",
[],
['Accept' => 'image/png']
);
$pngResponse->assertOk();
$pngResponse->assertHeader('Content-Type', 'image/png');
$refreshed = $event->fresh()->joinTokens()->first();
$meta = Arr::get($refreshed->metadata, 'layout_customization');
$this->assertSame('advanced', $meta['mode']);
$this->assertCount(5, $meta['elements']);
}
}

View File

@@ -29,7 +29,7 @@ class EventInviteQrCodeTest extends TenantTestCase
$this->assertArrayHasKey('qr_code_data_url', $firstInvite);
$this->assertNotNull($firstInvite['qr_code_data_url']);
$this->assertIsString($firstInvite['qr_code_data_url']);
$this->assertStringStartsWith('data:image/svg+xml;base64,', $firstInvite['qr_code_data_url']);
$this->assertStringStartsWith('data:image/png;base64,', $firstInvite['qr_code_data_url']);
$this->assertSame(url('/e/'.$firstInvite['token']), $firstInvite['url']);
}
}