Fix auth translations and admin PWA UI
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@tamagui/card';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Spinner } from 'tamagui';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function AuthCallbackPage(): React.ReactElement {
|
||||
const { status } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('auth');
|
||||
const [redirected, setRedirected] = React.useState(false);
|
||||
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
|
||||
const safeAreaStyle: React.CSSProperties = {
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||
@@ -40,12 +46,34 @@ export default function AuthCallbackPage(): React.ReactElement {
|
||||
}, [destination, navigate, redirected, status]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground"
|
||||
style={safeAreaStyle}
|
||||
<YStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$4"
|
||||
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<span className="text-base font-medium text-foreground">{t('processing.title', 'Signing you in …')}</span>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p>
|
||||
</div>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack alignItems="center" space="$2">
|
||||
<Spinner size="small" color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('processing.title', 'Signing you in …')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted} textAlign="center">
|
||||
{t('processing.copy', 'One moment please while we prepare your dashboard.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Card>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Slider } from 'tamagui';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||
import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect } from './components/FormControls';
|
||||
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ApiError, getApiErrorMessage } from '../lib/apiError';
|
||||
@@ -363,22 +364,17 @@ export default function MobileBrandingPage() {
|
||||
{t('events.watermark.title', 'Wasserzeichen')}
|
||||
</Text>
|
||||
|
||||
<InputField
|
||||
label={t('events.watermark.mode', 'Modus')}
|
||||
value={mode}
|
||||
onChange={(value) => {
|
||||
if (controlsLocked) return;
|
||||
if (value === 'custom' || value === 'base' || value === 'off') {
|
||||
setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] }));
|
||||
}
|
||||
}}
|
||||
onPicker={() => undefined}
|
||||
>
|
||||
<select
|
||||
<MobileField label={t('events.watermark.mode', 'Modus')}>
|
||||
<MobileSelect
|
||||
value={mode}
|
||||
disabled={controlsLocked}
|
||||
onChange={(event) => setWatermarkForm((prev) => ({ ...prev, mode: event.target.value as WatermarkForm['mode'] }))}
|
||||
style={{ width: '100%', height: 40, border: 'none', outline: 'none', background: 'transparent' }}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (controlsLocked) return;
|
||||
if (value === 'custom' || value === 'base' || value === 'off') {
|
||||
setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="base">{t('events.watermark.modeBase', 'Basis')}</option>
|
||||
<option value="custom" disabled={watermarkLocked}>
|
||||
@@ -387,8 +383,8 @@ export default function MobileBrandingPage() {
|
||||
<option value="off" disabled={policyLabel === 'basic'}>
|
||||
{t('events.watermark.modeOff', 'Deaktiviert')}
|
||||
</option>
|
||||
</select>
|
||||
</InputField>
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
|
||||
{mode === 'custom' && !controlsLocked ? (
|
||||
<YStack space="$2">
|
||||
@@ -414,11 +410,9 @@ export default function MobileBrandingPage() {
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
<input
|
||||
<MobileFileInput
|
||||
id="watermark-upload-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -855,11 +849,9 @@ export default function MobileBrandingPage() {
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
<MobileFileInput
|
||||
id="branding-logo-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
disabled={brandingDisabled}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -1171,24 +1163,22 @@ function ColorField({
|
||||
onChange: (next: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { textStrong, muted, border, surface } = useAdminTheme();
|
||||
const { textStrong, muted } = useAdminTheme();
|
||||
return (
|
||||
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
|
||||
/>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<MobileColorInput
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -1211,7 +1201,6 @@ function InputField({
|
||||
placeholder,
|
||||
onChange,
|
||||
onPicker,
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -1219,51 +1208,27 @@ function InputField({
|
||||
placeholder?: string;
|
||||
onChange: (next: string) => void;
|
||||
onPicker?: () => void;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { textStrong, border, surface, primary } = useAdminTheme();
|
||||
const { primary } = useAdminTheme();
|
||||
return (
|
||||
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
paddingLeft="$3"
|
||||
paddingRight="$2"
|
||||
height={48}
|
||||
backgroundColor={surface}
|
||||
space="$2"
|
||||
>
|
||||
{children ?? (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontSize: 14,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onFocus={onPicker}
|
||||
/>
|
||||
)}
|
||||
<MobileField label={label}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<MobileInput
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={onPicker}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{onPicker ? (
|
||||
<Pressable onPress={onPicker} disabled={disabled}>
|
||||
<ChevronDown size={16} color={primary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</MobileField>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { Switch } from '@tamagui/switch';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
import { MobileDateTimeInput, MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
import {
|
||||
createEvent,
|
||||
@@ -401,14 +401,9 @@ export default function MobileEventFormPage() {
|
||||
|
||||
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<NativeDateTimeInput
|
||||
<MobileDateTimeInput
|
||||
value={form.date}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, date: value }))}
|
||||
border={border}
|
||||
surface={surface}
|
||||
text={text}
|
||||
primary={primary}
|
||||
danger={danger}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<CalendarDays size={16} color={subtle} />
|
||||
@@ -624,57 +619,6 @@ function toDateTimeLocal(value?: string | null): string {
|
||||
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
|
||||
}
|
||||
|
||||
function NativeDateTimeInput({
|
||||
value,
|
||||
onChange,
|
||||
border,
|
||||
surface,
|
||||
text,
|
||||
primary,
|
||||
danger,
|
||||
hasError,
|
||||
style,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
border: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
primary: string;
|
||||
danger: string;
|
||||
hasError?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
const borderColor = hasError ? danger : focused ? primary : border;
|
||||
|
||||
return (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 44,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor,
|
||||
backgroundColor: surface,
|
||||
color: text,
|
||||
fontSize: 14,
|
||||
boxShadow: focused ? `0 0 0 3px ${ringColor}` : undefined,
|
||||
outline: 'none',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLocation(event: TenantEvent): string {
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<div ref={formRef}>
|
||||
<YStack ref={formRef}>
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('guestMessages.composeTitle', 'Send a message')}
|
||||
@@ -299,7 +299,7 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</div>
|
||||
</YStack>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } from 'lucide-r
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Switch } from '@tamagui/switch';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
@@ -315,12 +316,9 @@ function ToggleOption({ label, value, onToggle }: { label: string; value: boolea
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="600">
|
||||
{label}
|
||||
</Text>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
<Switch size="$4" checked={value} onCheckedChange={onToggle} aria-label={label}>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { AlertDialog } from '@tamagui/alert-dialog';
|
||||
import { ScrollView } from '@tamagui/scroll-view';
|
||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives';
|
||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
@@ -183,28 +184,34 @@ function SummaryLegendItem({
|
||||
}
|
||||
|
||||
function QuickNavChip({
|
||||
value,
|
||||
label,
|
||||
count,
|
||||
onPress,
|
||||
isActive = false,
|
||||
}: {
|
||||
value: TaskSectionKey;
|
||||
label: string;
|
||||
count: number;
|
||||
onPress: () => void;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const { textStrong, border, surface, surfaceMuted } = useAdminTheme();
|
||||
const { textStrong, border, surface, surfaceMuted, primary } = useAdminTheme();
|
||||
const activeBorder = withAlpha(primary, 0.45);
|
||||
const activeBackground = withAlpha(primary, 0.16);
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
space="$1.5"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
style={{ minHeight: 36 }}
|
||||
>
|
||||
<ToggleGroup.Item
|
||||
value={value}
|
||||
onPress={onPress}
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={isActive ? activeBorder : border}
|
||||
backgroundColor={isActive ? activeBackground : surface}
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
height={36}
|
||||
>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -213,7 +220,7 @@ function QuickNavChip({
|
||||
paddingVertical="$0.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
borderColor={isActive ? activeBorder : border}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<Text fontSize={10} fontWeight="800" color={textStrong}>
|
||||
@@ -221,7 +228,7 @@ function QuickNavChip({
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -263,6 +270,7 @@ export default function MobileEventTasksPage() {
|
||||
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
|
||||
const [savingEmotion, setSavingEmotion] = React.useState(false);
|
||||
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
|
||||
const [quickNavSelection, setQuickNavSelection] = React.useState<TaskSectionKey | ''>('');
|
||||
const text = textStrong;
|
||||
const assignedRef = React.useRef<HTMLDivElement>(null);
|
||||
const libraryRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -623,16 +631,34 @@ export default function MobileEventTasksPage() {
|
||||
</XStack>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<XStack space="$2" paddingVertical="$1">
|
||||
{sectionCounts.map((section) => (
|
||||
<QuickNavChip
|
||||
key={section.key}
|
||||
label={t(`events.tasks.sections.${section.key}`, section.key)}
|
||||
count={section.count}
|
||||
onPress={() => handleQuickNav(section.key)}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={quickNavSelection}
|
||||
onValueChange={(next) => {
|
||||
const key = next as TaskSectionKey | '';
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
setQuickNavSelection(key);
|
||||
handleQuickNav(key);
|
||||
}}
|
||||
>
|
||||
<XStack space="$2" paddingVertical="$1">
|
||||
{sectionCounts.map((section) => (
|
||||
<QuickNavChip
|
||||
key={section.key}
|
||||
value={section.key}
|
||||
label={t(`events.tasks.sections.${section.key}`, section.key)}
|
||||
count={section.count}
|
||||
onPress={() => {
|
||||
setQuickNavSelection(section.key);
|
||||
handleQuickNav(section.key);
|
||||
}}
|
||||
isActive={quickNavSelection === section.key}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</ToggleGroup>
|
||||
</ScrollView>
|
||||
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -770,7 +796,7 @@ export default function MobileEventTasksPage() {
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
<div ref={assignedRef} />
|
||||
<YStack ref={assignedRef} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
||||
</Text>
|
||||
@@ -822,7 +848,7 @@ export default function MobileEventTasksPage() {
|
||||
))}
|
||||
</YGroup>
|
||||
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
|
||||
<div ref={libraryRef} />
|
||||
<YStack ref={libraryRef} />
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{t('events.tasks.library', 'Weitere Aufgaben')}
|
||||
</Text>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
|
||||
import { Card } from '@tamagui/card';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { ScrollView } from '@tamagui/scroll-view';
|
||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||
import { Separator } from '@tamagui/separator';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
|
||||
import { PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
|
||||
import { MobileInput } from './components/FormControls';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
import { adminPath } from '../constants';
|
||||
@@ -27,7 +31,7 @@ export default function MobileEventsPage() {
|
||||
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const back = useBackNavigation();
|
||||
const { text, muted, subtle, border, primary, danger, surface, accentSoft, accent } = useAdminTheme();
|
||||
const { text, muted, subtle, border, primary, danger, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -54,22 +58,61 @@ export default function MobileEventsPage() {
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<MobileInput
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('events.list.search', 'Search events')}
|
||||
compact
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack space="$2.5">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||
{t('events.list.filters.title', 'Filters & Search')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<MobileInput
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('events.list.search', 'Search events')}
|
||||
compact
|
||||
/>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.list.filters.hint', 'Filter your events by status or search by name.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
@@ -78,15 +121,27 @@ export default function MobileEventsPage() {
|
||||
))}
|
||||
</YStack>
|
||||
) : events.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$3">
|
||||
<Text fontSize="$md" fontWeight="700">
|
||||
{t('events.list.empty.title', 'Noch kein Event angelegt')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted} textAlign="center">
|
||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||
</Text>
|
||||
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
|
||||
</MobileCard>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack space="$2" alignItems="center">
|
||||
<Text fontSize="$md" fontWeight="700">
|
||||
{t('events.list.empty.title', 'Noch kein Event angelegt')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted} textAlign="center">
|
||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||
</Text>
|
||||
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
|
||||
</YStack>
|
||||
</Card>
|
||||
) : (
|
||||
<EventsList
|
||||
events={events}
|
||||
@@ -123,7 +178,7 @@ function EventsList({
|
||||
onEdit: (slug: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { text, muted, subtle, border, primary, surface, accentSoft, accent } = useAdminTheme();
|
||||
const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
|
||||
const activeBg = accentSoft;
|
||||
const activeBorder = accent;
|
||||
|
||||
@@ -150,47 +205,93 @@ function EventsList({
|
||||
|
||||
return (
|
||||
<YStack space="$3">
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
{filters.map((filter) => {
|
||||
const active = filter.key === statusFilter;
|
||||
return (
|
||||
<Pressable key={filter.key} onPress={() => onStatusChange(filter.key)} style={{ flexGrow: 1 }}>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
space="$1.5"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
borderRadius={14}
|
||||
backgroundColor={active ? activeBg : surface}
|
||||
borderWidth={1}
|
||||
borderColor={active ? activeBorder : border}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.14}
|
||||
shadowRadius={14}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
>
|
||||
<YStack space="$2.5">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||
{t('events.list.filters.status', 'Status')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => value && onStatusChange(value as EventStatusKey)}
|
||||
>
|
||||
<XStack space="$2" paddingVertical="$1">
|
||||
{filters.map((filter) => {
|
||||
const active = filter.key === statusFilter;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={filter.key}
|
||||
value={filter.key}
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={active ? activeBorder : border}
|
||||
backgroundColor={active ? activeBg : surface}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
|
||||
</XStack>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
</ToggleGroup>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</Card>
|
||||
|
||||
{filteredEvents.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('events.list.empty.filtered', 'No events match this filter.')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted} textAlign="center">
|
||||
{t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('events.list.filters.all', 'All')}
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
onPress={() => onStatusChange('all')}
|
||||
/>
|
||||
</MobileCard>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.14}
|
||||
shadowRadius={14}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
>
|
||||
<YStack space="$2" alignItems="center">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('events.list.empty.filtered', 'No events match this filter.')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted} textAlign="center">
|
||||
{t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('events.list.filters.all', 'All')}
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
onPress={() => onStatusChange('all')}
|
||||
/>
|
||||
</YStack>
|
||||
</Card>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
const statusKey = resolveEventStatusKey(event);
|
||||
@@ -210,6 +311,8 @@ function EventsList({
|
||||
subtle={subtle}
|
||||
border={border}
|
||||
primary={primary}
|
||||
surface={surface}
|
||||
shadow={shadow}
|
||||
statusLabel={statusLabel}
|
||||
statusTone={statusTone}
|
||||
onOpen={onOpen}
|
||||
@@ -229,6 +332,8 @@ function EventRow({
|
||||
subtle,
|
||||
border,
|
||||
primary,
|
||||
surface,
|
||||
shadow,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
onOpen,
|
||||
@@ -240,6 +345,8 @@ function EventRow({
|
||||
subtle: string;
|
||||
border: string;
|
||||
primary: string;
|
||||
surface: string;
|
||||
shadow: string;
|
||||
statusLabel: string;
|
||||
statusTone: 'success' | 'warning' | 'muted';
|
||||
onOpen: (slug: string) => void;
|
||||
@@ -248,62 +355,77 @@ function EventRow({
|
||||
const { t } = useTranslation('management');
|
||||
const stats = buildEventListStats(event);
|
||||
return (
|
||||
<MobileCard borderColor={border}>
|
||||
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{renderName(event.name)}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CalendarDays size={14} color={subtle} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{formatDate(event.event_date)}
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.14}
|
||||
shadowRadius={14}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
>
|
||||
<YStack space="$2.5">
|
||||
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{renderName(event.name)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<MapPin size={14} color={subtle} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{resolveLocation(event)}
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CalendarDays size={14} color={subtle} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{formatDate(event.event_date)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<MapPin size={14} color={subtle} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{resolveLocation(event)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
|
||||
</YStack>
|
||||
<Pressable onPress={() => onEdit(event.slug)}>
|
||||
<Text fontSize="$xl" color={muted}>
|
||||
˅
|
||||
</Text>
|
||||
</XStack>
|
||||
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
|
||||
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
||||
<EventStatChip
|
||||
icon={Camera}
|
||||
label={t('events.list.stats.photos', 'Photos')}
|
||||
value={stats.photos}
|
||||
muted={subtle}
|
||||
/>
|
||||
<EventStatChip
|
||||
icon={Users}
|
||||
label={t('events.list.stats.guests', 'Guests')}
|
||||
value={stats.guests}
|
||||
muted={subtle}
|
||||
/>
|
||||
<EventStatChip
|
||||
icon={Sparkles}
|
||||
label={t('events.list.stats.tasks', 'Tasks')}
|
||||
value={stats.tasks}
|
||||
muted={subtle}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<Pressable onPress={() => onEdit(event.slug)}>
|
||||
<Text fontSize="$xl" color={muted}>
|
||||
˅
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
|
||||
<Pressable onPress={() => onOpen(event.slug)} style={{ marginTop: 8 }}>
|
||||
<XStack alignItems="center" justifyContent="flex-start" space="$2">
|
||||
<Plus size={16} color={primary} />
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('events.list.actions.open', 'Open event')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
|
||||
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
||||
<EventStatChip
|
||||
icon={Camera}
|
||||
label={t('events.list.stats.photos', 'Photos')}
|
||||
value={stats.photos}
|
||||
muted={subtle}
|
||||
/>
|
||||
<EventStatChip
|
||||
icon={Users}
|
||||
label={t('events.list.stats.guests', 'Guests')}
|
||||
value={stats.guests}
|
||||
muted={subtle}
|
||||
/>
|
||||
<EventStatChip
|
||||
icon={Sparkles}
|
||||
label={t('events.list.stats.tasks', 'Tasks')}
|
||||
value={stats.tasks}
|
||||
muted={subtle}
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
<Separator borderColor={border} />
|
||||
|
||||
<Pressable onPress={() => onOpen(event.slug)}>
|
||||
<XStack alignItems="center" justifyContent="flex-start" space="$2">
|
||||
<Plus size={16} color={primary} />
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('events.list.actions.open', 'Open event')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</YStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@ import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@tamagui/card';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Spinner } from 'tamagui';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function LoginStartPage(): React.ReactElement {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('auth');
|
||||
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
|
||||
const safeAreaStyle: React.CSSProperties = {
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||
@@ -25,11 +31,34 @@ export default function LoginStartPage(): React.ReactElement {
|
||||
}, [location.search, navigate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70"
|
||||
style={safeAreaStyle}
|
||||
<YStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$4"
|
||||
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p>
|
||||
</div>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack alignItems="center" space="$2">
|
||||
<Spinner size="small" color={textStrong} />
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('redirecting', 'Redirecting to login …')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('redirectingHint', 'One moment, preparing the login screen.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Card>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@tamagui/card';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Spinner } from 'tamagui';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function LogoutPage() {
|
||||
const { logout } = useAuth();
|
||||
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
|
||||
const safeAreaStyle: React.CSSProperties = {
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||
@@ -14,13 +20,34 @@ export default function LogoutPage() {
|
||||
}, [logout]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600"
|
||||
style={safeAreaStyle}
|
||||
<YStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$4"
|
||||
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60">
|
||||
Abmeldung wird vorbereitet ...
|
||||
</div>
|
||||
</div>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack alignItems="center" space="$2">
|
||||
<Spinner size="small" color={textStrong} />
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
Abmeldung wird vorbereitet ...
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
Bitte einen Moment Geduld.
|
||||
</Text>
|
||||
</YStack>
|
||||
</Card>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<YStack position="relative">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
@@ -119,7 +119,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
|
||||
>
|
||||
<Pressable onPress={handlePress}>{children}</Pressable>
|
||||
</motion.div>
|
||||
</div>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LogOut, User, Settings, Shield, Globe, Moon, Download } from 'lucide-react';
|
||||
import { User, Settings, Globe, Moon, Download, LogOut } from 'lucide-react';
|
||||
import { Avatar } from '@tamagui/avatar';
|
||||
import { Card } from '@tamagui/card';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { YGroup } from '@tamagui/group';
|
||||
import { ListItem } from '@tamagui/list-item';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { CTAButton } from './components/Primitives';
|
||||
import { MobileSelect } from './components/FormControls';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { fetchTenantProfile } from '../api';
|
||||
@@ -16,6 +17,7 @@ import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '
|
||||
import i18n from '../i18n';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { withAlpha } from './components/colors';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileProfilePage() {
|
||||
@@ -23,7 +25,7 @@ export default function MobileProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { textStrong, muted, border, accentSoft, primary, subtle } = useAdminTheme();
|
||||
const { textStrong, muted, border, accentSoft, primary, subtle, surface, surfaceMuted, shadow, danger } = useAdminTheme();
|
||||
const textColor = textStrong;
|
||||
const mutedText = muted;
|
||||
const borderColor = border;
|
||||
@@ -54,100 +56,155 @@ export default function MobileProfilePage() {
|
||||
title={t('mobileProfile.title', 'Profile')}
|
||||
onBack={back}
|
||||
>
|
||||
<MobileCard space="$3" alignItems="center">
|
||||
<XStack
|
||||
width={64}
|
||||
height={64}
|
||||
borderRadius={20}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={avatarBg}
|
||||
>
|
||||
<User size={28} color={primary} />
|
||||
</XStack>
|
||||
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={mutedText}>
|
||||
{email}
|
||||
</Text>
|
||||
{role ? (
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
{role}
|
||||
</Text>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={borderColor}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack space="$2.5" alignItems="center">
|
||||
<Avatar size="$7" borderRadius={20} backgroundColor={avatarBg}>
|
||||
<Avatar.Fallback>
|
||||
<User size={28} color={primary} />
|
||||
</Avatar.Fallback>
|
||||
</Avatar>
|
||||
<YStack space="$0.5" alignItems="center">
|
||||
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={mutedText}>
|
||||
{email}
|
||||
</Text>
|
||||
{role ? (
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
{role}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</Card>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
||||
{t('mobileProfile.settings', 'Settings')}
|
||||
</Text>
|
||||
<YStack space="$4">
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={borderColor}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack space="$2.5">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={borderColor}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="800" color={textColor}>
|
||||
{t('mobileProfile.settings', 'Settings')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.account', 'Account bearbeiten')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.account', 'Account bearbeiten')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('billing.sections.packages.title', 'Packages & Billing')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('billing.sections.packages.title', 'Packages & Billing')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
onPress={() => navigate(adminPath('/mobile/billing#packages'))}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
onPress={() => navigate(adminPath('/mobile/billing#invoices'))}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('dataExports.title', 'Data exports')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Download size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('dataExports.title', 'Data exports')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Download size={18} color={subtle} />}
|
||||
onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
</YGroup>
|
||||
</YStack>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={borderColor}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack space="$2.5">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={borderColor}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="800" color={textColor}>
|
||||
{t('mobileProfile.preferences', 'Preferences')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
paddingVertical="$2"
|
||||
@@ -205,15 +262,48 @@ export default function MobileProfilePage() {
|
||||
</YGroup.Item>
|
||||
</YGroup>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</Card>
|
||||
|
||||
<CTAButton
|
||||
label={t('mobileProfile.logout', 'Log out')}
|
||||
onPress={() => {
|
||||
logout();
|
||||
navigate(adminPath('/logout'));
|
||||
}}
|
||||
/>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={withAlpha(danger, 0.4)}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.12}
|
||||
shadowRadius={12}
|
||||
shadowOffset={{ width: 0, height: 6 }}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={withAlpha(danger, 0.12)}
|
||||
>
|
||||
<LogOut size={16} color={danger} />
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textColor}>
|
||||
{t('mobileProfile.logoutTitle', 'Sign out')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
{t('mobileProfile.logoutHint', 'Sign out from this device.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('mobileProfile.logout', 'Log out')}
|
||||
tone="danger"
|
||||
onPress={() => {
|
||||
logout();
|
||||
navigate(adminPath('/logout'));
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
</Card>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { ArrowLeft, RefreshCcw, Plus, ChevronDown } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Input, TextArea } from 'tamagui';
|
||||
import { Accordion } from '@tamagui/accordion';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { Portal } from '@tamagui/portal';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
import {
|
||||
TenantEvent,
|
||||
EventQrInvite,
|
||||
@@ -753,7 +753,7 @@ function BackgroundStep({
|
||||
{presets.map((preset) => {
|
||||
const isSelected = selectedPreset === preset.id;
|
||||
return (
|
||||
<Pressable key={preset.id} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
|
||||
<Pressable key={preset.id ?? preset.labelKey} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
|
||||
<YStack
|
||||
aspectRatio={210 / 297}
|
||||
maxHeight={220}
|
||||
@@ -795,7 +795,7 @@ function BackgroundStep({
|
||||
{t('events.qr.gradients', 'Gradienten')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{gradientPresets.map((gradient, idx) => {
|
||||
{gradientPresets.map((gradient) => {
|
||||
const isSelected =
|
||||
selectedGradient &&
|
||||
selectedGradient.angle === gradient.angle &&
|
||||
@@ -803,9 +803,10 @@ function BackgroundStep({
|
||||
const style = {
|
||||
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
|
||||
} as React.CSSProperties;
|
||||
const key = `${gradient.labelKey}-${gradient.angle}`;
|
||||
return (
|
||||
<Pressable
|
||||
key={idx}
|
||||
key={key}
|
||||
onPress={() => onSelectGradient(gradient)}
|
||||
style={{ width: '30%' }}
|
||||
aria-label={t(gradient.labelKey, gradient.label)}
|
||||
@@ -829,10 +830,11 @@ function BackgroundStep({
|
||||
{t('events.qr.colors', 'Vollfarbe')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{solidPresets.map((color) => {
|
||||
{solidPresets.map((color, idx) => {
|
||||
const isSelected = selectedSolid === color;
|
||||
const key = `${color}-${idx}`;
|
||||
return (
|
||||
<Pressable key={color} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}>
|
||||
<Pressable key={key} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}>
|
||||
<YStack
|
||||
height={50}
|
||||
borderRadius={12}
|
||||
@@ -912,24 +914,20 @@ function TextStep({
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.textFields', 'Texte')}
|
||||
</Text>
|
||||
<Input
|
||||
<MobileInput
|
||||
placeholder={t('events.qr.headline', 'Headline')}
|
||||
value={textFields.headline}
|
||||
onChangeText={(val) => updateField('headline', val)}
|
||||
size="$4"
|
||||
onChange={(event) => updateField('headline', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
<MobileInput
|
||||
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
||||
value={textFields.subtitle}
|
||||
onChangeText={(val) => updateField('subtitle', val)}
|
||||
size="$4"
|
||||
onChange={(event) => updateField('subtitle', event.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
<MobileTextArea
|
||||
placeholder={t('events.qr.bottomNote', 'Unterer Hinweistext')}
|
||||
value={textFields.description}
|
||||
onChangeText={(val) => updateField('description', val)}
|
||||
size="$4"
|
||||
numberOfLines={3}
|
||||
onChange={(event) => updateField('description', event.target.value)}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
@@ -939,13 +937,12 @@ function TextStep({
|
||||
</Text>
|
||||
{textFields.instructions.map((item, idx) => (
|
||||
<XStack key={idx} alignItems="center" space="$2">
|
||||
<TextArea
|
||||
<MobileTextArea
|
||||
value={item}
|
||||
onChangeText={(val) => updateInstruction(idx, val)}
|
||||
onChange={(event) => updateInstruction(idx, event.target.value)}
|
||||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||||
numberOfLines={2}
|
||||
{...({ flex: 1 } as any)}
|
||||
size="$4"
|
||||
style={{ flex: 1 }}
|
||||
compact
|
||||
/>
|
||||
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
|
||||
<XStack
|
||||
@@ -1204,36 +1201,13 @@ function LayoutControls({
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
|
||||
const { border, surface, surfaceMuted, text, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
|
||||
const { border, surface, surfaceMuted, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
|
||||
const fontOptions = React.useMemo(() => {
|
||||
const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora'];
|
||||
const tenant = tenantFonts.map((font) => font.family);
|
||||
return Array.from(new Set([...tenant, ...preset]));
|
||||
}, [tenantFonts]);
|
||||
|
||||
const numberInputStyle: React.CSSProperties = {
|
||||
width: 90,
|
||||
padding: '10px 12px',
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
fontFamily: DEFAULT_BODY_FONT,
|
||||
background: surfaceMuted,
|
||||
color: text,
|
||||
appearance: 'textfield',
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
fontFamily: DEFAULT_BODY_FONT,
|
||||
background: surfaceMuted,
|
||||
color: text,
|
||||
};
|
||||
|
||||
const StepperInput = ({
|
||||
value,
|
||||
min,
|
||||
@@ -1260,11 +1234,9 @@ function LayoutControls({
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
<input
|
||||
<MobileInput
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
min={min}
|
||||
max={max}
|
||||
value={formatValue(value)}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
@@ -1272,7 +1244,13 @@ function LayoutControls({
|
||||
onChange(next);
|
||||
}
|
||||
}}
|
||||
style={numberInputStyle}
|
||||
compact
|
||||
style={{
|
||||
width: 90,
|
||||
textAlign: 'center',
|
||||
fontFamily: DEFAULT_BODY_FONT,
|
||||
backgroundColor: surfaceMuted,
|
||||
}}
|
||||
/>
|
||||
<Pressable onPress={inc}>
|
||||
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
|
||||
@@ -1395,10 +1373,9 @@ function LayoutControls({
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.fontFamily', 'Font Family')}
|
||||
</Text>
|
||||
<select
|
||||
<MobileSelect
|
||||
value={override.fontFamily ?? slot.fontFamily ?? ''}
|
||||
onChange={(event) => onFontFamilyChange(event.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">{t('events.qr.defaultFont', 'Standard')}</option>
|
||||
{fontOptions.map((family) => (
|
||||
@@ -1406,7 +1383,7 @@ function LayoutControls({
|
||||
{family}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</MobileSelect>
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
@@ -1424,12 +1401,12 @@ function LayoutControls({
|
||||
backgroundColor={override.color ?? slot.color ?? textStrong}
|
||||
/>
|
||||
</Pressable>
|
||||
<input
|
||||
type="text"
|
||||
<MobileInput
|
||||
value={override.color ?? slot.color ?? ''}
|
||||
placeholder={ADMIN_COLORS.text}
|
||||
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
|
||||
style={{ ...numberInputStyle, width: 110 }}
|
||||
compact
|
||||
style={{ width: 110, backgroundColor: surfaceMuted }}
|
||||
/>
|
||||
</XStack>
|
||||
{openColorSlot === slotKey ? (
|
||||
@@ -1496,15 +1473,14 @@ function LayoutControls({
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.align', 'Align')}
|
||||
</Text>
|
||||
<select
|
||||
<MobileSelect
|
||||
value={override.align ?? slot.align ?? 'left'}
|
||||
onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="left">{t('common.left', 'Links')}</option>
|
||||
<option value="center">{t('common.center', 'Zentriert')}</option>
|
||||
<option value="right">{t('common.right', 'Rechts')}</option>
|
||||
</select>
|
||||
</MobileSelect>
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { MobileInput, MobileTextArea } from './components/FormControls';
|
||||
import {
|
||||
TenantEvent,
|
||||
EventQrInvite,
|
||||
@@ -489,20 +490,20 @@ function TextStep({
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.textFields', 'Texte')}
|
||||
</Text>
|
||||
<StyledInput
|
||||
<MobileInput
|
||||
placeholder={t('events.qr.headline', 'Headline')}
|
||||
value={textFields.headline}
|
||||
onChangeText={(val) => updateField('headline', val)}
|
||||
onChange={(event) => updateField('headline', event.target.value)}
|
||||
/>
|
||||
<StyledInput
|
||||
<MobileInput
|
||||
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
||||
value={textFields.subtitle}
|
||||
onChangeText={(val) => updateField('subtitle', val)}
|
||||
onChange={(event) => updateField('subtitle', event.target.value)}
|
||||
/>
|
||||
<StyledTextarea
|
||||
<MobileTextArea
|
||||
placeholder={t('events.qr.description', 'Beschreibung')}
|
||||
value={textFields.description}
|
||||
onChangeText={(val) => updateField('description', val)}
|
||||
onChange={(event) => updateField('description', event.target.value)}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
@@ -512,11 +513,11 @@ function TextStep({
|
||||
</Text>
|
||||
{textFields.instructions.map((item, idx) => (
|
||||
<XStack key={idx} alignItems="center" space="$2">
|
||||
<StyledInput
|
||||
flex={1}
|
||||
<MobileInput
|
||||
style={{ flex: 1 }}
|
||||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||||
value={item}
|
||||
onChangeText={(val) => updateInstruction(idx, val)}
|
||||
onChange={(event) => updateInstruction(idx, event.target.value)}
|
||||
/>
|
||||
<CTAButton
|
||||
label="–"
|
||||
@@ -651,60 +652,3 @@ function PreviewStep({
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
function StyledInput({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
flex,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder?: string;
|
||||
flex?: number;
|
||||
}) {
|
||||
const { border } = useAdminTheme();
|
||||
return (
|
||||
<input
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${border}`,
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
flex: flex ?? undefined,
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledTextarea({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { border } = useAdminTheme();
|
||||
return (
|
||||
<textarea
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${border}`,
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
minHeight: 96,
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@tamagui/card';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { YGroup } from '@tamagui/group';
|
||||
import { ListItem } from '@tamagui/list-item';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { CTAButton } from './components/Primitives';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import { adminPath } from '../constants';
|
||||
@@ -15,7 +17,7 @@ export default function MobileUploadsTabPage() {
|
||||
const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const { text, muted, border, primary } = useAdminTheme();
|
||||
const { text, muted, border, primary, surface, surfaceMuted, shadow } = useAdminTheme();
|
||||
|
||||
if (activeEvent?.slug) {
|
||||
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/control-room`)} replace />;
|
||||
@@ -24,18 +26,43 @@ export default function MobileUploadsTabPage() {
|
||||
if (!hasEvents) {
|
||||
return (
|
||||
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
|
||||
<MobileCard alignItems="flex-start" space="$3">
|
||||
<Text fontSize="$lg" fontWeight="800" color={text}>
|
||||
{t('mobileUploads.emptyTitle', 'Create an event first')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('mobileUploads.emptyBody', 'Add your first event to review uploads and manage QR sharing.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('mobileDashboard.ctaCreate', 'Create event')}
|
||||
onPress={() => navigate(adminPath('/mobile/events/new'))}
|
||||
/>
|
||||
</MobileCard>
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack space="$2.5">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||
{t('mobileUploads.emptyBadge', 'No uploads yet')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color={text}>
|
||||
{t('mobileUploads.emptyTitle', 'Create an event first')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('mobileUploads.emptyBody', 'Add your first event to review uploads and manage QR sharing.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('mobileDashboard.ctaCreate', 'Create event')}
|
||||
onPress={() => navigate(adminPath('/mobile/events/new'))}
|
||||
/>
|
||||
</YStack>
|
||||
</Card>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -44,38 +71,66 @@ export default function MobileUploadsTabPage() {
|
||||
|
||||
return (
|
||||
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" color={text} fontWeight="700">
|
||||
{t('mobileUploads.pickEvent', 'Pick an event to manage uploads')}
|
||||
</Text>
|
||||
{events.map((event) => (
|
||||
<Pressable
|
||||
key={event.slug}
|
||||
onPress={() => {
|
||||
selectEvent(event.slug ?? null);
|
||||
if (event.slug) {
|
||||
navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
|
||||
}
|
||||
}}
|
||||
<Card
|
||||
borderRadius={22}
|
||||
borderWidth={2}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.16}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
>
|
||||
<YStack space="$2.5">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<MobileCard borderColor={border} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('mobileUploads.open', 'Open')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</Pressable>
|
||||
))}
|
||||
</YStack>
|
||||
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||
{t('mobileUploads.pickEvent', 'Pick an event to manage uploads')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: border, overflow: "hidden" } as any)}>
|
||||
{events.map((event) => (
|
||||
<YGroup.Item key={event.slug}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
|
||||
</Text>
|
||||
</YStack>
|
||||
}
|
||||
iconAfter={
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('mobileUploads.open', 'Open')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
selectEvent(event.slug ?? null);
|
||||
if (event.slug) {
|
||||
navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
))}
|
||||
</YGroup>
|
||||
</YStack>
|
||||
</Card>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/context', () => ({
|
||||
useAuth: () => ({ status: 'loading' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/returnTo', () => ({
|
||||
decodeReturnTo: () => null,
|
||||
resolveReturnTarget: () => ({ finalTarget: '/event-admin/mobile/dashboard' }),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/card', () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('tamagui', () => ({
|
||||
Spinner: () => <div>spinner</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
muted: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
surface: '#ffffff',
|
||||
shadow: 'rgba(15,23,42,0.12)',
|
||||
appBackground: 'linear-gradient(180deg, #fff, #f8fafc)',
|
||||
}),
|
||||
}));
|
||||
|
||||
import AuthCallbackPage from '../AuthCallbackPage';
|
||||
|
||||
describe('AuthCallbackPage', () => {
|
||||
it('renders the processing message', () => {
|
||||
render(<AuthCallbackPage />);
|
||||
|
||||
expect(screen.getByText('Signing you in …')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,21 @@ vi.mock('../components/Primitives', () => ({
|
||||
SkeletonCard: () => <div>Loading...</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/FormControls', () => ({
|
||||
MobileField: ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
MobileColorInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileFileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) => (
|
||||
<select {...props}>{children}</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Sheet', () => ({
|
||||
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock('../components/Primitives', () => ({
|
||||
|
||||
vi.mock('../components/FormControls', () => ({
|
||||
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
MobileDateTimeInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
|
||||
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useParams: () => ({ slug: 'demo-event' }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
i18n: { language: 'de' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventContext', () => ({
|
||||
useEventContext: () => ({
|
||||
activeEvent: { slug: 'demo-event' },
|
||||
selectEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
getEvents: vi.fn().mockResolvedValue([]),
|
||||
listGuestNotifications: vi.fn().mockResolvedValue([]),
|
||||
sendGuestNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/tokens', () => ({
|
||||
isAuthError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: () => 'error',
|
||||
}));
|
||||
|
||||
vi.mock('../guestMessages', () => ({
|
||||
formatGuestMessageDate: () => '19. Feb. 2026',
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SkeletonCard: () => <div>Loading...</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/FormControls', () => ({
|
||||
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
|
||||
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
danger: '#dc2626',
|
||||
}),
|
||||
}));
|
||||
|
||||
import MobileEventGuestNotificationsPage from '../EventGuestNotificationsPage';
|
||||
|
||||
describe('MobileEventGuestNotificationsPage', () => {
|
||||
it('renders the compose section', async () => {
|
||||
render(<MobileEventGuestNotificationsPage />);
|
||||
|
||||
expect(await screen.findByText('Send a message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
143
resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx
Normal file
143
resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const fixtures = vi.hoisted(() => ({
|
||||
event: {
|
||||
id: 1,
|
||||
name: 'Demo Event',
|
||||
slug: 'demo-event',
|
||||
event_date: '2026-02-19',
|
||||
status: 'published',
|
||||
settings: {
|
||||
guest_downloads_enabled: true,
|
||||
guest_sharing_enabled: false,
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
photo_count: 12,
|
||||
like_count: 3,
|
||||
pending_count: 1,
|
||||
guest_count: 22,
|
||||
},
|
||||
invites: [],
|
||||
addons: [],
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ slug: fixtures.event.slug }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
getEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||
getEventStats: vi.fn().mockResolvedValue(fixtures.stats),
|
||||
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
|
||||
getAddonCatalog: vi.fn().mockResolvedValue(fixtures.addons),
|
||||
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||
createEventAddonCheckout: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/tokens', () => ({
|
||||
isAuthError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: () => 'error',
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SkeletonCard: () => <div>Loading...</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/LegalConsentSheet', () => ({
|
||||
LegalConsentSheet: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/switch', () => ({
|
||||
Switch: Object.assign(
|
||||
({ children, checked, onCheckedChange, 'aria-label': ariaLabel }: any) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={ariaLabel}
|
||||
checked={checked}
|
||||
onChange={(event) => onCheckedChange?.(event.target.checked)}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
{ Thumb: () => <span /> },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
subtle: '#94a3b8',
|
||||
border: '#e5e7eb',
|
||||
primary: '#ff5a5f',
|
||||
successText: '#16a34a',
|
||||
danger: '#dc2626',
|
||||
surface: '#ffffff',
|
||||
surfaceMuted: '#f9fafb',
|
||||
accentSoft: '#eef2ff',
|
||||
textStrong: '#0f172a',
|
||||
}),
|
||||
}));
|
||||
|
||||
import MobileEventRecapPage from '../EventRecapPage';
|
||||
|
||||
describe('MobileEventRecapPage', () => {
|
||||
it('renders recap settings toggles', async () => {
|
||||
render(<MobileEventRecapPage />);
|
||||
|
||||
expect(await screen.findByText('Nachlauf-Optionen')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Gäste dürfen Fotos laden')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Gäste dürfen Fotos teilen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,12 @@ vi.mock('@tamagui/scroll-view', () => ({
|
||||
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/toggle-group', () => ({
|
||||
ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/group', () => ({
|
||||
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
|
||||
136
resources/js/admin/mobile/__tests__/EventsPage.test.tsx
Normal file
136
resources/js/admin/mobile/__tests__/EventsPage.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
getEvents: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Demo Event',
|
||||
slug: 'demo-event',
|
||||
event_date: '2026-02-19',
|
||||
settings: {},
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/tokens', () => ({
|
||||
isAuthError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: () => 'error',
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/eventFilters', () => ({
|
||||
buildEventStatusCounts: () => ({ all: 1, upcoming: 1, draft: 0, past: 0 }),
|
||||
filterEventsByStatus: (events: any[]) => events,
|
||||
resolveEventStatusKey: () => 'upcoming',
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/eventListStats', () => ({
|
||||
buildEventListStats: () => ({ photos: 2, guests: 3, tasks: 4 }),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
FloatingActionButton: ({ label }: { label: string }) => <div>{label}</div>,
|
||||
SkeletonCard: () => <div>Loading...</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/FormControls', () => ({
|
||||
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/card', () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/scroll-view', () => ({
|
||||
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/toggle-group', () => ({
|
||||
ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/separator', () => ({
|
||||
Separator: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
subtle: '#94a3b8',
|
||||
border: '#e5e7eb',
|
||||
primary: '#ff5a5f',
|
||||
danger: '#dc2626',
|
||||
surface: '#ffffff',
|
||||
surfaceMuted: '#f9fafb',
|
||||
accentSoft: '#eef2ff',
|
||||
accent: '#6366f1',
|
||||
shadow: 'rgba(15,23,42,0.12)',
|
||||
}),
|
||||
}));
|
||||
|
||||
import MobileEventsPage from '../EventsPage';
|
||||
|
||||
describe('MobileEventsPage', () => {
|
||||
it('renders filters and event list', async () => {
|
||||
render(<MobileEventsPage />);
|
||||
|
||||
expect(await screen.findByText('Filters & Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Status')).toBeInTheDocument();
|
||||
expect(screen.getByText('Demo Event')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
61
resources/js/admin/mobile/__tests__/LoginStartPage.test.tsx
Normal file
61
resources/js/admin/mobile/__tests__/LoginStartPage.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useLocation: () => ({ search: '' }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/card', () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('tamagui', () => ({
|
||||
Spinner: () => <div>spinner</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
muted: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
surface: '#ffffff',
|
||||
shadow: 'rgba(15,23,42,0.12)',
|
||||
appBackground: 'linear-gradient(180deg, #fff, #f8fafc)',
|
||||
}),
|
||||
}));
|
||||
|
||||
import LoginStartPage from '../LoginStartPage';
|
||||
|
||||
describe('LoginStartPage', () => {
|
||||
it('renders the redirect message', () => {
|
||||
render(<LoginStartPage />);
|
||||
|
||||
expect(screen.getByText('Redirecting to login …')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
48
resources/js/admin/mobile/__tests__/LogoutPage.test.tsx
Normal file
48
resources/js/admin/mobile/__tests__/LogoutPage.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const logoutMock = vi.fn();
|
||||
|
||||
vi.mock('../../auth/context', () => ({
|
||||
useAuth: () => ({
|
||||
logout: logoutMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/card', () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('tamagui', () => ({
|
||||
Spinner: () => <div>spinner</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
muted: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
surface: '#ffffff',
|
||||
shadow: 'rgba(15,23,42,0.12)',
|
||||
appBackground: 'linear-gradient(180deg, #fff, #f8fafc)',
|
||||
}),
|
||||
}));
|
||||
|
||||
import LogoutPage from '../LogoutPage';
|
||||
|
||||
describe('LogoutPage', () => {
|
||||
it('renders the logout message', () => {
|
||||
render(<LogoutPage />);
|
||||
|
||||
expect(screen.getByText('Abmeldung wird vorbereitet ...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
129
resources/js/admin/mobile/__tests__/NotificationsPage.test.tsx
Normal file
129
resources/js/admin/mobile/__tests__/NotificationsPage.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({ search: '' }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
listNotificationLogs: vi.fn().mockResolvedValue([]),
|
||||
markNotificationLogs: vi.fn(),
|
||||
getEvents: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/tokens', () => ({
|
||||
isAuthError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: () => 'error',
|
||||
}));
|
||||
|
||||
vi.mock('../lib/notificationGrouping', () => ({
|
||||
groupNotificationsByScope: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../lib/notificationUnread', () => ({
|
||||
collectUnreadIds: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../lib/relativeTime', () => ({
|
||||
formatRelativeTime: () => 'now',
|
||||
}));
|
||||
|
||||
vi.mock('../lib/haptics', () => ({
|
||||
triggerHaptic: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
},
|
||||
useAnimationControls: () => ({ start: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SkeletonCard: () => <div>Loading...</div>,
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/FormControls', () => ({
|
||||
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Sheet', () => ({
|
||||
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
subtle: '#94a3b8',
|
||||
border: '#e5e7eb',
|
||||
primary: '#ff5a5f',
|
||||
danger: '#dc2626',
|
||||
successBg: '#dcfce7',
|
||||
successText: '#166534',
|
||||
infoBg: '#e0e7ff',
|
||||
infoText: '#3730a3',
|
||||
}),
|
||||
}));
|
||||
|
||||
import MobileNotificationsPage from '../NotificationsPage';
|
||||
|
||||
describe('MobileNotificationsPage', () => {
|
||||
it('shows empty state when no notifications are available', async () => {
|
||||
render(<MobileNotificationsPage />);
|
||||
|
||||
expect(await screen.findByText('All caught up')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
134
resources/js/admin/mobile/__tests__/ProfilePage.test.tsx
Normal file
134
resources/js/admin/mobile/__tests__/ProfilePage.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const logoutMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n', () => ({
|
||||
default: {
|
||||
language: 'de',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/context', () => ({
|
||||
useAuth: () => ({
|
||||
user: { name: 'Demo User', email: 'demo@example.com', role: 'tenant_admin' },
|
||||
logout: logoutMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
fetchTenantProfile: vi.fn().mockResolvedValue({
|
||||
name: 'Demo User',
|
||||
email: 'demo@example.com',
|
||||
role: 'tenant_admin',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-appearance', () => ({
|
||||
useAppearance: () => ({
|
||||
appearance: 'system',
|
||||
updateAppearance: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/avatar', () => ({
|
||||
Avatar: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||
Fallback: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/card', () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/group', () => ({
|
||||
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/list-item', () => ({
|
||||
ListItem: ({ title, iconAfter }: { title?: React.ReactNode; iconAfter?: React.ReactNode }) => (
|
||||
<div>
|
||||
{title}
|
||||
{iconAfter}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/FormControls', () => ({
|
||||
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/colors', () => ({
|
||||
withAlpha: (color: string) => color,
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
muted: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
accentSoft: '#eef2ff',
|
||||
primary: '#ff5a5f',
|
||||
subtle: '#94a3b8',
|
||||
surface: '#ffffff',
|
||||
surfaceMuted: '#f9fafb',
|
||||
shadow: 'rgba(15,23,42,0.12)',
|
||||
danger: '#dc2626',
|
||||
}),
|
||||
}));
|
||||
|
||||
import MobileProfilePage from '../ProfilePage';
|
||||
|
||||
describe('MobileProfilePage', () => {
|
||||
it('renders settings and preferences sections', async () => {
|
||||
render(<MobileProfilePage />);
|
||||
|
||||
expect(await screen.findByText('Settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Preferences')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log out')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const fixtures = vi.hoisted(() => ({
|
||||
event: {
|
||||
id: 1,
|
||||
name: 'Demo Event',
|
||||
slug: 'demo-event',
|
||||
},
|
||||
invites: [
|
||||
{
|
||||
id: 1,
|
||||
url: 'https://example.test/guest/demo-event',
|
||||
qr_code_data_url: '',
|
||||
layouts: [
|
||||
{
|
||||
id: 'layout-1',
|
||||
name: 'Poster Layout',
|
||||
description: 'Layout description',
|
||||
paper: 'a4',
|
||||
orientation: 'portrait',
|
||||
panel_mode: 'single',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ slug: fixtures.event.slug, tokenId: '1' }),
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
getEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
|
||||
updateEventQrInvite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/tokens', () => ({
|
||||
isAuthError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: () => 'error',
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/fonts', () => ({
|
||||
useTenantFonts: () => ({ fonts: [] }),
|
||||
ensureFontLoaded: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./invite-layout/export-utils', () => ({
|
||||
generatePdfBytes: vi.fn(),
|
||||
generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'),
|
||||
triggerDownloadFromBlob: vi.fn(),
|
||||
triggerDownloadFromDataUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/FormControls', () => ({
|
||||
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
||||
MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) => (
|
||||
<select {...props}>{children}</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/accordion', () => ({
|
||||
Accordion: Object.assign(
|
||||
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
{
|
||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Trigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/portal', () => ({
|
||||
Portal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
ADMIN_COLORS: {
|
||||
primary: '#ff5a5f',
|
||||
accent: '#6d28d9',
|
||||
text: '#0f172a',
|
||||
backdrop: '#0f172a',
|
||||
},
|
||||
ADMIN_GRADIENTS: {
|
||||
softCard: 'linear-gradient(180deg, #fff, #f3f4f6)',
|
||||
},
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#0f172a',
|
||||
muted: '#64748b',
|
||||
border: '#e2e8f0',
|
||||
primary: '#ff5a5f',
|
||||
accentSoft: '#f5f3ff',
|
||||
successText: '#16a34a',
|
||||
surface: '#ffffff',
|
||||
surfaceMuted: '#f8fafc',
|
||||
overlay: 'rgba(15, 23, 42, 0.5)',
|
||||
shadow: 'rgba(15, 23, 42, 0.2)',
|
||||
danger: '#dc2626',
|
||||
}),
|
||||
}));
|
||||
|
||||
import MobileQrLayoutCustomizePage from '../QrLayoutCustomizePage';
|
||||
|
||||
describe('MobileQrLayoutCustomizePage', () => {
|
||||
it('renders the layout customize stepper', async () => {
|
||||
render(<MobileQrLayoutCustomizePage />);
|
||||
|
||||
expect(await screen.findByText('Hintergrund')).toBeInTheDocument();
|
||||
expect(screen.getByText('Text')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vorschau')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,77 +2,69 @@ import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const backMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
const fixtures = vi.hoisted(() => ({
|
||||
event: {
|
||||
id: 1,
|
||||
name: 'Demo Event',
|
||||
slug: 'demo-event',
|
||||
public_url: 'https://example.test/guest/demo-event',
|
||||
},
|
||||
invites: [
|
||||
{
|
||||
id: 10,
|
||||
url: 'https://example.test/guest/demo-event',
|
||||
qr_code_data_url: 'data:image/png;base64,abc',
|
||||
is_active: true,
|
||||
layouts: [
|
||||
{
|
||||
id: 'layout-1',
|
||||
panel_mode: 'single',
|
||||
orientation: 'portrait',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useParams: () => ({ slug: 'demo-event' }),
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ slug: fixtures.event.slug }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => backMock,
|
||||
useBackNavigation: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
getEvent: vi.fn().mockResolvedValue({ slug: 'demo-event', name: 'Demo' }),
|
||||
getEventQrInvites: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
token: 'demo-token',
|
||||
url: 'https://example.test/g/demo-token',
|
||||
label: null,
|
||||
qr_code_data_url: 'data:image/png;base64,abc',
|
||||
usage_limit: null,
|
||||
usage_count: 0,
|
||||
expires_at: '2026-01-12T12:00:00Z',
|
||||
revoked_at: null,
|
||||
is_active: true,
|
||||
created_at: null,
|
||||
metadata: {},
|
||||
layouts_url: null,
|
||||
layouts: [
|
||||
{
|
||||
id: 'layout-1',
|
||||
name: 'Poster',
|
||||
description: '',
|
||||
subtitle: '',
|
||||
formats: [],
|
||||
preview: {
|
||||
background: null,
|
||||
background_gradient: null,
|
||||
accent: null,
|
||||
text: null,
|
||||
},
|
||||
paper: 'a4',
|
||||
orientation: 'portrait',
|
||||
panel_mode: 'single',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
getEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
|
||||
createQrInvite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
vi.mock('../../auth/tokens', () => ({
|
||||
isAuthError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: () => 'error',
|
||||
}));
|
||||
|
||||
vi.mock('./qr/utils', () => ({
|
||||
resolveLayoutForFormat: () => 'layout-1',
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
@@ -82,14 +74,15 @@ vi.mock('../components/MobileShell', () => ({
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/FormControls', () => ({
|
||||
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
@@ -99,31 +92,37 @@ vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
subtle: '#94a3b8',
|
||||
border: '#e5e7eb',
|
||||
surfaceMuted: '#fffdfb',
|
||||
primary: '#ff5a5f',
|
||||
danger: '#b91c1c',
|
||||
accentSoft: '#ffe5ec',
|
||||
glassSurface: 'rgba(255,255,255,0.8)',
|
||||
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
|
||||
glassBorder: 'rgba(229,231,235,0.7)',
|
||||
glassShadow: 'rgba(15,23,42,0.14)',
|
||||
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
|
||||
danger: '#dc2626',
|
||||
surface: '#ffffff',
|
||||
surfaceMuted: '#f9fafb',
|
||||
accentSoft: '#eef2ff',
|
||||
textStrong: '#0f172a',
|
||||
}),
|
||||
}));
|
||||
|
||||
import MobileQrPrintPage from '../QrPrintPage';
|
||||
|
||||
describe('MobileQrPrintPage', () => {
|
||||
it('shows token expiry info when the invite has expires_at', async () => {
|
||||
it('renders QR overview content', async () => {
|
||||
render(<MobileQrPrintPage />);
|
||||
|
||||
expect(await screen.findByText('events.qr.expiresAt')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Entrance QR Code')).toBeInTheDocument();
|
||||
expect(screen.getByText('Schritt 1: Format wählen')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Neuen QR-Link erstellen').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
99
resources/js/admin/mobile/__tests__/UploadsTabPage.test.tsx
Normal file
99
resources/js/admin/mobile/__tests__/UploadsTabPage.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
Navigate: ({ to }: { to: string }) => <div>{to}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
i18n: { language: 'de' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventContext', () => ({
|
||||
useEventContext: () => ({
|
||||
events: [{ slug: 'demo-event', event_date: '2026-02-19' }],
|
||||
activeEvent: null,
|
||||
hasEvents: true,
|
||||
selectEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/events', () => ({
|
||||
resolveEventDisplayName: () => 'Demo Event',
|
||||
formatEventDate: () => '19. Feb. 2026',
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/card', () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/group', () => ({
|
||||
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/list-item', () => ({
|
||||
ListItem: ({ title, iconAfter }: { title?: React.ReactNode; iconAfter?: React.ReactNode }) => (
|
||||
<div>
|
||||
{title}
|
||||
{iconAfter}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
primary: '#ff5a5f',
|
||||
surface: '#ffffff',
|
||||
surfaceMuted: '#f9fafb',
|
||||
shadow: 'rgba(15,23,42,0.12)',
|
||||
}),
|
||||
}));
|
||||
|
||||
import MobileUploadsTabPage from '../UploadsTabPage';
|
||||
|
||||
describe('MobileUploadsTabPage', () => {
|
||||
it('renders the event picker list', () => {
|
||||
render(<MobileUploadsTabPage />);
|
||||
|
||||
expect(screen.getByText('Pick an event to manage uploads')).toBeInTheDocument();
|
||||
expect(screen.getByText('Demo Event')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ vi.mock('@tamagui/select', () => {
|
||||
return { Select };
|
||||
});
|
||||
|
||||
import { MobileSelect } from './FormControls';
|
||||
import { MobileColorInput, MobileDateTimeInput, MobileFileInput, MobileSelect } from './FormControls';
|
||||
|
||||
describe('MobileSelect', () => {
|
||||
it('maps options and forwards selection changes', () => {
|
||||
@@ -83,3 +83,30 @@ describe('MobileSelect', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileColorInput', () => {
|
||||
it('renders a color input with default sizing', () => {
|
||||
render(<MobileColorInput value="#ff0000" onChange={vi.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue('#ff0000');
|
||||
expect(input).toHaveAttribute('type', 'color');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileFileInput', () => {
|
||||
it('renders a hidden file input', () => {
|
||||
render(<MobileFileInput data-testid="file-input" />);
|
||||
|
||||
const input = screen.getByTestId('file-input');
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileDateTimeInput', () => {
|
||||
it('renders a datetime-local input', () => {
|
||||
render(<MobileDateTimeInput value="2024-10-20T14:30" onChange={vi.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue('2024-10-20T14:30');
|
||||
expect(input).toHaveAttribute('type', 'datetime-local');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,86 @@ type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps
|
||||
containerStyle?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const MobileColorInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentPropsWithoutRef<'input'> & { size?: number }
|
||||
>(function MobileColorInput({ size = 52, style, ...props }, ref) {
|
||||
const { border, surface } = useAdminTheme();
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="color"
|
||||
{...props}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${border}`,
|
||||
background: surface,
|
||||
padding: 0,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const MobileFileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'>>(
|
||||
function MobileFileInput({ style, ...props }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
{...props}
|
||||
style={{
|
||||
display: 'none',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MobileDateTimeInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentPropsWithoutRef<'input'> & ControlProps
|
||||
>(function MobileDateTimeInput({ hasError = false, style, ...props }, ref) {
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
const borderColor = hasError ? danger : border;
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="datetime-local"
|
||||
{...props}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 44,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor,
|
||||
backgroundColor: surface,
|
||||
color: text,
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxShadow: `0 0 0 0 ${ringColor}`,
|
||||
...style,
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
event.currentTarget.style.boxShadow = `0 0 0 3px ${ringColor}`;
|
||||
props.onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
event.currentTarget.style.boxShadow = `0 0 0 0 ${ringColor}`;
|
||||
props.onBlur?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
|
||||
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Checkbox } from '@tamagui/checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import { MobileSheet } from './Sheet';
|
||||
import { CTAButton } from './Primitives';
|
||||
import { useAdminTheme } from '../theme';
|
||||
@@ -41,17 +43,6 @@ export function LegalConsentSheet({
|
||||
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const checkboxStyle = {
|
||||
marginTop: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
accentColor: primary,
|
||||
backgroundColor: surface,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 4,
|
||||
appearance: 'auto',
|
||||
WebkitAppearance: 'auto',
|
||||
} as any;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -111,36 +102,60 @@ export function LegalConsentSheet({
|
||||
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
|
||||
</Text>
|
||||
{requireTerms ? (
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<XStack space="$3" alignItems="flex-start">
|
||||
<Checkbox
|
||||
id="legal-terms"
|
||||
size="$4"
|
||||
checked={acceptedTerms}
|
||||
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
||||
style={checkboxStyle}
|
||||
/>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
onCheckedChange={(checked) => setAcceptedTerms(Boolean(checked))}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<Checkbox.Indicator>
|
||||
<Check size={14} color={primary} />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox>
|
||||
<Text
|
||||
fontSize="$sm"
|
||||
color={text}
|
||||
onPress={() => setAcceptedTerms((prev) => !prev)}
|
||||
flex={1}
|
||||
>
|
||||
{copy?.checkboxTerms ?? t(
|
||||
'events.legalConsent.checkboxTerms',
|
||||
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
|
||||
)}
|
||||
</Text>
|
||||
</label>
|
||||
</XStack>
|
||||
) : null}
|
||||
{requireWaiver ? (
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<XStack space="$3" alignItems="flex-start">
|
||||
<Checkbox
|
||||
id="legal-waiver"
|
||||
size="$4"
|
||||
checked={acceptedWaiver}
|
||||
onChange={(event) => setAcceptedWaiver(event.target.checked)}
|
||||
style={checkboxStyle}
|
||||
/>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
onCheckedChange={(checked) => setAcceptedWaiver(Boolean(checked))}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<Checkbox.Indicator>
|
||||
<Check size={14} color={primary} />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox>
|
||||
<Text
|
||||
fontSize="$sm"
|
||||
color={text}
|
||||
onPress={() => setAcceptedWaiver((prev) => !prev)}
|
||||
flex={1}
|
||||
>
|
||||
{copy?.checkboxWaiver ?? t(
|
||||
'events.legalConsent.checkboxWaiver',
|
||||
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
|
||||
)}
|
||||
</Text>
|
||||
</label>
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
@@ -23,6 +23,23 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/checkbox', () => ({
|
||||
Checkbox: Object.assign(
|
||||
({ children, checked, onCheckedChange, id }: any) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={(event) => onCheckedChange?.(event.target.checked)}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
{ Indicator: ({ children }: { children: React.ReactNode }) => <span>{children}</span> },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/sheet', () => {
|
||||
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function AuthSimpleLayout({ children, title, description, name, l
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-display text-3xl leading-tight sm:text-4xl">
|
||||
{t('login.hero_heading', 'Willkommen zurück bei Fotospiel')}
|
||||
{t('login.hero_heading', 'Willkommen zurück bei der Fotospiel App')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/80 sm:text-base">
|
||||
{t('login.hero_subheading', 'Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.')}
|
||||
|
||||
78
resources/js/pages/auth/__tests__/ForgotPassword.test.tsx
Normal file
78
resources/js/pages/auth/__tests__/ForgotPassword.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/layouts/auth-layout', () => ({
|
||||
default: ({ children, title, description }: { children: React.ReactNode; title: string; description: string }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/actions/App/Http/Controllers/Auth/PasswordResetLinkController', () => ({
|
||||
store: {
|
||||
form: () => ({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
Form: ({ children }: { children: (props: { processing: boolean; errors: Record<string, string[]> }) => React.ReactNode }) => (
|
||||
<form>{children({ processing: false, errors: {} })}</form>
|
||||
),
|
||||
Head: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/routes', () => ({
|
||||
login: () => '/login',
|
||||
}));
|
||||
|
||||
vi.mock('@/components/input-error', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/text-link', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/input', () => ({
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/label', () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
|
||||
import ForgotPassword from '../forgot-password';
|
||||
|
||||
describe('ForgotPassword', () => {
|
||||
it('renders the translated copy', () => {
|
||||
render(<ForgotPassword />);
|
||||
|
||||
expect(screen.getByText('Forgot password')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter your email to receive a password reset link')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email password reset link')).toBeInTheDocument();
|
||||
expect(screen.getByText('Or, return to')).toBeInTheDocument();
|
||||
expect(screen.getByText('Login')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,14 @@ import AuthLayout from '@/layouts/auth-layout';
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
const { t } = useTranslation('auth');
|
||||
return (
|
||||
<AuthLayout title={t('auth.forgot.title', 'Forgot password')} description={t('auth.forgot.description', 'Enter your email to receive a password reset link')}>
|
||||
<Head title={t('auth.forgot.title', 'Forgot password')} />
|
||||
<AuthLayout
|
||||
title={t('forgot.title', 'Forgot password')}
|
||||
description={t('forgot.description', 'Enter your email to receive a password reset link')}
|
||||
name={t('login.brand', 'Die Fotospiel App')}
|
||||
logoSrc="/logo-transparent-md.png"
|
||||
logoAlt={t('login.logo_alt', 'Logo Die Fotospiel App')}
|
||||
>
|
||||
<Head title={t('forgot.title', 'Forgot password')} />
|
||||
|
||||
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
|
||||
|
||||
@@ -25,8 +31,15 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input id="email" type="email" name="email" autoComplete="off" autoFocus placeholder={t('auth.forgot.email_placeholder')} />
|
||||
<Label htmlFor="email">{t('forgot.email_label', 'Email address')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
placeholder={t('forgot.email_placeholder', 'name@example.com')}
|
||||
/>
|
||||
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
@@ -34,7 +47,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
<div className="my-6 flex items-center justify-start">
|
||||
<Button className="w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Email password reset link
|
||||
{t('forgot.submit', 'Email password reset link')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -42,8 +55,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
</Form>
|
||||
|
||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||
<span>Or, return to</span>
|
||||
<TextLink href={login()}>{t('auth.forgot.back')}</TextLink>
|
||||
<span>{t('forgot.back_prefix', 'Or, return to')}</span>
|
||||
<TextLink href={login()}>{t('forgot.back', 'Login')}</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user