Files
fotospiel-app/resources/js/admin/pages/FaqPage.tsx

246 lines
10 KiB
TypeScript

import React from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, RefreshCcw } from 'lucide-react';
import { AdminLayout } from '../components/AdminLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { HelpCenterArticleSummary, HelpCenterArticle } from '../api';
import { fetchHelpCenterArticles, fetchHelpCenterArticle } from '../api';
function normalizeLocale(language: string | undefined): 'de' | 'en' {
const normalized = (language ?? 'de').toLowerCase().split('-')[0];
return normalized === 'en' ? 'en' : 'de';
}
export default function FaqPage() {
const { t, i18n } = useTranslation('dashboard');
const helpLocale = normalizeLocale(i18n.language);
const [query, setQuery] = React.useState('');
const [articles, setArticles] = React.useState<HelpCenterArticleSummary[]>([]);
const [listState, setListState] = React.useState<'loading' | 'ready' | 'error'>('loading');
const [selectedSlug, setSelectedSlug] = React.useState<string | null>(null);
const [detailState, setDetailState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [articleCache, setArticleCache] = React.useState<Record<string, HelpCenterArticle>>({});
const loadArticles = React.useCallback(async () => {
setListState('loading');
try {
const data = await fetchHelpCenterArticles(helpLocale);
setArticles(data);
setListState('ready');
} catch (error) {
console.error('[HelpCenter] Failed to load list', error);
setListState('error');
}
}, [helpLocale]);
React.useEffect(() => {
setArticles([]);
setArticleCache({});
setSelectedSlug(null);
loadArticles();
}, [loadArticles]);
React.useEffect(() => {
if (!selectedSlug && articles.length > 0) {
setSelectedSlug(articles[0].slug);
} else if (selectedSlug && !articles.some((article) => article.slug === selectedSlug)) {
setSelectedSlug(articles[0]?.slug ?? null);
}
}, [articles, selectedSlug]);
const loadArticle = React.useCallback(async (slug: string, options?: { bypassCache?: boolean }) => {
if (!slug) {
return;
}
const bypassCache = options?.bypassCache ?? false;
if (!bypassCache && articleCache[slug]) {
setDetailState('ready');
return;
}
setDetailState('loading');
try {
const article = await fetchHelpCenterArticle(slug, helpLocale);
setArticleCache((prev) => ({ ...prev, [slug]: article }));
setDetailState('ready');
} catch (error) {
console.error('[HelpCenter] Failed to load article', error);
setDetailState('error');
}
}, [articleCache, helpLocale]);
React.useEffect(() => {
if (selectedSlug) {
loadArticle(selectedSlug);
}
}, [selectedSlug, loadArticle]);
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 activeArticle = selectedSlug ? articleCache[selectedSlug] : null;
return (
<AdminLayout
title={t('helpCenter.title', 'Hilfe & Dokumentation')}
subtitle={t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}
>
<div className="grid gap-6 lg:grid-cols-[320px,1fr]">
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{t('helpCenter.title', 'Hilfe & Dokumentation')}</CardTitle>
<CardDescription>{t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder={t('helpCenter.search.placeholder', 'Suche nach Thema')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div>
{listState === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('helpCenter.article.loading', 'Lädt...')}
</div>
)}
{listState === 'error' && (
<div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
<p>{t('helpCenter.list.error')}</p>
<Button variant="secondary" size="sm" onClick={loadArticles}>
<span className="flex items-center gap-2">
<RefreshCcw className="h-4 w-4" />
{t('helpCenter.list.retry')}
</span>
</Button>
</div>
)}
{listState === 'ready' && filteredArticles.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-200/80 p-4 text-sm text-muted-foreground dark:border-white/10">
{t('helpCenter.list.empty')}
</div>
)}
{listState === 'ready' && filteredArticles.length > 0 && (
<div className="space-y-2">
{filteredArticles.map((article) => {
const isActive = selectedSlug === article.slug;
return (
<button
key={article.slug}
type="button"
onClick={() => setSelectedSlug(article.slug)}
className={`w-full rounded-xl border p-3 text-left transition-colors ${
isActive
? 'border-sky-500 bg-sky-500/5 shadow-sm'
: 'border-slate-200/80 hover:border-sky-400/70'
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-slate-900 dark:text-white">{article.title}</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300 line-clamp-2">{article.summary}</p>
</div>
{article.updated_at && (
<span className="text-xs text-slate-400 dark:text-slate-500">
{t('helpCenter.list.updated', { date: formatDate(article.updated_at, i18n.language) })}
</span>
)}
</div>
</button>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
<Card className="border-slate-200 bg-white/95 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{activeArticle?.title ?? t('helpCenter.article.placeholder')}</CardTitle>
<CardDescription className="text-sm text-slate-500 dark:text-slate-300">
{activeArticle?.summary ?? ''}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!selectedSlug && (
<p className="text-sm text-muted-foreground">{t('helpCenter.article.placeholder')}</p>
)}
{selectedSlug && detailState === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('helpCenter.article.loading')}
</div>
)}
{selectedSlug && detailState === 'error' && (
<div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
<p>{t('helpCenter.article.error')}</p>
<Button size="sm" variant="secondary" onClick={() => loadArticle(selectedSlug, { bypassCache: true })}>
{t('helpCenter.list.retry')}
</Button>
</div>
)}
{detailState === 'ready' && activeArticle && (
<div className="space-y-6">
{activeArticle.updated_at && (
<Badge variant="outline" className="border-slate-200 text-xs font-normal text-slate-600 dark:border-white/20 dark:text-slate-300">
{t('helpCenter.article.updated', { date: formatDate(activeArticle.updated_at, i18n.language) })}
</Badge>
)}
<div
className="prose prose-sm max-w-none text-slate-700 dark:prose-invert"
dangerouslySetInnerHTML={{ __html: activeArticle.body_html ?? activeArticle.body_markdown ?? '' }}
/>
{activeArticle.related && activeArticle.related.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
{t('helpCenter.article.related')}
</h3>
<div className="flex flex-wrap gap-2">
{activeArticle.related.map((rel) => (
<Button
key={rel.slug}
variant="outline"
size="sm"
onClick={() => setSelectedSlug(rel.slug)}
>
{rel.slug}
</Button>
))}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</AdminLayout>
);
}
function formatDate(value: string, language: string | undefined): string {
try {
return new Date(value).toLocaleDateString(language ?? 'de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch (error) {
return value;
}
}