first implementation of tamagui mobile pages

This commit is contained in:
Codex Agent
2025-12-10 15:49:08 +01:00
parent 5c93bfa405
commit 9930b272ca
39 changed files with 491904 additions and 2727 deletions

View File

@@ -114,16 +114,16 @@
}
:root {
--guest-primary: #f43f5e;
--guest-secondary: #fb7185;
--guest-background: #ffffff;
--guest-primary: #007aff;
--guest-secondary: #5ad2f4;
--guest-background: #f7f8fb;
--guest-surface: #ffffff;
--guest-radius: 12px;
--guest-radius: 14px;
--guest-button-style: filled;
--guest-link: #fb7185;
--guest-link: #007aff;
--guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-heading-font: 'Playfair Display', serif;
--guest-heading-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-serif-font: 'Lora', serif;
}

View File

@@ -14,6 +14,8 @@ import deSettings from './locales/de/settings.json';
import enSettings from './locales/en/settings.json';
import deAuth from './locales/de/auth.json';
import enAuth from './locales/en/auth.json';
import deMobile from './locales/de/mobile.json';
import enMobile from './locales/en/mobile.json';
const DEFAULT_NAMESPACE = 'common';
@@ -25,6 +27,7 @@ const resources = {
management: deManagement,
settings: deSettings,
auth: deAuth,
mobile: deMobile,
},
en: {
common: enCommon,
@@ -33,6 +36,7 @@ const resources = {
management: enManagement,
settings: enSettings,
auth: enAuth,
mobile: enMobile,
},
} as const;

View File

@@ -0,0 +1,14 @@
{
"nav": {
"dashboard": "Übersicht",
"events": "Events",
"tasks": "Aufgaben",
"alerts": "Alerts",
"profile": "Profil"
},
"actions": {
"back": "Zurück",
"close": "Schließen",
"refresh": "Aktualisieren"
}
}

View File

@@ -0,0 +1,14 @@
{
"nav": {
"dashboard": "Dashboard",
"events": "Events",
"tasks": "Tasks",
"alerts": "Alerts",
"profile": "Profile"
},
"actions": {
"back": "Back",
"close": "Close",
"refresh": "Refresh"
}
}

View File

@@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { TamaguiProvider } from '@tamagui/core';
import '@tamagui/core/reset.css';
import tamaguiConfig from '../../../tamagui.config';
import { AuthProvider } from './auth/context';
import { router } from './router';
import '../../css/app.css';
@@ -43,32 +46,36 @@ if ('serviceWorker' in navigator) {
createRoot(rootEl).render(
<React.StrictMode>
<ConsentProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<EventProvider>
<OnboardingProgressProvider>
<MatomoTracker config={(window as any).__MATOMO_ADMIN__} />
<Suspense
fallback={(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Oberfläche wird geladen
<TamaguiProvider config={tamaguiConfig} defaultTheme="light">
<ConsentProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<EventProvider>
<OnboardingProgressProvider>
<MatomoTracker config={(window as any).__MATOMO_ADMIN__} />
<Suspense
fallback={(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Oberfläche wird geladen
</div>
)}
>
<div className="font-[Montserrat] text-[13px] font-normal leading-[1.5] text-slate-700">
<RouterProvider router={router} />
</div>
)}
>
<RouterProvider router={router} />
</Suspense>
</OnboardingProgressProvider>
</EventProvider>
</AuthProvider>
<CookieBanner />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
{enableDevSwitcher ? (
<Suspense fallback={null}>
<DevTenantSwitcher />
</Suspense>
) : null}
</QueryClientProvider>
</ConsentProvider>
</Suspense>
</OnboardingProgressProvider>
</EventProvider>
</AuthProvider>
<CookieBanner />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
{enableDevSwitcher ? (
<Suspense fallback={null}>
<DevTenantSwitcher />
</Suspense>
) : null}
</QueryClientProvider>
</ConsentProvider>
</TamaguiProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,197 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Bell, RefreshCcw } 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 { MobileScaffold } from './components/Scaffold';
import { MobileCard, PillBadge } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { GuestNotificationSummary, listGuestNotifications } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
import { getEvents, TenantEvent } from '../api';
type AlertItem = {
id: string;
title: string;
body: string;
time: string;
tone: 'info' | 'warning';
};
async function loadNotifications(slug?: string): Promise<AlertItem[]> {
try {
const result = slug ? await listGuestNotifications(slug) : [];
return (result ?? []).map((item: GuestNotificationSummary) => ({
id: String(item.id),
title: item.title || 'Alert',
body: item.body ?? '',
time: item.created_at ?? '',
tone: item.type === 'support_tip' ? 'warning' : 'info',
}));
} catch (err) {
throw err;
}
}
export default function MobileAlertsPage() {
const navigate = useNavigate();
const { t } = useTranslation('management');
const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
const slug = search.get('event') ?? undefined;
const [alerts, setAlerts] = React.useState<AlertItem[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug ?? null);
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [showEventPicker, setShowEventPicker] = React.useState(false);
const reload = React.useCallback(async () => {
setLoading(true);
try {
const data = await loadNotifications(slug ?? undefined);
setAlerts(data);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Alerts konnten nicht geladen werden.'));
setError(message);
toast.error(message);
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void reload();
}, [reload]);
React.useEffect(() => {
(async () => {
try {
const list = await getEvents();
setEvents(list);
} catch {
// non-fatal
}
})();
}, []);
return (
<MobileScaffold
title={t('alerts.title', 'Alerts')}
onBack={() => navigate(-1)}
rightSlot={
<Pressable onPress={() => reload()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="alerts" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} />
))}
</YStack>
) : alerts.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Bell size={24} color="#9ca3af" />
<Text fontSize="$sm" color="#4b5563">
{t('alerts.empty', 'Keine Alerts vorhanden.')}
</Text>
</MobileCard>
) : (
<YStack space="$2">
{events.length ? (
<Pressable onPress={() => setShowEventPicker(true)}>
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
{t('alerts.filterByEvent', 'Filter by event')}
</Text>
</Pressable>
) : null}
{alerts.map((item) => (
<MobileCard key={item.id} space="$2">
<XStack alignItems="center" space="$2">
<XStack
width={36}
height={36}
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={item.tone === 'warning' ? '#fef3c7' : '#e0f2fe'}
>
<Bell size={18} color={item.tone === 'warning' ? '#92400e' : '#2563eb'} />
</XStack>
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{item.title}
</Text>
<Text fontSize="$xs" color="#4b5563">
{item.body}
</Text>
</YStack>
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
</XStack>
</MobileCard>
))}
</YStack>
)}
<MobileSheet
open={showEventPicker}
onClose={() => setShowEventPicker(false)}
title={t('alerts.filterByEvent', 'Filter by event')}
footer={null}
>
<YStack space="$2">
{events.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
) : (
events.map((ev) => (
<Pressable
key={ev.slug}
onPress={() => {
setShowEventPicker(false);
if (ev.slug) {
navigate(`/admin/mobile/alerts?event=${ev.slug}`);
}
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{ev.name}
</Text>
<Text fontSize="$xs" color="#6b7280">
{ev.slug}
</Text>
</YStack>
<PillBadge tone="muted">{ev.status ?? '—'}</PillBadge>
</XStack>
</Pressable>
))
)}
</YStack>
</MobileSheet>
</MobileScaffold>
);
}

View File

