Compare commits

..

2 Commits

Author SHA1 Message Date
Codex Agent
53a90fec33 Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 08:56:44 +01:00
Codex Agent
e1a2850768 Add admin help center entry points 2026-01-23 08:55:37 +01:00
11 changed files with 765 additions and 4 deletions

View File

@@ -17,6 +17,7 @@
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"} {"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"} {"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"} {"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T08:21:47.812129626+01:00"}
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"} {"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"} {"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}

View File

@@ -1 +1 @@
fotospiel-app-5veo fotospiel-app-43mp

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ChevronRight } from 'lucide-react';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { useAdminTheme } from './theme';
import { useBackNavigation } from './hooks/useBackNavigation';
import { adminPath, ADMIN_FAQ_PATH } from '../constants';
import { fetchHelpCenterArticle, type HelpCenterArticle } from '../api';
export default function MobileHelpArticlePage() {
const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation(['management', 'dashboard']);
const theme = useAdminTheme();
const back = useBackNavigation(ADMIN_FAQ_PATH);
const navigate = useNavigate();
const locale = i18n.language;
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['mobile', 'help-article', slug, locale],
enabled: Boolean(slug),
queryFn: () => fetchHelpCenterArticle(slug ?? '', locale),
});
const article: HelpCenterArticle | null = data ?? null;
return (
<MobileShell activeTab="profile" title={article?.title ?? t('common.help', 'Help')} onBack={back}>
{isLoading ? (
<YStack space="$2">
<SkeletonCard height={120} />
<SkeletonCard height={160} />
</YStack>
) : null}
{isError ? (
<MobileCard>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('dashboard:help.error', 'Help could not be loaded.')}
</Text>
<CTAButton
label={t('common.retry', 'Erneut versuchen')}
onPress={() => refetch()}
fullWidth={false}
/>
</YStack>
</MobileCard>
) : null}
{!isLoading && article ? (
<YStack space="$3">
<MobileCard>
<YStack space="$2">
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
{article.title}
</Text>
{article.updated_at ? (
<Text fontSize="$xs" color={theme.muted}>
{t('help.article.updated', 'Aktualisiert')}:{' '}
{new Date(article.updated_at).toLocaleDateString(locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
</Text>
) : null}
<div
className="prose prose-sm max-w-none dark:prose-invert [&_table]:w-full [&_table]:text-sm [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
</YStack>
</MobileCard>
{article.related && article.related.length > 0 ? (
<MobileCard>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('help.article.relatedTitle', 'Weitere Artikel')}
</Text>
<YGroup {...({ borderRadius: '$4', borderWidth: 1, borderColor: theme.border, overflow: 'hidden' } as any)}>
{article.related.map((rel) => (
<YGroup.Item key={rel.slug}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
onPress={() => navigate(adminPath(`/mobile/help/${encodeURIComponent(rel.slug)}`))}
title={
<Text fontSize="$sm" color={theme.textStrong}>
{rel.title ?? rel.slug}
</Text>
}
iconAfter={<ChevronRight size={16} color={theme.muted} />}
/>
</YGroup.Item>
))}
</YGroup>
</YStack>
</MobileCard>
) : null}
</YStack>
) : null}
</MobileShell>
);
}

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ChevronRight, HelpCircle } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { useAdminTheme } from './theme';
import { useBackNavigation } from './hooks/useBackNavigation';
import { adminPath, ADMIN_PROFILE_PATH } from '../constants';
import { fetchHelpCenterArticles, type HelpCenterArticleSummary } from '../api';
const FAQ_SLUGS = new Set(['admin-issue-resolution']);
function isFaqArticle(article: HelpCenterArticleSummary): boolean {
const title = article.title?.toLowerCase() ?? '';
return FAQ_SLUGS.has(article.slug) || article.slug.startsWith('faq-') || title.includes('faq');
}
export default function MobileHelpCenterPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation(['management', 'dashboard']);
const theme = useAdminTheme();
const back = useBackNavigation(ADMIN_PROFILE_PATH);
const locale = i18n.language;
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['mobile', 'help-center', locale],
queryFn: () => fetchHelpCenterArticles(locale),
});
const articles = Array.isArray(data) ? data : [];
const faqArticles = articles.filter(isFaqArticle);
const guideArticles = articles.filter((article) => !isFaqArticle(article));
return (
<MobileShell activeTab="profile" title={t('common.help', 'Help')} onBack={back}>
{isLoading ? (
<YStack space="$2">
<SkeletonCard height={120} />
<SkeletonCard height={120} />
</YStack>
) : null}
{isError ? (
<MobileCard>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('dashboard:help.error', 'Help could not be loaded.')}
</Text>
<CTAButton
label={t('common.retry', 'Erneut versuchen')}
onPress={() => refetch()}
fullWidth={false}
/>
</YStack>
</MobileCard>
) : null}
{!isLoading && !isError ? (
<YStack space="$3">
<HelpSection
title={t('dashboard:help.title', 'FAQ')}
icon={HelpCircle}
items={faqArticles}
emptyLabel={t('dashboard:help.description', 'Noch keine FAQ-Artikel verfügbar.')}
onSelect={(slug) => navigate(adminPath(`/mobile/help/${encodeURIComponent(slug)}`))}
/>
<HelpSection
title={t('dashboard:help.documentationTitle', 'Guides & Dokus')}
items={guideArticles}
emptyLabel={t('dashboard:help.description', 'Noch keine Help-Artikel verfügbar.')}
onSelect={(slug) => navigate(adminPath(`/mobile/help/${encodeURIComponent(slug)}`))}
/>
</YStack>
) : null}
</MobileShell>
);
}
function HelpSection({
title,
items,
emptyLabel,
icon: IconCmp,
onSelect,
}: {
title: string;
items: HelpCenterArticleSummary[];
emptyLabel: string;
icon?: React.ComponentType<{ size?: number; color?: string }>;
onSelect: (slug: string) => void;
}) {
const theme = useAdminTheme();
return (
<MobileCard padding="$0">
<YStack padding="$3" space="$2">
<XStack alignItems="center" space="$2">
{IconCmp ? (
<XStack
width={28}
height={28}
borderRadius={10}
alignItems="center"
justifyContent="center"
backgroundColor={theme.surfaceMuted}
borderWidth={1}
borderColor={theme.border}
>
<IconCmp size={14} color={theme.textStrong} />
</XStack>
) : null}
<Text fontSize="$md" fontWeight="800" color={theme.textStrong}>
{title}
</Text>
</XStack>
{items.length === 0 ? (
<Text fontSize="$sm" color={theme.muted}>
{emptyLabel}
</Text>
) : (
<YGroup {...({ borderRadius: '$4', borderWidth: 1, borderColor: theme.border, overflow: 'hidden' } as any)}>
{items.map((item) => (
<YGroup.Item key={item.slug}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
onPress={() => onSelect(item.slug)}
title={
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{item.title}
</Text>
}
subTitle={
item.summary ? (
<Text fontSize="$xs" color={theme.muted}>
{item.summary}
</Text>
) : undefined
}
iconAfter={<ChevronRight size={16} color={theme.muted} />}
/>
</YGroup.Item>
))}
</YGroup>
)}
</YStack>
</MobileCard>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { User, Settings, Globe, Moon, Download, LogOut } from 'lucide-react'; import { User, Settings, Globe, Moon, Download, LogOut, HelpCircle } from 'lucide-react';
import { Avatar } from '@tamagui/avatar'; import { Avatar } from '@tamagui/avatar';
import { Card } from '@tamagui/card'; import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
@@ -13,7 +13,7 @@ import { CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls'; import { MobileSelect } from './components/FormControls';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api'; import { fetchTenantProfile } from '../api';
import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants'; import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH, ADMIN_FAQ_PATH } from '../constants';
import i18n from '../i18n'; import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
@@ -180,6 +180,21 @@ export default function MobileProfilePage() {
</YGroup.Item> </YGroup.Item>
</> </>
) : null} ) : null}
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('common.help', 'Help')}
</Text>
}
iconAfter={<HelpCircle size={18} color={subtle} />}
onPress={() => navigate(ADMIN_FAQ_PATH)}
/>
</YGroup.Item>
</YGroup> </YGroup>
</YStack> </YStack>
</Card> </Card>

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const article = {
slug: 'tenant-dashboard-overview',
title: 'Dashboard overview',
summary: 'Learn the dashboard.',
body_html: '<p>Welcome</p>',
related: [],
};
vi.mock('@tanstack/react-query', () => ({
useQuery: () => ({
data: article,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock('react-router-dom', () => ({
useParams: () => ({ slug: 'tenant-dashboard-overview' }),
useNavigate: () => vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
i18n: { language: 'de' },
}),
initReactI18next: {
type: '3rdParty',
init: () => undefined,
},
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => vi.fn(),
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
muted: '#6b7280',
border: '#e5e7eb',
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title }: { title?: React.ReactNode }) => <div>{title}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
SkeletonCard: () => <div>Loading...</div>,
}));
import MobileHelpArticlePage from '../HelpArticlePage';
describe('MobileHelpArticlePage', () => {
it('renders article title', async () => {
render(<MobileHelpArticlePage />);
expect(screen.getByText('Dashboard overview')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const articles = [
{ slug: 'admin-issue-resolution', title: 'FAQ & Troubleshooting', summary: 'Common issues.' },
{ slug: 'tenant-dashboard-overview', title: 'Dashboard overview', summary: 'Learn the dashboard.' },
];
vi.mock('@tanstack/react-query', () => ({
useQuery: () => ({
data: articles,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
i18n: { language: 'de' },
}),
initReactI18next: {
type: '3rdParty',
init: () => undefined,
},
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => vi.fn(),
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
textStrong: '#111827',
muted: '#6b7280',
border: '#e5e7eb',
surfaceMuted: '#f3f4f6',
shadow: 'rgba(0,0,0,0.12)',
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title, subTitle }: { title?: React.ReactNode; subTitle?: React.ReactNode }) => (
<div>
{title}
{subTitle}
</div>
),
}));
vi.mock('tamagui', () => ({
Separator: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
SkeletonCard: () => <div>Loading...</div>,
}));
import MobileHelpCenterPage from '../HelpCenterPage';
describe('MobileHelpCenterPage', () => {
it('renders FAQ and guide articles', async () => {
render(<MobileHelpCenterPage />);
expect(screen.getByText('FAQ & Troubleshooting')).toBeInTheDocument();
expect(screen.getByText('Dashboard overview')).toBeInTheDocument();
});
});

View File

@@ -21,6 +21,7 @@ import { countQueuedPhotoActions } from '../lib/queueStatus';
import { useAdminTheme } from '../theme'; import { useAdminTheme } from '../theme';
import { useAuth } from '../../auth/context'; import { useAuth } from '../../auth/context';
import { EventSwitcherSheet } from './EventSwitcherSheet'; import { EventSwitcherSheet } from './EventSwitcherSheet';
import { UserMenuSheet } from './UserMenuSheet';
type MobileShellProps = { type MobileShellProps = {
title?: string; title?: string;
@@ -55,6 +56,7 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
const [attemptedFetch, setAttemptedFetch] = React.useState(false); const [attemptedFetch, setAttemptedFetch] = React.useState(false);
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0); const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0);
const [switcherOpen, setSwitcherOpen] = React.useState(false); const [switcherOpen, setSwitcherOpen] = React.useState(false);
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
const effectiveEvents = events.length ? events : fallbackEvents; const effectiveEvents = events.length ? events : fallbackEvents;
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null); const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
@@ -267,7 +269,7 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
</HeaderActionButton> </HeaderActionButton>
{/* User Avatar */} {/* User Avatar */}
<Pressable onPress={() => navigate(adminPath('/mobile/profile'))}> <Pressable onPress={() => setUserMenuOpen(true)} aria-label={t('mobileProfile.title', 'Profile')}>
<XStack <XStack
width={36} height={36} borderRadius={18} width={36} height={36} borderRadius={18}
backgroundColor={actionSurface} backgroundColor={actionSurface}
@@ -369,6 +371,13 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
events={effectiveEvents} events={effectiveEvents}
activeSlug={effectiveActive?.slug ?? null} activeSlug={effectiveActive?.slug ?? null}
/> />
<UserMenuSheet
open={userMenuOpen}
onClose={() => setUserMenuOpen(false)}
user={user}
isMember={isMember}
navigate={navigate}
/>
</YStack> </YStack>
); );
} }

