{game.name ?? `App ${game.appid}`}
diff --git a/src/components/HeroArt.test.jsx b/src/components/HeroArt.test.jsx
index 8e43d01..facf2b6 100644
--- a/src/components/HeroArt.test.jsx
+++ b/src/components/HeroArt.test.jsx
@@ -27,6 +27,13 @@ describe('PageBackground', () => {
expect(container.innerHTML).toBe('');
});
+ it('shows a worn animated background instead of the art', () => {
+ const {container} = render( );
+
+ expect(container.querySelector('[data-effect="aurora"]')).not.toBeNull();
+ expect(container.querySelector('img')).toBeNull();
+ });
+
it('starts with the library art again for another game', () => {
const {container, rerender} = render();
fireEvent.error(container.querySelector('img'));
diff --git a/src/components/HeroBanner.jsx b/src/components/HeroBanner.jsx
index 989ecbe..98a72f8 100644
--- a/src/components/HeroBanner.jsx
+++ b/src/components/HeroBanner.jsx
@@ -1,15 +1,20 @@
import {cn} from '@/lib/utils';
+import EffectBackground from './EffectBackground.jsx';
import HeroArt from './HeroArt.jsx';
/**
* The art of a game behind a card's content, or the accent gradient without one. Put it first in a card with
* `relative overflow-hidden`; the gradient keeps the text readable.
+ *
+ * @param effect a worn banner background (utils/rewards.js bannerEffect); it replaces the art
*/
-export default function HeroBanner({appid}) {
+export default function HeroBanner({appid, effect}) {
return (
<>
-
-
+ : }
+
>
diff --git a/src/components/MostPlayedCard.jsx b/src/components/MostPlayedCard.jsx
index 4b72bf4..182d41e 100644
--- a/src/components/MostPlayedCard.jsx
+++ b/src/components/MostPlayedCard.jsx
@@ -6,8 +6,9 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js';
+import {coverEffect} from '../utils/rewards.js';
-export default function MostPlayedCard({games, syncing}) {
+export default function MostPlayedCard({games, syncing, equipped}) {
const {t} = useTranslation();
const maxMinutes = games[0]?.playtimeMinutes || 1;
return (
@@ -37,7 +38,8 @@ export default function MostPlayedCard({games, syncing}) {
{index + 1}
-
+
{g.name ?? `App ${g.appid}`}
diff --git a/src/components/PageBackground.jsx b/src/components/PageBackground.jsx
index 36479df..af1e8c2 100644
--- a/src/components/PageBackground.jsx
+++ b/src/components/PageBackground.jsx
@@ -1,9 +1,19 @@
+import EffectBackground from './EffectBackground.jsx';
import HeroArt from './HeroArt.jsx';
/**
- * Game art behind the whole page, blurred and dimmed so text on it stays readable.
+ * A worn animated background (utils/rewards.js backgroundEffect) or the game art behind the whole page, dimmed
+ * (and the art blurred) so text on it stays readable. The effect wins over the art.
*/
-export default function PageBackground({appid}) {
+export default function PageBackground({appid, effect}) {
+ if (effect) {
+ return (
+ <>
+
+
+ >
+ );
+ }
if (!appid) return null;
return (
diff --git a/src/context/PreferencesContext.jsx b/src/context/PreferencesContext.jsx
index c051ab8..d428c42 100644
--- a/src/context/PreferencesContext.jsx
+++ b/src/context/PreferencesContext.jsx
@@ -12,7 +12,7 @@ import {
import {hasSession} from '../utils/auth.js';
import useSession from '../hooks/useSession.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
-import {equippedAccent, loadWallet} from '../utils/rewards.js';
+import {cacheEquipped, equippedAccent, loadWallet, readCachedEquipped} from '../utils/rewards.js';
import {storage} from '../utils/storage.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
@@ -52,17 +52,23 @@ export function PreferencesProvider({children}) {
// Pile Points: balance, shop and worn looks of Core; null until loaded.
const [wallet, setWallet] = useState(null);
+ // The worn looks: the cached ones until the wallet answers.
+ const [equipped, setEquipped] = useState(readCachedEquipped);
// Layout effect: the cached look is set before the first paint.
useLayoutEffect(() => {
- applyAppearance(appearance, equippedAccent(wallet?.equipped));
+ applyAppearance(appearance, equippedAccent(equipped));
cacheAppearance(appearance);
- }, [appearance, wallet]);
+ cacheEquipped(equipped);
+ }, [appearance, equipped]);
const reloadWallet = useCallback(async () => {
const started = generation.current;
const {ok, data} = await loadWallet();
- if (ok && generation.current === started) setWallet(data);
+ if (ok && generation.current === started) {
+ setWallet(data);
+ setEquipped(data.equipped ?? {});
+ }
}, []);
// Grows on every language pick, so a load that started before does not undo it.
@@ -136,6 +142,7 @@ export function PreferencesProvider({children}) {
savedAppearance.current = DEFAULT_APPEARANCE;
setAppearanceState(DEFAULT_APPEARANCE);
setWallet(null);
+ setEquipped({});
}, []);
// Without a session the next user must not get this look.
@@ -166,6 +173,7 @@ export function PreferencesProvider({children}) {
appearance,
changeAppearance,
wallet,
+ equipped,
reloadWallet,
syncPreferences,
resetAppearance,
diff --git a/src/context/PreferencesContext.test.jsx b/src/context/PreferencesContext.test.jsx
index 1497809..cb4df2a 100644
--- a/src/context/PreferencesContext.test.jsx
+++ b/src/context/PreferencesContext.test.jsx
@@ -109,3 +109,32 @@ describe('PreferencesProvider theme color', () => {
expect(themeColor()).toBe('#09090b');
});
});
+
+describe('PreferencesProvider worn looks', () => {
+ it('has the cached worn looks before the wallet answers and caches the answer', async () => {
+ localStorage.setItem('tbp_session', '1');
+ localStorage.setItem('tbp_equipped', JSON.stringify({PAGE_BACKGROUND: 'BG_GRID'}));
+ fetchRoutes({
+ 'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}),
+ 'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {PAGE_BACKGROUND: 'BG_MESH'}, history: []}),
+ });
+
+ render( );
+ expect(preferences.wallet).toBeNull();
+ expect(preferences.equipped).toEqual({PAGE_BACKGROUND: 'BG_GRID'});
+
+ await act(() => preferences.syncPreferences());
+
+ expect(preferences.equipped).toEqual({PAGE_BACKGROUND: 'BG_MESH'});
+ expect(JSON.parse(localStorage.getItem('tbp_equipped'))).toEqual({PAGE_BACKGROUND: 'BG_MESH'});
+ });
+
+ it('ignores a damaged cache', () => {
+ localStorage.setItem('tbp_session', '1');
+ localStorage.setItem('tbp_equipped', '[1]');
+
+ render( );
+
+ expect(preferences.equipped).toEqual({});
+ });
+});
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index 83ac379..0303cf1 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -60,7 +60,7 @@ const Dashboard = () => {
const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0;
- const {appearance, wallet} = usePreferences();
+ const {appearance, equipped} = usePreferences();
const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = appearance.showBanner
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
@@ -99,7 +99,8 @@ const Dashboard = () => {
switch (id) {
case 'GAME_COUNT': return showLibrary && ;
case 'MOST_PLAYED':
- return showLibrary && ;
+ return showLibrary && ;
case 'PLAYING':
return showLibrary && (
@@ -127,7 +128,7 @@ const Dashboard = () => {
return (
setEditing(true)}/>
import('../test/i18n-mock.js'));
+vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
+
+const game = {
+ appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 0, source: 'STEAM',
+ rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
+};
+
+const renderDetail = (status) => {
+ fetchRoutes({
+ 'GET /api/users/me': json(200, {settings: {}}),
+ 'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
+ 'GET /api/library/games/220': json(200, {...game, status}),
+ });
+ return renderWithProviders(<> >, {path: '/library/:appid', entries: ['/library/220']});
+};
+
+beforeEach(() => localStorage.setItem('tbp_session', '1'));
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
+
+describe('GameDetail completed effect', () => {
+ it('decorates the card of a COMPLETED game with the worn effect and its cover', async () => {
+ const {container} = renderDetail('COMPLETED');
+
+ await waitFor(() => expect(container.querySelector('.fx-detail-gold-shine')).not.toBeNull());
+ const decoration = container.querySelector('.fx-detail-gold-shine');
+ expect(decoration.getAttribute('aria-hidden')).toBe('true');
+ expect(decoration.children).toHaveLength(8);
+ expect(container.querySelector('.fx-cover-gold-shine')).not.toBeNull();
+ });
+
+ it('shows nothing for a FINISHED game', async () => {
+ const {container} = renderDetail('FINISHED');
+
+ await screen.findByText('Half-Life 2');
+ await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.includes('/rewards/wallet'))).toBe(true));
+ await act(async () => {});
+ expect(container.querySelector('.fx-detail-gold-shine')).toBeNull();
+ expect(container.querySelector('.fx-cover-gold-shine')).toBeNull();
+ });
+});
diff --git a/src/pages/GameLibrary.effects.test.jsx b/src/pages/GameLibrary.effects.test.jsx
new file mode 100644
index 0000000..7e84ff4
--- /dev/null
+++ b/src/pages/GameLibrary.effects.test.jsx
@@ -0,0 +1,42 @@
+// @vitest-environment jsdom
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {cleanup, screen, waitFor} from '@testing-library/react';
+import GameLibrary from './GameLibrary.jsx';
+import {fetchRoutes, json} from '../test/fetchRoutes.js';
+import {renderWithProviders} from '../test/renderWithProviders.jsx';
+import WalletLoader from '../test/WalletLoader.jsx';
+
+vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
+vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
+vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
+vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
+
+const games = [
+ {appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'COMPLETED'},
+ {appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'FINISHED'},
+];
+
+beforeEach(() => {
+ localStorage.setItem('tbp_session', '1');
+ fetchRoutes({
+ 'GET /api/users/me': json(200, {settings: {}}),
+ 'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
+ 'GET /api/library/games': json(200, {content: games, page: 0, size: 20, totalElements: 2, totalPages: 1}),
+ 'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true}),
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
+
+describe('GameLibrary completed effect', () => {
+ it('puts the worn effect on the cover of a COMPLETED game only', async () => {
+ renderWithProviders(<> >, {entries: ['/library']});
+
+ const cover = async (name) => (await screen.findByText(name)).closest('li').querySelector('img').parentElement;
+ await waitFor(async () => expect((await cover('Portal 2')).classList.contains('fx-cover-gold-shine')).toBe(true));
+ expect((await cover('Half-Life 2')).classList.contains('fx-cover-gold-shine')).toBe(false);
+ });
+});
diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx
index 1ad2565..3714d11 100644
--- a/src/pages/GameLibrary.jsx
+++ b/src/pages/GameLibrary.jsx
@@ -20,6 +20,8 @@ import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
+import {usePreferences} from '../context/PreferencesContext.jsx';
+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';
@@ -41,6 +43,7 @@ const SORT_OPTIONS = [
export default function GameLibrary() {
const {t} = useTranslation();
+ const {equipped} = usePreferences();
const [searchParams, setSearchParams] = useSearchParams();
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
@@ -286,7 +289,8 @@ export default function GameLibrary() {
to={`/library/${g.appid}`}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
-
+
{g.name ?? `App ${g.appid}`}
diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx
index dd00a5b..69ad8f8 100644
--- a/src/pages/PublicProfilePage.jsx
+++ b/src/pages/PublicProfilePage.jsx
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
-import {equippedAccent, lookClass} from '../utils/rewards.js';
+import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
@@ -135,7 +135,7 @@ export default function PublicProfilePage() {
return library.playing?.length > 0
&& ;
case 'FINISHED':
- return library.finished?.length > 0 && ;
+ return library.finished?.length > 0 && ;
default: return statGroupTile(id, library.stats, false);
}
};
@@ -153,10 +153,10 @@ export default function PublicProfilePage() {
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
return (
-
+
-
+
{t('PROFILE.TITLE')}
{profile.displayName}
@@ -247,7 +247,7 @@ function Playing({games, compact}) {
);
}
-function Finished({games}) {
+function Finished({games, equipped}) {
const {t} = useTranslation();
return (
@@ -257,7 +257,8 @@ function Finished({games}) {
{games.map((g) => (
-
-
+
{g.name ?? `App ${g.appid}`}
{g.rating && (
diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx
index 8288388..590c81a 100644
--- a/src/pages/PublicProfilePage.test.jsx
+++ b/src/pages/PublicProfilePage.test.jsx
@@ -44,6 +44,17 @@ describe('PublicProfilePage', () => {
expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket');
});
+ it('wears the effects of the owner: background, banner and the cover of a COMPLETED game', async () => {
+ mockBackends({style: {accent: 'red'}, equipped: {
+ PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES', COMPLETED_EFFECT: 'FX_NEON_PULSE'}});
+ const {container} = renderPage();
+
+ expect(await screen.findByText('Portal 2')).toBeTruthy();
+ await waitFor(() => expect(container.querySelector('[data-effect="starfield"]')).not.toBeNull());
+ expect(screen.getByText('Lobo').closest('[data-slot="card"]').querySelector('[data-effect="waves"]')).not.toBeNull();
+ expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull();
+ });
+
it('still shows the profile when the looks call fails', async () => {
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
renderPage();
diff --git a/src/test/WalletLoader.jsx b/src/test/WalletLoader.jsx
new file mode 100644
index 0000000..731013f
--- /dev/null
+++ b/src/test/WalletLoader.jsx
@@ -0,0 +1,9 @@
+import {useEffect} from 'react';
+import {usePreferences} from '../context/PreferencesContext.jsx';
+
+/** Loads the preferences and the wallet like App does after the login, so a page under test sees the worn looks. */
+export default function WalletLoader() {
+ const {syncPreferences} = usePreferences();
+ useEffect(() => { syncPreferences(); }, [syncPreferences]);
+ return null;
+}
{t('PROFILE.TITLE')}
{profile.displayName}
@@ -247,7 +247,7 @@ function Playing({games, compact}) { ); } -function Finished({games}) { +function Finished({games, equipped}) { const {t} = useTranslation(); return (-
{games.map((g) => (
-
-
+ {g.name ?? `App ${g.appid}`} {g.rating && ( diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index 8288388..590c81a 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -44,6 +44,17 @@ describe('PublicProfilePage', () => { expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket'); }); + it('wears the effects of the owner: background, banner and the cover of a COMPLETED game', async () => { + mockBackends({style: {accent: 'red'}, equipped: { + PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES', COMPLETED_EFFECT: 'FX_NEON_PULSE'}}); + const {container} = renderPage(); + + expect(await screen.findByText('Portal 2')).toBeTruthy(); + await waitFor(() => expect(container.querySelector('[data-effect="starfield"]')).not.toBeNull()); + expect(screen.getByText('Lobo').closest('[data-slot="card"]').querySelector('[data-effect="waves"]')).not.toBeNull(); + expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull(); + }); + it('still shows the profile when the looks call fails', async () => { mockBackends({style: {accent: 'red'}, rewardsStatus: 500}); renderPage(); diff --git a/src/test/WalletLoader.jsx b/src/test/WalletLoader.jsx new file mode 100644 index 0000000..731013f --- /dev/null +++ b/src/test/WalletLoader.jsx @@ -0,0 +1,9 @@ +import {useEffect} from 'react'; +import {usePreferences} from '../context/PreferencesContext.jsx'; + +/** Loads the preferences and the wallet like App does after the login, so a page under test sees the worn looks. */ +export default function WalletLoader() { + const {syncPreferences} = usePreferences(); + useEffect(() => { syncPreferences(); }, [syncPreferences]); + return null; +}