@@ -0,0 +1,378 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Save } 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 { MobileScaffold } from './components/Scaffold';
import { MobileCard, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
type BrandingForm = {
primary: string;
accent: string;
headingFont: string;
bodyFont: string;
};
export default function MobileBrandingPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [form, setForm] = React.useState<BrandingForm>({
primary: '#007AFF',
accent: '#5AD2F4',
headingFont: '',
bodyFont: '',
});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
const [showFontsSheet, setShowFontsSheet] = React.useState(false);
const [fonts, setFonts] = React.useState<TenantFont[]>([]);
const [fontsLoading, setFontsLoading] = React.useState(false);
React.useEffect(() => {
if (!slug) return;
(async () => {
setLoading(true);
try {
const data = await getEvent(slug);
setEvent(data);
setForm(extractBranding(data));
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Branding konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [slug, t]);
React.useEffect(() => {
(async () => {
setFontsLoading(true);
try {
const data = await getTenantFonts();
setFonts(data ?? []);
} catch {
// non-fatal
} finally {
setFontsLoading(false);
}
})();
}, []);
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
async function handleSave() {
if (!event?.slug) return;
setSaving(true);
setError(null);
try {
const settings = { ...(event.settings ?? {}) };
settings.branding = {
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
primary_color: form.primary,
accent_color: form.accent,
heading_font: form.headingFont,
body_font: form.bodyFont,
};
const updated = await updateEvent(event.slug, { settings });
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Branding konnte nicht gespeichert werden.')));
}
} finally {
setSaving(false);
}
}
function handleReset() {
if (event) {
setForm(extractBranding(event));
}
}
return (
<MobileScaffold
title={t('events.branding.title', 'Branding & Customization')}
onBack={() => navigate(-1)}
rightSlot={
<Pressable disabled={saving} onPress={() => handleSave()}>
<Save size={18} color="#007AFF" />
</Pressable>
}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.branding.previewTitle', 'Guest App Preview')}
</Text>
<YStack borderRadius={16} borderWidth={1} borderColor="#e5e7eb" backgroundColor="#f8fafc" padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor="white" borderWidth={1} borderColor="#e5e7eb" overflow="hidden">
<YStack backgroundColor={form.primary} height={64} />
<YStack padding="$3" space="$1.5">
<Text fontSize="$md" fontWeight="800" color="#111827">
{previewTitle}
</Text>
<Text fontSize="$sm" color="#4b5563">
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
</Text>
<XStack space="$2" marginTop="$1">
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
</XStack>
</YStack>
</YStack>
</YStack>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.branding.colors', 'Colors')}
</Text>
<ColorField
label={t('events.branding.primary', 'Primary Color')}
value={form.primary}
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
/>
<ColorField
label={t('events.branding.accent', 'Accent Color')}
value={form.accent}
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
/>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.branding.fonts', 'Fonts')}
</Text>
<InputField
label={t('events.branding.headingFont', 'Headline Font')}
value={form.headingFont}
placeholder="SF Pro Display"
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
/>
<InputField
label={t('events.branding.bodyFont', 'Body Font')}
value={form.bodyFont}
placeholder="SF Pro Text"
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
/>
<Pressable onPress={() => setShowFontsSheet(true)}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
{t('events.branding.chooseFont', 'Choose from installed fonts')}
</Text>
<Save size={16} color="#007AFF" />
</XStack>
</Pressable>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.branding.logo', 'Logo')}
</Text>
<YStack
borderRadius={14}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#f8fafc"
padding="$3"
alignItems="center"
justifyContent="center"
space="$2"
>
<ImageIcon size={28} color="#94a3b8" />
<Text fontSize="$sm" color="#4b5563">
{t('events.branding.logoHint', 'Logo Upload folgt nutze Farben/Schriften.')}
</Text>
</YStack>
</MobileCard>
<YStack space="$2">
<CTAButton label={saving ? t('events.branding.saving', 'Saving...') : t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
<Pressable disabled={loading || saving} onPress={handleReset}>
<XStack
height={52}
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor="white"
borderWidth={1}
borderColor="#e5e7eb"
space="$2"
>
<RefreshCcw size={16} color="#111827" />
<Text fontSize="$sm" color="#111827" fontWeight="700">
{t('events.branding.reset', 'Reset to Defaults')}
</Text>
</XStack>
</Pressable>
</YStack>
<MobileSheet
open={showFontsSheet}
onClose={() => setShowFontsSheet(false)}
title={t('events.branding.fontPicker', 'Select font')}
footer={null}
bottomOffsetPx={120}
>
<YStack space="$2">
{fontsLoading ? (
Array.from({ length: 4 }).map((_, idx) => <MobileCard key={`font-sk-${idx}`} height={48} opacity={0.6} />)
) : fonts.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
{t('events.branding.noFonts', 'Keine Schriftarten gefunden.')}
</Text>
) : (
fonts.map((font) => (
<Pressable
key={font.family}
onPress={() => {
setForm((prev) => ({ ...prev, headingFont: font.family, bodyFont: font.family }));
setShowFontsSheet(false);
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack>
<Text fontSize="$sm" color="#111827">
{font.family}
</Text>
{font.variants?.length ? (
<Text fontSize="$xs" color="#6b7280">
{font.variants.map((v) => v.style ?? v.weight ?? '').filter(Boolean).join(', ')}
</Text>
) : null}
</YStack>
{form.headingFont === font.family || form.bodyFont === font.family ? (
<Text fontSize="$xs" color="#007AFF">
{t('common.active', 'Active')}
</Text>
) : null}
</XStack>
</Pressable>
))
)}
</YStack>
</MobileSheet>
</MobileScaffold>
);
}
function extractBranding(event: TenantEvent): BrandingForm {
const source = (event.settings as Record<string, unknown>) ?? {};
const branding = (source.branding as Record<string, unknown>) ?? source;
const readColor = (key: string, fallback: string) => {
const value = branding[key];
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
};
const readText = (key: string) => {
const value = branding[key];
return typeof value === 'string' ? value : '';
};
return {
primary: readColor('primary_color', '#007AFF'),
accent: readColor('accent_color', '#5AD2F4'),
headingFont: readText('heading_font'),
bodyFont: readText('body_font'),
};
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
}
return '';
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text>
<XStack alignItems="center" space="$2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }}
/>
<Text fontSize="$sm" color="#4b5563">
{value}
</Text>
</XStack>
</YStack>
);
}
function ColorSwatch({ color, label }: { color: string; label: string }) {
return (
<YStack alignItems="center" space="$1">
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor="#e5e7eb" backgroundColor={color} />
<Text fontSize="$xs" color="#4b5563">
{label}
</Text>
</YStack>
);
}
function InputField({
label,
value,
placeholder,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
onChange: (next: string) => void;
}) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text>
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
style={{
width: '100%',
height: 48,
borderRadius: 12,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 14,
}}
/>
</YStack>
);
}

View File

@@ -0,0 +1,204 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, MapPin, Settings, Plus, Bell, ListTodo, Image as ImageIcon } 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 { MobileScaffold } from './components/Scaffold';
import { MobileCard, PillBadge, CTAButton, KpiTile, ActionTile } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
import { getEventStats, EventStats } from '../api';
export default function MobileDashboardPage() {
const navigate = useNavigate();
const { t } = useTranslation('management');
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [stats, setStats] = React.useState<Record<string, EventStats>>({});
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav();
React.useEffect(() => {
(async () => {
try {
setEvents(await getEvents());
const fetched: Record<string, EventStats> = {};
const list = await getEvents();
await Promise.all(
(list || []).map(async (ev) => {
if (!ev.slug) return;
try {
fetched[ev.slug] = await getEventStats(ev.slug);
} catch {
// ignore per-event stat failures
}
})
);
setStats(fetched);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Events konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [t]);
return (
<MobileScaffold
title={t('events.list.dashboardTitle', 'All Events Dashboard')}
onBack={() => navigate(-1)}
rightSlot={
<Pressable onPress={() => navigate(adminPath('/settings'))}>
<Settings size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={90} opacity={0.6} />
))}
</YStack>
) : events.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Text fontSize="$md" fontWeight="700" color="#111827">
{t('events.list.empty.title', 'Noch kein Event angelegt')}
</Text>
<Text fontSize="$sm" color="#4b5563" textAlign="center">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
</MobileCard>
) : (
<YStack space="$3">
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('dashboard.kpis', 'Key Performance Indicators')}
</Text>
<XStack space="$2" flexWrap="wrap">
<KpiTile icon={ListTodo} label={t('events.detail.kpi.tasks', 'Tasks Completed')} value="—" />
<KpiTile icon={Bell} label={t('events.detail.kpi.guests', 'Guests Registered')} value="—" />
<KpiTile icon={ImageIcon} label={t('events.detail.kpi.photos', 'Images Uploaded')} value="—" />
</XStack>
</MobileCard>
{events.map((event) => (
<MobileCard key={event.id} borderColor="#e2e8f0" space="$2">
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
<YStack space="$1.5">
<Text fontSize="$lg" fontWeight="800" color="#111827">
{renderName(event.name)}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={14} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
{formatDate(event.event_date)}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<MapPin size={14} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
{resolveLocation(event)}
</Text>
</XStack>
<PillBadge tone={resolveTone(event)}>{resolveStatus(event, t)}</PillBadge>
</YStack>
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${event.slug}`))}>
<Text fontSize="$xl" color="#9ca3af">
˅
</Text>
</Pressable>
</XStack>
<XStack marginTop="$2" space="$2" flexWrap="wrap">
<ActionTile
icon={ListTodo}
label={t('events.quick.tasks', 'Tasks')}
color="#60a5fa"
onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/tasks`))}
width="32%"
/>
<ActionTile
icon={ImageIcon}
label={t('events.quick.images', 'Images')}
color="#a855f7"
onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/photos`))}
width="32%"
/>
<ActionTile
icon={Bell}
label={t('alerts.title', 'Alerts')}
color="#fbbf24"
onPress={() => navigate(adminPath(`/mobile/alerts?event=${event.slug}`))}
width="32%"
/>
</XStack>
</MobileCard>
))}
</YStack>
)}
</MobileScaffold>
);
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
}
return 'Unbenanntes Event';
}
function formatDate(iso: string | null): string {
if (!iso) return 'Date tbd';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'Date tbd';
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function resolveLocation(event: TenantEvent): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) {
return candidate;
}
return 'Location';
}
function resolveStatus(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
if (event.status === 'published') return t('events.status.published', 'Upcoming');
if (event.status === 'draft') return t('events.status.draft', 'Draft');
return t('events.status.archived', 'Past');
}
function resolveTone(event: TenantEvent): 'success' | 'warning' | 'muted' {
if (event.status === 'published') return 'success';
if (event.status === 'draft') return 'warning';
return 'muted';
}

View File

@@ -0,0 +1,258 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, 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 { MobileScaffold } from './components/Scaffold';
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit } from '../api';
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext';
export default function MobileEventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setStats] = React.useState<EventStats | null>(null);
const [toolkit, setToolkit] = React.useState<EventToolkit | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
const { events, activeEvent, selectEvent } = useEventContext();
const [showEventPicker, setShowEventPicker] = React.useState(false);
React.useEffect(() => {
if (!slug) return;
(async () => {
setLoading(true);
try {
const [eventData, statsData, toolkitData] = await Promise.all([getEvent(slug), getEventStats(slug), getEventToolkit(slug)]);
setEvent(eventData);
setStats(statsData);
setToolkit(toolkitData);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [slug, t]);
const kpis = [
{
label: t('events.detail.kpi.tasks', 'Tasks Completed'),
value: toolkit?.tasks?.summary ? `${toolkit.tasks.summary.completed}/${toolkit.tasks.summary.total}` : '—',
icon: Sparkles,
},
{
label: t('events.detail.kpi.guests', 'Guests Registered'),
value: toolkit?.invites?.summary.total ?? event?.active_invites_count ?? '—',
icon: Users,
},
{
label: t('events.detail.kpi.photos', 'Images Uploaded'),
value: stats?.uploads_total ?? event?.photo_count ?? '—',
icon: Camera,
},
];
return (
<MobileScaffold
title={t('events.detail.title', 'Event Details Dashboard')}
onBack={() => navigate(adminPath('/mobile/events'))}
rightSlot={
<XStack space="$3" alignItems="center">
<Pressable onPress={() => setShowEventPicker(true)}>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
{activeEvent?.name ?? t('events.detail.pickEvent', 'Event wählen')}
</Text>
<ChevronDown size={14} color="#007AFF" />
</XStack>
</Pressable>
<Pressable onPress={() => navigate(adminPath('/settings'))}>
<Settings size={18} color="#0f172a" />
</Pressable>
<Pressable onPress={() => navigate(0)}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
</XStack>
}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<Text fontSize="$lg" fontWeight="800" color="#111827">
{event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
{formatDate(event?.event_date)}
</Text>
<MapPin size={16} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
{resolveLocation(event)}
</Text>
</XStack>
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
{event?.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
</PillBadge>
</MobileCard>
<YStack space="$2">
{loading ? (
<XStack space="$2" flexWrap="wrap">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
))}
</XStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value} />
))}
</XStack>
)}
</YStack>
<MobileSheet
open={showEventPicker}
onClose={() => setShowEventPicker(false)}
title={t('events.detail.pickEvent', 'Event wählen')}
footer={null}
bottomOffsetPx={120}
>
<YStack space="$2">
{events.length === 0 ? (
<Text fontSize={12.5} color="#4b5563">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
) : (
events.map((ev) => (
<Pressable
key={ev.slug}
onPress={() => {
selectEvent(ev.slug ?? null);
setShowEventPicker(false);
navigate(adminPath(`/mobile/events/${ev.slug}`));
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$1">
<Text fontSize={13} fontWeight="700" color="#111827">
{renderName(ev.name)}
</Text>
<XStack alignItems="center" space="$1.5">
<CalendarDays size={14} color="#6b7280" />
<Text fontSize={12} color="#4b5563">
{formatDate(ev.event_date)}
</Text>
</XStack>
</YStack>
<PillBadge tone={ev.slug === activeEvent?.slug ? 'success' : 'muted'}>
{ev.slug === activeEvent?.slug ? t('events.detail.active', 'Aktiv') : t('events.actions.open', 'Öffnen')}
</PillBadge>
</XStack>
</Pressable>
))
)}
</YStack>
</MobileSheet>
<YStack space="$2">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.detail.managementTitle', 'Event Management')}
</Text>
<XStack flexWrap="wrap" space="$2">
<ActionTile
icon={Sparkles}
label={t('events.quick.tasks', 'Tasks & Checklists')}
color="#60a5fa"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
/>
<ActionTile
icon={QrCode}
label={t('events.quick.qr', 'QR Code Layouts')}
color="#fbbf24"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/qr`))}
/>
<ActionTile
icon={Image}
label={t('events.quick.images', 'Image Management')}
color="#a855f7"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
/>
<ActionTile
icon={Users}
label={t('events.quick.guests', 'Guest Management')}
color="#4ade80"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
/>
<ActionTile
icon={Layout}
label={t('events.quick.branding', 'Branding & Theme')}
color="#fb7185"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`))}
/>
<ActionTile
icon={Shield}
label={t('events.quick.moderation', 'Photo Moderation')}
color="#38bdf8"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
/>
</XStack>
</YStack>
</MobileScaffold>
);
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
}
return 'Unbenanntes Event';
}
function formatDate(iso?: string | null): string {
if (!iso) return 'Date tbd';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return 'Date tbd';
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function resolveLocation(event: TenantEvent | null): string {
if (!event) return 'Location';
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) {
return candidate;
}
return 'Location';
}

View File

@@ -0,0 +1,266 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, ChevronDown, MapPin } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileScaffold } from './components/Scaffold';
import { MobileCard, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { createEvent, getEvent, updateEvent, TenantEvent } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
type FormState = {
name: string;
date: string;
eventType: string;
description: string;
location: string;
enableBranding: boolean;
};
const EVENT_TYPES = ['Wedding', 'Corporate', 'Party', 'Other'];
export default function MobileEventFormPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const isEdit = Boolean(slug);
const navigate = useNavigate();
const { t } = useTranslation('management');
const [form, setForm] = React.useState<FormState>({
name: '',
date: '',
eventType: EVENT_TYPES[0],
description: '',
location: '',
enableBranding: false,
});
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
React.useEffect(() => {
if (!slug) return;
(async () => {
setLoading(true);
try {
const data = await getEvent(slug);
setForm({
name: renderName(data.name),
date: data.event_date ?? '',
eventType: data.event_type?.name ?? EVENT_TYPES[0],
description: typeof data.description === 'string' ? data.description : '',
location: resolveLocation(data),
enableBranding: Boolean((data.settings as Record<string, unknown>)?.branding_allowed ?? true),
});
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [slug, t, isEdit]);
async function handleSubmit() {
setSaving(true);
setError(null);
try {
if (isEdit && slug) {
await updateEvent(slug, {
name: form.name,
event_date: form.date || undefined,
settings: { branding_allowed: form.enableBranding, location: form.location },
});
navigate(adminPath(`/mobile/events/${slug}`));
} else {
const payload = {
name: form.name || 'Event',
slug: `${Date.now()}`,
event_type_id: 1,
event_date: form.date || undefined,
status: 'draft' as const,
settings: { branding_allowed: form.enableBranding, location: form.location },
};
const { event } = await createEvent(payload as any);
navigate(adminPath(`/mobile/events/${event.slug}`));
}
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Event konnte nicht gespeichert werden.')));
}
} finally {
setSaving(false);
}
}
return (
<MobileScaffold
title={isEdit ? t('events.form.editTitle', 'Edit Event') : t('events.form.createTitle', 'Create New Event')}
onBack={() => navigate(-1)}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<Field label={t('events.form.name', 'Event Name')}>
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g., Smith Wedding"
style={inputStyle}
/>
</Field>
<Field label={t('events.form.date', 'Date & Time')}>
<XStack alignItems="center" space="$2">
<input
type="datetime-local"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
style={{ ...inputStyle, flex: 1 }}
/>
<CalendarDays size={16} color="#9ca3af" />
</XStack>
</Field>
<Field label={t('events.form.type', 'Event Type')}>
<XStack space="$1" flexWrap="wrap">
{EVENT_TYPES.map((type) => {
const active = form.eventType === type;
return (
<button
key={type}
type="button"
onClick={() => setForm((prev) => ({ ...prev, eventType: type }))}
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px solid ${active ? '#007AFF' : '#e5e7eb'}`,
background: active ? '#e8f1ff' : 'white',
color: active ? '#0f172a' : '#111827',
fontWeight: 700,
minWidth: 90,
textAlign: 'center',
}}
>
{type}
</button>
);
})}
</XStack>
</Field>
<Field label={t('events.form.description', 'Optional Details')}>
<textarea
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t('events.form.descriptionPlaceholder', 'Description')}
style={{ ...inputStyle, minHeight: 96 }}
/>
</Field>
<Field label={t('events.form.location', 'Location')}>
<XStack alignItems="center" space="$2">
<input
type="text"
value={form.location}
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))}
placeholder={t('events.form.locationPlaceholder', 'Location')}
style={{ ...inputStyle, flex: 1 }}
/>
<MapPin size={16} color="#9ca3af" />
</XStack>
</Field>
<Field label={t('events.form.enableBranding', 'Enable Branding & Moderation')}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={form.enableBranding}
onChange={(e) => setForm((prev) => ({ ...prev, enableBranding: e.target.checked }))}
/>
<Text fontSize="$sm" color="#111827">
{form.enableBranding ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
</Text>
</label>
</Field>
</MobileCard>
<YStack space="$2">
{!isEdit ? (
<button
type="button"
onClick={() => navigate(-1)}
style={{
...inputStyle,
height: 48,
borderRadius: 12,
border: '1px solid #e5e7eb',
background: '#f1f5f9',
fontWeight: 700,
}}
>
{t('events.form.saveDraft', 'Save as Draft')}
</button>
) : null}
<CTAButton label={saving ? t('events.form.saving', 'Saving...') : isEdit ? t('events.form.update', 'Update Event') : t('events.form.create', 'Create Event')} onPress={() => handleSubmit()} />
</YStack>
</MobileScaffold>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{label}
</Text>
{children}
</YStack>
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
height: 44,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 14,
background: 'white',
};
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
}
return '';
}
function resolveLocation(event: TenantEvent): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) return candidate;
return '';
}

