Fix auth translations and admin PWA UI
This commit is contained in:
@@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if ($user && $user->email_verified_at === null) {
|
if ($user && $user->email_verified_at === null) {
|
||||||
|
$intended = $request->session()->get('url.intended');
|
||||||
|
$intended = is_string($intended) ? trim($intended) : null;
|
||||||
|
|
||||||
|
if ($this->isVerificationLink($intended)) {
|
||||||
|
$request->session()->forget('url.intended');
|
||||||
|
|
||||||
|
return Inertia::location($intended);
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::location(route('verification.notice'));
|
return Inertia::location(route('verification.notice'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +125,29 @@ class AuthenticatedSessionController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isVerificationLink(?string $target): bool
|
||||||
|
{
|
||||||
|
if (! is_string($target) || trim($target) === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = trim($target);
|
||||||
|
|
||||||
|
if (str_starts_with($path, '/verify-email/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = parse_url($path);
|
||||||
|
|
||||||
|
if ($parsed === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $parsed['path'] ?? '';
|
||||||
|
|
||||||
|
return $path !== '' && str_starts_with($path, '/verify-email/');
|
||||||
|
}
|
||||||
|
|
||||||
private function decodeReturnTo(string $value, Request $request): ?string
|
private function decodeReturnTo(string $value, Request $request): ?string
|
||||||
{
|
{
|
||||||
$candidate = $this->decodeBase64Url($value) ?? $value;
|
$candidate = $this->decodeBase64Url($value) ?? $value;
|
||||||
|
|||||||
@@ -10,19 +10,44 @@
|
|||||||
"register": "Registrieren",
|
"register": "Registrieren",
|
||||||
"home": "Startseite",
|
"home": "Startseite",
|
||||||
"packages": "Pakete",
|
"packages": "Pakete",
|
||||||
|
"how_it_works": "So geht's",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"occasions": {
|
"occasions": {
|
||||||
|
"label": "Anlässe",
|
||||||
"wedding": "Hochzeit",
|
"wedding": "Hochzeit",
|
||||||
"birthday": "Geburtstag",
|
"birthday": "Geburtstag",
|
||||||
|
"confirmation": "Konfirmation/Jugendweihe",
|
||||||
"corporate": "Firmenevent"
|
"corporate": "Firmenevent"
|
||||||
},
|
},
|
||||||
"contact": "Kontakt"
|
"contact": "Kontakt",
|
||||||
|
"cta": "Jetzt ausprobieren",
|
||||||
|
"utility": "Darstellung und Sprache öffnen",
|
||||||
|
"appearance": "Darstellung",
|
||||||
|
"appearance_light": "Hell",
|
||||||
|
"appearance_dark": "Dunkel",
|
||||||
|
"language": "Sprache"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Die Fotospiel App",
|
"title": "Die Fotospiel App",
|
||||||
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
|
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
|
||||||
"brand": "Die Fotospiel App",
|
"brand": "Die Fotospiel App",
|
||||||
"logo_alt": "Logo Die Fotospiel App",
|
"logo_alt": "Logo Die Fotospiel App",
|
||||||
|
"hero_tagline": "Event-Tech mit Herz",
|
||||||
|
"hero_heading": "Willkommen zurück bei der Fotospiel App",
|
||||||
|
"hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.",
|
||||||
|
"hero_footer": {
|
||||||
|
"headline": "Noch kein Account?",
|
||||||
|
"subline": "Entdecke unsere Packages und erlebe Fotospiel live.",
|
||||||
|
"cta": "Packages entdecken"
|
||||||
|
},
|
||||||
|
"highlights": {
|
||||||
|
"moments": "Momente in Echtzeit teilen",
|
||||||
|
"moments_description": "Uploads landen sofort in der Event-Galerie – ohne App-Download.",
|
||||||
|
"branding": "Branding & Slideshows, die begeistern",
|
||||||
|
"branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.",
|
||||||
|
"privacy": "Sicherer Zugang über Tokens",
|
||||||
|
"privacy_description": "Eventzugänge bleiben geschützt – DSGVO-konform mit Join Tokens."
|
||||||
|
},
|
||||||
"identifier": "E-Mail oder Username",
|
"identifier": "E-Mail oder Username",
|
||||||
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
|
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
|
||||||
"username_or_email": "Username oder E-Mail",
|
"username_or_email": "Username oder E-Mail",
|
||||||
@@ -39,6 +64,20 @@
|
|||||||
"no_account": "Noch keinen Zugang?",
|
"no_account": "Noch keinen Zugang?",
|
||||||
"sign_up": "Jetzt registrieren"
|
"sign_up": "Jetzt registrieren"
|
||||||
},
|
},
|
||||||
|
"forgot": {
|
||||||
|
"title": "Passwort zurücksetzen",
|
||||||
|
"description": "Gib deine E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
|
||||||
|
"email_label": "E-Mail-Adresse",
|
||||||
|
"email_placeholder": "name@beispiel.de",
|
||||||
|
"submit": "Reset-Link per E-Mail senden",
|
||||||
|
"back_prefix": "Oder zurück zu",
|
||||||
|
"back": "Login"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"ui": {
|
||||||
|
"language_select": "Sprache auswählen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Registrieren",
|
"title": "Registrieren",
|
||||||
"name": "Vollständiger Name",
|
"name": "Vollständiger Name",
|
||||||
|
|||||||
@@ -10,19 +10,44 @@
|
|||||||
"register": "Register",
|
"register": "Register",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
|
"how_it_works": "How it works",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"occasions": {
|
"occasions": {
|
||||||
|
"label": "Occasions",
|
||||||
"wedding": "Wedding",
|
"wedding": "Wedding",
|
||||||
"birthday": "Birthday",
|
"birthday": "Birthday",
|
||||||
|
"confirmation": "Confirmation/Youth dedication",
|
||||||
"corporate": "Corporate Event"
|
"corporate": "Corporate Event"
|
||||||
},
|
},
|
||||||
"contact": "Contact"
|
"contact": "Contact",
|
||||||
|
"cta": "Try now",
|
||||||
|
"utility": "Open appearance and language",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"appearance_light": "Light",
|
||||||
|
"appearance_dark": "Dark",
|
||||||
|
"language": "Language"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Die Fotospiel App",
|
"title": "Die Fotospiel App",
|
||||||
"description": "Sign in with your Fotospiel account to manage every event in one place.",
|
"description": "Sign in with your Fotospiel account to manage every event in one place.",
|
||||||
"brand": "Die Fotospiel App",
|
"brand": "Die Fotospiel App",
|
||||||
"logo_alt": "Fotospiel App logo",
|
"logo_alt": "Fotospiel App logo",
|
||||||
|
"hero_tagline": "Event tech with heart",
|
||||||
|
"hero_heading": "Welcome back to the Fotospiel App",
|
||||||
|
"hero_subheading": "Manage events, galleries, and guest lists in one beautifully crafted dashboard.",
|
||||||
|
"hero_footer": {
|
||||||
|
"headline": "No account yet?",
|
||||||
|
"subline": "Explore our packages and experience Fotospiel live.",
|
||||||
|
"cta": "Discover packages"
|
||||||
|
},
|
||||||
|
"highlights": {
|
||||||
|
"moments": "Share moments in real time",
|
||||||
|
"moments_description": "Uploads land instantly in the event gallery — no app download needed.",
|
||||||
|
"branding": "Branding & slideshows that impress",
|
||||||
|
"branding_description": "Configure slideshows, watermarks, and tasks for your event.",
|
||||||
|
"privacy": "Secure access via tokens",
|
||||||
|
"privacy_description": "Event access stays protected — GDPR-compliant with join tokens."
|
||||||
|
},
|
||||||
"identifier": "Email or Username",
|
"identifier": "Email or Username",
|
||||||
"identifier_placeholder": "you@example.com or username",
|
"identifier_placeholder": "you@example.com or username",
|
||||||
"username_or_email": "Username or Email",
|
"username_or_email": "Username or Email",
|
||||||
@@ -39,6 +64,20 @@
|
|||||||
"no_account": "Don't have access yet?",
|
"no_account": "Don't have access yet?",
|
||||||
"sign_up": "Create an account"
|
"sign_up": "Create an account"
|
||||||
},
|
},
|
||||||
|
"forgot": {
|
||||||
|
"title": "Reset your password",
|
||||||
|
"description": "Enter your email address to receive a password reset link.",
|
||||||
|
"email_label": "Email address",
|
||||||
|
"email_placeholder": "name@example.com",
|
||||||
|
"submit": "Email password reset link",
|
||||||
|
"back_prefix": "Or, return to",
|
||||||
|
"back": "Login"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"ui": {
|
||||||
|
"language_select": "Select language"
|
||||||
|
}
|
||||||
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Register",
|
"title": "Register",
|
||||||
"name": "Full Name",
|
"name": "Full Name",
|
||||||
|
|||||||
@@ -72,6 +72,31 @@
|
|||||||
"action": "Installieren",
|
"action": "Installieren",
|
||||||
"iosHint": "iOS: Teilen → Zum Home-Bildschirm."
|
"iosHint": "iOS: Teilen → Zum Home-Bildschirm."
|
||||||
},
|
},
|
||||||
|
"consent": {
|
||||||
|
"banner": {
|
||||||
|
"title": "Cookie-Einstellungen",
|
||||||
|
"body": "Wir verwenden notwendige Cookies für den Betrieb. Optionale Analyse-Cookies helfen uns, die App zu verbessern.",
|
||||||
|
"reject": "Ablehnen",
|
||||||
|
"customize": "Anpassen",
|
||||||
|
"accept": "Alle akzeptieren"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"title": "Cookie-Auswahl",
|
||||||
|
"description": "Wähle, welche Cookies du erlaubst. Du kannst das jederzeit ändern.",
|
||||||
|
"functional": "Notwendige Cookies",
|
||||||
|
"functional_desc": "Erforderlich für Kernfunktionen und Sicherheit.",
|
||||||
|
"required": "Erforderlich",
|
||||||
|
"analytics": "Analyse-Cookies",
|
||||||
|
"analytics_desc": "Helfen uns, die Nutzung zu verstehen und die Performance zu verbessern.",
|
||||||
|
"reject_all": "Alle ablehnen",
|
||||||
|
"accept_all": "Alle akzeptieren",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"save": "Auswahl speichern"
|
||||||
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"banner_label": "Cookie-Hinweis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||||
"eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.",
|
"eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.",
|
||||||
|
|||||||
@@ -72,6 +72,31 @@
|
|||||||
"action": "Install",
|
"action": "Install",
|
||||||
"iosHint": "On iOS: Share → Add to Home Screen."
|
"iosHint": "On iOS: Share → Add to Home Screen."
|
||||||
},
|
},
|
||||||
|
"consent": {
|
||||||
|
"banner": {
|
||||||
|
"title": "Cookie preferences",
|
||||||
|
"body": "We use essential cookies to run the app. Optional analytics help us improve your experience.",
|
||||||
|
"reject": "Reject",
|
||||||
|
"customize": "Customize",
|
||||||
|
"accept": "Accept all"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"title": "Cookie settings",
|
||||||
|
"description": "Choose which cookies you allow. You can change this anytime.",
|
||||||
|
"functional": "Essential cookies",
|
||||||
|
"functional_desc": "Required for core features and security.",
|
||||||
|
"required": "Required",
|
||||||
|
"analytics": "Analytics cookies",
|
||||||
|
"analytics_desc": "Help us understand usage to improve performance.",
|
||||||
|
"reject_all": "Reject all",
|
||||||
|
"accept_all": "Accept all",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save selection"
|
||||||
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"banner_label": "Cookie consent"
|
||||||
|
}
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Something went wrong. Please try again.",
|
"generic": "Something went wrong. Please try again.",
|
||||||
"eventLimit": "Your current package has no remaining event bundle.",
|
"eventLimit": "Your current package has no remaining event bundle.",
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@tamagui/card';
|
||||||
|
import { YStack } from '@tamagui/stacks';
|
||||||
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import { Spinner } from 'tamagui';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||||
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||||
|
import { useAdminTheme } from './theme';
|
||||||
|
|
||||||
export default function AuthCallbackPage(): React.ReactElement {
|
export default function AuthCallbackPage(): React.ReactElement {
|
||||||
const { status } = useAuth();
|
const { status } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('auth');
|
const { t } = useTranslation('auth');
|
||||||
const [redirected, setRedirected] = React.useState(false);
|
const [redirected, setRedirected] = React.useState(false);
|
||||||
|
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
|
||||||
const safeAreaStyle: React.CSSProperties = {
|
const safeAreaStyle: React.CSSProperties = {
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
@@ -40,12 +46,34 @@ export default function AuthCallbackPage(): React.ReactElement {
|
|||||||
}, [destination, navigate, redirected, status]);
|
}, [destination, navigate, redirected, status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<YStack
|
||||||
className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground"
|
alignItems="center"
|
||||||
style={safeAreaStyle}
|
justifyContent="center"
|
||||||
|
padding="$4"
|
||||||
|
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
|
||||||
|
backgroundColor={surface}
|
||||||
>
|
>
|
||||||
<span className="text-base font-medium text-foreground">{t('processing.title', 'Signing you in …')}</span>
|
<Card
|
||||||
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p>
|
borderRadius={22}
|
||||||
</div>
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack alignItems="center" space="$2">
|
||||||
|
<Spinner size="small" color={textStrong} />
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{t('processing.title', 'Signing you in …')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted} textAlign="center">
|
||||||
|
{t('processing.copy', 'One moment please while we prepare your dashboard.')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</Card>
|
||||||
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
|||||||
import { Slider } from 'tamagui';
|
import { Slider } from 'tamagui';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||||
|
import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect } from './components/FormControls';
|
||||||
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
|
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { ApiError, getApiErrorMessage } from '../lib/apiError';
|
import { ApiError, getApiErrorMessage } from '../lib/apiError';
|
||||||
@@ -363,22 +364,17 @@ export default function MobileBrandingPage() {
|
|||||||
{t('events.watermark.title', 'Wasserzeichen')}
|
{t('events.watermark.title', 'Wasserzeichen')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<InputField
|
<MobileField label={t('events.watermark.mode', 'Modus')}>
|
||||||
label={t('events.watermark.mode', 'Modus')}
|
<MobileSelect
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={(value) => {
|
disabled={controlsLocked}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
if (controlsLocked) return;
|
if (controlsLocked) return;
|
||||||
if (value === 'custom' || value === 'base' || value === 'off') {
|
if (value === 'custom' || value === 'base' || value === 'off') {
|
||||||
setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] }));
|
setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPicker={() => undefined}
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
value={mode}
|
|
||||||
disabled={controlsLocked}
|
|
||||||
onChange={(event) => setWatermarkForm((prev) => ({ ...prev, mode: event.target.value as WatermarkForm['mode'] }))}
|
|
||||||
style={{ width: '100%', height: 40, border: 'none', outline: 'none', background: 'transparent' }}
|
|
||||||
>
|
>
|
||||||
<option value="base">{t('events.watermark.modeBase', 'Basis')}</option>
|
<option value="base">{t('events.watermark.modeBase', 'Basis')}</option>
|
||||||
<option value="custom" disabled={watermarkLocked}>
|
<option value="custom" disabled={watermarkLocked}>
|
||||||
@@ -387,8 +383,8 @@ export default function MobileBrandingPage() {
|
|||||||
<option value="off" disabled={policyLabel === 'basic'}>
|
<option value="off" disabled={policyLabel === 'basic'}>
|
||||||
{t('events.watermark.modeOff', 'Deaktiviert')}
|
{t('events.watermark.modeOff', 'Deaktiviert')}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</MobileSelect>
|
||||||
</InputField>
|
</MobileField>
|
||||||
|
|
||||||
{mode === 'custom' && !controlsLocked ? (
|
{mode === 'custom' && !controlsLocked ? (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
@@ -414,11 +410,9 @@ export default function MobileBrandingPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<input
|
<MobileFileInput
|
||||||
id="watermark-upload-input"
|
id="watermark-upload-input"
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -855,11 +849,9 @@ export default function MobileBrandingPage() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<input
|
<MobileFileInput
|
||||||
id="branding-logo-input"
|
id="branding-logo-input"
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
style={{ display: 'none' }}
|
|
||||||
disabled={brandingDisabled}
|
disabled={brandingDisabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -1171,19 +1163,17 @@ function ColorField({
|
|||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { textStrong, muted, border, surface } = useAdminTheme();
|
const { textStrong, muted } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<input
|
<MobileColorInput
|
||||||
type="color"
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
|
|
||||||
/>
|
/>
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{value}
|
{value}
|
||||||
@@ -1211,7 +1201,6 @@ function InputField({
|
|||||||
placeholder,
|
placeholder,
|
||||||
onChange,
|
onChange,
|
||||||
onPicker,
|
onPicker,
|
||||||
children,
|
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -1219,51 +1208,27 @@ function InputField({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
onPicker?: () => void;
|
onPicker?: () => void;
|
||||||
children?: React.ReactNode;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { textStrong, border, surface, primary } = useAdminTheme();
|
const { primary } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
<MobileField label={label}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<XStack alignItems="center" space="$2">
|
||||||
{label}
|
<MobileInput
|
||||||
</Text>
|
|
||||||
<XStack
|
|
||||||
alignItems="center"
|
|
||||||
borderRadius={12}
|
|
||||||
borderWidth={1}
|
|
||||||
borderColor={border}
|
|
||||||
paddingLeft="$3"
|
|
||||||
paddingRight="$2"
|
|
||||||
height={48}
|
|
||||||
backgroundColor={surface}
|
|
||||||
space="$2"
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
disabled={disabled}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: '100%',
|
|
||||||
border: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
fontSize: 14,
|
|
||||||
background: 'transparent',
|
|
||||||
}}
|
|
||||||
onFocus={onPicker}
|
onFocus={onPicker}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{onPicker ? (
|
{onPicker ? (
|
||||||
<Pressable onPress={onPicker} disabled={disabled}>
|
<Pressable onPress={onPicker} disabled={disabled}>
|
||||||
<ChevronDown size={16} color={primary} />
|
<ChevronDown size={16} color={primary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</MobileField>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
|
|||||||
import { Switch } from '@tamagui/switch';
|
import { Switch } from '@tamagui/switch';
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton } from './components/Primitives';
|
import { MobileCard, CTAButton } from './components/Primitives';
|
||||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
import { MobileDateTimeInput, MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||||
import {
|
import {
|
||||||
createEvent,
|
createEvent,
|
||||||
@@ -401,14 +401,9 @@ export default function MobileEventFormPage() {
|
|||||||
|
|
||||||
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<NativeDateTimeInput
|
<MobileDateTimeInput
|
||||||
value={form.date}
|
value={form.date}
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, date: value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))}
|
||||||
border={border}
|
|
||||||
surface={surface}
|
|
||||||
text={text}
|
|
||||||
primary={primary}
|
|
||||||
danger={danger}
|
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<CalendarDays size={16} color={subtle} />
|
<CalendarDays size={16} color={subtle} />
|
||||||
@@ -624,57 +619,6 @@ function toDateTimeLocal(value?: string | null): string {
|
|||||||
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
|
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function NativeDateTimeInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
border,
|
|
||||||
surface,
|
|
||||||
text,
|
|
||||||
primary,
|
|
||||||
danger,
|
|
||||||
hasError,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
border: string;
|
|
||||||
surface: string;
|
|
||||||
text: string;
|
|
||||||
primary: string;
|
|
||||||
danger: string;
|
|
||||||
hasError?: boolean;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}) {
|
|
||||||
const [focused, setFocused] = React.useState(false);
|
|
||||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
|
||||||
const borderColor = hasError ? danger : focused ? primary : border;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
onFocus={() => setFocused(true)}
|
|
||||||
onBlur={() => setFocused(false)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: 44,
|
|
||||||
padding: '0 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor,
|
|
||||||
backgroundColor: surface,
|
|
||||||
color: text,
|
|
||||||
fontSize: 14,
|
|
||||||
boxShadow: focused ? `0 0 0 3px ${ringColor}` : undefined,
|
|
||||||
outline: 'none',
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocation(event: TenantEvent): string {
|
function resolveLocation(event: TenantEvent): string {
|
||||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||||
const candidate =
|
const candidate =
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div ref={formRef}>
|
<YStack ref={formRef}>
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('guestMessages.composeTitle', 'Send a message')}
|
{t('guestMessages.composeTitle', 'Send a message')}
|
||||||
@@ -299,7 +299,7 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</div>
|
</YStack>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard space="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } from 'lucide-r
|
|||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
|
import { Switch } from '@tamagui/switch';
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||||
@@ -315,12 +316,9 @@ function ToggleOption({ label, value, onToggle }: { label: string; value: boolea
|
|||||||
<Text fontSize="$sm" color={textStrong} fontWeight="600">
|
<Text fontSize="$sm" color={textStrong} fontWeight="600">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<input
|
<Switch size="$4" checked={value} onCheckedChange={onToggle} aria-label={label}>
|
||||||
type="checkbox"
|
<Switch.Thumb />
|
||||||
checked={value}
|
</Switch>
|
||||||
onChange={(e) => onToggle(e.target.checked)}
|
|
||||||
style={{ width: 20, height: 20 }}
|
|
||||||
/>
|
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
|||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { AlertDialog } from '@tamagui/alert-dialog';
|
import { AlertDialog } from '@tamagui/alert-dialog';
|
||||||
import { ScrollView } from '@tamagui/scroll-view';
|
import { ScrollView } from '@tamagui/scroll-view';
|
||||||
|
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives';
|
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives';
|
||||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||||
@@ -183,28 +184,34 @@ function SummaryLegendItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function QuickNavChip({
|
function QuickNavChip({
|
||||||
|
value,
|
||||||
label,
|
label,
|
||||||
count,
|
count,
|
||||||
onPress,
|
onPress,
|
||||||
|
isActive = false,
|
||||||
}: {
|
}: {
|
||||||
|
value: TaskSectionKey;
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { textStrong, border, surface, surfaceMuted } = useAdminTheme();
|
const { textStrong, border, surface, surfaceMuted, primary } = useAdminTheme();
|
||||||
|
const activeBorder = withAlpha(primary, 0.45);
|
||||||
|
const activeBackground = withAlpha(primary, 0.16);
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress}>
|
<ToggleGroup.Item
|
||||||
<XStack
|
value={value}
|
||||||
alignItems="center"
|
onPress={onPress}
|
||||||
space="$1.5"
|
|
||||||
paddingHorizontal="$3"
|
|
||||||
paddingVertical="$2"
|
|
||||||
borderRadius={999}
|
borderRadius={999}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={isActive ? activeBorder : border}
|
||||||
backgroundColor={surface}
|
backgroundColor={isActive ? activeBackground : surface}
|
||||||
style={{ minHeight: 36 }}
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$2"
|
||||||
|
height={36}
|
||||||
>
|
>
|
||||||
|
<XStack alignItems="center" space="$1.5">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
|
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -213,7 +220,7 @@ function QuickNavChip({
|
|||||||
paddingVertical="$0.5"
|
paddingVertical="$0.5"
|
||||||
borderRadius={999}
|
borderRadius={999}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={isActive ? activeBorder : border}
|
||||||
backgroundColor={surfaceMuted}
|
backgroundColor={surfaceMuted}
|
||||||
>
|
>
|
||||||
<Text fontSize={10} fontWeight="800" color={textStrong}>
|
<Text fontSize={10} fontWeight="800" color={textStrong}>
|
||||||
@@ -221,7 +228,7 @@ function QuickNavChip({
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</ToggleGroup.Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +270,7 @@ export default function MobileEventTasksPage() {
|
|||||||
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
|
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
|
||||||
const [savingEmotion, setSavingEmotion] = React.useState(false);
|
const [savingEmotion, setSavingEmotion] = React.useState(false);
|
||||||
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
|
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
|
||||||
|
const [quickNavSelection, setQuickNavSelection] = React.useState<TaskSectionKey | ''>('');
|
||||||
const text = textStrong;
|
const text = textStrong;
|
||||||
const assignedRef = React.useRef<HTMLDivElement>(null);
|
const assignedRef = React.useRef<HTMLDivElement>(null);
|
||||||
const libraryRef = React.useRef<HTMLDivElement>(null);
|
const libraryRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -623,16 +631,34 @@ export default function MobileEventTasksPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={quickNavSelection}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const key = next as TaskSectionKey | '';
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuickNavSelection(key);
|
||||||
|
handleQuickNav(key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<XStack space="$2" paddingVertical="$1">
|
<XStack space="$2" paddingVertical="$1">
|
||||||
{sectionCounts.map((section) => (
|
{sectionCounts.map((section) => (
|
||||||
<QuickNavChip
|
<QuickNavChip
|
||||||
key={section.key}
|
key={section.key}
|
||||||
|
value={section.key}
|
||||||
label={t(`events.tasks.sections.${section.key}`, section.key)}
|
label={t(`events.tasks.sections.${section.key}`, section.key)}
|
||||||
count={section.count}
|
count={section.count}
|
||||||
onPress={() => handleQuickNav(section.key)}
|
onPress={() => {
|
||||||
|
setQuickNavSelection(section.key);
|
||||||
|
handleQuickNav(section.key);
|
||||||
|
}}
|
||||||
|
isActive={quickNavSelection === section.key}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
</ToggleGroup>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
@@ -770,7 +796,7 @@ export default function MobileEventTasksPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
<div ref={assignedRef} />
|
<YStack ref={assignedRef} />
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -822,7 +848,7 @@ export default function MobileEventTasksPage() {
|
|||||||
))}
|
))}
|
||||||
</YGroup>
|
</YGroup>
|
||||||
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
|
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
|
||||||
<div ref={libraryRef} />
|
<YStack ref={libraryRef} />
|
||||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||||
{t('events.tasks.library', 'Weitere Aufgaben')}
|
{t('events.tasks.library', 'Weitere Aufgaben')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
|
import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
|
||||||
|
import { Card } from '@tamagui/card';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
|
import { ScrollView } from '@tamagui/scroll-view';
|
||||||
|
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||||
|
import { Separator } from '@tamagui/separator';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
|
import { PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
|
||||||
import { MobileInput } from './components/FormControls';
|
import { MobileInput } from './components/FormControls';
|
||||||
import { getEvents, TenantEvent } from '../api';
|
import { getEvents, TenantEvent } from '../api';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
@@ -27,7 +31,7 @@ export default function MobileEventsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
|
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
|
||||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
const back = useBackNavigation();
|
const back = useBackNavigation();
|
||||||
const { text, muted, subtle, border, primary, danger, surface, accentSoft, accent } = useAdminTheme();
|
const { text, muted, subtle, border, primary, danger, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -54,13 +58,48 @@ export default function MobileEventsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<MobileCard>
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
<Text fontWeight="700" color={danger}>
|
<Text fontWeight="700" color={danger}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
</MobileCard>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2.5">
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$1.5"
|
||||||
|
borderRadius={999}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surfaceMuted}
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||||
|
{t('events.list.filters.title', 'Filters & Search')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
type="search"
|
type="search"
|
||||||
@@ -68,8 +107,12 @@ export default function MobileEventsPage() {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder={t('events.list.search', 'Search events')}
|
placeholder={t('events.list.search', 'Search events')}
|
||||||
compact
|
compact
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
/>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('events.list.filters.hint', 'Filter your events by status or search by name.')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
@@ -78,7 +121,18 @@ export default function MobileEventsPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : events.length === 0 ? (
|
) : events.length === 0 ? (
|
||||||
<MobileCard alignItems="center" justifyContent="center" space="$3">
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2" alignItems="center">
|
||||||
<Text fontSize="$md" fontWeight="700">
|
<Text fontSize="$md" fontWeight="700">
|
||||||
{t('events.list.empty.title', 'Noch kein Event angelegt')}
|
{t('events.list.empty.title', 'Noch kein Event angelegt')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -86,7 +140,8 @@ export default function MobileEventsPage() {
|
|||||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||||
</Text>
|
</Text>
|
||||||
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
|
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
|
||||||
</MobileCard>
|
</YStack>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<EventsList
|
<EventsList
|
||||||
events={events}
|
events={events}
|
||||||
@@ -123,7 +178,7 @@ function EventsList({
|
|||||||
onEdit: (slug: string) => void;
|
onEdit: (slug: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { text, muted, subtle, border, primary, surface, accentSoft, accent } = useAdminTheme();
|
const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
|
||||||
const activeBg = accentSoft;
|
const activeBg = accentSoft;
|
||||||
const activeBorder = accent;
|
const activeBorder = accent;
|
||||||
|
|
||||||
@@ -150,34 +205,79 @@ function EventsList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3">
|
<YStack space="$3">
|
||||||
<XStack space="$2" flexWrap="wrap">
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.14}
|
||||||
|
shadowRadius={14}
|
||||||
|
shadowOffset={{ width: 0, height: 8 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2.5">
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$1.5"
|
||||||
|
borderRadius={999}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surfaceMuted}
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||||
|
{t('events.list.filters.status', 'Status')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => value && onStatusChange(value as EventStatusKey)}
|
||||||
|
>
|
||||||
|
<XStack space="$2" paddingVertical="$1">
|
||||||
{filters.map((filter) => {
|
{filters.map((filter) => {
|
||||||
const active = filter.key === statusFilter;
|
const active = filter.key === statusFilter;
|
||||||
return (
|
return (
|
||||||
<Pressable key={filter.key} onPress={() => onStatusChange(filter.key)} style={{ flexGrow: 1 }}>
|
<ToggleGroup.Item
|
||||||
<XStack
|
key={filter.key}
|
||||||
alignItems="center"
|
value={filter.key}
|
||||||
justifyContent="center"
|
borderRadius={999}
|
||||||
space="$1.5"
|
|
||||||
paddingVertical="$2"
|
|
||||||
paddingHorizontal="$3"
|
|
||||||
borderRadius={14}
|
|
||||||
backgroundColor={active ? activeBg : surface}
|
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={active ? activeBorder : border}
|
borderColor={active ? activeBorder : border}
|
||||||
|
backgroundColor={active ? activeBg : surface}
|
||||||
|
paddingVertical="$2"
|
||||||
|
paddingHorizontal="$3"
|
||||||
>
|
>
|
||||||
|
<XStack alignItems="center" space="$1.5">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
</Text>
|
</Text>
|
||||||
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
|
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</ToggleGroup.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
</ToggleGroup>
|
||||||
|
</ScrollView>
|
||||||
|
</YStack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{filteredEvents.length === 0 ? (
|
{filteredEvents.length === 0 ? (
|
||||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.14}
|
||||||
|
shadowRadius={14}
|
||||||
|
shadowOffset={{ width: 0, height: 8 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2" alignItems="center">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('events.list.empty.filtered', 'No events match this filter.')}
|
{t('events.list.empty.filtered', 'No events match this filter.')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -190,7 +290,8 @@ function EventsList({
|
|||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
onPress={() => onStatusChange('all')}
|
onPress={() => onStatusChange('all')}
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</YStack>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
filteredEvents.map((event) => {
|
filteredEvents.map((event) => {
|
||||||
const statusKey = resolveEventStatusKey(event);
|
const statusKey = resolveEventStatusKey(event);
|
||||||
@@ -210,6 +311,8 @@ function EventsList({
|
|||||||
subtle={subtle}
|
subtle={subtle}
|
||||||
border={border}
|
border={border}
|
||||||
primary={primary}
|
primary={primary}
|
||||||
|
surface={surface}
|
||||||
|
shadow={shadow}
|
||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
statusTone={statusTone}
|
statusTone={statusTone}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
@@ -229,6 +332,8 @@ function EventRow({
|
|||||||
subtle,
|
subtle,
|
||||||
border,
|
border,
|
||||||
primary,
|
primary,
|
||||||
|
surface,
|
||||||
|
shadow,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
statusTone,
|
statusTone,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -240,6 +345,8 @@ function EventRow({
|
|||||||
subtle: string;
|
subtle: string;
|
||||||
border: string;
|
border: string;
|
||||||
primary: string;
|
primary: string;
|
||||||
|
surface: string;
|
||||||
|
shadow: string;
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
statusTone: 'success' | 'warning' | 'muted';
|
statusTone: 'success' | 'warning' | 'muted';
|
||||||
onOpen: (slug: string) => void;
|
onOpen: (slug: string) => void;
|
||||||
@@ -248,7 +355,18 @@ function EventRow({
|
|||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const stats = buildEventListStats(event);
|
const stats = buildEventListStats(event);
|
||||||
return (
|
return (
|
||||||
<MobileCard borderColor={border}>
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.14}
|
||||||
|
shadowRadius={14}
|
||||||
|
shadowOffset={{ width: 0, height: 8 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2.5">
|
||||||
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
||||||
<YStack space="$1">
|
<YStack space="$1">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
@@ -267,6 +385,14 @@ function EventRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
|
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
|
||||||
|
</YStack>
|
||||||
|
<Pressable onPress={() => onEdit(event.slug)}>
|
||||||
|
<Text fontSize="$xl" color={muted}>
|
||||||
|
˅
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
||||||
<EventStatChip
|
<EventStatChip
|
||||||
icon={Camera}
|
icon={Camera}
|
||||||
@@ -287,15 +413,10 @@ function EventRow({
|
|||||||
muted={subtle}
|
muted={subtle}
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
|
||||||
<Pressable onPress={() => onEdit(event.slug)}>
|
|
||||||
<Text fontSize="$xl" color={muted}>
|
|
||||||
˅
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</XStack>
|
|
||||||
|
|
||||||
<Pressable onPress={() => onOpen(event.slug)} style={{ marginTop: 8 }}>
|
<Separator borderColor={border} />
|
||||||
|
|
||||||
|
<Pressable onPress={() => onOpen(event.slug)}>
|
||||||
<XStack alignItems="center" justifyContent="flex-start" space="$2">
|
<XStack alignItems="center" justifyContent="flex-start" space="$2">
|
||||||
<Plus size={16} color={primary} />
|
<Plus size={16} color={primary} />
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||||
@@ -303,7 +424,8 @@ function EventRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</MobileCard>
|
</YStack>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import React from 'react';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@tamagui/card';
|
||||||
|
import { YStack } from '@tamagui/stacks';
|
||||||
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import { Spinner } from 'tamagui';
|
||||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||||
|
import { useAdminTheme } from './theme';
|
||||||
|
|
||||||
export default function LoginStartPage(): React.ReactElement {
|
export default function LoginStartPage(): React.ReactElement {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('auth');
|
const { t } = useTranslation('auth');
|
||||||
|
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
|
||||||
const safeAreaStyle: React.CSSProperties = {
|
const safeAreaStyle: React.CSSProperties = {
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
@@ -25,11 +31,34 @@ export default function LoginStartPage(): React.ReactElement {
|
|||||||
}, [location.search, navigate]);
|
}, [location.search, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<YStack
|
||||||
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70"
|
alignItems="center"
|
||||||
style={safeAreaStyle}
|
justifyContent="center"
|
||||||
|
padding="$4"
|
||||||
|
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
|
||||||
|
backgroundColor={surface}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p>
|
<Card
|
||||||
</div>
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack alignItems="center" space="$2">
|
||||||
|
<Spinner size="small" color={textStrong} />
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
|
{t('redirecting', 'Redirecting to login …')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('redirectingHint', 'One moment, preparing the login screen.')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</Card>
|
||||||
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Card } from '@tamagui/card';
|
||||||
|
import { YStack } from '@tamagui/stacks';
|
||||||
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import { Spinner } from 'tamagui';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
|
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
|
||||||
|
import { useAdminTheme } from './theme';
|
||||||
|
|
||||||
export default function LogoutPage() {
|
export default function LogoutPage() {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
|
||||||
const safeAreaStyle: React.CSSProperties = {
|
const safeAreaStyle: React.CSSProperties = {
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
@@ -14,13 +20,34 @@ export default function LogoutPage() {
|
|||||||
}, [logout]);
|
}, [logout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<YStack
|
||||||
className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600"
|
alignItems="center"
|
||||||
style={safeAreaStyle}
|
justifyContent="center"
|
||||||
|
padding="$4"
|
||||||
|
style={{ minHeight: '100vh', backgroundImage: appBackground, ...safeAreaStyle }}
|
||||||
|
backgroundColor={surface}
|
||||||
>
|
>
|
||||||
<div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60">
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack alignItems="center" space="$2">
|
||||||
|
<Spinner size="small" color={textStrong} />
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
Abmeldung wird vorbereitet ...
|
Abmeldung wird vorbereitet ...
|
||||||
</div>
|
</Text>
|
||||||
</div>
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
Bitte einen Moment Geduld.
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</Card>
|
||||||
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<YStack position="relative">
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
@@ -119,7 +119,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
|
|||||||
>
|
>
|
||||||
<Pressable onPress={handlePress}>{children}</Pressable>
|
<Pressable onPress={handlePress}>{children}</Pressable>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LogOut, User, Settings, Shield, Globe, Moon, Download } from 'lucide-react';
|
import { User, Settings, Globe, Moon, Download, LogOut } from 'lucide-react';
|
||||||
|
import { Avatar } from '@tamagui/avatar';
|
||||||
|
import { Card } from '@tamagui/card';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
||||||
import { YGroup } from '@tamagui/group';
|
import { YGroup } from '@tamagui/group';
|
||||||
import { ListItem } from '@tamagui/list-item';
|
import { ListItem } from '@tamagui/list-item';
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton } from './components/Primitives';
|
import { CTAButton } from './components/Primitives';
|
||||||
import { MobileSelect } from './components/FormControls';
|
import { MobileSelect } from './components/FormControls';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { fetchTenantProfile } from '../api';
|
import { fetchTenantProfile } from '../api';
|
||||||
@@ -16,6 +17,7 @@ import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '
|
|||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
|
import { withAlpha } from './components/colors';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
|
|
||||||
export default function MobileProfilePage() {
|
export default function MobileProfilePage() {
|
||||||
@@ -23,7 +25,7 @@ export default function MobileProfilePage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
const { textStrong, muted, border, accentSoft, primary, subtle } = useAdminTheme();
|
const { textStrong, muted, border, accentSoft, primary, subtle, surface, surfaceMuted, shadow, danger } = useAdminTheme();
|
||||||
const textColor = textStrong;
|
const textColor = textStrong;
|
||||||
const mutedText = muted;
|
const mutedText = muted;
|
||||||
const borderColor = border;
|
const borderColor = border;
|
||||||
@@ -54,17 +56,24 @@ export default function MobileProfilePage() {
|
|||||||
title={t('mobileProfile.title', 'Profile')}
|
title={t('mobileProfile.title', 'Profile')}
|
||||||
onBack={back}
|
onBack={back}
|
||||||
>
|
>
|
||||||
<MobileCard space="$3" alignItems="center">
|
<Card
|
||||||
<XStack
|
borderRadius={22}
|
||||||
width={64}
|
borderWidth={2}
|
||||||
height={64}
|
borderColor={borderColor}
|
||||||
borderRadius={20}
|
backgroundColor={surface}
|
||||||
alignItems="center"
|
padding="$3"
|
||||||
justifyContent="center"
|
shadowColor={shadow}
|
||||||
backgroundColor={avatarBg}
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
|
<YStack space="$2.5" alignItems="center">
|
||||||
|
<Avatar size="$7" borderRadius={20} backgroundColor={avatarBg}>
|
||||||
|
<Avatar.Fallback>
|
||||||
<User size={28} color={primary} />
|
<User size={28} color={primary} />
|
||||||
</XStack>
|
</Avatar.Fallback>
|
||||||
|
</Avatar>
|
||||||
|
<YStack space="$0.5" alignItems="center">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -76,16 +85,37 @@ export default function MobileProfilePage() {
|
|||||||
{role}
|
{role}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</MobileCard>
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<Card
|
||||||
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={borderColor}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2.5">
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$1.5"
|
||||||
|
borderRadius={999}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={borderColor}
|
||||||
|
backgroundColor={surfaceMuted}
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" fontWeight="800" color={textColor}>
|
||||||
{t('mobileProfile.settings', 'Settings')}
|
{t('mobileProfile.settings', 'Settings')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$4">
|
</XStack>
|
||||||
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
|
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
|
||||||
<YGroup.Item>
|
<YGroup.Item>
|
||||||
<Pressable onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -97,11 +127,10 @@ export default function MobileProfilePage() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
iconAfter={<Settings size={18} color={subtle} />}
|
iconAfter={<Settings size={18} color={subtle} />}
|
||||||
|
onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
<YGroup.Item>
|
<YGroup.Item>
|
||||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -113,11 +142,10 @@ export default function MobileProfilePage() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
iconAfter={<Settings size={18} color={subtle} />}
|
iconAfter={<Settings size={18} color={subtle} />}
|
||||||
|
onPress={() => navigate(adminPath('/mobile/billing#packages'))}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
<YGroup.Item>
|
<YGroup.Item>
|
||||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -129,11 +157,10 @@ export default function MobileProfilePage() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
iconAfter={<Settings size={18} color={subtle} />}
|
iconAfter={<Settings size={18} color={subtle} />}
|
||||||
|
onPress={() => navigate(adminPath('/mobile/billing#invoices'))}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
<YGroup.Item>
|
<YGroup.Item>
|
||||||
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -145,9 +172,39 @@ export default function MobileProfilePage() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
iconAfter={<Download size={18} color={subtle} />}
|
iconAfter={<Download size={18} color={subtle} />}
|
||||||
|
onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
|
</YGroup>
|
||||||
|
</YStack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={borderColor}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2.5">
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$1.5"
|
||||||
|
borderRadius={999}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={borderColor}
|
||||||
|
backgroundColor={surfaceMuted}
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" fontWeight="800" color={textColor}>
|
||||||
|
{t('mobileProfile.preferences', 'Preferences')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
|
||||||
<YGroup.Item>
|
<YGroup.Item>
|
||||||
<ListItem
|
<ListItem
|
||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
@@ -205,15 +262,48 @@ export default function MobileProfilePage() {
|
|||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
</YGroup>
|
</YGroup>
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={withAlpha(danger, 0.4)}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.12}
|
||||||
|
shadowRadius={12}
|
||||||
|
shadowOffset={{ width: 0, height: 6 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2">
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<XStack
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
borderRadius={12}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
backgroundColor={withAlpha(danger, 0.12)}
|
||||||
|
>
|
||||||
|
<LogOut size={16} color={danger} />
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$sm" fontWeight="800" color={textColor}>
|
||||||
|
{t('mobileProfile.logoutTitle', 'Sign out')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$xs" color={mutedText}>
|
||||||
|
{t('mobileProfile.logoutHint', 'Sign out from this device.')}
|
||||||
|
</Text>
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('mobileProfile.logout', 'Log out')}
|
label={t('mobileProfile.logout', 'Log out')}
|
||||||
|
tone="danger"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
logout();
|
logout();
|
||||||
navigate(adminPath('/logout'));
|
navigate(adminPath('/logout'));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</YStack>
|
||||||
|
</Card>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { ArrowLeft, RefreshCcw, Plus, ChevronDown } from 'lucide-react';
|
|||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { Input, TextArea } from 'tamagui';
|
|
||||||
import { Accordion } from '@tamagui/accordion';
|
import { Accordion } from '@tamagui/accordion';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import { Portal } from '@tamagui/portal';
|
import { Portal } from '@tamagui/portal';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
|
import { MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||||
import {
|
import {
|
||||||
TenantEvent,
|
TenantEvent,
|
||||||
EventQrInvite,
|
EventQrInvite,
|
||||||
@@ -753,7 +753,7 @@ function BackgroundStep({
|
|||||||
{presets.map((preset) => {
|
{presets.map((preset) => {
|
||||||
const isSelected = selectedPreset === preset.id;
|
const isSelected = selectedPreset === preset.id;
|
||||||
return (
|
return (
|
||||||
<Pressable key={preset.id} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
|
<Pressable key={preset.id ?? preset.labelKey} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
|
||||||
<YStack
|
<YStack
|
||||||
aspectRatio={210 / 297}
|
aspectRatio={210 / 297}
|
||||||
maxHeight={220}
|
maxHeight={220}
|
||||||
@@ -795,7 +795,7 @@ function BackgroundStep({
|
|||||||
{t('events.qr.gradients', 'Gradienten')}
|
{t('events.qr.gradients', 'Gradienten')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack flexWrap="wrap" gap="$2">
|
<XStack flexWrap="wrap" gap="$2">
|
||||||
{gradientPresets.map((gradient, idx) => {
|
{gradientPresets.map((gradient) => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedGradient &&
|
selectedGradient &&
|
||||||
selectedGradient.angle === gradient.angle &&
|
selectedGradient.angle === gradient.angle &&
|
||||||
@@ -803,9 +803,10 @@ function BackgroundStep({
|
|||||||
const style = {
|
const style = {
|
||||||
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
|
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
|
const key = `${gradient.labelKey}-${gradient.angle}`;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={idx}
|
key={key}
|
||||||
onPress={() => onSelectGradient(gradient)}
|
onPress={() => onSelectGradient(gradient)}
|
||||||
style={{ width: '30%' }}
|
style={{ width: '30%' }}
|
||||||
aria-label={t(gradient.labelKey, gradient.label)}
|
aria-label={t(gradient.labelKey, gradient.label)}
|
||||||
@@ -829,10 +830,11 @@ function BackgroundStep({
|
|||||||
{t('events.qr.colors', 'Vollfarbe')}
|
{t('events.qr.colors', 'Vollfarbe')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack flexWrap="wrap" gap="$2">
|
<XStack flexWrap="wrap" gap="$2">
|
||||||
{solidPresets.map((color) => {
|
{solidPresets.map((color, idx) => {
|
||||||
const isSelected = selectedSolid === color;
|
const isSelected = selectedSolid === color;
|
||||||
|
const key = `${color}-${idx}`;
|
||||||
return (
|
return (
|
||||||
<Pressable key={color} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}>
|
<Pressable key={key} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}>
|
||||||
<YStack
|
<YStack
|
||||||
height={50}
|
height={50}
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
@@ -912,24 +914,20 @@ function TextStep({
|
|||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.textFields', 'Texte')}
|
{t('events.qr.textFields', 'Texte')}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<MobileInput
|
||||||
placeholder={t('events.qr.headline', 'Headline')}
|
placeholder={t('events.qr.headline', 'Headline')}
|
||||||
value={textFields.headline}
|
value={textFields.headline}
|
||||||
onChangeText={(val) => updateField('headline', val)}
|
onChange={(event) => updateField('headline', event.target.value)}
|
||||||
size="$4"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<MobileInput
|
||||||
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
||||||
value={textFields.subtitle}
|
value={textFields.subtitle}
|
||||||
onChangeText={(val) => updateField('subtitle', val)}
|
onChange={(event) => updateField('subtitle', event.target.value)}
|
||||||
size="$4"
|
|
||||||
/>
|
/>
|
||||||
<TextArea
|
<MobileTextArea
|
||||||
placeholder={t('events.qr.bottomNote', 'Unterer Hinweistext')}
|
placeholder={t('events.qr.bottomNote', 'Unterer Hinweistext')}
|
||||||
value={textFields.description}
|
value={textFields.description}
|
||||||
onChangeText={(val) => updateField('description', val)}
|
onChange={(event) => updateField('description', event.target.value)}
|
||||||
size="$4"
|
|
||||||
numberOfLines={3}
|
|
||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
@@ -939,13 +937,12 @@ function TextStep({
|
|||||||
</Text>
|
</Text>
|
||||||
{textFields.instructions.map((item, idx) => (
|
{textFields.instructions.map((item, idx) => (
|
||||||
<XStack key={idx} alignItems="center" space="$2">
|
<XStack key={idx} alignItems="center" space="$2">
|
||||||
<TextArea
|
<MobileTextArea
|
||||||
value={item}
|
value={item}
|
||||||
onChangeText={(val) => updateInstruction(idx, val)}
|
onChange={(event) => updateInstruction(idx, event.target.value)}
|
||||||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||||||
numberOfLines={2}
|
style={{ flex: 1 }}
|
||||||
{...({ flex: 1 } as any)}
|
compact
|
||||||
size="$4"
|
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
|
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
|
||||||
<XStack
|
<XStack
|
||||||
@@ -1204,36 +1201,13 @@ function LayoutControls({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
|
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
|
||||||
const { border, surface, surfaceMuted, text, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
|
const { border, surface, surfaceMuted, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
|
||||||
const fontOptions = React.useMemo(() => {
|
const fontOptions = React.useMemo(() => {
|
||||||
const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora'];
|
const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora'];
|
||||||
const tenant = tenantFonts.map((font) => font.family);
|
const tenant = tenantFonts.map((font) => font.family);
|
||||||
return Array.from(new Set([...tenant, ...preset]));
|
return Array.from(new Set([...tenant, ...preset]));
|
||||||
}, [tenantFonts]);
|
}, [tenantFonts]);
|
||||||
|
|
||||||
const numberInputStyle: React.CSSProperties = {
|
|
||||||
width: 90,
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: `1px solid ${border}`,
|
|
||||||
borderRadius: 12,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: DEFAULT_BODY_FONT,
|
|
||||||
background: surfaceMuted,
|
|
||||||
color: text,
|
|
||||||
appearance: 'textfield',
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectStyle: React.CSSProperties = {
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: `1px solid ${border}`,
|
|
||||||
borderRadius: 12,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: DEFAULT_BODY_FONT,
|
|
||||||
background: surfaceMuted,
|
|
||||||
color: text,
|
|
||||||
};
|
|
||||||
|
|
||||||
const StepperInput = ({
|
const StepperInput = ({
|
||||||
value,
|
value,
|
||||||
min,
|
min,
|
||||||
@@ -1260,11 +1234,9 @@ function LayoutControls({
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<input
|
<MobileInput
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
value={formatValue(value)}
|
value={formatValue(value)}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = Number(event.target.value);
|
const next = Number(event.target.value);
|
||||||
@@ -1272,7 +1244,13 @@ function LayoutControls({
|
|||||||
onChange(next);
|
onChange(next);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={numberInputStyle}
|
compact
|
||||||
|
style={{
|
||||||
|
width: 90,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: DEFAULT_BODY_FONT,
|
||||||
|
backgroundColor: surfaceMuted,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={inc}>
|
<Pressable onPress={inc}>
|
||||||
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
|
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
|
||||||
@@ -1395,10 +1373,9 @@ function LayoutControls({
|
|||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.fontFamily', 'Font Family')}
|
{t('events.qr.fontFamily', 'Font Family')}
|
||||||
</Text>
|
</Text>
|
||||||
<select
|
<MobileSelect
|
||||||
value={override.fontFamily ?? slot.fontFamily ?? ''}
|
value={override.fontFamily ?? slot.fontFamily ?? ''}
|
||||||
onChange={(event) => onFontFamilyChange(event.target.value)}
|
onChange={(event) => onFontFamilyChange(event.target.value)}
|
||||||
style={selectStyle}
|
|
||||||
>
|
>
|
||||||
<option value="">{t('events.qr.defaultFont', 'Standard')}</option>
|
<option value="">{t('events.qr.defaultFont', 'Standard')}</option>
|
||||||
{fontOptions.map((family) => (
|
{fontOptions.map((family) => (
|
||||||
@@ -1406,7 +1383,7 @@ function LayoutControls({
|
|||||||
{family}
|
{family}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</MobileSelect>
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} space="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
@@ -1424,12 +1401,12 @@ function LayoutControls({
|
|||||||
backgroundColor={override.color ?? slot.color ?? textStrong}
|
backgroundColor={override.color ?? slot.color ?? textStrong}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<input
|
<MobileInput
|
||||||
type="text"
|
|
||||||
value={override.color ?? slot.color ?? ''}
|
value={override.color ?? slot.color ?? ''}
|
||||||
placeholder={ADMIN_COLORS.text}
|
placeholder={ADMIN_COLORS.text}
|
||||||
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
|
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
|
||||||
style={{ ...numberInputStyle, width: 110 }}
|
compact
|
||||||
|
style={{ width: 110, backgroundColor: surfaceMuted }}
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
{openColorSlot === slotKey ? (
|
{openColorSlot === slotKey ? (
|
||||||
@@ -1496,15 +1473,14 @@ function LayoutControls({
|
|||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.align', 'Align')}
|
{t('events.qr.align', 'Align')}
|
||||||
</Text>
|
</Text>
|
||||||
<select
|
<MobileSelect
|
||||||
value={override.align ?? slot.align ?? 'left'}
|
value={override.align ?? slot.align ?? 'left'}
|
||||||
onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })}
|
onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })}
|
||||||
style={selectStyle}
|
|
||||||
>
|
>
|
||||||
<option value="left">{t('common.left', 'Links')}</option>
|
<option value="left">{t('common.left', 'Links')}</option>
|
||||||
<option value="center">{t('common.center', 'Zentriert')}</option>
|
<option value="center">{t('common.center', 'Zentriert')}</option>
|
||||||
<option value="right">{t('common.right', 'Rechts')}</option>
|
<option value="right">{t('common.right', 'Rechts')}</option>
|
||||||
</select>
|
</MobileSelect>
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} space="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
|
|||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
|
import { MobileInput, MobileTextArea } from './components/FormControls';
|
||||||
import {
|
import {
|
||||||
TenantEvent,
|
TenantEvent,
|
||||||
EventQrInvite,
|
EventQrInvite,
|
||||||
@@ -489,20 +490,20 @@ function TextStep({
|
|||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.textFields', 'Texte')}
|
{t('events.qr.textFields', 'Texte')}
|
||||||
</Text>
|
</Text>
|
||||||
<StyledInput
|
<MobileInput
|
||||||
placeholder={t('events.qr.headline', 'Headline')}
|
placeholder={t('events.qr.headline', 'Headline')}
|
||||||
value={textFields.headline}
|
value={textFields.headline}
|
||||||
onChangeText={(val) => updateField('headline', val)}
|
onChange={(event) => updateField('headline', event.target.value)}
|
||||||
/>
|
/>
|
||||||
<StyledInput
|
<MobileInput
|
||||||
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
||||||
value={textFields.subtitle}
|
value={textFields.subtitle}
|
||||||
onChangeText={(val) => updateField('subtitle', val)}
|
onChange={(event) => updateField('subtitle', event.target.value)}
|
||||||
/>
|
/>
|
||||||
<StyledTextarea
|
<MobileTextArea
|
||||||
placeholder={t('events.qr.description', 'Beschreibung')}
|
placeholder={t('events.qr.description', 'Beschreibung')}
|
||||||
value={textFields.description}
|
value={textFields.description}
|
||||||
onChangeText={(val) => updateField('description', val)}
|
onChange={(event) => updateField('description', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
@@ -512,11 +513,11 @@ function TextStep({
|
|||||||
</Text>
|
</Text>
|
||||||
{textFields.instructions.map((item, idx) => (
|
{textFields.instructions.map((item, idx) => (
|
||||||
<XStack key={idx} alignItems="center" space="$2">
|
<XStack key={idx} alignItems="center" space="$2">
|
||||||
<StyledInput
|
<MobileInput
|
||||||
flex={1}
|
style={{ flex: 1 }}
|
||||||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||||||
value={item}
|
value={item}
|
||||||
onChangeText={(val) => updateInstruction(idx, val)}
|
onChange={(event) => updateInstruction(idx, event.target.value)}
|
||||||
/>
|
/>
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label="–"
|
label="–"
|
||||||
@@ -651,60 +652,3 @@ function PreviewStep({
|
|||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function StyledInput({
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
placeholder,
|
|
||||||
flex,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChangeText: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
flex?: number;
|
|
||||||
}) {
|
|
||||||
const { border } = useAdminTheme();
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px solid ${border}`,
|
|
||||||
fontSize: 14,
|
|
||||||
outline: 'none',
|
|
||||||
flex: flex ?? undefined,
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChangeText(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StyledTextarea({
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
placeholder,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChangeText: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
}) {
|
|
||||||
const { border } = useAdminTheme();
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px solid ${border}`,
|
|
||||||
fontSize: 14,
|
|
||||||
outline: 'none',
|
|
||||||
minHeight: 96,
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChangeText(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@tamagui/card';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
|
import { YGroup } from '@tamagui/group';
|
||||||
|
import { ListItem } from '@tamagui/list-item';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton } from './components/Primitives';
|
import { CTAButton } from './components/Primitives';
|
||||||
import { useEventContext } from '../context/EventContext';
|
import { useEventContext } from '../context/EventContext';
|
||||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
@@ -15,7 +17,7 @@ export default function MobileUploadsTabPage() {
|
|||||||
const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
|
const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
|
||||||
const { t, i18n } = useTranslation('management');
|
const { t, i18n } = useTranslation('management');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { text, muted, border, primary } = useAdminTheme();
|
const { text, muted, border, primary, surface, surfaceMuted, shadow } = useAdminTheme();
|
||||||
|
|
||||||
if (activeEvent?.slug) {
|
if (activeEvent?.slug) {
|
||||||
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/control-room`)} replace />;
|
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/control-room`)} replace />;
|
||||||
@@ -24,7 +26,31 @@ export default function MobileUploadsTabPage() {
|
|||||||
if (!hasEvents) {
|
if (!hasEvents) {
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
|
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
|
||||||
<MobileCard alignItems="flex-start" space="$3">
|
<Card
|
||||||
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2.5">
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$1.5"
|
||||||
|
borderRadius={999}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surfaceMuted}
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||||
|
{t('mobileUploads.emptyBadge', 'No uploads yet')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
<Text fontSize="$lg" fontWeight="800" color={text}>
|
<Text fontSize="$lg" fontWeight="800" color={text}>
|
||||||
{t('mobileUploads.emptyTitle', 'Create an event first')}
|
{t('mobileUploads.emptyTitle', 'Create an event first')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -35,7 +61,8 @@ export default function MobileUploadsTabPage() {
|
|||||||
label={t('mobileDashboard.ctaCreate', 'Create event')}
|
label={t('mobileDashboard.ctaCreate', 'Create event')}
|
||||||
onPress={() => navigate(adminPath('/mobile/events/new'))}
|
onPress={() => navigate(adminPath('/mobile/events/new'))}
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</YStack>
|
||||||
|
</Card>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,22 +71,40 @@ export default function MobileUploadsTabPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
|
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
|
||||||
<YStack space="$2">
|
<Card
|
||||||
<Text fontSize="$sm" color={text} fontWeight="700">
|
borderRadius={22}
|
||||||
|
borderWidth={2}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.16}
|
||||||
|
shadowRadius={16}
|
||||||
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
|
>
|
||||||
|
<YStack space="$2.5">
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$1.5"
|
||||||
|
borderRadius={999}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surfaceMuted}
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||||
{t('mobileUploads.pickEvent', 'Pick an event to manage uploads')}
|
{t('mobileUploads.pickEvent', 'Pick an event to manage uploads')}
|
||||||
</Text>
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: border, overflow: "hidden" } as any)}>
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<Pressable
|
<YGroup.Item key={event.slug}>
|
||||||
key={event.slug}
|
<ListItem
|
||||||
onPress={() => {
|
hoverTheme
|
||||||
selectEvent(event.slug ?? null);
|
pressTheme
|
||||||
if (event.slug) {
|
paddingVertical="$2"
|
||||||
navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
|
paddingHorizontal="$3"
|
||||||
}
|
title={
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MobileCard borderColor={border} space="$2">
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<YStack space="$1">
|
<YStack space="$1">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{resolveEventDisplayName(event)}
|
{resolveEventDisplayName(event)}
|
||||||
@@ -68,14 +113,24 @@ export default function MobileUploadsTabPage() {
|
|||||||
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
|
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
}
|
||||||
|
iconAfter={
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||||
{t('mobileUploads.open', 'Open')}
|
{t('mobileUploads.open', 'Open')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
}
|
||||||
</MobileCard>
|
onPress={() => {
|
||||||
</Pressable>
|
selectEvent(event.slug ?? null);
|
||||||
|
if (event.slug) {
|
||||||
|
navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</YGroup.Item>
|
||||||
))}
|
))}
|
||||||
|
</YGroup>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
</Card>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
SkeletonCard: () => <div>Loading...</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/FormControls', () => ({
|
||||||
|
MobileField: ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||||
|
<div>
|
||||||
|
<span>{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
MobileColorInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||||
|
MobileFileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||||
|
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||||
|
MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) => (
|
||||||
|
<select {...props}>{children}</select>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../components/Sheet', () => ({
|
vi.mock('../components/Sheet', () => ({
|
||||||
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ vi.mock('../components/Primitives', () => ({
|
|||||||
|
|
||||||
vi.mock('../components/FormControls', () => ({
|
vi.mock('../components/FormControls', () => ({
|
||||||
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
MobileDateTimeInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||||
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||||
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
|
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
|
||||||
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
||||||
|
|||||||
@@ -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>,
|
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/toggle-group', () => ({
|
||||||
|
ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||||
|
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@tamagui/group', () => ({
|
vi.mock('@tamagui/group', () => ({
|
||||||
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
|||||||
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 { describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
const backMock = vi.fn();
|
const fixtures = vi.hoisted(() => ({
|
||||||
const navigateMock = vi.fn();
|
event: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Demo Event',
|
||||||
|
slug: 'demo-event',
|
||||||
|
public_url: 'https://example.test/guest/demo-event',
|
||||||
|
},
|
||||||
|
invites: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
url: 'https://example.test/guest/demo-event',
|
||||||
|
qr_code_data_url: 'data:image/png;base64,abc',
|
||||||
|
is_active: true,
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
id: 'layout-1',
|
||||||
|
panel_mode: 'single',
|
||||||
|
orientation: 'portrait',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('react-router-dom', () => ({
|
vi.mock('react-router-dom', () => ({
|
||||||
useNavigate: () => navigateMock,
|
useNavigate: () => vi.fn(),
|
||||||
useParams: () => ({ slug: 'demo-event' }),
|
useParams: () => ({ slug: fixtures.event.slug }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
t: (key: string) => key,
|
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||||
|
if (typeof fallback === 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||||
|
return fallback.defaultValue;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../hooks/useBackNavigation', () => ({
|
vi.mock('../hooks/useBackNavigation', () => ({
|
||||||
useBackNavigation: () => backMock,
|
useBackNavigation: () => undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../api', () => ({
|
vi.mock('../../api', () => ({
|
||||||
getEvent: vi.fn().mockResolvedValue({ slug: 'demo-event', name: 'Demo' }),
|
getEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||||
getEventQrInvites: vi.fn().mockResolvedValue([
|
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
token: 'demo-token',
|
|
||||||
url: 'https://example.test/g/demo-token',
|
|
||||||
label: null,
|
|
||||||
qr_code_data_url: 'data:image/png;base64,abc',
|
|
||||||
usage_limit: null,
|
|
||||||
usage_count: 0,
|
|
||||||
expires_at: '2026-01-12T12:00:00Z',
|
|
||||||
revoked_at: null,
|
|
||||||
is_active: true,
|
|
||||||
created_at: null,
|
|
||||||
metadata: {},
|
|
||||||
layouts_url: null,
|
|
||||||
layouts: [
|
|
||||||
{
|
|
||||||
id: 'layout-1',
|
|
||||||
name: 'Poster',
|
|
||||||
description: '',
|
|
||||||
subtitle: '',
|
|
||||||
formats: [],
|
|
||||||
preview: {
|
|
||||||
background: null,
|
|
||||||
background_gradient: null,
|
|
||||||
accent: null,
|
|
||||||
text: null,
|
|
||||||
},
|
|
||||||
paper: 'a4',
|
|
||||||
orientation: 'portrait',
|
|
||||||
panel_mode: 'single',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
createQrInvite: vi.fn(),
|
createQrInvite: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-hot-toast', () => ({
|
vi.mock('../../auth/tokens', () => ({
|
||||||
default: {
|
isAuthError: () => false,
|
||||||
error: vi.fn(),
|
|
||||||
success: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
vi.mock('../../lib/apiError', () => ({
|
||||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
getApiErrorMessage: () => 'error',
|
||||||
<button type="button" onClick={onPress}>
|
}));
|
||||||
{children}
|
|
||||||
</button>
|
vi.mock('./qr/utils', () => ({
|
||||||
),
|
resolveLayoutForFormat: () => 'layout-1',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/MobileShell', () => ({
|
vi.mock('../components/MobileShell', () => ({
|
||||||
@@ -82,14 +74,15 @@ vi.mock('../components/MobileShell', () => ({
|
|||||||
|
|
||||||
vi.mock('../components/Primitives', () => ({
|
vi.mock('../components/Primitives', () => ({
|
||||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
|
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||||
<button type="button" onClick={onPress}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/FormControls', () => ({
|
||||||
|
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||||
|
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@tamagui/stacks', () => ({
|
vi.mock('@tamagui/stacks', () => ({
|
||||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
@@ -99,31 +92,37 @@ vi.mock('@tamagui/text', () => ({
|
|||||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||||
|
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||||
|
<button type="button" onClick={onPress}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../theme', () => ({
|
vi.mock('../theme', () => ({
|
||||||
useAdminTheme: () => ({
|
useAdminTheme: () => ({
|
||||||
textStrong: '#111827',
|
|
||||||
text: '#111827',
|
text: '#111827',
|
||||||
muted: '#6b7280',
|
muted: '#6b7280',
|
||||||
subtle: '#94a3b8',
|
subtle: '#94a3b8',
|
||||||
border: '#e5e7eb',
|
border: '#e5e7eb',
|
||||||
surfaceMuted: '#fffdfb',
|
|
||||||
primary: '#ff5a5f',
|
primary: '#ff5a5f',
|
||||||
danger: '#b91c1c',
|
danger: '#dc2626',
|
||||||
accentSoft: '#ffe5ec',
|
surface: '#ffffff',
|
||||||
glassSurface: 'rgba(255,255,255,0.8)',
|
surfaceMuted: '#f9fafb',
|
||||||
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
|
accentSoft: '#eef2ff',
|
||||||
glassBorder: 'rgba(229,231,235,0.7)',
|
textStrong: '#0f172a',
|
||||||
glassShadow: 'rgba(15,23,42,0.14)',
|
|
||||||
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import MobileQrPrintPage from '../QrPrintPage';
|
import MobileQrPrintPage from '../QrPrintPage';
|
||||||
|
|
||||||
describe('MobileQrPrintPage', () => {
|
describe('MobileQrPrintPage', () => {
|
||||||
it('shows token expiry info when the invite has expires_at', async () => {
|
it('renders QR overview content', async () => {
|
||||||
render(<MobileQrPrintPage />);
|
render(<MobileQrPrintPage />);
|
||||||
|
|
||||||
expect(await screen.findByText('events.qr.expiresAt')).toBeInTheDocument();
|
expect(await screen.findByText('Entrance QR Code')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Schritt 1: Format wählen')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Neuen QR-Link erstellen').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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 };
|
return { Select };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { MobileSelect } from './FormControls';
|
import { MobileColorInput, MobileDateTimeInput, MobileFileInput, MobileSelect } from './FormControls';
|
||||||
|
|
||||||
describe('MobileSelect', () => {
|
describe('MobileSelect', () => {
|
||||||
it('maps options and forwards selection changes', () => {
|
it('maps options and forwards selection changes', () => {
|
||||||
@@ -83,3 +83,30 @@ describe('MobileSelect', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MobileColorInput', () => {
|
||||||
|
it('renders a color input with default sizing', () => {
|
||||||
|
render(<MobileColorInput value="#ff0000" onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('#ff0000');
|
||||||
|
expect(input).toHaveAttribute('type', 'color');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MobileFileInput', () => {
|
||||||
|
it('renders a hidden file input', () => {
|
||||||
|
render(<MobileFileInput data-testid="file-input" />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('file-input');
|
||||||
|
expect(input).toHaveAttribute('type', 'file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MobileDateTimeInput', () => {
|
||||||
|
it('renders a datetime-local input', () => {
|
||||||
|
render(<MobileDateTimeInput value="2024-10-20T14:30" onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('2024-10-20T14:30');
|
||||||
|
expect(input).toHaveAttribute('type', 'datetime-local');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,86 @@ type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps
|
|||||||
containerStyle?: React.CSSProperties;
|
containerStyle?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MobileColorInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
React.ComponentPropsWithoutRef<'input'> & { size?: number }
|
||||||
|
>(function MobileColorInput({ size = 52, style, ...props }, ref) {
|
||||||
|
const { border, surface } = useAdminTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="color"
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
background: surface,
|
||||||
|
padding: 0,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MobileFileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'>>(
|
||||||
|
function MobileFileInput({ style, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="file"
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MobileDateTimeInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
React.ComponentPropsWithoutRef<'input'> & ControlProps
|
||||||
|
>(function MobileDateTimeInput({ hasError = false, style, ...props }, ref) {
|
||||||
|
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||||
|
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||||
|
const borderColor = hasError ? danger : border;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="datetime-local"
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 44,
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor,
|
||||||
|
backgroundColor: surface,
|
||||||
|
color: text,
|
||||||
|
fontSize: 14,
|
||||||
|
outline: 'none',
|
||||||
|
boxShadow: `0 0 0 0 ${ringColor}`,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
onFocus={(event) => {
|
||||||
|
event.currentTarget.style.boxShadow = `0 0 0 3px ${ringColor}`;
|
||||||
|
props.onFocus?.(event);
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
event.currentTarget.style.boxShadow = `0 0 0 0 ${ringColor}`;
|
||||||
|
props.onBlur?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
|
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
|
||||||
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
|
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
|
||||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import { Checkbox } from '@tamagui/checkbox';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
import { MobileSheet } from './Sheet';
|
import { MobileSheet } from './Sheet';
|
||||||
import { CTAButton } from './Primitives';
|
import { CTAButton } from './Primitives';
|
||||||
import { useAdminTheme } from '../theme';
|
import { useAdminTheme } from '../theme';
|
||||||
@@ -41,17 +43,6 @@ export function LegalConsentSheet({
|
|||||||
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||||
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const checkboxStyle = {
|
|
||||||
marginTop: 4,
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
accentColor: primary,
|
|
||||||
backgroundColor: surface,
|
|
||||||
border: `1px solid ${border}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
appearance: 'auto',
|
|
||||||
WebkitAppearance: 'auto',
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -111,36 +102,60 @@ export function LegalConsentSheet({
|
|||||||
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
|
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
|
||||||
</Text>
|
</Text>
|
||||||
{requireTerms ? (
|
{requireTerms ? (
|
||||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
<XStack space="$3" alignItems="flex-start">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
id="legal-terms"
|
||||||
|
size="$4"
|
||||||
checked={acceptedTerms}
|
checked={acceptedTerms}
|
||||||
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
onCheckedChange={(checked) => setAcceptedTerms(Boolean(checked))}
|
||||||
style={checkboxStyle}
|
borderWidth={1}
|
||||||
/>
|
borderColor={border}
|
||||||
<Text fontSize="$sm" color={text}>
|
backgroundColor={surface}
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator>
|
||||||
|
<Check size={14} color={primary} />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox>
|
||||||
|
<Text
|
||||||
|
fontSize="$sm"
|
||||||
|
color={text}
|
||||||
|
onPress={() => setAcceptedTerms((prev) => !prev)}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
{copy?.checkboxTerms ?? t(
|
{copy?.checkboxTerms ?? t(
|
||||||
'events.legalConsent.checkboxTerms',
|
'events.legalConsent.checkboxTerms',
|
||||||
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
|
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</label>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
{requireWaiver ? (
|
{requireWaiver ? (
|
||||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
<XStack space="$3" alignItems="flex-start">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
id="legal-waiver"
|
||||||
|
size="$4"
|
||||||
checked={acceptedWaiver}
|
checked={acceptedWaiver}
|
||||||
onChange={(event) => setAcceptedWaiver(event.target.checked)}
|
onCheckedChange={(checked) => setAcceptedWaiver(Boolean(checked))}
|
||||||
style={checkboxStyle}
|
borderWidth={1}
|
||||||
/>
|
borderColor={border}
|
||||||
<Text fontSize="$sm" color={text}>
|
backgroundColor={surface}
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator>
|
||||||
|
<Check size={14} color={primary} />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox>
|
||||||
|
<Text
|
||||||
|
fontSize="$sm"
|
||||||
|
color={text}
|
||||||
|
onPress={() => setAcceptedWaiver((prev) => !prev)}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
{copy?.checkboxWaiver ?? t(
|
{copy?.checkboxWaiver ?? t(
|
||||||
'events.legalConsent.checkboxWaiver',
|
'events.legalConsent.checkboxWaiver',
|
||||||
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
|
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</label>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileSheet>
|
</MobileSheet>
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
|
|||||||
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/checkbox', () => ({
|
||||||
|
Checkbox: Object.assign(
|
||||||
|
({ children, checked, onCheckedChange, id }: any) => (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => onCheckedChange?.(event.target.checked)}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
{ Indicator: ({ children }: { children: React.ReactNode }) => <span>{children}</span> },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@tamagui/sheet', () => {
|
vi.mock('@tamagui/sheet', () => {
|
||||||
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||||
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ i18n
|
|||||||
},
|
},
|
||||||
backend: {
|
backend: {
|
||||||
// Cache-bust to ensure fresh translations when files change.
|
// Cache-bust to ensure fresh translations when files change.
|
||||||
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250116',
|
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250212',
|
||||||
},
|
},
|
||||||
react: {
|
react: {
|
||||||
useSuspense: true,
|
useSuspense: true,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function AuthSimpleLayout({ children, title, description, name, l
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="font-display text-3xl leading-tight sm:text-4xl">
|
<h2 className="font-display text-3xl leading-tight sm:text-4xl">
|
||||||
{t('login.hero_heading', 'Willkommen zurück bei Fotospiel')}
|
{t('login.hero_heading', 'Willkommen zurück bei der Fotospiel App')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-white/80 sm:text-base">
|
<p className="text-sm text-white/80 sm:text-base">
|
||||||
{t('login.hero_subheading', 'Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.')}
|
{t('login.hero_subheading', 'Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.')}
|
||||||
|
|||||||
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 }) {
|
export default function ForgotPassword({ status }: { status?: string }) {
|
||||||
const { t } = useTranslation('auth');
|
const { t } = useTranslation('auth');
|
||||||
return (
|
return (
|
||||||
<AuthLayout title={t('auth.forgot.title', 'Forgot password')} description={t('auth.forgot.description', 'Enter your email to receive a password reset link')}>
|
<AuthLayout
|
||||||
<Head title={t('auth.forgot.title', 'Forgot password')} />
|
title={t('forgot.title', 'Forgot password')}
|
||||||
|
description={t('forgot.description', 'Enter your email to receive a password reset link')}
|
||||||
|
name={t('login.brand', 'Die Fotospiel App')}
|
||||||
|
logoSrc="/logo-transparent-md.png"
|
||||||
|
logoAlt={t('login.logo_alt', 'Logo Die Fotospiel App')}
|
||||||
|
>
|
||||||
|
<Head title={t('forgot.title', 'Forgot password')} />
|
||||||
|
|
||||||
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
|
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
|
||||||
|
|
||||||
@@ -25,8 +31,15 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
|||||||
{({ processing, errors }) => (
|
{({ processing, errors }) => (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email address</Label>
|
<Label htmlFor="email">{t('forgot.email_label', 'Email address')}</Label>
|
||||||
<Input id="email" type="email" name="email" autoComplete="off" autoFocus placeholder={t('auth.forgot.email_placeholder')} />
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
placeholder={t('forgot.email_placeholder', 'name@example.com')}
|
||||||
|
/>
|
||||||
|
|
||||||
<InputError message={errors.email} />
|
<InputError message={errors.email} />
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +47,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
|||||||
<div className="my-6 flex items-center justify-start">
|
<div className="my-6 flex items-center justify-start">
|
||||||
<Button className="w-full" disabled={processing}>
|
<Button className="w-full" disabled={processing}>
|
||||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||||
Email password reset link
|
{t('forgot.submit', 'Email password reset link')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -42,8 +55,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||||
<span>Or, return to</span>
|
<span>{t('forgot.back_prefix', 'Or, return to')}</span>
|
||||||
<TextLink href={login()}>{t('auth.forgot.back')}</TextLink>
|
<TextLink href={login()}>{t('forgot.back', 'Login')}</TextLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|||||||
@@ -10,19 +10,44 @@
|
|||||||
"register": "Registrieren",
|
"register": "Registrieren",
|
||||||
"home": "Startseite",
|
"home": "Startseite",
|
||||||
"packages": "Pakete",
|
"packages": "Pakete",
|
||||||
|
"how_it_works": "So geht's",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"occasions": {
|
"occasions": {
|
||||||
|
"label": "Anlässe",
|
||||||
"wedding": "Hochzeit",
|
"wedding": "Hochzeit",
|
||||||
"birthday": "Geburtstag",
|
"birthday": "Geburtstag",
|
||||||
|
"confirmation": "Konfirmation/Jugendweihe",
|
||||||
"corporate": "Firmenevent"
|
"corporate": "Firmenevent"
|
||||||
},
|
},
|
||||||
"contact": "Kontakt"
|
"contact": "Kontakt",
|
||||||
|
"cta": "Jetzt ausprobieren",
|
||||||
|
"utility": "Darstellung und Sprache öffnen",
|
||||||
|
"appearance": "Darstellung",
|
||||||
|
"appearance_light": "Hell",
|
||||||
|
"appearance_dark": "Dunkel",
|
||||||
|
"language": "Sprache"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Die Fotospiel App",
|
"title": "Die Fotospiel App",
|
||||||
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
|
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
|
||||||
"brand": "Die Fotospiel App",
|
"brand": "Die Fotospiel App",
|
||||||
"logo_alt": "Logo Die Fotospiel App",
|
"logo_alt": "Logo Die Fotospiel App",
|
||||||
|
"hero_tagline": "Event-Tech mit Herz",
|
||||||
|
"hero_heading": "Willkommen zurück bei der Fotospiel App",
|
||||||
|
"hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.",
|
||||||
|
"hero_footer": {
|
||||||
|
"headline": "Noch kein Account?",
|
||||||
|
"subline": "Entdecke unsere Packages und erlebe Fotospiel live.",
|
||||||
|
"cta": "Packages entdecken"
|
||||||
|
},
|
||||||
|
"highlights": {
|
||||||
|
"moments": "Momente in Echtzeit teilen",
|
||||||
|
"moments_description": "Uploads landen sofort in der Event-Galerie – ohne App-Download.",
|
||||||
|
"branding": "Branding & Slideshows, die begeistern",
|
||||||
|
"branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.",
|
||||||
|
"privacy": "Sicherer Zugang über Tokens",
|
||||||
|
"privacy_description": "Eventzugänge bleiben geschützt – DSGVO-konform mit Join Tokens."
|
||||||
|
},
|
||||||
"identifier": "E-Mail oder Username",
|
"identifier": "E-Mail oder Username",
|
||||||
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
|
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
|
||||||
"username_or_email": "Username oder E-Mail",
|
"username_or_email": "Username oder E-Mail",
|
||||||
@@ -39,6 +64,20 @@
|
|||||||
"no_account": "Noch keinen Zugang?",
|
"no_account": "Noch keinen Zugang?",
|
||||||
"sign_up": "Jetzt registrieren"
|
"sign_up": "Jetzt registrieren"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"ui": {
|
||||||
|
"language_select": "Sprache auswählen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forgot": {
|
||||||
|
"title": "Passwort zurücksetzen",
|
||||||
|
"description": "Gib deine E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
|
||||||
|
"email_label": "E-Mail-Adresse",
|
||||||
|
"email_placeholder": "name@beispiel.de",
|
||||||
|
"submit": "Reset-Link per E-Mail senden",
|
||||||
|
"back_prefix": "Oder zurück zu",
|
||||||
|
"back": "Login"
|
||||||
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Registrieren",
|
"title": "Registrieren",
|
||||||
"name": "Vollständiger Name",
|
"name": "Vollständiger Name",
|
||||||
|
|||||||
@@ -10,19 +10,44 @@
|
|||||||
"register": "Register",
|
"register": "Register",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
|
"how_it_works": "How it works",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"occasions": {
|
"occasions": {
|
||||||
|
"label": "Occasions",
|
||||||
"wedding": "Wedding",
|
"wedding": "Wedding",
|
||||||
"birthday": "Birthday",
|
"birthday": "Birthday",
|
||||||
|
"confirmation": "Confirmation/Youth dedication",
|
||||||
"corporate": "Corporate Event"
|
"corporate": "Corporate Event"
|
||||||
},
|
},
|
||||||
"contact": "Contact"
|
"contact": "Contact",
|
||||||
|
"cta": "Try now",
|
||||||
|
"utility": "Open appearance and language",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"appearance_light": "Light",
|
||||||
|
"appearance_dark": "Dark",
|
||||||
|
"language": "Language"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Die Fotospiel App",
|
"title": "Die Fotospiel App",
|
||||||
"description": "Sign in with your Fotospiel account to manage every event in one place.",
|
"description": "Sign in with your Fotospiel account to manage every event in one place.",
|
||||||
"brand": "Die Fotospiel App",
|
"brand": "Die Fotospiel App",
|
||||||
"logo_alt": "Fotospiel App logo",
|
"logo_alt": "Fotospiel App logo",
|
||||||
|
"hero_tagline": "Event tech with heart",
|
||||||
|
"hero_heading": "Welcome back to the Fotospiel App",
|
||||||
|
"hero_subheading": "Manage events, galleries, and guest lists in one beautifully crafted dashboard.",
|
||||||
|
"hero_footer": {
|
||||||
|
"headline": "No account yet?",
|
||||||
|
"subline": "Explore our packages and experience Fotospiel live.",
|
||||||
|
"cta": "Discover packages"
|
||||||
|
},
|
||||||
|
"highlights": {
|
||||||
|
"moments": "Share moments in real time",
|
||||||
|
"moments_description": "Uploads land instantly in the event gallery — no app download needed.",
|
||||||
|
"branding": "Branding & slideshows that impress",
|
||||||
|
"branding_description": "Configure slideshows, watermarks, and tasks for your event.",
|
||||||
|
"privacy": "Secure access via tokens",
|
||||||
|
"privacy_description": "Event access stays protected — GDPR-compliant with join tokens."
|
||||||
|
},
|
||||||
"identifier": "Email or Username",
|
"identifier": "Email or Username",
|
||||||
"identifier_placeholder": "you@example.com or username",
|
"identifier_placeholder": "you@example.com or username",
|
||||||
"username_or_email": "Username or Email",
|
"username_or_email": "Username or Email",
|
||||||
@@ -39,6 +64,20 @@
|
|||||||
"no_account": "Don't have access yet?",
|
"no_account": "Don't have access yet?",
|
||||||
"sign_up": "Create an account"
|
"sign_up": "Create an account"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"ui": {
|
||||||
|
"language_select": "Select language"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forgot": {
|
||||||
|
"title": "Reset your password",
|
||||||
|
"description": "Enter your email address to receive a password reset link.",
|
||||||
|
"email_label": "Email address",
|
||||||
|
"email_placeholder": "name@example.com",
|
||||||
|
"submit": "Email password reset link",
|
||||||
|
"back_prefix": "Or, return to",
|
||||||
|
"back": "Login"
|
||||||
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Register",
|
"title": "Register",
|
||||||
"name": "Full Name",
|
"name": "Full Name",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Auth;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class LoginTest extends TestCase
|
class LoginTest extends TestCase
|
||||||
@@ -221,6 +222,30 @@ class LoginTest extends TestCase
|
|||||||
$response->assertRedirect(route('verification.notice', absolute: false));
|
$response->assertRedirect(route('verification.notice', absolute: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_login_redirects_unverified_user_to_verification_link_when_intended(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'verify@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||||
|
absolute: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->withSession(['url.intended' => $verificationUrl])->post(route('login.store'), [
|
||||||
|
'login' => 'verify@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertAuthenticated();
|
||||||
|
$response->assertRedirect($verificationUrl);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_rate_limiting_on_failed_logins()
|
public function test_rate_limiting_on_failed_logins()
|
||||||
{
|
{
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
|
|||||||
Reference in New Issue
Block a user