View File

@@ -0,0 +1,266 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight, CreditCard, FileText, HelpCircle, User, X } from 'lucide-react';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { ListItem } from '@tamagui/list-item';
import { YGroup } from '@tamagui/group';
import { Switch } from '@tamagui/switch';
import { Separator } from 'tamagui';
import { useAppearance } from '@/hooks/use-appearance';
import { ADMIN_BILLING_PATH, ADMIN_DATA_EXPORTS_PATH, ADMIN_FAQ_PATH, ADMIN_PROFILE_ACCOUNT_PATH, adminPath } from '../../constants';
import { useAdminTheme } from '../theme';
import { MobileSelect } from './FormControls';
type UserMenuSheetProps = {
open: boolean;
onClose: () => void;
user?: { name?: string | null; email?: string | null; avatar_url?: string | null };
isMember: boolean;
navigate: (path: string) => void;
};
const MENU_WIDTH = 320;
export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserMenuSheetProps) {
const { t, i18n } = useTranslation('management');
const theme = useAdminTheme();
const { appearance, resolved, updateAppearance } = useAppearance();
const [language, setLanguage] = React.useState<string>(() => (i18n.language?.startsWith('en') ? 'en' : 'de'));
React.useEffect(() => {
setLanguage(i18n.language?.startsWith('en') ? 'en' : 'de');
}, [i18n.language]);
const isDark = resolved === 'dark';
const handleNavigate = (path: string) => {
onClose();
navigate(path);
};
const menuItems = [
{
label: t('mobileProfile.account', 'Account bearbeiten'),
icon: User,
path: ADMIN_PROFILE_ACCOUNT_PATH,
show: true,
},
{
label: t('billing.sections.packages.title', 'Pakete'),
icon: CreditCard,
path: adminPath('/mobile/billing#packages'),
show: !isMember,
},
{
label: t('billing.sections.invoices.title', 'Rechnungen & Zahlungen'),
icon: CreditCard,
path: adminPath('/mobile/billing#invoices'),
show: !isMember,
},
{
label: t('dataExports.title', 'Datenexporte'),
icon: FileText,
path: ADMIN_DATA_EXPORTS_PATH,
show: !isMember,
},
{
label: t('common.help', 'Help'),
icon: HelpCircle,
path: ADMIN_FAQ_PATH,
show: true,
},
].filter((item) => item.show);
return (
<YStack
position="fixed"
top={0}
bottom={0}
left={0}
right={0}
zIndex={100000}
pointerEvents={open ? 'auto' : 'none'}
>
<Pressable
onPress={onClose}
style={{
position: 'absolute',
inset: 0,
backgroundColor: theme.overlay,
opacity: open ? 1 : 0,
transition: 'opacity 180ms ease',
}}
/>
<YStack
position="absolute"
top={0}
bottom={0}
right={0}
width="86vw"
maxWidth={MENU_WIDTH}
backgroundColor={theme.surface}
borderLeftWidth={1}
borderColor={theme.border}
padding="$4"
space="$3"
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 220ms ease',
boxShadow: `-12px 0 24px ${theme.glassShadow ?? theme.shadow}`,
}}
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={theme.textStrong}>
{t('mobileProfile.title', 'Profil')}
</Text>
<Pressable onPress={onClose} aria-label={t('common.close', 'Close')}>
<XStack
width={32}
height={32}
borderRadius={10}
alignItems="center"
justifyContent="center"
backgroundColor={theme.surfaceMuted}
borderWidth={1}
borderColor={theme.border}
>
<X size={16} color={theme.muted} />
</XStack>
</Pressable>
</XStack>
<XStack alignItems="center" space="$3">
<XStack
width={48}
height={48}
borderRadius={16}
alignItems="center"
justifyContent="center"
backgroundColor={theme.accentSoft}
>
<Text fontSize="$lg" fontWeight="800" color={theme.primary}>
{user?.name?.charAt(0)?.toUpperCase() ?? 'U'}
</Text>
</XStack>
<YStack>
<Text fontSize="$sm" fontWeight="800" color={theme.textStrong}>
{user?.name ?? t('events.members.roles.guest', 'Guest')}
</Text>
<Text fontSize="$xs" color={theme.muted}>
{user?.email ?? ''}
</Text>
</YStack>
</XStack>
<Separator backgroundColor={theme.border} opacity={0.6} />
<YStack space="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{t('mobileProfile.settings', 'Einstellungen')}
</Text>
<YGroup {...({ borderRadius: '$4', borderWidth: 1, borderColor: theme.border, overflow: 'hidden' } as any)}>
{menuItems.map((item) => (
<YGroup.Item key={item.label}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
onPress={() => handleNavigate(item.path)}
title={
<XStack alignItems="center" space="$2">
<XStack
width={28}
height={28}
borderRadius={10}
alignItems="center"
justifyContent="center"
backgroundColor={theme.surfaceMuted}
borderWidth={1}
borderColor={theme.border}
>
<item.icon size={14} color={theme.textStrong} />
</XStack>
<Text fontSize="$sm" color={theme.textStrong}>
{item.label}
</Text>
</XStack>
}
iconAfter={<ChevronRight size={16} color={theme.muted} />}
/>
</YGroup.Item>
))}
</YGroup>
</YStack>
<YStack space="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{t('settings.appearance.title', 'Darstellung')}
</Text>
<YGroup {...({ borderRadius: '$4', borderWidth: 1, borderColor: theme.border, overflow: 'hidden' } as any)}>
<YGroup.Item>
<ListItem
paddingVertical="$2"
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<Text fontSize="$sm" color={theme.textStrong}>
{t('mobileProfile.language', 'Sprache')}
</Text>
</XStack>
}
iconAfter={
<MobileSelect
value={language}
onChange={(event) => {
const lng = event.target.value;
setLanguage(lng);
void i18n.changeLanguage(lng);
}}
compact
style={{ minWidth: 120 }}
>
<option value="de">{t('mobileProfile.languageDe', 'Deutsch')}</option>
<option value="en">{t('mobileProfile.languageEn', 'English')}</option>
</MobileSelect>
}
/>
</YGroup.Item>
<YGroup.Item>
<ListItem
paddingVertical="$2"
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<Text fontSize="$sm" color={theme.textStrong}>
{t('mobileProfile.theme', 'Dark Mode')}
</Text>
</XStack>
}
iconAfter={
<Switch
size="$2"
checked={isDark}
onCheckedChange={(next) => updateAppearance(next ? 'dark' : 'light')}
aria-label={t('mobileProfile.theme', 'Dark Mode')}
>
<Switch.Thumb />
</Switch>
}
/>
</YGroup.Item>
</YGroup>
{appearance === 'system' ? (
<Text fontSize="$xs" color={theme.muted}>
{t('mobileProfile.themeSystem', 'System')}
</Text>
) : null}
</YStack>
</YStack>
</YStack>
);
}