View File

@@ -0,0 +1,315 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { UserPlus, Trash2, Copy, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileScaffold } from './components/Scaffold';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { EventMember, getEventMembers, inviteEventMember, removeEventMember } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
export default function MobileEventMembersPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [members, setMembers] = React.useState<EventMember[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [invite, setInvite] = React.useState({ name: '', email: '', role: 'member' as EventMember['role'] });
const [saving, setSaving] = React.useState(false);
const [inviteLink, setInviteLink] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
const [search, setSearch] = React.useState('');
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
const [search, setSearch] = React.useState('');
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
const load = React.useCallback(async () => {
if (!slug) return;
setLoading(true);
setError(null);
try {
const result = await getEventMembers(slug, 1);
setMembers(result.data);
if (result.data.length) {
const pending = result.data.find((m) => m.status === 'pending' && m.permissions?.includes('invite_link'));
if (pending?.email) {
setInviteLink(`mailto:${pending.email}`);
}
}
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Mitglieder konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
async function handleInvite() {
if (!slug || !invite.email.trim()) return;
setSaving(true);
setError(null);
try {
const member = await inviteEventMember(slug, {
email: invite.email.trim(),
name: invite.name.trim() || undefined,
role: invite.role,
});
setMembers((prev) => [member, ...prev]);
setInvite({ name: '', email: '', role: 'member' });
toast.success(t('events.members.inviteSuccess', 'Einladung gesendet'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Einladung fehlgeschlagen.')));
toast.error(t('events.members.inviteFailed', 'Einladung fehlgeschlagen.'));
}
} finally {
setSaving(false);
}
}
async function handleRemove(member: EventMember) {
if (!slug) return;
try {
await removeEventMember(slug, member.id);
setMembers((prev) => prev.filter((m) => m.id !== member.id));
toast.success(t('events.members.removeSuccess', 'Mitglied entfernt'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Mitglied konnte nicht entfernt werden.')));
toast.error(t('events.members.removeFailed', 'Mitglied konnte nicht entfernt werden.'));
}
}
}
return (
<MobileScaffold
title={t('events.members.title', 'Guest Management')}
onBack={() => navigate(-1)}
rightSlot={
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.members.inviteTitle', 'Invite Member')}
</Text>
<YStack space="$2">
<Field label={t('events.members.name', 'Name')}>
<input
type="text"
value={invite.name}
onChange={(e) => setInvite((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Alex Example"
style={inputStyle}
/>
</Field>
<Field label={t('events.members.email', 'Email')}>
<input
type="email"
value={invite.email}
onChange={(e) => setInvite((prev) => ({ ...prev, email: e.target.value }))}
placeholder="alex@example.com"
style={inputStyle}
/>
</Field>
<Field label={t('events.members.role', 'Role')}>
<select
value={invite.role}
onChange={(e) => setInvite((prev) => ({ ...prev, role: e.target.value as EventMember['role'] }))}
style={{ ...inputStyle, height: 44 }}
>
<option value="member">{t('events.members.roleMember', 'Member')}</option>
<option value="tenant_admin">{t('events.members.roleAdmin', 'Admin')}</option>
</select>
</Field>
<CTAButton label={saving ? t('common.saving', 'Saving...') : t('events.members.invite', 'Send Invite')} onPress={() => handleInvite()} />
{saving ? (
<Text fontSize="$xs" color="#4b5563">
{t('common.processing', 'Processing...')}
</Text>
) : null}
</YStack>
</MobileCard>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('events.members.search', 'Search members')}
style={{
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
}}
/>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.members.listTitle', 'Team & Guests')}
</Text>
{inviteLink ? (
<Pressable
onPress={async () => {
try {
await navigator.clipboard.writeText(inviteLink);
toast.success(t('events.members.copyInvite', 'Einladungslink kopiert'));
} catch {
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
}
}}
>
<XStack alignItems="center" space="$2" marginBottom="$2">
<Copy size={16} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF">
{t('events.members.copyInviteLabel', 'Invite Link kopieren')}
</Text>
</XStack>
</Pressable>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`m-${idx}`} height={70} opacity={0.6} />
))}
</YStack>
) : members.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
{t('events.members.empty', 'Noch keine Einladungen.')}
</Text>
) : (
<YStack space="$2">
{members
.filter((member) => {
if (!search.trim()) return true;
const hay = `${member.name ?? ''} ${member.email ?? ''}`.toLowerCase();
return hay.includes(search.toLowerCase());
})
.map((member) => (
<MobileCard key={member.id} padding="$3" borderColor="#e5e7eb">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{member.name || member.email || 'Gast'}
</Text>
<Text fontSize="$xs" color="#6b7280">
{member.email ?? ''}
</Text>
<XStack space="$1.5" alignItems="center">
<PillBadge tone={member.status === 'pending' ? 'warning' : 'muted'}>
{member.status ?? 'pending'}
</PillBadge>
<PillBadge tone={member.role === 'tenant_admin' ? 'success' : 'muted'}>
{member.role === 'tenant_admin'
? t('events.members.admin', 'Admin')
: t('events.members.member', 'Member')}
</PillBadge>
</XStack>
</YStack>
<XStack space="$2">
<Pressable
onPress={async () => {
const link = inviteLink || (member.email ? `mailto:${member.email}` : null);
if (!link) {
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
return;
}
try {
await navigator.clipboard.writeText(link);
toast.success(t('events.members.copyInvite', 'Einladungslink kopiert'));
} catch {
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
}
}}
>
<Copy size={16} color="#6b7280" />
</Pressable>
<Pressable onPress={() => setConfirmRemove(member)}>
<Trash2 size={16} color="#ef4444" />
</Pressable>
</XStack>
</XStack>
</MobileCard>
))}
</YStack>
)}
</MobileCard>
<MobileSheet
open={Boolean(confirmRemove)}
onClose={() => setConfirmRemove(null)}
title={t('events.members.confirmRemove', 'Mitglied entfernen?')}
footer={
<CTAButton
label={t('events.members.remove', 'Remove')}
onPress={() => {
if (confirmRemove) {
void handleRemove(confirmRemove);
}
setConfirmRemove(null);
}}
/>
}
bottomOffsetPx={120}
>
<Text fontSize={12.5} color="#4b5563">
{t('events.members.removeHint', 'Dieses Mitglied verliert den Zugang zum Event.')}
</Text>
<Text fontSize={13} fontWeight="700" color="#111827">
{confirmRemove?.name || confirmRemove?.email}
</Text>
</MobileSheet>
</MobileScaffold>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{label}
</Text>
{children}
</YStack>
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
height: 42,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 14,
background: 'white',
};

View File

@@ -0,0 +1,339 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Search, Filter } 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 { MobileScaffold } from './components/Scaffold';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { getEventPhotos, updatePhotoVisibility, featurePhoto, unfeaturePhoto, TenantPhoto } from '../api';
import toast from 'react-hot-toast';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
type FilterKey = 'all' | 'featured' | 'hidden';
export default function MobileEventPhotosPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
const [filter, setFilter] = React.useState<FilterKey>('all');
const [page, setPage] = React.useState(1);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
const [totalCount, setTotalCount] = React.useState<number>(0);
const [hasMore, setHasMore] = React.useState(false);
const { go } = useMobileNav(slug);
const [search, setSearch] = React.useState('');
const [showFilters, setShowFilters] = React.useState(false);
const [uploaderFilter, setUploaderFilter] = React.useState('');
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
const [onlyHidden, setOnlyHidden] = React.useState(false);
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
const load = React.useCallback(async () => {
if (!slug) return;
setLoading(true);
setError(null);
try {
const result = await getEventPhotos(slug, {
page,
perPage: 20,
sort: 'desc',
featured: filter === 'featured' || onlyFeatured,
status: filter === 'hidden' || onlyHidden ? 'hidden' : undefined,
search: search || undefined,
uploader: uploaderFilter || undefined,
});
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
setTotalCount(result.meta?.total ?? result.photos.length);
const lastPage = result.meta?.last_page ?? 1;
setHasMore(page < lastPage);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Fotos konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, filter, t, page]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
setPage(1);
}, [filter, slug]);
async function toggleVisibility(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
const updated = await updatePhotoVisibility(slug, photo.id, photo.status === 'hidden');
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
toast.success(
updated.status === 'hidden'
? t('events.photos.hideSuccess', 'Foto versteckt')
: t('events.photos.showSuccess', 'Foto eingeblendet'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
toast.error(t('events.photos.hideFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
}
}
async function toggleFeature(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
const updated = photo.is_featured ? await unfeaturePhoto(slug, photo.id) : await featurePhoto(slug, photo.id);
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
toast.success(
updated.is_featured
? t('events.photos.featureSuccess', 'Als Highlight markiert')
: t('events.photos.unfeatureSuccess', 'Highlight entfernt'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Feature konnte nicht geändert werden.')));
toast.error(t('events.photos.featureFailed', 'Feature konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
}
}
return (
<MobileScaffold
title={t('events.photos.title', 'Photo Moderation')}
onBack={() => navigate(-1)}
rightSlot={
<XStack space="$3">
<Pressable onPress={() => setShowFilters(true)}>
<Filter size={18} color="#0f172a" />
</Pressable>
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
</XStack>
}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<input
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('events.photos.search', 'Search photos')}
style={{
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
marginBottom: 12,
}}
/>
<XStack space="$2">
{(['all', 'featured', 'hidden'] as FilterKey[]).map((key) => (
<Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}>
<MobileCard
backgroundColor={filter === key ? '#e8f1ff' : 'white'}
borderColor={filter === key ? '#bfdbfe' : '#e5e7eb'}
padding="$2.5"
>
<Text fontSize="$sm" fontWeight="700" textAlign="center" color="#111827">
{key === 'all' ? t('common.all', 'All') : key === 'featured' ? t('events.photos.featured', 'Featured') : t('events.photos.hidden', 'Hidden')}
</Text>
</MobileCard>
</Pressable>
))}
</XStack>
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`ph-${idx}`} height={100} opacity={0.6} />
))}
</YStack>
) : photos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<ImageIcon size={28} color="#9ca3af" />
<Text fontSize="$sm" color="#4b5563">
{t('events.photos.empty', 'Keine Fotos gefunden.')}
</Text>
</MobileCard>
) : (
<YStack space="$3">
<Text fontSize="$sm" color="#4b5563">
{t('events.photos.count', '{{count}} Fotos', { count: totalCount })}
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: 8,
}}
>
{photos.map((photo) => (
<Pressable key={photo.id} onPress={() => setLightbox(photo)}>
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor="#e5e7eb">
<img
src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Photo'}
style={{ width: '100%', height: 110, objectFit: 'cover' }}
/>
<XStack position="absolute" top={6} left={6} space="$1">
{photo.is_featured ? <PillBadge tone="warning">{t('events.photos.featured', 'Featured')}</PillBadge> : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('events.photos.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
))}
</div>
{hasMore ? (
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
) : null}
</YStack>
)}
{lightbox ? (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center">
<div
style={{
width: '100%',
maxWidth: 520,
margin: '0 16px',
background: '#fff',
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
>
<img
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: '#0f172a' }}
/>
<YStack padding="$3" space="$2">
<XStack space="$2" alignItems="center">
<PillBadge tone="muted">{lightbox.uploader_name || t('events.photos.guest', 'Gast')}</PillBadge>
<PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.is_featured
? t('events.photos.unfeature', 'Unfeature')
: t('events.photos.feature', 'Feature')
}
onPress={() => toggleFeature(lightbox)}
/>
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.status === 'hidden'
? t('events.photos.show', 'Show')
: t('events.photos.hide', 'Hide')
}
onPress={() => toggleVisibility(lightbox)}
/>
</XStack>
<CTAButton label={t('common.close', 'Close')} onPress={() => setLightbox(null)} />
</YStack>
</div>
</div>
) : null}
<MobileSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title={t('events.photos.filters', 'Filter')}
footer={
<CTAButton
label={t('events.photos.applyFilters', 'Apply filters')}
onPress={() => {
setPage(1);
setShowFilters(false);
void load();
}}
/>
}
>
<YStack space="$2">
<Field label={t('events.photos.uploader', 'Uploader')}>
<input
type="text"
value={uploaderFilter}
onChange={(e) => setUploaderFilter(e.target.value)}
placeholder={t('events.photos.uploaderPlaceholder', 'Name or email')}
style={inputStyle}
/>
</Field>
<XStack space="$2" alignItems="center">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={onlyFeatured}
onChange={(e) => setOnlyFeatured(e.target.checked)}
/>
<Text fontSize="$sm" color="#111827">
{t('events.photos.onlyFeatured', 'Only featured')}
</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={onlyHidden}
onChange={(e) => setOnlyHidden(e.target.checked)}
/>
<Text fontSize="$sm" color="#111827">
{t('events.photos.onlyHidden', 'Only hidden')}
</Text>
</label>
</XStack>
<CTAButton
label={t('common.reset', 'Reset')}
tone="ghost"
onPress={() => {
setUploaderFilter('');
setOnlyFeatured(false);
setOnlyHidden(false);
}}
/>
</YStack>
</MobileSheet>
</MobileScaffold>
);
}

