added more translations and added the new layout wizard

This commit is contained in:
Codex Agent
2025-12-11 16:55:12 +01:00
parent b4417db5cd
commit 57be7d0030
15 changed files with 4951 additions and 2897 deletions

View File

@@ -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})$/'],

View File

@@ -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) {

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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,

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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">

View File

@@ -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 =

View 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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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}

View File

@@ -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'));

View File

@@ -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>