Files
fotospiel-app/resources/js/guest-v2/screens/HelpCenterScreen.tsx
Codex Agent 298a8375b6
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest v2 branding and theming
2026-02-03 15:18:44 +01:00

182 lines
6.9 KiB
TypeScript

import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Input } from '@tamagui/input';
import { Loader2, RefreshCcw, Search } from 'lucide-react';
import AppShell from '../components/AppShell';
import StandaloneShell from '../components/StandaloneShell';
import SurfaceCard from '../components/SurfaceCard';
import PullToRefresh from '@/guest/components/PullToRefresh';
import { getHelpArticles, type HelpArticleSummary } from '@/guest/services/helpApi';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import EventLogo from '../components/EventLogo';
import { useGuestThemeVariant } from '../lib/guestTheme';
export default function HelpCenterScreen() {
const params = useParams<{ token?: string }>();
const { locale } = useLocale();
const { t } = useTranslation();
const { isDark } = useGuestThemeVariant();
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [articles, setArticles] = React.useState<HelpArticleSummary[]>([]);
const [query, setQuery] = React.useState('');
const [state, setState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('loading');
const [servedFromCache, setServedFromCache] = React.useState(false);
const [isOnline, setIsOnline] = React.useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true));
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const brandName = 'Fotospiel';
const showStandaloneHeader = !params.token;
const loadArticles = React.useCallback(async (forceRefresh = false) => {
setState('loading');
try {
const result = await getHelpArticles(locale, { forceRefresh });
setArticles(result.articles);
setServedFromCache(result.servedFromCache);
setState('ready');
} catch (error) {
console.error('[HelpCenter] Failed to load articles', error);
setState('error');
}
}, [locale]);
React.useEffect(() => {
loadArticles();
}, [loadArticles]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const showOfflineBadge = servedFromCache && !isOnline;
const filteredArticles = React.useMemo(() => {
if (!query.trim()) return articles;
const needle = query.trim().toLowerCase();
return articles.filter((article) => `${article.title} ${article.summary}`.toLowerCase().includes(needle));
}, [articles, query]);
const wrapper = (
<PullToRefresh
onRefresh={() => loadArticles(true)}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<YStack gap="$4">
<SurfaceCard glow>
<XStack alignItems="center" gap="$3">
{showStandaloneHeader ? <EventLogo name={brandName} size="s" /> : null}
<YStack gap="$1">
<Text fontSize="$5" fontWeight="$8">
{t('help.center.title')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('help.center.subtitle')}
</Text>
</YStack>
</XStack>
</SurfaceCard>
<SurfaceCard>
<XStack gap="$2" alignItems="center">
<Search size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Input
flex={1}
placeholder={t('help.center.searchPlaceholder')}
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
aria-label={t('help.center.searchPlaceholder')}
/>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={() => loadArticles(true)}
disabled={state === 'loading'}
>
{state === 'loading' ? <Loader2 size={16} className="animate-spin" /> : <RefreshCcw size={16} />}
</Button>
</XStack>
{showOfflineBadge ? (
<YStack marginTop="$3">
<Text fontSize="$2" color={mutedText}>
{t('help.center.offlineDescription')}
</Text>
</YStack>
) : null}
</SurfaceCard>
<YStack gap="$3">
<Text fontSize="$3" fontWeight="$7">
{t('help.center.listTitle')}
</Text>
{state === 'loading' ? (
<SurfaceCard>
<XStack alignItems="center" gap="$2">
<Loader2 size={16} className="animate-spin" />
<Text fontSize="$2" color={mutedText}>
{t('common.actions.loading')}
</Text>
</XStack>
</SurfaceCard>
) : null}
{state === 'error' ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('help.center.error')}
</Text>
<Button size="$3" borderRadius="$pill" marginTop="$2" onPress={() => loadArticles(false)}>
{t('help.center.retry')}
</Button>
</SurfaceCard>
) : null}
{state === 'ready' && filteredArticles.length === 0 ? (
<SurfaceCard>
<Text fontSize="$2" color={mutedText}>
{t('help.center.empty')}
</Text>
</SurfaceCard>
) : null}
{state === 'ready' && filteredArticles.length > 0 ? (
<YStack gap="$3">
{filteredArticles.map((article) => (
<SurfaceCard key={article.slug} padding="$3">
<Link to={`${basePath}/${encodeURIComponent(article.slug)}`}>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7">
{article.title}
</Text>
<Text fontSize="$2" color={mutedText}>
{article.summary}
</Text>
</YStack>
</Link>
</SurfaceCard>
))}
</YStack>
) : null}
</YStack>
</YStack>
</PullToRefresh>
);
if (params.token) {
return <AppShell>{wrapper}</AppShell>;
}
return <StandaloneShell>{wrapper}</StandaloneShell>;
}