From 03c7b20caee6512b1f362dcb6cd977e1b0738514 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 14 Jan 2026 09:00:12 +0100 Subject: [PATCH] Improve guest help routing and loading --- resources/js/guest/components/Header.tsx | 17 ------- .../js/guest/components/settings-sheet.tsx | 8 ++- resources/js/guest/i18n/messages.ts | 4 ++ .../guest/lib/__tests__/helpRouting.test.ts | 32 ++++++++++++ resources/js/guest/lib/helpRouting.ts | 44 +++++++++++++++++ resources/js/guest/pages/HelpArticlePage.tsx | 34 ++++++++----- resources/js/guest/pages/HelpCenterPage.tsx | 21 +++++++- .../pages/__tests__/HelpArticlePage.test.tsx | 49 +++++++++++++++++++ 8 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 resources/js/guest/lib/__tests__/helpRouting.test.ts create mode 100644 resources/js/guest/lib/helpRouting.ts create mode 100644 resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 20b136d..d384c93 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -13,7 +13,6 @@ import { Clock, MessageSquare, Sparkles, - LifeBuoy, UploadCloud, AlertCircle, Check, @@ -188,13 +187,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; - const basePath = eventToken ? `/e/${encodeURIComponent(eventToken)}` : ''; - const showGalleryHelp = Boolean( - basePath - && (location.pathname.startsWith(`${basePath}/gallery`) || location.pathname.startsWith(`${basePath}/photo`)) - ); - const galleryHelpHref = basePath ? `${basePath}/help/gallery-and-sharing` : '/help/gallery-and-sharing'; - const headerStyle: React.CSSProperties = { background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`, color: headerTextColor, @@ -259,15 +251,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string t={t} /> )} - {showGalleryHelp && ( - - - - )} diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx index 751e144..131f0a1 100644 --- a/resources/js/guest/components/settings-sheet.tsx +++ b/resources/js/guest/components/settings-sheet.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Link, useParams } from 'react-router-dom'; +import { Link, useLocation, useParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { @@ -23,6 +23,7 @@ import { useTranslation } from '../i18n/useTranslation'; import type { LocaleCode } from '../i18n/messages'; import { useHapticsPreference } from '../hooks/useHapticsPreference'; import { triggerHaptic } from '../lib/haptics'; +import { getHelpSlugForPathname } from '../lib/helpRouting'; const legalPages = [ { slug: 'impressum', translationKey: 'settings.legal.section.impressum' }, @@ -53,12 +54,15 @@ export function SettingsSheet() { const localeContext = useLocale(); const { t } = useTranslation(); const params = useParams<{ token?: string }>(); + const location = useLocation(); const [nameDraft, setNameDraft] = React.useState(identity?.name ?? ''); const [nameStatus, setNameStatus] = React.useState('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'; + const helpSlug = getHelpSlugForPathname(location.pathname); + const helpBase = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help'; + const helpHref = helpSlug ? `${helpBase}/${helpSlug}` : helpBase; React.useEffect(() => { if (open && identity?.hydrated) { diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 83b15ec..63d4e0a 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -746,6 +746,8 @@ export const messages: Record = { back: 'Zurück zur Übersicht', updated: 'Aktualisiert am {date}', relatedTitle: 'Verwandte Artikel', + loadingTitle: 'Artikel wird geladen', + loadingDescription: 'Wir holen die neuesten Infos für dich.', unavailable: 'Dieser Artikel ist nicht verfügbar.', reload: 'Neu laden', }, @@ -1481,6 +1483,8 @@ export const messages: Record = { back: 'Back to overview', updated: 'Updated on {date}', relatedTitle: 'Related articles', + loadingTitle: 'Loading article', + loadingDescription: 'Fetching the latest details for you.', unavailable: 'This article is unavailable.', reload: 'Reload', }, diff --git a/resources/js/guest/lib/__tests__/helpRouting.test.ts b/resources/js/guest/lib/__tests__/helpRouting.test.ts new file mode 100644 index 0000000..f4d4c6c --- /dev/null +++ b/resources/js/guest/lib/__tests__/helpRouting.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { getHelpSlugForPathname } from '../helpRouting'; + +describe('getHelpSlugForPathname', () => { + it('returns a getting-started slug for home paths', () => { + expect(getHelpSlugForPathname('/')).toBe('getting-started'); + expect(getHelpSlugForPathname('/e/demo')).toBe('getting-started'); + }); + + it('returns null for help pages', () => { + expect(getHelpSlugForPathname('/help')).toBeNull(); + expect(getHelpSlugForPathname('/help/gallery-and-sharing')).toBeNull(); + expect(getHelpSlugForPathname('/e/demo/help/gallery-and-sharing')).toBeNull(); + }); + + it('maps gallery related pages', () => { + expect(getHelpSlugForPathname('/e/demo/gallery')).toBe('gallery-and-sharing'); + expect(getHelpSlugForPathname('/e/demo/photo/123')).toBe('gallery-and-sharing'); + expect(getHelpSlugForPathname('/e/demo/slideshow')).toBe('gallery-and-sharing'); + }); + + it('maps upload related pages', () => { + expect(getHelpSlugForPathname('/e/demo/upload')).toBe('uploading-photos'); + expect(getHelpSlugForPathname('/e/demo/queue')).toBe('upload-troubleshooting'); + }); + + it('maps tasks and achievements', () => { + expect(getHelpSlugForPathname('/e/demo/tasks')).toBe('tasks-and-missions'); + expect(getHelpSlugForPathname('/e/demo/tasks/12')).toBe('tasks-and-missions'); + expect(getHelpSlugForPathname('/e/demo/achievements')).toBe('achievements-and-badges'); + }); +}); diff --git a/resources/js/guest/lib/helpRouting.ts b/resources/js/guest/lib/helpRouting.ts new file mode 100644 index 0000000..54e535a --- /dev/null +++ b/resources/js/guest/lib/helpRouting.ts @@ -0,0 +1,44 @@ +export function getHelpSlugForPathname(pathname: string): string | null { + if (!pathname) { + return null; + } + + const normalized = pathname + .replace(/^\/e\/[^/]+/, '') + .replace(/\/+$/g, '') + .toLowerCase(); + + if (!normalized || normalized === '/') { + return 'getting-started'; + } + + if (normalized.startsWith('/help')) { + return null; + } + + if (normalized.startsWith('/gallery') || normalized.startsWith('/photo') || normalized.startsWith('/slideshow')) { + return 'gallery-and-sharing'; + } + + if (normalized.startsWith('/upload')) { + return 'uploading-photos'; + } + + if (normalized.startsWith('/queue')) { + return 'upload-troubleshooting'; + } + + if (normalized.startsWith('/tasks')) { + return 'tasks-and-missions'; + } + + if (normalized.startsWith('/achievements')) { + return 'achievements-and-badges'; + } + + if (normalized.startsWith('/settings')) { + return 'settings-and-cache'; + } + + return 'how-fotospiel-works'; +} diff --git a/resources/js/guest/pages/HelpArticlePage.tsx b/resources/js/guest/pages/HelpArticlePage.tsx index e15fa56..76b8ac7 100644 --- a/resources/js/guest/pages/HelpArticlePage.tsx +++ b/resources/js/guest/pages/HelpArticlePage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Link, useParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { Loader2 } from 'lucide-react'; +import { ArrowLeft, Loader2 } from 'lucide-react'; import { Page } from './_util'; import { useLocale } from '../i18n/LocaleContext'; import { useTranslation } from '../i18n/useTranslation'; @@ -37,7 +37,9 @@ export default function HelpArticlePage() { loadArticle(); }, [loadArticle]); - const title = article?.title ?? t('help.article.unavailable'); + const title = state === 'loading' + ? t('help.article.loadingTitle') + : (article?.title ?? t('help.article.unavailable')); return ( @@ -48,17 +50,30 @@ export default function HelpArticlePage() { refreshingLabel={t('common.refreshing')} >
-
{state === 'loading' && ( -
- - {t('common.actions.loading')} +
+
+ +
+
{t('help.article.loadingTitle')}
+
{t('help.article.loadingDescription')}
+
+
+
+
+
+
+
)} @@ -77,11 +92,6 @@ export default function HelpArticlePage() { {article.updated_at && (
{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}
)} -
('loading'); const [servedFromCache, setServedFromCache] = React.useState(false); + const [isOnline, setIsOnline] = React.useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true)); const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help'; const loadArticles = React.useCallback(async (forceRefresh = false) => { @@ -37,6 +38,24 @@ export default function HelpCenterPage() { loadArticles(); }, [loadArticles]); + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + const showOfflineBadge = servedFromCache && !isOnline; + const filteredArticles = React.useMemo(() => { if (!query.trim()) { return articles; @@ -85,7 +104,7 @@ export default function HelpCenterPage() { )}
- {servedFromCache && ( + {showOfflineBadge && (
{t('help.center.offlineBadge')} diff --git a/resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx b/resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx new file mode 100644 index 0000000..fc19d20 --- /dev/null +++ b/resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import HelpArticlePage from '../HelpArticlePage'; +import type { HelpArticleDetail } from '../../services/helpApi'; + +vi.mock('../../i18n/LocaleContext', () => ({ + useLocale: () => ({ locale: 'de' }), +})); + +vi.mock('../../i18n/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('../../services/helpApi', () => ({ + getHelpArticle: vi.fn(), +})); + +const { getHelpArticle } = await import('../../services/helpApi'); + +describe('HelpArticlePage', () => { + it('renders a single back button after loading', async () => { + const article: HelpArticleDetail = { + slug: 'gallery-and-sharing', + title: 'Galerie & Teilen', + summary: 'Kurzfassung', + body_html: '

Inhalt

', + }; + + (getHelpArticle as ReturnType).mockResolvedValue({ article, servedFromCache: false }); + + render( + + + } /> + + , + ); + + await waitFor(() => { + expect(screen.getByText('Galerie & Teilen')).toBeInTheDocument(); + }); + + expect(screen.getAllByText('help.article.back')).toHaveLength(1); + }); +});