246 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|