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:
Codex Agent
2025-11-10 16:23:09 +01:00
parent ba9e64dfcb
commit 447a90a742
123 changed files with 6398 additions and 153 deletions

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
export type GalleryFilter = 'latest' | 'popular' | 'mine';
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
export default function FiltersBar({ value, onChange }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void }) {
return (
@@ -10,8 +10,8 @@ export default function FiltersBar({ value, onChange }: { value: GalleryFilter;
<ToggleGroupItem value="latest">Neueste</ToggleGroupItem>
<ToggleGroupItem value="popular">Beliebt</ToggleGroupItem>
<ToggleGroupItem value="mine">Meine</ToggleGroupItem>
<ToggleGroupItem value="photobooth">Fotobox</ToggleGroupItem>
</ToggleGroup>
</div>
);
}

View File

@@ -6,17 +6,21 @@ import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
type Props = { token: string };
type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
export default function GalleryPreview({ token }: Props) {
const { photos, loading } = usePollGalleryDelta(token);
const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest');
const [mode, setMode] = React.useState<PreviewFilter>('latest');
const items = React.useMemo(() => {
let arr = photos.slice();
// MyPhotos filter (requires session_id matching)
if (mode === 'myphotos') {
if (mode === 'mine') {
const deviceId = getDeviceId();
arr = arr.filter((photo: any) => photo.session_id === deviceId);
} else if (mode === 'photobooth') {
arr = arr.filter((photo: any) => photo.ingest_source === 'photobooth');
}
// Sorting
@@ -71,15 +75,25 @@ export default function GalleryPreview({ token }: Props) {
Popular
</button>
<button
onClick={() => setMode('myphotos')}
onClick={() => setMode('mine')}
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
mode === 'myphotos'
mode === 'mine'
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
}`}
>
My Photos
</button>
<button
onClick={() => setMode('photobooth')}
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
mode === 'photobooth'
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
}`}
>
Fotobox
</button>
</div>
<Link to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
Alle ansehen

View File

@@ -1,4 +1,5 @@
import React from "react";
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -13,7 +14,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react';
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle, LifeBuoy } from 'lucide-react';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { LegalMarkdown } from './legal-markdown';
import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext';
@@ -48,11 +49,13 @@ export function SettingsSheet() {
const identity = useOptionalGuestIdentity();
const localeContext = useLocale();
const { t } = useTranslation();
const params = useParams<{ token?: string }>();
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
const [savingName, setSavingName] = React.useState(false);
const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
const helpHref = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
React.useEffect(() => {
if (open && identity?.hydrated) {
@@ -170,6 +173,7 @@ export function SettingsSheet() {
nameStatus={nameStatus}
localeContext={localeContext}
onOpenLegal={handleOpenLegal}
helpHref={helpHref}
/>
)}
</main>
@@ -241,6 +245,7 @@ interface HomeViewProps {
slug: (typeof legalPages)[number]['slug'],
translationKey: (typeof legalPages)[number]['translationKey'],
) => void;
helpHref: string;
}
function HomeView({
@@ -254,6 +259,7 @@ function HomeView({
nameStatus,
localeContext,
onOpenLegal,
helpHref,
}: HomeViewProps) {
const { t } = useTranslation();
const legalLinks = React.useMemo(
@@ -374,6 +380,23 @@ function HomeView({
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>
<div className="flex items-center gap-2">
<LifeBuoy className="h-4 w-4 text-pink-500" />
{t('settings.help.title')}
</div>
</CardTitle>
<CardDescription>{t('settings.help.description')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link to={helpHref}>{t('settings.help.cta')}</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('settings.cache.title')}</CardTitle>

View File

@@ -430,6 +430,11 @@ export const messages: Record<LocaleCode, NestedMessages> = {
cleared: 'Cache gelöscht.',
note: 'Dies betrifft nur diesen Browser und muss pro Gerät erneut ausgeführt werden.',
},
help: {
title: 'Hilfe & Support',
description: 'Öffne das Hilfecenter mit Schritt-für-Schritt-Anleitungen.',
cta: 'Hilfecenter öffnen',
},
footer: {
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
},
@@ -439,6 +444,26 @@ export const messages: Record<LocaleCode, NestedMessages> = {
legalDescription: 'Rechtlicher Hinweis',
},
},
help: {
center: {
title: 'Hilfe & Tipps',
subtitle: 'Antworten für Gäste nach dem ersten Laden auch offline verfügbar.',
searchPlaceholder: 'Suche nach Thema oder Stichwort',
offlineBadge: 'Offline-Version',
offlineDescription: 'Du siehst eine zwischengespeicherte Version. Geh online für aktuelle Inhalte.',
empty: 'Keine Artikel gefunden.',
error: 'Hilfe konnte nicht geladen werden.',
retry: 'Erneut versuchen',
listTitle: 'Alle Artikel',
},
article: {
back: 'Zurück zur Übersicht',
updated: 'Aktualisiert am {date}',
relatedTitle: 'Verwandte Artikel',
unavailable: 'Dieser Artikel ist nicht verfügbar.',
reload: 'Neu laden',
},
},
},
en: {
common: {
@@ -858,6 +883,11 @@ export const messages: Record<LocaleCode, NestedMessages> = {
cleared: 'Cache cleared.',
note: 'This only affects this browser and must be repeated per device.',
},
help: {
title: 'Help & support',
description: 'Open the help center for guides and quick answers.',
cta: 'Open help center',
},
footer: {
notice: 'Guest area - data is stored locally in the browser.',
},
@@ -867,6 +897,26 @@ export const messages: Record<LocaleCode, NestedMessages> = {
legalDescription: 'Legal notice',
},
},
help: {
center: {
title: 'Help & tips',
subtitle: 'Guides for guests available offline after the first sync.',
searchPlaceholder: 'Search by topic or keyword',
offlineBadge: 'Offline copy',
offlineDescription: 'You are viewing cached content. Go online to refresh articles.',
empty: 'No articles found.',
error: 'Help could not be loaded.',
retry: 'Try again',
listTitle: 'All articles',
},
article: {
back: 'Back to overview',
updated: 'Updated on {date}',
relatedTitle: 'Related articles',
unavailable: 'This article is unavailable.',
reload: 'Reload',
},
},
},
};

View File

@@ -13,11 +13,19 @@ import PhotoLightbox from './PhotoLightbox';
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
const parseGalleryFilter = (value: string | null): GalleryFilter =>
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
export default function GalleryPage() {
const { token } = useParams<{ token?: string }>();
const navigate = useNavigate();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
const [searchParams, setSearchParams] = useSearchParams();
const photoIdParam = searchParams.get('photoId');
const modeParam = searchParams.get('mode');
const [filter, setFilterState] = React.useState<GalleryFilter>(() => parseGalleryFilter(modeParam));
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
@@ -28,8 +36,16 @@ export default function GalleryPage() {
const { t } = useTranslation();
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
const [searchParams] = useSearchParams();
const photoIdParam = searchParams.get('photoId');
useEffect(() => {
setFilterState(parseGalleryFilter(modeParam));
}, [modeParam]);
const setFilter = React.useCallback((next: GalleryFilter) => {
setFilterState(next);
const params = new URLSearchParams(searchParams);
params.set('mode', next);
setSearchParams(params, { replace: true });
}, [searchParams, setSearchParams]);
// Auto-open lightbox if photoId in query params
useEffect(() => {
if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) {
@@ -79,6 +95,9 @@ export default function GalleryPage() {
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
} else if (filter === 'mine') {
arr = arr.filter((p: any) => myPhotoIds.has(p.id));
} else if (filter === 'photobooth') {
arr = arr.filter((p: any) => p.ingest_source === 'photobooth');
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
} else {
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
}

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { Page } from './_util';
import { useLocale } from '../i18n/LocaleContext';
import { useTranslation } from '../i18n/useTranslation';
import { getHelpArticle, type HelpArticleDetail } from '../services/helpApi';
export default function HelpArticlePage() {
const params = useParams<{ token?: string; slug: string }>();
const slug = params.slug;
const { locale } = useLocale();
const { t } = useTranslation();
const [article, setArticle] = React.useState<HelpArticleDetail | null>(null);
const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading');
const [servedFromCache, setServedFromCache] = React.useState(false);
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const loadArticle = React.useCallback(async () => {
if (!slug) {
setState('error');
return;
}
setState('loading');
try {
const result = await getHelpArticle(slug, locale);
setArticle(result.article);
setServedFromCache(result.servedFromCache);
setState('ready');
} catch (error) {
console.error('[HelpArticle] Failed to load article', error);
setState('error');
}
}, [slug, locale]);
React.useEffect(() => {
loadArticle();
}, [loadArticle]);
const title = article?.title ?? t('help.article.unavailable');
return (
<Page title={title}>
<div className="mb-4">
<Button variant="ghost" size="sm" asChild>
<Link to={basePath}>
{t('help.article.back')}
</Link>
</Button>
</div>
{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="space-y-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
<p>{t('help.article.unavailable')}</p>
<Button variant="secondary" size="sm" onClick={loadArticle}>
{t('help.article.reload')}
</Button>
</div>
)}
{state === 'ready' && article && (
<article className="space-y-6">
<div className="space-y-2 text-sm text-muted-foreground">
{article.updated_at && (
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
)}
{servedFromCache && (
<Badge variant="secondary" className="bg-amber-200/70 text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">
{t('help.center.offlineBadge')}
</Badge>
)}
</div>
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
{article.related && article.related.length > 0 && (
<section className="space-y-3">
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
<div className="flex flex-wrap gap-2">
{article.related.map((rel) => (
<Button
key={rel.slug}
variant="outline"
size="sm"
asChild
>
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
{rel.slug}
</Link>
</Button>
))}
</div>
</section>
)}
</article>
)}
</Page>
);
}
function formatDate(value: string, locale: string): string {
try {
return new Date(value).toLocaleDateString(locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch (error) {
return value;
}
}

View File

@@ -0,0 +1,153 @@
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 (error) {
return value;
}
}

View File

@@ -34,9 +34,14 @@ export function usePollGalleryDelta(token: string) {
const json = await res.json();
// Handle different response formats
const newPhotos = Array.isArray(json.data) ? json.data :
const rawPhotos = Array.isArray(json.data) ? json.data :
Array.isArray(json) ? json :
json.photos || [];
const newPhotos = rawPhotos.map((photo: any) => ({
...photo,
session_id: photo?.session_id ?? photo?.guest_name ?? null,
}));
if (newPhotos.length > 0) {
const added = newPhotos.length;

View File

@@ -27,6 +27,8 @@ const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
const LegalPage = React.lazy(() => import('./pages/LegalPage'));
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage'));
const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage'));
const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage'));
@@ -75,10 +77,14 @@ export const router = createBrowserRouter([
{ path: 'photo/:photoId', element: <PhotoLightbox /> },
{ path: 'achievements', element: <AchievementsPage /> },
{ path: 'slideshow', element: <SlideshowPage /> },
{ path: 'help', element: <HelpCenterPage /> },
{ path: 'help/:slug', element: <HelpArticlePage /> },
],
},
{ path: '/settings', element: <SimpleLayout title="Einstellungen"><SettingsPage /></SimpleLayout> },
{ path: '/legal/:page', element: <SimpleLayout title="Rechtliches"><LegalPage /></SimpleLayout> },
{ path: '/help', element: <HelpStandalone /> },
{ path: '/help/:slug', element: <HelpArticleStandalone /> },
{ path: '*', element: <NotFoundPage /> },
]);
@@ -248,3 +254,21 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
</EventBrandingProvider>
);
}
function HelpStandalone() {
const { t } = useTranslation();
return (
<SimpleLayout title={t('help.center.title')}>
<HelpCenterPage />
</SimpleLayout>
);
}
function HelpArticleStandalone() {
const { t } = useTranslation();
return (
<SimpleLayout title={t('help.center.title')}>
<HelpArticlePage />
</SimpleLayout>
);
}

View File

@@ -0,0 +1,160 @@
import type { LocaleCode } from '../i18n/messages';
export type HelpArticleSummary = {
slug: string;
title: string;
summary: string;
version_introduced?: string;
requires_app_version?: string | null;
status?: string;
translation_state?: string;
last_reviewed_at?: string;
owner?: string;
updated_at?: string;
related?: Array<{ slug: string }>;
};
export type HelpArticleDetail = HelpArticleSummary & {
body_markdown?: string;
body_html?: string;
source_path?: string;
};
export interface HelpListResult {
articles: HelpArticleSummary[];
servedFromCache: boolean;
}
export interface HelpArticleResult {
article: HelpArticleDetail;
servedFromCache: boolean;
}
const AUDIENCE = 'guest';
const LIST_CACHE_TTL = 1000 * 60 * 60 * 6; // 6 hours
const DETAIL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
interface CacheRecord<T> {
storedAt: number;
data: T;
}
function listCacheKey(locale: LocaleCode): string {
return `help:list:${AUDIENCE}:${locale}`;
}
function detailCacheKey(locale: LocaleCode, slug: string): string {
return `help:article:${AUDIENCE}:${locale}:${slug}`;
}
function readCache<T>(key: string, ttl: number): CacheRecord<T> | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(key);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as CacheRecord<T>;
if (!parsed?.data || !parsed?.storedAt) {
return null;
}
if (Date.now() - parsed.storedAt > ttl) {
return null;
}
return parsed;
} catch (error) {
console.warn('[HelpApi] Failed to read cache', error);
return null;
}
}
function writeCache<T>(key: string, data: T): void {
if (typeof window === 'undefined') {
return;
}
try {
const payload: CacheRecord<T> = {
storedAt: Date.now(),
data,
};
window.localStorage.setItem(key, JSON.stringify(payload));
} catch (error) {
console.warn('[HelpApi] Failed to write cache', error);
}
}
async function requestJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
},
credentials: 'same-origin',
});
if (!response.ok) {
const error = new Error('Help request failed');
(error as any).status = response.status;
throw error;
}
const payload = await response.json();
return payload as T;
}
export async function getHelpArticles(locale: LocaleCode, options?: { forceRefresh?: boolean }): Promise<HelpListResult> {
const cacheKey = listCacheKey(locale);
const cached = readCache<HelpArticleSummary[]>(cacheKey, LIST_CACHE_TTL);
if (cached && !options?.forceRefresh) {
return { articles: cached.data, servedFromCache: true };
}
try {
const params = new URLSearchParams({
audience: AUDIENCE,
locale,
});
const data = await requestJson<{ data?: HelpArticleSummary[] }>(`/api/v1/help?${params.toString()}`);
const articles = Array.isArray(data?.data) ? data.data : [];
writeCache(cacheKey, articles);
return { articles, servedFromCache: false };
} catch (error) {
if (cached) {
return { articles: cached.data, servedFromCache: true };
}
console.error('[HelpApi] Failed to fetch help articles', error);
throw error;
}
}
export async function getHelpArticle(slug: string, locale: LocaleCode): Promise<HelpArticleResult> {
const cacheKey = detailCacheKey(locale, slug);
const cached = readCache<HelpArticleDetail>(cacheKey, DETAIL_CACHE_TTL);
if (cached) {
return { article: cached.data, servedFromCache: true };
}
try {
const params = new URLSearchParams({
audience: AUDIENCE,
locale,
});
const data = await requestJson<{ data?: HelpArticleDetail }>(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`);
const article = data?.data ?? { slug, title: slug, summary: '' };
writeCache(cacheKey, article);
return { article, servedFromCache: false };
} catch (error) {
if (cached) {
return { article: cached.data, servedFromCache: true };
}
console.error('[HelpApi] Failed to fetch help article', error);
throw error;
}
}