442 lines
17 KiB
PHP
442 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Tenant;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Event;
|
|
use App\Models\EventJoinToken;
|
|
use App\Support\JoinTokenLayoutRegistry;
|
|
use App\Support\TenantMemberPermissions;
|
|
use Dompdf\Dompdf;
|
|
use Dompdf\Options;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Str;
|
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
|
|
|
class EventJoinTokenLayoutController extends Controller
|
|
{
|
|
/**
|
|
* Mapping of preset keys to portrait background assets.
|
|
*
|
|
* @var array<string, string>
|
|
*/
|
|
private const BACKGROUND_PRESETS = [
|
|
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
|
|
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
|
|
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
|
|
];
|
|
|
|
public function index(Request $request, Event $event, EventJoinToken $joinToken)
|
|
{
|
|
$this->ensureBelongsToEvent($event, $joinToken);
|
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
|
|
|
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
|
|
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
|
'event' => $event,
|
|
'joinToken' => $joinToken,
|
|
'layout' => $layoutId,
|
|
'format' => $format,
|
|
]);
|
|
});
|
|
|
|
return response()->json([
|
|
'data' => $layouts,
|
|
]);
|
|
}
|
|
|
|
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
|
|
{
|
|
$this->ensureBelongsToEvent($event, $joinToken);
|
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
|
|
|
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
|
|
|
|
if (! $layoutConfig) {
|
|
abort(404, 'Layout nicht gefunden.');
|
|
}
|
|
|
|
if (! in_array($format, ['pdf', 'png'], true)) {
|
|
abort(404, 'Unbekanntes Exportformat.');
|
|
}
|
|
|
|
$layoutConfig = $this->applyCustomization($layoutConfig, $joinToken);
|
|
|
|
$tokenUrl = url('/e/'.$joinToken->token);
|
|
|
|
$qrPngDataUri = 'data:image/png;base64,'.base64_encode(
|
|
QrCode::format('png')
|
|
->margin(0)
|
|
->size($layoutConfig['qr']['size_px'])
|
|
->generate($tokenUrl)
|
|
);
|
|
|
|
$backgroundStyle = $this->buildBackgroundStyle($layoutConfig);
|
|
$eventName = $this->resolveEventName($event);
|
|
$backgroundImage = $layoutConfig['background_image'] ?? null;
|
|
|
|
$viewData = [
|
|
'layout' => $layoutConfig,
|
|
'event' => $event,
|
|
'eventName' => $eventName,
|
|
'token' => $joinToken,
|
|
'tokenUrl' => $tokenUrl,
|
|
'qrPngDataUri' => $qrPngDataUri,
|
|
'backgroundStyle' => $backgroundStyle,
|
|
'backgroundImage' => $backgroundImage,
|
|
'customization' => $joinToken->metadata['layout_customization'] ?? null,
|
|
'advancedLayout' => $this->buildAdvancedLayout(
|
|
$layoutConfig,
|
|
$joinToken->metadata['layout_customization'] ?? null,
|
|
$qrPngDataUri,
|
|
$tokenUrl,
|
|
$eventName
|
|
),
|
|
];
|
|
|
|
$html = view('layouts.join-token.pdf', $viewData)->render();
|
|
|
|
$options = new Options;
|
|
$options->set('isHtml5ParserEnabled', true);
|
|
$options->set('isRemoteEnabled', true);
|
|
$options->set('defaultFont', 'Helvetica');
|
|
|
|
$dompdf = new Dompdf($options);
|
|
$dompdf->setPaper(strtoupper($layoutConfig['paper']), $layoutConfig['orientation'] === 'landscape' ? 'landscape' : 'portrait');
|
|
$dompdf->loadHtml($html, 'UTF-8');
|
|
$dompdf->render();
|
|
|
|
$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="'.$filenameStem.'.pdf"');
|
|
}
|
|
|
|
private function ensureBelongsToEvent(Event $event, EventJoinToken $joinToken): void
|
|
{
|
|
if ($joinToken->event_id !== $event->id) {
|
|
abort(404);
|
|
}
|
|
}
|
|
|
|
private function resolveEventName(Event $event): string
|
|
{
|
|
$name = $event->name;
|
|
|
|
if (is_array($name)) {
|
|
$locale = $event->default_locale ?? 'de';
|
|
|
|
return $name[$locale] ?? $name['de'] ?? reset($name) ?: 'Event';
|
|
}
|
|
|
|
return is_string($name) && $name !== '' ? $name : 'Event';
|
|
}
|
|
|
|
private function applyCustomization(array $layout, EventJoinToken $joinToken): array
|
|
{
|
|
$customization = data_get($joinToken->metadata, 'layout_customization');
|
|
|
|
if (! is_array($customization)) {
|
|
return $layout;
|
|
}
|
|
|
|
$layoutId = $customization['layout_id'] ?? null;
|
|
if (is_string($layoutId) && isset($layout['id']) && $layoutId !== $layout['id']) {
|
|
// Allow customization to target a specific layout; if mismatch, skip style overrides.
|
|
// General text overrides are still applied below.
|
|
}
|
|
|
|
$colorKeys = [
|
|
'accent' => 'accent_color',
|
|
'text' => 'text_color',
|
|
'background' => 'background_color',
|
|
'secondary' => 'secondary_color',
|
|
'badge' => 'badge_color',
|
|
];
|
|
|
|
foreach ($colorKeys as $layoutKey => $customKey) {
|
|
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
|
|
$layout[$layoutKey] = $customization[$customKey];
|
|
}
|
|
}
|
|
|
|
if (isset($customization['background_gradient']) && is_array($customization['background_gradient'])) {
|
|
$layout['background_gradient'] = $customization['background_gradient'];
|
|
}
|
|
|
|
foreach (['headline' => 'name', 'subtitle', 'description', 'badge_label', 'instructions_heading', 'link_heading', 'cta_label', 'cta_caption', 'link_label'] as $customKey => $layoutKey) {
|
|
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
|
|
$layout[$layoutKey] = $customization[$customKey];
|
|
}
|
|
}
|
|
|
|
if (array_key_exists('instructions', $customization) && is_array($customization['instructions'])) {
|
|
$layout['instructions'] = array_values(array_filter($customization['instructions'], fn ($value) => is_string($value) && trim($value) !== ''));
|
|
}
|
|
|
|
if (! empty($customization['logo_data_url']) && is_string($customization['logo_data_url'])) {
|
|
$layout['logo_url'] = $customization['logo_data_url'];
|
|
} elseif (! empty($customization['logo_url']) && is_string($customization['logo_url'])) {
|
|
$layout['logo_url'] = $customization['logo_url'];
|
|
}
|
|
|
|
if (! empty($customization['background_preset']) && is_string($customization['background_preset'])) {
|
|
$presetImage = $this->resolveBackgroundPreset($customization['background_preset']);
|
|
if ($presetImage) {
|
|
$layout['background_image'] = $presetImage;
|
|
$layout['background_preset'] = $customization['background_preset'];
|
|
}
|
|
}
|
|
|
|
return $layout;
|
|
}
|
|
|
|
private function buildBackgroundStyle(array $layout): string
|
|
{
|
|
if (! empty($layout['background_image']) && is_string($layout['background_image'])) {
|
|
return sprintf('url(%s) center center / cover no-repeat', $layout['background_image']);
|
|
}
|
|
|
|
$gradient = $layout['background_gradient'] ?? null;
|
|
|
|
if (is_array($gradient) && ! empty($gradient['stops'])) {
|
|
$angle = $gradient['angle'] ?? 180;
|
|
$stops = implode(',', $gradient['stops']);
|
|
|
|
return sprintf('linear-gradient(%ddeg,%s)', $angle, $stops);
|
|
}
|
|
|
|
return $layout['background'] ?? '#FFFFFF';
|
|
}
|
|
|
|
private function buildAdvancedLayout(array $layout, $customization, string $qrPngDataUri, string $tokenUrl, string $eventName): ?array
|
|
{
|
|
$customization = is_array($customization) ? $customization : [];
|
|
$hasLayoutElements = is_array($layout['elements'] ?? null) && ! empty($layout['elements']);
|
|
$isAdvancedRequested = ($customization['mode'] ?? null) === 'advanced';
|
|
|
|
if (! $isAdvancedRequested && ! $hasLayoutElements) {
|
|
return null;
|
|
}
|
|
|
|
$elements = $customization['elements'] ?? ($layout['elements'] ?? null);
|
|
|
|
if (! is_array($elements) || empty($elements)) {
|
|
return null;
|
|
}
|
|
|
|
$width = (int) ($layout['canvas_width'] ?? $layout['svg']['width'] ?? 1080);
|
|
$height = (int) ($layout['canvas_height'] ?? $layout['svg']['height'] ?? 1520);
|
|
$accent = $layout['accent'] ?? '#6366F1';
|
|
$text = $layout['text'] ?? '#0F172A';
|
|
$secondary = $layout['secondary'] ?? '#1F2937';
|
|
$badge = $layout['badge'] ?? $accent;
|
|
$backgroundImage = $layout['background_image'] ?? null;
|
|
|
|
if (! $backgroundImage && ! empty($customization['background_preset']) && is_string($customization['background_preset'])) {
|
|
$backgroundImage = $this->resolveBackgroundPreset($customization['background_preset']);
|
|
}
|
|
|
|
$resolved = [];
|
|
|
|
foreach ($elements as $element) {
|
|
if (! is_array($element) || empty($element['id']) || (! isset($element['type']) && ! isset($element['role']))) {
|
|
continue;
|
|
}
|
|
|
|
$type = (string) ($element['role'] ?? $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_image' => $backgroundImage,
|
|
'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 resolveBackgroundPreset(string $preset): ?string
|
|
{
|
|
$path = self::BACKGROUND_PRESETS[$preset] ?? null;
|
|
|
|
if (! $path) {
|
|
return null;
|
|
}
|
|
|
|
$fullPath = public_path($path);
|
|
|
|
if (! file_exists($fullPath) || ! is_readable($fullPath)) {
|
|
return null;
|
|
}
|
|
|
|
$mime = mime_content_type($fullPath) ?: 'image/png';
|
|
$data = @file_get_contents($fullPath);
|
|
|
|
if ($data === false) {
|
|
return null;
|
|
}
|
|
|
|
return 'data:'.$mime.';base64,'.base64_encode($data);
|
|
}
|
|
|
|
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,
|
|
];
|
|
}
|
|
}
|