Compare commits
2 Commits
03ee16bb87
...
53a90fec33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a90fec33 | ||
|
|
e1a2850768 |
@@ -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-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-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-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)"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-5veo
|
||||
fotospiel-app-43mp
|
||||
|
||||
114
resources/js/admin/mobile/HelpArticlePage.tsx
Normal file
114
resources/js/admin/mobile/HelpArticlePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
resources/js/admin/mobile/HelpCenterPage.tsx
Normal file
158
resources/js/admin/mobile/HelpCenterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { Card } from '@tamagui/card';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
@@ -13,7 +13,7 @@ import { CTAButton } from './components/Primitives';
|
||||
import { MobileSelect } from './components/FormControls';
|
||||
import { useAuth } from '../auth/context';
|
||||
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 { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
@@ -180,6 +180,21 @@ export default function MobileProfilePage() {
|
||||
</YGroup.Item>
|
||||
</>
|
||||
) : 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>
|
||||
</YStack>
|
||||
</Card>
|
||||
|
||||
86
resources/js/admin/mobile/__tests__/HelpArticlePage.test.tsx
Normal file
86
resources/js/admin/mobile/__tests__/HelpArticlePage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
95
resources/js/admin/mobile/__tests__/HelpCenterPage.test.tsx
Normal file
95
resources/js/admin/mobile/__tests__/HelpCenterPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { countQueuedPhotoActions } from '../lib/queueStatus';
|
||||
import { useAdminTheme } from '../theme';
|
||||
import { useAuth } from '../../auth/context';
|
||||
import { EventSwitcherSheet } from './EventSwitcherSheet';
|
||||
import { UserMenuSheet } from './UserMenuSheet';
|
||||
|
||||
type MobileShellProps = {
|
||||
title?: string;
|
||||
@@ -55,6 +56,7 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
|
||||
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
|
||||
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0);
|
||||
const [switcherOpen, setSwitcherOpen] = React.useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
|
||||
|
||||
const effectiveEvents = events.length ? events : fallbackEvents;
|
||||
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
|
||||
@@ -267,7 +269,7 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
|
||||
</HeaderActionButton>
|
||||
|
||||
{/* User Avatar */}
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/profile'))}>
|
||||
<Pressable onPress={() => setUserMenuOpen(true)} aria-label={t('mobileProfile.title', 'Profile')}>
|
||||
<XStack
|
||||
width={36} height={36} borderRadius={18}
|
||||
backgroundColor={actionSurface}
|
||||
@@ -369,6 +371,13 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
|
||||
events={effectiveEvents}
|
||||
activeSlug={effectiveActive?.slug ?? null}
|
||||
/>
|
||||
<UserMenuSheet
|
||||
open={userMenuOpen}
|
||||
onClose={() => setUserMenuOpen(false)}
|
||||
user={user}
|
||||
isMember={isMember}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
266
resources/js/admin/mobile/components/UserMenuSheet.tsx
Normal file
266
resources/js/admin/mobile/components/UserMenuSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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', () => ({
|
||||
BottomNav: () => <div data-testid="bottom-nav" />,
|
||||
NavKey: {},
|
||||
}));
|
||||
|
||||
vi.mock('../UserMenuSheet', () => ({
|
||||
UserMenuSheet: () => <div data-testid="user-menu-sheet" />,
|
||||
}));
|
||||
|
||||
const eventContext = {
|
||||
events: [],
|
||||
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
|
||||
|
||||
@@ -41,6 +41,8 @@ const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
|
||||
const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage'));
|
||||
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
|
||||
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 MobileResetPasswordPage = React.lazy(() => import('./mobile/ResetPasswordPage'));
|
||||
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
|
||||
@@ -214,6 +216,8 @@ export const router = createBrowserRouter([
|
||||
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
|
||||
{ path: 'mobile/profile', element: <MobileProfilePage /> },
|
||||
{ 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/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
|
||||
|
||||
Reference in New Issue
Block a user