added more translations and added the new layout wizard
This commit is contained in:
@@ -132,6 +132,7 @@ class EventJoinTokenController extends Controller
|
||||
'metadata.layout_customization.instructions.*' => ['nullable', 'string', 'max:160'],
|
||||
'metadata.layout_customization.logo_url' => ['nullable', 'string', 'max:2048'],
|
||||
'metadata.layout_customization.logo_data_url' => ['nullable', 'string'],
|
||||
'metadata.layout_customization.background_preset' => ['nullable', 'string', 'max:120'],
|
||||
'metadata.layout_customization.accent_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||
'metadata.layout_customization.text_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||
'metadata.layout_customization.background_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||
|
||||
@@ -14,6 +14,17 @@ 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);
|
||||
@@ -59,6 +70,7 @@ class EventJoinTokenLayoutController extends Controller
|
||||
|
||||
$backgroundStyle = $this->buildBackgroundStyle($layoutConfig);
|
||||
$eventName = $this->resolveEventName($event);
|
||||
$backgroundImage = $layoutConfig['background_image'] ?? null;
|
||||
|
||||
$viewData = [
|
||||
'layout' => $layoutConfig,
|
||||
@@ -68,6 +80,7 @@ class EventJoinTokenLayoutController extends Controller
|
||||
'tokenUrl' => $tokenUrl,
|
||||
'qrPngDataUri' => $qrPngDataUri,
|
||||
'backgroundStyle' => $backgroundStyle,
|
||||
'backgroundImage' => $backgroundImage,
|
||||
'customization' => $joinToken->metadata['layout_customization'] ?? null,
|
||||
'advancedLayout' => $this->buildAdvancedLayout(
|
||||
$layoutConfig,
|
||||
@@ -200,11 +213,23 @@ class EventJoinTokenLayoutController extends Controller
|
||||
$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'])) {
|
||||
@@ -239,6 +264,11 @@ class EventJoinTokenLayoutController extends Controller
|
||||
$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 = [];
|
||||
|
||||
@@ -306,6 +336,7 @@ class EventJoinTokenLayoutController extends Controller
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'background' => $layout['background'] ?? '#FFFFFF',
|
||||
'background_image' => $backgroundImage,
|
||||
'background_gradient' => $layout['background_gradient'] ?? null,
|
||||
'accent' => $accent,
|
||||
'text' => $text,
|
||||
@@ -317,6 +348,30 @@ class EventJoinTokenLayoutController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -12,6 +12,39 @@ class JoinTokenLayoutRegistry
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private const LAYOUTS = [
|
||||
'foldable-table-a5' => [
|
||||
'id' => 'foldable-table-a5',
|
||||
'name' => 'Foldable Table Card (A5)',
|
||||
'subtitle' => 'Doppelseitige Tischkarte zum Falten – QR vorn & hinten.',
|
||||
'description' => 'Zwei identische Hälften auf A4 quer, rechte Seite gespiegelt für sauberes Falten.',
|
||||
'paper' => 'a4',
|
||||
'orientation' => 'landscape',
|
||||
'panel_mode' => 'double-mirror',
|
||||
'container_padding_px' => 28,
|
||||
'background' => '#F8FAFC',
|
||||
'background_gradient' => [
|
||||
'angle' => 180,
|
||||
'stops' => ['#F8FAFC', '#EEF2FF', '#F8FAFC'],
|
||||
],
|
||||
'text' => '#0F172A',
|
||||
'accent' => '#2563EB',
|
||||
'secondary' => '#E0E7FF',
|
||||
'badge' => '#1D4ED8',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => "So funktioniert's",
|
||||
'link_heading' => 'Alternative zum Einscannen',
|
||||
'cta_label' => 'Scan & loslegen',
|
||||
'cta_caption' => 'Kein Login nötig',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'qr' => ['size_px' => 520],
|
||||
'svg' => ['width' => 1754, 'height' => 1240],
|
||||
'instructions' => [
|
||||
'QR-Code scannen oder Kurzlink öffnen.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'Fotos hochladen, liken & kommentieren.',
|
||||
'Challenges spielen und Punkte sammeln.',
|
||||
],
|
||||
],
|
||||
'evergreen-vows' => [
|
||||
'id' => 'evergreen-vows',
|
||||
'name' => 'Evergreen Vows',
|
||||
@@ -225,6 +258,8 @@ class JoinTokenLayoutRegistry
|
||||
'description' => '',
|
||||
'paper' => 'a4',
|
||||
'orientation' => 'portrait',
|
||||
'panel_mode' => null,
|
||||
'container_padding_px' => 48,
|
||||
'background' => '#F9FAFB',
|
||||
'text' => '#0F172A',
|
||||
'accent' => '#6366F1',
|
||||
@@ -327,6 +362,9 @@ class JoinTokenLayoutRegistry
|
||||
'name' => $layout['name'],
|
||||
'description' => $layout['description'],
|
||||
'subtitle' => $layout['subtitle'],
|
||||
'paper' => $layout['paper'] ?? 'a4',
|
||||
'orientation' => $layout['orientation'] ?? 'portrait',
|
||||
'panel_mode' => $layout['panel_mode'] ?? null,
|
||||
'badge_label' => $layout['badge_label'] ?? null,
|
||||
'instructions_heading' => $layout['instructions_heading'] ?? null,
|
||||
'link_heading' => $layout['link_heading'] ?? null,
|
||||
|
||||
6343
package-lock.json
generated
6343
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
106
package.json
106
package.json
@@ -21,64 +21,65 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@laravel/vite-plugin-wayfinder": "^0.1.7",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@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",
|
||||
"baseline-browser-mapping": "^2.9.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/fabric": "^5.3.10",
|
||||
"@types/node": "^22.19.2",
|
||||
"baseline-browser-mapping": "^2.9.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"i18next-scanner": "^4.6.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"playwright": "^1.55.1",
|
||||
"prettier": "^3.4.2",
|
||||
"shadcn": "^3.3.1",
|
||||
"typescript-eslint": "^8.23.0",
|
||||
"vitest": "^2.1.5"
|
||||
"prettier": "^3.7.4",
|
||||
"shadcn": "^3.5.2",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@inertiajs/react": "^2.1.0",
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@inertiajs/react": "^2.2.21",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@stripe/stripe-js": "^8.0.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tamagui/button": "^1.139.2",
|
||||
"@tamagui/config": "^1.139.2",
|
||||
"@tamagui/font": "^1.139.3",
|
||||
"@tamagui/group": "^1.139.2",
|
||||
"@tamagui/list-item": "^1.139.2",
|
||||
"@tamagui/radio-group": "1.139.2",
|
||||
"@tamagui/stacks": "^1.139.2",
|
||||
"@tamagui/text": "^1.139.2",
|
||||
"@tamagui/themes": "^1.139.2",
|
||||
"@tamagui/vite-plugin": "^1.139.2",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tamagui/button": "~1.139.2",
|
||||
"@tamagui/config": "~1.139.2",
|
||||
"@tamagui/font": "~1.139.3",
|
||||
"@tamagui/group": "~1.139.2",
|
||||
"@tamagui/list-item": "~1.139.2",
|
||||
"@tamagui/radio-group": "~1.139.2",
|
||||
"@tamagui/stacks": "~1.139.2",
|
||||
"@tamagui/switch": "~1.139.2",
|
||||
"@tamagui/text": "~1.139.2",
|
||||
"@tamagui/themes": "~1.139.2",
|
||||
"@tamagui/vite-plugin": "~1.139.2",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -86,25 +87,26 @@
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"fabric": "^6.0.1",
|
||||
"globals": "^15.14.0",
|
||||
"fabric": "^6.9.0",
|
||||
"globals": "^15.15.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"i18next": "^25.5.3",
|
||||
"i18next": "^25.7.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"laravel-vite-plugin": "^2.0.1",
|
||||
"lucide-react": "^0.475.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"react-i18next": "^16.4.1",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.0.4"
|
||||
"tamagui": "^1.139.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
|
||||
@@ -29,6 +29,9 @@ export type EventQrInviteLayout = {
|
||||
name: string;
|
||||
description: string;
|
||||
subtitle: string;
|
||||
paper?: string | null;
|
||||
orientation?: string | null;
|
||||
panel_mode?: string | null;
|
||||
badge_label?: string | null;
|
||||
instructions_heading?: string | null;
|
||||
link_heading?: string | null;
|
||||
@@ -1133,6 +1136,9 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
|
||||
name: String(layout.name ?? ''),
|
||||
description: String(layout.description ?? ''),
|
||||
subtitle: String(layout.subtitle ?? ''),
|
||||
paper: layout.paper ?? null,
|
||||
orientation: layout.orientation ?? null,
|
||||
panel_mode: layout.panel_mode ?? null,
|
||||
badge_label: layout.badge_label ?? null,
|
||||
instructions_heading: layout.instructions_heading ?? null,
|
||||
link_heading: layout.link_heading ?? null,
|
||||
|
||||
@@ -1766,6 +1766,20 @@
|
||||
"days": "+{{count}} Tage"
|
||||
}
|
||||
},
|
||||
"mobileEvents": {
|
||||
"edit": "Event bearbeiten"
|
||||
},
|
||||
"events.qr.layouts.badges.title": "Badges",
|
||||
"events.qr.layouts.badges.subtitle": "Standard, Staff",
|
||||
"events.qr.layouts.tents.title": "Tischnummern",
|
||||
"events.qr.layouts.tents.subtitle": "A4, Letter",
|
||||
"events.qr.layouts.posters.title": "Poster",
|
||||
"events.qr.layouts.posters.subtitle": "A3, 11x17",
|
||||
"events.qr.layouts.programs.title": "Programmhefte",
|
||||
"events.qr.layouts.programs.subtitle": "Gefalzt, Booklet",
|
||||
"events.qr.paperOption.A4 (210 x 297 mm)": "A4 (210 x 297 mm)",
|
||||
"events.qr.paperOption.Letter (8.5 x 11 in)": "Letter (8.5 x 11 in)",
|
||||
"events.qr.paperOption.A3 (297 x 420 mm)": "A3 (297 x 420 mm)",
|
||||
"mobileNotifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"empty": "Keine Benachrichtigungen vorhanden.",
|
||||
|
||||
@@ -1789,6 +1789,20 @@
|
||||
"days": "+{{count}} days"
|
||||
}
|
||||
},
|
||||
"mobileEvents": {
|
||||
"edit": "Edit event"
|
||||
},
|
||||
"events.qr.layouts.badges.title": "Badges",
|
||||
"events.qr.layouts.badges.subtitle": "Standard, Staff",
|
||||
"events.qr.layouts.tents.title": "Table Tents",
|
||||
"events.qr.layouts.tents.subtitle": "A4, Letter",
|
||||
"events.qr.layouts.posters.title": "Posters",
|
||||
"events.qr.layouts.posters.subtitle": "A3, 11x17",
|
||||
"events.qr.layouts.programs.title": "Event Programs",
|
||||
"events.qr.layouts.programs.subtitle": "Folded, Booklet",
|
||||
"events.qr.paperOption.A4 (210 x 297 mm)": "A4 (210 x 297 mm)",
|
||||
"events.qr.paperOption.Letter (8.5 x 11 in)": "Letter (8.5 x 11 in)",
|
||||
"events.qr.paperOption.A3 (297 x 420 mm)": "A3 (297 x 420 mm)",
|
||||
"mobileNotifications": {
|
||||
"title": "Notifications",
|
||||
"empty": "No notifications yet.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, ChevronDown } from 'lucide-react';
|
||||
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, ChevronDown, Pencil } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
@@ -129,6 +129,24 @@ export default function MobileEventDetailPage() {
|
||||
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
|
||||
{event?.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
|
||||
</PillBadge>
|
||||
<Pressable
|
||||
aria-label={t('mobileEvents.edit', 'Edit event')}
|
||||
onPress={() => slug && navigate(adminPath(`/mobile/events/${slug}/edit`))}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#e2e8f0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<Pencil size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
|
||||
<YStack space="$2">
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, ChevronDown, MapPin } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Switch } from '@tamagui/switch';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { createEvent, getEvent, updateEvent, TenantEvent } from '../api';
|
||||
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
@@ -14,14 +15,13 @@ import { getApiErrorMessage } from '../lib/apiError';
|
||||
type FormState = {
|
||||
name: string;
|
||||
date: string;
|
||||
eventType: string;
|
||||
eventTypeId: number | null;
|
||||
description: string;
|
||||
location: string;
|
||||
enableBranding: boolean;
|
||||
published: boolean;
|
||||
};
|
||||
|
||||
const EVENT_TYPES = ['Wedding', 'Corporate', 'Party', 'Other'];
|
||||
|
||||
export default function MobileEventFormPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
@@ -32,11 +32,14 @@ export default function MobileEventFormPage() {
|
||||
const [form, setForm] = React.useState<FormState>({
|
||||
name: '',
|
||||
date: '',
|
||||
eventType: EVENT_TYPES[0],
|
||||
eventTypeId: null,
|
||||
description: '',
|
||||
location: '',
|
||||
enableBranding: false,
|
||||
published: false,
|
||||
});
|
||||
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
|
||||
const [typesLoading, setTypesLoading] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(isEdit);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -49,11 +52,12 @@ export default function MobileEventFormPage() {
|
||||
const data = await getEvent(slug);
|
||||
setForm({
|
||||
name: renderName(data.name),
|
||||
date: data.event_date ?? '',
|
||||
eventType: data.event_type?.name ?? EVENT_TYPES[0],
|
||||
date: toDateTimeLocal(data.event_date),
|
||||
eventTypeId: data.event_type_id ?? data.event_type?.id ?? null,
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
location: resolveLocation(data),
|
||||
enableBranding: Boolean((data.settings as Record<string, unknown>)?.branding_allowed ?? true),
|
||||
published: data.status === 'published',
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -66,6 +70,24 @@ export default function MobileEventFormPage() {
|
||||
})();
|
||||
}, [slug, t, isEdit]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
setTypesLoading(true);
|
||||
try {
|
||||
const types = await getEventTypes();
|
||||
setEventTypes(types);
|
||||
// Default to first type if none set
|
||||
if (!form.eventTypeId && types.length) {
|
||||
setForm((prev) => ({ ...prev, eventTypeId: types[0].id }));
|
||||
}
|
||||
} catch {
|
||||
// silently ignore; fallback to null
|
||||
} finally {
|
||||
setTypesLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function handleSubmit() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -74,6 +96,8 @@ export default function MobileEventFormPage() {
|
||||
await updateEvent(slug, {
|
||||
name: form.name,
|
||||
event_date: form.date || undefined,
|
||||
event_type_id: form.eventTypeId ?? undefined,
|
||||
status: form.published ? 'published' : 'draft',
|
||||
settings: { branding_allowed: form.enableBranding, location: form.location },
|
||||
});
|
||||
navigate(adminPath(`/mobile/events/${slug}`));
|
||||
@@ -81,9 +105,9 @@ export default function MobileEventFormPage() {
|
||||
const payload = {
|
||||
name: form.name || 'Event',
|
||||
slug: `${Date.now()}`,
|
||||
event_type_id: 1,
|
||||
event_type_id: form.eventTypeId ?? undefined,
|
||||
event_date: form.date || undefined,
|
||||
status: 'draft' as const,
|
||||
status: form.published ? 'published' : 'draft' as const,
|
||||
settings: { branding_allowed: form.enableBranding, location: form.location },
|
||||
};
|
||||
const { event } = await createEvent(payload as any);
|
||||
@@ -135,31 +159,25 @@ export default function MobileEventFormPage() {
|
||||
</XStack>
|
||||
</Field>
|
||||
|
||||
<Field label={t('events.form.type', 'Event Type')}>
|
||||
<XStack space="$1" flexWrap="wrap">
|
||||
{EVENT_TYPES.map((type) => {
|
||||
const active = form.eventType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, eventType: type }))}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${active ? '#007AFF' : '#e5e7eb'}`,
|
||||
background: active ? '#e8f1ff' : 'white',
|
||||
color: active ? '#0f172a' : '#111827',
|
||||
fontWeight: 700,
|
||||
minWidth: 90,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<Field label={t('eventForm.fields.type.label', 'Event type')}>
|
||||
{typesLoading ? (
|
||||
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
|
||||
) : eventTypes.length === 0 ? (
|
||||
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text>
|
||||
) : (
|
||||
<select
|
||||
value={form.eventTypeId ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, eventTypeId: Number(e.target.value) }))}
|
||||
style={{ ...inputStyle, height: 44 }}
|
||||
>
|
||||
<option value="">{t('eventForm.fields.type.placeholder', 'Select event type')}</option>
|
||||
{eventTypes.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{renderName(type.name as any) || type.slug}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label={t('events.form.description', 'Optional Details')}>
|
||||
@@ -185,16 +203,40 @@ export default function MobileEventFormPage() {
|
||||
</Field>
|
||||
|
||||
<Field label={t('events.form.enableBranding', 'Enable Branding & Moderation')}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Switch
|
||||
checked={form.enableBranding}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, enableBranding: e.target.checked }))}
|
||||
/>
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, enableBranding: Boolean(checked) }))
|
||||
}
|
||||
size="$3"
|
||||
aria-label={t('events.form.enableBranding', 'Enable Branding & Moderation')}
|
||||
>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{form.enableBranding ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
||||
</Text>
|
||||
</label>
|
||||
</XStack>
|
||||
</Field>
|
||||
|
||||
<Field label={t('eventForm.fields.publish.label', 'Publish immediately')}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Switch
|
||||
checked={form.published}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, published: Boolean(checked) }))
|
||||
}
|
||||
size="$3"
|
||||
aria-label={t('eventForm.fields.publish.label', 'Publish immediately')}
|
||||
>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{form.published ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
|
||||
</Field>
|
||||
</MobileCard>
|
||||
|
||||
@@ -250,6 +292,16 @@ function renderName(name: TenantEvent['name']): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function toDateTimeLocal(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 16);
|
||||
}
|
||||
const fallback = value.replace(' ', 'T');
|
||||
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
|
||||
}
|
||||
|
||||
function resolveLocation(event: TenantEvent): string {
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
|
||||
297
resources/js/admin/mobile/EventPhotoboothPage.tsx
Normal file
297
resources/js/admin/mobile/EventPhotoboothPage.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import {
|
||||
getEvent,
|
||||
getEventPhotoboothStatus,
|
||||
enableEventPhotobooth,
|
||||
disableEventPhotobooth,
|
||||
rotateEventPhotobooth,
|
||||
PhotoboothStatus,
|
||||
TenantEvent,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function MobileEventPhotoboothPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [updating, setUpdating] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
|
||||
setEvent(eventData);
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
|
||||
if (!slug) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await enableEventPhotobooth(slug, { mode: mode ?? status?.mode ?? 'ftp' });
|
||||
setStatus(result);
|
||||
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')));
|
||||
}
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!slug) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await disableEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' });
|
||||
setStatus(result);
|
||||
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')));
|
||||
}
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotate = async () => {
|
||||
if (!slug) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await rotateEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' });
|
||||
setStatus(result);
|
||||
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')));
|
||||
}
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modeLabel =
|
||||
status?.mode === 'sparkbooth'
|
||||
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
|
||||
: t('photobooth.credentials.heading', 'FTP (Classic)');
|
||||
|
||||
const isActive = Boolean(status?.enabled);
|
||||
const title = event ? resolveEventDisplayName(event) : t('management.header.appName', 'Event Admin');
|
||||
const subtitle =
|
||||
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={title}
|
||||
subtitle={subtitle ?? undefined}
|
||||
onBack={() => navigate(-1)}
|
||||
headerActions={
|
||||
<Pressable onPress={() => load()}>
|
||||
<RefreshCcw size={18} color={text} />
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<MobileCard key={`ph-skel-${idx}`} height={110} opacity={0.6} />
|
||||
))}
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
<MobileCard space="$2">
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{t('photobooth.title', 'Photobooth')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('photobooth.credentials.description', 'Share these credentials with your photobooth software.')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone={isActive ? 'success' : 'warning'}>
|
||||
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
|
||||
<XStack flex={1} minWidth={0}>
|
||||
<CTAButton
|
||||
label={t('photobooth.credentials.heading', 'FTP credentials')}
|
||||
tone={status?.mode === 'ftp' ? 'primary' : 'ghost'}
|
||||
onPress={() => handleEnable('ftp')}
|
||||
disabled={updating}
|
||||
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
||||
/>
|
||||
</XStack>
|
||||
<XStack flex={1} minWidth={0}>
|
||||
<CTAButton
|
||||
label={t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)')}
|
||||
tone={status?.mode === 'sparkbooth' ? 'primary' : 'ghost'}
|
||||
onPress={() => handleEnable('sparkbooth')}
|
||||
disabled={updating}
|
||||
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('photobooth.credentials.heading', 'FTP credentials')}
|
||||
</Text>
|
||||
<YStack space="$1">
|
||||
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={status?.host ?? '—'} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={status?.username ?? '—'} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={status?.password ?? '—'} border={border} masked />
|
||||
{status?.upload_url ? <CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={status.upload_url} border={border} /> : null}
|
||||
</YStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
|
||||
<XStack flex={1} minWidth={0}>
|
||||
<CTAButton
|
||||
label={updating ? t('common.processing', '...') : t('photobooth.actions.rotate', 'Regenerate access')}
|
||||
onPress={() => handleRotate()}
|
||||
iconLeft={<RefreshCw size={14} color={surface} />}
|
||||
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
||||
/>
|
||||
</XStack>
|
||||
<XStack flex={1} minWidth={0}>
|
||||
<CTAButton
|
||||
label={isActive ? t('photobooth.actions.disable', 'Disable') : t('photobooth.actions.enable', 'Activate photobooth')}
|
||||
onPress={() => (isActive ? handleDisable() : handleEnable())}
|
||||
tone={isActive ? 'ghost' : 'primary'}
|
||||
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
|
||||
disabled={updating}
|
||||
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('photobooth.status.heading', 'Status')}
|
||||
</Text>
|
||||
<YStack space="$1">
|
||||
<StatusRow icon={<ShieldCheck size={16} color={text} />} label={t('photobooth.status.mode', 'Mode')} value={modeLabel} />
|
||||
<StatusRow
|
||||
icon={<PlugZap size={16} color={text} />}
|
||||
label={t('photobooth.status.heading', 'Status')}
|
||||
value={isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
||||
/>
|
||||
{status?.metrics?.uploads_last_hour != null ? (
|
||||
<StatusRow
|
||||
icon={<RefreshCcw size={16} color={text} />}
|
||||
label={t('photobooth.rateLimit.usage', 'Uploads last hour')}
|
||||
value={String(status.metrics.uploads_last_hour)}
|
||||
/>
|
||||
) : null}
|
||||
{status?.metrics?.last_upload_at ? (
|
||||
<StatusRow
|
||||
icon={<Clock3 size={16} color={text} />}
|
||||
label={t('photobooth.stats.lastUpload', 'Letzter Upload')}
|
||||
value={formatEventDate(status.metrics.last_upload_at, locale) ?? '—'}
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
)}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialRow({ label, value, border, masked }: { label: string; value: string; border: string; masked?: boolean }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between" borderWidth={1} borderColor={border} borderRadius="$3" padding="$2">
|
||||
<YStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{masked ? '••••••••' : value}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(t('common.copied', 'Kopiert'));
|
||||
} catch {
|
||||
toast.error(t('common.copyFailed', 'Kopieren fehlgeschlagen'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={16} color="#6b7280" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<XStack alignItems="center" space="$2">
|
||||
{icon}
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Share2, ChevronRight, RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { ChevronRight, RefreshCcw, ArrowLeft } from 'lucide-react';
|
||||
import { YStack, XStack, Stack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { TenantEvent, getEvent, getEventQrInvites, createQrInvite } from '../api';
|
||||
import {
|
||||
TenantEvent,
|
||||
EventQrInvite,
|
||||
EventQrInviteLayout,
|
||||
getEvent,
|
||||
getEventQrInvites,
|
||||
createQrInvite,
|
||||
updateEventQrInvite,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
|
||||
const LAYOUTS = [
|
||||
{ key: 'badges', title: 'Badges', subtitle: 'Standard, Staff' },
|
||||
{ key: 'tents', title: 'Table Tents', subtitle: 'A4, Letter' },
|
||||
{ key: 'posters', title: 'Posters', subtitle: 'A3, 11x17' },
|
||||
{ key: 'programs', title: 'Event Programs', subtitle: 'Folded, Booklet' },
|
||||
];
|
||||
|
||||
export default function MobileQrPrintPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
@@ -27,12 +27,26 @@ export default function MobileQrPrintPage() {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [selectedInvite, setSelectedInvite] = React.useState<EventQrInvite | null>(null);
|
||||
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [paperSize, setPaperSize] = React.useState('A4 (210 x 297 mm)');
|
||||
const [qrUrl, setQrUrl] = React.useState<string>('');
|
||||
const [showPaperSheet, setShowPaperSheet] = React.useState(false);
|
||||
const [showLayoutSheet, setShowLayoutSheet] = React.useState(false);
|
||||
const [wizardStep, setWizardStep] = React.useState<'select-layout' | 'background' | 'text' | 'preview'>('select-layout');
|
||||
const [selectedBackgroundPreset, setSelectedBackgroundPreset] = React.useState<string | null>(null);
|
||||
const [textFields, setTextFields] = React.useState({
|
||||
headline: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
instructions: [''],
|
||||
});
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const BACKGROUND_PRESETS = [
|
||||
{ id: 'bg-blue-floral', src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png', label: 'Blue Floral' },
|
||||
{ id: 'bg-goldframe', src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png', label: 'Gold Frame' },
|
||||
{ id: 'gr-green-floral', src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png', label: 'Green Floral' },
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return;
|
||||
@@ -42,7 +56,20 @@ export default function MobileQrPrintPage() {
|
||||
const data = await getEvent(slug);
|
||||
const invites = await getEventQrInvites(slug);
|
||||
setEvent(data);
|
||||
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0];
|
||||
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null;
|
||||
setSelectedInvite(primaryInvite);
|
||||
setSelectedLayoutId(primaryInvite?.layouts?.[0]?.id ?? null);
|
||||
const backgroundPreset = (primaryInvite?.metadata as any)?.layout_customization?.background_preset ?? null;
|
||||
setSelectedBackgroundPreset(typeof backgroundPreset === 'string' ? backgroundPreset : null);
|
||||
const customization = (primaryInvite?.metadata as any)?.layout_customization ?? {};
|
||||
setTextFields({
|
||||
headline: customization.headline ?? '',
|
||||
subtitle: customization.subtitle ?? '',
|
||||
description: customization.description ?? '',
|
||||
instructions: Array.isArray(customization.instructions) && customization.instructions.length
|
||||
? customization.instructions.map((item: unknown) => String(item ?? '')).filter((item: string) => item.length > 0)
|
||||
: [''],
|
||||
});
|
||||
setQrUrl(primaryInvite?.url ?? data.public_url ?? '');
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -97,7 +124,7 @@ export default function MobileQrPrintPage() {
|
||||
/>
|
||||
) : (
|
||||
<Text color="#9ca3af" fontSize="$sm">
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
@@ -134,29 +161,122 @@ export default function MobileQrPrintPage() {
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.qr.layouts', 'Print Layouts')}
|
||||
</Text>
|
||||
<YStack space="$1">
|
||||
{LAYOUTS.map((layout) => (
|
||||
<XStack
|
||||
key={layout.key}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
paddingVertical="$2"
|
||||
borderBottomWidth={layout.key === 'programs' ? 0 : 1}
|
||||
borderColor="#e5e7eb"
|
||||
onPress={() => setShowLayoutSheet(true)}
|
||||
>
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{layout.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{layout.subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
{(() => {
|
||||
if (wizardStep === 'select-layout') {
|
||||
return (
|
||||
<LayoutSelection
|
||||
layouts={selectedInvite?.layouts ?? []}
|
||||
selectedLayoutId={selectedLayoutId}
|
||||
onSelect={(layoutId) => {
|
||||
setSelectedLayoutId(layoutId);
|
||||
setWizardStep('background');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (wizardStep === 'background') {
|
||||
return (
|
||||
<BackgroundStep
|
||||
onBack={() => setWizardStep('select-layout')}
|
||||
presets={BACKGROUND_PRESETS}
|
||||
selectedPreset={selectedBackgroundPreset}
|
||||
onSelectPreset={setSelectedBackgroundPreset}
|
||||
selectedLayout={selectedInvite?.layouts.find((l) => l.id === selectedLayoutId) ?? null}
|
||||
onSave={async () => {
|
||||
if (!slug || !selectedInvite || !selectedLayoutId) {
|
||||
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
metadata: {
|
||||
layout_customization: {
|
||||
layout_id: selectedLayoutId,
|
||||
background_preset: selectedBackgroundPreset,
|
||||
headline: textFields.headline || undefined,
|
||||
subtitle: textFields.subtitle || undefined,
|
||||
description: textFields.description || undefined,
|
||||
instructions: textFields.instructions.filter((item) => item.trim().length > 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
const updated = await updateEventQrInvite(slug, selectedInvite.id, payload);
|
||||
setSelectedInvite(updated);
|
||||
toast.success(t('common.saved', 'Gespeichert'));
|
||||
setWizardStep('text');
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Speichern fehlgeschlagen.')));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
saving={saving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (wizardStep === 'text') {
|
||||
return (
|
||||
<TextStep
|
||||
onBack={() => setWizardStep('background')}
|
||||
textFields={textFields}
|
||||
onChange={(fields) => setTextFields(fields)}
|
||||
onSave={async () => {
|
||||
if (!slug || !selectedInvite || !selectedLayoutId) {
|
||||
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
metadata: {
|
||||
layout_customization: {
|
||||
layout_id: selectedLayoutId,
|
||||
background_preset: selectedBackgroundPreset,
|
||||
headline: textFields.headline || null,
|
||||
subtitle: textFields.subtitle || null,
|
||||
description: textFields.description || null,
|
||||
instructions: textFields.instructions.filter((item) => item.trim().length > 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
const updated = await updateEventQrInvite(slug, selectedInvite.id, payload);
|
||||
setSelectedInvite(updated);
|
||||
toast.success(t('common.saved', 'Gespeichert'));
|
||||
setWizardStep('preview');
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Speichern fehlgeschlagen.')));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
saving={saving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewStep
|
||||
onBack={() => setWizardStep('text')}
|
||||
layout={selectedInvite?.layouts.find((l) => l.id === selectedLayoutId) ?? null}
|
||||
backgroundPreset={selectedBackgroundPreset}
|
||||
presets={BACKGROUND_PRESETS}
|
||||
textFields={textFields}
|
||||
qrUrl={qrUrl}
|
||||
onExport={(format) => {
|
||||
const layout = selectedInvite?.layouts.find((l) => l.id === selectedLayoutId);
|
||||
const url = layout?.download_urls?.[format];
|
||||
if (!url) {
|
||||
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
|
||||
return;
|
||||
}
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
@@ -174,19 +294,17 @@ export default function MobileQrPrintPage() {
|
||||
</Text>
|
||||
</label>
|
||||
</XStack>
|
||||
<Pressable onPress={() => setShowPaperSheet(true)}>
|
||||
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2">
|
||||
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('events.qr.paper', 'Paper Size')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('events.qr.paper', 'Paper Size')}
|
||||
{t('events.qr.paperAuto', 'Auto (per layout)')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{paperSize}
|
||||
</Text>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
<CTAButton
|
||||
label={t('events.qr.preview', 'Preview & Print')}
|
||||
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
|
||||
@@ -206,62 +324,406 @@ export default function MobileQrPrintPage() {
|
||||
}}
|
||||
/>
|
||||
</MobileCard>
|
||||
|
||||
<MobileSheet
|
||||
open={showPaperSheet}
|
||||
onClose={() => setShowPaperSheet(false)}
|
||||
title={t('events.qr.paper', 'Paper Size')}
|
||||
footer={null}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{['A4 (210 x 297 mm)', 'Letter (8.5 x 11 in)', 'A3 (297 x 420 mm)'].map((size) => (
|
||||
<Pressable
|
||||
key={size}
|
||||
onPress={() => {
|
||||
setPaperSize(size);
|
||||
setShowPaperSheet(false);
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{size}
|
||||
</Text>
|
||||
{paperSize === size ? <ChevronRight size={16} color="#007AFF" /> : null}
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showLayoutSheet}
|
||||
onClose={() => setShowLayoutSheet(false)}
|
||||
title={t('events.qr.layouts', 'Print Layouts')}
|
||||
footer={
|
||||
<CTAButton
|
||||
label={t('events.qr.preview', 'Preview & Print')}
|
||||
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{LAYOUTS.map((layout) => (
|
||||
<MobileCard key={`lay-${layout.key}`} padding="$3" borderColor="#e5e7eb">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{layout.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{layout.subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="muted">{paperSize}</PillBadge>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutSelection({
|
||||
layouts,
|
||||
selectedLayoutId,
|
||||
onSelect,
|
||||
}: {
|
||||
layouts: EventQrInviteLayout[];
|
||||
selectedLayoutId: string | null;
|
||||
onSelect: (layoutId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
if (!layouts.length) {
|
||||
return (
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
{t('events.qr.noLayouts', 'Keine Layouts verfügbar.')}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack space="$2" marginTop="$2">
|
||||
{layouts.map((layout) => {
|
||||
const isSelected = layout.id === selectedLayoutId;
|
||||
return (
|
||||
<Pressable key={layout.id} onPress={() => onSelect(layout.id)} style={{ width: '100%' }}>
|
||||
<MobileCard
|
||||
padding="$3"
|
||||
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
|
||||
borderWidth={isSelected ? 2 : 1}
|
||||
backgroundColor={isSelected ? '#eff6ff' : '#fff'}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" space="$3">
|
||||
<YStack space="$1" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{layout.name || layout.id}
|
||||
</Text>
|
||||
{layout.description ? (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{layout.description}
|
||||
</Text>
|
||||
) : null}
|
||||
<XStack space="$2" alignItems="center" flexWrap="wrap">
|
||||
<PillBadge tone="muted">{(layout.paper || 'A4').toUpperCase()}</PillBadge>
|
||||
<PillBadge tone="muted">{(layout.orientation || 'portrait').toUpperCase()}</PillBadge>
|
||||
{layout.panel_mode ? <PillBadge tone="muted">{layout.panel_mode}</PillBadge> : null}
|
||||
</XStack>
|
||||
</YStack>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundStep({
|
||||
onBack,
|
||||
presets,
|
||||
selectedPreset,
|
||||
onSelectPreset,
|
||||
selectedLayout,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
presets: { id: string; src: string; label: string }[];
|
||||
selectedPreset: string | null;
|
||||
onSelectPreset: (id: string) => void;
|
||||
selectedLayout: EventQrInviteLayout | null;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<YStack space="$3" marginTop="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
<PillBadge tone="muted">
|
||||
{selectedLayout ? `${(selectedLayout.paper || 'A4').toUpperCase()} • ${(selectedLayout.orientation || 'portrait').toUpperCase()}` : 'Layout'}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.qr.backgroundPicker', 'Hintergrund auswählen (A4 Portrait Presets)')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{presets.map((preset) => {
|
||||
const isSelected = selectedPreset === preset.id;
|
||||
return (
|
||||
<Pressable key={preset.id} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
|
||||
<Stack
|
||||
height={120}
|
||||
borderRadius={14}
|
||||
overflow="hidden"
|
||||
borderWidth={isSelected ? 2 : 1}
|
||||
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
|
||||
backgroundColor="#f8fafc"
|
||||
>
|
||||
<Stack
|
||||
flex={1}
|
||||
backgroundImage={`url(${preset.src})`}
|
||||
backgroundSize="cover"
|
||||
backgroundPosition="center"
|
||||
/>
|
||||
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
|
||||
<Text fontSize="$xs" color="#111827">
|
||||
{preset.label}
|
||||
</Text>
|
||||
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
|
||||
</XStack>
|
||||
</Stack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<CTAButton
|
||||
label={saving ? t('common.saving', 'Speichern …') : t('common.save', 'Speichern')}
|
||||
disabled={saving || !selectedLayout}
|
||||
onPress={onSave}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function TextStep({
|
||||
onBack,
|
||||
textFields,
|
||||
onChange,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
||||
onChange: (fields: { headline: string; subtitle: string; description: string; instructions: string[] }) => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => {
|
||||
onChange({ ...textFields, [key]: value });
|
||||
};
|
||||
|
||||
const updateInstruction = (idx: number, value: string) => {
|
||||
const next = [...textFields.instructions];
|
||||
next[idx] = value;
|
||||
onChange({ ...textFields, instructions: next });
|
||||
};
|
||||
|
||||
const addInstruction = () => {
|
||||
onChange({ ...textFields, instructions: [...textFields.instructions, ''] });
|
||||
};
|
||||
|
||||
const removeInstruction = (idx: number) => {
|
||||
const next = textFields.instructions.filter((_, i) => i !== idx);
|
||||
onChange({ ...textFields, instructions: next.length ? next : [''] });
|
||||
};
|
||||
|
||||
return (
|
||||
<YStack space="$3" marginTop="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
<PillBadge tone="muted">{t('events.qr.textStep', 'Texte & Hinweise')}</PillBadge>
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.qr.textFields', 'Texte')}
|
||||
</Text>
|
||||
<StyledInput
|
||||
placeholder={t('events.qr.headline', 'Headline')}
|
||||
value={textFields.headline}
|
||||
onChangeText={(val) => updateField('headline', val)}
|
||||
/>
|
||||
<StyledInput
|
||||
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
||||
value={textFields.subtitle}
|
||||
onChangeText={(val) => updateField('subtitle', val)}
|
||||
/>
|
||||
<StyledTextarea
|
||||
placeholder={t('events.qr.description', 'Beschreibung')}
|
||||
value={textFields.description}
|
||||
onChangeText={(val) => updateField('description', val)}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.qr.instructions', 'Anleitung')}
|
||||
</Text>
|
||||
{textFields.instructions.map((item, idx) => (
|
||||
<XStack key={idx} alignItems="center" space="$2">
|
||||
<StyledInput
|
||||
flex={1}
|
||||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||||
value={item}
|
||||
onChangeText={(val) => updateInstruction(idx, val)}
|
||||
/>
|
||||
<CTAButton
|
||||
label="–"
|
||||
onPress={() => removeInstruction(idx)}
|
||||
disabled={textFields.instructions.length === 1}
|
||||
/>
|
||||
</XStack>
|
||||
))}
|
||||
<CTAButton label={t('common.add', 'Hinzufügen')} onPress={addInstruction} />
|
||||
</YStack>
|
||||
|
||||
<CTAButton
|
||||
label={saving ? t('common.saving', 'Speichern …') : t('common.save', 'Speichern')}
|
||||
disabled={saving}
|
||||
onPress={onSave}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewStep({
|
||||
onBack,
|
||||
layout,
|
||||
backgroundPreset,
|
||||
presets,
|
||||
textFields,
|
||||
qrUrl,
|
||||
onExport,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
layout: EventQrInviteLayout | null;
|
||||
backgroundPreset: string | null;
|
||||
presets: { id: string; src: string; label: string }[];
|
||||
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
||||
qrUrl: string;
|
||||
onExport: (format: 'pdf' | 'png') => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null;
|
||||
const resolvedBg = presetSrc ?? layout?.preview?.background ?? '#f8fafc';
|
||||
|
||||
return (
|
||||
<YStack space="$3" marginTop="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
{layout ? (
|
||||
<PillBadge tone="muted">
|
||||
{(layout.paper || 'A4').toUpperCase()} • {(layout.orientation || 'portrait').toUpperCase()}
|
||||
</PillBadge>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.qr.preview', 'Vorschau')}
|
||||
</Text>
|
||||
<Stack
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
overflow="hidden"
|
||||
backgroundColor={presetSrc ? 'transparent' : '#f8fafc'}
|
||||
style={
|
||||
presetSrc
|
||||
? {
|
||||
backgroundImage: `url(${presetSrc})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
: { background: resolvedBg ?? '#f8fafc' }
|
||||
}
|
||||
padding="$3"
|
||||
gap="$3"
|
||||
>
|
||||
<Text fontSize="$lg" fontWeight="800" color={layout?.preview?.text ?? '#0f172a'}>
|
||||
{textFields.headline || layout?.name || t('events.qr.previewHeadline', 'Event QR')}
|
||||
</Text>
|
||||
{textFields.subtitle ? (
|
||||
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
|
||||
{textFields.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{textFields.description ? (
|
||||
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
|
||||
{textFields.description}
|
||||
</Text>
|
||||
) : null}
|
||||
<YStack space="$1">
|
||||
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
|
||||
<Text key={idx} fontSize="$xs" color={layout?.preview?.text ?? '#1f2937'}>
|
||||
• {item}
|
||||
</Text>
|
||||
))}
|
||||
</YStack>
|
||||
<YStack alignItems="center" justifyContent="center">
|
||||
{qrUrl ? (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}`}
|
||||
alt="QR"
|
||||
style={{ width: 140, height: 140, objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
</Stack>
|
||||
</YStack>
|
||||
|
||||
<XStack space="$2">
|
||||
<CTAButton label={t('events.qr.exportPdf', 'Export PDF')} onPress={() => onExport('pdf')} />
|
||||
<CTAButton label={t('events.qr.exportPng', 'Export PNG')} onPress={() => onExport('png')} />
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
function StyledInput({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
flex,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder?: string;
|
||||
flex?: number;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e5e7eb',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
flex: flex ?? undefined,
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledTextarea({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e5e7eb',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
minHeight: 96,
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,11 +107,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
{onBack ? (
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={18} color="#007AFF" />
|
||||
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : (
|
||||
<XStack width={18} />
|
||||
<XStack width={28} />
|
||||
)}
|
||||
|
||||
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
|
||||
@@ -195,7 +195,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
|
||||
{showDevTenantSwitcher ? (
|
||||
<Suspense fallback={null}>
|
||||
<DevTenantSwitcher bottomOffset={96} />
|
||||
<DevTenantSwitcher bottomOffset={64} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const EventMembersPage = React.lazy(() => import('./pages/EventMembersPage'));
|
||||
const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage'));
|
||||
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
|
||||
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
|
||||
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
|
||||
const EventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
|
||||
const EventBrandingPage = React.lazy(() => import('./pages/EventBrandingPage'));
|
||||
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
|
||||
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
$qrSize = $layout['qr']['size_px'] ?? 500;
|
||||
$isAdvanced = ! empty($advancedLayout['elements'] ?? null);
|
||||
$advancedBackground = null;
|
||||
$advancedBackgroundImage = $isAdvanced ? ($advancedLayout['background_image'] ?? null) : null;
|
||||
if ($isAdvanced) {
|
||||
$gradient = $advancedLayout['background_gradient'] ?? null;
|
||||
if (is_array($gradient) && ! empty($gradient['stops'])) {
|
||||
@@ -16,7 +17,17 @@
|
||||
} else {
|
||||
$advancedBackground = $advancedLayout['background'] ?? '#FFFFFF';
|
||||
}
|
||||
} else {
|
||||
$advancedBackground = '#FFFFFF';
|
||||
}
|
||||
$containerPadding = $layout['container_padding_px'] ?? 48;
|
||||
$isDoublePanel = ($layout['panel_mode'] ?? null) === 'double-mirror';
|
||||
$backgroundImageInline = ($backgroundImage ?? null)
|
||||
? 'background:'.$backgroundStyle.';background-image:url('.$backgroundImage.');background-size:cover;background-position:center;background-repeat:no-repeat;'
|
||||
: 'background:'.$backgroundStyle.';';
|
||||
$advancedBackgroundInline = ($advancedBackgroundImage ?? null)
|
||||
? 'background:'.$advancedBackground.';background-image:url('.$advancedBackgroundImage.');background-size:cover;background-position:center;background-repeat:no-repeat;'
|
||||
: 'background:'.$advancedBackground.';';
|
||||
@endphp
|
||||
<style>
|
||||
:root {
|
||||
@@ -24,7 +35,7 @@
|
||||
--secondary: {{ $layout['secondary'] }};
|
||||
--text: {{ $layout['text'] }};
|
||||
--badge: {{ $layout['badge'] }};
|
||||
--container-padding: 48px;
|
||||
--container-padding: {{ $containerPadding }}px;
|
||||
--qr-size: {{ $qrSize }}px;
|
||||
--background: {{ $backgroundStyle }};
|
||||
}
|
||||
@@ -45,6 +56,26 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-sheet {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panel--mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.panel--mirror .panel-inner {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -228,9 +259,62 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@php
|
||||
$renderStandard = function () use ($layout, $eventName, $tokenUrl, $qrPngDataUri, $token) {
|
||||
ob_start();
|
||||
@endphp
|
||||
<div class="layout-wrapper panel-inner" style="{{ $backgroundImageInline }}">
|
||||
<div class="header">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:24px;">
|
||||
<span class="badge">{{ $layout['badge_label'] ?? 'Digitale Gästebox' }}</span>
|
||||
@if(!empty($layout['logo_url']))
|
||||
<img src="{{ $layout['logo_url'] }}" alt="Logo" class="logo" />
|
||||
@endif
|
||||
</div>
|
||||
<h1 class="event-title">{{ $layout['headline'] ?? $eventName }}</h1>
|
||||
@if(!empty($layout['subtitle']))
|
||||
<p class="subtitle">{{ $layout['subtitle'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="info-card">
|
||||
<h2>{{ $layout['instructions_heading'] ?? "So funktioniert's" }}</h2>
|
||||
<p>{{ $layout['description'] }}</p>
|
||||
@if(!empty($layout['instructions']))
|
||||
<ul class="instructions">
|
||||
@foreach($layout['instructions'] as $step)
|
||||
<li>{{ $step }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
<div>
|
||||
<div class="cta">{{ $layout['link_heading'] ?? 'Alternative zum Einscannen' }}</div>
|
||||
<div class="link-box">{{ $layout['link_label'] ?? $tokenUrl }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-wrapper">
|
||||
<img src="{{ $qrPngDataUri }}" alt="QR-Code zum Event {{ $eventName }}">
|
||||
<div class="cta">{{ $layout['cta_label'] ?? 'Scan mich & starte direkt' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>
|
||||
<strong>{{ config('app.name', 'Fotospiel') }}</strong> – Gästebox & Fotochallenges
|
||||
</div>
|
||||
<div>Einladungsgültigkeit: {{ $token->expires_at ? $token->expires_at->locale(app()->getLocale())->isoFormat('LLL') : 'bis Widerruf' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@php
|
||||
return ob_get_clean();
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if($isAdvanced)
|
||||
<div class="advanced-wrapper">
|
||||
<div class="advanced-canvas">
|
||||
<div class="advanced-canvas" style="{{ $advancedBackgroundInline }}">
|
||||
@foreach($advancedLayout['elements'] as $element)
|
||||
@php
|
||||
$style = $element['style_string'];
|
||||
@@ -279,51 +363,17 @@
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@elseif($isDoublePanel)
|
||||
<div class="panel-sheet">
|
||||
<div class="panel">
|
||||
{!! str_replace('class="layout-wrapper panel-inner"', 'class="layout-wrapper panel-inner" style="'.$backgroundImageInline.'"', $renderStandard()) !!}
|
||||
</div>
|
||||
<div class="panel panel--mirror">
|
||||
{!! str_replace('class="layout-wrapper panel-inner"', 'class="layout-wrapper panel-inner" style="'.$backgroundImageInline.'"', $renderStandard()) !!}
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="layout-wrapper">
|
||||
<div class="header">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:24px;">
|
||||
<span class="badge">{{ $layout['badge_label'] ?? 'Digitale Gästebox' }}</span>
|
||||
@if(!empty($layout['logo_url']))
|
||||
<img src="{{ $layout['logo_url'] }}" alt="Logo" class="logo" />
|
||||
@endif
|
||||
</div>
|
||||
<h1 class="event-title">{{ $layout['headline'] ?? $eventName }}</h1>
|
||||
@if(!empty($layout['subtitle']))
|
||||
<p class="subtitle">{{ $layout['subtitle'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="info-card">
|
||||
<h2>{{ $layout['instructions_heading'] ?? "So funktioniert's" }}</h2>
|
||||
<p>{{ $layout['description'] }}</p>
|
||||
@if(!empty($layout['instructions']))
|
||||
<ul class="instructions">
|
||||
@foreach($layout['instructions'] as $step)
|
||||
<li>{{ $step }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
<div>
|
||||
<div class="cta">{{ $layout['link_heading'] ?? 'Alternative zum Einscannen' }}</div>
|
||||
<div class="link-box">{{ $layout['link_label'] ?? $tokenUrl }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-wrapper">
|
||||
<img src="{{ $qrPngDataUri }}" alt="QR-Code zum Event {{ $eventName }}">
|
||||
<div class="cta">{{ $layout['cta_label'] ?? 'Scan mich & starte direkt' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>
|
||||
<strong>{{ config('app.name', 'Fotospiel') }}</strong> – Gästebox & Fotochallenges
|
||||
</div>
|
||||
<div>Einladungsgültigkeit: {{ $token->expires_at ? $token->expires_at->locale(app()->getLocale())->isoFormat('LLL') : 'bis Widerruf' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!! str_replace('class="layout-wrapper panel-inner"', 'class="layout-wrapper panel-inner" style="'.$backgroundImageInline.'"', $renderStandard()) !!}
|
||||
@endif
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user