diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index e80184e..24a6951 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller $user = Auth::user(); if ($user && $user->email_verified_at === null) { + $intended = $request->session()->get('url.intended'); + $intended = is_string($intended) ? trim($intended) : null; + + if ($this->isVerificationLink($intended)) { + $request->session()->forget('url.intended'); + + return Inertia::location($intended); + } + return Inertia::location(route('verification.notice')); } @@ -116,6 +125,29 @@ class AuthenticatedSessionController extends Controller ); } + private function isVerificationLink(?string $target): bool + { + if (! is_string($target) || trim($target) === '') { + return false; + } + + $path = trim($target); + + if (str_starts_with($path, '/verify-email/')) { + return true; + } + + $parsed = parse_url($path); + + if ($parsed === false) { + return false; + } + + $path = $parsed['path'] ?? ''; + + return $path !== '' && str_starts_with($path, '/verify-email/'); + } + private function decodeReturnTo(string $value, Request $request): ?string { $candidate = $this->decodeBase64Url($value) ?? $value; diff --git a/public/lang/de/auth.json b/public/lang/de/auth.json index 0ba8a46..d8521a6 100644 --- a/public/lang/de/auth.json +++ b/public/lang/de/auth.json @@ -10,19 +10,44 @@ "register": "Registrieren", "home": "Startseite", "packages": "Pakete", + "how_it_works": "So geht's", "blog": "Blog", "occasions": { + "label": "Anlässe", "wedding": "Hochzeit", "birthday": "Geburtstag", + "confirmation": "Konfirmation/Jugendweihe", "corporate": "Firmenevent" }, - "contact": "Kontakt" + "contact": "Kontakt", + "cta": "Jetzt ausprobieren", + "utility": "Darstellung und Sprache öffnen", + "appearance": "Darstellung", + "appearance_light": "Hell", + "appearance_dark": "Dunkel", + "language": "Sprache" }, "login": { "title": "Die Fotospiel App", "description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.", "brand": "Die Fotospiel App", "logo_alt": "Logo Die Fotospiel App", + "hero_tagline": "Event-Tech mit Herz", + "hero_heading": "Willkommen zurück bei der Fotospiel App", + "hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.", + "hero_footer": { + "headline": "Noch kein Account?", + "subline": "Entdecke unsere Packages und erlebe Fotospiel live.", + "cta": "Packages entdecken" + }, + "highlights": { + "moments": "Momente in Echtzeit teilen", + "moments_description": "Uploads landen sofort in der Event-Galerie – ohne App-Download.", + "branding": "Branding & Slideshows, die begeistern", + "branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.", + "privacy": "Sicherer Zugang über Tokens", + "privacy_description": "Eventzugänge bleiben geschützt – DSGVO-konform mit Join Tokens." + }, "identifier": "E-Mail oder Username", "identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia", "username_or_email": "Username oder E-Mail", @@ -39,6 +64,20 @@ "no_account": "Noch keinen Zugang?", "sign_up": "Jetzt registrieren" }, + "forgot": { + "title": "Passwort zurücksetzen", + "description": "Gib deine E-Mail-Adresse ein, um einen Reset-Link zu erhalten.", + "email_label": "E-Mail-Adresse", + "email_placeholder": "name@beispiel.de", + "submit": "Reset-Link per E-Mail senden", + "back_prefix": "Oder zurück zu", + "back": "Login" + }, + "common": { + "ui": { + "language_select": "Sprache auswählen" + } + }, "register": { "title": "Registrieren", "name": "Vollständiger Name", diff --git a/public/lang/en/auth.json b/public/lang/en/auth.json index 2e59574..02cefe4 100644 --- a/public/lang/en/auth.json +++ b/public/lang/en/auth.json @@ -10,19 +10,44 @@ "register": "Register", "home": "Home", "packages": "Packages", + "how_it_works": "How it works", "blog": "Blog", "occasions": { + "label": "Occasions", "wedding": "Wedding", "birthday": "Birthday", + "confirmation": "Confirmation/Youth dedication", "corporate": "Corporate Event" }, - "contact": "Contact" + "contact": "Contact", + "cta": "Try now", + "utility": "Open appearance and language", + "appearance": "Appearance", + "appearance_light": "Light", + "appearance_dark": "Dark", + "language": "Language" }, "login": { "title": "Die Fotospiel App", "description": "Sign in with your Fotospiel account to manage every event in one place.", "brand": "Die Fotospiel App", "logo_alt": "Fotospiel App logo", + "hero_tagline": "Event tech with heart", + "hero_heading": "Welcome back to the Fotospiel App", + "hero_subheading": "Manage events, galleries, and guest lists in one beautifully crafted dashboard.", + "hero_footer": { + "headline": "No account yet?", + "subline": "Explore our packages and experience Fotospiel live.", + "cta": "Discover packages" + }, + "highlights": { + "moments": "Share moments in real time", + "moments_description": "Uploads land instantly in the event gallery — no app download needed.", + "branding": "Branding & slideshows that impress", + "branding_description": "Configure slideshows, watermarks, and tasks for your event.", + "privacy": "Secure access via tokens", + "privacy_description": "Event access stays protected — GDPR-compliant with join tokens." + }, "identifier": "Email or Username", "identifier_placeholder": "you@example.com or username", "username_or_email": "Username or Email", @@ -39,6 +64,20 @@ "no_account": "Don't have access yet?", "sign_up": "Create an account" }, + "forgot": { + "title": "Reset your password", + "description": "Enter your email address to receive a password reset link.", + "email_label": "Email address", + "email_placeholder": "name@example.com", + "submit": "Email password reset link", + "back_prefix": "Or, return to", + "back": "Login" + }, + "common": { + "ui": { + "language_select": "Select language" + } + }, "register": { "title": "Register", "name": "Full Name", diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 1c1a400..010b7ec 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -72,6 +72,31 @@ "action": "Installieren", "iosHint": "iOS: Teilen → Zum Home-Bildschirm." }, + "consent": { + "banner": { + "title": "Cookie-Einstellungen", + "body": "Wir verwenden notwendige Cookies für den Betrieb. Optionale Analyse-Cookies helfen uns, die App zu verbessern.", + "reject": "Ablehnen", + "customize": "Anpassen", + "accept": "Alle akzeptieren" + }, + "modal": { + "title": "Cookie-Auswahl", + "description": "Wähle, welche Cookies du erlaubst. Du kannst das jederzeit ändern.", + "functional": "Notwendige Cookies", + "functional_desc": "Erforderlich für Kernfunktionen und Sicherheit.", + "required": "Erforderlich", + "analytics": "Analyse-Cookies", + "analytics_desc": "Helfen uns, die Nutzung zu verstehen und die Performance zu verbessern.", + "reject_all": "Alle ablehnen", + "accept_all": "Alle akzeptieren", + "cancel": "Abbrechen", + "save": "Auswahl speichern" + }, + "accessibility": { + "banner_label": "Cookie-Hinweis" + } + }, "errors": { "generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.", diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index b2ed61f..2cd4b4f 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -72,6 +72,31 @@ "action": "Install", "iosHint": "On iOS: Share → Add to Home Screen." }, + "consent": { + "banner": { + "title": "Cookie preferences", + "body": "We use essential cookies to run the app. Optional analytics help us improve your experience.", + "reject": "Reject", + "customize": "Customize", + "accept": "Accept all" + }, + "modal": { + "title": "Cookie settings", + "description": "Choose which cookies you allow. You can change this anytime.", + "functional": "Essential cookies", + "functional_desc": "Required for core features and security.", + "required": "Required", + "analytics": "Analytics cookies", + "analytics_desc": "Help us understand usage to improve performance.", + "reject_all": "Reject all", + "accept_all": "Accept all", + "cancel": "Cancel", + "save": "Save selection" + }, + "accessibility": { + "banner_label": "Cookie consent" + } + }, "errors": { "generic": "Something went wrong. Please try again.", "eventLimit": "Your current package has no remaining event bundle.", diff --git a/resources/js/admin/mobile/AuthCallbackPage.tsx b/resources/js/admin/mobile/AuthCallbackPage.tsx index 72ceb33..29de24e 100644 --- a/resources/js/admin/mobile/AuthCallbackPage.tsx +++ b/resources/js/admin/mobile/AuthCallbackPage.tsx @@ -1,15 +1,21 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { Card } from '@tamagui/card'; +import { YStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Spinner } from 'tamagui'; import { useAuth } from '../auth/context'; import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants'; import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo'; +import { useAdminTheme } from './theme'; export default function AuthCallbackPage(): React.ReactElement { const { status } = useAuth(); const navigate = useNavigate(); const { t } = useTranslation('auth'); const [redirected, setRedirected] = React.useState(false); + const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme(); const safeAreaStyle: React.CSSProperties = { paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)', paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)', @@ -40,12 +46,34 @@ export default function AuthCallbackPage(): React.ReactElement { }, [destination, navigate, redirected, status]); return ( -
- {t('processing.title', 'Signing you in …')} -

{t('processing.copy', 'One moment please while we prepare your dashboard.')}

-
+ + + + + {t('processing.title', 'Signing you in …')} + + + {t('processing.copy', 'One moment please while we prepare your dashboard.')} + + + + ); } diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index c4485cc..7a3decb 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -8,6 +8,7 @@ import { Pressable } from '@tamagui/react-native-web-lite'; import { Slider } from 'tamagui'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; +import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect } from './components/FormControls'; import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; @@ -363,22 +364,17 @@ export default function MobileBrandingPage() { {t('events.watermark.title', 'Wasserzeichen')} - { - if (controlsLocked) return; - if (value === 'custom' || value === 'base' || value === 'off') { - setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] })); - } - }} - onPicker={() => undefined} - > - - + + {mode === 'custom' && !controlsLocked ? ( @@ -414,11 +410,9 @@ export default function MobileBrandingPage() { - { const file = event.target.files?.[0]; if (!file) return; @@ -855,11 +849,9 @@ export default function MobileBrandingPage() { )} - { const file = event.target.files?.[0]; @@ -1171,24 +1163,22 @@ function ColorField({ onChange: (next: string) => void; disabled?: boolean; }) { - const { textStrong, muted, border, surface } = useAdminTheme(); + const { textStrong, muted } = useAdminTheme(); return ( {label} - - onChange(event.target.value)} - disabled={disabled} - style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }} - /> - - {value} - - + + onChange(event.target.value)} + disabled={disabled} + /> + + {value} + + ); } @@ -1211,7 +1201,6 @@ function InputField({ placeholder, onChange, onPicker, - children, disabled, }: { label: string; @@ -1219,51 +1208,27 @@ function InputField({ placeholder?: string; onChange: (next: string) => void; onPicker?: () => void; - children?: React.ReactNode; disabled?: boolean; }) { - const { textStrong, border, surface, primary } = useAdminTheme(); + const { primary } = useAdminTheme(); return ( - - - {label} - - - {children ?? ( - onChange(event.target.value)} - disabled={disabled} - style={{ - flex: 1, - height: '100%', - border: 'none', - outline: 'none', - fontSize: 14, - background: 'transparent', - }} - onFocus={onPicker} - /> - )} + + + onChange(event.target.value)} + onFocus={onPicker} + disabled={disabled} + style={{ flex: 1 }} + /> {onPicker ? ( ) : null} - + ); } diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index bc94040..35801e1 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text'; import { Switch } from '@tamagui/switch'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton } from './components/Primitives'; -import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; +import { MobileDateTimeInput, MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { LegalConsentSheet } from './components/LegalConsentSheet'; import { createEvent, @@ -401,14 +401,9 @@ export default function MobileEventFormPage() { - setForm((prev) => ({ ...prev, date: value }))} - border={border} - surface={surface} - text={text} - primary={primary} - danger={danger} + onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))} style={{ flex: 1 }} /> @@ -624,57 +619,6 @@ function toDateTimeLocal(value?: string | null): string { return fallback.length >= 16 ? fallback.slice(0, 16) : ''; } -function NativeDateTimeInput({ - value, - onChange, - border, - surface, - text, - primary, - danger, - hasError, - style, -}: { - value: string; - onChange: (value: string) => void; - border: string; - surface: string; - text: string; - primary: string; - danger: string; - hasError?: boolean; - style?: React.CSSProperties; -}) { - const [focused, setFocused] = React.useState(false); - const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18); - const borderColor = hasError ? danger : focused ? primary : border; - - return ( - onChange(event.target.value)} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - style={{ - width: '100%', - height: 44, - padding: '0 12px', - borderRadius: 12, - borderWidth: 1, - borderStyle: 'solid', - borderColor, - backgroundColor: surface, - color: text, - fontSize: 14, - boxShadow: focused ? `0 0 0 3px ${ringColor}` : undefined, - outline: 'none', - ...style, - }} - /> - ); -} - function resolveLocation(event: TenantEvent): string { const settings = (event.settings ?? {}) as Record; const candidate = diff --git a/resources/js/admin/mobile/EventGuestNotificationsPage.tsx b/resources/js/admin/mobile/EventGuestNotificationsPage.tsx index 913592e..286dca0 100644 --- a/resources/js/admin/mobile/EventGuestNotificationsPage.tsx +++ b/resources/js/admin/mobile/EventGuestNotificationsPage.tsx @@ -203,7 +203,7 @@ export default function MobileEventGuestNotificationsPage() { ) : null} -
+ {t('guestMessages.composeTitle', 'Send a message')} @@ -299,7 +299,7 @@ export default function MobileEventGuestNotificationsPage() { ) : null} -
+
diff --git a/resources/js/admin/mobile/EventRecapPage.tsx b/resources/js/admin/mobile/EventRecapPage.tsx index d92917c..372b7ef 100644 --- a/resources/js/admin/mobile/EventRecapPage.tsx +++ b/resources/js/admin/mobile/EventRecapPage.tsx @@ -5,6 +5,7 @@ import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } from 'lucide-r import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; +import { Switch } from '@tamagui/switch'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { LegalConsentSheet } from './components/LegalConsentSheet'; @@ -315,12 +316,9 @@ function ToggleOption({ label, value, onToggle }: { label: string; value: boolea {label} - onToggle(e.target.checked)} - style={{ width: 20, height: 20 }} - /> + + + ); } diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index 3fc74cd..acae514 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -11,6 +11,7 @@ import { Pressable } from '@tamagui/react-native-web-lite'; import { Button } from '@tamagui/button'; import { AlertDialog } from '@tamagui/alert-dialog'; import { ScrollView } from '@tamagui/scroll-view'; +import { ToggleGroup } from '@tamagui/toggle-group'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; @@ -183,28 +184,34 @@ function SummaryLegendItem({ } function QuickNavChip({ + value, label, count, onPress, + isActive = false, }: { + value: TaskSectionKey; label: string; count: number; onPress: () => void; + isActive?: boolean; }) { - const { textStrong, border, surface, surfaceMuted } = useAdminTheme(); + const { textStrong, border, surface, surfaceMuted, primary } = useAdminTheme(); + const activeBorder = withAlpha(primary, 0.45); + const activeBackground = withAlpha(primary, 0.16); return ( - - + + {label} @@ -213,7 +220,7 @@ function QuickNavChip({ paddingVertical="$0.5" borderRadius={999} borderWidth={1} - borderColor={border} + borderColor={isActive ? activeBorder : border} backgroundColor={surfaceMuted} > @@ -221,7 +228,7 @@ function QuickNavChip({ - + ); } @@ -263,6 +270,7 @@ export default function MobileEventTasksPage() { const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) }); const [savingEmotion, setSavingEmotion] = React.useState(false); const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false); + const [quickNavSelection, setQuickNavSelection] = React.useState(''); const text = textStrong; const assignedRef = React.useRef(null); const libraryRef = React.useRef(null); @@ -623,16 +631,34 @@ export default function MobileEventTasksPage() { - - {sectionCounts.map((section) => ( - handleQuickNav(section.key)} - /> - ))} - + { + const key = next as TaskSectionKey | ''; + if (!key) { + return; + } + setQuickNavSelection(key); + handleQuickNav(key); + }} + > + + {sectionCounts.map((section) => ( + { + setQuickNavSelection(section.key); + handleQuickNav(section.key); + }} + isActive={quickNavSelection === section.key} + /> + ))} + + @@ -770,7 +796,7 @@ export default function MobileEventTasksPage() { ) : ( -
+ {t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })} @@ -822,7 +848,7 @@ export default function MobileEventTasksPage() { ))} -
+ {t('events.tasks.library', 'Weitere Aufgaben')} diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx index 033fe2c..08c83c8 100644 --- a/resources/js/admin/mobile/EventsPage.tsx +++ b/resources/js/admin/mobile/EventsPage.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react'; +import { Card } from '@tamagui/card'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; +import { ScrollView } from '@tamagui/scroll-view'; +import { ToggleGroup } from '@tamagui/toggle-group'; +import { Separator } from '@tamagui/separator'; import { useTranslation } from 'react-i18next'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; -import { MobileCard, PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives'; +import { PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives'; import { MobileInput } from './components/FormControls'; import { getEvents, TenantEvent } from '../api'; import { adminPath } from '../constants'; @@ -27,7 +31,7 @@ export default function MobileEventsPage() { const [statusFilter, setStatusFilter] = React.useState('all'); const searchRef = React.useRef(null); const back = useBackNavigation(); - const { text, muted, subtle, border, primary, danger, surface, accentSoft, accent } = useAdminTheme(); + const { text, muted, subtle, border, primary, danger, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme(); React.useEffect(() => { (async () => { try { @@ -54,22 +58,61 @@ export default function MobileEventsPage() { } > {error ? ( - + {error} - + ) : null} - setQuery(e.target.value)} - placeholder={t('events.list.search', 'Search events')} - compact - style={{ marginBottom: 12 }} - /> + + + + + {t('events.list.filters.title', 'Filters & Search')} + + + setQuery(e.target.value)} + placeholder={t('events.list.search', 'Search events')} + compact + /> + + {t('events.list.filters.hint', 'Filter your events by status or search by name.')} + + + {loading ? ( @@ -78,15 +121,27 @@ export default function MobileEventsPage() { ))} ) : events.length === 0 ? ( - - - {t('events.list.empty.title', 'Noch kein Event angelegt')} - - - {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')} - - navigate(adminPath('/events/new'))} /> - + + + + {t('events.list.empty.title', 'Noch kein Event angelegt')} + + + {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')} + + navigate(adminPath('/events/new'))} /> + + ) : ( void; }) { const { t } = useTranslation('management'); - const { text, muted, subtle, border, primary, surface, accentSoft, accent } = useAdminTheme(); + const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme(); const activeBg = accentSoft; const activeBorder = accent; @@ -150,47 +205,93 @@ function EventsList({ return ( - - {filters.map((filter) => { - const active = filter.key === statusFilter; - return ( - onStatusChange(filter.key)} style={{ flexGrow: 1 }}> - - - {filter.label} - - {filter.count} + + + + + {t('events.list.filters.status', 'Status')} + + + + value && onStatusChange(value as EventStatusKey)} + > + + {filters.map((filter) => { + const active = filter.key === statusFilter; + return ( + + + + {filter.label} + + {filter.count} + + + ); + })} - - ); - })} - + + + + {filteredEvents.length === 0 ? ( - - - {t('events.list.empty.filtered', 'No events match this filter.')} - - - {t('events.list.empty.filteredHint', 'Try a different status or clear your search.')} - - onStatusChange('all')} - /> - + + + + {t('events.list.empty.filtered', 'No events match this filter.')} + + + {t('events.list.empty.filteredHint', 'Try a different status or clear your search.')} + + onStatusChange('all')} + /> + + ) : ( filteredEvents.map((event) => { const statusKey = resolveEventStatusKey(event); @@ -210,6 +311,8 @@ function EventsList({ subtle={subtle} border={border} primary={primary} + surface={surface} + shadow={shadow} statusLabel={statusLabel} statusTone={statusTone} onOpen={onOpen} @@ -229,6 +332,8 @@ function EventRow({ subtle, border, primary, + surface, + shadow, statusLabel, statusTone, onOpen, @@ -240,6 +345,8 @@ function EventRow({ subtle: string; border: string; primary: string; + surface: string; + shadow: string; statusLabel: string; statusTone: 'success' | 'warning' | 'muted'; onOpen: (slug: string) => void; @@ -248,62 +355,77 @@ function EventRow({ const { t } = useTranslation('management'); const stats = buildEventListStats(event); return ( - - - - - {renderName(event.name)} - - - - - {formatDate(event.event_date)} + + + + + + {renderName(event.name)} - - - - - {resolveLocation(event)} + + + + {formatDate(event.event_date)} + + + + + + {resolveLocation(event)} + + + {statusLabel} + + onEdit(event.slug)}> + + ˅ - - {statusLabel} - - - - - - - onEdit(event.slug)}> - - ˅ - - - - - onOpen(event.slug)} style={{ marginTop: 8 }}> - - - - {t('events.list.actions.open', 'Open event')} - + - - + + + + + + + + + + onOpen(event.slug)}> + + + + {t('events.list.actions.open', 'Open event')} + + + + + ); } diff --git a/resources/js/admin/mobile/LoginStartPage.tsx b/resources/js/admin/mobile/LoginStartPage.tsx index de4d251..c47c570 100644 --- a/resources/js/admin/mobile/LoginStartPage.tsx +++ b/resources/js/admin/mobile/LoginStartPage.tsx @@ -2,12 +2,18 @@ import React from 'react'; import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { Card } from '@tamagui/card'; +import { YStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Spinner } from 'tamagui'; import { ADMIN_LOGIN_PATH } from '../constants'; +import { useAdminTheme } from './theme'; export default function LoginStartPage(): React.ReactElement { const location = useLocation(); const navigate = useNavigate(); const { t } = useTranslation('auth'); + const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme(); const safeAreaStyle: React.CSSProperties = { paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)', paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)', @@ -25,11 +31,34 @@ export default function LoginStartPage(): React.ReactElement { }, [location.search, navigate]); return ( -
-

