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

@@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller
$user = Auth::user(); $user = Auth::user();
if ($user && $user->email_verified_at === null) { if ($user && $user->email_verified_at === null) {
$intended = $request->session()->get('url.intended');
$intended = is_string($intended) ? trim($intended) : null;
if ($this->isVerificationLink($intended)) {
$request->session()->forget('url.intended');
return Inertia::location($intended);
}
return Inertia::location(route('verification.notice')); return Inertia::location(route('verification.notice'));
} }
@@ -116,6 +125,29 @@ class AuthenticatedSessionController extends Controller
); );
} }
private function isVerificationLink(?string $target): bool
{
if (! is_string($target) || trim($target) === '') {
return false;
}
$path = trim($target);
if (str_starts_with($path, '/verify-email/')) {
return true;
}
$parsed = parse_url($path);
if ($parsed === false) {
return false;
}
$path = $parsed['path'] ?? '';
return $path !== '' && str_starts_with($path, '/verify-email/');
}
private function decodeReturnTo(string $value, Request $request): ?string private function decodeReturnTo(string $value, Request $request): ?string
{ {
$candidate = $this->decodeBase64Url($value) ?? $value; $candidate = $this->decodeBase64Url($value) ?? $value;

View File

@@ -10,19 +10,44 @@
"register": "Registrieren", "register": "Registrieren",
"home": "Startseite", "home": "Startseite",
"packages": "Pakete", "packages": "Pakete",
"how_it_works": "So geht's",
"blog": "Blog", "blog": "Blog",
"occasions": { "occasions": {
"label": "Anlässe",
"wedding": "Hochzeit", "wedding": "Hochzeit",
"birthday": "Geburtstag", "birthday": "Geburtstag",
"confirmation": "Konfirmation/Jugendweihe",
"corporate": "Firmenevent" "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": { "login": {
"title": "Die Fotospiel App", "title": "Die Fotospiel App",
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.", "description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel App", "brand": "Die Fotospiel App",
"logo_alt": "Logo 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": "E-Mail oder Username",
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia", "identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
"username_or_email": "Username oder E-Mail", "username_or_email": "Username oder E-Mail",
@@ -39,6 +64,20 @@
"no_account": "Noch keinen Zugang?", "no_account": "Noch keinen Zugang?",
"sign_up": "Jetzt registrieren" "sign_up": "Jetzt registrieren"
}, },
"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"
},
"common": {
"ui": {
"language_select": "Sprache auswählen"
}
},
"register": { "register": {
"title": "Registrieren", "title": "Registrieren",
"name": "Vollständiger Name", "name": "Vollständiger Name",

View File

@@ -10,19 +10,44 @@
"register": "Register", "register": "Register",
"home": "Home", "home": "Home",
"packages": "Packages", "packages": "Packages",
"how_it_works": "How it works",
"blog": "Blog", "blog": "Blog",
"occasions": { "occasions": {
"label": "Occasions",
"wedding": "Wedding", "wedding": "Wedding",
"birthday": "Birthday", "birthday": "Birthday",
"confirmation": "Confirmation/Youth dedication",
"corporate": "Corporate Event" "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": { "login": {
"title": "Die Fotospiel App", "title": "Die Fotospiel App",
"description": "Sign in with your Fotospiel account to manage every event in one place.", "description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel App", "brand": "Die Fotospiel App",
"logo_alt": "Fotospiel App logo", "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": "Email or Username",
"identifier_placeholder": "you@example.com or username", "identifier_placeholder": "you@example.com or username",
"username_or_email": "Username or Email", "username_or_email": "Username or Email",
@@ -39,6 +64,20 @@
"no_account": "Don't have access yet?", "no_account": "Don't have access yet?",
"sign_up": "Create an account" "sign_up": "Create an account"
}, },
"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"
},
"common": {
"ui": {
"language_select": "Select language"
}
},
"register": { "register": {
"title": "Register", "title": "Register",
"name": "Full Name", "name": "Full Name",

View File

@@ -72,6 +72,31 @@
"action": "Installieren", "action": "Installieren",
"iosHint": "iOS: Teilen → Zum Home-Bildschirm." "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": { "errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.", "eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.",

View File

@@ -72,6 +72,31 @@
"action": "Install", "action": "Install",
"iosHint": "On iOS: Share → Add to Home Screen." "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": { "errors": {
"generic": "Something went wrong. Please try again.", "generic": "Something went wrong. Please try again.",
"eventLimit": "Your current package has no remaining event bundle.", "eventLimit": "Your current package has no remaining event bundle.",

View File

@@ -1,15 +1,21 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants'; import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo'; import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
import { useAdminTheme } from './theme';
export default function AuthCallbackPage(): React.ReactElement { export default function AuthCallbackPage(): React.ReactElement {
const { status } = useAuth(); const { status } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const [redirected, setRedirected] = React.useState(false); const [redirected, setRedirected] = React.useState(false);
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = { const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)', paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 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]); }, [destination, navigate, redirected, status]);
return ( return (
<div <YStack
className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground" alignItems="center"
style={safeAreaStyle} 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> <Card
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p> borderRadius={22}
</div> 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 { Slider } from 'tamagui';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; 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 { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { ApiError, getApiErrorMessage } from '../lib/apiError';
@@ -363,22 +364,17 @@ export default function MobileBrandingPage() {
{t('events.watermark.title', 'Wasserzeichen')} {t('events.watermark.title', 'Wasserzeichen')}
</Text> </Text>
<InputField <MobileField label={t('events.watermark.mode', 'Modus')}>
label={t('events.watermark.mode', 'Modus')} <MobileSelect
value={mode} value={mode}
onChange={(value) => { disabled={controlsLocked}
onChange={(event) => {
const value = event.target.value;
if (controlsLocked) return; if (controlsLocked) return;
if (value === 'custom' || value === 'base' || value === 'off') { if (value === 'custom' || value === 'base' || value === 'off') {
setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] })); setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] }));
} }
}} }}
onPicker={() => undefined}
>
<select
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' }}
> >
<option value="base">{t('events.watermark.modeBase', 'Basis')}</option> <option value="base">{t('events.watermark.modeBase', 'Basis')}</option>
<option value="custom" disabled={watermarkLocked}> <option value="custom" disabled={watermarkLocked}>
@@ -387,8 +383,8 @@ export default function MobileBrandingPage() {
<option value="off" disabled={policyLabel === 'basic'}> <option value="off" disabled={policyLabel === 'basic'}>
{t('events.watermark.modeOff', 'Deaktiviert')} {t('events.watermark.modeOff', 'Deaktiviert')}
</option> </option>
</select> </MobileSelect>
</InputField> </MobileField>
{mode === 'custom' && !controlsLocked ? ( {mode === 'custom' && !controlsLocked ? (
<YStack space="$2"> <YStack space="$2">
@@ -414,11 +410,9 @@ export default function MobileBrandingPage() {
</Text> </Text>
</XStack> </XStack>
</Pressable> </Pressable>
<input <MobileFileInput
id="watermark-upload-input" id="watermark-upload-input"
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml" accept="image/png,image/jpeg,image/webp,image/svg+xml"
style={{ display: 'none' }}
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -855,11 +849,9 @@ export default function MobileBrandingPage() {
</Pressable> </Pressable>
</> </>
)} )}
<input <MobileFileInput
id="branding-logo-input" id="branding-logo-input"
type="file"
accept="image/*" accept="image/*"
style={{ display: 'none' }}
disabled={brandingDisabled} disabled={brandingDisabled}
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -1171,19 +1163,17 @@ function ColorField({
onChange: (next: string) => void; onChange: (next: string) => void;
disabled?: boolean; disabled?: boolean;
}) { }) {
const { textStrong, muted, border, surface } = useAdminTheme(); const { textStrong, muted } = useAdminTheme();
return ( return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}> <YStack space="$2" opacity={disabled ? 0.6 : 1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label} {label}
</Text> </Text>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<input <MobileColorInput
type="color"
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
disabled={disabled} disabled={disabled}
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
/> />
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{value} {value}
@@ -1211,7 +1201,6 @@ function InputField({
placeholder, placeholder,
onChange, onChange,
onPicker, onPicker,
children,
disabled, disabled,
}: { }: {
label: string; label: string;
@@ -1219,51 +1208,27 @@ function InputField({
placeholder?: string; placeholder?: string;
onChange: (next: string) => void; onChange: (next: string) => void;
onPicker?: () => void; onPicker?: () => void;
children?: React.ReactNode;
disabled?: boolean; disabled?: boolean;
}) { }) {
const { textStrong, border, surface, primary } = useAdminTheme(); const { primary } = useAdminTheme();
return ( return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}> <MobileField label={label}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <XStack alignItems="center" space="$2">
{label} <MobileInput
</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} value={value}
placeholder={placeholder} placeholder={placeholder}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
disabled={disabled}
style={{
flex: 1,
height: '100%',
border: 'none',
outline: 'none',
fontSize: 14,
background: 'transparent',
}}
onFocus={onPicker} onFocus={onPicker}
disabled={disabled}
style={{ flex: 1 }}
/> />
)}
{onPicker ? ( {onPicker ? (
<Pressable onPress={onPicker} disabled={disabled}> <Pressable onPress={onPicker} disabled={disabled}>
<ChevronDown size={16} color={primary} /> <ChevronDown size={16} color={primary} />
</Pressable> </Pressable>
) : null} ) : null}
</XStack> </XStack>
</YStack> </MobileField>
); );
} }

View File

@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch'; import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; 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 { LegalConsentSheet } from './components/LegalConsentSheet';
import { import {
createEvent, createEvent,
@@ -401,14 +401,9 @@ export default function MobileEventFormPage() {
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}> <MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<NativeDateTimeInput <MobileDateTimeInput
value={form.date} value={form.date}
onChange={(value) => setForm((prev) => ({ ...prev, date: value }))} onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))}
border={border}
surface={surface}
text={text}
primary={primary}
danger={danger}
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<CalendarDays size={16} color={subtle} /> <CalendarDays size={16} color={subtle} />
@@ -624,57 +619,6 @@ function toDateTimeLocal(value?: string | null): string {
return fallback.length >= 16 ? fallback.slice(0, 16) : ''; 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 { function resolveLocation(event: TenantEvent): string {
const settings = (event.settings ?? {}) as Record<string, unknown>; const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate = const candidate =

View File

@@ -203,7 +203,7 @@ export default function MobileEventGuestNotificationsPage() {
</MobileCard> </MobileCard>
) : null} ) : null}
<div ref={formRef}> <YStack ref={formRef}>
<MobileCard space="$3"> <MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('guestMessages.composeTitle', 'Send a message')} {t('guestMessages.composeTitle', 'Send a message')}
@@ -299,7 +299,7 @@ export default function MobileEventGuestNotificationsPage() {
) : null} ) : null}
</YStack> </YStack>
</MobileCard> </MobileCard>
</div> </YStack>
<MobileCard space="$2"> <MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between"> <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 { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { LegalConsentSheet } from './components/LegalConsentSheet'; 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"> <Text fontSize="$sm" color={textStrong} fontWeight="600">
{label} {label}
</Text> </Text>
<input <Switch size="$4" checked={value} onCheckedChange={onToggle} aria-label={label}>
type="checkbox" <Switch.Thumb />
checked={value} </Switch>
onChange={(e) => onToggle(e.target.checked)}
style={{ width: 20, height: 20 }}
/>
</XStack> </XStack>
); );
} }

