Fix auth translations and admin PWA UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-16 12:14:53 +01:00
parent 292c8f0b26
commit 918bff08aa
44 changed files with 2504 additions and 677 deletions

View File

@@ -72,6 +72,31 @@
"action": "Installieren",
"iosHint": "iOS: Teilen → Zum Home-Bildschirm."
},
"consent": {
"banner": {
"title": "Cookie-Einstellungen",
"body": "Wir verwenden notwendige Cookies für den Betrieb. Optionale Analyse-Cookies helfen uns, die App zu verbessern.",
"reject": "Ablehnen",
"customize": "Anpassen",
"accept": "Alle akzeptieren"
},
"modal": {
"title": "Cookie-Auswahl",
"description": "Wähle, welche Cookies du erlaubst. Du kannst das jederzeit ändern.",
"functional": "Notwendige Cookies",
"functional_desc": "Erforderlich für Kernfunktionen und Sicherheit.",
"required": "Erforderlich",
"analytics": "Analyse-Cookies",
"analytics_desc": "Helfen uns, die Nutzung zu verstehen und die Performance zu verbessern.",
"reject_all": "Alle ablehnen",
"accept_all": "Alle akzeptieren",
"cancel": "Abbrechen",
"save": "Auswahl speichern"
},
"accessibility": {
"banner_label": "Cookie-Hinweis"
}
},
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.",

View File

@@ -72,6 +72,31 @@
"action": "Install",
"iosHint": "On iOS: Share → Add to Home Screen."
},
"consent": {
"banner": {
"title": "Cookie preferences",
"body": "We use essential cookies to run the app. Optional analytics help us improve your experience.",
"reject": "Reject",
"customize": "Customize",
"accept": "Accept all"
},
"modal": {
"title": "Cookie settings",
"description": "Choose which cookies you allow. You can change this anytime.",
"functional": "Essential cookies",
"functional_desc": "Required for core features and security.",
"required": "Required",
"analytics": "Analytics cookies",
"analytics_desc": "Help us understand usage to improve performance.",
"reject_all": "Reject all",
"accept_all": "Accept all",
"cancel": "Cancel",
"save": "Save selection"
},
"accessibility": {
"banner_label": "Cookie consent"
}
},
"errors": {
"generic": "Something went wrong. Please try again.",
"eventLimit": "Your current package has no remaining event bundle.",

View File

@@ -1,15 +1,21 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card } from '@tamagui/card';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Spinner } from 'tamagui';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
import { useAdminTheme } from './theme';
export default function AuthCallbackPage(): React.ReactElement {
const { status } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation('auth');
const [redirected, setRedirected] = React.useState(false);
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -40,12 +46,34 @@ export default function AuthCallbackPage(): React.ReactElement {
}, [destination, navigate, redirected, status]);
return (
<div
className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground"
style={safeAreaStyle}
<YStack
alignItems="center"
justifyContent="center"
padding="$4"
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
backgroundColor={surface}
>
<span className="text-base font-medium text-foreground">{t('processing.title', 'Signing you in …')}</span>
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p>
</div>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack alignItems="center" space="$2">
<Spinner size="small" color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('processing.title', 'Signing you in …')}
</Text>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('processing.copy', 'One moment please while we prepare your dashboard.')}
</Text>
</YStack>
</Card>
</YStack>
);
}

View File