View File

@@ -0,0 +1,770 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Plus, Folder, Pencil, Trash2, MoreHorizontal } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { ListItem } from '@tamagui/list-item';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileScaffold } from './components/Scaffold';
import { MobileCard, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import {
getEvent,
getEventTasks,
updateTask,
TenantTask,
assignTasksToEvent,
getTasks,
getTaskCollections,
importTaskCollection,
createTask,
TenantTaskCollection,
getEmotions,
TenantEmotion,
detachTasksFromEvent,
createEmotion,
updateEmotion as updateEmotionApi,
deleteEmotion as deleteEmotionApi,
} from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { MobileSheet } from './components/Sheet';
import { Tag } from './components/Tag';
import { useMobileNav } from './hooks/useMobileNav';
const inputStyle: React.CSSProperties = {
width: '100%',
height: 40,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
};
function InlineSeparator() {
return <XStack height={1} backgroundColor="#e5e7eb" opacity={0.7} marginLeft="$3" />;
}
export default function MobileEventTasksPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
const [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const [showCollectionSheet, setShowCollectionSheet] = React.useState(false);
const [showTaskSheet, setShowTaskSheet] = React.useState(false);
const [newTask, setNewTask] = React.useState({ id: null as number | null, title: '', description: '', emotion_id: '' as string | '' });
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
const [assigningId, setAssigningId] = React.useState<number | null>(null);
const [eventId, setEventId] = React.useState<number | null>(null);
const { go } = useMobileNav(slug);
const [searchTerm, setSearchTerm] = React.useState('');
const [emotionFilter, setEmotionFilter] = React.useState<string>('');
const [expandedLibrary, setExpandedLibrary] = React.useState(false);
const [expandedCollections, setExpandedCollections] = React.useState(false);
const [showActionsSheet, setShowActionsSheet] = React.useState(false);
const [showBulkSheet, setShowBulkSheet] = React.useState(false);
const [bulkLines, setBulkLines] = React.useState('');
const [showEmotionSheet, setShowEmotionSheet] = React.useState(false);
const [editingEmotion, setEditingEmotion] = React.useState<TenantEmotion | null>(null);
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: '#e5e7eb' });
const [savingEmotion, setSavingEmotion] = React.useState(false);
const load = React.useCallback(async () => {
if (!slug) {
setError(t('events.errors.missingSlug', 'Kein Event-Slug angegeben.'));
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const event = await getEvent(slug);
setEventId(event.id);
const result = await getEventTasks(event.id, 1);
const libraryTasks = await getTasks({ per_page: 50 });
const collectionList = await getTaskCollections({ per_page: 50 });
const emotionList = await getEmotions();
setTasks(result.data);
setLibrary(libraryTasks.data.filter((task) => !result.data.find((t) => t.id === task.id)));
setCollections(collectionList.data ?? []);
setEmotions(emotionList ?? []);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Tasks konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
async function quickAssign(taskId: number) {
if (!eventId) return;
setAssigningId(taskId);
try {
await assignTasksToEvent(eventId, [taskId]);
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
setLibrary((prev) => prev.filter((t) => t.id !== taskId));
toast.success(t('events.tasks.assigned', 'Task hinzugefügt'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Task konnte nicht zugewiesen werden.')));
toast.error(t('events.tasks.updateFailed', 'Task konnte nicht zugewiesen werden.'));
}
} finally {
setAssigningId(null);
}
}
async function importCollection(collectionId: number) {
if (!eventId) return;
try {
await importTaskCollection(collectionId, eventId);
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
toast.success(t('events.tasks.imported', 'Aufgabenpaket importiert'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Paket konnte nicht importiert werden.')));
toast.error(t('events.errors.saveFailed', 'Paket konnte nicht importiert werden.'));
}
}
}
async function createNewTask() {
if (!eventId || !newTask.title.trim()) return;
try {
if (newTask.id) {
await updateTask(newTask.id, {
title: newTask.title.trim(),
description: newTask.description.trim() || null,
emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined,
} as any);
} else {
const created = await createTask({
title: newTask.title.trim(),
description: newTask.description.trim() || null,
emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined,
} as any);
await assignTasksToEvent(eventId, [created.id]);
}
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
setShowTaskSheet(false);
setNewTask({ id: null, title: '', description: '', emotion_id: '' });
toast.success(t('events.tasks.created', 'Aufgabe gespeichert'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Aufgabe konnte nicht erstellt werden.')));
toast.error(t('events.errors.saveFailed', 'Aufgabe konnte nicht erstellt werden.'));
}
}
}
async function detachTask(taskId: number) {
if (!eventId) return;
setBusyId(taskId);
try {
await detachTasksFromEvent(eventId, [taskId]);
setTasks((prev) => prev.filter((task) => task.id !== taskId));
toast.success(t('events.tasks.removed', 'Aufgabe entfernt'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Aufgabe konnte nicht entfernt werden.')));
toast.error(t('events.errors.saveFailed', 'Aufgabe konnte nicht entfernt werden.'));
}
} finally {
setBusyId(null);
}
}
const startEdit = (task: TenantTask) => {
setNewTask({
id: task.id,
title: task.title,
description: task.description ?? '',
emotion_id: task.emotion?.id ? String(task.emotion.id) : '',
});
setShowTaskSheet(true);
};
const filteredTasks = tasks.filter((task) => {
const matchText =
!searchTerm ||
task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(task.description ?? '').toLowerCase().includes(searchTerm.toLowerCase());
const matchEmotion = !emotionFilter || task.emotion?.id === Number(emotionFilter);
return matchText && matchEmotion;
});
async function handleBulkAdd() {
if (!eventId || !bulkLines.trim()) return;
const lines = bulkLines
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return;
try {
for (const line of lines) {
const created = await createTask({ title: line } as any);
await assignTasksToEvent(eventId, [created.id]);
}
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
setBulkLines('');
setShowBulkSheet(false);
toast.success(t('events.tasks.created', 'Aufgabe gespeichert'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('events.errors.saveFailed', 'Aufgabe konnte nicht erstellt werden.'));
}
}
}
async function saveEmotion() {
if (!emotionForm.name.trim()) return;
setSavingEmotion(true);
try {
if (editingEmotion) {
const updated = await updateEmotionApi(editingEmotion.id, { name: emotionForm.name.trim(), color: emotionForm.color });
setEmotions((prev) => prev.map((em) => (em.id === editingEmotion.id ? updated : em)));
} else {
const created = await createEmotion({ name: emotionForm.name.trim(), color: emotionForm.color });
setEmotions((prev) => [...prev, created]);
}
setShowEmotionSheet(false);
setEditingEmotion(null);
setEmotionForm({ name: '', color: '#e5e7eb' });
toast.success(t('events.tasks.emotionSaved', 'Emotion gespeichert'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Konnte nicht gespeichert werden.')));
}
} finally {
setSavingEmotion(false);
}
}
async function removeEmotion(emotionId: number) {
try {
await deleteEmotionApi(emotionId);
setEmotions((prev) => prev.filter((em) => em.id !== emotionId));
toast.success(t('events.tasks.emotionRemoved', 'Emotion entfernt'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Konnte nicht gespeichert werden.')));
}
}
}
return (
<MobileScaffold
title={t('events.tasks.title', 'Tasks & Checklists')}
onBack={() => navigate(-1)}
rightSlot={
<XStack space="$2">
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
<Pressable onPress={() => setShowActionsSheet(true)}>
<MoreHorizontal size={18} color="#0f172a" />
</Pressable>
</XStack>
}
footer={
<BottomNav active="tasks" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontSize={13} fontWeight="600" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`tsk-${idx}`} height={70} opacity={0.6} />
))}
</YStack>
) : tasks.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Text fontSize={13} fontWeight="500" color="#4b5563">
{t('events.tasks.empty', 'Noch keine Aufgaben.')}
</Text>
</MobileCard>
) : (
<YStack space="$2">
<YStack space="$2">
<input
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('events.tasks.search', 'Search tasks')}
style={{ ...inputStyle, height: 38 }}
/>
<XStack space="$2" flexWrap="wrap">
<Chip
active={!emotionFilter}
label={t('events.tasks.allEmotions', 'All')}
onPress={() => setEmotionFilter('')}
/>
{emotions.map((emotion) => (
<Chip
key={emotion.id}
label={emotion.name}
color={emotion.color ?? '#e5e7eb'}
active={emotionFilter === String(emotion.id)}
onPress={() => setEmotionFilter(String(emotion.id))}
/>
))}
</XStack>
</YStack>
<Text fontSize="$sm" color="#4b5563">
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
</Text>
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
{filteredTasks.map((task, idx) => (
<React.Fragment key={task.id}>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
{task.title}
</Text>
}
subTitle={
task.description ? (
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
{task.description}
</Text>
) : null
}
iconAfter={
<XStack space="$2">
<Pressable onPress={() => startEdit(task)}>
<Pencil size={14} color="#007AFF" />
</Pressable>
<Pressable disabled={busyId === task.id} onPress={() => detachTask(task.id)}>
<Trash2 size={14} color="#ef4444" />
</Pressable>
</XStack>
}
paddingVertical="$2"
paddingHorizontal="$3"
>
{task.emotion ? (
<XStack marginTop="$1">
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? '#e5e7eb'} />
</XStack>
) : null}
</ListItem>
{idx < tasks.length - 1 ? <InlineSeparator /> : null}
</React.Fragment>
))}
</YStack>
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
<Text fontSize={12.5} fontWeight="600" color="#111827">
{t('events.tasks.library', 'Weitere Aufgaben')}
</Text>
<Pressable onPress={() => setShowCollectionSheet(true)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
{t('events.tasks.import', 'Import Pack')}
</Text>
</Pressable>
</XStack>
<Pressable onPress={() => setExpandedLibrary((prev) => !prev)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
{expandedLibrary ? t('events.tasks.hideLibrary', 'Hide library') : t('events.tasks.viewAllLibrary', 'View all')}
</Text>
</Pressable>
{library.length === 0 ? (
<Text fontSize={12} fontWeight="500" color="#6b7280">
{t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')}
</Text>
) : (
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
<React.Fragment key={`lib-${task.id}`}>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
{task.title}
</Text>
}
subTitle={
task.description ? (
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
{task.description}
</Text>
) : null
}
iconAfter={
<Pressable onPress={() => quickAssign(task.id)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
</Text>
</Pressable>
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
{idx < arr.length - 1 ? <InlineSeparator /> : null}
</React.Fragment>
))}
</YStack>
)}
</YStack>
)}
<MobileSheet
open={showCollectionSheet}
onClose={() => setShowCollectionSheet(false)}
title={t('events.tasks.import', 'Aufgabenpaket importieren')}
footer={null}
>
<YStack space="$2">
<Pressable onPress={() => setExpandedCollections((prev) => !prev)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
{expandedCollections ? t('events.tasks.hideCollections', 'Hide collections') : t('events.tasks.showCollections', 'Show all')}
</Text>
</Pressable>
{collections.length === 0 ? (
<Text fontSize={13} fontWeight="500" color="#4b5563">
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
</Text>
) : (
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
<React.Fragment key={collection.id}>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
{collection.title}
</Text>
}
subTitle={
collection.description ? (
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
{collection.description}
</Text>
) : null
}
iconAfter={
<Pressable onPress={() => importCollection(collection.id)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
{t('events.tasks.import', 'Import')}
</Text>
</Pressable>
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
{idx < arr.length - 1 ? <InlineSeparator /> : null}
</React.Fragment>
))}
</YStack>
)}
</YStack>
</MobileSheet>
<MobileSheet
open={showTaskSheet}
onClose={() => setShowTaskSheet(false)}
title={t('events.tasks.addTask', 'Aufgabe hinzufügen')}
footer={
<CTAButton label={t('events.tasks.saveTask', 'Aufgabe speichern')} onPress={() => createNewTask()} />
}
>
<YStack space="$2">
<Field label={t('events.tasks.title', 'Titel')}>
<input
type="text"
value={newTask.title}
onChange={(e) => setNewTask((prev) => ({ ...prev, title: e.target.value }))}
placeholder={t('events.tasks.titlePlaceholder', 'z.B. Erstes Gruppenfoto')}
style={inputStyle}
/>
</Field>
<Field label={t('events.tasks.description', 'Beschreibung')}>
<textarea
value={newTask.description}
onChange={(e) => setNewTask((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t('events.tasks.descriptionPlaceholder', 'Optionale Hinweise')}
style={{ ...inputStyle, minHeight: 80 }}
/>
</Field>
<Field label={t('events.tasks.emotion', 'Emotion')}>
<select
value={newTask.emotion_id}
onChange={(e) => setNewTask((prev) => ({ ...prev, emotion_id: e.target.value }))}
style={{ ...inputStyle, height: 42 }}
>
<option value="">{t('events.tasks.emotionNone', 'Keine')}</option>
{emotions.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.name}
</option>
))}
</select>
</Field>
</YStack>
</MobileSheet>
<MobileSheet
open={showActionsSheet}
onClose={() => setShowActionsSheet(false)}
title={t('events.tasks.moreActions', 'Mehr Aktionen')}
footer={null}
>
<YStack space="$2">
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
{t('events.tasks.bulkAdd', 'Bulk add')}
</Text>
}
onPress={() => {
setShowActionsSheet(false);
setShowBulkSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
/>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
{t('events.tasks.manageEmotions', 'Manage emotions')}
</Text>
}
subTitle={
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
{t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')}
</Text>
}
onPress={() => {
setShowActionsSheet(false);
setShowEmotionSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</YStack>
</MobileSheet>
<MobileSheet
open={showBulkSheet}
onClose={() => setShowBulkSheet(false)}
title={t('events.tasks.bulkAdd', 'Bulk add')}
footer={<CTAButton label={t('events.tasks.saveTask', 'Aufgabe speichern')} onPress={() => handleBulkAdd()} />}
>
<YStack space="$2">
<Text fontSize={12} color="#4b5563">
{t('events.tasks.bulkHint', 'One task per line. These will be created and added to the event.')}
</Text>
<textarea
value={bulkLines}
onChange={(e) => setBulkLines(e.target.value)}
placeholder={t('events.tasks.bulkPlaceholder', 'e.g.\nBride & groom portrait\nGroup photo main guests')}
style={{ ...inputStyle, minHeight: 140, fontSize: 12.5 }}
/>
</YStack>
</MobileSheet>
<MobileSheet
open={showEmotionSheet}
onClose={() => {
setShowEmotionSheet(false);
setEditingEmotion(null);
setEmotionForm({ name: '', color: '#e5e7eb' });
}}
title={t('events.tasks.manageEmotions', 'Manage emotions')}
footer={
<CTAButton
label={savingEmotion ? t('common.saving', 'Saving...') : t('events.tasks.saveEmotion', 'Emotion speichern')}
onPress={() => saveEmotion()}
/>
}
>
<YStack space="$2">
<Field label={t('events.tasks.emotionName', 'Name')}>
<input
type="text"
value={emotionForm.name}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')}
style={inputStyle}
/>
</Field>
<Field label={t('events.tasks.emotionColor', 'Farbe')}>
<input
type="color"
value={emotionForm.color}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))}
style={{ width: '100%', height: 44, borderRadius: 10, border: '1px solid #e5e7eb', background: 'white' }}
/>
</Field>
<YStack space="$2">
{emotions.map((em) => (
<ListItem
key={`emo-${em.id}`}
title={
<XStack alignItems="center" space="$2">
<Tag label={em.name ?? ''} color={em.color ?? '#e5e7eb'} />
</XStack>
}
iconAfter={
<XStack space="$2">
<Pressable
onPress={() => {
setEditingEmotion(em);
setEmotionForm({ name: em.name ?? '', color: em.color ?? '#e5e7eb' });
}}
>
<Pencil size={14} color="#007AFF" />
</Pressable>
<Pressable onPress={() => removeEmotion(em.id)}>
<Trash2 size={14} color="#ef4444" />
</Pressable>
</XStack>
}
/>
))}
</YStack>
</YStack>
</MobileSheet>
<MobileSheet
open={showEmotionSheet}
onClose={() => {
setShowEmotionSheet(false);
setEditingEmotion(null);
setEmotionForm({ name: '', color: '#e5e7eb' });
}}
title={t('events.tasks.manageEmotions', 'Manage emotions')}
footer={
<CTAButton
label={t('events.tasks.saveEmotion', 'Emotion speichern')}
onPress={() => {
void saveEmotion();
}}
/>
}
>
<YStack space="$2">
<Field label={t('events.tasks.emotionName', 'Name')}>
<input
type="text"
value={emotionForm.name}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')}
style={inputStyle}
/>
</Field>
<Field label={t('events.tasks.emotionColor', 'Farbe')}>
<input
type="color"
value={emotionForm.color}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))}
style={{ width: '100%', height: 44, borderRadius: 10, border: '1px solid #e5e7eb', background: 'white' }}
/>
</Field>
<YStack space="$2">
{emotions.map((em) => (
<ListItem
key={`emo-${em.id}`}
title={
<XStack alignItems="center" space="$2">
<Tag label={em.name ?? ''} color={em.color ?? '#e5e7eb'} />
</XStack>
}
iconAfter={
<XStack space="$2">
<Pressable
onPress={() => {
setEditingEmotion(em);
setEmotionForm({ name: em.name ?? '', color: em.color ?? '#e5e7eb' });
}}
>
<Pencil size={14} color="#007AFF" />
</Pressable>
<Pressable onPress={() => removeEmotion(em.id)}>
<Trash2 size={14} color="#ef4444" />
</Pressable>
</XStack>
}
/>
))}
</YStack>
</YStack>
</MobileSheet>
<Pressable
onPress={() => setShowTaskSheet(true)}
style={{
position: 'fixed',
right: 20,
bottom: 90,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
boxShadow: '0 10px 25px rgba(0,122,255,0.35)',
zIndex: 60,
}}
>
<Plus size={20} color="#ffffff" />
</Pressable>
</MobileScaffold>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize={12.5} fontWeight="600" color="#111827">
{label}
</Text>
{children}
</YStack>
);
}
function Chip({ label, onPress, active, color }: { label: string; onPress: () => void; active: boolean; color?: string }) {
return (
<Pressable onPress={onPress}>
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical={8}
borderRadius={999}
backgroundColor={active ? '#e0f2fe' : '#f3f4f6'}
borderWidth={1}
borderColor={active ? '#93c5fd' : '#e5e7eb'}
>
<Text fontSize={12} fontWeight="600" color={color ?? (active ? '#0f172a' : '#4b5563')}>
{label}
</Text>
</XStack>
</Pressable>
);
}

