added a help system, replaced the words "tenant" and "Pwa" with better alternatives. corrected and implemented cron jobs. prepared going live on a coolify-powered system.
This commit is contained in:
@@ -1,80 +1,245 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, RefreshCcw } from 'lucide-react';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
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 } = useTranslation('dashboard');
|
||||
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 entries = [
|
||||
{
|
||||
question: t('faq.events.question', 'Wie arbeite ich mit Events?'),
|
||||
answer: t(
|
||||
'faq.events.answer',
|
||||
'Wähle dein aktives Event, passe Aufgaben an und lade Gäste über die Einladungsseite ein. Weitere Dokumentation folgt bald.'
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t('faq.uploads.question', 'Wie moderiere ich Uploads?'),
|
||||
answer: t(
|
||||
'faq.uploads.answer',
|
||||
'Sobald Fotos eintreffen, findest du sie in der Galerie-Ansicht deines Events. Von dort kannst du sie freigeben oder zurückweisen.'
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t('faq.support.question', 'Wo erhalte ich Support?'),
|
||||
answer: t(
|
||||
'faq.support.answer',
|
||||
'Dieses FAQ dient als Platzhalter. Bitte nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank veröffentlicht wird.'
|
||||
),
|
||||
},
|
||||
];
|
||||
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('faq.title', 'FAQ & Hilfe')}
|
||||
subtitle={t('faq.subtitle', 'Antworten und Hinweise rund um den Tenant Admin.')}
|
||||
title={t('helpCenter.title', 'Hilfe & Dokumentation')}
|
||||
subtitle={t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}
|
||||
>
|
||||
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('faq.intro.title', 'Was dich erwartet')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'faq.intro.description',
|
||||
'Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt.'
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.question} className="rounded-2xl border border-slate-200/80 p-4 dark:border-white/10">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{entry.question}</p>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{entry.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-2xl bg-rose-50/70 p-4 text-sm text-rose-900 dark:bg-rose-200/10 dark:text-rose-100">
|
||||
<p className="font-semibold">
|
||||
{t('faq.cta.needHelp', 'Fehlt dir etwas?')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{t(
|
||||
'faq.cta.description',
|
||||
'Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail – wir erweitern dieses FAQ mit deinen Themen.'
|
||||
<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>
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-3 rounded-full"
|
||||
onClick={() => window.open('mailto:hello@fotospiel.app', '_blank')}
|
||||
>
|
||||
{t('faq.cta.contact', 'Support kontaktieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user