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

+15
View File
@@ -132,6 +132,10 @@
"VERIFY": "Mit Steam bestätigen", "VERIFY": "Mit Steam bestätigen",
"CHANGE": "Anderes Steam-Konto verknüpfen", "CHANGE": "Anderes Steam-Konto verknüpfen",
"UNLINK": "Trennen", "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", "VERIFIED": "Bestätigt",
"NOT_VERIFIED": "Nicht 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.", "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.", "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", "REVIEW": "Wartende Titel prüfen",
"UNLINK": "Trennen", "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.", "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.", "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.", "UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
@@ -367,6 +374,7 @@
"WELCOME": "Dein Game-Backlog unter Kontrolle", "WELCOME": "Dein Game-Backlog unter Kontrolle",
"DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.", "DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.",
"GET_STARTED": "Jetzt loslegen", "GET_STARTED": "Jetzt loslegen",
"APPROVAL_HINT": "Ein Admin schaltet jedes neue Konto vor der ersten Anmeldung frei.",
"LOGIN": "Anmelden", "LOGIN": "Anmelden",
"LOGOUT": "Abmelden" "LOGOUT": "Abmelden"
}, },
@@ -674,6 +682,13 @@
"GAMES_other": "{{count}} Spiele" "GAMES_other": "{{count}} Spiele"
} }
}, },
"DIALOG": {
"CANCEL": "Abbrechen",
"CLOSE": "Schließen"
},
"NOT_FOUND": {
"TITLE": "Seite nicht gefunden"
},
"ROLES": { "ROLES": {
"USER": "Nutzer", "USER": "Nutzer",
"AUDITOR": "Auditor", "AUDITOR": "Auditor",
+15
View File
@@ -132,6 +132,10 @@
"VERIFY": "Verify with Steam", "VERIFY": "Verify with Steam",
"CHANGE": "Link another Steam account", "CHANGE": "Link another Steam account",
"UNLINK": "Unlink", "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", "VERIFIED": "Verified",
"NOT_VERIFIED": "Not 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.", "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.", "SYNC_HINT": "Every sync brings in newly played titles. Clear matches are added; unclear ones wait in the import review.",
"REVIEW": "Review waiting titles", "REVIEW": "Review waiting titles",
"UNLINK": "Unlink", "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.", "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.", "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.", "UNAVAILABLE": "PlayStation did not answer. Please try again later.",
@@ -367,6 +374,7 @@
"WELCOME": "Control your game backlog", "WELCOME": "Control your game backlog",
"DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.", "DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.",
"GET_STARTED": "Get Started", "GET_STARTED": "Get Started",
"APPROVAL_HINT": "An admin approves every new account before the first login.",
"LOGIN": "Log in", "LOGIN": "Log in",
"LOGOUT": "Log out" "LOGOUT": "Log out"
}, },
@@ -674,6 +682,13 @@
"GAMES_other": "{{count}} games" "GAMES_other": "{{count}} games"
} }
}, },
"DIALOG": {
"CANCEL": "Cancel",
"CLOSE": "Close"
},
"NOT_FOUND": {
"TITLE": "Page not found"
},
"ROLES": { "ROLES": {
"USER": "User", "USER": "User",
"AUDITOR": "Auditor", "AUDITOR": "Auditor",
+5 -1
View File
@@ -5,6 +5,7 @@ import {useEffect} from 'react';
import LandingPage from './pages/LandingPage'; import LandingPage from './pages/LandingPage';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import ImprintPage from './pages/ImprintPage'; import ImprintPage from './pages/ImprintPage';
import NotFoundPage from './pages/NotFoundPage.jsx';
import Dashboard from "./pages/Dashboard.jsx"; import Dashboard from "./pages/Dashboard.jsx";
import RegistrationPage from './pages/RegistrationPage.jsx'; import RegistrationPage from './pages/RegistrationPage.jsx';
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx'; import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
@@ -46,7 +47,7 @@ import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
import {logout} from './utils/api.js'; import {logout} from './utils/api.js';
function App() { function App() {
const {t} = useTranslation(); const {t, i18n} = useTranslation();
const {active: isAuthenticated} = useSession(); const {active: isAuthenticated} = useSession();
const {appearance, equipped, syncPreferences} = usePreferences(); const {appearance, equipped, syncPreferences} = usePreferences();
@@ -54,6 +55,8 @@ function App() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
useEffect(() => { document.documentElement.lang = i18n.language; }, [i18n.language]);
// On login and on page load; the cached look shows until Auth answers. // On login and on page load; the cached look shows until Auth answers.
useEffect(() => { useEffect(() => {
if (isAuthenticated) syncPreferences(); if (isAuthenticated) syncPreferences();
@@ -110,6 +113,7 @@ function App() {
</Route> </Route>
<Route path="/u/:name" element={<PublicProfilePage/>}/> <Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/> <Route path="/impressum" element={<ImprintPage/>}/>
<Route path="*" element={<NotFoundPage/>}/>
</Routes> </Routes>
</main> </main>
+8
View File
@@ -24,6 +24,14 @@ afterEach(() => {
}); });
describe('App', () => { 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 () => { it('ends the session on a 401 that the refresh cannot fix', async () => {
let expired = false; let expired = false;
const library = () => (expired ? json(401, {}) : json(200, page)); 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 {user: profile} = useCurrentUserContext();
const accountRows = [ const accountRows = [
[t('SETTINGS.EMAIL'), profile?.email], [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.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, 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 {useState} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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 {toast} from 'sonner';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import UnlinkDialog from './UnlinkDialog.jsx';
import usePsnAccount from '../hooks/usePsnAccount.js'; import usePsnAccount from '../hooks/usePsnAccount.js';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.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> <Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
</Button> </Button>
)} )}
<Button type="button" variant="outline" onClick={unlink} disabled={busy}> <UnlinkDialog label={t('SETTINGS.PSN.UNLINK')} title={t('SETTINGS.PSN.UNLINK_TITLE')}
<Unlink/>{t('SETTINGS.PSN.UNLINK')} description={t('SETTINGS.PSN.UNLINK_EXPLAIN')}
</Button> confirmLabel={t('SETTINGS.PSN.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}/>
</div> </div>
</> </>
)} )}
+17 -2
View File
@@ -118,13 +118,28 @@ describe('PsnAccountCard', () => {
expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull(); 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({ renderCard({
'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))), 'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))),
'DELETE /api/library/psn-account': noBody, '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(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED'); 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 {useTranslation} from 'react-i18next';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import useReducedMotion from '../hooks/useReducedMotion.js';
import {useDrillTarget} from './stats/StatDrill.jsx'; import {useDrillTarget} from './stats/StatDrill.jsx';
function useCountUp(target, durationMs = 1600) { function useCountUp(target, instant, durationMs = 1600) {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
useEffect(() => { useEffect(() => {
const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0; const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0;
if (safeTarget === 0) { if (instant || safeTarget === 0) {
setValue(0); setValue(safeTarget);
return; return;
} }
let raf; let raf;
@@ -22,7 +23,7 @@ function useCountUp(target, durationMs = 1600) {
}; };
raf = requestAnimationFrame(step); raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [target, durationMs]); }, [target, instant, durationMs]);
return value; return value;
} }
@@ -32,7 +33,8 @@ function useCountUp(target, durationMs = 1600) {
*/ */
export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) { export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) {
const {i18n} = useTranslation(); 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 target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick});
const card = ( const card = (
<Card className="gap-3 p-5"> <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 {useState} from 'react';
import {useTranslation} from 'react-i18next'; 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 {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import UnlinkDialog from './UnlinkDialog.jsx';
import {markSession} from '../utils/auth.js'; import {markSession} from '../utils/auth.js';
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js'; import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
@@ -36,7 +37,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
const response = await unlinkSteam(); const response = await unlinkSteam();
if (response.status === 401) return; if (response.status === 401) return;
if (!response.ok) { if (!response.ok) {
toast.error(t('SETTINGS.STEAM_LINK.ERROR')); toast.error(t('SETTINGS.STEAM_LINK.UNLINK_ERROR'));
return; return;
} }
// The new access cookie has no Steam ID; the new stamp makes the header load again. // 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')} {t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
</Button> </Button>
{steamId && ( {steamId && (
<Button type="button" variant="outline" onClick={unlink} disabled={busy} <UnlinkDialog label={t('SETTINGS.STEAM_LINK.UNLINK')} title={t('SETTINGS.STEAM_LINK.UNLINK_TITLE')}
className="h-auto max-w-full whitespace-normal py-2 text-left"> description={t('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')}
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')} confirmLabel={t('SETTINGS.STEAM_LINK.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}
</Button> className="h-auto max-w-full whitespace-normal py-2 text-left"/>
)} )}
</div> </div>
</CardContent> </CardContent>
+7 -1
View File
@@ -4,15 +4,21 @@ import {useTranslation} from 'react-i18next';
import {ArrowRight, Gamepad2} from 'lucide-react'; import {ArrowRight, Gamepad2} from 'lucide-react';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import useReducedMotion from '../hooks/useReducedMotion.js';
const DURATION_MS = 1000; const DURATION_MS = 1000;
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums'; const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
const SteamGameCount = ({targetCount}) => { const SteamGameCount = ({targetCount}) => {
const {t} = useTranslation(); const {t} = useTranslation();
const reduce = useReducedMotion();
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
useEffect(() => { useEffect(() => {
if (reduce) {
setCount(targetCount);
return undefined;
}
let frame; let frame;
let startTime; let startTime;
const animate = (timestamp) => { const animate = (timestamp) => {
@@ -25,7 +31,7 @@ const SteamGameCount = ({targetCount}) => {
}; };
frame = requestAnimationFrame(animate); frame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [targetCount]); }, [targetCount, reduce]);
return ( return (
<Card className="relative justify-between overflow-hidden p-6"> <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')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
describe('SteamGameCount', () => { describe('SteamGameCount', () => {
afterEach(cleanup); afterEach(() => {
cleanup();
delete document.documentElement.dataset.reduceMotion;
});
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => { it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>); const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
@@ -17,4 +20,12 @@ describe('SteamGameCount', () => {
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2); expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy(); 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'; 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 * The news entries the user has not seen, newest first. Any close (Got it, X, Esc, click outside) marks them seen.
* way shows them again on the next dashboard visit.
*/ */
export default function WhatsNewDialog() { export default function WhatsNewDialog() {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -39,7 +38,7 @@ export default function WhatsNewDialog() {
}; };
return ( 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}> <DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle> <DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
+14 -3
View File
@@ -50,13 +50,24 @@ describe('WhatsNewDialog', () => {
expect(screen.queryByRole('dialog')).toBeNull(); 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); 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(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([ it.each([
+3 -1
View File
@@ -1,4 +1,5 @@
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import { useTranslation } from "react-i18next"
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -26,6 +27,7 @@ function DialogContent({
children, children,
...props ...props
}) { }) {
const { t } = useTranslation()
return ( return (
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
@@ -43,7 +45,7 @@ function DialogContent({
<DialogPrimitive.Close <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"> 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" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">{t('DIALOG.CLOSE')}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
+2 -1
View File
@@ -1,6 +1,6 @@
import {StrictMode} from 'react'; import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client'; 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 App from './App.jsx';
import {PreferencesProvider} from './context/PreferencesContext.jsx'; import {PreferencesProvider} from './context/PreferencesContext.jsx';
import {CurrentUserProvider} from './context/CurrentUserContext.jsx'; import {CurrentUserProvider} from './context/CurrentUserContext.jsx';
@@ -14,6 +14,7 @@ const router = createBrowserRouter([{
<PreferencesProvider> <PreferencesProvider>
<CurrentUserProvider> <CurrentUserProvider>
<App/> <App/>
<ScrollRestoration/>
</CurrentUserProvider> </CurrentUserProvider>
</PreferencesProvider> </PreferencesProvider>
), ),
+18 -6
View File
@@ -482,14 +482,26 @@ describe('GameDetail confetti', () => {
return container.querySelector('canvas[data-size]'); return container.querySelector('canvas[data-size]');
}; };
it('plays a small one for a finished game', async () => { it('plays nothing on opening a finished game', async () => {
expect((await opened({status: 'FINISHED'})).dataset.size).toBe('small'); expect(await opened({status: 'FINISHED'})).toBeNull();
}); });
it('plays a big one when every achievement is unlocked too', async () => { it('plays nothing on opening a completed game', async () => {
const canvas = await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true, expect(await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true,
achievementsTotal: 3, achievementsUnlocked: 3}); achievementsTotal: 3, achievementsUnlocked: 3})).toBeNull();
expect(canvas.dataset.size).toBe('big'); });
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 () => { 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 {apiFetch, request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js';
import {platformLabel} from '../utils/platforms.js'; import {platformLabel} from '../utils/platforms.js';
import {sessionStamp} from '../utils/auth.js';
const PAGE_SIZES = [20, 40, 80]; const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20; const DEFAULT_SIZE = 20;
@@ -45,6 +46,16 @@ const SORT_OPTIONS = [
{value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'}, {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() { export default function GameLibrary() {
const {t} = useTranslation(); const {t} = useTranslation();
const {equipped} = usePreferences(); const {equipped} = usePreferences();
@@ -73,16 +84,18 @@ export default function GameLibrary() {
const [canSync, setCanSync] = useState(true); const [canSync, setCanSync] = useState(true);
const [ownSources, setOwnSources] = useState([]); const [ownSources, setOwnSources] = useState([]);
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); const query = libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey});
const [isLoading, setIsLoading] = useState(true); 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 [error, setError] = useState(null);
const nextSignal = useAbortPrevious(); const nextSignal = useAbortPrevious();
const load = useCallback(async () => { const load = useCallback(async () => {
const signal = nextSignal(); const signal = nextSignal();
setIsLoading(true); setIsLoading(!cachedFor(query));
setError(null); 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}); const {ok, status, data: json} = await request(url, {signal});
if (signal.aborted) return; if (signal.aborted) return;
setIsLoading(false); setIsLoading(false);
@@ -91,8 +104,9 @@ export default function GameLibrary() {
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD'); setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
return; return;
} }
lastPage = {query, data: json, stamp: sessionStamp()};
setData(json); setData(json);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, sourceKey]); }, [nextSignal, query]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -129,10 +143,10 @@ export default function GameLibrary() {
}, },
}); });
const updateParams = useCallback((next) => { const updateParams = useCallback((next, options) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v)))); Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
setSearchParams(params); setSearchParams(params, options);
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams]);
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing. // 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. // updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params.
useEffect(() => { useEffect(() => {
if (search.trim() === q) return undefined; 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); return () => clearTimeout(timer);
}, [search, q, updateParams]); }, [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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom'; 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 {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {clearSession, markSession} from '../utils/auth.js';
import {toast} from 'sonner'; import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -48,6 +49,7 @@ function renderLibrary(initialEntry = '/library') {
}], {initialEntries: [initialEntry]}); }], {initialEntries: [initialEntry]});
render(<RouterProvider router={router}/>); render(<RouterProvider router={router}/>);
return { return {
router,
navigations: () => lib.navigations, navigations: () => lib.navigations,
params: () => new URLSearchParams(lib.location.search), params: () => new URLSearchParams(lib.location.search),
back: () => lib.navigate(-1), back: () => lib.navigate(-1),
@@ -57,6 +59,7 @@ function renderLibrary(initialEntry = '/library') {
const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => { beforeEach(() => {
clearLibraryPageCache();
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
answer(); answer();
}); });
@@ -66,6 +69,42 @@ afterEach(() => {
localStorage.clear(); 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', () => { describe('GameLibrary search', () => {
it('shows the coming badge only for a game that is not released yet', async () => { it('shows the coming badge only for a game that is not released yet', async () => {
answer([ answer([
@@ -163,14 +202,26 @@ describe('GameLibrary search', () => {
expect(lib.params().get('status')).toBe('DROPPED'); 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 () => { it('shows the URL search again after back navigation', async () => {
const lib = renderLibrary(); const lib = renderLibrary();
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.DROPPED'}));
await waitFor(() => expect(lib.params().get('status')).toBe('DROPPED'));
typeInto('half'); typeInto('half');
await waitFor(() => expect(lib.params().get('q')).toBe('half')); await waitFor(() => expect(lib.params().get('q')).toBe('half'));
await act(async () => lib.back()); await act(async () => lib.back());
await waitFor(() => expect(screen.getByRole('searchbox').value).toBe('')); 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 ? ( {active ? (
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link> <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> </Button>
{!active && <p className="mt-4 text-sm text-muted-foreground">{t('MAIN.APPROVAL_HINT')}</p>}
</div> </div>
</section> </section>
+15
View File
@@ -36,6 +36,21 @@ describe('LandingPage', () => {
expect(screen.getByText('2025')).toBeTruthy(); 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 () => { it('hides the three blocks while their lists are empty', async () => {
answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []}); answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []});
renderPage(); 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'); }}; 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({ fetchRoutes({
'GET /api/users/me': json(200, me), 'GET /api/users/me': json(200, me),
'POST /api/users/steam-link': json(200, {redirectUrl}), '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, 'PUT /api/users/settings': noBody,
'POST /api/auth/refresh': noBody, 'POST /api/auth/refresh': noBody,
'GET /api/version': json(404, {}), '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); 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})); 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(await screen.findByText('SETTINGS.STEAM_LINK.NONE')).toBeTruthy();
expect(calls('DELETE')[0][0]).toMatch(/\/api\/users\/steam-link$/); 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 () => { it('never sends a Steam ID with the settings', async () => {
renderPage(profile()); 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 * The confetti to play: only when the level rises while the page shows the game (the user sets Finished or completes
* same page (e.g. the user sets FINISHED). A reload of the same game plays nothing. * 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 * @param before {appid, level} of the game shown before, null on opening the page
* @returns 'small', 'big' or null * @returns 'small', 'big' or null
*/ */
export const celebrationFor = (before, game) => { export const celebrationFor = (before, game) => {
if (before?.appid !== game.appid) return null;
const level = celebrationLevel(game); const level = celebrationLevel(game);
const from = before?.appid === game.appid ? before.level : 0; if (level <= before.level) return null;
if (level <= from) return null;
return level === 2 ? 'big' : 'small'; return level === 2 ? 'big' : 'small';
}; };
+5 -5
View File
@@ -19,9 +19,9 @@ describe('celebrationLevel', () => {
}); });
describe('celebrationFor', () => { describe('celebrationFor', () => {
it('plays on opening a finished game', () => { it('plays nothing on opening a game, finished or not', () => {
expect(celebrationFor(null, game('FINISHED'))).toBe('small'); expect(celebrationFor(null, game('FINISHED'))).toBeNull();
expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBe('big'); expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBeNull();
expect(celebrationFor(null, game('BACKLOG'))).toBeNull(); expect(celebrationFor(null, game('BACKLOG'))).toBeNull();
}); });
@@ -32,7 +32,7 @@ describe('celebrationFor', () => {
expect(celebrationFor({appid: 620, level: 2}, game('PLAYING'))).toBeNull(); expect(celebrationFor({appid: 620, level: 2}, game('PLAYING'))).toBeNull();
}); });
it('plays for another game opened from the page', () => { it('plays nothing for another game opened from the page', () => {
expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBe('small'); expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBeNull();
}); });
}); });