View File

@@ -0,0 +1,197 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { CalendarDays, MapPin, Plus, Search } 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 { useTranslation } from 'react-i18next';
import { MobileScaffold } from './components/Scaffold';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
export default function MobileEventsPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav();
const [query, setQuery] = React.useState('');
React.useEffect(() => {
(async () => {
try {
setEvents(await getEvents());
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [t]);
return (
<MobileScaffold
title={t('events.list.dashboardTitle', 'All Events Dashboard')}
onBack={() => navigate(-1)}
rightSlot={
<Pressable>
<Search size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('events.list.search', 'Search events')}
style={{
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
marginBottom: 12,
}}
/>
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={90} opacity={0.6} />
))}
</YStack>
) : events.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$3">
<Text fontSize="$md" fontWeight="700">
{t('events.list.empty.title', 'Noch kein Event angelegt')}
</Text>
<Text fontSize="$sm" color="#4b5563" textAlign="center">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
</MobileCard>
) : (
<YStack space="$3">
{events
.filter((event) => {
if (!query.trim()) return true;
const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
return hay.includes(query.toLowerCase());
})
.map((event) => (
<EventRow
key={event.id}
event={event}
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))}
onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
/>
))}
</YStack>
)}
</MobileScaffold>
);
}
function EventRow({ event, onOpen, onEdit }: { event: TenantEvent; onOpen: (slug: string) => void; onEdit: (slug: string) => void }) {
const status = resolveStatus(event);
return (
<MobileCard borderColor="#e2e8f0">
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color="#111827">
{renderName(event.name)}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={14} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
{formatDate(event.event_date)}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<MapPin size={14} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
{resolveLocation(event)}
</Text>
</XStack>
<PillBadge tone={status.tone}>{status.label}</PillBadge>
</YStack>
<Pressable onPress={() => onEdit(event.slug)}>
<Text fontSize="$xl" color="#9ca3af">
˅
</Text>
</Pressable>
</XStack>
<Pressable onPress={() => onOpen(event.slug)} style={{ marginTop: 8 }}>
<XStack alignItems="center" justifyContent="flex-start" space="$2">
<Plus size={16} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
Open event
</Text>
</XStack>
</Pressable>
</MobileCard>
);
}
function resolveStatus(event: TenantEvent): { label: string; tone: 'success' | 'warning' | 'muted' } {
if (event.status === 'published') {
return { label: 'Upcoming', tone: 'success' };
}
if (event.status === 'draft') {
return { label: 'Draft', tone: 'warning' };
}
return { label: 'Past', tone: 'muted' };
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
}
return 'Unbenanntes Event';
}
function resolveLocation(event: TenantEvent): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) {
return candidate;
}
return 'Location';
}
function formatDate(iso: string | null): string {
if (!iso) return 'Date tbd';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'Date tbd';
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2, Lock, Mail } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { adminPath, ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants';
import { useAuth } from '../auth/context';
import { resolveReturnTarget } from '../lib/returnTo';
type LoginResponse = {
token: string;
token_type: string;
abilities: string[];
};
async function performLogin(payload: { login: string; password: string; return_to?: string | null }): Promise<LoginResponse> {
const response = await fetch('/api/v1/tenant-auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
...payload,
remember: true,
}),
});
if (response.status === 422) {
const data = await response.json();
const errors = data.errors ?? {};
const flattened = Object.values(errors).flat();
throw new Error(flattened.join(' ') || 'Validation failed');
}
if (!response.ok) {
throw new Error('Login failed.');
}
return (await response.json()) as LoginResponse;
}
export default function MobileLoginPage() {
const { status, applyToken, abilities } = useAuth();
const { t } = useTranslation('auth');
const location = useLocation();
const navigate = useNavigate();
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const rawReturnTo = searchParams.get('return_to');
const computeDefaultAfterLogin = React.useCallback(
(abilityList?: string[]) => {
const source = abilityList ?? abilities;
return source.includes('tenant-admin') ? ADMIN_DEFAULT_AFTER_LOGIN_PATH : ADMIN_EVENTS_PATH;
},
[abilities],
);
const fallbackTarget = computeDefaultAfterLogin();
const { finalTarget } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
[rawReturnTo, fallbackTarget],
);
React.useEffect(() => {
if (status === 'authenticated') {
navigate(finalTarget, { replace: true });
}
}, [finalTarget, navigate, status]);
const [login, setLogin] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const mutation = useMutation({
mutationKey: ['tenantAdminLoginMobile'],
mutationFn: performLogin,
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
setError(message);
},
onSuccess: async (data) => {
setError(null);
await applyToken(data.token, data.abilities ?? []);
const postLoginFallback = computeDefaultAfterLogin(data.abilities ?? []);
const { finalTarget: successTarget } = resolveReturnTarget(rawReturnTo, postLoginFallback);
navigate(successTarget, { replace: true });
},
});
const isSubmitting =
(mutation as { isPending?: boolean; isLoading?: boolean }).isPending ??
(mutation as { isPending?: boolean; isLoading?: boolean }).isLoading ??
false;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
mutation.mutate({
login,
password,
return_to: rawReturnTo,
});
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#0b1020] via-[#0f172a] to-[#0b1020] px-5 py-10 text-white">
<div className="w-full max-w-md space-y-8 rounded-3xl border border-white/10 bg-white/5 p-8 shadow-2xl shadow-blue-500/10 backdrop-blur-lg">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 ring-1 ring-white/15">
<img src="/logo-transparent-md.png" alt="Fotospiel" className="h-10 w-10" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">{t('login.panel_title', 'Team Login')}</h1>
<p className="text-sm text-white/70">
{t('login.panel_copy', 'Melde dich an, um Events, Fotos und Aufgaben zu verwalten.')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-semibold text-white/90" htmlFor="login-mobile">
{t('login.username_or_email', 'E-Mail oder Benutzername')}
</label>
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/10 px-3 py-3">
<Mail size={16} className="text-white/70" />
<input
id="login-mobile"
type="text"
autoComplete="username"
value={login}
onChange={(e) => setLogin(e.target.value)}
placeholder={t('login.username_or_email_placeholder', 'name@example.com')}
className="w-full bg-transparent text-sm text-white placeholder:text-white/50 focus:outline-none"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-white/90" htmlFor="password-mobile">
{t('login.password', 'Passwort')}
</label>
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/10 px-3 py-3">
<Lock size={16} className="text-white/70" />
<input
id="password-mobile"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('login.password_placeholder', '••••••••')}
className="w-full bg-transparent text-sm text-white placeholder:text-white/50 focus:outline-none"
required
/>
</div>
</div>
{error ? (
<div className="rounded-2xl border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-100">
{error}
</div>
) : null}
<button
type="submit"
disabled={isSubmitting}
className="flex h-12 w-full items-center justify-center gap-2 rounded-2xl bg-gradient-to-r from-[#2563eb] via-[#3b82f6] to-[#22d3ee] text-sm font-semibold text-white shadow-lg shadow-blue-500/25 transition hover:brightness-110 disabled:opacity-70"
>
<Loader2 className={`h-4 w-4 animate-spin ${isSubmitting ? 'opacity-100' : 'opacity-0'}`} />
<span>{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}</span>
</button>
</form>
<div className="text-center text-xs text-white/60">
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
</div>
<div className="text-center">
<button
type="button"
onClick={() => navigate(adminPath('/faq'))}
className="text-xs font-semibold text-white/70 underline underline-offset-4"
>
{t('login.faq', 'Hilfe & FAQ')}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LogOut, User, Settings, Shield, Globe, Moon } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileScaffold } from './components/Scaffold';
import { MobileCard, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api';
import { useMobileNav } from './hooks/useMobileNav';
import { adminPath } from '../constants';
import i18n from '../i18n';
export default function MobileProfilePage() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { go } = useMobileNav();
const [name, setName] = React.useState(user?.name ?? 'Guest');
const [email, setEmail] = React.useState(user?.email ?? '');
const [role, setRole] = React.useState<string>(user?.role ?? '');
const [theme, setTheme] = React.useState<'light' | 'dark'>('light');
const [language, setLanguage] = React.useState<string>(i18n.language || 'de');
React.useEffect(() => {
(async () => {
try {
const profile = await fetchTenantProfile();
setName(profile.name ?? name);
setEmail(profile.email ?? email);
setRole((profile as any)?.role ?? role);
} catch {
// non-fatal for mobile profile view
}
})();
}, [email, name, role]);
return (
<MobileScaffold
title={t('profile.title', 'Profile')}
onBack={() => navigate(-1)}
footer={
<BottomNav active="profile" onNavigate={go} />
}
>
<MobileCard space="$3" alignItems="center">
<XStack
width={64}
height={64}
borderRadius={20}
alignItems="center"
justifyContent="center"
backgroundColor="#e0f2fe"
>
<User size={28} color="#2563eb" />
</XStack>
<Text fontSize="$md" fontWeight="800" color="#111827">
{name}
</Text>
<Text fontSize="$sm" color="#4b5563">
{email}
</Text>
{role ? (
<Text fontSize="$xs" color="#6b7280">
{role}
</Text>
) : null}
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('profile.settings', 'Settings')}
</Text>
<Pressable onPress={() => navigate(adminPath('/settings'))}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<Text fontSize="$sm" color="#111827">
{t('profile.account', 'Account & Security')}
</Text>
<Settings size={18} color="#9ca3af" />
</XStack>
</Pressable>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<XStack space="$2" alignItems="center">
<Globe size={16} color="#6b7280" />
<Text fontSize="$sm" color="#111827">
{t('profile.language', 'Language')}
</Text>
</XStack>
<select
value={language}
onChange={(e) => {
const lng = e.target.value;
setLanguage(lng);
void i18n.changeLanguage(lng);
}}
style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '6px 10px', background: 'white', fontSize: 13 }}
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</XStack>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<XStack space="$2" alignItems="center">
<Moon size={16} color="#6b7280" />
<Text fontSize="$sm" color="#111827">
{t('profile.theme', 'Theme')}
</Text>
</XStack>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as 'light' | 'dark')}
style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '6px 10px', background: 'white', fontSize: 13 }}
>
<option value="light">{t('profile.themeLight', 'Light')}</option>
<option value="dark">{t('profile.themeDark', 'Dark')}</option>
</select>
</XStack>
</MobileCard>
<CTAButton
label={t('profile.logout', 'Log out')}
onPress={() => {
logout();
navigate(adminPath('/logout'));
}}
/>
</MobileScaffold>
);
}