@@ -8,6 +8,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { Slider } from 'tamagui';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect } from './components/FormControls';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
import { isAuthError } from '../auth/tokens';
import { ApiError, getApiErrorMessage } from '../lib/apiError';
@@ -363,22 +364,17 @@ export default function MobileBrandingPage() {
{t('events.watermark.title', 'Wasserzeichen')}
</Text>
<InputField
label={t('events.watermark.mode', 'Modus')}
value={mode}
onChange={(value) => {
if (controlsLocked) return;
if (value === 'custom' || value === 'base' || value === 'off') {
setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] }));
}
}}
onPicker={() => undefined}
>
<select
<MobileField label={t('events.watermark.mode', 'Modus')}>
<MobileSelect
value={mode}
disabled={controlsLocked}
onChange={(event) => setWatermarkForm((prev) => ({ ...prev, mode: event.target.value as WatermarkForm['mode'] }))}
style={{ width: '100%', height: 40, border: 'none', outline: 'none', background: 'transparent' }}
onChange={(event) => {
const value = event.target.value;
if (controlsLocked) return;
if (value === 'custom' || value === 'base' || value === 'off') {
setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] }));
}
}}
>
<option value="base">{t('events.watermark.modeBase', 'Basis')}</option>
<option value="custom" disabled={watermarkLocked}>
@@ -387,8 +383,8 @@ export default function MobileBrandingPage() {
<option value="off" disabled={policyLabel === 'basic'}>
{t('events.watermark.modeOff', 'Deaktiviert')}
</option>
</select>
</InputField>
</MobileSelect>
</MobileField>
{mode === 'custom' && !controlsLocked ? (
<YStack space="$2">
@@ -414,11 +410,9 @@ export default function MobileBrandingPage() {
</Text>
</XStack>
</Pressable>
<input
<MobileFileInput
id="watermark-upload-input"
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
style={{ display: 'none' }}
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -855,11 +849,9 @@ export default function MobileBrandingPage() {
</Pressable>
</>
)}
<input
<MobileFileInput
id="branding-logo-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
disabled={brandingDisabled}
onChange={(event) => {
const file = event.target.files?.[0];
@@ -1171,24 +1163,22 @@ function ColorField({
onChange: (next: string) => void;
disabled?: boolean;
}) {
const { textStrong, muted, border, surface } = useAdminTheme();
const { textStrong, muted } = useAdminTheme();
return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack alignItems="center" space="$2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
/>
<Text fontSize="$sm" color={muted}>
{value}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<MobileColorInput
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
/>
<Text fontSize="$sm" color={muted}>
{value}
</Text>
</XStack>
</YStack>
);
}
@@ -1211,7 +1201,6 @@ function InputField({
placeholder,
onChange,
onPicker,
children,
disabled,
}: {
label: string;
@@ -1219,51 +1208,27 @@ function InputField({
placeholder?: string;
onChange: (next: string) => void;
onPicker?: () => void;
children?: React.ReactNode;
disabled?: boolean;
}) {
const { textStrong, border, surface, primary } = useAdminTheme();
const { primary } = useAdminTheme();
return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack
alignItems="center"
borderRadius={12}
borderWidth={1}
borderColor={border}
paddingLeft="$3"
paddingRight="$2"
height={48}
backgroundColor={surface}
space="$2"
>
{children ?? (
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
style={{
flex: 1,
height: '100%',
border: 'none',
outline: 'none',
fontSize: 14,
background: 'transparent',
}}
onFocus={onPicker}
/>
)}
<MobileField label={label}>
<XStack alignItems="center" space="$2">
<MobileInput
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
onFocus={onPicker}
disabled={disabled}
style={{ flex: 1 }}
/>
{onPicker ? (
<Pressable onPress={onPicker} disabled={disabled}>
<ChevronDown size={16} color={primary} />
</Pressable>
) : null}
</XStack>
</YStack>
</MobileField>
);
}

View File

@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { MobileDateTimeInput, MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import {
createEvent,
@@ -401,14 +401,9 @@ export default function MobileEventFormPage() {
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2">
<NativeDateTimeInput
<MobileDateTimeInput
value={form.date}
onChange={(value) => setForm((prev) => ({ ...prev, date: value }))}
border={border}
surface={surface}
text={text}
primary={primary}
danger={danger}
onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))}
style={{ flex: 1 }}
/>
<CalendarDays size={16} color={subtle} />
@@ -624,57 +619,6 @@ function toDateTimeLocal(value?: string | null): string {
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
}
function NativeDateTimeInput({
value,
onChange,
border,
surface,
text,
primary,
danger,
hasError,
style,
}: {
value: string;
onChange: (value: string) => void;
border: string;
surface: string;
text: string;
primary: string;
danger: string;
hasError?: boolean;
style?: React.CSSProperties;
}) {
const [focused, setFocused] = React.useState(false);
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const borderColor = hasError ? danger : focused ? primary : border;
return (
<input
type="datetime-local"
value={value}
onChange={(event) => onChange(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
width: '100%',
height: 44,
padding: '0 12px',
borderRadius: 12,
borderWidth: 1,
borderStyle: 'solid',
borderColor,
backgroundColor: surface,
color: text,
fontSize: 14,
boxShadow: focused ? `0 0 0 3px ${ringColor}` : undefined,
outline: 'none',
...style,
}}
/>
);
}
function resolveLocation(event: TenantEvent): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =

View File

@@ -203,7 +203,7 @@ export default function MobileEventGuestNotificationsPage() {
</MobileCard>
) : null}
<div ref={formRef}>
<YStack ref={formRef}>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('guestMessages.composeTitle', 'Send a message')}
@@ -299,7 +299,7 @@ export default function MobileEventGuestNotificationsPage() {
) : null}
</YStack>
</MobileCard>
</div>
</YStack>
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">

View File

@@ -5,6 +5,7 @@ import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } from 'lucide-r
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { LegalConsentSheet } from './components/LegalConsentSheet';
@@ -315,12 +316,9 @@ function ToggleOption({ label, value, onToggle }: { label: string; value: boolea
<Text fontSize="$sm" color={textStrong} fontWeight="600">
{label}
</Text>
<input
type="checkbox"
checked={value}
onChange={(e) => onToggle(e.target.checked)}
style={{ width: 20, height: 20 }}
/>
<Switch size="$4" checked={value} onCheckedChange={onToggle} aria-label={label}>
<Switch.Thumb />
</Switch>
</XStack>
);
}

View File

@@ -11,6 +11,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { Button } from '@tamagui/button';
import { AlertDialog } from '@tamagui/alert-dialog';
import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
@@ -183,28 +184,34 @@ function SummaryLegendItem({
}
function QuickNavChip({
value,
label,
count,
onPress,
isActive = false,
}: {
value: TaskSectionKey;
label: string;
count: number;
onPress: () => void;
isActive?: boolean;
}) {
const { textStrong, border, surface, surfaceMuted } = useAdminTheme();
const { textStrong, border, surface, surfaceMuted, primary } = useAdminTheme();
const activeBorder = withAlpha(primary, 0.45);
const activeBackground = withAlpha(primary, 0.16);
return (
<Pressable onPress={onPress}>
<XStack
alignItems="center"
space="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
style={{ minHeight: 36 }}
>
<ToggleGroup.Item
value={value}
onPress={onPress}
borderRadius={999}
borderWidth={1}
borderColor={isActive ? activeBorder : border}
backgroundColor={isActive ? activeBackground : surface}
paddingHorizontal="$3"
paddingVertical="$2"
height={36}
>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
{label}
</Text>
@@ -213,7 +220,7 @@ function QuickNavChip({
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
borderColor={isActive ? activeBorder : border}
backgroundColor={surfaceMuted}
>
<Text fontSize={10} fontWeight="800" color={textStrong}>
@@ -221,7 +228,7 @@ function QuickNavChip({
</Text>
</XStack>
</XStack>
</Pressable>
</ToggleGroup.Item>
);
}
@@ -263,6 +270,7 @@ export default function MobileEventTasksPage() {
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
const [quickNavSelection, setQuickNavSelection] = React.useState<TaskSectionKey | ''>('');
const text = textStrong;
const assignedRef = React.useRef<HTMLDivElement>(null);
const libraryRef = React.useRef<HTMLDivElement>(null);
@@ -623,16 +631,34 @@ export default function MobileEventTasksPage() {
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack space="$2" paddingVertical="$1">
{sectionCounts.map((section) => (
<QuickNavChip
key={section.key}
label={t(`events.tasks.sections.${section.key}`, section.key)}
count={section.count}
onPress={() => handleQuickNav(section.key)}
/>
))}
</XStack>
<ToggleGroup
type="single"
value={quickNavSelection}
onValueChange={(next) => {
const key = next as TaskSectionKey | '';
if (!key) {
return;
}
setQuickNavSelection(key);
handleQuickNav(key);
}}
>
<XStack space="$2" paddingVertical="$1">
{sectionCounts.map((section) => (
<QuickNavChip
key={section.key}
value={section.key}
label={t(`events.tasks.sections.${section.key}`, section.key)}
count={section.count}
onPress={() => {
setQuickNavSelection(section.key);
handleQuickNav(section.key);
}}
isActive={quickNavSelection === section.key}
/>
))}
</XStack>
</ToggleGroup>
</ScrollView>
<XStack alignItems="center" space="$2">
@@ -770,7 +796,7 @@ export default function MobileEventTasksPage() {
</YStack>
) : (
<YStack space="$2">
<div ref={assignedRef} />
<YStack ref={assignedRef} />
<Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
</Text>
@@ -822,7 +848,7 @@ export default function MobileEventTasksPage() {
))}
</YGroup>
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
<div ref={libraryRef} />
<YStack ref={libraryRef} />
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.library', 'Weitere Aufgaben')}
</Text>

View File

@@ -1,12 +1,16 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group';
import { Separator } from '@tamagui/separator';
import { useTranslation } from 'react-i18next';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
import { PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
import { MobileInput } from './components/FormControls';
import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants';
@@ -27,7 +31,7 @@ export default function MobileEventsPage() {
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
const searchRef = React.useRef<HTMLInputElement>(null);
const back = useBackNavigation();
const { text, muted, subtle, border, primary, danger, surface, accentSoft, accent } = useAdminTheme();
const { text, muted, subtle, border, primary, danger, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
React.useEffect(() => {
(async () => {
try {
@@ -54,22 +58,61 @@ export default function MobileEventsPage() {
}
>
{error ? (
<MobileCard>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
</Card>
) : null}
<MobileInput
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('events.list.search', 'Search events')}
compact
style={{ marginBottom: 12 }}
/>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.list.filters.title', 'Filters & Search')}
</Text>
</XStack>
<MobileInput
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('events.list.search', 'Search events')}
compact
/>
<Text fontSize="$xs" color={muted}>
{t('events.list.filters.hint', 'Filter your events by status or search by name.')}
</Text>
</YStack>
</Card>
{loading ? (
<YStack space="$2">
@@ -78,15 +121,27 @@ export default function MobileEventsPage() {
))}
</YStack>
) : events.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$3">
<Text fontSize="$md" fontWeight="700">
{t('events.list.empty.title', 'Noch kein Event angelegt')}
</Text>
<Text fontSize="$sm" color={muted} textAlign="center">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
</MobileCard>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2" alignItems="center">
<Text fontSize="$md" fontWeight="700">
{t('events.list.empty.title', 'Noch kein Event angelegt')}
</Text>
<Text fontSize="$sm" color={muted} textAlign="center">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
</YStack>
</Card>
) : (
<EventsList
events={events}
@@ -123,7 +178,7 @@ function EventsList({
onEdit: (slug: string) => void;
}) {
const { t } = useTranslation('management');
const { text, muted, subtle, border, primary, surface, accentSoft, accent } = useAdminTheme();
const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
const activeBg = accentSoft;
const activeBorder = accent;
@@ -150,47 +205,93 @@ function EventsList({
return (
<YStack space="$3">
<XStack space="$2" flexWrap="wrap">
{filters.map((filter) => {
const active = filter.key === statusFilter;
return (
<Pressable key={filter.key} onPress={() => onStatusChange(filter.key)} style={{ flexGrow: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
space="$1.5"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
backgroundColor={active ? activeBg : surface}
borderWidth={1}
borderColor={active ? activeBorder : border}
>
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{filter.label}
</Text>
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.list.filters.status', 'Status')}
</Text>
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<ToggleGroup
type="single"
value={statusFilter}
onValueChange={(value) => value && onStatusChange(value as EventStatusKey)}
>
<XStack space="$2" paddingVertical="$1">
{filters.map((filter) => {
const active = filter.key === statusFilter;
return (
<ToggleGroup.Item
key={filter.key}
value={filter.key}
borderRadius={999}
borderWidth={1}
borderColor={active ? activeBorder : border}
backgroundColor={active ? activeBg : surface}
paddingVertical="$2"
paddingHorizontal="$3"
>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{filter.label}
</Text>
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
</XStack>
</ToggleGroup.Item>
);
})}
</XStack>
</Pressable>
);
})}
</XStack>
</ToggleGroup>
</ScrollView>
</YStack>
</Card>
{filteredEvents.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('events.list.empty.filtered', 'No events match this filter.')}
</Text>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
</Text>
<CTAButton
label={t('events.list.filters.all', 'All')}
tone="ghost"
fullWidth={false}
onPress={() => onStatusChange('all')}
/>
</MobileCard>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2" alignItems="center">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('events.list.empty.filtered', 'No events match this filter.')}
</Text>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
</Text>
<CTAButton
label={t('events.list.filters.all', 'All')}
tone="ghost"
fullWidth={false}
onPress={() => onStatusChange('all')}
/>
</YStack>
</Card>
) : (
filteredEvents.map((event) => {
const statusKey = resolveEventStatusKey(event);
@@ -210,6 +311,8 @@ function EventsList({
subtle={subtle}
border={border}
primary={primary}
surface={surface}
shadow={shadow}
statusLabel={statusLabel}
statusTone={statusTone}
onOpen={onOpen}
@@ -229,6 +332,8 @@ function EventRow({
subtle,
border,
primary,
surface,
shadow,
statusLabel,
statusTone,
onOpen,
@@ -240,6 +345,8 @@ function EventRow({
subtle: string;
border: string;
primary: string;
surface: string;
shadow: string;
statusLabel: string;
statusTone: 'success' | 'warning' | 'muted';
onOpen: (slug: string) => void;
@@ -248,62 +355,77 @@ function EventRow({
const { t } = useTranslation('management');
const stats = buildEventListStats(event);
return (
<MobileCard borderColor={border}>
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{renderName(event.name)}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={14} color={subtle} />
<Text fontSize="$sm" color={muted}>
{formatDate(event.event_date)}
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{renderName(event.name)}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<MapPin size={14} color={subtle} />
<Text fontSize="$sm" color={muted}>
{resolveLocation(event)}
<XStack alignItems="center" space="$2">
<CalendarDays size={14} color={subtle} />
<Text fontSize="$sm" color={muted}>
{formatDate(event.event_date)}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<MapPin size={14} color={subtle} />
<Text fontSize="$sm" color={muted}>
{resolveLocation(event)}
</Text>
</XStack>
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
</YStack>
<Pressable onPress={() => onEdit(event.slug)}>
<Text fontSize="$xl" color={muted}>
˅
</Text>
</XStack>
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
<XStack alignItems="center" space="$2" flexWrap="wrap">
<EventStatChip
icon={Camera}
label={t('events.list.stats.photos', 'Photos')}
value={stats.photos}
muted={subtle}
/>
<EventStatChip
icon={Users}
label={t('events.list.stats.guests', 'Guests')}
value={stats.guests}
muted={subtle}
/>
<EventStatChip
icon={Sparkles}
label={t('events.list.stats.tasks', 'Tasks')}
value={stats.tasks}
muted={subtle}
/>
</XStack>
</YStack>
<Pressable onPress={() => onEdit(event.slug)}>
<Text fontSize="$xl" color={muted}>
˅
</Text>
</Pressable>
</XStack>
<Pressable onPress={() => onOpen(event.slug)} style={{ marginTop: 8 }}>
<XStack alignItems="center" justifyContent="flex-start" space="$2">
<Plus size={16} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('events.list.actions.open', 'Open event')}
</Text>
</Pressable>
</XStack>
</Pressable>
</MobileCard>
<XStack alignItems="center" space="$2" flexWrap="wrap">
<EventStatChip
icon={Camera}
label={t('events.list.stats.photos', 'Photos')}
value={stats.photos}
muted={subtle}
/>
<EventStatChip
icon={Users}
label={t('events.list.stats.guests', 'Guests')}
value={stats.guests}
muted={subtle}
/>
<EventStatChip
icon={Sparkles}
label={t('events.list.stats.tasks', 'Tasks')}
value={stats.tasks}
muted={subtle}
/>
</XStack>
<Separator borderColor={border} />
<Pressable onPress={() => onOpen(event.slug)}>
<XStack alignItems="center" justifyContent="flex-start" space="$2">
<Plus size={16} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('events.list.actions.open', 'Open event')}
</Text>
</XStack>
</Pressable>
</YStack>
</Card>
);
}

View File

@@ -2,12 +2,18 @@ import React from 'react';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card } from '@tamagui/card';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Spinner } from 'tamagui';
import { ADMIN_LOGIN_PATH } from '../constants';
import { useAdminTheme } from './theme';
export default function LoginStartPage(): React.ReactElement {
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation('auth');
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -25,11 +31,34 @@ export default function LoginStartPage(): React.ReactElement {
}, [location.search, navigate]);
return (
<div
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70"
style={safeAreaStyle}
<YStack
alignItems="center"
justifyContent="center"
padding="$4"
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
backgroundColor={surface}
>
<p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p>
</div>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack alignItems="center" space="$2">
<Spinner size="small" color={textStrong} />
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('redirecting', 'Redirecting to login …')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('redirectingHint', 'One moment, preparing the login screen.')}
</Text>
</YStack>
</Card>
</YStack>
);
}

View File

@@ -1,9 +1,15 @@
import React from 'react';
import { Card } from '@tamagui/card';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Spinner } from 'tamagui';
import { useAuth } from '../auth/context';
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
import { useAdminTheme } from './theme';
export default function LogoutPage() {
const { logout } = useAuth();
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -14,13 +20,34 @@ export default function LogoutPage() {
}, [logout]);
return (
<div
className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600"
style={safeAreaStyle}
<YStack
alignItems="center"
justifyContent="center"
padding="$4"
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
backgroundColor={surface}
>
<div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60">
Abmeldung wird vorbereitet ...
</div>
</div>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack alignItems="center" space="$2">
<Spinner size="small" color={textStrong} />
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
Abmeldung wird vorbereitet ...
</Text>
<Text fontSize="$xs" color={muted}>
Bitte einen Moment Geduld.
</Text>
</YStack>
</Card>
</YStack>
);
}

View File

@@ -77,7 +77,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
};
return (
<div style={{ position: 'relative' }}>
<YStack position="relative">
<XStack
alignItems="center"
justifyContent="space-between"
@@ -119,7 +119,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
>
<Pressable onPress={handlePress}>{children}</Pressable>
</motion.div>
</div>
</YStack>
);
}

View File

@@ -1,14 +1,15 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LogOut, User, Settings, Shield, Globe, Moon, Download } from 'lucide-react';
import { User, Settings, Globe, Moon, Download, LogOut } from 'lucide-react';
import { Avatar } from '@tamagui/avatar';
import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api';
@@ -16,6 +17,7 @@ import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '
import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation';
import { withAlpha } from './components/colors';
import { useAdminTheme } from './theme';
export default function MobileProfilePage() {
@@ -23,7 +25,7 @@ export default function MobileProfilePage() {
const navigate = useNavigate();
const { t } = useTranslation('management');
const { appearance, updateAppearance } = useAppearance();
const { textStrong, muted, border, accentSoft, primary, subtle } = useAdminTheme();
const { textStrong, muted, border, accentSoft, primary, subtle, surface, surfaceMuted, shadow, danger } = useAdminTheme();
const textColor = textStrong;
const mutedText = muted;
const borderColor = border;
@@ -54,100 +56,155 @@ export default function MobileProfilePage() {
title={t('mobileProfile.title', 'Profile')}
onBack={back}
>
<MobileCard space="$3" alignItems="center">
<XStack
width={64}
height={64}
borderRadius={20}
alignItems="center"
justifyContent="center"
backgroundColor={avatarBg}
>
<User size={28} color={primary} />
</XStack>
<Text fontSize="$md" fontWeight="800" color={textColor}>
{name}
</Text>
<Text fontSize="$sm" color={mutedText}>
{email}
</Text>
{role ? (
<Text fontSize="$xs" color={mutedText}>
{role}
</Text>
) : null}
</MobileCard>
<Card
borderRadius={22}
borderWidth={2}
borderColor={borderColor}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5" alignItems="center">
<Avatar size="$7" borderRadius={20} backgroundColor={avatarBg}>
<Avatar.Fallback>
<User size={28} color={primary} />
</Avatar.Fallback>
</Avatar>
<YStack space="$0.5" alignItems="center">
<Text fontSize="$md" fontWeight="800" color={textColor}>
{name}
</Text>
<Text fontSize="$sm" color={mutedText}>
{email}
</Text>
{role ? (
<Text fontSize="$xs" color={mutedText}>
{role}
</Text>
) : null}
</YStack>
</YStack>
</Card>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textColor}>
{t('mobileProfile.settings', 'Settings')}
</Text>
<YStack space="$4">
<Card
borderRadius={22}
borderWidth={2}
borderColor={borderColor}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={borderColor}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={textColor}>
{t('mobileProfile.settings', 'Settings')}
</Text>
</XStack>
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
<YGroup.Item>
<Pressable onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.account', 'Account bearbeiten')}
</Text>
}
iconAfter={<Settings size={18} color={subtle} />}
/>
</Pressable>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.account', 'Account bearbeiten')}
</Text>
}
iconAfter={<Settings size={18} color={subtle} />}
onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}
/>
</YGroup.Item>
<YGroup.Item>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('billing.sections.packages.title', 'Packages & Billing')}
</Text>
}
iconAfter={<Settings size={18} color={subtle} />}
/>
</Pressable>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('billing.sections.packages.title', 'Packages & Billing')}
</Text>
}
iconAfter={<Settings size={18} color={subtle} />}
onPress={() => navigate(adminPath('/mobile/billing#packages'))}
/>
</YGroup.Item>
<YGroup.Item>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
}
iconAfter={<Settings size={18} color={subtle} />}
/>
</Pressable>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
}
iconAfter={<Settings size={18} color={subtle} />}
onPress={() => navigate(adminPath('/mobile/billing#invoices'))}
/>
</YGroup.Item>
<YGroup.Item>
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('dataExports.title', 'Data exports')}
</Text>
}
iconAfter={<Download size={18} color={subtle} />}
/>
</Pressable>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('dataExports.title', 'Data exports')}
</Text>
}
iconAfter={<Download size={18} color={subtle} />}
onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}
/>
</YGroup.Item>
</YGroup>
</YStack>
</Card>
<Card
borderRadius={22}
borderWidth={2}
borderColor={borderColor}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={borderColor}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={textColor}>
{t('mobileProfile.preferences', 'Preferences')}
</Text>
</XStack>
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
<YGroup.Item>
<ListItem
paddingVertical="$2"
@@ -205,15 +262,48 @@ export default function MobileProfilePage() {
</YGroup.Item>
</YGroup>
</YStack>
</MobileCard>
</Card>
<CTAButton
label={t('mobileProfile.logout', 'Log out')}
onPress={() => {
logout();
navigate(adminPath('/logout'));
}}
/>
<Card
borderRadius={22}
borderWidth={2}
borderColor={withAlpha(danger, 0.4)}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.12}
shadowRadius={12}
shadowOffset={{ width: 0, height: 6 }}
>
<YStack space="$2">
<XStack alignItems="center" space="$2">
<XStack
width={36}
height={36}
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={withAlpha(danger, 0.12)}
>
<LogOut size={16} color={danger} />
</XStack>
<Text fontSize="$sm" fontWeight="800" color={textColor}>
{t('mobileProfile.logoutTitle', 'Sign out')}
</Text>
</XStack>
<Text fontSize="$xs" color={mutedText}>
{t('mobileProfile.logoutHint', 'Sign out from this device.')}
</Text>
<CTAButton
label={t('mobileProfile.logout', 'Log out')}
tone="danger"
onPress={() => {
logout();
navigate(adminPath('/logout'));
}}
/>
</YStack>
</Card>
</MobileShell>
);
}

View File

@@ -5,12 +5,12 @@ import { ArrowLeft, RefreshCcw, Plus, ChevronDown } 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 { Input, TextArea } from 'tamagui';
import { Accordion } from '@tamagui/accordion';
import { HexColorPicker } from 'react-colorful';
import { Portal } from '@tamagui/portal';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import {
TenantEvent,
EventQrInvite,
@@ -753,7 +753,7 @@ function BackgroundStep({
{presets.map((preset) => {
const isSelected = selectedPreset === preset.id;
return (
<Pressable key={preset.id} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
<Pressable key={preset.id ?? preset.labelKey} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
<YStack
aspectRatio={210 / 297}
maxHeight={220}
@@ -795,7 +795,7 @@ function BackgroundStep({
{t('events.qr.gradients', 'Gradienten')}
</Text>
<XStack flexWrap="wrap" gap="$2">
{gradientPresets.map((gradient, idx) => {
{gradientPresets.map((gradient) => {
const isSelected =
selectedGradient &&
selectedGradient.angle === gradient.angle &&
@@ -803,9 +803,10 @@ function BackgroundStep({
const style = {
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
} as React.CSSProperties;
const key = `${gradient.labelKey}-${gradient.angle}`;
return (
<Pressable
key={idx}
key={key}
onPress={() => onSelectGradient(gradient)}
style={{ width: '30%' }}
aria-label={t(gradient.labelKey, gradient.label)}
@@ -829,10 +830,11 @@ function BackgroundStep({
{t('events.qr.colors', 'Vollfarbe')}
</Text>
<XStack flexWrap="wrap" gap="$2">
{solidPresets.map((color) => {
{solidPresets.map((color, idx) => {
const isSelected = selectedSolid === color;
const key = `${color}-${idx}`;
return (
<Pressable key={color} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}>
<Pressable key={key} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}>
<YStack
height={50}
borderRadius={12}
@@ -912,24 +914,20 @@ function TextStep({
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.textFields', 'Texte')}
</Text>
<Input
<MobileInput
placeholder={t('events.qr.headline', 'Headline')}
value={textFields.headline}
onChangeText={(val) => updateField('headline', val)}
size="$4"
onChange={(event) => updateField('headline', event.target.value)}
/>
<Input
<MobileInput
placeholder={t('events.qr.subtitle', 'Subtitle')}
value={textFields.subtitle}
onChangeText={(val) => updateField('subtitle', val)}
size="$4"
onChange={(event) => updateField('subtitle', event.target.value)}
/>
<TextArea
<MobileTextArea
placeholder={t('events.qr.bottomNote', 'Unterer Hinweistext')}
value={textFields.description}
onChangeText={(val) => updateField('description', val)}
size="$4"
numberOfLines={3}
onChange={(event) => updateField('description', event.target.value)}
/>
</YStack>
@@ -939,13 +937,12 @@ function TextStep({
</Text>
{textFields.instructions.map((item, idx) => (
<XStack key={idx} alignItems="center" space="$2">
<TextArea
<MobileTextArea
value={item}
onChangeText={(val) => updateInstruction(idx, val)}
onChange={(event) => updateInstruction(idx, event.target.value)}
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
numberOfLines={2}
{...({ flex: 1 } as any)}
size="$4"
style={{ flex: 1 }}
compact
/>
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
<XStack
@@ -1204,36 +1201,13 @@ function LayoutControls({
}) {
const { t } = useTranslation('management');
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
const { border, surface, surfaceMuted, text, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
const { border, surface, surfaceMuted, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
const fontOptions = React.useMemo(() => {
const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora'];
const tenant = tenantFonts.map((font) => font.family);
return Array.from(new Set([...tenant, ...preset]));
}, [tenantFonts]);
const numberInputStyle: React.CSSProperties = {
width: 90,
padding: '10px 12px',
border: `1px solid ${border}`,
borderRadius: 12,
fontSize: 14,
fontFamily: DEFAULT_BODY_FONT,
background: surfaceMuted,
color: text,
appearance: 'textfield',
};
const selectStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
border: `1px solid ${border}`,
borderRadius: 12,
fontSize: 14,
fontFamily: DEFAULT_BODY_FONT,
background: surfaceMuted,
color: text,
};
const StepperInput = ({
value,
min,
@@ -1260,11 +1234,9 @@ function LayoutControls({
</Text>
</XStack>
</Pressable>
<input
<MobileInput
type="text"
inputMode="decimal"
min={min}
max={max}
value={formatValue(value)}
onChange={(event) => {
const next = Number(event.target.value);
@@ -1272,7 +1244,13 @@ function LayoutControls({
onChange(next);
}
}}
style={numberInputStyle}
compact
style={{
width: 90,
textAlign: 'center',
fontFamily: DEFAULT_BODY_FONT,
backgroundColor: surfaceMuted,
}}
/>
<Pressable onPress={inc}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
@@ -1395,10 +1373,9 @@ function LayoutControls({
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontFamily', 'Font Family')}
</Text>
<select
<MobileSelect
value={override.fontFamily ?? slot.fontFamily ?? ''}
onChange={(event) => onFontFamilyChange(event.target.value)}
style={selectStyle}
>
<option value="">{t('events.qr.defaultFont', 'Standard')}</option>
{fontOptions.map((family) => (
@@ -1406,7 +1383,7 @@ function LayoutControls({
{family}
</option>
))}
</select>
</MobileSelect>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
@@ -1424,12 +1401,12 @@ function LayoutControls({
backgroundColor={override.color ?? slot.color ?? textStrong}
/>
</Pressable>
<input
type="text"
<MobileInput
value={override.color ?? slot.color ?? ''}
placeholder={ADMIN_COLORS.text}
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
style={{ ...numberInputStyle, width: 110 }}
compact
style={{ width: 110, backgroundColor: surfaceMuted }}
/>
</XStack>
{openColorSlot === slotKey ? (
@@ -1496,15 +1473,14 @@ function LayoutControls({
<Text fontSize="$xs" color={muted}>
{t('events.qr.align', 'Align')}
</Text>
<select
<MobileSelect
value={override.align ?? slot.align ?? 'left'}
onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })}
style={selectStyle}
>
<option value="left">{t('common.left', 'Links')}</option>
<option value="center">{t('common.center', 'Zentriert')}</option>
<option value="right">{t('common.right', 'Rechts')}</option>
</select>
</MobileSelect>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>

View File

@@ -7,6 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileInput, MobileTextArea } from './components/FormControls';
import {
TenantEvent,
EventQrInvite,
@@ -489,20 +490,20 @@ function TextStep({
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.textFields', 'Texte')}
</Text>
<StyledInput
<MobileInput
placeholder={t('events.qr.headline', 'Headline')}
value={textFields.headline}
onChangeText={(val) => updateField('headline', val)}
onChange={(event) => updateField('headline', event.target.value)}
/>
<StyledInput
<MobileInput
placeholder={t('events.qr.subtitle', 'Subtitle')}
value={textFields.subtitle}
onChangeText={(val) => updateField('subtitle', val)}
onChange={(event) => updateField('subtitle', event.target.value)}
/>
<StyledTextarea
<MobileTextArea
placeholder={t('events.qr.description', 'Beschreibung')}
value={textFields.description}
onChangeText={(val) => updateField('description', val)}
onChange={(event) => updateField('description', event.target.value)}
/>
</YStack>
@@ -512,11 +513,11 @@ function TextStep({
</Text>
{textFields.instructions.map((item, idx) => (
<XStack key={idx} alignItems="center" space="$2">
<StyledInput
flex={1}
<MobileInput
style={{ flex: 1 }}
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
value={item}
onChangeText={(val) => updateInstruction(idx, val)}
onChange={(event) => updateInstruction(idx, event.target.value)}
/>
<CTAButton
label=""
@@ -651,60 +652,3 @@ function PreviewStep({
</YStack>
);
}
function StyledInput({
value,
onChangeText,
placeholder,
flex,
}: {
value: string;
onChangeText: (value: string) => void;
placeholder?: string;
flex?: number;
}) {
const { border } = useAdminTheme();
return (
<input
style={{
width: '100%',
padding: '10px 12px',
borderRadius: 12,
border: `1px solid ${border}`,
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;
}) {
const { border } = useAdminTheme();
return (
<textarea
style={{
width: '100%',
padding: '10px 12px',
borderRadius: 12,
border: `1px solid ${border}`,
fontSize: 14,
outline: 'none',
minHeight: 96,
}}
value={value}
onChange={(e) => onChangeText(e.target.value)}
placeholder={placeholder}
/>
);
}

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { CTAButton } from './components/Primitives';
import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants';
@@ -15,7 +17,7 @@ export default function MobileUploadsTabPage() {
const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
const { t, i18n } = useTranslation('management');
const navigate = useNavigate();
const { text, muted, border, primary } = useAdminTheme();
const { text, muted, border, primary, surface, surfaceMuted, shadow } = useAdminTheme();
if (activeEvent?.slug) {
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/control-room`)} replace />;
@@ -24,18 +26,43 @@ export default function MobileUploadsTabPage() {
if (!hasEvents) {
return (
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
<MobileCard alignItems="flex-start" space="$3">
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('mobileUploads.emptyTitle', 'Create an event first')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('mobileUploads.emptyBody', 'Add your first event to review uploads and manage QR sharing.')}
</Text>
<CTAButton
label={t('mobileDashboard.ctaCreate', 'Create event')}
onPress={() => navigate(adminPath('/mobile/events/new'))}
/>
</MobileCard>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobileUploads.emptyBadge', 'No uploads yet')}
</Text>
</XStack>
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('mobileUploads.emptyTitle', 'Create an event first')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('mobileUploads.emptyBody', 'Add your first event to review uploads and manage QR sharing.')}
</Text>
<CTAButton
label={t('mobileDashboard.ctaCreate', 'Create event')}
onPress={() => navigate(adminPath('/mobile/events/new'))}
/>
</YStack>
</Card>
</MobileShell>
);
}
@@ -44,38 +71,66 @@ export default function MobileUploadsTabPage() {
return (
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
<YStack space="$2">
<Text fontSize="$sm" color={text} fontWeight="700">
{t('mobileUploads.pickEvent', 'Pick an event to manage uploads')}
</Text>
{events.map((event) => (
<Pressable
key={event.slug}
onPress={() => {
selectEvent(event.slug ?? null);
if (event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
}
}}
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<MobileCard borderColor={border} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
</Text>
</YStack>
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('mobileUploads.open', 'Open')}
</Text>
</XStack>
</MobileCard>
</Pressable>
))}
</YStack>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobileUploads.pickEvent', 'Pick an event to manage uploads')}
</Text>
</XStack>
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: border, overflow: "hidden" } as any)}>
{events.map((event) => (
<YGroup.Item key={event.slug}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
</Text>
</YStack>
}
iconAfter={
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('mobileUploads.open', 'Open')}
</Text>
}
onPress={() => {
selectEvent(event.slug ?? null);
if (event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
}
}}
/>
</YGroup.Item>
))}
</YGroup>
</YStack>
</Card>
</MobileShell>
);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'loading' }),
}));
vi.mock('../../lib/returnTo', () => ({
decodeReturnTo: () => null,
resolveReturnTarget: () => ({ finalTarget: '/event-admin/mobile/dashboard' }),
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('tamagui', () => ({
Spinner: () => <div>spinner</div>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
muted: '#6b7280',
border: '#e5e7eb',
surface: '#ffffff',
shadow: 'rgba(15,23,42,0.12)',
appBackground: 'linear-gradient(180deg, #fff, #f8fafc)',
}),
}));
import AuthCallbackPage from '../AuthCallbackPage';
describe('AuthCallbackPage', () => {
it('renders the processing message', () => {
render(<AuthCallbackPage />);
expect(screen.getByText('Signing you in …')).toBeInTheDocument();
});
});

View File

@@ -38,6 +38,21 @@ vi.mock('../components/Primitives', () => ({
SkeletonCard: () => <div>Loading...</div>,
}));
vi.mock('../components/FormControls', () => ({
MobileField: ({ label, children }: { label: string; children: React.ReactNode }) => (
<div>
<span>{label}</span>
{children}
</div>
),
MobileColorInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileFileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) => (
<select {...props}>{children}</select>
),
}));
vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

View File

@@ -45,6 +45,7 @@ vi.mock('../components/Primitives', () => ({
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileDateTimeInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useParams: () => ({ slug: 'demo-event' }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
i18n: { language: 'de' },
}),
}));
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
activeEvent: { slug: 'demo-event' },
selectEvent: vi.fn(),
}),
}));
vi.mock('../../api', () => ({
getEvents: vi.fn().mockResolvedValue([]),
listGuestNotifications: vi.fn().mockResolvedValue([]),
sendGuestNotification: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('../../auth/tokens', () => ({
isAuthError: () => false,
}));
vi.mock('../../lib/apiError', () => ({
getApiErrorMessage: () => 'error',
}));
vi.mock('../guestMessages', () => ({
formatGuestMessageDate: () => '19. Feb. 2026',
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => undefined,
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SkeletonCard: () => <div>Loading...</div>,
}));
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
text: '#111827',
muted: '#6b7280',
border: '#e5e7eb',
danger: '#dc2626',
}),
}));
import MobileEventGuestNotificationsPage from '../EventGuestNotificationsPage';
describe('MobileEventGuestNotificationsPage', () => {
it('renders the compose section', async () => {
render(<MobileEventGuestNotificationsPage />);
expect(await screen.findByText('Send a message')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,143 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const fixtures = vi.hoisted(() => ({
event: {
id: 1,
name: 'Demo Event',
slug: 'demo-event',
event_date: '2026-02-19',
status: 'published',
settings: {
guest_downloads_enabled: true,
guest_sharing_enabled: false,
},
},
stats: {
photo_count: 12,
like_count: 3,
pending_count: 1,
guest_count: 22,
},
invites: [],
addons: [],
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ slug: fixtures.event.slug }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => undefined,
}));
vi.mock('../../api', () => ({
getEvent: vi.fn().mockResolvedValue(fixtures.event),
getEventStats: vi.fn().mockResolvedValue(fixtures.stats),
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
getAddonCatalog: vi.fn().mockResolvedValue(fixtures.addons),
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
createEventAddonCheckout: vi.fn(),
}));
vi.mock('../../auth/tokens', () => ({
isAuthError: () => false,
}));
vi.mock('../../lib/apiError', () => ({
getApiErrorMessage: () => 'error',
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SkeletonCard: () => <div>Loading...</div>,
}));
vi.mock('../components/LegalConsentSheet', () => ({
LegalConsentSheet: () => <div />,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({ children, checked, onCheckedChange, 'aria-label': ariaLabel }: any) => (
<label>
<input
type="checkbox"
aria-label={ariaLabel}
checked={checked}
onChange={(event) => onCheckedChange?.(event.target.checked)}
/>
{children}
</label>
),
{ Thumb: () => <span /> },
),
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
text: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
border: '#e5e7eb',
primary: '#ff5a5f',
successText: '#16a34a',
danger: '#dc2626',
surface: '#ffffff',
surfaceMuted: '#f9fafb',
accentSoft: '#eef2ff',
textStrong: '#0f172a',
}),
}));
import MobileEventRecapPage from '../EventRecapPage';
describe('MobileEventRecapPage', () => {
it('renders recap settings toggles', async () => {
render(<MobileEventRecapPage />);
expect(await screen.findByText('Nachlauf-Optionen')).toBeInTheDocument();
expect(screen.getByLabelText('Gäste dürfen Fotos laden')).toBeInTheDocument();
expect(screen.getByLabelText('Gäste dürfen Fotos teilen')).toBeInTheDocument();
});
});

View File

@@ -89,6 +89,12 @@ vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/toggle-group', () => ({
ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../../api', () => ({
getEvents: vi.fn().mockResolvedValue([
{
id: 1,
name: 'Demo Event',
slug: 'demo-event',
event_date: '2026-02-19',
settings: {},
},
]),
}));
vi.mock('../../auth/tokens', () => ({
isAuthError: () => false,
}));
vi.mock('../../lib/apiError', () => ({
getApiErrorMessage: () => 'error',
}));
vi.mock('../../lib/eventFilters', () => ({
buildEventStatusCounts: () => ({ all: 1, upcoming: 1, draft: 0, past: 0 }),
filterEventsByStatus: (events: any[]) => events,
resolveEventStatusKey: () => 'upcoming',
}));
vi.mock('../../lib/eventListStats', () => ({
buildEventListStats: () => ({ photos: 2, guests: 3, tasks: 4 }),
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => undefined,
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
FloatingActionButton: ({ label }: { label: string }) => <div>{label}</div>,
SkeletonCard: () => <div>Loading...</div>,
}));
vi.mock('../components/FormControls', () => ({
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('@tamagui/toggle-group', () => ({
ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/separator', () => ({
Separator: () => <div />,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
text: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
border: '#e5e7eb',
primary: '#ff5a5f',
danger: '#dc2626',
surface: '#ffffff',
surfaceMuted: '#f9fafb',
accentSoft: '#eef2ff',
accent: '#6366f1',
shadow: 'rgba(15,23,42,0.12)',
}),
}));
import MobileEventsPage from '../EventsPage';
describe('MobileEventsPage', () => {
it('renders filters and event list', async () => {
render(<MobileEventsPage />);
expect(await screen.findByText('Filters & Search')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
expect(screen.getByText('Demo Event')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useLocation: () => ({ search: '' }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('tamagui', () => ({
Spinner: () => <div>spinner</div>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
muted: '#6b7280',
border: '#e5e7eb',
surface: '#ffffff',
shadow: 'rgba(15,23,42,0.12)',
appBackground: 'linear-gradient(180deg, #fff, #f8fafc)',
}),
}));
import LoginStartPage from '../LoginStartPage';
describe('LoginStartPage', () => {
it('renders the redirect message', () => {
render(<LoginStartPage />);
expect(screen.getByText('Redirecting to login …')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const logoutMock = vi.fn();
vi.mock('../../auth/context', () => ({
useAuth: () => ({
logout: logoutMock,
}),
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('tamagui', () => ({
Spinner: () => <div>spinner</div>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
muted: '#6b7280',
border: '#e5e7eb',
surface: '#ffffff',
shadow: 'rgba(15,23,42,0.12)',
appBackground: 'linear-gradient(180deg, #fff, #f8fafc)',
}),
}));
import LogoutPage from '../LogoutPage';
describe('LogoutPage', () => {
it('renders the logout message', () => {
render(<LogoutPage />);
expect(screen.getByText('Abmeldung wird vorbereitet ...')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,129 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useParams: () => ({}),
useLocation: () => ({ search: '' }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../../api', () => ({
listNotificationLogs: vi.fn().mockResolvedValue([]),
markNotificationLogs: vi.fn(),
getEvents: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../auth/tokens', () => ({
isAuthError: () => false,
}));
vi.mock('../../lib/apiError', () => ({
getApiErrorMessage: () => 'error',
}));
vi.mock('../lib/notificationGrouping', () => ({
groupNotificationsByScope: () => [],
}));
vi.mock('../lib/notificationUnread', () => ({
collectUnreadIds: () => [],
}));
vi.mock('../lib/relativeTime', () => ({
formatRelativeTime: () => 'now',
}));
vi.mock('../lib/haptics', () => ({
triggerHaptic: () => undefined,
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => undefined,
}));
vi.mock('framer-motion', () => ({
motion: {
div: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
useAnimationControls: () => ({ start: vi.fn() }),
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SkeletonCard: () => <div>Loading...</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
}));
vi.mock('../components/FormControls', () => ({
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
}));
vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
text: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
border: '#e5e7eb',
primary: '#ff5a5f',
danger: '#dc2626',
successBg: '#dcfce7',
successText: '#166534',
infoBg: '#e0e7ff',
infoText: '#3730a3',
}),
}));
import MobileNotificationsPage from '../NotificationsPage';
describe('MobileNotificationsPage', () => {
it('shows empty state when no notifications are available', async () => {
render(<MobileNotificationsPage />);
expect(await screen.findByText('All caught up')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const logoutMock = vi.fn();
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../../i18n', () => ({
default: {
language: 'de',
changeLanguage: vi.fn(),
},
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({
user: { name: 'Demo User', email: 'demo@example.com', role: 'tenant_admin' },
logout: logoutMock,
}),
}));
vi.mock('../../api', () => ({
fetchTenantProfile: vi.fn().mockResolvedValue({
name: 'Demo User',
email: 'demo@example.com',
role: 'tenant_admin',
}),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({
appearance: 'system',
updateAppearance: vi.fn(),
}),
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => undefined,
}));
vi.mock('@tamagui/avatar', () => ({
Avatar: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Fallback: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title, iconAfter }: { title?: React.ReactNode; iconAfter?: React.ReactNode }) => (
<div>
{title}
{iconAfter}
</div>
),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
}));
vi.mock('../components/FormControls', () => ({
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
}));
vi.mock('../components/colors', () => ({
withAlpha: (color: string) => color,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
muted: '#6b7280',
border: '#e5e7eb',
accentSoft: '#eef2ff',
primary: '#ff5a5f',
subtle: '#94a3b8',
surface: '#ffffff',
surfaceMuted: '#f9fafb',
shadow: 'rgba(15,23,42,0.12)',
danger: '#dc2626',
}),
}));
import MobileProfilePage from '../ProfilePage';
describe('MobileProfilePage', () => {
it('renders settings and preferences sections', async () => {
render(<MobileProfilePage />);
expect(await screen.findByText('Settings')).toBeInTheDocument();
expect(screen.getByText('Preferences')).toBeInTheDocument();
expect(screen.getByText('Log out')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,173 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const fixtures = vi.hoisted(() => ({
event: {
id: 1,
name: 'Demo Event',
slug: 'demo-event',
},
invites: [
{
id: 1,
url: 'https://example.test/guest/demo-event',
qr_code_data_url: '',
layouts: [
{
id: 'layout-1',
name: 'Poster Layout',
description: 'Layout description',
paper: 'a4',
orientation: 'portrait',
panel_mode: 'single',
},
],
},
],
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ slug: fixtures.event.slug, tokenId: '1' }),
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => undefined,
}));
vi.mock('../../api', () => ({
getEvent: vi.fn().mockResolvedValue(fixtures.event),
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
updateEventQrInvite: vi.fn(),
}));
vi.mock('../../auth/tokens', () => ({
isAuthError: () => false,
}));
vi.mock('../../lib/apiError', () => ({
getApiErrorMessage: () => 'error',
}));
vi.mock('../../lib/fonts', () => ({
useTenantFonts: () => ({ fonts: [] }),
ensureFontLoaded: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('./invite-layout/export-utils', () => ({
generatePdfBytes: vi.fn(),
generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'),
triggerDownloadFromBlob: vi.fn(),
triggerDownloadFromDataUrl: vi.fn(),
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/FormControls', () => ({
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) => (
<select {...props}>{children}</select>
),
}));
vi.mock('@tamagui/accordion', () => ({
Accordion: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Trigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
),
}));
vi.mock('@tamagui/portal', () => ({
Portal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('../theme', () => ({
ADMIN_COLORS: {
primary: '#ff5a5f',
accent: '#6d28d9',
text: '#0f172a',
backdrop: '#0f172a',
},
ADMIN_GRADIENTS: {
softCard: 'linear-gradient(180deg, #fff, #f3f4f6)',
},
useAdminTheme: () => ({
textStrong: '#0f172a',
muted: '#64748b',
border: '#e2e8f0',
primary: '#ff5a5f',
accentSoft: '#f5f3ff',
successText: '#16a34a',
surface: '#ffffff',
surfaceMuted: '#f8fafc',
overlay: 'rgba(15, 23, 42, 0.5)',
shadow: 'rgba(15, 23, 42, 0.2)',
danger: '#dc2626',
}),
}));
import MobileQrLayoutCustomizePage from '../QrLayoutCustomizePage';
describe('MobileQrLayoutCustomizePage', () => {
it('renders the layout customize stepper', async () => {
render(<MobileQrLayoutCustomizePage />);
expect(await screen.findByText('Hintergrund')).toBeInTheDocument();
expect(screen.getByText('Text')).toBeInTheDocument();
expect(screen.getByText('Vorschau')).toBeInTheDocument();
});
});

View File

@@ -2,77 +2,69 @@ import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const backMock = vi.fn();
const navigateMock = vi.fn();
const fixtures = vi.hoisted(() => ({
event: {
id: 1,
name: 'Demo Event',
slug: 'demo-event',
public_url: 'https://example.test/guest/demo-event',
},
invites: [
{
id: 10,
url: 'https://example.test/guest/demo-event',
qr_code_data_url: 'data:image/png;base64,abc',
is_active: true,
layouts: [
{
id: 'layout-1',
panel_mode: 'single',
orientation: 'portrait',
},
],
},
],
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useParams: () => ({ slug: 'demo-event' }),
useNavigate: () => vi.fn(),
useParams: () => ({ slug: fixtures.event.slug }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => backMock,
useBackNavigation: () => undefined,
}));
vi.mock('../../api', () => ({
getEvent: vi.fn().mockResolvedValue({ slug: 'demo-event', name: 'Demo' }),
getEventQrInvites: vi.fn().mockResolvedValue([
{
id: 1,
token: 'demo-token',
url: 'https://example.test/g/demo-token',
label: null,
qr_code_data_url: 'data:image/png;base64,abc',
usage_limit: null,
usage_count: 0,
expires_at: '2026-01-12T12:00:00Z',
revoked_at: null,
is_active: true,
created_at: null,
metadata: {},
layouts_url: null,
layouts: [
{
id: 'layout-1',
name: 'Poster',
description: '',
subtitle: '',
formats: [],
preview: {
background: null,
background_gradient: null,
accent: null,
text: null,
},
paper: 'a4',
orientation: 'portrait',
panel_mode: 'single',
},
],
},
]),
getEvent: vi.fn().mockResolvedValue(fixtures.event),
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
createQrInvite: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
vi.mock('../../auth/tokens', () => ({
isAuthError: () => false,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
vi.mock('../../lib/apiError', () => ({
getApiErrorMessage: () => 'error',
}));
vi.mock('./qr/utils', () => ({
resolveLayoutForFormat: () => 'layout-1',
}));
vi.mock('../components/MobileShell', () => ({
@@ -82,14 +74,15 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{label}
</button>
),
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/FormControls', () => ({
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -99,31 +92,37 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
text: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
border: '#e5e7eb',
surfaceMuted: '#fffdfb',
primary: '#ff5a5f',
danger: '#b91c1c',
accentSoft: '#ffe5ec',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
danger: '#dc2626',
surface: '#ffffff',
surfaceMuted: '#f9fafb',
accentSoft: '#eef2ff',
textStrong: '#0f172a',
}),
}));
import MobileQrPrintPage from '../QrPrintPage';
describe('MobileQrPrintPage', () => {
it('shows token expiry info when the invite has expires_at', async () => {
it('renders QR overview content', async () => {
render(<MobileQrPrintPage />);
expect(await screen.findByText('events.qr.expiresAt')).toBeInTheDocument();
expect(await screen.findByText('Entrance QR Code')).toBeInTheDocument();
expect(screen.getByText('Schritt 1: Format wählen')).toBeInTheDocument();
expect(screen.getAllByText('Neuen QR-Link erstellen').length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
Navigate: ({ to }: { to: string }) => <div>{to}</div>,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
i18n: { language: 'de' },
}),
}));
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
events: [{ slug: 'demo-event', event_date: '2026-02-19' }],
activeEvent: null,
hasEvents: true,
selectEvent: vi.fn(),
}),
}));
vi.mock('../../lib/events', () => ({
resolveEventDisplayName: () => 'Demo Event',
formatEventDate: () => '19. Feb. 2026',
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title, iconAfter }: { title?: React.ReactNode; iconAfter?: React.ReactNode }) => (
<div>
{title}
{iconAfter}
</div>
),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
text: '#111827',
muted: '#6b7280',
border: '#e5e7eb',
primary: '#ff5a5f',
surface: '#ffffff',
surfaceMuted: '#f9fafb',
shadow: 'rgba(15,23,42,0.12)',
}),
}));
import MobileUploadsTabPage from '../UploadsTabPage';
describe('MobileUploadsTabPage', () => {
it('renders the event picker list', () => {
render(<MobileUploadsTabPage />);
expect(screen.getByText('Pick an event to manage uploads')).toBeInTheDocument();
expect(screen.getByText('Demo Event')).toBeInTheDocument();
expect(screen.getByText('Open')).toBeInTheDocument();
});
});

View File

@@ -60,7 +60,7 @@ vi.mock('@tamagui/select', () => {
return { Select };
});
import { MobileSelect } from './FormControls';
import { MobileColorInput, MobileDateTimeInput, MobileFileInput, MobileSelect } from './FormControls';
describe('MobileSelect', () => {
it('maps options and forwards selection changes', () => {
@@ -83,3 +83,30 @@ describe('MobileSelect', () => {
);
});
});
describe('MobileColorInput', () => {
it('renders a color input with default sizing', () => {
render(<MobileColorInput value="#ff0000" onChange={vi.fn()} />);
const input = screen.getByDisplayValue('#ff0000');
expect(input).toHaveAttribute('type', 'color');
});
});
describe('MobileFileInput', () => {
it('renders a hidden file input', () => {
render(<MobileFileInput data-testid="file-input" />);
const input = screen.getByTestId('file-input');
expect(input).toHaveAttribute('type', 'file');
});
});
describe('MobileDateTimeInput', () => {
it('renders a datetime-local input', () => {
render(<MobileDateTimeInput value="2024-10-20T14:30" onChange={vi.fn()} />);
const input = screen.getByDisplayValue('2024-10-20T14:30');
expect(input).toHaveAttribute('type', 'datetime-local');
});
});

View File

@@ -47,6 +47,86 @@ type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps
containerStyle?: React.CSSProperties;
};
export const MobileColorInput = React.forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<'input'> & { size?: number }
>(function MobileColorInput({ size = 52, style, ...props }, ref) {
const { border, surface } = useAdminTheme();
return (
<input
ref={ref}
type="color"
{...props}
style={{
width: size,
height: size,
borderRadius: 12,
border: `1px solid ${border}`,
background: surface,
padding: 0,
...style,
}}
/>
);
});
export const MobileFileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'>>(
function MobileFileInput({ style, ...props }, ref) {
return (
<input
ref={ref}
type="file"
{...props}
style={{
display: 'none',
...style,
}}
/>
);
},
);
export const MobileDateTimeInput = React.forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<'input'> & ControlProps
>(function MobileDateTimeInput({ hasError = false, style, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const borderColor = hasError ? danger : border;
return (
<input
ref={ref}
type="datetime-local"
{...props}
style={{
width: '100%',
height: 44,
padding: '0 12px',
borderRadius: 12,
borderWidth: 1,
borderStyle: 'solid',
borderColor,
backgroundColor: surface,
color: text,
fontSize: 14,
outline: 'none',
boxShadow: `0 0 0 0 ${ringColor}`,
...style,
}}
onFocus={(event) => {
event.currentTarget.style.boxShadow = `0 0 0 3px ${ringColor}`;
props.onFocus?.(event);
}}
onBlur={(event) => {
event.currentTarget.style.boxShadow = `0 0 0 0 ${ringColor}`;
props.onBlur?.(event);
}}
/>
);
});
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Checkbox } from '@tamagui/checkbox';
import { Check } from 'lucide-react';
import { MobileSheet } from './Sheet';
import { CTAButton } from './Primitives';
import { useAdminTheme } from '../theme';
@@ -41,17 +43,6 @@ export function LegalConsentSheet({
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const checkboxStyle = {
marginTop: 4,
width: 18,
height: 18,
accentColor: primary,
backgroundColor: surface,
border: `1px solid ${border}`,
borderRadius: 4,
appearance: 'auto',
WebkitAppearance: 'auto',
} as any;
React.useEffect(() => {
if (open) {
@@ -111,36 +102,60 @@ export function LegalConsentSheet({
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
</Text>
{requireTerms ? (
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<input
type="checkbox"
<XStack space="$3" alignItems="flex-start">
<Checkbox
id="legal-terms"
size="$4"
checked={acceptedTerms}
onChange={(event) => setAcceptedTerms(event.target.checked)}
style={checkboxStyle}
/>
<Text fontSize="$sm" color={text}>
onCheckedChange={(checked) => setAcceptedTerms(Boolean(checked))}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
>
<Checkbox.Indicator>
<Check size={14} color={primary} />
</Checkbox.Indicator>
</Checkbox>
<Text
fontSize="$sm"
color={text}
onPress={() => setAcceptedTerms((prev) => !prev)}
flex={1}
>
{copy?.checkboxTerms ?? t(
'events.legalConsent.checkboxTerms',
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
)}
</Text>
</label>
</XStack>
) : null}
{requireWaiver ? (
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<input
type="checkbox"
<XStack space="$3" alignItems="flex-start">
<Checkbox
id="legal-waiver"
size="$4"
checked={acceptedWaiver}
onChange={(event) => setAcceptedWaiver(event.target.checked)}
style={checkboxStyle}
/>
<Text fontSize="$sm" color={text}>
onCheckedChange={(checked) => setAcceptedWaiver(Boolean(checked))}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
>
<Checkbox.Indicator>
<Check size={14} color={primary} />
</Checkbox.Indicator>
</Checkbox>
<Text
fontSize="$sm"
color={text}
onPress={() => setAcceptedWaiver((prev) => !prev)}
flex={1}
>
{copy?.checkboxWaiver ?? t(
'events.legalConsent.checkboxWaiver',
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
)}
</Text>
</label>
</XStack>
) : null}
</YStack>
</MobileSheet>

View File

@@ -23,6 +23,23 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
}));
vi.mock('@tamagui/checkbox', () => ({
Checkbox: Object.assign(
({ children, checked, onCheckedChange, id }: any) => (
<label>
<input
type="checkbox"
id={id}
checked={checked}
onChange={(event) => onCheckedChange?.(event.target.checked)}
/>
{children}
</label>
),
{ Indicator: ({ children }: { children: React.ReactNode }) => <span>{children}</span> },
),
}));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;

View File

@@ -29,7 +29,7 @@ i18n
},
backend: {
// Cache-bust to ensure fresh translations when files change.
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250116',
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250212',
},
react: {
useSuspense: true,

View File

@@ -61,7 +61,7 @@ export default function AuthSimpleLayout({ children, title, description, name, l
<div className="space-y-4">
<h2 className="font-display text-3xl leading-tight sm:text-4xl">
{t('login.hero_heading', 'Willkommen zurück bei Fotospiel')}
{t('login.hero_heading', 'Willkommen zurück bei der Fotospiel App')}
</h2>
<p className="text-sm text-white/80 sm:text-base">
{t('login.hero_subheading', 'Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.')}

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('@/layouts/auth-layout', () => ({
default: ({ children, title, description }: { children: React.ReactNode; title: string; description: string }) => (
<div>
<h1>{title}</h1>
<p>{description}</p>
{children}
</div>
),
}));
vi.mock('@/actions/App/Http/Controllers/Auth/PasswordResetLinkController', () => ({
store: {
form: () => ({}),
},
}));
vi.mock('@inertiajs/react', () => ({
Form: ({ children }: { children: (props: { processing: boolean; errors: Record<string, string[]> }) => React.ReactNode }) => (
<form>{children({ processing: false, errors: {} })}</form>
),
Head: () => null,
}));
vi.mock('@/routes', () => ({
login: () => '/login',
}));
vi.mock('@/components/input-error', () => ({
default: () => null,
}));
vi.mock('@/components/text-link', () => ({
default: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@/components/ui/button', () => ({
Button: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
}));
vi.mock('@/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
vi.mock('@/components/ui/label', () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
import ForgotPassword from '../forgot-password';
describe('ForgotPassword', () => {
it('renders the translated copy', () => {
render(<ForgotPassword />);
expect(screen.getByText('Forgot password')).toBeInTheDocument();
expect(screen.getByText('Enter your email to receive a password reset link')).toBeInTheDocument();
expect(screen.getByText('Email password reset link')).toBeInTheDocument();
expect(screen.getByText('Or, return to')).toBeInTheDocument();
expect(screen.getByText('Login')).toBeInTheDocument();
});
});

View File

@@ -15,8 +15,14 @@ import AuthLayout from '@/layouts/auth-layout';
export default function ForgotPassword({ status }: { status?: string }) {
const { t } = useTranslation('auth');
return (
<AuthLayout title={t('auth.forgot.title', 'Forgot password')} description={t('auth.forgot.description', 'Enter your email to receive a password reset link')}>
<Head title={t('auth.forgot.title', 'Forgot password')} />
<AuthLayout
title={t('forgot.title', 'Forgot password')}
description={t('forgot.description', 'Enter your email to receive a password reset link')}
name={t('login.brand', 'Die Fotospiel App')}
logoSrc="/logo-transparent-md.png"
logoAlt={t('login.logo_alt', 'Logo Die Fotospiel App')}
>
<Head title={t('forgot.title', 'Forgot password')} />
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
@@ -25,8 +31,15 @@ export default function ForgotPassword({ status }: { status?: string }) {
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input id="email" type="email" name="email" autoComplete="off" autoFocus placeholder={t('auth.forgot.email_placeholder')} />
<Label htmlFor="email">{t('forgot.email_label', 'Email address')}</Label>
<Input
id="email"
type="email"
name="email"
autoComplete="off"
autoFocus
placeholder={t('forgot.email_placeholder', 'name@example.com')}
/>
<InputError message={errors.email} />
</div>
@@ -34,7 +47,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
<div className="my-6 flex items-center justify-start">
<Button className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Email password reset link
{t('forgot.submit', 'Email password reset link')}
</Button>
</div>
</>
@@ -42,8 +55,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
</Form>
<div className="space-x-1 text-center text-sm text-muted-foreground">
<span>Or, return to</span>
<TextLink href={login()}>{t('auth.forgot.back')}</TextLink>
<span>{t('forgot.back_prefix', 'Or, return to')}</span>
<TextLink href={login()}>{t('forgot.back', 'Login')}</TextLink>
</div>
</div>
</AuthLayout>

View File

@@ -10,19 +10,44 @@
"register": "Registrieren",
"home": "Startseite",
"packages": "Pakete",
"how_it_works": "So geht's",
"blog": "Blog",
"occasions": {
"label": "Anlässe",
"wedding": "Hochzeit",
"birthday": "Geburtstag",
"confirmation": "Konfirmation/Jugendweihe",
"corporate": "Firmenevent"
},
"contact": "Kontakt"
"contact": "Kontakt",
"cta": "Jetzt ausprobieren",
"utility": "Darstellung und Sprache öffnen",
"appearance": "Darstellung",
"appearance_light": "Hell",
"appearance_dark": "Dunkel",
"language": "Sprache"
},
"login": {
"title": "Die Fotospiel App",
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel App",
"logo_alt": "Logo Die Fotospiel App",
"hero_tagline": "Event-Tech mit Herz",
"hero_heading": "Willkommen zurück bei der Fotospiel App",
"hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.",
"hero_footer": {
"headline": "Noch kein Account?",
"subline": "Entdecke unsere Packages und erlebe Fotospiel live.",
"cta": "Packages entdecken"
},
"highlights": {
"moments": "Momente in Echtzeit teilen",
"moments_description": "Uploads landen sofort in der Event-Galerie ohne App-Download.",
"branding": "Branding & Slideshows, die begeistern",
"branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.",
"privacy": "Sicherer Zugang über Tokens",
"privacy_description": "Eventzugänge bleiben geschützt DSGVO-konform mit Join Tokens."
},
"identifier": "E-Mail oder Username",
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
"username_or_email": "Username oder E-Mail",
@@ -39,6 +64,20 @@
"no_account": "Noch keinen Zugang?",
"sign_up": "Jetzt registrieren"
},
"common": {
"ui": {
"language_select": "Sprache auswählen"
}
},
"forgot": {
"title": "Passwort zurücksetzen",
"description": "Gib deine E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
"email_label": "E-Mail-Adresse",
"email_placeholder": "name@beispiel.de",
"submit": "Reset-Link per E-Mail senden",
"back_prefix": "Oder zurück zu",
"back": "Login"
},
"register": {
"title": "Registrieren",
"name": "Vollständiger Name",

View File

@@ -10,19 +10,44 @@
"register": "Register",
"home": "Home",
"packages": "Packages",
"how_it_works": "How it works",
"blog": "Blog",
"occasions": {
"label": "Occasions",
"wedding": "Wedding",
"birthday": "Birthday",
"confirmation": "Confirmation/Youth dedication",
"corporate": "Corporate Event"
},
"contact": "Contact"
"contact": "Contact",
"cta": "Try now",
"utility": "Open appearance and language",
"appearance": "Appearance",
"appearance_light": "Light",
"appearance_dark": "Dark",
"language": "Language"
},
"login": {
"title": "Die Fotospiel App",
"description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel App",
"logo_alt": "Fotospiel App logo",
"hero_tagline": "Event tech with heart",
"hero_heading": "Welcome back to the Fotospiel App",
"hero_subheading": "Manage events, galleries, and guest lists in one beautifully crafted dashboard.",
"hero_footer": {
"headline": "No account yet?",
"subline": "Explore our packages and experience Fotospiel live.",
"cta": "Discover packages"
},
"highlights": {
"moments": "Share moments in real time",
"moments_description": "Uploads land instantly in the event gallery — no app download needed.",
"branding": "Branding & slideshows that impress",
"branding_description": "Configure slideshows, watermarks, and tasks for your event.",
"privacy": "Secure access via tokens",
"privacy_description": "Event access stays protected — GDPR-compliant with join tokens."
},
"identifier": "Email or Username",
"identifier_placeholder": "you@example.com or username",
"username_or_email": "Username or Email",
@@ -39,6 +64,20 @@
"no_account": "Don't have access yet?",
"sign_up": "Create an account"
},
"common": {
"ui": {
"language_select": "Select language"
}
},
"forgot": {
"title": "Reset your password",
"description": "Enter your email address to receive a password reset link.",
"email_label": "Email address",
"email_placeholder": "name@example.com",
"submit": "Email password reset link",
"back_prefix": "Or, return to",
"back": "Login"
},
"register": {
"title": "Register",
"name": "Full Name",