From a60010e055b5e6b20176b0b5206c24671444b2ac Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 21 Oct 2026 08:21:39 +0200 Subject: [PATCH] fix(ux): apply UX quick wins (#54) Get started links to registration and says an admin approves accounts. Back navigation restores the library scroll; search no longer fills history. Steam and PSN unlink ask first. Any close marks news as seen. Confetti plays only when the level rises on the page. Count-ups respect reduced motion. Html lang follows the UI language. Add a 404 page, translated role and dialog close label. --- public/i18n/de.json | 15 +++++++ public/i18n/en.json | 15 +++++++ src/App.jsx | 6 ++- src/App.test.jsx | 8 ++++ src/components/AccountInfoCard.jsx | 2 +- src/components/AccountInfoCard.test.jsx | 25 ++++++++++++ src/components/PsnAccountCard.jsx | 9 +++-- src/components/PsnAccountCard.test.jsx | 19 ++++++++- src/components/StatCard.jsx | 12 +++--- src/components/StatCard.test.jsx | 26 ++++++++++++ src/components/SteamAccountCard.jsx | 13 +++--- src/components/SteamGameCount.jsx | 8 +++- src/components/SteamGameCount.test.jsx | 13 +++++- src/components/UnlinkDialog.jsx | 38 ++++++++++++++++++ src/components/WhatsNewDialog.jsx | 5 +-- src/components/WhatsNewDialog.test.jsx | 17 ++++++-- src/components/ui/dialog.jsx | 4 +- src/main.jsx | 3 +- src/pages/GameDetail.test.jsx | 24 ++++++++--- src/pages/GameLibrary.jsx | 30 ++++++++++---- src/pages/GameLibrary.test.jsx | 53 ++++++++++++++++++++++++- src/pages/LandingPage.jsx | 3 +- src/pages/LandingPage.test.jsx | 15 +++++++ src/pages/NotFoundPage.jsx | 26 ++++++++++++ src/pages/NotFoundPage.test.jsx | 46 +++++++++++++++++++++ src/pages/SettingsPage.steam.test.jsx | 32 +++++++++++++-- src/utils/confetti.js | 8 ++-- src/utils/confetti.test.js | 10 ++--- 28 files changed, 427 insertions(+), 58 deletions(-) create mode 100644 src/components/AccountInfoCard.test.jsx create mode 100644 src/components/StatCard.test.jsx create mode 100644 src/components/UnlinkDialog.jsx create mode 100644 src/pages/NotFoundPage.jsx create mode 100644 src/pages/NotFoundPage.test.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index a5509b5..d2eec82 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -132,6 +132,10 @@ "VERIFY": "Mit Steam bestätigen", "CHANGE": "Anderes Steam-Konto verknüpfen", "UNLINK": "Trennen", + "UNLINK_TITLE": "Steam-Konto trennen?", + "UNLINK_EXPLAIN": "Der Steam-Sync stoppt. Deine Spiele bleiben in deiner Bibliothek. Für einen neuen Sync verknüpfst du das Konto wieder.", + "UNLINK_CONFIRM": "Ja, trennen", + "UNLINK_ERROR": "Das Steam-Konto konnte nicht getrennt werden.", "VERIFIED": "Bestätigt", "NOT_VERIFIED": "Nicht bestätigt", "NONE": "Noch kein Steam-Konto verknüpft. Du meldest dich bei Steam an, und Steam nennt uns deine Steam-ID; dein Steam-Passwort sehen wir nie.", @@ -159,6 +163,9 @@ "SYNC_HINT": "Jeder Sync holt neu gespielte Titel. Sichere Treffer werden hinzugefügt, unsichere warten in der Import-Prüfung.", "REVIEW": "Wartende Titel prüfen", "UNLINK": "Trennen", + "UNLINK_TITLE": "PlayStation-Konto trennen?", + "UNLINK_EXPLAIN": "Der PlayStation-Sync stoppt. Importierte Spiele bleiben in deiner Bibliothek. Für einen neuen Sync fügst du einen neuen NPSSO-Code ein.", + "UNLINK_CONFIRM": "Ja, trennen", "UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.", "INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.", "UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.", @@ -367,6 +374,7 @@ "WELCOME": "Dein Game-Backlog unter Kontrolle", "DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.", "GET_STARTED": "Jetzt loslegen", + "APPROVAL_HINT": "Ein Admin schaltet jedes neue Konto vor der ersten Anmeldung frei.", "LOGIN": "Anmelden", "LOGOUT": "Abmelden" }, @@ -674,6 +682,13 @@ "GAMES_other": "{{count}} Spiele" } }, + "DIALOG": { + "CANCEL": "Abbrechen", + "CLOSE": "Schließen" + }, + "NOT_FOUND": { + "TITLE": "Seite nicht gefunden" + }, "ROLES": { "USER": "Nutzer", "AUDITOR": "Auditor", diff --git a/public/i18n/en.json b/public/i18n/en.json index e8fbaba..1859176 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -132,6 +132,10 @@ "VERIFY": "Verify with Steam", "CHANGE": "Link another Steam account", "UNLINK": "Unlink", + "UNLINK_TITLE": "Unlink Steam account?", + "UNLINK_EXPLAIN": "The Steam sync stops. Your games stay in your library. To sync again, link the account again.", + "UNLINK_CONFIRM": "Yes, unlink", + "UNLINK_ERROR": "Could not unlink the Steam account.", "VERIFIED": "Verified", "NOT_VERIFIED": "Not verified", "NONE": "No Steam account linked yet. You sign in on Steam, and Steam tells us your Steam ID; we never see your Steam password.", @@ -159,6 +163,9 @@ "SYNC_HINT": "Every sync brings in newly played titles. Clear matches are added; unclear ones wait in the import review.", "REVIEW": "Review waiting titles", "UNLINK": "Unlink", + "UNLINK_TITLE": "Unlink PlayStation account?", + "UNLINK_EXPLAIN": "The PlayStation sync stops. Imported games stay in your library. To sync again, paste a new NPSSO code.", + "UNLINK_CONFIRM": "Yes, unlink", "UNLINKED": "PlayStation account unlinked. Imported games stay in your library.", "INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.", "UNAVAILABLE": "PlayStation did not answer. Please try again later.", @@ -367,6 +374,7 @@ "WELCOME": "Control your game backlog", "DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.", "GET_STARTED": "Get Started", + "APPROVAL_HINT": "An admin approves every new account before the first login.", "LOGIN": "Log in", "LOGOUT": "Log out" }, @@ -674,6 +682,13 @@ "GAMES_other": "{{count}} games" } }, + "DIALOG": { + "CANCEL": "Cancel", + "CLOSE": "Close" + }, + "NOT_FOUND": { + "TITLE": "Page not found" + }, "ROLES": { "USER": "User", "AUDITOR": "Auditor", diff --git a/src/App.jsx b/src/App.jsx index 0439dff..285b84b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import {useEffect} from 'react'; import LandingPage from './pages/LandingPage'; import LoginPage from './pages/LoginPage'; import ImprintPage from './pages/ImprintPage'; +import NotFoundPage from './pages/NotFoundPage.jsx'; import Dashboard from "./pages/Dashboard.jsx"; import RegistrationPage from './pages/RegistrationPage.jsx'; import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx'; @@ -46,7 +47,7 @@ import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js'; import {logout} from './utils/api.js'; function App() { - const {t} = useTranslation(); + const {t, i18n} = useTranslation(); const {active: isAuthenticated} = useSession(); const {appearance, equipped, syncPreferences} = usePreferences(); @@ -54,6 +55,8 @@ function App() { const navigate = useNavigate(); const location = useLocation(); + useEffect(() => { document.documentElement.lang = i18n.language; }, [i18n.language]); + // On login and on page load; the cached look shows until Auth answers. useEffect(() => { if (isAuthenticated) syncPreferences(); @@ -110,6 +113,7 @@ function App() { }/> }/> + }/> diff --git a/src/App.test.jsx b/src/App.test.jsx index b268cf3..d5259b1 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -24,6 +24,14 @@ afterEach(() => { }); describe('App', () => { + it('sets the document language to the UI language', () => { + fetchRoutes({'GET /api/users/me': json(200, {settings: {}})}); + localStorage.clear(); + renderWithProviders(, {entries: ['/impressum']}); + + expect(document.documentElement.lang).toBe('de'); + }); + it('ends the session on a 401 that the refresh cannot fix', async () => { let expired = false; const library = () => (expired ? json(401, {}) : json(200, page)); diff --git a/src/components/AccountInfoCard.jsx b/src/components/AccountInfoCard.jsx index d3d7f82..2f03770 100644 --- a/src/components/AccountInfoCard.jsx +++ b/src/components/AccountInfoCard.jsx @@ -21,7 +21,7 @@ export default function AccountInfoCard() { const {user: profile} = useCurrentUserContext(); const accountRows = [ [t('SETTINGS.EMAIL'), profile?.email], - [t('SETTINGS.ROLE'), profile?.role], + [t('SETTINGS.ROLE'), profile?.role && t(`ROLES.${profile.role}`)], [t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)], [t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)], ]; diff --git a/src/components/AccountInfoCard.test.jsx b/src/components/AccountInfoCard.test.jsx new file mode 100644 index 0000000..cf5125b --- /dev/null +++ b/src/components/AccountInfoCard.test.jsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, screen} from '@testing-library/react'; +import AccountInfoCard from './AccountInfoCard.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; +import {renderWithProviders} from '../test/renderWithProviders.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +beforeEach(() => localStorage.setItem('tbp_session', '1')); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe('AccountInfoCard', () => { + it('shows the role by its translated name, not the enum value', async () => { + fetchRoutes({'GET /api/users/me': json(200, {email: 'a@b.de', role: 'PLATFORM_ADMIN', settings: {}})}); + renderWithProviders(, {path: '/settings', entries: ['/settings']}); + + expect(await screen.findByText('ROLES.PLATFORM_ADMIN')).toBeTruthy(); + expect(screen.queryByText('PLATFORM_ADMIN')).toBeNull(); + }); +}); diff --git a/src/components/PsnAccountCard.jsx b/src/components/PsnAccountCard.jsx index 0c5ad59..2a63ebc 100644 --- a/src/components/PsnAccountCard.jsx +++ b/src/components/PsnAccountCard.jsx @@ -1,12 +1,13 @@ import {useState} from 'react'; import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {Gamepad, Link2, Loader2, Unlink} from 'lucide-react'; +import {Gamepad, Link2, Loader2} from 'lucide-react'; import {toast} from 'sonner'; import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; +import UnlinkDialog from './UnlinkDialog.jsx'; import usePsnAccount from '../hooks/usePsnAccount.js'; import {formatDate} from '../utils/format.js'; import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js'; @@ -102,9 +103,9 @@ export default function PsnAccountCard() { {t('SETTINGS.PSN.REVIEW')} )} - + )} diff --git a/src/components/PsnAccountCard.test.jsx b/src/components/PsnAccountCard.test.jsx index 5812acd..5660c72 100644 --- a/src/components/PsnAccountCard.test.jsx +++ b/src/components/PsnAccountCard.test.jsx @@ -118,13 +118,28 @@ describe('PsnAccountCard', () => { expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull(); }); - it('unlinks and shows the link form again', async () => { + it('asks before it unlinks and sends nothing on cancel', async () => { + renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(5)))}); + + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK'})); + + expect(await screen.findByText('SETTINGS.PSN.UNLINK_EXPLAIN')).toBeTruthy(); + expect(calls('DELETE')).toHaveLength(0); + + fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'})); + + await waitFor(() => expect(screen.queryByText('SETTINGS.PSN.UNLINK_EXPLAIN')).toBeNull()); + expect(calls('DELETE')).toHaveLength(0); + }); + + it('unlinks after the confirmation and shows the link form again', async () => { renderCard({ 'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))), 'DELETE /api/library/psn-account': noBody, }); - fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.PSN.UNLINK/})); + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK'})); + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK_CONFIRM'})); expect(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy(); expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED'); diff --git a/src/components/StatCard.jsx b/src/components/StatCard.jsx index 6edd8f4..1bff1bb 100644 --- a/src/components/StatCard.jsx +++ b/src/components/StatCard.jsx @@ -2,14 +2,15 @@ import {useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {Card} from '@/components/ui/card'; +import useReducedMotion from '../hooks/useReducedMotion.js'; import {useDrillTarget} from './stats/StatDrill.jsx'; -function useCountUp(target, durationMs = 1600) { +function useCountUp(target, instant, durationMs = 1600) { const [value, setValue] = useState(0); useEffect(() => { const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0; - if (safeTarget === 0) { - setValue(0); + if (instant || safeTarget === 0) { + setValue(safeTarget); return; } let raf; @@ -22,7 +23,7 @@ function useCountUp(target, durationMs = 1600) { }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); - }, [target, durationMs]); + }, [target, instant, durationMs]); return value; } @@ -32,7 +33,8 @@ function useCountUp(target, durationMs = 1600) { */ export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) { const {i18n} = useTranslation(); - const animated = useCountUp(value); + const reduce = useReducedMotion(); + const animated = useCountUp(value, reduce); const target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick}); const card = ( diff --git a/src/components/StatCard.test.jsx b/src/components/StatCard.test.jsx new file mode 100644 index 0000000..b8bef8b --- /dev/null +++ b/src/components/StatCard.test.jsx @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; +import StatCard from './StatCard.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +afterEach(() => { + cleanup(); + delete document.documentElement.dataset.reduceMotion; +}); + +describe('StatCard', () => { + it('shows the final number at once when the user wants reduced motion', () => { + document.documentElement.dataset.reduceMotion = 'true'; + render(); + + expect(screen.getByText('1.234')).toBeTruthy(); + }); + + it('counts up from 0 otherwise', () => { + render(); + + expect(screen.getByText('0')).toBeTruthy(); + }); +}); diff --git a/src/components/SteamAccountCard.jsx b/src/components/SteamAccountCard.jsx index 3900e6a..36b77a1 100644 --- a/src/components/SteamAccountCard.jsx +++ b/src/components/SteamAccountCard.jsx @@ -1,11 +1,12 @@ import {useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2, Unlink} from 'lucide-react'; +import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import UnlinkDialog from './UnlinkDialog.jsx'; import {markSession} from '../utils/auth.js'; import {startSteamLink, unlinkSteam} from '../utils/steamLink.js'; @@ -36,7 +37,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) { const response = await unlinkSteam(); if (response.status === 401) return; if (!response.ok) { - toast.error(t('SETTINGS.STEAM_LINK.ERROR')); + toast.error(t('SETTINGS.STEAM_LINK.UNLINK_ERROR')); return; } // The new access cookie has no Steam ID; the new stamp makes the header load again. @@ -79,10 +80,10 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) { {t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')} {steamId && ( - + )} diff --git a/src/components/SteamGameCount.jsx b/src/components/SteamGameCount.jsx index 5e1f7bb..f17a2ca 100644 --- a/src/components/SteamGameCount.jsx +++ b/src/components/SteamGameCount.jsx @@ -4,15 +4,21 @@ import {useTranslation} from 'react-i18next'; import {ArrowRight, Gamepad2} from 'lucide-react'; import {Card} from '@/components/ui/card'; +import useReducedMotion from '../hooks/useReducedMotion.js'; const DURATION_MS = 1000; const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums'; const SteamGameCount = ({targetCount}) => { const {t} = useTranslation(); + const reduce = useReducedMotion(); const [count, setCount] = useState(0); useEffect(() => { + if (reduce) { + setCount(targetCount); + return undefined; + } let frame; let startTime; const animate = (timestamp) => { @@ -25,7 +31,7 @@ const SteamGameCount = ({targetCount}) => { }; frame = requestAnimationFrame(animate); return () => cancelAnimationFrame(frame); - }, [targetCount]); + }, [targetCount, reduce]); return ( diff --git a/src/components/SteamGameCount.test.jsx b/src/components/SteamGameCount.test.jsx index d082c2a..64eb48a 100644 --- a/src/components/SteamGameCount.test.jsx +++ b/src/components/SteamGameCount.test.jsx @@ -7,7 +7,10 @@ import SteamGameCount from './SteamGameCount.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); describe('SteamGameCount', () => { - afterEach(cleanup); + afterEach(() => { + cleanup(); + delete document.documentElement.dataset.reduceMotion; + }); it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => { const {container, rerender} = render(); @@ -17,4 +20,12 @@ describe('SteamGameCount', () => { expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2); expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy(); }); + + it('showsTheFinalCountAtOnceUnderReducedMotion', () => { + document.documentElement.dataset.reduceMotion = 'true'; + + render(); + + expect(screen.getByText('123')).toBeTruthy(); + }); }); diff --git a/src/components/UnlinkDialog.jsx b/src/components/UnlinkDialog.jsx new file mode 100644 index 0000000..8acf094 --- /dev/null +++ b/src/components/UnlinkDialog.jsx @@ -0,0 +1,38 @@ +import {useTranslation} from 'react-i18next'; +import {Unlink} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import { + Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, +} from '@/components/ui/dialog'; + +/** + * "Unlink" button that asks first. The description says what the unlink does and does not do. + */ +export default function UnlinkDialog({label, title, description, confirmLabel, onConfirm, disabled, className}) { + const {t} = useTranslation(); + + return ( + + + + + + + {title} + {description} + + + + + + + + + + + + ); +} diff --git a/src/components/WhatsNewDialog.jsx b/src/components/WhatsNewDialog.jsx index 832537d..fed4974 100644 --- a/src/components/WhatsNewDialog.jsx +++ b/src/components/WhatsNewDialog.jsx @@ -8,8 +8,7 @@ import NewsEntry from './NewsEntry.jsx'; import {fetchNews, markNewsSeen} from '../utils/news.js'; /** - * The news entries the user has not seen, newest first. Only "Got it" marks them seen: closing the dialog any other - * way shows them again on the next dashboard visit. + * The news entries the user has not seen, newest first. Any close (Got it, X, Esc, click outside) marks them seen. */ export default function WhatsNewDialog() { const {t} = useTranslation(); @@ -39,7 +38,7 @@ export default function WhatsNewDialog() { }; return ( - + { if (!next) gotIt(); }}> {t('NEWS.TITLE')} diff --git a/src/components/WhatsNewDialog.test.jsx b/src/components/WhatsNewDialog.test.jsx index 8f97bdc..7bc5974 100644 --- a/src/components/WhatsNewDialog.test.jsx +++ b/src/components/WhatsNewDialog.test.jsx @@ -50,13 +50,24 @@ describe('WhatsNewDialog', () => { expect(screen.queryByRole('dialog')).toBeNull(); }); - it('closes with X without marking anything seen', async () => { + it('marks the entries seen when closed with X', async () => { await showNews(NEW); - fireEvent.click(screen.getByRole('button', {name: 'Close'})); + fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CLOSE'})); + await act(async () => undefined); expect(screen.queryByRole('dialog')).toBeNull(); - expect(putCalls()).toEqual([]); + expect(putCalls()).toHaveLength(1); + }); + + it('marks the entries seen when closed with Esc', async () => { + await showNews(NEW); + + fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'}); + await act(async () => undefined); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(putCalls()).toHaveLength(1); }); it.each([ diff --git a/src/components/ui/dialog.jsx b/src/components/ui/dialog.jsx index 8abd186..f0206a1 100644 --- a/src/components/ui/dialog.jsx +++ b/src/components/ui/dialog.jsx @@ -1,4 +1,5 @@ import { XIcon } from "lucide-react" +import { useTranslation } from "react-i18next" import { Dialog as DialogPrimitive } from "radix-ui" import { cn } from "@/lib/utils" @@ -26,6 +27,7 @@ function DialogContent({ children, ...props }) { + const { t } = useTranslation() return ( - Close + {t('DIALOG.CLOSE')} diff --git a/src/main.jsx b/src/main.jsx index 8d572c9..7c3af64 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,6 +1,6 @@ import {StrictMode} from 'react'; import {createRoot} from 'react-dom/client'; -import {createBrowserRouter, RouterProvider} from 'react-router-dom'; +import {createBrowserRouter, RouterProvider, ScrollRestoration} from 'react-router-dom'; import App from './App.jsx'; import {PreferencesProvider} from './context/PreferencesContext.jsx'; import {CurrentUserProvider} from './context/CurrentUserContext.jsx'; @@ -14,6 +14,7 @@ const router = createBrowserRouter([{ + ), diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 2b4338c..ff1fdc5 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -482,14 +482,26 @@ describe('GameDetail confetti', () => { return container.querySelector('canvas[data-size]'); }; - it('plays a small one for a finished game', async () => { - expect((await opened({status: 'FINISHED'})).dataset.size).toBe('small'); + it('plays nothing on opening a finished game', async () => { + expect(await opened({status: 'FINISHED'})).toBeNull(); }); - it('plays a big one when every achievement is unlocked too', async () => { - const canvas = await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true, - achievementsTotal: 3, achievementsUnlocked: 3}); - expect(canvas.dataset.size).toBe('big'); + it('plays nothing on opening a completed game', async () => { + expect(await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true, + achievementsTotal: 3, achievementsUnlocked: 3})).toBeNull(); + }); + + it('plays a small one when the user sets the game to finished here', async () => { + answer({...game, status: 'PLAYING', finishedHint: {type: 'ENDING', achievement: 'Survivor'}}, { + 'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body), finishedHint: null}), + }); + const {container} = renderDetail(); + await screen.findByText('Half-Life 2'); + expect(container.querySelector('canvas[data-size]')).toBeNull(); + + fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'})); + + await waitFor(() => expect(container.querySelector('canvas[data-size="small"]')).toBeTruthy()); }); it('plays nothing for a game not finished', async () => { diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 222a99c..2755c09 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -29,6 +29,7 @@ import {coverEffect} from '../utils/rewards.js'; import {apiFetch, request} from '../utils/api.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js'; import {platformLabel} from '../utils/platforms.js'; +import {sessionStamp} from '../utils/auth.js'; const PAGE_SIZES = [20, 40, 80]; const DEFAULT_SIZE = 20; @@ -45,6 +46,16 @@ const SORT_OPTIONS = [ {value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'}, ]; +// Last loaded page, so a back navigation from a game renders at once and the scroll position can be restored. +let lastPage = {query: null, data: null, stamp: null}; + +// A new login gets a new stamp, so one user's rows never show for the next. +const cachedFor = (query) => (lastPage.query === query && lastPage.stamp === sessionStamp() ? lastPage.data : null); + +export function clearLibraryPageCache() { + lastPage = {query: null, data: null, stamp: null}; +} + export default function GameLibrary() { const {t} = useTranslation(); const {equipped} = usePreferences(); @@ -73,16 +84,18 @@ export default function GameLibrary() { const [canSync, setCanSync] = useState(true); const [ownSources, setOwnSources] = useState([]); - const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); - const [isLoading, setIsLoading] = useState(true); + const query = libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey}); + const cached = cachedFor(query); + const [data, setData] = useState(cached ?? {content: [], page: 0, size, totalElements: 0, totalPages: 0}); + const [isLoading, setIsLoading] = useState(!cached); const [error, setError] = useState(null); const nextSignal = useAbortPrevious(); const load = useCallback(async () => { const signal = nextSignal(); - setIsLoading(true); + setIsLoading(!cachedFor(query)); setError(null); - const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey})}`; + const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${query}`; const {ok, status, data: json} = await request(url, {signal}); if (signal.aborted) return; setIsLoading(false); @@ -91,8 +104,9 @@ export default function GameLibrary() { setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD'); return; } + lastPage = {query, data: json, stamp: sessionStamp()}; setData(json); - }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, sourceKey]); + }, [nextSignal, query]); useEffect(() => { load(); @@ -129,10 +143,10 @@ export default function GameLibrary() { }, }); - const updateParams = useCallback((next) => { + const updateParams = useCallback((next, options) => { const params = new URLSearchParams(searchParams); Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v)))); - setSearchParams(params); + setSearchParams(params, options); }, [searchParams, setSearchParams]); // Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing. @@ -143,7 +157,7 @@ export default function GameLibrary() { // updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params. useEffect(() => { if (search.trim() === q) return undefined; - const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS); + const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}, {replace: true}), SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [search, q, updateParams]); diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index 6a02c1e..feedb3d 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -3,11 +3,12 @@ import {StrictMode} from 'react'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom'; -import GameLibrary from './GameLibrary.jsx'; +import GameLibrary, {clearLibraryPageCache} from './GameLibrary.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {renderWithProviders} from '../test/renderWithProviders.jsx'; +import {clearSession, markSession} from '../utils/auth.js'; import {toast} from 'sonner'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); @@ -48,6 +49,7 @@ function renderLibrary(initialEntry = '/library') { }], {initialEntries: [initialEntry]}); render(); return { + router, navigations: () => lib.navigations, params: () => new URLSearchParams(lib.location.search), back: () => lib.navigate(-1), @@ -57,6 +59,7 @@ function renderLibrary(initialEntry = '/library') { const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); beforeEach(() => { + clearLibraryPageCache(); localStorage.setItem('tbp_session', '1'); answer(); }); @@ -66,6 +69,42 @@ afterEach(() => { localStorage.clear(); }); +describe('GameLibrary page cache', () => { + it('renders the last loaded rows at once on return, without a skeleton', async () => { + renderLibrary(); + await screen.findByText('Half-Life 2'); + cleanup(); + fetchRoutes({ + 'GET /api/users/me': json(200, {settings: {}}), + 'GET /api/library/games': () => new Promise(() => {}), + 'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true, sources: []}), + }); + + renderLibrary(); + + expect(screen.getByText('Half-Life 2')).toBeTruthy(); + expect(document.querySelector('[data-slot="skeleton"]')).toBeNull(); + }); + + // REVIEW-FINDING: the module cache survives a logout, so the next login on the same tab renders the previous user's rows + it('reviewFinding_cacheDoesNotOutliveTheSession', async () => { + renderLibrary(); + await screen.findByText('Half-Life 2'); + cleanup(); + clearSession(); + markSession(); + fetchRoutes({ + 'GET /api/users/me': json(200, {settings: {}}), + 'GET /api/library/games': () => new Promise(() => {}), + 'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true, sources: []}), + }); + + renderLibrary(); + + expect(screen.queryByText('Half-Life 2')).toBeNull(); + }); +}); + describe('GameLibrary search', () => { it('shows the coming badge only for a game that is not released yet', async () => { answer([ @@ -163,14 +202,26 @@ describe('GameLibrary search', () => { expect(lib.params().get('status')).toBe('DROPPED'); }); + it('replaces the history entry when the search is typed', async () => { + const lib = renderLibrary(); + + typeInto('half'); + await waitFor(() => expect(lib.params().get('q')).toBe('half')); + + expect(lib.router.state.historyAction).toBe('REPLACE'); + }); + it('shows the URL search again after back navigation', async () => { const lib = renderLibrary(); + fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.DROPPED'})); + await waitFor(() => expect(lib.params().get('status')).toBe('DROPPED')); typeInto('half'); await waitFor(() => expect(lib.params().get('q')).toBe('half')); await act(async () => lib.back()); await waitFor(() => expect(screen.getByRole('searchbox').value).toBe('')); + expect(lib.params().get('status')).toBeNull(); }); }); diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index 07ee0ac..4e27cfd 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -131,9 +131,10 @@ export default function LandingPage() { {active ? ( {t('NAV.TO_DASHBOARD')} ) : ( - {t('MAIN.GET_STARTED')} + {t('MAIN.GET_STARTED')} )} + {!active &&

{t('MAIN.APPROVAL_HINT')}

} diff --git a/src/pages/LandingPage.test.jsx b/src/pages/LandingPage.test.jsx index aa9378d..562d8a6 100644 --- a/src/pages/LandingPage.test.jsx +++ b/src/pages/LandingPage.test.jsx @@ -36,6 +36,21 @@ describe('LandingPage', () => { expect(screen.getByText('2025')).toBeTruthy(); }); + it('links "get started" to the registration and shows the approval hint, but not when logged in', async () => { + answer({}); + const {unmount} = renderPage(); + + expect(screen.getByRole('link', {name: /MAIN.GET_STARTED/}).getAttribute('href')).toBe('/register'); + expect(screen.getByText('MAIN.APPROVAL_HINT')).toBeTruthy(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + unmount(); + + session.active = true; + renderPage(); + expect(screen.queryByText('MAIN.APPROVAL_HINT')).toBeNull(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + }); + it('hides the three blocks while their lists are empty', async () => { answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []}); renderPage(); diff --git a/src/pages/NotFoundPage.jsx b/src/pages/NotFoundPage.jsx new file mode 100644 index 0000000..c7e50fa --- /dev/null +++ b/src/pages/NotFoundPage.jsx @@ -0,0 +1,26 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; + +import {Button} from '@/components/ui/button'; +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import useSession from '../hooks/useSession.js'; + +export default function NotFoundPage() { + const {t} = useTranslation(); + const {active} = useSession(); + + return ( +
+ + + {t('NOT_FOUND.TITLE')} + + + + + +
+ ); +} diff --git a/src/pages/NotFoundPage.test.jsx b/src/pages/NotFoundPage.test.jsx new file mode 100644 index 0000000..64346ef --- /dev/null +++ b/src/pages/NotFoundPage.test.jsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, screen, waitFor} from '@testing-library/react'; +import App from '../App.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; +import {renderWithProviders} from '../test/renderWithProviders.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); +vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})})); + +beforeEach(() => { + fetchRoutes({ + 'GET /api/users/me': json(200, {settings: {}}), + 'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}), + 'GET /api/library/games-count': json(200, {hasSteamId: true}), + 'GET /api/library/steam-profile': json(200, null), + 'GET /api/inbox': json(200, {results: [], unread: 0}), + 'GET /api/library/questions/count': json(200, {count: 0}), + 'GET /api/library/removed-games': json(200, {results: []}), + 'GET /api/news': json(200, {results: []}), + }); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe('NotFoundPage', () => { + it('shows a not-found page with a link to the start page without a session', async () => { + renderWithProviders(, {entries: ['/no-such-page']}); + + expect(await screen.findByText('NOT_FOUND.TITLE')).toBeTruthy(); + expect(screen.getByRole('link', {name: 'PROFILE.TO_START'}).getAttribute('href')).toBe('/'); + }); + + it('links to the dashboard with a session', async () => { + localStorage.setItem('tbp_session', '1'); + renderWithProviders(, {entries: ['/no-such-page']}); + + await screen.findByText('NOT_FOUND.TITLE'); + await waitFor(() => expect(screen.getAllByRole('link', {name: 'NAV.TO_DASHBOARD'}).some( + (link) => link.getAttribute('href') === '/dashboard')).toBe(true)); + }); +}); diff --git a/src/pages/SettingsPage.steam.test.jsx b/src/pages/SettingsPage.steam.test.jsx index 7f1d87c..12c2b90 100644 --- a/src/pages/SettingsPage.steam.test.jsx +++ b/src/pages/SettingsPage.steam.test.jsx @@ -24,11 +24,11 @@ const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}; -function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) { +function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings', unlinkAnswer = noBody} = {}) { fetchRoutes({ 'GET /api/users/me': json(200, me), 'POST /api/users/steam-link': json(200, {redirectUrl}), - 'DELETE /api/users/steam-link': noBody, + 'DELETE /api/users/steam-link': unlinkAnswer, 'PUT /api/users/settings': noBody, 'POST /api/auth/refresh': noBody, 'GET /api/version': json(404, {}), @@ -97,15 +97,39 @@ describe('SettingsPage Steam account', () => { expect(calls('POST').some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(false); }); - it('unlinks the Steam account', async () => { + it('asks before it unlinks and sends nothing on cancel', async () => { renderPage(profile({steamIdVerified: true})); - fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.STEAM_LINK.UNLINK/})); + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'})); + + expect(await screen.findByText('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')).toBeTruthy(); + expect(calls('DELETE')).toHaveLength(0); + + fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'})); + + await waitFor(() => expect(screen.queryByText('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')).toBeNull()); + expect(calls('DELETE')).toHaveLength(0); + }); + + it('unlinks the Steam account after the confirmation', async () => { + renderPage(profile({steamIdVerified: true})); + + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'})); + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK_CONFIRM'})); expect(await screen.findByText('SETTINGS.STEAM_LINK.NONE')).toBeTruthy(); expect(calls('DELETE')[0][0]).toMatch(/\/api\/users\/steam-link$/); }); + it('says the unlink failed with its own message', async () => { + renderPage(profile({steamIdVerified: true}), {unlinkAnswer: json(500, {})}); + + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'})); + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK_CONFIRM'})); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.STEAM_LINK.UNLINK_ERROR')); + }); + it('never sends a Steam ID with the settings', async () => { renderPage(profile()); diff --git a/src/utils/confetti.js b/src/utils/confetti.js index fc81215..1b18e36 100644 --- a/src/utils/confetti.js +++ b/src/utils/confetti.js @@ -15,15 +15,15 @@ export const celebrationLevel = (game) => { }; /** - * The confetti to play when the page shows this game: on opening a finished game, and when the level rises on the - * same page (e.g. the user sets FINISHED). A reload of the same game plays nothing. + * The confetti to play: only when the level rises while the page shows the game (the user sets Finished or completes + * it here). Opening a finished game and a reload play nothing. * * @param before {appid, level} of the game shown before, null on opening the page * @returns 'small', 'big' or null */ export const celebrationFor = (before, game) => { + if (before?.appid !== game.appid) return null; const level = celebrationLevel(game); - const from = before?.appid === game.appid ? before.level : 0; - if (level <= from) return null; + if (level <= before.level) return null; return level === 2 ? 'big' : 'small'; }; diff --git a/src/utils/confetti.test.js b/src/utils/confetti.test.js index 4ba8739..934c94d 100644 --- a/src/utils/confetti.test.js +++ b/src/utils/confetti.test.js @@ -19,9 +19,9 @@ describe('celebrationLevel', () => { }); describe('celebrationFor', () => { - it('plays on opening a finished game', () => { - expect(celebrationFor(null, game('FINISHED'))).toBe('small'); - expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBe('big'); + it('plays nothing on opening a game, finished or not', () => { + expect(celebrationFor(null, game('FINISHED'))).toBeNull(); + expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBeNull(); expect(celebrationFor(null, game('BACKLOG'))).toBeNull(); }); @@ -32,7 +32,7 @@ describe('celebrationFor', () => { expect(celebrationFor({appid: 620, level: 2}, game('PLAYING'))).toBeNull(); }); - it('plays for another game opened from the page', () => { - expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBe('small'); + it('plays nothing for another game opened from the page', () => { + expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBeNull(); }); });