View File

@@ -0,0 +1,270 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Download, Share2, ChevronRight, RefreshCcw } 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 { MobileScaffold } from './components/Scaffold';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { TenantEvent, getEvent, getEventQrInvites, createQrInvite } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
const LAYOUTS = [
{ key: 'badges', title: 'Badges', subtitle: 'Standard, Staff' },
{ key: 'tents', title: 'Table Tents', subtitle: 'A4, Letter' },
{ key: 'posters', title: 'Posters', subtitle: 'A3, 11x17' },
{ key: 'programs', title: 'Event Programs', subtitle: 'Folded, Booklet' },
];
export default function MobileQrPrintPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [paperSize, setPaperSize] = React.useState('A4 (210 x 297 mm)');
const [qrUrl, setQrUrl] = React.useState<string>('');
const { go } = useMobileNav(slug);
const [showPaperSheet, setShowPaperSheet] = React.useState(false);
const [showLayoutSheet, setShowLayoutSheet] = React.useState(false);
React.useEffect(() => {
if (!slug) return;
(async () => {
setLoading(true);
try {
const data = await getEvent(slug);
const invites = await getEventQrInvites(slug);
setEvent(data);
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0];
setQrUrl(primaryInvite?.url ?? data.public_url ?? '');
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [slug, t]);
return (
<MobileScaffold
title={t('events.qr.title', 'QR Code & Print Layouts')}
onBack={() => navigate(-1)}
rightSlot={
<Pressable onPress={() => window.location.reload()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="events" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3" alignItems="center">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.qr.heroTitle', 'Entrance QR Code')}
</Text>
<YStack
width={180}
height={180}
borderRadius={16}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#f8fafc"
alignItems="center"
justifyContent="center"
overflow="hidden"
>
{qrUrl ? (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(qrUrl)}`}
alt="QR"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<Text color="#9ca3af" fontSize="$sm">
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
</Text>
)}
</YStack>
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.description', 'Scan to access the event guest app.')}
</Text>
<XStack space="$2" width="100%" marginTop="$2">
<CTAButton
label={t('events.qr.download', 'Download')}
onPress={() => {
if (qrUrl) {
toast.success(t('events.qr.downloadStarted', 'Download gestartet'));
} else {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
}
}}
/>
<CTAButton
label={t('events.qr.share', 'Share')}
onPress={async () => {
try {
await navigator.clipboard.writeText(qrUrl || event?.public_url || '');
toast.success(t('events.qr.shareSuccess', 'Link kopiert'));
} catch {
toast.error(t('events.qr.shareFailed', 'Konnte Link nicht kopieren'));
}
}}
/>
</XStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.qr.layouts', 'Print Layouts')}
</Text>
<YStack space="$1">
{LAYOUTS.map((layout) => (
<XStack
key={layout.key}
alignItems="center"
justifyContent="space-between"
paddingVertical="$2"
borderBottomWidth={layout.key === 'programs' ? 0 : 1}
borderColor="#e5e7eb"
onPress={() => setShowLayoutSheet(true)}
>
<YStack>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{layout.title}
</Text>
<Text fontSize="$xs" color="#6b7280">
{layout.subtitle}
</Text>
</YStack>
<ChevronRight size={16} color="#9ca3af" />
</XStack>
))}
</YStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.templates', 'Templates')}
</Text>
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<Text fontSize="$sm" color="#111827">
{t('events.qr.branding', 'Branding')}
</Text>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" defaultChecked />
<Text fontSize="$sm" color="#111827">
{t('common.enabled', 'Enabled')}
</Text>
</label>
</XStack>
<Pressable onPress={() => setShowPaperSheet(true)}>
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2">
<Text fontSize="$sm" color="#111827">
{t('events.qr.paper', 'Paper Size')}
</Text>
<XStack alignItems="center" space="$2">
<Text fontSize="$sm" color="#111827">
{paperSize}
</Text>
<ChevronRight size={16} color="#9ca3af" />
</XStack>
</XStack>
</Pressable>
<CTAButton
label={t('events.qr.preview', 'Preview & Print')}
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
/>
<CTAButton
label={t('events.qr.createLink', 'Neuen QR-Link erstellen')}
onPress={async () => {
if (!slug) return;
try {
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
setQrUrl(invite.url);
toast.success(t('events.qr.created', 'Neuer QR-Link erstellt'));
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.qr.createFailed', 'Link konnte nicht erstellt werden.')));
}
}}
/>
</MobileCard>
<MobileSheet
open={showPaperSheet}
onClose={() => setShowPaperSheet(false)}
title={t('events.qr.paper', 'Paper Size')}
footer={null}
>
<YStack space="$2">
{['A4 (210 x 297 mm)', 'Letter (8.5 x 11 in)', 'A3 (297 x 420 mm)'].map((size) => (
<Pressable
key={size}
onPress={() => {
setPaperSize(size);
setShowPaperSheet(false);
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<Text fontSize="$sm" color="#111827">
{size}
</Text>
{paperSize === size ? <ChevronRight size={16} color="#007AFF" /> : null}
</XStack>
</Pressable>
))}
</YStack>
</MobileSheet>
<MobileSheet
open={showLayoutSheet}
onClose={() => setShowLayoutSheet(false)}
title={t('events.qr.layouts', 'Print Layouts')}
footer={
<CTAButton
label={t('events.qr.preview', 'Preview & Print')}
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
/>
}
>
<YStack space="$2">
{LAYOUTS.map((layout) => (
<MobileCard key={`lay-${layout.key}`} padding="$3" borderColor="#e5e7eb">
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{layout.title}
</Text>
<Text fontSize="$xs" color="#6b7280">
{layout.subtitle}
</Text>
</YStack>
<PillBadge tone="muted">{paperSize}</PillBadge>
</XStack>
</MobileCard>
))}
</YStack>
</MobileSheet>
</MobileScaffold>
);
}

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Home, CheckSquare, Bell, User } from 'lucide-react';
import { useTheme } from '@tamagui/core';
import { useTranslation } from 'react-i18next';
import { useAlertsBadge } from '../hooks/useAlertsBadge';
export type NavKey = 'events' | 'tasks' | 'alerts' | 'profile';
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
const { t } = useTranslation('mobile');
const theme = useTheme();
const { count: alertCount } = useAlertsBadge();
const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [
{ key: 'events', icon: Home, label: t('nav.events', 'Events') },
{ key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') },
{ key: 'alerts', icon: Bell, label: t('nav.alerts', 'Alerts') },
{ key: 'profile', icon: User, label: t('nav.profile', 'Profile') },
];
return (
<YStack
position="fixed"
bottom={0}
left={0}
right={0}
backgroundColor="white"
borderTopWidth={1}
borderColor="#e5e7eb"
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
shadowColor="#0f172a"
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
// allow for safe-area inset on modern phones
style={{ paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)' }}
>
<XStack justifyContent="space-between" alignItems="center">
{items.map((item) => {
const activeState = item.key === active;
const IconCmp = item.icon;
return (
<Pressable key={item.key} onPress={() => onNavigate(item.key)}>
<YStack alignItems="center" space="$1" position="relative">
<IconCmp size={20} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
<Text fontSize="$xs" color={activeState ? '$primary' : '#6b7280'}>
{item.label}
</Text>
{item.key === 'alerts' && alertCount > 0 ? (
<XStack
position="absolute"
top={-6}
right={-12}
minWidth={18}
height={18}
paddingHorizontal={6}
borderRadius={999}
backgroundColor="#ef4444"
alignItems="center"
justifyContent="center"
>
<Text fontSize={10} color="white" fontWeight="700">
{alertCount > 9 ? '9+' : alertCount}
</Text>
</XStack>
) : null}
</YStack>
</Pressable>
);
})}
</XStack>
</YStack>
);
}

View File

@@ -0,0 +1,144 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core';
export function MobileCard({ children, ...rest }: React.ComponentProps<typeof YStack>) {
return (
<YStack
backgroundColor="white"
borderRadius={16}
borderWidth={1}
borderColor="#e5e7eb"
shadowColor="#0f172a"
shadowOpacity={0.06}
shadowRadius={12}
shadowOffset={{ width: 0, height: 8 }}
padding="$3.5"
space="$2"
{...rest}
>
{children}
</YStack>
);
}
export function PillBadge({
tone = 'muted',
children,
}: {
tone?: 'success' | 'warning' | 'muted';
children: React.ReactNode;
}) {
const palette: Record<typeof tone, { bg: string; text: string; border: string }> = {
success: { bg: '#ecfdf3', text: '#047857', border: '#bbf7d0' },
warning: { bg: '#fffbeb', text: '#92400e', border: '#fef3c7' },
muted: { bg: '#f3f4f6', text: '#374151', border: '#e5e7eb' },
};
const colors = palette[tone] ?? palette.muted;
return (
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
backgroundColor={colors.bg}
borderColor={colors.border}
>
<Text fontSize="$xs" fontWeight="700" color={colors.text}>
{children}
</Text>
</XStack>
);
}
export function CTAButton({
label,
onPress,
tone = 'primary',
}: {
label: string;
onPress: () => void;
tone?: 'primary' | 'ghost';
}) {
const theme = useTheme();
const isPrimary = tone === 'primary';
return (
<Pressable onPress={onPress} style={{ width: '100%' }}>
<XStack
height={56}
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor={isPrimary ? String(theme.primary?.val ?? '#007AFF') : 'white'}
borderWidth={isPrimary ? 0 : 1}
borderColor={isPrimary ? 'transparent' : '#e5e7eb'}
>
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : '#111827'}>
{label}
</Text>
</XStack>
</Pressable>
);
}
export function KpiTile({
icon: IconCmp,
label,
value,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
label: string;
value: string | number;
}) {
return (
<MobileCard borderRadius={14} padding="$3" width="32%" minWidth={110} alignItems="flex-start">
<XStack alignItems="center" space="$2">
<XStack width={32} height={32} borderRadius={12} backgroundColor="#e5f0ff" alignItems="center" justifyContent="center">
<IconCmp size={16} color="#2563eb" />
</XStack>
<Text fontSize="$xs" color="#111827">
{label}
</Text>
</XStack>
<Text fontSize="$xl" fontWeight="800" color="#111827">
{value}
</Text>
</MobileCard>
);
}
export function ActionTile({
icon: IconCmp,
label,
color,
onPress,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
label: string;
color: string;
onPress: () => void;
}) {
return (
<Pressable onPress={onPress} style={{ width: '48%' }}>
<YStack
borderRadius={16}
padding="$3"
space="$2"
backgroundColor={`${color}22`}
borderWidth={1}
borderColor={`${color}55`}
minHeight={110}
>
<XStack width={38} height={38} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
<IconCmp size={18} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text>
</YStack>
</Pressable>
);
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { ChevronLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type MobileScaffoldProps = {
title: string;
onBack?: () => void;
rightSlot?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
};
export function MobileScaffold({ title, onBack, rightSlot, children, footer }: MobileScaffoldProps) {
const { t } = useTranslation('mobile');
return (
<YStack backgroundColor="#f7f8fb" minHeight="100vh">
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
backgroundColor="white"
borderBottomWidth={1}
borderColor="#e5e7eb"
>
<XStack alignItems="center" space="$2">
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
{t('actions.back', 'Back')}
</Text>
</XStack>
</Pressable>
) : (
<Text />
)}
</XStack>
<Text fontSize="$lg" fontWeight="800" color="#111827">
{title}
</Text>
<XStack minWidth={40} justifyContent="flex-end">
{rightSlot ?? null}
</XStack>
</XStack>
<YStack flex={1} padding="$4" space="$3" paddingBottom={footer ? '$14' : '$5'}>
{children}
</YStack>
{footer ? <YStack>{footer}</YStack> : null}
</YStack>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTranslation } from 'react-i18next';
type SheetProps = {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
/** Optional bottom offset so content sits above the bottom nav/safe-area. */
bottomOffsetPx?: number;
};
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
const { t } = useTranslation('mobile');
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm">
<YStack
width="100%"
maxWidth={520}
borderTopLeftRadius={24}
borderTopRightRadius={24}
backgroundColor="white"
padding="$4"
paddingBottom="$7"
space="$3"
shadowColor="#0f172a"
shadowOpacity={0.12}
shadowRadius={18}
shadowOffset={{ width: 0, height: -8 }}
maxHeight="82vh"
overflow="auto"
// keep sheet above bottom nav / safe area
style={{ marginBottom: `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)` }}
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#111827">
{title}
</Text>
<Pressable onPress={onClose}>
<Text fontSize="$md" color="#6b7280">
{t('actions.close', 'Close')}
</Text>
</Pressable>
</XStack>
{children}
{footer ? footer : null}
</YStack>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { SizableText as Text } from '@tamagui/text';
import { XStack } from '@tamagui/stacks';
export function Tag({ label, color = '#e5e7eb' }: { label: string; color?: string }) {
return (
<XStack
alignItems="center"
paddingHorizontal="$2"
paddingVertical={2}
borderRadius={999}
backgroundColor={`${color}22`}
borderWidth={1}
borderColor={`${color}55`}
alignSelf="flex-start"
>
<Text fontSize={11} fontWeight="600" color="#111827">
{label}
</Text>
</XStack>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useEventContext } from '../../context/EventContext';
import { listGuestNotifications } from '../../api';
/**
* Lightweight badge count for alerts tab.
* Fetches guest notifications for the active event and returns count.
*/
export function useAlertsBadge() {
const { activeEvent } = useEventContext();
const slug = activeEvent?.slug;
const { data: count = 0 } = useQuery<number>({
queryKey: ['mobile', 'alerts', 'badge', slug],
enabled: Boolean(slug),
staleTime: 60_000,
queryFn: async () => {
if (!slug) {
return 0;
}
const alerts = await listGuestNotifications(slug);
return Array.isArray(alerts) ? alerts.length : 0;
},
});
return React.useMemo(() => ({ count }), [count]);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { adminPath } from '../../constants';
import { useEventContext } from '../../context/EventContext';
import { NavKey } from '../components/BottomNav';
export function useMobileNav(currentSlug?: string | null) {
const navigate = useNavigate();
const { activeEvent } = useEventContext();
const slug = currentSlug ?? activeEvent?.slug ?? null;
const go = React.useCallback(
(key: NavKey) => {
if (key === 'events') {
navigate(adminPath('/mobile/events'));
return;
}
if (key === 'tasks') {
if (slug) {
navigate(adminPath(`/mobile/events/${slug}/tasks`));
} else {
navigate(adminPath('/mobile/events'));
}
return;
}
if (key === 'alerts') {
navigate(adminPath('/mobile/alerts'));
return;
}
if (key === 'profile') {
navigate(adminPath('/mobile/profile'));
}
},
[navigate, slug]
);
return { go, slug };
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,10 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AlertTriangle, ArrowRight, CalendarDays, Camera, Heart, Plus } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { FrostedSurface, SectionCard } from '../components/tenant';
import { cn } from '@/lib/utils';
import { AppCard, PrimaryCTA, Segmented, StatusPill, MetaRow, BottomNav } from '../tamagui/primitives';
import { AdminLayout } from '../components/AdminLayout';
import { getEvents, TenantEvent } from '../api';
@@ -86,33 +84,32 @@ export default function EventsPage() {
];
return (
<AdminLayout title={pageTitle}>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<AdminLayout title={pageTitle} disableCommandShelf>
<YStack space="$3" maxWidth={560} marginHorizontal="auto" paddingBottom="$8">
{error ? (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AppCard>
<YStack space="$1">
<Text fontSize="$lg" fontWeight="700" color="$color">
{t('events.list.dashboardTitle', 'All Events Dashboard')}
</Text>
<Text fontSize="$sm" color="$color">
{t('events.list.dashboardSubtitle', 'Schneller Überblick über deine Events')}
</Text>
</YStack>
<Segmented
options={filterOptions.map((opt) => ({ key: opt.key, label: `${opt.label} (${opt.count})` }))}
value={statusFilter}
onChange={(key) => setStatusFilter(key as typeof statusFilter)}
/>
<PrimaryCTA label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
</AppCard>
<SectionCard className="space-y-4">
<div className="flex gap-2 overflow-x-auto pb-1">
{filterOptions.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setStatusFilter(option.key)}
className={cn(
'flex items-center gap-2 rounded-full border px-4 py-1.5 text-xs font-semibold transition',
statusFilter === option.key
? 'border-rose-200 bg-rose-50 text-rose-700 shadow shadow-rose-100/40 dark:border-white/60 dark:bg-white/10 dark:text-white'
: 'border-slate-200 text-slate-600 hover:text-slate-900 dark:border-white/15 dark:text-slate-300 dark:hover:text-white'
)}
>
{option.label}
<span className="text-[11px] text-slate-400 dark:text-slate-500">{option.count}</span>
</button>
))}
</div>
{loading ? (
<LoadingState />
) : filteredRows.length === 0 ? (
@@ -125,7 +122,7 @@ export default function EventsPage() {
onCreate={() => navigate(adminPath('/events/new'))}
/>
) : (
<div className="space-y-3">
<YStack space="$3">
{filteredRows.map((event) => (
<EventCard
key={event.id}
@@ -134,9 +131,21 @@ export default function EventsPage() {
translateCommon={translateCommon}
/>
))}
</div>
</YStack>
)}
</SectionCard>
</YStack>
<BottomNav
active="events"
onNavigate={(key) => {
if (key === 'analytics') {
navigate(adminPath('/dashboard'));
} else if (key === 'settings') {
navigate(adminPath('/settings'));
} else {
navigate(adminPath('/events'));
}
}}
/>
</AdminLayout>
);
}
@@ -158,6 +167,14 @@ function EventCard({
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, undefined, opts)),
[event.limits, translateCommon],
);
const statusLabel = translateCommon(
event.status === 'published'
? 'events.status.published'
: event.status === 'archived'
? 'events.status.archived'
: 'events.status.draft',
event.status === 'published' ? 'Live' : event.status === 'archived' ? 'Archiviert' : 'Entwurf',
);
const metaItems = [
{
key: 'date',
@@ -189,68 +206,71 @@ function EventCard({
];
return (
<FrostedSurface className="space-y-4 rounded-3xl p-5 shadow-lg shadow-rose-100/30 transition hover:-translate-y-0.5 hover:shadow-rose-200/60">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-rose-300/80">{translate('events.list.item.label', 'Event')}</p>
<h3 className="text-xl font-semibold text-slate-900">{renderName(event.name)}</h3>
</div>
<Badge className={isPublished ? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30' : 'bg-slate-200 text-slate-700'}>
{isPublished
? translateCommon('events.status.published', 'Veröffentlicht')
: translateCommon('events.status.draft', 'Entwurf')}
</Badge>
</div>
<AppCard>
<XStack justifyContent="space-between" alignItems="flex-start" space="$3">
<YStack space="$1">
<Text fontSize="$xs" letterSpacing={2.6} textTransform="uppercase" color="$color">
{translate('events.list.item.label', 'Event')}
</Text>
<Text fontSize="$lg" fontWeight="700" color="$color">
{renderName(event.name)}
</Text>
<MetaRow date={formatDate(event.event_date)} location={resolveLocation(event, translate)} status={statusLabel} />
</YStack>
<StatusPill tone={isPublished ? 'success' : 'warning'}>{statusLabel}</StatusPill>
</XStack>
<div className="-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1">
<XStack space="$2" flexWrap="wrap">
{metaItems.map((item) => (
<MetaChip key={item.key} icon={item.icon} label={item.label} value={item.value} />
))}
</div>
</XStack>
{limitWarnings.length > 0 && (
<div className="space-y-2">
{limitWarnings.length > 0 ? (
<YStack space="$2">
{limitWarnings.map((warning) => (
<div
<XStack
key={warning.id}
className={cn(
'flex items-start gap-2 rounded-2xl border p-3 text-xs',
warning.tone === 'danger'
? 'border-rose-200/60 bg-rose-50 text-rose-900'
: 'border-amber-200/60 bg-amber-50 text-amber-900',
)}
space="$2"
alignItems="flex-start"
borderWidth={1}
borderRadius="$tile"
padding="$3"
backgroundColor={warning.tone === 'danger' ? '#fff1f2' : '#fffbeb'}
borderColor={warning.tone === 'danger' ? '#fecdd3' : '#fef3c7'}
>
<AlertTriangle className="h-4 w-4" />
<span>{warning.message}</span>
</div>
<Text fontSize="$xs" color="$color">
{warning.message}
</Text>
</XStack>
))}
</div>
)}
</YStack>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<Button
asChild
className="rounded-full bg-brand-rose text-white shadow shadow-rose-400/40 hover:bg-brand-rose/90"
<XStack space="$2">
<Link
to={ADMIN_EVENT_VIEW_PATH(slug)}
className="flex-1 rounded-xl bg-[#007AFF] px-4 py-3 text-center text-sm font-semibold text-white shadow-sm transition hover:opacity-90"
>
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button asChild variant="outline" className="rounded-full border-rose-200 text-rose-600 hover:bg-rose-50">
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</Button>
</div>
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="inline h-4 w-4" />
</Link>
<Link
to={ADMIN_EVENT_PHOTOS_PATH(slug)}
className="flex-1 rounded-xl border border-slate-200 px-4 py-3 text-center text-sm font-semibold text-[#007AFF] transition hover:bg-slate-50"
>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</XStack>
<div className="flex flex-wrap gap-2">
<XStack flexWrap="wrap" space="$2">
{secondaryLinks.map((action) => (
<ActionChip key={action.key} to={action.to}>
{action.label}
</ActionChip>
))}
</div>
</FrostedSurface>
</XStack>
</AppCard>
);
}
@@ -264,22 +284,23 @@ function MetaChip({
value: string | number;
}) {
return (
<div className="min-w-[55%] snap-center rounded-2xl border border-slate-200 bg-white p-3 text-left text-xs shadow-sm sm:min-w-0 dark:border-white/15 dark:bg-white/10 dark:text-white">
<div className="flex items-center gap-2 text-slate-500 dark:text-slate-300">
<YStack borderWidth={1} borderColor="$muted" borderRadius="$tile" padding="$3" minWidth="45%">
<XStack alignItems="center" space="$2">
{icon}
<span>{label}</span>
</div>
<p className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">{value}</p>
</div>
<Text fontSize="$xs" color="$color">
{label}
</Text>
</XStack>
<Text fontSize="$md" fontWeight="700" color="$color" marginTop="$1">
{value}
</Text>
</YStack>
);
}
function ActionChip({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
className="inline-flex items-center rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700 dark:border-white/15 dark:text-slate-300 dark:hover:border-white/40 dark:hover:bg-white/10 dark:hover:text-white"
>
<Link to={to} className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50">
{children}
</Link>
);
@@ -287,14 +308,11 @@ function ActionChip({ to, children }: { to: string; children: React.ReactNode })
function LoadingState() {
return (
<div className="space-y-3">
<YStack space="$2">
{Array.from({ length: 3 }).map((_, index) => (
<FrostedSurface
key={index}
className="h-24 animate-pulse rounded-3xl bg-gradient-to-r from-white/20 via-white/60 to-white/20"
/>
<AppCard key={index} height={96} opacity={0.6} />
))}
</div>
</YStack>
);
}
@@ -308,21 +326,20 @@ function EmptyState({
onCreate: () => void;
}) {
return (
<FrostedSurface className="flex flex-col items-center justify-center gap-4 border-dashed border-pink-200/70 p-10 text-center">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Plus className="h-5 w-5" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
<p className="text-sm text-slate-600">{description}</p>
</div>
<Button
onClick={onCreate}
className="rounded-full bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-6 text-white shadow-lg shadow-pink-500/20"
>
<Plus className="mr-1 h-4 w-4" /> Event erstellen
</Button>
</FrostedSurface>
<AppCard alignItems="center" justifyContent="center" space="$3" borderStyle="dashed" borderColor="$muted">
<YStack bg="$muted" padding="$3" borderRadius="$pill">
<Plus className="h-5 w-5 text-[#007AFF]" />
</YStack>
<YStack space="$1" alignItems="center">
<Text fontSize="$lg" fontWeight="700" color="$color">
{title}
</Text>
<Text fontSize="$sm" color="$color" textAlign="center">
{description}
</Text>
</YStack>
<PrimaryCTA label="Event erstellen" onPress={onCreate} />
</AppCard>
);
}
@@ -346,3 +363,20 @@ function renderName(name: TenantEvent['name']): string {
}
return 'Unbenanntes Event';
}
function resolveLocation(
event: TenantEvent,
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string,
): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
return translate('events.list.meta.locationFallback', 'Ort folgt');
}

View File

@@ -23,6 +23,18 @@ const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
const EventBrandingPage = React.lazy(() => import('./pages/EventBrandingPage'));
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage'));
const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage'));
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
const MobileAlertsPage = React.lazy(() => import('./mobile/AlertsPage'));
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
const EngagementPage = React.lazy(() => import('./pages/EngagementPage'));
const BillingPage = React.lazy(() => import('./pages/BillingPage'));
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
@@ -92,6 +104,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <LandingGate /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'mobile/login', element: <MobileLoginPage /> },
{ path: 'start', element: <LoginStartPage /> },
{ path: 'logout', element: <LogoutPage /> },
{ path: 'auth/callback', element: <AuthCallbackPage /> },
@@ -112,6 +125,18 @@ export const router = createBrowserRouter([
{ path: 'events/:slug/branding', element: <RequireAdminAccess><EventBrandingPage /></RequireAdminAccess> },
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> },
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
{ path: 'mobile/events', element: <MobileEventsPage /> },
{ path: 'mobile/events/:slug', element: <MobileEventDetailPage /> },
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
{ path: 'mobile/alerts', element: <MobileAlertsPage /> },
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> },
{ path: 'engagement', element: <EngagementPage /> },
{ path: 'tasks', element: <TasksPage /> },
{ path: 'task-collections', element: <TaskCollectionsPage /> },

View File

@@ -0,0 +1,159 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core';
import { Home, BarChart2, Settings } from 'lucide-react';
export function AppCard({ children, padding = '$4', ...rest }: React.ComponentProps<typeof YStack> & { padding?: keyof typeof rest }) {
return (
<YStack
bg="$surface"
borderRadius="$card"
borderWidth={1}
borderColor="$muted"
shadowColor="#0f172a"
shadowOpacity={0.05}
shadowRadius={12}
shadowOffset={{ width: 0, height: 8 }}
padding={padding as any}
space="$3"
{...rest}
>
{children}
</YStack>
);
}
export function StatusPill({ tone = 'muted', children }: { tone?: 'success' | 'warning' | 'muted'; children: React.ReactNode }) {
const colors: Record<typeof tone, { bg: string; color: string; border: string }> = {
success: { bg: '#ecfdf3', color: '#047857', border: '#bbf7d0' },
warning: { bg: '#fffbeb', color: '#92400e', border: '#fef3c7' },
muted: { bg: '#f3f4f6', color: '#374151', border: '#e5e7eb' },
};
const palette = colors[tone] ?? colors.muted;
return (
<XStack
alignItems="center"
paddingHorizontal="$2.5"
paddingVertical="$1"
borderRadius="$pill"
borderWidth={1}
backgroundColor={palette.bg}
borderColor={palette.border}
alignSelf="flex-start"
>
<Text fontSize={11} fontWeight="700" color={palette.color}>
{children}
</Text>
</XStack>
);
}
export function PrimaryCTA({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Button
backgroundColor="$primary"
color="white"
height={56}
borderRadius="$card"
fontWeight="700"
onPress={onPress}
pressStyle={{ opacity: 0.9 }}
>
{label}
</Button>
);
}
export function Segmented({
options,
value,
onChange,
}: {
options: Array<{ key: string; label: string }>;
value: string;
onChange: (key: string) => void;
}) {
return (
<XStack bg="$muted" borderRadius="$pill" borderWidth={1} borderColor="$muted" padding="$1" space="$1">
{options.map((option) => {
const active = option.key === value;
return (
<Pressable key={option.key} onPress={() => onChange(option.key)} style={{ flex: 1 }}>
<YStack
bg={active ? '$primary' : 'transparent'}
borderRadius="$pill"
paddingVertical="$2"
alignItems="center"
justifyContent="center"
>
<Text color={active ? 'white' : '$color'} fontWeight="700" fontSize="$sm">
{option.label}
</Text>
</YStack>
</Pressable>
);
})}
</XStack>
);
}
export function MetaRow({ date, location, status }: { date: string; location: string; status: string }) {
return (
<YStack space="$1">
<Text fontSize="$sm" color="$color">{date}</Text>
<Text fontSize="$sm" color="$color">{location}</Text>
<StatusPill tone="muted">{status}</StatusPill>
</YStack>
);
}
export function BottomNav({
active,
onNavigate,
}: {
active: 'events' | 'analytics' | 'settings';
onNavigate: (key: 'events' | 'analytics' | 'settings') => void;
}) {
const theme = useTheme();
const items = [
{ key: 'events', icon: Home, label: 'Events' },
{ key: 'analytics', icon: BarChart2, label: 'Analytics' },
{ key: 'settings', icon: Settings, label: 'Settings' },
];
return (
<XStack
position="fixed"
bottom={0}
left={0}
right={0}
bg="$background"
borderTopWidth={1}
borderColor="$muted"
padding="$3"
justifyContent="space-around"
shadowColor="#0f172a"
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
zIndex={50}
>
{items.map((item) => {
const activeState = item.key === active;
const IconCmp = item.icon;
return (
<Pressable key={item.key} onPress={() => onNavigate(item.key as typeof active)}>
<YStack alignItems="center" space="$1">
<IconCmp size={20} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#9ca3af'} />
<Text fontSize="$xs" color={activeState ? '$primary' : '$muted'}>
{item.label}
</Text>
</YStack>
</Pressable>
);
})}
</XStack>
);
}