View File

@@ -37,11 +37,24 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
), ),
})); }));
vi.mock('tamagui', () => ({
Separator: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Tabs: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
List: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('../BottomNav', () => ({ vi.mock('../BottomNav', () => ({
BottomNav: () => <div data-testid="bottom-nav" />, BottomNav: () => <div data-testid="bottom-nav" />,
NavKey: {}, NavKey: {},
})); }));
vi.mock('../UserMenuSheet', () => ({
UserMenuSheet: () => <div data-testid="user-menu-sheet" />,
}));
const eventContext = { const eventContext = {
events: [], events: [],
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} }, activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },

View File

@@ -41,6 +41,8 @@ const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage')); const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage'));
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage')); const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
const MobilePublicHelpPage = React.lazy(() => import('./mobile/PublicHelpPage')); const MobilePublicHelpPage = React.lazy(() => import('./mobile/PublicHelpPage'));
const MobileHelpCenterPage = React.lazy(() => import('./mobile/HelpCenterPage'));
const MobileHelpArticlePage = React.lazy(() => import('./mobile/HelpArticlePage'));
const MobileForgotPasswordPage = React.lazy(() => import('./mobile/ForgotPasswordPage')); const MobileForgotPasswordPage = React.lazy(() => import('./mobile/ForgotPasswordPage'));
const MobileResetPasswordPage = React.lazy(() => import('./mobile/ResetPasswordPage')); const MobileResetPasswordPage = React.lazy(() => import('./mobile/ResetPasswordPage'));
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage')); const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
@@ -214,6 +216,8 @@ export const router = createBrowserRouter([
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> }, { path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
{ path: 'mobile/profile', element: <MobileProfilePage /> }, { path: 'mobile/profile', element: <MobileProfilePage /> },
{ path: 'mobile/profile/account', element: <MobileProfileAccountPage /> }, { path: 'mobile/profile/account', element: <MobileProfileAccountPage /> },
{ path: 'mobile/help', element: <MobileHelpCenterPage /> },
{ path: 'mobile/help/:slug', element: <MobileHelpArticlePage /> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> }, { path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
{ path: 'mobile/billing/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> }, { path: 'mobile/billing/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> },
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> }, { path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },