Public Access
feat(points): apply worn effects on covers, details and backgrounds
This commit is contained in:
1 parent
dd45c0b5f6
commit
249973dd69
19 files changed
+317
-35
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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})}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user