feat(points): apply worn effects on covers, details and backgrounds
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 27s

This commit is contained in:
lobo committed 2026-10-01 15:09:25 +02:00
1 parent dd45c0b5f6
commit 249973dd69
19 files changed
+317 -35

No files matched your search

+4 -3
View File
@@ -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 && <SteamGameCount targetCount={steamData.count}/>;
case 'MOST_PLAYED':
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}/>;
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}
equipped={equipped}/>;
case 'PLAYING':
return showLibrary && (
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
@@ -127,7 +128,7 @@ const Dashboard = () => {
return (
<PageContainer className="p-4 md:p-8 space-y-6">
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={wallet?.equipped}
equipped={equipped}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, screen, waitFor} from '@testing-library/react';
import GameDetail from './GameDetail.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()}}));
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(<><WalletLoader/><GameDetail/></>, {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();
});
});
+42
View File
@@ -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(<><WalletLoader/><GameLibrary/></>, {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);
});
});
+5 -1
View File
@@ -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"
>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
</span>
+7 -6
View File
@@ -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
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
case 'FINISHED':
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])}/>;
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
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 (
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
<PageBackground appid={style.backgroundAppid}/>
<PageBackground appid={style.backgroundAppid} effect={backgroundEffect(equipped)}/>
<PageContainer className="p-4 md:p-8 space-y-6">
<Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
<HeroBanner appid={style.bannerAppid}/>
<HeroBanner appid={style.bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative">
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1>
@@ -247,7 +247,7 @@ function Playing({games, compact}) {
);
}
function Finished({games}) {
function Finished({games, equipped}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0">
@@ -257,7 +257,8 @@ function Finished({games}) {
<ul className="divide-y">
{games.map((g) => (
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
{g.rating && (
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
+11
View File
@@ -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();