154 lines
5.8 KiB
TypeScript
154 lines
5.8 KiB
TypeScript
import React from 'react';
|
|
import { Link, useParams } from 'react-router-dom';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Loader2, RefreshCcw } from 'lucide-react';
|
|
import { Page } from './_util';
|
|
import { useLocale } from '../i18n/LocaleContext';
|
|
import { useTranslation } from '../i18n/useTranslation';
|
|
import { getHelpArticles, type HelpArticleSummary } from '../services/helpApi';
|
|
|
|
export default function HelpCenterPage() {
|
|
const params = useParams<{ token?: string }>();
|
|
const { locale } = useLocale();
|
|
const { t } = useTranslation();
|
|
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 basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
|
|
|
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]);
|
|
|
|
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]);
|
|
|
|
return (
|
|
<Page title={t('help.center.title')}>
|
|
<p className="mb-4 text-sm text-muted-foreground">{t('help.center.subtitle')}</p>
|
|
|
|
<div className="flex flex-col gap-2 rounded-xl border border-border/60 bg-background/50 p-3">
|
|
<div className="flex flex-col gap-3 sm:flex-row">
|
|
<Input
|
|
placeholder={t('help.center.searchPlaceholder')}
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
className="flex-1"
|
|
aria-label={t('help.center.searchPlaceholder')}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
className="sm:w-auto"
|
|
onClick={() => loadArticles(true)}
|
|
disabled={state === 'loading'}
|
|
>
|
|
{state === 'loading' ? (
|
|
<span className="flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t('common.actions.loading')}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-2">
|
|
<RefreshCcw className="h-4 w-4" />
|
|
{t('help.center.retry')}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{servedFromCache && (
|
|
<div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200">
|
|
<Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100">
|
|
{t('help.center.offlineBadge')}
|
|
</Badge>
|
|
<span>{t('help.center.offlineDescription')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<section className="mt-6 space-y-4">
|
|
<h2 className="text-base font-semibold text-foreground">{t('help.center.listTitle')}</h2>
|
|
{state === 'loading' && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t('common.actions.loading')}
|
|
</div>
|
|
)}
|
|
{state === 'error' && (
|
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
|
<p>{t('help.center.error')}</p>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="mt-3"
|
|
onClick={() => loadArticles(false)}
|
|
>
|
|
{t('help.center.retry')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{state === 'ready' && filteredArticles.length === 0 && (
|
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
|
|
{t('help.center.empty')}
|
|
</div>
|
|
)}
|
|
{state === 'ready' && filteredArticles.length > 0 && (
|
|
<div className="space-y-3">
|
|
{filteredArticles.map((article) => (
|
|
<Link
|
|
key={article.slug}
|
|
to={`${basePath}/${encodeURIComponent(article.slug)}`}
|
|
className="block rounded-2xl border border-border/60 bg-card/70 p-4 transition-colors hover:border-primary/60"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-foreground">{article.title}</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground line-clamp-3">{article.summary}</p>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{article.updated_at ? formatDate(article.updated_at, locale) : ''}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
function formatDate(value: string, locale: string): string {
|
|
try {
|
|
return new Date(value).toLocaleDateString(locale, {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
});
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|