{t('redirecting', 'Redirecting to login …')}

-
+ + + + + {t('redirecting', 'Redirecting to login …')} + + + {t('redirectingHint', 'One moment, preparing the login screen.')} + + + + ); } diff --git a/resources/js/admin/mobile/LogoutPage.tsx b/resources/js/admin/mobile/LogoutPage.tsx index b3d7d9f..f988715 100644 --- a/resources/js/admin/mobile/LogoutPage.tsx +++ b/resources/js/admin/mobile/LogoutPage.tsx @@ -1,9 +1,15 @@ import React from 'react'; +import { Card } from '@tamagui/card'; +import { YStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Spinner } from 'tamagui'; import { useAuth } from '../auth/context'; import { ADMIN_PUBLIC_LANDING_PATH } from '../constants'; +import { useAdminTheme } from './theme'; export default function LogoutPage() { const { logout } = useAuth(); + const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme(); const safeAreaStyle: React.CSSProperties = { paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)', paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)', @@ -14,13 +20,34 @@ export default function LogoutPage() { }, [logout]); return ( -
-
- Abmeldung wird vorbereitet ... -
-
+ + + + + Abmeldung wird vorbereitet ... + + + Bitte einen Moment Geduld. + + + + ); } diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index 07b3429..4b6a3d1 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -77,7 +77,7 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati }; return ( -
+ {children} -
+ ); } diff --git a/resources/js/admin/mobile/ProfilePage.tsx b/resources/js/admin/mobile/ProfilePage.tsx index 23d58b9..43b8203 100644 --- a/resources/js/admin/mobile/ProfilePage.tsx +++ b/resources/js/admin/mobile/ProfilePage.tsx @@ -1,14 +1,15 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { LogOut, User, Settings, Shield, Globe, Moon, Download } from 'lucide-react'; +import { User, Settings, Globe, Moon, Download, LogOut } from 'lucide-react'; +import { Avatar } from '@tamagui/avatar'; +import { Card } from '@tamagui/card'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; -import { Pressable } from '@tamagui/react-native-web-lite'; import { YGroup } from '@tamagui/group'; import { ListItem } from '@tamagui/list-item'; import { MobileShell } from './components/MobileShell'; -import { MobileCard, CTAButton } from './components/Primitives'; +import { CTAButton } from './components/Primitives'; import { MobileSelect } from './components/FormControls'; import { useAuth } from '../auth/context'; import { fetchTenantProfile } from '../api'; @@ -16,6 +17,7 @@ import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from ' import i18n from '../i18n'; import { useAppearance } from '@/hooks/use-appearance'; import { useBackNavigation } from './hooks/useBackNavigation'; +import { withAlpha } from './components/colors'; import { useAdminTheme } from './theme'; export default function MobileProfilePage() { @@ -23,7 +25,7 @@ export default function MobileProfilePage() { const navigate = useNavigate(); const { t } = useTranslation('management'); const { appearance, updateAppearance } = useAppearance(); - const { textStrong, muted, border, accentSoft, primary, subtle } = useAdminTheme(); + const { textStrong, muted, border, accentSoft, primary, subtle, surface, surfaceMuted, shadow, danger } = useAdminTheme(); const textColor = textStrong; const mutedText = muted; const borderColor = border; @@ -54,100 +56,155 @@ export default function MobileProfilePage() { title={t('mobileProfile.title', 'Profile')} onBack={back} > - - - - - - {name} - - - {email} - - {role ? ( - - {role} - - ) : null} - + + + + + + + + + + {name} + + + {email} + + {role ? ( + + {role} + + ) : null} + + + - - - {t('mobileProfile.settings', 'Settings')} - - + + + + + {t('mobileProfile.settings', 'Settings')} + + - navigate(ADMIN_PROFILE_ACCOUNT_PATH)}> - - {t('mobileProfile.account', 'Account bearbeiten')} - - } - iconAfter={} - /> - + + {t('mobileProfile.account', 'Account bearbeiten')} + + } + iconAfter={} + onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)} + /> - navigate(adminPath('/mobile/billing#packages'))}> - - {t('billing.sections.packages.title', 'Packages & Billing')} - - } - iconAfter={} - /> - + + {t('billing.sections.packages.title', 'Packages & Billing')} + + } + iconAfter={} + onPress={() => navigate(adminPath('/mobile/billing#packages'))} + /> - navigate(adminPath('/mobile/billing#invoices'))}> - - {t('billing.sections.invoices.title', 'Invoices & Payments')} - - } - iconAfter={} - /> - + + {t('billing.sections.invoices.title', 'Invoices & Payments')} + + } + iconAfter={} + onPress={() => navigate(adminPath('/mobile/billing#invoices'))} + /> - navigate(ADMIN_DATA_EXPORTS_PATH)}> - - {t('dataExports.title', 'Data exports')} - - } - iconAfter={} - /> - + + {t('dataExports.title', 'Data exports')} + + } + iconAfter={} + onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)} + /> + + + + + + + + + {t('mobileProfile.preferences', 'Preferences')} + + + - + - { - logout(); - navigate(adminPath('/logout')); - }} - /> + + + + + + + + {t('mobileProfile.logoutTitle', 'Sign out')} + + + + {t('mobileProfile.logoutHint', 'Sign out from this device.')} + + { + logout(); + navigate(adminPath('/logout')); + }} + /> + + ); } diff --git a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx index 21c60a4..b442357 100644 --- a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx +++ b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx @@ -5,12 +5,12 @@ import { ArrowLeft, RefreshCcw, Plus, ChevronDown } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; -import { Input, TextArea } from 'tamagui'; import { Accordion } from '@tamagui/accordion'; import { HexColorPicker } from 'react-colorful'; import { Portal } from '@tamagui/portal'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; +import { MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { TenantEvent, EventQrInvite, @@ -753,7 +753,7 @@ function BackgroundStep({ {presets.map((preset) => { const isSelected = selectedPreset === preset.id; return ( - onSelectPreset(preset.id)} style={{ width: '48%' }}> + onSelectPreset(preset.id)} style={{ width: '48%' }}> - {gradientPresets.map((gradient, idx) => { + {gradientPresets.map((gradient) => { const isSelected = selectedGradient && selectedGradient.angle === gradient.angle && @@ -803,9 +803,10 @@ function BackgroundStep({ const style = { backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`, } as React.CSSProperties; + const key = `${gradient.labelKey}-${gradient.angle}`; return ( onSelectGradient(gradient)} style={{ width: '30%' }} aria-label={t(gradient.labelKey, gradient.label)} @@ -829,10 +830,11 @@ function BackgroundStep({ {t('events.qr.colors', 'Vollfarbe')} - {solidPresets.map((color) => { + {solidPresets.map((color, idx) => { const isSelected = selectedSolid === color; + const key = `${color}-${idx}`; return ( - onSelectSolid(color)} style={{ width: '20%' }}> + onSelectSolid(color)} style={{ width: '20%' }}> {t('events.qr.textFields', 'Texte')} - updateField('headline', val)} - size="$4" + onChange={(event) => updateField('headline', event.target.value)} /> - updateField('subtitle', val)} - size="$4" + onChange={(event) => updateField('subtitle', event.target.value)} /> -