View File

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

View File

@@ -1,12 +1,16 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react'; import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; 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 { useTranslation } from 'react-i18next';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; 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 { MobileInput } from './components/FormControls';
import { getEvents, TenantEvent } from '../api'; import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
@@ -27,7 +31,7 @@ export default function MobileEventsPage() {
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all'); const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
const searchRef = React.useRef<HTMLInputElement>(null); const searchRef = React.useRef<HTMLInputElement>(null);
const back = useBackNavigation(); 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(() => { React.useEffect(() => {
(async () => { (async () => {
try { try {
@@ -54,13 +58,48 @@ export default function MobileEventsPage() {
} }
> >
{error ? ( {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}> <Text fontWeight="700" color={danger}>
{error} {error}
</Text> </Text>
</MobileCard> </Card>
) : null} ) : null}
<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 <MobileInput
ref={searchRef} ref={searchRef}
type="search" type="search"
@@ -68,8 +107,12 @@ export default function MobileEventsPage() {
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={t('events.list.search', 'Search events')} placeholder={t('events.list.search', 'Search events')}
compact compact
style={{ marginBottom: 12 }}
/> />
<Text fontSize="$xs" color={muted}>
{t('events.list.filters.hint', 'Filter your events by status or search by name.')}
</Text>
</YStack>
</Card>
{loading ? ( {loading ? (
<YStack space="$2"> <YStack space="$2">
@@ -78,7 +121,18 @@ export default function MobileEventsPage() {
))} ))}
</YStack> </YStack>
) : events.length === 0 ? ( ) : events.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$3"> <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"> <Text fontSize="$md" fontWeight="700">
{t('events.list.empty.title', 'Noch kein Event angelegt')} {t('events.list.empty.title', 'Noch kein Event angelegt')}
</Text> </Text>
@@ -86,7 +140,8 @@ export default function MobileEventsPage() {
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')} {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text> </Text>
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} /> <CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
</MobileCard> </YStack>
</Card>
) : ( ) : (
<EventsList <EventsList
events={events} events={events}
@@ -123,7 +178,7 @@ function EventsList({
onEdit: (slug: string) => void; onEdit: (slug: string) => void;
}) { }) {
const { t } = useTranslation('management'); 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 activeBg = accentSoft;
const activeBorder = accent; const activeBorder = accent;
@@ -150,34 +205,79 @@ function EventsList({
return ( return (
<YStack space="$3"> <YStack space="$3">
<XStack space="$2" flexWrap="wrap"> <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) => { {filters.map((filter) => {
const active = filter.key === statusFilter; const active = filter.key === statusFilter;
return ( return (
<Pressable key={filter.key} onPress={() => onStatusChange(filter.key)} style={{ flexGrow: 1 }}> <ToggleGroup.Item
<XStack key={filter.key}
alignItems="center" value={filter.key}
justifyContent="center" borderRadius={999}
space="$1.5"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
backgroundColor={active ? activeBg : surface}
borderWidth={1} borderWidth={1}
borderColor={active ? activeBorder : border} 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}> <Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{filter.label} {filter.label}
</Text> </Text>
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge> <PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
</XStack> </XStack>
</Pressable> </ToggleGroup.Item>
); );
})} })}
</XStack> </XStack>
</ToggleGroup>
</ScrollView>
</YStack>
</Card>
{filteredEvents.length === 0 ? ( {filteredEvents.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2"> <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}> <Text fontSize="$sm" fontWeight="700" color={text}>
{t('events.list.empty.filtered', 'No events match this filter.')} {t('events.list.empty.filtered', 'No events match this filter.')}
</Text> </Text>
@@ -190,7 +290,8 @@ function EventsList({
fullWidth={false} fullWidth={false}
onPress={() => onStatusChange('all')} onPress={() => onStatusChange('all')}
/> />
</MobileCard> </YStack>
</Card>
) : ( ) : (
filteredEvents.map((event) => { filteredEvents.map((event) => {
const statusKey = resolveEventStatusKey(event); const statusKey = resolveEventStatusKey(event);
@@ -210,6 +311,8 @@ function EventsList({
subtle={subtle} subtle={subtle}
border={border} border={border}
primary={primary} primary={primary}
surface={surface}
shadow={shadow}
statusLabel={statusLabel} statusLabel={statusLabel}
statusTone={statusTone} statusTone={statusTone}
onOpen={onOpen} onOpen={onOpen}
@@ -229,6 +332,8 @@ function EventRow({
subtle, subtle,
border, border,
primary, primary,
surface,
shadow,
statusLabel, statusLabel,
statusTone, statusTone,
onOpen, onOpen,
@@ -240,6 +345,8 @@ function EventRow({
subtle: string; subtle: string;
border: string; border: string;
primary: string; primary: string;
surface: string;
shadow: string;
statusLabel: string; statusLabel: string;
statusTone: 'success' | 'warning' | 'muted'; statusTone: 'success' | 'warning' | 'muted';
onOpen: (slug: string) => void; onOpen: (slug: string) => void;
@@ -248,7 +355,18 @@ function EventRow({
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const stats = buildEventListStats(event); const stats = buildEventListStats(event);
return ( return (
<MobileCard borderColor={border}> <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"> <XStack justifyContent="space-between" alignItems="flex-start" space="$2">
<YStack space="$1"> <YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}> <Text fontSize="$md" fontWeight="800" color={text}>
@@ -267,6 +385,14 @@ function EventRow({
</Text> </Text>
</XStack> </XStack>
<PillBadge tone={statusTone}>{statusLabel}</PillBadge> <PillBadge tone={statusTone}>{statusLabel}</PillBadge>
</YStack>
<Pressable onPress={() => onEdit(event.slug)}>
<Text fontSize="$xl" color={muted}>
˅
</Text>
</Pressable>
</XStack>
<XStack alignItems="center" space="$2" flexWrap="wrap"> <XStack alignItems="center" space="$2" flexWrap="wrap">
<EventStatChip <EventStatChip
icon={Camera} icon={Camera}
@@ -287,15 +413,10 @@ function EventRow({
muted={subtle} muted={subtle}
/> />
</XStack> </XStack>
</YStack>
<Pressable onPress={() => onEdit(event.slug)}>
<Text fontSize="$xl" color={muted}>
˅
</Text>
</Pressable>
</XStack>
<Pressable onPress={() => onOpen(event.slug)} style={{ marginTop: 8 }}> <Separator borderColor={border} />
<Pressable onPress={() => onOpen(event.slug)}>
<XStack alignItems="center" justifyContent="flex-start" space="$2"> <XStack alignItems="center" justifyContent="flex-start" space="$2">
<Plus size={16} color={primary} /> <Plus size={16} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700"> <Text fontSize="$sm" color={primary} fontWeight="700">
@@ -303,7 +424,8 @@ function EventRow({
</Text> </Text>
</XStack> </XStack>
</Pressable> </Pressable>
</MobileCard> </YStack>
</Card>
); );
} }

View File

@@ -2,12 +2,18 @@ import React from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { ADMIN_LOGIN_PATH } from '../constants';
import { useAdminTheme } from './theme';
export default function LoginStartPage(): React.ReactElement { export default function LoginStartPage(): React.ReactElement {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = { const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)', paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)', paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -25,11 +31,34 @@ export default function LoginStartPage(): React.ReactElement {
}, [location.search, navigate]); }, [location.search, navigate]);
return ( return (
<div <YStack
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70" alignItems="center"
style={safeAreaStyle} justifyContent="center"
padding="$4"
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
backgroundColor={surface}
> >
<p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p> <Card
</div> 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 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 { useAuth } from '../auth/context';
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants'; import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
import { useAdminTheme } from './theme';
export default function LogoutPage() { export default function LogoutPage() {
const { logout } = useAuth(); const { logout } = useAuth();
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = { const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)', paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)', paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -14,13 +20,34 @@ export default function LogoutPage() {
}, [logout]); }, [logout]);
return ( return (
<div <YStack
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" alignItems="center"
style={safeAreaStyle} 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"> <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 ... Abmeldung wird vorbereitet ...
</div> </Text>
</div> <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 ( return (
<div style={{ position: 'relative' }}> <YStack position="relative">
<XStack <XStack
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
@@ -119,7 +119,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
> >
<Pressable onPress={handlePress}>{children}</Pressable> <Pressable onPress={handlePress}>{children}</Pressable>
</motion.div> </motion.div>
</div> </YStack>
); );
} }

View File

@@ -1,14 +1,15 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { YGroup } from '@tamagui/group'; import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item'; import { ListItem } from '@tamagui/list-item';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls'; import { MobileSelect } from './components/FormControls';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api'; import { fetchTenantProfile } from '../api';
@@ -16,6 +17,7 @@ import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '
import i18n from '../i18n'; import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { withAlpha } from './components/colors';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
export default function MobileProfilePage() { export default function MobileProfilePage() {
@@ -23,7 +25,7 @@ export default function MobileProfilePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { appearance, updateAppearance } = useAppearance(); 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 textColor = textStrong;
const mutedText = muted; const mutedText = muted;
const borderColor = border; const borderColor = border;
@@ -54,17 +56,24 @@ export default function MobileProfilePage() {
title={t('mobileProfile.title', 'Profile')} title={t('mobileProfile.title', 'Profile')}
onBack={back} onBack={back}
> >
<MobileCard space="$3" alignItems="center"> <Card
<XStack borderRadius={22}
width={64} borderWidth={2}
height={64} borderColor={borderColor}
borderRadius={20} backgroundColor={surface}
alignItems="center" padding="$3"
justifyContent="center" shadowColor={shadow}
backgroundColor={avatarBg} 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} /> <User size={28} color={primary} />
</XStack> </Avatar.Fallback>
</Avatar>
<YStack space="$0.5" alignItems="center">
<Text fontSize="$md" fontWeight="800" color={textColor}> <Text fontSize="$md" fontWeight="800" color={textColor}>
{name} {name}
</Text> </Text>
@@ -76,16 +85,37 @@ export default function MobileProfilePage() {
{role} {role}
</Text> </Text>
) : null} ) : null}
</MobileCard> </YStack>
</YStack>
</Card>
<MobileCard space="$3"> <Card
<Text fontSize="$md" fontWeight="800" color={textColor}> 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')} {t('mobileProfile.settings', 'Settings')}
</Text> </Text>
<YStack space="$4"> </XStack>
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}> <YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
<YGroup.Item> <YGroup.Item>
<Pressable onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -97,11 +127,10 @@ export default function MobileProfilePage() {
</Text> </Text>
} }
iconAfter={<Settings size={18} color={subtle} />} iconAfter={<Settings size={18} color={subtle} />}
onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}
/> />
</Pressable>
</YGroup.Item> </YGroup.Item>
<YGroup.Item> <YGroup.Item>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -113,11 +142,10 @@ export default function MobileProfilePage() {
</Text> </Text>
} }
iconAfter={<Settings size={18} color={subtle} />} iconAfter={<Settings size={18} color={subtle} />}
onPress={() => navigate(adminPath('/mobile/billing#packages'))}
/> />
</Pressable>
</YGroup.Item> </YGroup.Item>
<YGroup.Item> <YGroup.Item>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -129,11 +157,10 @@ export default function MobileProfilePage() {
</Text> </Text>
} }
iconAfter={<Settings size={18} color={subtle} />} iconAfter={<Settings size={18} color={subtle} />}
onPress={() => navigate(adminPath('/mobile/billing#invoices'))}
/> />
</Pressable>
</YGroup.Item> </YGroup.Item>
<YGroup.Item> <YGroup.Item>
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -145,9 +172,39 @@ export default function MobileProfilePage() {
</Text> </Text>
} }
iconAfter={<Download size={18} color={subtle} />} iconAfter={<Download size={18} color={subtle} />}
onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}
/> />
</Pressable>
</YGroup.Item> </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> <YGroup.Item>
<ListItem <ListItem
paddingVertical="$2" paddingVertical="$2"
@@ -205,15 +262,48 @@ export default function MobileProfilePage() {
</YGroup.Item> </YGroup.Item>
</YGroup> </YGroup>
</YStack> </YStack>
</MobileCard> </Card>
<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 <CTAButton
label={t('mobileProfile.logout', 'Log out')} label={t('mobileProfile.logout', 'Log out')}
tone="danger"
onPress={() => { onPress={() => {
logout(); logout();
navigate(adminPath('/logout')); navigate(adminPath('/logout'));
}} }}
/> />
</YStack>
</Card>
</MobileShell> </MobileShell>
); );
} }

View File

@@ -5,12 +5,12 @@ import { ArrowLeft, RefreshCcw, Plus, ChevronDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Input, TextArea } from 'tamagui';
import { Accordion } from '@tamagui/accordion'; import { Accordion } from '@tamagui/accordion';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { Portal } from '@tamagui/portal'; import { Portal } from '@tamagui/portal';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { import {
TenantEvent, TenantEvent,
EventQrInvite, EventQrInvite,
@@ -753,7 +753,7 @@ function BackgroundStep({
{presets.map((preset) => { {presets.map((preset) => {
const isSelected = selectedPreset === preset.id; const isSelected = selectedPreset === preset.id;
return ( 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 <YStack
aspectRatio={210 / 297} aspectRatio={210 / 297}
maxHeight={220} maxHeight={220}
@@ -795,7 +795,7 @@ function BackgroundStep({
{t('events.qr.gradients', 'Gradienten')} {t('events.qr.gradients', 'Gradienten')}
</Text> </Text>
<XStack flexWrap="wrap" gap="$2"> <XStack flexWrap="wrap" gap="$2">
{gradientPresets.map((gradient, idx) => { {gradientPresets.map((gradient) => {
const isSelected = const isSelected =
selectedGradient && selectedGradient &&
selectedGradient.angle === gradient.angle && selectedGradient.angle === gradient.angle &&
@@ -803,9 +803,10 @@ function BackgroundStep({
const style = { const style = {
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`, backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
} as React.CSSProperties; } as React.CSSProperties;
const key = `${gradient.labelKey}-${gradient.angle}`;
return ( return (
<Pressable <Pressable
key={idx} key={key}
onPress={() => onSelectGradient(gradient)} onPress={() => onSelectGradient(gradient)}
style={{ width: '30%' }} style={{ width: '30%' }}
aria-label={t(gradient.labelKey, gradient.label)} aria-label={t(gradient.labelKey, gradient.label)}
@@ -829,10 +830,11 @@ function BackgroundStep({
{t('events.qr.colors', 'Vollfarbe')} {t('events.qr.colors', 'Vollfarbe')}
</Text> </Text>
<XStack flexWrap="wrap" gap="$2"> <XStack flexWrap="wrap" gap="$2">
{solidPresets.map((color) => { {solidPresets.map((color, idx) => {
const isSelected = selectedSolid === color; const isSelected = selectedSolid === color;
const key = `${color}-${idx}`;
return ( return (
<Pressable key={color} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}> <Pressable key={key} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}>
<YStack <YStack
height={50} height={50}
borderRadius={12} borderRadius={12}
@@ -912,24 +914,20 @@ function TextStep({
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.textFields', 'Texte')} {t('events.qr.textFields', 'Texte')}
</Text> </Text>
<Input <MobileInput
placeholder={t('events.qr.headline', 'Headline')} placeholder={t('events.qr.headline', 'Headline')}
value={textFields.headline} value={textFields.headline}
onChangeText={(val) => updateField('headline', val)} onChange={(event) => updateField('headline', event.target.value)}
size="$4"
/> />
<Input <MobileInput
placeholder={t('events.qr.subtitle', 'Subtitle')} placeholder={t('events.qr.subtitle', 'Subtitle')}
value={textFields.subtitle} value={textFields.subtitle}
onChangeText={(val) => updateField('subtitle', val)} onChange={(event) => updateField('subtitle', event.target.value)}
size="$4"
/> />
<TextArea <MobileTextArea
placeholder={t('events.qr.bottomNote', 'Unterer Hinweistext')} placeholder={t('events.qr.bottomNote', 'Unterer Hinweistext')}
value={textFields.description} value={textFields.description}
onChangeText={(val) => updateField('description', val)} onChange={(event) => updateField('description', event.target.value)}
size="$4"
numberOfLines={3}
/> />
</YStack> </YStack>
@@ -939,13 +937,12 @@ function TextStep({
</Text> </Text>
{textFields.instructions.map((item, idx) => ( {textFields.instructions.map((item, idx) => (
<XStack key={idx} alignItems="center" space="$2"> <XStack key={idx} alignItems="center" space="$2">
<TextArea <MobileTextArea
value={item} value={item}
onChangeText={(val) => updateInstruction(idx, val)} onChange={(event) => updateInstruction(idx, event.target.value)}
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')} placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
numberOfLines={2} style={{ flex: 1 }}
{...({ flex: 1 } as any)} compact
size="$4"
/> />
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}> <Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
<XStack <XStack
@@ -1204,36 +1201,13 @@ function LayoutControls({
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null); 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 fontOptions = React.useMemo(() => {
const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora']; const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora'];
const tenant = tenantFonts.map((font) => font.family); const tenant = tenantFonts.map((font) => font.family);
return Array.from(new Set([...tenant, ...preset])); return Array.from(new Set([...tenant, ...preset]));
}, [tenantFonts]); }, [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 = ({ const StepperInput = ({
value, value,
min, min,
@@ -1260,11 +1234,9 @@ function LayoutControls({
</Text> </Text>
</XStack> </XStack>
</Pressable> </Pressable>
<input <MobileInput
type="text" type="text"
inputMode="decimal" inputMode="decimal"
min={min}
max={max}
value={formatValue(value)} value={formatValue(value)}
onChange={(event) => { onChange={(event) => {
const next = Number(event.target.value); const next = Number(event.target.value);
@@ -1272,7 +1244,13 @@ function LayoutControls({
onChange(next); onChange(next);
} }
}} }}
style={numberInputStyle} compact
style={{
width: 90,
textAlign: 'center',
fontFamily: DEFAULT_BODY_FONT,
backgroundColor: surfaceMuted,
}}
/> />
<Pressable onPress={inc}> <Pressable onPress={inc}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}> <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}> <Text fontSize="$xs" color={muted}>
{t('events.qr.fontFamily', 'Font Family')} {t('events.qr.fontFamily', 'Font Family')}
</Text> </Text>
<select <MobileSelect
value={override.fontFamily ?? slot.fontFamily ?? ''} value={override.fontFamily ?? slot.fontFamily ?? ''}
onChange={(event) => onFontFamilyChange(event.target.value)} onChange={(event) => onFontFamilyChange(event.target.value)}
style={selectStyle}
> >
<option value="">{t('events.qr.defaultFont', 'Standard')}</option> <option value="">{t('events.qr.defaultFont', 'Standard')}</option>
{fontOptions.map((family) => ( {fontOptions.map((family) => (
@@ -1406,7 +1383,7 @@ function LayoutControls({
{family} {family}
</option> </option>
))} ))}
</select> </MobileSelect>
</YStack> </YStack>
<YStack flex={1} space="$1"> <YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
@@ -1424,12 +1401,12 @@ function LayoutControls({
backgroundColor={override.color ?? slot.color ?? textStrong} backgroundColor={override.color ?? slot.color ?? textStrong}
/> />
</Pressable> </Pressable>
<input <MobileInput
type="text"
value={override.color ?? slot.color ?? ''} value={override.color ?? slot.color ?? ''}
placeholder={ADMIN_COLORS.text} placeholder={ADMIN_COLORS.text}
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })} onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
style={{ ...numberInputStyle, width: 110 }} compact
style={{ width: 110, backgroundColor: surfaceMuted }}
/> />
</XStack> </XStack>
{openColorSlot === slotKey ? ( {openColorSlot === slotKey ? (
@@ -1496,15 +1473,14 @@ function LayoutControls({
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
{t('events.qr.align', 'Align')} {t('events.qr.align', 'Align')}
</Text> </Text>
<select <MobileSelect
value={override.align ?? slot.align ?? 'left'} value={override.align ?? slot.align ?? 'left'}
onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })} onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })}
style={selectStyle}
> >
<option value="left">{t('common.left', 'Links')}</option> <option value="left">{t('common.left', 'Links')}</option>
<option value="center">{t('common.center', 'Zentriert')}</option> <option value="center">{t('common.center', 'Zentriert')}</option>
<option value="right">{t('common.right', 'Rechts')}</option> <option value="right">{t('common.right', 'Rechts')}</option>
</select> </MobileSelect>
</YStack> </YStack>
<YStack flex={1} space="$1"> <YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}> <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 { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileInput, MobileTextArea } from './components/FormControls';
import { import {
TenantEvent, TenantEvent,
EventQrInvite, EventQrInvite,
@@ -489,20 +490,20 @@ function TextStep({
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.textFields', 'Texte')} {t('events.qr.textFields', 'Texte')}
</Text> </Text>
<StyledInput <MobileInput
placeholder={t('events.qr.headline', 'Headline')} placeholder={t('events.qr.headline', 'Headline')}
value={textFields.headline} value={textFields.headline}
onChangeText={(val) => updateField('headline', val)} onChange={(event) => updateField('headline', event.target.value)}
/> />
<StyledInput <MobileInput
placeholder={t('events.qr.subtitle', 'Subtitle')} placeholder={t('events.qr.subtitle', 'Subtitle')}
value={textFields.subtitle} value={textFields.subtitle}
onChangeText={(val) => updateField('subtitle', val)} onChange={(event) => updateField('subtitle', event.target.value)}
/> />
<StyledTextarea <MobileTextArea
placeholder={t('events.qr.description', 'Beschreibung')} placeholder={t('events.qr.description', 'Beschreibung')}
value={textFields.description} value={textFields.description}
onChangeText={(val) => updateField('description', val)} onChange={(event) => updateField('description', event.target.value)}
/> />
</YStack> </YStack>
@@ -512,11 +513,11 @@ function TextStep({
</Text> </Text>
{textFields.instructions.map((item, idx) => ( {textFields.instructions.map((item, idx) => (
<XStack key={idx} alignItems="center" space="$2"> <XStack key={idx} alignItems="center" space="$2">
<StyledInput <MobileInput
flex={1} style={{ flex: 1 }}
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')} placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
value={item} value={item}
onChangeText={(val) => updateInstruction(idx, val)} onChange={(event) => updateInstruction(idx, event.target.value)}
/> />
<CTAButton <CTAButton
label="" label=""
@@ -651,60 +652,3 @@ function PreviewStep({
</YStack> </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 React from 'react';
import { Navigate, useNavigate } from 'react-router-dom'; import { Navigate, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks'; 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 { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { CTAButton } from './components/Primitives';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
@@ -15,7 +17,7 @@ export default function MobileUploadsTabPage() {
const { events, activeEvent, hasEvents, selectEvent } = useEventContext(); const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
const { t, i18n } = useTranslation('management'); const { t, i18n } = useTranslation('management');
const navigate = useNavigate(); const navigate = useNavigate();
const { text, muted, border, primary } = useAdminTheme(); const { text, muted, border, primary, surface, surfaceMuted, shadow } = useAdminTheme();
if (activeEvent?.slug) { if (activeEvent?.slug) {
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/control-room`)} replace />; return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/control-room`)} replace />;
@@ -24,7 +26,31 @@ export default function MobileUploadsTabPage() {
if (!hasEvents) { if (!hasEvents) {
return ( return (
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}> <MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
<MobileCard alignItems="flex-start" space="$3"> <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}> <Text fontSize="$lg" fontWeight="800" color={text}>
{t('mobileUploads.emptyTitle', 'Create an event first')} {t('mobileUploads.emptyTitle', 'Create an event first')}
</Text> </Text>
@@ -35,7 +61,8 @@ export default function MobileUploadsTabPage() {
label={t('mobileDashboard.ctaCreate', 'Create event')} label={t('mobileDashboard.ctaCreate', 'Create event')}
onPress={() => navigate(adminPath('/mobile/events/new'))} onPress={() => navigate(adminPath('/mobile/events/new'))}
/> />
</MobileCard> </YStack>
</Card>
</MobileShell> </MobileShell>
); );
} }
@@ -44,22 +71,40 @@ export default function MobileUploadsTabPage() {
return ( return (
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}> <MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
<YStack space="$2"> <Card
<Text fontSize="$sm" color={text} fontWeight="700"> 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.pickEvent', 'Pick an event to manage uploads')} {t('mobileUploads.pickEvent', 'Pick an event to manage uploads')}
</Text> </Text>
</XStack>
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: border, overflow: "hidden" } as any)}>
{events.map((event) => ( {events.map((event) => (
<Pressable <YGroup.Item key={event.slug}>
key={event.slug} <ListItem
onPress={() => { hoverTheme
selectEvent(event.slug ?? null); pressTheme
if (event.slug) { paddingVertical="$2"
navigate(adminPath(`/mobile/events/${event.slug}/control-room`)); paddingHorizontal="$3"
} title={
}}
>
<MobileCard borderColor={border} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1"> <YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}> <Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)} {resolveEventDisplayName(event)}
@@ -68,14 +113,24 @@ export default function MobileUploadsTabPage() {
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')} {formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
</Text> </Text>
</YStack> </YStack>
}
iconAfter={
<Text fontSize="$sm" color={primary} fontWeight="700"> <Text fontSize="$sm" color={primary} fontWeight="700">
{t('mobileUploads.open', 'Open')} {t('mobileUploads.open', 'Open')}
</Text> </Text>
</XStack> }
</MobileCard> onPress={() => {
</Pressable> selectEvent(event.slug ?? null);
if (event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
}
}}
/>
</YGroup.Item>
))} ))}
</YGroup>
</YStack> </YStack>
</Card>
</MobileShell> </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>, 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', () => ({ vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
})); }));

View File

@@ -45,6 +45,7 @@ vi.mock('../components/Primitives', () => ({
vi.mock('../components/FormControls', () => ({ vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileDateTimeInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />, MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>, MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />, 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>, 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', () => ({ vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, { YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ 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 { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
const backMock = vi.fn(); const fixtures = vi.hoisted(() => ({
const navigateMock = vi.fn(); 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', () => ({ vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock, useNavigate: () => vi.fn(),
useParams: () => ({ slug: 'demo-event' }), useParams: () => ({ slug: fixtures.event.slug }),
})); }));
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
useTranslation: () => ({ 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', () => ({ vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => backMock, useBackNavigation: () => undefined,
})); }));
vi.mock('../../api', () => ({ vi.mock('../../api', () => ({
getEvent: vi.fn().mockResolvedValue({ slug: 'demo-event', name: 'Demo' }), getEvent: vi.fn().mockResolvedValue(fixtures.event),
getEventQrInvites: vi.fn().mockResolvedValue([ getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
{
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',
},
],
},
]),
createQrInvite: vi.fn(), createQrInvite: vi.fn(),
})); }));
vi.mock('react-hot-toast', () => ({ vi.mock('../../auth/tokens', () => ({
default: { isAuthError: () => false,
error: vi.fn(),
success: vi.fn(),
},
})); }));
vi.mock('@tamagui/react-native-web-lite', () => ({ vi.mock('../../lib/apiError', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => ( getApiErrorMessage: () => 'error',
<button type="button" onClick={onPress}> }));
{children}
</button> vi.mock('./qr/utils', () => ({
), resolveLayoutForFormat: () => 'layout-1',
})); }));
vi.mock('../components/MobileShell', () => ({ vi.mock('../components/MobileShell', () => ({
@@ -82,14 +74,15 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({ vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => ( CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
<button type="button" onClick={onPress}>
{label}
</button>
),
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, 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', () => ({ vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ 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>, 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', () => ({ vi.mock('../theme', () => ({
useAdminTheme: () => ({ useAdminTheme: () => ({
textStrong: '#111827',
text: '#111827', text: '#111827',
muted: '#6b7280', muted: '#6b7280',
subtle: '#94a3b8', subtle: '#94a3b8',
border: '#e5e7eb', border: '#e5e7eb',
surfaceMuted: '#fffdfb',
primary: '#ff5a5f', primary: '#ff5a5f',
danger: '#b91c1c', danger: '#dc2626',
accentSoft: '#ffe5ec', surface: '#ffffff',
glassSurface: 'rgba(255,255,255,0.8)', surfaceMuted: '#f9fafb',
glassSurfaceStrong: 'rgba(255,255,255,0.9)', accentSoft: '#eef2ff',
glassBorder: 'rgba(229,231,235,0.7)', textStrong: '#0f172a',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}), }),
})); }));
import MobileQrPrintPage from '../QrPrintPage'; import MobileQrPrintPage from '../QrPrintPage';
describe('MobileQrPrintPage', () => { describe('MobileQrPrintPage', () => {
it('shows token expiry info when the invite has expires_at', async () => { it('renders QR overview content', async () => {
render(<MobileQrPrintPage />); 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 }; return { Select };
}); });
import { MobileSelect } from './FormControls'; import { MobileColorInput, MobileDateTimeInput, MobileFileInput, MobileSelect } from './FormControls';
describe('MobileSelect', () => { describe('MobileSelect', () => {
it('maps options and forwards selection changes', () => { 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; 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>( export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) { function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme(); const { border, surface, text, primary, danger } = useAdminTheme();

View File

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

View File

@@ -23,6 +23,23 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>, 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', () => { vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>; const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>; Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;

View File

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

View File

@@ -61,7 +61,7 @@ export default function AuthSimpleLayout({ children, title, description, name, l
<div className="space-y-4"> <div className="space-y-4">
<h2 className="font-display text-3xl leading-tight sm:text-4xl"> <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> </h2>
<p className="text-sm text-white/80 sm:text-base"> <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.')} {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 }) { export default function ForgotPassword({ status }: { status?: string }) {
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
return ( return (
<AuthLayout title={t('auth.forgot.title', 'Forgot password')} description={t('auth.forgot.description', 'Enter your email to receive a password reset link')}> <AuthLayout
<Head title={t('auth.forgot.title', 'Forgot password')} /> 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>} {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 }) => ( {({ processing, errors }) => (
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email">Email address</Label> <Label htmlFor="email">{t('forgot.email_label', 'Email address')}</Label>
<Input id="email" type="email" name="email" autoComplete="off" autoFocus placeholder={t('auth.forgot.email_placeholder')} /> <Input
id="email"
type="email"
name="email"
autoComplete="off"
autoFocus
placeholder={t('forgot.email_placeholder', 'name@example.com')}
/>
<InputError message={errors.email} /> <InputError message={errors.email} />
</div> </div>
@@ -34,7 +47,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
<div className="my-6 flex items-center justify-start"> <div className="my-6 flex items-center justify-start">
<Button className="w-full" disabled={processing}> <Button className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />} {processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Email password reset link {t('forgot.submit', 'Email password reset link')}
</Button> </Button>
</div> </div>
</> </>
@@ -42,8 +55,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
</Form> </Form>
<div className="space-x-1 text-center text-sm text-muted-foreground"> <div className="space-x-1 text-center text-sm text-muted-foreground">
<span>Or, return to</span> <span>{t('forgot.back_prefix', 'Or, return to')}</span>
<TextLink href={login()}>{t('auth.forgot.back')}</TextLink> <TextLink href={login()}>{t('forgot.back', 'Login')}</TextLink>
</div> </div>
</div> </div>
</AuthLayout> </AuthLayout>

View File

@@ -10,19 +10,44 @@
"register": "Registrieren", "register": "Registrieren",
"home": "Startseite", "home": "Startseite",
"packages": "Pakete", "packages": "Pakete",
"how_it_works": "So geht's",
"blog": "Blog", "blog": "Blog",
"occasions": { "occasions": {
"label": "Anlässe",
"wedding": "Hochzeit", "wedding": "Hochzeit",
"birthday": "Geburtstag", "birthday": "Geburtstag",
"confirmation": "Konfirmation/Jugendweihe",
"corporate": "Firmenevent" "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": { "login": {
"title": "Die Fotospiel App", "title": "Die Fotospiel App",
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.", "description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel App", "brand": "Die Fotospiel App",
"logo_alt": "Logo 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": "E-Mail oder Username",
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia", "identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
"username_or_email": "Username oder E-Mail", "username_or_email": "Username oder E-Mail",
@@ -39,6 +64,20 @@
"no_account": "Noch keinen Zugang?", "no_account": "Noch keinen Zugang?",
"sign_up": "Jetzt registrieren" "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": { "register": {
"title": "Registrieren", "title": "Registrieren",
"name": "Vollständiger Name", "name": "Vollständiger Name",

View File

@@ -10,19 +10,44 @@
"register": "Register", "register": "Register",
"home": "Home", "home": "Home",
"packages": "Packages", "packages": "Packages",
"how_it_works": "How it works",
"blog": "Blog", "blog": "Blog",
"occasions": { "occasions": {
"label": "Occasions",
"wedding": "Wedding", "wedding": "Wedding",
"birthday": "Birthday", "birthday": "Birthday",
"confirmation": "Confirmation/Youth dedication",
"corporate": "Corporate Event" "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": { "login": {
"title": "Die Fotospiel App", "title": "Die Fotospiel App",
"description": "Sign in with your Fotospiel account to manage every event in one place.", "description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel App", "brand": "Die Fotospiel App",
"logo_alt": "Fotospiel App logo", "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": "Email or Username",
"identifier_placeholder": "you@example.com or username", "identifier_placeholder": "you@example.com or username",
"username_or_email": "Username or Email", "username_or_email": "Username or Email",
@@ -39,6 +64,20 @@
"no_account": "Don't have access yet?", "no_account": "Don't have access yet?",
"sign_up": "Create an account" "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": { "register": {
"title": "Register", "title": "Register",
"name": "Full Name", "name": "Full Name",

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\URL;
use Tests\TestCase; use Tests\TestCase;
class LoginTest extends TestCase class LoginTest extends TestCase
@@ -221,6 +222,30 @@ class LoginTest extends TestCase
$response->assertRedirect(route('verification.notice', absolute: false)); $response->assertRedirect(route('verification.notice', absolute: false));
} }
public function test_login_redirects_unverified_user_to_verification_link_when_intended(): void
{
$user = User::factory()->create([
'email' => 'verify@example.com',
'password' => bcrypt('password'),
'email_verified_at' => null,
]);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
absolute: false,
);
$response = $this->withSession(['url.intended' => $verificationUrl])->post(route('login.store'), [
'login' => 'verify@example.com',
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect($verificationUrl);
}
public function test_rate_limiting_on_failed_logins() public function test_rate_limiting_on_failed_logins()
{ {
$user = User::factory()->create([ $user = User::factory()->create([