diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php
index e80184e..24a6951 100644
--- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php
+++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php
@@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller
$user = Auth::user();
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'));
}
@@ -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
{
$candidate = $this->decodeBase64Url($value) ?? $value;
diff --git a/public/lang/de/auth.json b/public/lang/de/auth.json
index 0ba8a46..d8521a6 100644
--- a/public/lang/de/auth.json
+++ b/public/lang/de/auth.json
@@ -10,19 +10,44 @@
"register": "Registrieren",
"home": "Startseite",
"packages": "Pakete",
+ "how_it_works": "So geht's",
"blog": "Blog",
"occasions": {
+ "label": "Anlässe",
"wedding": "Hochzeit",
"birthday": "Geburtstag",
+ "confirmation": "Konfirmation/Jugendweihe",
"corporate": "Firmenevent"
},
- "contact": "Kontakt"
+ "contact": "Kontakt",
+ "cta": "Jetzt ausprobieren",
+ "utility": "Darstellung und Sprache öffnen",
+ "appearance": "Darstellung",
+ "appearance_light": "Hell",
+ "appearance_dark": "Dunkel",
+ "language": "Sprache"
},
"login": {
"title": "Die Fotospiel App",
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel App",
"logo_alt": "Logo Die Fotospiel App",
+ "hero_tagline": "Event-Tech mit Herz",
+ "hero_heading": "Willkommen zurück bei der Fotospiel App",
+ "hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.",
+ "hero_footer": {
+ "headline": "Noch kein Account?",
+ "subline": "Entdecke unsere Packages und erlebe Fotospiel live.",
+ "cta": "Packages entdecken"
+ },
+ "highlights": {
+ "moments": "Momente in Echtzeit teilen",
+ "moments_description": "Uploads landen sofort in der Event-Galerie – ohne App-Download.",
+ "branding": "Branding & Slideshows, die begeistern",
+ "branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.",
+ "privacy": "Sicherer Zugang über Tokens",
+ "privacy_description": "Eventzugänge bleiben geschützt – DSGVO-konform mit Join Tokens."
+ },
"identifier": "E-Mail oder Username",
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
"username_or_email": "Username oder E-Mail",
@@ -39,6 +64,20 @@
"no_account": "Noch keinen Zugang?",
"sign_up": "Jetzt registrieren"
},
+ "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": {
"title": "Registrieren",
"name": "Vollständiger Name",
diff --git a/public/lang/en/auth.json b/public/lang/en/auth.json
index 2e59574..02cefe4 100644
--- a/public/lang/en/auth.json
+++ b/public/lang/en/auth.json
@@ -10,19 +10,44 @@
"register": "Register",
"home": "Home",
"packages": "Packages",
+ "how_it_works": "How it works",
"blog": "Blog",
"occasions": {
+ "label": "Occasions",
"wedding": "Wedding",
"birthday": "Birthday",
+ "confirmation": "Confirmation/Youth dedication",
"corporate": "Corporate Event"
},
- "contact": "Contact"
+ "contact": "Contact",
+ "cta": "Try now",
+ "utility": "Open appearance and language",
+ "appearance": "Appearance",
+ "appearance_light": "Light",
+ "appearance_dark": "Dark",
+ "language": "Language"
},
"login": {
"title": "Die Fotospiel App",
"description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel App",
"logo_alt": "Fotospiel App logo",
+ "hero_tagline": "Event tech with heart",
+ "hero_heading": "Welcome back to the Fotospiel App",
+ "hero_subheading": "Manage events, galleries, and guest lists in one beautifully crafted dashboard.",
+ "hero_footer": {
+ "headline": "No account yet?",
+ "subline": "Explore our packages and experience Fotospiel live.",
+ "cta": "Discover packages"
+ },
+ "highlights": {
+ "moments": "Share moments in real time",
+ "moments_description": "Uploads land instantly in the event gallery — no app download needed.",
+ "branding": "Branding & slideshows that impress",
+ "branding_description": "Configure slideshows, watermarks, and tasks for your event.",
+ "privacy": "Secure access via tokens",
+ "privacy_description": "Event access stays protected — GDPR-compliant with join tokens."
+ },
"identifier": "Email or Username",
"identifier_placeholder": "you@example.com or username",
"username_or_email": "Username or Email",
@@ -39,6 +64,20 @@
"no_account": "Don't have access yet?",
"sign_up": "Create an account"
},
+ "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": {
"title": "Register",
"name": "Full Name",
diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json
index 1c1a400..010b7ec 100644
--- a/resources/js/admin/i18n/locales/de/common.json
+++ b/resources/js/admin/i18n/locales/de/common.json
@@ -72,6 +72,31 @@
"action": "Installieren",
"iosHint": "iOS: Teilen → Zum Home-Bildschirm."
},
+ "consent": {
+ "banner": {
+ "title": "Cookie-Einstellungen",
+ "body": "Wir verwenden notwendige Cookies für den Betrieb. Optionale Analyse-Cookies helfen uns, die App zu verbessern.",
+ "reject": "Ablehnen",
+ "customize": "Anpassen",
+ "accept": "Alle akzeptieren"
+ },
+ "modal": {
+ "title": "Cookie-Auswahl",
+ "description": "Wähle, welche Cookies du erlaubst. Du kannst das jederzeit ändern.",
+ "functional": "Notwendige Cookies",
+ "functional_desc": "Erforderlich für Kernfunktionen und Sicherheit.",
+ "required": "Erforderlich",
+ "analytics": "Analyse-Cookies",
+ "analytics_desc": "Helfen uns, die Nutzung zu verstehen und die Performance zu verbessern.",
+ "reject_all": "Alle ablehnen",
+ "accept_all": "Alle akzeptieren",
+ "cancel": "Abbrechen",
+ "save": "Auswahl speichern"
+ },
+ "accessibility": {
+ "banner_label": "Cookie-Hinweis"
+ }
+ },
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.",
diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json
index b2ed61f..2cd4b4f 100644
--- a/resources/js/admin/i18n/locales/en/common.json
+++ b/resources/js/admin/i18n/locales/en/common.json
@@ -72,6 +72,31 @@
"action": "Install",
"iosHint": "On iOS: Share → Add to Home Screen."
},
+ "consent": {
+ "banner": {
+ "title": "Cookie preferences",
+ "body": "We use essential cookies to run the app. Optional analytics help us improve your experience.",
+ "reject": "Reject",
+ "customize": "Customize",
+ "accept": "Accept all"
+ },
+ "modal": {
+ "title": "Cookie settings",
+ "description": "Choose which cookies you allow. You can change this anytime.",
+ "functional": "Essential cookies",
+ "functional_desc": "Required for core features and security.",
+ "required": "Required",
+ "analytics": "Analytics cookies",
+ "analytics_desc": "Help us understand usage to improve performance.",
+ "reject_all": "Reject all",
+ "accept_all": "Accept all",
+ "cancel": "Cancel",
+ "save": "Save selection"
+ },
+ "accessibility": {
+ "banner_label": "Cookie consent"
+ }
+ },
"errors": {
"generic": "Something went wrong. Please try again.",
"eventLimit": "Your current package has no remaining event bundle.",
diff --git a/resources/js/admin/mobile/AuthCallbackPage.tsx b/resources/js/admin/mobile/AuthCallbackPage.tsx
index 72ceb33..29de24e 100644
--- a/resources/js/admin/mobile/AuthCallbackPage.tsx
+++ b/resources/js/admin/mobile/AuthCallbackPage.tsx
@@ -1,15 +1,21 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
+import { Card } from '@tamagui/card';
+import { YStack } from '@tamagui/stacks';
+import { SizableText as Text } from '@tamagui/text';
+import { Spinner } from 'tamagui';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
+import { useAdminTheme } from './theme';
export default function AuthCallbackPage(): React.ReactElement {
const { status } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation('auth');
const [redirected, setRedirected] = React.useState(false);
+ const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -40,12 +46,34 @@ export default function AuthCallbackPage(): React.ReactElement {
}, [destination, navigate, redirected, status]);
return (
-
-
{t('processing.title', 'Signing you in …')}
-
{t('processing.copy', 'One moment please while we prepare your dashboard.')}
-
+
+
+
+
+ {t('processing.title', 'Signing you in …')}
+
+
+ {t('processing.copy', 'One moment please while we prepare your dashboard.')}
+
+
+
+
);
}
diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx
index c4485cc..7a3decb 100644
--- a/resources/js/admin/mobile/BrandingPage.tsx
+++ b/resources/js/admin/mobile/BrandingPage.tsx
@@ -8,6 +8,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { Slider } from 'tamagui';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
+import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect } from './components/FormControls';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
import { isAuthError } from '../auth/tokens';
import { ApiError, getApiErrorMessage } from '../lib/apiError';
@@ -363,22 +364,17 @@ export default function MobileBrandingPage() {
{t('events.watermark.title', 'Wasserzeichen')}
- {
- if (controlsLocked) return;
- if (value === 'custom' || value === 'base' || value === 'off') {
- setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] }));
- }
- }}
- onPicker={() => undefined}
- >
-
-
+
+
{mode === 'custom' && !controlsLocked ? (
@@ -414,11 +410,9 @@ export default function MobileBrandingPage() {
- {
const file = event.target.files?.[0];
if (!file) return;
@@ -855,11 +849,9 @@ export default function MobileBrandingPage() {
>
)}
- {
const file = event.target.files?.[0];
@@ -1171,24 +1163,22 @@ function ColorField({
onChange: (next: string) => void;
disabled?: boolean;
}) {
- const { textStrong, muted, border, surface } = useAdminTheme();
+ const { textStrong, muted } = useAdminTheme();
return (
{label}
-
- onChange(event.target.value)}
- disabled={disabled}
- style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
- />
-
- {value}
-
-
+
+ onChange(event.target.value)}
+ disabled={disabled}
+ />
+
+ {value}
+
+
);
}
@@ -1211,7 +1201,6 @@ function InputField({
placeholder,
onChange,
onPicker,
- children,
disabled,
}: {
label: string;
@@ -1219,51 +1208,27 @@ function InputField({
placeholder?: string;
onChange: (next: string) => void;
onPicker?: () => void;
- children?: React.ReactNode;
disabled?: boolean;
}) {
- const { textStrong, border, surface, primary } = useAdminTheme();
+ const { primary } = useAdminTheme();
return (
-
-
- {label}
-
-
- {children ?? (
- onChange(event.target.value)}
- disabled={disabled}
- style={{
- flex: 1,
- height: '100%',
- border: 'none',
- outline: 'none',
- fontSize: 14,
- background: 'transparent',
- }}
- onFocus={onPicker}
- />
- )}
+
+
+ onChange(event.target.value)}
+ onFocus={onPicker}
+ disabled={disabled}
+ style={{ flex: 1 }}
+ />
{onPicker ? (
) : null}
-
+
);
}
diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx
index bc94040..35801e1 100644
--- a/resources/js/admin/mobile/EventFormPage.tsx
+++ b/resources/js/admin/mobile/EventFormPage.tsx
@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
-import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
+import { MobileDateTimeInput, MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import {
createEvent,
@@ -401,14 +401,9 @@ export default function MobileEventFormPage() {
- setForm((prev) => ({ ...prev, date: value }))}
- border={border}
- surface={surface}
- text={text}
- primary={primary}
- danger={danger}
+ onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))}
style={{ flex: 1 }}
/>
@@ -624,57 +619,6 @@ function toDateTimeLocal(value?: string | null): string {
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
}
-function NativeDateTimeInput({
- value,
- onChange,
- border,
- surface,
- text,
- primary,
- danger,
- hasError,
- style,
-}: {
- value: string;
- onChange: (value: string) => void;
- border: string;
- surface: string;
- text: string;
- primary: string;
- danger: string;
- hasError?: boolean;
- style?: React.CSSProperties;
-}) {
- const [focused, setFocused] = React.useState(false);
- const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
- const borderColor = hasError ? danger : focused ? primary : border;
-
- return (
- onChange(event.target.value)}
- onFocus={() => setFocused(true)}
- onBlur={() => setFocused(false)}
- style={{
- width: '100%',
- height: 44,
- padding: '0 12px',
- borderRadius: 12,
- borderWidth: 1,
- borderStyle: 'solid',
- borderColor,
- backgroundColor: surface,
- color: text,
- fontSize: 14,
- boxShadow: focused ? `0 0 0 3px ${ringColor}` : undefined,
- outline: 'none',
- ...style,
- }}
- />
- );
-}
-
function resolveLocation(event: TenantEvent): string {
const settings = (event.settings ?? {}) as Record;
const candidate =
diff --git a/resources/js/admin/mobile/EventGuestNotificationsPage.tsx b/resources/js/admin/mobile/EventGuestNotificationsPage.tsx
index 913592e..286dca0 100644
--- a/resources/js/admin/mobile/EventGuestNotificationsPage.tsx
+++ b/resources/js/admin/mobile/EventGuestNotificationsPage.tsx
@@ -203,7 +203,7 @@ export default function MobileEventGuestNotificationsPage() {
) : null}
-
+
{t('guestMessages.composeTitle', 'Send a message')}
@@ -299,7 +299,7 @@ export default function MobileEventGuestNotificationsPage() {
) : null}
-
+
diff --git a/resources/js/admin/mobile/EventRecapPage.tsx b/resources/js/admin/mobile/EventRecapPage.tsx
index d92917c..372b7ef 100644
--- a/resources/js/admin/mobile/EventRecapPage.tsx
+++ b/resources/js/admin/mobile/EventRecapPage.tsx
@@ -5,6 +5,7 @@ import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } from 'lucide-r
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
+import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { LegalConsentSheet } from './components/LegalConsentSheet';
@@ -315,12 +316,9 @@ function ToggleOption({ label, value, onToggle }: { label: string; value: boolea
{label}
- onToggle(e.target.checked)}
- style={{ width: 20, height: 20 }}
- />
+
+
+
);
}
diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx
index 3fc74cd..acae514 100644
--- a/resources/js/admin/mobile/EventTasksPage.tsx
+++ b/resources/js/admin/mobile/EventTasksPage.tsx
@@ -11,6 +11,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { Button } from '@tamagui/button';
import { AlertDialog } from '@tamagui/alert-dialog';
import { ScrollView } from '@tamagui/scroll-view';
+import { ToggleGroup } from '@tamagui/toggle-group';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
@@ -183,28 +184,34 @@ function SummaryLegendItem({
}
function QuickNavChip({
+ value,
label,
count,
onPress,
+ isActive = false,
}: {
+ value: TaskSectionKey;
label: string;
count: number;
onPress: () => void;
+ isActive?: boolean;
}) {
- const { textStrong, border, surface, surfaceMuted } = useAdminTheme();
+ const { textStrong, border, surface, surfaceMuted, primary } = useAdminTheme();
+ const activeBorder = withAlpha(primary, 0.45);
+ const activeBackground = withAlpha(primary, 0.16);
return (
-
-
+
+
{label}
@@ -213,7 +220,7 @@ function QuickNavChip({
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
- borderColor={border}
+ borderColor={isActive ? activeBorder : border}
backgroundColor={surfaceMuted}
>
@@ -221,7 +228,7 @@ function QuickNavChip({
-
+
);
}
@@ -263,6 +270,7 @@ export default function MobileEventTasksPage() {
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
+ const [quickNavSelection, setQuickNavSelection] = React.useState('');
const text = textStrong;
const assignedRef = React.useRef(null);
const libraryRef = React.useRef(null);
@@ -623,16 +631,34 @@ export default function MobileEventTasksPage() {
-
- {sectionCounts.map((section) => (
- handleQuickNav(section.key)}
- />
- ))}
-
+ {
+ const key = next as TaskSectionKey | '';
+ if (!key) {
+ return;
+ }
+ setQuickNavSelection(key);
+ handleQuickNav(key);
+ }}
+ >
+
+ {sectionCounts.map((section) => (
+ {
+ setQuickNavSelection(section.key);
+ handleQuickNav(section.key);
+ }}
+ isActive={quickNavSelection === section.key}
+ />
+ ))}
+
+
@@ -770,7 +796,7 @@ export default function MobileEventTasksPage() {
) : (
-
+
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
@@ -822,7 +848,7 @@ export default function MobileEventTasksPage() {
))}
-
+
{t('events.tasks.library', 'Weitere Aufgaben')}
diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx
index 033fe2c..08c83c8 100644
--- a/resources/js/admin/mobile/EventsPage.tsx
+++ b/resources/js/admin/mobile/EventsPage.tsx
@@ -1,12 +1,16 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
+import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
+import { ScrollView } from '@tamagui/scroll-view';
+import { ToggleGroup } from '@tamagui/toggle-group';
+import { Separator } from '@tamagui/separator';
import { useTranslation } from 'react-i18next';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
-import { MobileCard, PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
+import { PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
import { MobileInput } from './components/FormControls';
import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants';
@@ -27,7 +31,7 @@ export default function MobileEventsPage() {
const [statusFilter, setStatusFilter] = React.useState('all');
const searchRef = React.useRef(null);
const back = useBackNavigation();
- const { text, muted, subtle, border, primary, danger, surface, accentSoft, accent } = useAdminTheme();
+ const { text, muted, subtle, border, primary, danger, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
React.useEffect(() => {
(async () => {
try {
@@ -54,22 +58,61 @@ export default function MobileEventsPage() {
}
>
{error ? (
-
+
{error}
-
+
) : null}
- setQuery(e.target.value)}
- placeholder={t('events.list.search', 'Search events')}
- compact
- style={{ marginBottom: 12 }}
- />
+
+
+
+
+ {t('events.list.filters.title', 'Filters & Search')}
+
+
+ setQuery(e.target.value)}
+ placeholder={t('events.list.search', 'Search events')}
+ compact
+ />
+
+ {t('events.list.filters.hint', 'Filter your events by status or search by name.')}
+
+
+
{loading ? (
@@ -78,15 +121,27 @@ export default function MobileEventsPage() {
))}
) : events.length === 0 ? (
-
-
- {t('events.list.empty.title', 'Noch kein Event angelegt')}
-
-
- {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
-
- navigate(adminPath('/events/new'))} />
-
+
+
+
+ {t('events.list.empty.title', 'Noch kein Event angelegt')}
+
+
+ {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
+
+ navigate(adminPath('/events/new'))} />
+
+
) : (
void;
}) {
const { t } = useTranslation('management');
- const { text, muted, subtle, border, primary, surface, accentSoft, accent } = useAdminTheme();
+ const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
const activeBg = accentSoft;
const activeBorder = accent;
@@ -150,47 +205,93 @@ function EventsList({
return (
-
- {filters.map((filter) => {
- const active = filter.key === statusFilter;
- return (
- onStatusChange(filter.key)} style={{ flexGrow: 1 }}>
-
-
- {filter.label}
-
- {filter.count}
+
+
+
+
+ {t('events.list.filters.status', 'Status')}
+
+
+
+ value && onStatusChange(value as EventStatusKey)}
+ >
+
+ {filters.map((filter) => {
+ const active = filter.key === statusFilter;
+ return (
+
+
+
+ {filter.label}
+
+ {filter.count}
+
+
+ );
+ })}
-
- );
- })}
-
+
+
+
+
{filteredEvents.length === 0 ? (
-
-
- {t('events.list.empty.filtered', 'No events match this filter.')}
-
-
- {t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
-
- onStatusChange('all')}
- />
-
+
+
+
+ {t('events.list.empty.filtered', 'No events match this filter.')}
+
+
+ {t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
+
+ onStatusChange('all')}
+ />
+
+
) : (
filteredEvents.map((event) => {
const statusKey = resolveEventStatusKey(event);
@@ -210,6 +311,8 @@ function EventsList({
subtle={subtle}
border={border}
primary={primary}
+ surface={surface}
+ shadow={shadow}
statusLabel={statusLabel}
statusTone={statusTone}
onOpen={onOpen}
@@ -229,6 +332,8 @@ function EventRow({
subtle,
border,
primary,
+ surface,
+ shadow,
statusLabel,
statusTone,
onOpen,
@@ -240,6 +345,8 @@ function EventRow({
subtle: string;
border: string;
primary: string;
+ surface: string;
+ shadow: string;
statusLabel: string;
statusTone: 'success' | 'warning' | 'muted';
onOpen: (slug: string) => void;
@@ -248,62 +355,77 @@ function EventRow({
const { t } = useTranslation('management');
const stats = buildEventListStats(event);
return (
-
-
-
-
- {renderName(event.name)}
-
-
-
-
- {formatDate(event.event_date)}
+
+
+
+
+
+ {renderName(event.name)}
-
-
-
-
- {resolveLocation(event)}
+
+
+
+ {formatDate(event.event_date)}
+
+
+
+
+
+ {resolveLocation(event)}
+
+
+ {statusLabel}
+
+ onEdit(event.slug)}>
+
+ ˅
-
- {statusLabel}
-
-
-
-
-
-
- onEdit(event.slug)}>
-
- ˅
-
-
-
-
- onOpen(event.slug)} style={{ marginTop: 8 }}>
-
-
-
- {t('events.list.actions.open', 'Open event')}
-
+
-
-
+
+
+
+
+
+
+
+
+
+ onOpen(event.slug)}>
+
+
+
+ {t('events.list.actions.open', 'Open event')}
+
+
+
+
+
);
}
diff --git a/resources/js/admin/mobile/LoginStartPage.tsx b/resources/js/admin/mobile/LoginStartPage.tsx
index de4d251..c47c570 100644
--- a/resources/js/admin/mobile/LoginStartPage.tsx
+++ b/resources/js/admin/mobile/LoginStartPage.tsx
@@ -2,12 +2,18 @@ import React from 'react';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
+import { Card } from '@tamagui/card';
+import { YStack } from '@tamagui/stacks';
+import { SizableText as Text } from '@tamagui/text';
+import { Spinner } from 'tamagui';
import { ADMIN_LOGIN_PATH } from '../constants';
+import { useAdminTheme } from './theme';
export default function LoginStartPage(): React.ReactElement {
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation('auth');
+ const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -25,11 +31,34 @@ export default function LoginStartPage(): React.ReactElement {
}, [location.search, navigate]);
return (
-
-
{t('redirecting', 'Redirecting to login …')}
-
+
+
+
+
+ {t('redirecting', 'Redirecting to login …')}
+
+
+ {t('redirectingHint', 'One moment, preparing the login screen.')}
+
+
+
+
);
}
diff --git a/resources/js/admin/mobile/LogoutPage.tsx b/resources/js/admin/mobile/LogoutPage.tsx
index b3d7d9f..f988715 100644
--- a/resources/js/admin/mobile/LogoutPage.tsx
+++ b/resources/js/admin/mobile/LogoutPage.tsx
@@ -1,9 +1,15 @@
import React from 'react';
+import { Card } from '@tamagui/card';
+import { YStack } from '@tamagui/stacks';
+import { SizableText as Text } from '@tamagui/text';
+import { Spinner } from 'tamagui';
import { useAuth } from '../auth/context';
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
+import { useAdminTheme } from './theme';
export default function LogoutPage() {
const { logout } = useAuth();
+ const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -14,13 +20,34 @@ export default function LogoutPage() {
}, [logout]);
return (
-
-
- Abmeldung wird vorbereitet ...
-
-
+
+
+
+
+ Abmeldung wird vorbereitet ...
+
+
+ Bitte einen Moment Geduld.
+
+
+
+
);
}
diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx
index 07b3429..4b6a3d1 100644
--- a/resources/js/admin/mobile/NotificationsPage.tsx
+++ b/resources/js/admin/mobile/NotificationsPage.tsx
@@ -77,7 +77,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
};
return (
-
+
);
}
diff --git a/resources/js/admin/mobile/ProfilePage.tsx b/resources/js/admin/mobile/ProfilePage.tsx
index 23d58b9..43b8203 100644
--- a/resources/js/admin/mobile/ProfilePage.tsx
+++ b/resources/js/admin/mobile/ProfilePage.tsx
@@ -1,14 +1,15 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { LogOut, User, Settings, Shield, Globe, Moon, Download } from 'lucide-react';
+import { User, Settings, Globe, Moon, Download, LogOut } from 'lucide-react';
+import { Avatar } from '@tamagui/avatar';
+import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
-import { Pressable } from '@tamagui/react-native-web-lite';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { MobileShell } from './components/MobileShell';
-import { MobileCard, CTAButton } from './components/Primitives';
+import { CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api';
@@ -16,6 +17,7 @@ import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '
import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation';
+import { withAlpha } from './components/colors';
import { useAdminTheme } from './theme';
export default function MobileProfilePage() {
@@ -23,7 +25,7 @@ export default function MobileProfilePage() {
const navigate = useNavigate();
const { t } = useTranslation('management');
const { appearance, updateAppearance } = useAppearance();
- const { textStrong, muted, border, accentSoft, primary, subtle } = useAdminTheme();
+ const { textStrong, muted, border, accentSoft, primary, subtle, surface, surfaceMuted, shadow, danger } = useAdminTheme();
const textColor = textStrong;
const mutedText = muted;
const borderColor = border;
@@ -54,100 +56,155 @@ export default function MobileProfilePage() {
title={t('mobileProfile.title', 'Profile')}
onBack={back}
>
-
-
-
-
-
- {name}
-
-
- {email}
-
- {role ? (
-
- {role}
-
- ) : null}
-
+
+
+
+
+
+
+
+
+
+ {name}
+
+
+ {email}
+
+ {role ? (
+
+ {role}
+
+ ) : null}
+
+
+
-
-
- {t('mobileProfile.settings', 'Settings')}
-
-
+
+
+
+
+ {t('mobileProfile.settings', 'Settings')}
+
+
- navigate(ADMIN_PROFILE_ACCOUNT_PATH)}>
-
- {t('mobileProfile.account', 'Account bearbeiten')}
-
- }
- iconAfter={}
- />
-
+
+ {t('mobileProfile.account', 'Account bearbeiten')}
+
+ }
+ iconAfter={}
+ onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}
+ />
- navigate(adminPath('/mobile/billing#packages'))}>
-
- {t('billing.sections.packages.title', 'Packages & Billing')}
-
- }
- iconAfter={}
- />
-
+
+ {t('billing.sections.packages.title', 'Packages & Billing')}
+
+ }
+ iconAfter={}
+ onPress={() => navigate(adminPath('/mobile/billing#packages'))}
+ />
- navigate(adminPath('/mobile/billing#invoices'))}>
-
- {t('billing.sections.invoices.title', 'Invoices & Payments')}
-
- }
- iconAfter={}
- />
-
+
+ {t('billing.sections.invoices.title', 'Invoices & Payments')}
+
+ }
+ iconAfter={}
+ onPress={() => navigate(adminPath('/mobile/billing#invoices'))}
+ />
- navigate(ADMIN_DATA_EXPORTS_PATH)}>
-
- {t('dataExports.title', 'Data exports')}
-
- }
- iconAfter={}
- />
-
+
+ {t('dataExports.title', 'Data exports')}
+
+ }
+ iconAfter={}
+ onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}
+ />
+
+
+
+
+
+
+
+
+ {t('mobileProfile.preferences', 'Preferences')}
+
+
+
-
+
- {
- logout();
- navigate(adminPath('/logout'));
- }}
- />
+
+
+
+
+
+
+
+ {t('mobileProfile.logoutTitle', 'Sign out')}
+
+
+
+ {t('mobileProfile.logoutHint', 'Sign out from this device.')}
+
+ {
+ logout();
+ navigate(adminPath('/logout'));
+ }}
+ />
+
+
);
}
diff --git a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx
index 21c60a4..b442357 100644
--- a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx
+++ b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx
@@ -5,12 +5,12 @@ import { ArrowLeft, RefreshCcw, Plus, ChevronDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
-import { Input, TextArea } from 'tamagui';
import { Accordion } from '@tamagui/accordion';
import { HexColorPicker } from 'react-colorful';
import { Portal } from '@tamagui/portal';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
+import { MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import {
TenantEvent,
EventQrInvite,
@@ -753,7 +753,7 @@ function BackgroundStep({
{presets.map((preset) => {
const isSelected = selectedPreset === preset.id;
return (
- onSelectPreset(preset.id)} style={{ width: '48%' }}>
+ onSelectPreset(preset.id)} style={{ width: '48%' }}>
- {gradientPresets.map((gradient, idx) => {
+ {gradientPresets.map((gradient) => {
const isSelected =
selectedGradient &&
selectedGradient.angle === gradient.angle &&
@@ -803,9 +803,10 @@ function BackgroundStep({
const style = {
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
} as React.CSSProperties;
+ const key = `${gradient.labelKey}-${gradient.angle}`;
return (
onSelectGradient(gradient)}
style={{ width: '30%' }}
aria-label={t(gradient.labelKey, gradient.label)}
@@ -829,10 +830,11 @@ function BackgroundStep({
{t('events.qr.colors', 'Vollfarbe')}
- {solidPresets.map((color) => {
+ {solidPresets.map((color, idx) => {
const isSelected = selectedSolid === color;
+ const key = `${color}-${idx}`;
return (
- onSelectSolid(color)} style={{ width: '20%' }}>
+ onSelectSolid(color)} style={{ width: '20%' }}>
{t('events.qr.textFields', 'Texte')}
- updateField('headline', val)}
- size="$4"
+ onChange={(event) => updateField('headline', event.target.value)}
/>
- updateField('subtitle', val)}
- size="$4"
+ onChange={(event) => updateField('subtitle', event.target.value)}
/>
-
@@ -939,13 +937,12 @@ function TextStep({
{textFields.instructions.map((item, idx) => (
-
@@ -1424,12 +1401,12 @@ function LayoutControls({
backgroundColor={override.color ?? slot.color ?? textStrong}
/>
- onUpdateSlot(slotKey, { color: event.target.value })}
- style={{ ...numberInputStyle, width: 110 }}
+ compact
+ style={{ width: 110, backgroundColor: surfaceMuted }}
/>
{openColorSlot === slotKey ? (
@@ -1496,15 +1473,14 @@ function LayoutControls({
{t('events.qr.align', 'Align')}
-
+
diff --git a/resources/js/admin/mobile/QrPrintPage.tsx b/resources/js/admin/mobile/QrPrintPage.tsx
index b9fc4eb..688bea9 100644
--- a/resources/js/admin/mobile/QrPrintPage.tsx
+++ b/resources/js/admin/mobile/QrPrintPage.tsx
@@ -7,6 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
+import { MobileInput, MobileTextArea } from './components/FormControls';
import {
TenantEvent,
EventQrInvite,
@@ -489,20 +490,20 @@ function TextStep({
{t('events.qr.textFields', 'Texte')}
- updateField('headline', val)}
+ onChange={(event) => updateField('headline', event.target.value)}
/>
- updateField('subtitle', val)}
+ onChange={(event) => updateField('subtitle', event.target.value)}
/>
- updateField('description', val)}
+ onChange={(event) => updateField('description', event.target.value)}
/>
@@ -512,11 +513,11 @@ function TextStep({
{textFields.instructions.map((item, idx) => (
- updateInstruction(idx, val)}
+ onChange={(event) => updateInstruction(idx, event.target.value)}
/>
);
}
-function StyledInput({
- value,
- onChangeText,
- placeholder,
- flex,
-}: {
- value: string;
- onChangeText: (value: string) => void;
- placeholder?: string;
- flex?: number;
-}) {
- const { border } = useAdminTheme();
- return (
- onChangeText(e.target.value)}
- placeholder={placeholder}
- />
- );
-}
-
-function StyledTextarea({
- value,
- onChangeText,
- placeholder,
-}: {
- value: string;
- onChangeText: (value: string) => void;
- placeholder?: string;
-}) {
- const { border } = useAdminTheme();
- return (
-
+
+ {events.map((event) => (
+
+
+
+ {resolveEventDisplayName(event)}
+
+
+ {formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
+
+
+ }
+ iconAfter={
+
+ {t('mobileUploads.open', 'Open')}
+
+ }
+ onPress={() => {
+ selectEvent(event.slug ?? null);
+ if (event.slug) {
+ navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
+ }
+ }}
+ />
+
+ ))}
+
+
+
);
}
diff --git a/resources/js/admin/mobile/__tests__/AuthCallbackPage.test.tsx b/resources/js/admin/mobile/__tests__/AuthCallbackPage.test.tsx
new file mode 100644
index 0000000..87fa99a
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/AuthCallbackPage.test.tsx
@@ -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) => {
+ 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 }) => {children}
,
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('tamagui', () => ({
+ Spinner: () => spinner
,
+}));
+
+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();
+
+ expect(screen.getByText('Signing you in …')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx
index d226d99..6b4ea7c 100644
--- a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx
+++ b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx
@@ -38,6 +38,21 @@ vi.mock('../components/Primitives', () => ({
SkeletonCard: () => Loading...
,
}));
+vi.mock('../components/FormControls', () => ({
+ MobileField: ({ label, children }: { label: string; children: React.ReactNode }) => (
+
+ {label}
+ {children}
+
+ ),
+ MobileColorInput: (props: React.InputHTMLAttributes) => ,
+ MobileFileInput: (props: React.InputHTMLAttributes) => ,
+ MobileInput: (props: React.InputHTMLAttributes) => ,
+ MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes) => (
+
+ ),
+}));
+
vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => {children}
,
}));
diff --git a/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx b/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx
index d39c649..1351fa6 100644
--- a/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx
+++ b/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx
@@ -45,6 +45,7 @@ vi.mock('../components/Primitives', () => ({
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => {children}
,
+ MobileDateTimeInput: (props: React.InputHTMLAttributes) => ,
MobileInput: (props: React.InputHTMLAttributes) => ,
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => ,
MobileTextArea: (props: React.TextareaHTMLAttributes) => ,
diff --git a/resources/js/admin/mobile/__tests__/EventGuestNotificationsPage.test.tsx b/resources/js/admin/mobile/__tests__/EventGuestNotificationsPage.test.tsx
new file mode 100644
index 0000000..a4108f8
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/EventGuestNotificationsPage.test.tsx
@@ -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) => {
+ 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 }) => {children}
,
+ HeaderActionButton: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('../components/Primitives', () => ({
+ MobileCard: ({ children }: { children: React.ReactNode }) => {children}
,
+ CTAButton: ({ label }: { label: string }) => ,
+ PillBadge: ({ children }: { children: React.ReactNode }) => {children}
,
+ SkeletonCard: () => Loading...
,
+}));
+
+vi.mock('../components/FormControls', () => ({
+ MobileField: ({ children }: { children: React.ReactNode }) => {children}
,
+ MobileInput: (props: React.InputHTMLAttributes) => ,
+ MobileSelect: ({ children }: { children: React.ReactNode }) => ,
+ MobileTextArea: (props: React.TextareaHTMLAttributes) => ,
+}));
+
+vi.mock('@tamagui/react-native-web-lite', () => ({
+ Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
+
+ ),
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+ XStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+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();
+
+ expect(await screen.findByText('Send a message')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx b/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx
new file mode 100644
index 0000000..3b1202c
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx
@@ -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) => {
+ 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 }) => {children}
,
+ HeaderActionButton: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('../components/Primitives', () => ({
+ MobileCard: ({ children }: { children: React.ReactNode }) => {children}
,
+ CTAButton: ({ label }: { label: string }) => ,
+ PillBadge: ({ children }: { children: React.ReactNode }) => {children}
,
+ SkeletonCard: () => Loading...
,
+}));
+
+vi.mock('../components/LegalConsentSheet', () => ({
+ LegalConsentSheet: () => ,
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+ XStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('@tamagui/react-native-web-lite', () => ({
+ Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
+
+ ),
+}));
+
+vi.mock('@tamagui/switch', () => ({
+ Switch: Object.assign(
+ ({ children, checked, onCheckedChange, 'aria-label': ariaLabel }: any) => (
+
+ ),
+ { Thumb: () => },
+ ),
+}));
+
+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();
+
+ 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();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/EventTasksPage.test.tsx b/resources/js/admin/mobile/__tests__/EventTasksPage.test.tsx
index a09de4b..6822751 100644
--- a/resources/js/admin/mobile/__tests__/EventTasksPage.test.tsx
+++ b/resources/js/admin/mobile/__tests__/EventTasksPage.test.tsx
@@ -89,6 +89,12 @@ vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => {children}
,
}));
+vi.mock('@tamagui/toggle-group', () => ({
+ ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => {children}
, {
+ Item: ({ children }: { children: React.ReactNode }) => {children}
,
+ }),
+}));
+
vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => {children}
, {
Item: ({ children }: { children: React.ReactNode }) => {children}
,
diff --git a/resources/js/admin/mobile/__tests__/EventsPage.test.tsx b/resources/js/admin/mobile/__tests__/EventsPage.test.tsx
new file mode 100644
index 0000000..f8b54a8
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/EventsPage.test.tsx
@@ -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) => {
+ 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 }) => {children}
,
+ HeaderActionButton: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('../components/Primitives', () => ({
+ PillBadge: ({ children }: { children: React.ReactNode }) => {children}
,
+ CTAButton: ({ label }: { label: string }) => ,
+ FloatingActionButton: ({ label }: { label: string }) => {label}
,
+ SkeletonCard: () => Loading...
,
+}));
+
+vi.mock('../components/FormControls', () => ({
+ MobileInput: (props: React.InputHTMLAttributes) => ,
+}));
+
+vi.mock('@tamagui/card', () => ({
+ Card: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/scroll-view', () => ({
+ ScrollView: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/react-native-web-lite', () => ({
+ Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
+
+ ),
+}));
+
+vi.mock('@tamagui/toggle-group', () => ({
+ ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => {children}
, {
+ Item: ({ children }: { children: React.ReactNode }) => {children}
,
+ }),
+}));
+
+vi.mock('@tamagui/separator', () => ({
+ Separator: () => ,
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+ XStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+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();
+
+ expect(await screen.findByText('Filters & Search')).toBeInTheDocument();
+ expect(screen.getByText('Status')).toBeInTheDocument();
+ expect(screen.getByText('Demo Event')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/LoginStartPage.test.tsx b/resources/js/admin/mobile/__tests__/LoginStartPage.test.tsx
new file mode 100644
index 0000000..65a94f3
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/LoginStartPage.test.tsx
@@ -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) => {
+ 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 }) => {children}
,
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('tamagui', () => ({
+ Spinner: () => spinner
,
+}));
+
+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();
+
+ expect(screen.getByText('Redirecting to login …')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/LogoutPage.test.tsx b/resources/js/admin/mobile/__tests__/LogoutPage.test.tsx
new file mode 100644
index 0000000..01114fe
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/LogoutPage.test.tsx
@@ -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 }) => {children}
,
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('tamagui', () => ({
+ Spinner: () => spinner
,
+}));
+
+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();
+
+ expect(screen.getByText('Abmeldung wird vorbereitet ...')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/NotificationsPage.test.tsx b/resources/js/admin/mobile/__tests__/NotificationsPage.test.tsx
new file mode 100644
index 0000000..280ce4b
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/NotificationsPage.test.tsx
@@ -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) => {
+ 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 }) => {children}
,
+ },
+ useAnimationControls: () => ({ start: vi.fn() }),
+}));
+
+vi.mock('@tamagui/react-native-web-lite', () => ({
+ Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
+
+ ),
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+ XStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('../components/MobileShell', () => ({
+ MobileShell: ({ children }: { children: React.ReactNode }) => {children}
,
+ HeaderActionButton: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('../components/Primitives', () => ({
+ MobileCard: ({ children }: { children: React.ReactNode }) => {children}
,
+ PillBadge: ({ children }: { children: React.ReactNode }) => {children}
,
+ SkeletonCard: () => Loading...
,
+ CTAButton: ({ label }: { label: string }) => ,
+}));
+
+vi.mock('../components/FormControls', () => ({
+ MobileSelect: ({ children }: { children: React.ReactNode }) => ,
+}));
+
+vi.mock('../components/Sheet', () => ({
+ MobileSheet: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+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();
+
+ expect(await screen.findByText('All caught up')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/ProfilePage.test.tsx b/resources/js/admin/mobile/__tests__/ProfilePage.test.tsx
new file mode 100644
index 0000000..ee0526b
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/ProfilePage.test.tsx
@@ -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) => {
+ 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 }) => {children}
, {
+ Fallback: ({ children }: { children: React.ReactNode }) => {children}
,
+ }),
+}));
+
+vi.mock('@tamagui/card', () => ({
+ Card: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/group', () => ({
+ YGroup: Object.assign(({ children }: { children: React.ReactNode }) => {children}
, {
+ Item: ({ children }: { children: React.ReactNode }) => {children}
,
+ }),
+}));
+
+vi.mock('@tamagui/list-item', () => ({
+ ListItem: ({ title, iconAfter }: { title?: React.ReactNode; iconAfter?: React.ReactNode }) => (
+
+ {title}
+ {iconAfter}
+
+ ),
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+ XStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('../components/MobileShell', () => ({
+ MobileShell: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('../components/Primitives', () => ({
+ CTAButton: ({ label }: { label: string }) => ,
+}));
+
+vi.mock('../components/FormControls', () => ({
+ MobileSelect: ({ children }: { children: React.ReactNode }) => ,
+}));
+
+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();
+
+ expect(await screen.findByText('Settings')).toBeInTheDocument();
+ expect(screen.getByText('Preferences')).toBeInTheDocument();
+ expect(screen.getByText('Log out')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx b/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx
new file mode 100644
index 0000000..7b5ad61
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx
@@ -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) => {
+ 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 }) => {children}
,
+ HeaderActionButton: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('../components/Primitives', () => ({
+ MobileCard: ({ children }: { children: React.ReactNode }) => {children}
,
+ CTAButton: ({ label }: { label: string }) => ,
+ PillBadge: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('../components/FormControls', () => ({
+ MobileInput: (props: React.InputHTMLAttributes) => ,
+ MobileTextArea: (props: React.TextareaHTMLAttributes) => ,
+ MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes) => (
+
+ ),
+}));
+
+vi.mock('@tamagui/accordion', () => ({
+ Accordion: Object.assign(
+ ({ children }: { children: React.ReactNode }) => {children}
,
+ {
+ Item: ({ children }: { children: React.ReactNode }) => {children}
,
+ Trigger: ({ children }: { children: React.ReactNode }) => {children}
,
+ Content: ({ children }: { children: React.ReactNode }) => {children}
,
+ },
+ ),
+}));
+
+vi.mock('@tamagui/portal', () => ({
+ Portal: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+ XStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('@tamagui/react-native-web-lite', () => ({
+ Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
+
+ ),
+}));
+
+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();
+
+ expect(await screen.findByText('Hintergrund')).toBeInTheDocument();
+ expect(screen.getByText('Text')).toBeInTheDocument();
+ expect(screen.getByText('Vorschau')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/__tests__/QrPrintPage.test.tsx b/resources/js/admin/mobile/__tests__/QrPrintPage.test.tsx
index 7e395b2..b507180 100644
--- a/resources/js/admin/mobile/__tests__/QrPrintPage.test.tsx
+++ b/resources/js/admin/mobile/__tests__/QrPrintPage.test.tsx
@@ -2,77 +2,69 @@ import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
-const backMock = vi.fn();
-const navigateMock = vi.fn();
+const fixtures = vi.hoisted(() => ({
+ event: {
+ id: 1,
+ name: 'Demo Event',
+ slug: 'demo-event',
+ public_url: 'https://example.test/guest/demo-event',
+ },
+ invites: [
+ {
+ id: 10,
+ url: 'https://example.test/guest/demo-event',
+ qr_code_data_url: 'data:image/png;base64,abc',
+ is_active: true,
+ layouts: [
+ {
+ id: 'layout-1',
+ panel_mode: 'single',
+ orientation: 'portrait',
+ },
+ ],
+ },
+ ],
+}));
vi.mock('react-router-dom', () => ({
- useNavigate: () => navigateMock,
- useParams: () => ({ slug: 'demo-event' }),
+ useNavigate: () => vi.fn(),
+ useParams: () => ({ slug: fixtures.event.slug }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
- t: (key: string) => key,
+ t: (key: string, fallback?: string | Record) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
}),
}));
vi.mock('../hooks/useBackNavigation', () => ({
- useBackNavigation: () => backMock,
+ useBackNavigation: () => undefined,
}));
vi.mock('../../api', () => ({
- getEvent: vi.fn().mockResolvedValue({ slug: 'demo-event', name: 'Demo' }),
- getEventQrInvites: vi.fn().mockResolvedValue([
- {
- id: 1,
- token: 'demo-token',
- url: 'https://example.test/g/demo-token',
- label: null,
- qr_code_data_url: 'data:image/png;base64,abc',
- usage_limit: null,
- usage_count: 0,
- expires_at: '2026-01-12T12:00:00Z',
- revoked_at: null,
- is_active: true,
- created_at: null,
- metadata: {},
- layouts_url: null,
- layouts: [
- {
- id: 'layout-1',
- name: 'Poster',
- description: '',
- subtitle: '',
- formats: [],
- preview: {
- background: null,
- background_gradient: null,
- accent: null,
- text: null,
- },
- paper: 'a4',
- orientation: 'portrait',
- panel_mode: 'single',
- },
- ],
- },
- ]),
+ getEvent: vi.fn().mockResolvedValue(fixtures.event),
+ getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
createQrInvite: vi.fn(),
}));
-vi.mock('react-hot-toast', () => ({
- default: {
- error: vi.fn(),
- success: vi.fn(),
- },
+vi.mock('../../auth/tokens', () => ({
+ isAuthError: () => false,
}));
-vi.mock('@tamagui/react-native-web-lite', () => ({
- Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
-
- ),
+vi.mock('../../lib/apiError', () => ({
+ getApiErrorMessage: () => 'error',
+}));
+
+vi.mock('./qr/utils', () => ({
+ resolveLayoutForFormat: () => 'layout-1',
}));
vi.mock('../components/MobileShell', () => ({
@@ -82,14 +74,15 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => {children}
,
- CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
-
- ),
+ CTAButton: ({ label }: { label: string }) => ,
PillBadge: ({ children }: { children: React.ReactNode }) => {children}
,
}));
+vi.mock('../components/FormControls', () => ({
+ MobileInput: (props: React.InputHTMLAttributes) => ,
+ MobileTextArea: (props: React.TextareaHTMLAttributes) => ,
+}));
+
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => {children}
,
XStack: ({ children }: { children: React.ReactNode }) => {children}
,
@@ -99,31 +92,37 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => {children},
}));
+vi.mock('@tamagui/react-native-web-lite', () => ({
+ Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
+
+ ),
+}));
+
vi.mock('../theme', () => ({
useAdminTheme: () => ({
- textStrong: '#111827',
text: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
border: '#e5e7eb',
- surfaceMuted: '#fffdfb',
primary: '#ff5a5f',
- danger: '#b91c1c',
- accentSoft: '#ffe5ec',
- glassSurface: 'rgba(255,255,255,0.8)',
- glassSurfaceStrong: 'rgba(255,255,255,0.9)',
- glassBorder: 'rgba(229,231,235,0.7)',
- glassShadow: 'rgba(15,23,42,0.14)',
- appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
+ danger: '#dc2626',
+ surface: '#ffffff',
+ surfaceMuted: '#f9fafb',
+ accentSoft: '#eef2ff',
+ textStrong: '#0f172a',
}),
}));
import MobileQrPrintPage from '../QrPrintPage';
describe('MobileQrPrintPage', () => {
- it('shows token expiry info when the invite has expires_at', async () => {
+ it('renders QR overview content', async () => {
render();
- 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);
});
});
diff --git a/resources/js/admin/mobile/__tests__/UploadsTabPage.test.tsx b/resources/js/admin/mobile/__tests__/UploadsTabPage.test.tsx
new file mode 100644
index 0000000..3c7cb04
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/UploadsTabPage.test.tsx
@@ -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 }) => {to}
,
+}));
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | Record) => {
+ 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 }) => {children}
,
+}));
+
+vi.mock('../components/Primitives', () => ({
+ CTAButton: ({ label }: { label: string }) => ,
+}));
+
+vi.mock('@tamagui/card', () => ({
+ Card: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/group', () => ({
+ YGroup: Object.assign(({ children }: { children: React.ReactNode }) => {children}
, {
+ Item: ({ children }: { children: React.ReactNode }) => {children}
,
+ }),
+}));
+
+vi.mock('@tamagui/list-item', () => ({
+ ListItem: ({ title, iconAfter }: { title?: React.ReactNode; iconAfter?: React.ReactNode }) => (
+
+ {title}
+ {iconAfter}
+
+ ),
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+ XStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+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();
+
+ expect(screen.getByText('Pick an event to manage uploads')).toBeInTheDocument();
+ expect(screen.getByText('Demo Event')).toBeInTheDocument();
+ expect(screen.getByText('Open')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/admin/mobile/components/FormControls.test.tsx b/resources/js/admin/mobile/components/FormControls.test.tsx
index f922fbe..4eea665 100644
--- a/resources/js/admin/mobile/components/FormControls.test.tsx
+++ b/resources/js/admin/mobile/components/FormControls.test.tsx
@@ -60,7 +60,7 @@ vi.mock('@tamagui/select', () => {
return { Select };
});
-import { MobileSelect } from './FormControls';
+import { MobileColorInput, MobileDateTimeInput, MobileFileInput, MobileSelect } from './FormControls';
describe('MobileSelect', () => {
it('maps options and forwards selection changes', () => {
@@ -83,3 +83,30 @@ describe('MobileSelect', () => {
);
});
});
+
+describe('MobileColorInput', () => {
+ it('renders a color input with default sizing', () => {
+ render();
+
+ const input = screen.getByDisplayValue('#ff0000');
+ expect(input).toHaveAttribute('type', 'color');
+ });
+});
+
+describe('MobileFileInput', () => {
+ it('renders a hidden file input', () => {
+ render();
+
+ const input = screen.getByTestId('file-input');
+ expect(input).toHaveAttribute('type', 'file');
+ });
+});
+
+describe('MobileDateTimeInput', () => {
+ it('renders a datetime-local input', () => {
+ render();
+
+ const input = screen.getByDisplayValue('2024-10-20T14:30');
+ expect(input).toHaveAttribute('type', 'datetime-local');
+ });
+});
diff --git a/resources/js/admin/mobile/components/FormControls.tsx b/resources/js/admin/mobile/components/FormControls.tsx
index ddcbadd..51d2fc2 100644
--- a/resources/js/admin/mobile/components/FormControls.tsx
+++ b/resources/js/admin/mobile/components/FormControls.tsx
@@ -47,6 +47,86 @@ type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps
containerStyle?: React.CSSProperties;
};
+export const MobileColorInput = React.forwardRef<
+ HTMLInputElement,
+ React.ComponentPropsWithoutRef<'input'> & { size?: number }
+>(function MobileColorInput({ size = 52, style, ...props }, ref) {
+ const { border, surface } = useAdminTheme();
+
+ return (
+
+ );
+});
+
+export const MobileFileInput = React.forwardRef>(
+ function MobileFileInput({ style, ...props }, ref) {
+ return (
+
+ );
+ },
+);
+
+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 (
+ {
+ 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 & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
diff --git a/resources/js/admin/mobile/components/LegalConsentSheet.tsx b/resources/js/admin/mobile/components/LegalConsentSheet.tsx
index 53a6412..d9e51c1 100644
--- a/resources/js/admin/mobile/components/LegalConsentSheet.tsx
+++ b/resources/js/admin/mobile/components/LegalConsentSheet.tsx
@@ -1,6 +1,8 @@
import React from 'react';
-import { YStack } from '@tamagui/stacks';
+import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
+import { Checkbox } from '@tamagui/checkbox';
+import { Check } from 'lucide-react';
import { MobileSheet } from './Sheet';
import { CTAButton } from './Primitives';
import { useAdminTheme } from '../theme';
@@ -41,17 +43,6 @@ export function LegalConsentSheet({
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
const [error, setError] = React.useState(null);
- const checkboxStyle = {
- marginTop: 4,
- width: 18,
- height: 18,
- accentColor: primary,
- backgroundColor: surface,
- border: `1px solid ${border}`,
- borderRadius: 4,
- appearance: 'auto',
- WebkitAppearance: 'auto',
- } as any;
React.useEffect(() => {
if (open) {
@@ -111,36 +102,60 @@ export function LegalConsentSheet({
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
{requireTerms ? (
-
+
) : null}
{requireWaiver ? (
-
+
) : null}
diff --git a/resources/js/admin/mobile/components/__tests__/LegalConsentSheet.test.tsx b/resources/js/admin/mobile/components/__tests__/LegalConsentSheet.test.tsx
index 0ec7dc4..2c36d3f 100644
--- a/resources/js/admin/mobile/components/__tests__/LegalConsentSheet.test.tsx
+++ b/resources/js/admin/mobile/components/__tests__/LegalConsentSheet.test.tsx
@@ -23,6 +23,23 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children }: { children: React.ReactNode }) => ,
}));
+vi.mock('@tamagui/checkbox', () => ({
+ Checkbox: Object.assign(
+ ({ children, checked, onCheckedChange, id }: any) => (
+
+ ),
+ { Indicator: ({ children }: { children: React.ReactNode }) => {children} },
+ ),
+}));
+
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => {children}
;
Sheet.Frame = ({ children }: { children: React.ReactNode }) => {children}
;
diff --git a/resources/js/i18n.ts b/resources/js/i18n.ts
index 0179149..ff04bbf 100644
--- a/resources/js/i18n.ts
+++ b/resources/js/i18n.ts
@@ -29,7 +29,7 @@ i18n
},
backend: {
// Cache-bust to ensure fresh translations when files change.
- loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250116',
+ loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250212',
},
react: {
useSuspense: true,
diff --git a/resources/js/layouts/auth/auth-simple-layout.tsx b/resources/js/layouts/auth/auth-simple-layout.tsx
index 5a386f2..32f247e 100644
--- a/resources/js/layouts/auth/auth-simple-layout.tsx
+++ b/resources/js/layouts/auth/auth-simple-layout.tsx
@@ -61,7 +61,7 @@ export default function AuthSimpleLayout({ children, title, description, name, l
- {t('login.hero_heading', 'Willkommen zurück bei Fotospiel')}
+ {t('login.hero_heading', 'Willkommen zurück bei der Fotospiel App')}
{t('login.hero_subheading', 'Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.')}
diff --git a/resources/js/pages/auth/__tests__/ForgotPassword.test.tsx b/resources/js/pages/auth/__tests__/ForgotPassword.test.tsx
new file mode 100644
index 0000000..e9f0770
--- /dev/null
+++ b/resources/js/pages/auth/__tests__/ForgotPassword.test.tsx
@@ -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) => {
+ 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 }) => (
+
+
{title}
+
{description}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/actions/App/Http/Controllers/Auth/PasswordResetLinkController', () => ({
+ store: {
+ form: () => ({}),
+ },
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Form: ({ children }: { children: (props: { processing: boolean; errors: Record }) => React.ReactNode }) => (
+
+ ),
+ Head: () => null,
+}));
+
+vi.mock('@/routes', () => ({
+ login: () => '/login',
+}));
+
+vi.mock('@/components/input-error', () => ({
+ default: () => null,
+}));
+
+vi.mock('@/components/text-link', () => ({
+ default: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({ children }: { children: React.ReactNode }) => ,
+}));
+
+vi.mock('@/components/ui/input', () => ({
+ Input: (props: React.InputHTMLAttributes) => ,
+}));
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children }: { children: React.ReactNode }) => ,
+}));
+
+import ForgotPassword from '../forgot-password';
+
+describe('ForgotPassword', () => {
+ it('renders the translated copy', () => {
+ render();
+
+ 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();
+ });
+});
diff --git a/resources/js/pages/auth/forgot-password.tsx b/resources/js/pages/auth/forgot-password.tsx
index 48cdce8..73d687c 100644
--- a/resources/js/pages/auth/forgot-password.tsx
+++ b/resources/js/pages/auth/forgot-password.tsx
@@ -15,8 +15,14 @@ import AuthLayout from '@/layouts/auth-layout';
export default function ForgotPassword({ status }: { status?: string }) {
const { t } = useTranslation('auth');
return (
-
-
+
+
{status && {status}
}
@@ -25,8 +31,15 @@ export default function ForgotPassword({ status }: { status?: string }) {
{({ processing, errors }) => (
<>
-
-
+
+
@@ -34,7 +47,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
>
@@ -42,8 +55,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
- Or, return to
- {t('auth.forgot.back')}
+ {t('forgot.back_prefix', 'Or, return to')}
+ {t('forgot.back', 'Login')}
diff --git a/resources/lang/de/auth.json b/resources/lang/de/auth.json
index cc575a5..67e6fa4 100644
--- a/resources/lang/de/auth.json
+++ b/resources/lang/de/auth.json
@@ -10,19 +10,44 @@
"register": "Registrieren",
"home": "Startseite",
"packages": "Pakete",
+ "how_it_works": "So geht's",
"blog": "Blog",
"occasions": {
+ "label": "Anlässe",
"wedding": "Hochzeit",
"birthday": "Geburtstag",
+ "confirmation": "Konfirmation/Jugendweihe",
"corporate": "Firmenevent"
},
- "contact": "Kontakt"
+ "contact": "Kontakt",
+ "cta": "Jetzt ausprobieren",
+ "utility": "Darstellung und Sprache öffnen",
+ "appearance": "Darstellung",
+ "appearance_light": "Hell",
+ "appearance_dark": "Dunkel",
+ "language": "Sprache"
},
"login": {
"title": "Die Fotospiel App",
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel App",
"logo_alt": "Logo Die Fotospiel App",
+ "hero_tagline": "Event-Tech mit Herz",
+ "hero_heading": "Willkommen zurück bei der Fotospiel App",
+ "hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.",
+ "hero_footer": {
+ "headline": "Noch kein Account?",
+ "subline": "Entdecke unsere Packages und erlebe Fotospiel live.",
+ "cta": "Packages entdecken"
+ },
+ "highlights": {
+ "moments": "Momente in Echtzeit teilen",
+ "moments_description": "Uploads landen sofort in der Event-Galerie – ohne App-Download.",
+ "branding": "Branding & Slideshows, die begeistern",
+ "branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.",
+ "privacy": "Sicherer Zugang über Tokens",
+ "privacy_description": "Eventzugänge bleiben geschützt – DSGVO-konform mit Join Tokens."
+ },
"identifier": "E-Mail oder Username",
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
"username_or_email": "Username oder E-Mail",
@@ -39,6 +64,20 @@
"no_account": "Noch keinen Zugang?",
"sign_up": "Jetzt registrieren"
},
+ "common": {
+ "ui": {
+ "language_select": "Sprache auswählen"
+ }
+ },
+ "forgot": {
+ "title": "Passwort zurücksetzen",
+ "description": "Gib deine E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
+ "email_label": "E-Mail-Adresse",
+ "email_placeholder": "name@beispiel.de",
+ "submit": "Reset-Link per E-Mail senden",
+ "back_prefix": "Oder zurück zu",
+ "back": "Login"
+ },
"register": {
"title": "Registrieren",
"name": "Vollständiger Name",
diff --git a/resources/lang/en/auth.json b/resources/lang/en/auth.json
index 288d0d0..8528490 100644
--- a/resources/lang/en/auth.json
+++ b/resources/lang/en/auth.json
@@ -10,19 +10,44 @@
"register": "Register",
"home": "Home",
"packages": "Packages",
+ "how_it_works": "How it works",
"blog": "Blog",
"occasions": {
+ "label": "Occasions",
"wedding": "Wedding",
"birthday": "Birthday",
+ "confirmation": "Confirmation/Youth dedication",
"corporate": "Corporate Event"
},
- "contact": "Contact"
+ "contact": "Contact",
+ "cta": "Try now",
+ "utility": "Open appearance and language",
+ "appearance": "Appearance",
+ "appearance_light": "Light",
+ "appearance_dark": "Dark",
+ "language": "Language"
},
"login": {
"title": "Die Fotospiel App",
"description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel App",
"logo_alt": "Fotospiel App logo",
+ "hero_tagline": "Event tech with heart",
+ "hero_heading": "Welcome back to the Fotospiel App",
+ "hero_subheading": "Manage events, galleries, and guest lists in one beautifully crafted dashboard.",
+ "hero_footer": {
+ "headline": "No account yet?",
+ "subline": "Explore our packages and experience Fotospiel live.",
+ "cta": "Discover packages"
+ },
+ "highlights": {
+ "moments": "Share moments in real time",
+ "moments_description": "Uploads land instantly in the event gallery — no app download needed.",
+ "branding": "Branding & slideshows that impress",
+ "branding_description": "Configure slideshows, watermarks, and tasks for your event.",
+ "privacy": "Secure access via tokens",
+ "privacy_description": "Event access stays protected — GDPR-compliant with join tokens."
+ },
"identifier": "Email or Username",
"identifier_placeholder": "you@example.com or username",
"username_or_email": "Username or Email",
@@ -39,6 +64,20 @@
"no_account": "Don't have access yet?",
"sign_up": "Create an account"
},
+ "common": {
+ "ui": {
+ "language_select": "Select language"
+ }
+ },
+ "forgot": {
+ "title": "Reset your password",
+ "description": "Enter your email address to receive a password reset link.",
+ "email_label": "Email address",
+ "email_placeholder": "name@example.com",
+ "submit": "Email password reset link",
+ "back_prefix": "Or, return to",
+ "back": "Login"
+ },
"register": {
"title": "Register",
"name": "Full Name",
diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php
index 57656bd..8453867 100644
--- a/tests/Feature/Auth/LoginTest.php
+++ b/tests/Feature/Auth/LoginTest.php
@@ -5,6 +5,7 @@ namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class LoginTest extends TestCase
@@ -221,6 +222,30 @@ class LoginTest extends TestCase
$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()
{
$user = User::factory()->create([