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.
This commit is contained in:
lobo committed 2026-10-21 08:21:39 +02:00
1 parent 60dcfe8992
commit a60010e055
28 files changed
+427 -58

No files matched your search

+5 -1
View File
@@ -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() {
</Route>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/>
<Route path="*" element={<NotFoundPage/>}/>
</Routes>
</main>
+8
View File
@@ -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(<App/>, {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));
+1 -1
View File
@@ -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)],
];
+25
View File
@@ -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(<AccountInfoCard/>, {path: '/settings', entries: ['/settings']});
expect(await screen.findByText('ROLES.PLATFORM_ADMIN')).toBeTruthy();
expect(screen.queryByText('PLATFORM_ADMIN')).toBeNull();
});
});
+5 -4
View File
@@ -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() {
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
</Button>
)}
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
<Unlink/>{t('SETTINGS.PSN.UNLINK')}
</Button>
<UnlinkDialog label={t('SETTINGS.PSN.UNLINK')} title={t('SETTINGS.PSN.UNLINK_TITLE')}
description={t('SETTINGS.PSN.UNLINK_EXPLAIN')}
confirmLabel={t('SETTINGS.PSN.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}/>
</div>
</>
)}
+17 -2
View File
@@ -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');
+7 -5
View File
@@ -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 = (
<Card className="gap-3 p-5">
+26
View File
@@ -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(<StatCard label="Games" value={1234}/>);
expect(screen.getByText('1.234')).toBeTruthy();
});
it('counts up from 0 otherwise', () => {
render(<StatCard label="Games" value={1234}/>);
expect(screen.getByText('0')).toBeTruthy();
});
});
+7 -6
View File
@@ -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')}
</Button>
{steamId && (
<Button type="button" variant="outline" onClick={unlink} disabled={busy}
className="h-auto max-w-full whitespace-normal py-2 text-left">
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')}
</Button>
<UnlinkDialog label={t('SETTINGS.STEAM_LINK.UNLINK')} title={t('SETTINGS.STEAM_LINK.UNLINK_TITLE')}
description={t('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')}
confirmLabel={t('SETTINGS.STEAM_LINK.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}
className="h-auto max-w-full whitespace-normal py-2 text-left"/>
)}
</div>
</CardContent>
+7 -1
View File
@@ -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 (
<Card className="relative justify-between overflow-hidden p-6">
+12 -1
View File
@@ -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(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
@@ -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(<MemoryRouter><SteamGameCount targetCount={123}/></MemoryRouter>);
expect(screen.getByText('123')).toBeTruthy();
});
});
+38
View File
@@ -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 (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" disabled={disabled} className={className}>
<Unlink/>{label}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t('DIALOG.CANCEL')}</Button>
</DialogClose>
<DialogClose asChild>
<Button type="button" variant="destructive" onClick={onConfirm}>{confirmLabel}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+2 -3
View File
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={(next) => { if (!next) gotIt(); }}>
<DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
+14 -3
View File
@@ -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([
+3 -1
View File
@@ -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 (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
@@ -43,7 +45,7 @@ function DialogContent({
<DialogPrimitive.Close
className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
<span className="sr-only">{t('DIALOG.CLOSE')}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
+2 -1
View File
@@ -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([{
<PreferencesProvider>
<CurrentUserProvider>
<App/>
<ScrollRestoration/>
</CurrentUserProvider>
</PreferencesProvider>
),
+18 -6
View File
@@ -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 () => {
+22 -8
View File
@@ -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]);
+52 -1
View File
@@ -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(<RouterProvider router={router}/>);
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();
});
});
+2 -1
View File
@@ -131,9 +131,10 @@ export default function LandingPage() {
{active ? (
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
) : (
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
<Link to="/register">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
)}
</Button>
{!active && <p className="mt-4 text-sm text-muted-foreground">{t('MAIN.APPROVAL_HINT')}</p>}
</div>
</section>
+15
View File
@@ -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();
+26
View File
@@ -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 (
<div className="w-full max-w-3xl mx-auto p-4 md:p-8">
<Card>
<CardHeader>
<CardTitle className="text-3xl font-bold tracking-tight">{t('NOT_FOUND.TITLE')}</CardTitle>
</CardHeader>
<CardContent>
<Button asChild>
{active ? <Link to="/dashboard">{t('NAV.TO_DASHBOARD')}</Link> : <Link to="/">{t('PROFILE.TO_START')}</Link>}
</Button>
</CardContent>
</Card>
</div>
);
}
+46
View File
@@ -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(<App/>, {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(<App/>, {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));
});
});
+28 -4
View File
@@ -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());
+4 -4
View File
@@ -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';
};
+5 -5
View File
@@ -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();
});
});