Public Access
feat(ui): make stat numbers clickable to open games
StatDrill context holds one dialog for a page. StatCount wraps a number: total and status counts link to the library filter, other numbers open StatGamesDialog. DrillRow and DrillButton support drill-down in tag bars (genres, platforms, moods) and other stat displays. Dialog shows 40 games per page with bottom-sheet layout on phones, handles pagination and errors (403 not shared, 401 expired, 400 bad argument).
This commit is contained in:
1 parent
ed5df8c40d
commit
777ba87fd6
31 files changed
+1026
-261
No files matched your search
+14
-11
@@ -23,6 +23,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import TileGrid from '../components/TileGrid.jsx';
|
||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
import {
|
||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES,
|
||||
@@ -122,18 +123,20 @@ const Dashboard = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||
equipped={equipped}
|
||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||
<StatDrillProvider source="own">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||
equipped={equipped}
|
||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
<WhatsNewDialog/>
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
|
||||
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
|
||||
</PageContainer>
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
<WhatsNewDialog/>
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
|
||||
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
|
||||
</PageContainer>
|
||||
</StatDrillProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+105
-70
@@ -14,12 +14,16 @@ import Logo from '../components/Logo.jsx';
|
||||
import StatCard from '../components/StatCard.jsx';
|
||||
import {YearBars} from '../components/stats/FinishedStats.jsx';
|
||||
import {TagBars} from '../components/stats/TasteStats.jsx';
|
||||
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
||||
import useSession from '../hooks/useSession.js';
|
||||
import {genreKey} from '../utils/stats.js';
|
||||
import {gameHref} from '../utils/statGames.js';
|
||||
|
||||
import usePublicStats from '../hooks/usePublicStats.js';
|
||||
|
||||
function RankedGames({title, games, formatValue}) {
|
||||
function RankedGames({title, games, formatValue, drillKey}) {
|
||||
const {t} = useTranslation();
|
||||
const drill = useStatDrill();
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
|
||||
@@ -31,15 +35,58 @@ function RankedGames({title, games, formatValue}) {
|
||||
<li key={g.appid} className="flex items-center gap-3 px-5 py-3">
|
||||
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
|
||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
|
||||
<span className="@container min-w-0 flex-1 font-medium"><FitText text={g.name ?? `App ${g.appid}`}/></span>
|
||||
<GameName game={g}/>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{drill && (
|
||||
<Button variant="ghost" className="m-2 self-start max-sm:min-h-11"
|
||||
onClick={() => drill.open(title, {key: drillKey})}>
|
||||
{t('STATS.DRILL.SHOW_ALL')}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function GameName({game}) {
|
||||
const href = gameHref(game.appid, false);
|
||||
const name = <FitText text={game.name ?? `App ${game.appid}`}/>;
|
||||
return (
|
||||
<span className="@container min-w-0 flex-1 font-medium">
|
||||
{href ? <a href={href} target="_blank" rel="noopener noreferrer" className="hover:text-primary">{name}</a> : name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityBars({core}) {
|
||||
const {t} = useTranslation();
|
||||
const drill = useStatDrill();
|
||||
const select = useTagDrill();
|
||||
const genreLabel = (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name});
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{core?.gamesPerPlatform?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.PLATFORMS')}>
|
||||
<TagBars tags={core.gamesPerPlatform} onSelect={select('PLATFORM')}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
{core?.topGenres?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.TOP_GENRES')}>
|
||||
<TagBars tags={core.topGenres} label={genreLabel} onSelect={select('GENRE')}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
{core?.finishedPerYear?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
|
||||
<YearBars perYear={core.finishedPerYear}
|
||||
onSelect={drill && ((year) => drill.open(`${t('LANDING.FINISHED_PER_YEAR')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarsCard({title, children}) {
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
@@ -60,81 +107,69 @@ export default function LandingPage() {
|
||||
const stats = [
|
||||
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
|
||||
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
|
||||
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2],
|
||||
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles],
|
||||
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks],
|
||||
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
|
||||
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
|
||||
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
|
||||
[t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
|
||||
[t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
|
||||
[t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grow">
|
||||
<section className="relative overflow-hidden border-b">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
|
||||
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
|
||||
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
|
||||
<span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
|
||||
<Button asChild size="lg" className="mt-10">
|
||||
{active ? (
|
||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||
) : (
|
||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||
<StatDrillProvider source="community">
|
||||
<div className="grow">
|
||||
<section className="relative overflow-hidden border-b">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
|
||||
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
|
||||
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
|
||||
<span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
|
||||
<Button asChild size="lg" className="mt-10">
|
||||
{active ? (
|
||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||
) : (
|
||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
|
||||
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{isLoading
|
||||
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
||||
: stats.map(([label, value, icon, suffix, key]) => (
|
||||
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}
|
||||
drill={key && {params: {key}}}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{core?.topPlayedGames?.length > 0 && (
|
||||
<RankedGames
|
||||
title={t('LANDING.TOP_PLAYED')}
|
||||
games={core.topPlayedGames}
|
||||
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
|
||||
drillKey="MOST_PLAYED"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{core?.topOwnedGames?.length > 0 && (
|
||||
<RankedGames
|
||||
title={t('LANDING.TOP_OWNED')}
|
||||
games={core.topOwnedGames}
|
||||
formatValue={(count) => count.toLocaleString(i18n.language)}
|
||||
drillKey="OWNED"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
|
||||
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{isLoading
|
||||
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
||||
: stats.map(([label, value, icon, suffix]) => (
|
||||
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{core?.topPlayedGames?.length > 0 && (
|
||||
<RankedGames
|
||||
title={t('LANDING.TOP_PLAYED')}
|
||||
games={core.topPlayedGames}
|
||||
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
|
||||
/>
|
||||
)}
|
||||
{core?.topOwnedGames?.length > 0 && (
|
||||
<RankedGames
|
||||
title={t('LANDING.TOP_OWNED')}
|
||||
games={core.topOwnedGames}
|
||||
formatValue={(count) => count.toLocaleString(i18n.language)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{core?.gamesPerPlatform?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.PLATFORMS')}>
|
||||
<TagBars tags={core.gamesPerPlatform}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
{core?.topGenres?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.TOP_GENRES')}>
|
||||
<TagBars tags={core.topGenres}
|
||||
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
{core?.finishedPerYear?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
|
||||
<YearBars perYear={core.finishedPerYear}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<CommunityBars core={core}/>
|
||||
</section>
|
||||
</div>
|
||||
</StatDrillProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import LandingPage from './LandingPage.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
@@ -42,4 +42,20 @@ describe('LandingPage', () => {
|
||||
expect(screen.queryByText(`LANDING.${title}`)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}),
|
||||
'GET /api/stats/public/games': json(200, {content: [{appid: 3, name: 'Common', libraries: 2, minutes: 60}],
|
||||
page: 0, size: 40, totalElements: 1, totalPages: 1}),
|
||||
});
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: /LANDING\.TOTAL_GAMES/}));
|
||||
|
||||
expect(await screen.findByText('Common')).toBeTruthy();
|
||||
expect(fetch.mock.calls.at(-1)[0]).toContain('/api/stats/public/games?key=ENTRIES&page=0');
|
||||
expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull();
|
||||
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
|
||||
});
|
||||
});
|
||||
+95
-79
@@ -12,6 +12,7 @@ import {cn} from '@/lib/utils';
|
||||
import EffectBackground from '../components/EffectBackground.jsx';
|
||||
import FitText from '../components/FitText.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {StatCount, StatDrillProvider, useDrillTarget} from '../components/stats/StatDrill.jsx';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useInView from '../hooks/useInView.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
@@ -60,95 +61,103 @@ export default function PointsPage() {
|
||||
);
|
||||
}
|
||||
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
|
||||
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
|
||||
if (['MONTH', 'STREAK'].includes(entry.kind)) {
|
||||
return (
|
||||
<StatCount params={{kind: entry.kind, ref: entry.ref}} title={`${t(`POINTS.KINDS.${entry.kind}`)} ${entry.ref}`}>
|
||||
{entry.ref}
|
||||
</StatCount>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
|
||||
<p className="mt-2 flex items-center gap-2 text-lg">
|
||||
<Coins className="size-5 text-primary"/>
|
||||
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
|
||||
</p>
|
||||
</div>
|
||||
<StatDrillProvider source="points">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
|
||||
<p className="mt-2 flex items-center gap-2 text-lg">
|
||||
<Coins className="size-5 text-primary"/>
|
||||
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="gap-2 p-5">
|
||||
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card className="gap-2 p-5">
|
||||
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
|
||||
{SLOTS.map((slot) => (
|
||||
<Card key={slot} className="gap-3 p-5">
|
||||
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
|
||||
<ul className="divide-y">
|
||||
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
|
||||
const owned = wallet.owned.includes(item.code);
|
||||
const worn = wallet.equipped[slot] === item.code;
|
||||
const locked = !owned && item.track;
|
||||
return (
|
||||
<li key={item.code} className="flex items-center gap-3 py-3">
|
||||
<Preview code={item.code}/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
|
||||
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
|
||||
{owned ? (
|
||||
<Button size="sm" variant="outline"
|
||||
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
|
||||
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
|
||||
</Button>
|
||||
) : locked ? (
|
||||
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
|
||||
<Button size="sm" disabled={wallet.balance < item.price}
|
||||
onClick={() => act(() => buyItem(item.code))}>
|
||||
{t('POINTS.BUY')}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
|
||||
{SLOTS.map((slot) => (
|
||||
<Card key={slot} className="gap-3 p-5">
|
||||
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
|
||||
<ul className="divide-y">
|
||||
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
|
||||
const owned = wallet.owned.includes(item.code);
|
||||
const worn = wallet.equipped[slot] === item.code;
|
||||
const locked = !owned && item.track;
|
||||
return (
|
||||
<li key={item.code} className="flex items-center gap-3 py-3">
|
||||
<Preview code={item.code}/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
|
||||
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
|
||||
{owned ? (
|
||||
<Button size="sm" variant="outline"
|
||||
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
|
||||
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
|
||||
</Button>
|
||||
</>
|
||||
) : locked ? (
|
||||
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
|
||||
<Button size="sm" disabled={wallet.balance < item.price}
|
||||
onClick={() => act(() => buyItem(item.code))}>
|
||||
{t('POINTS.BUY')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Card className="gap-3 p-5">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
|
||||
{wallet.history.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{wallet.history.map((entry) => {
|
||||
const label = entryLabel(entry);
|
||||
return (
|
||||
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
|
||||
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatDate(entry.eventAt, i18n.language)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 gap-1">
|
||||
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
|
||||
{label && <span className="min-w-0 truncate">{label}</span>}
|
||||
</span>
|
||||
{entry.points !== 0 && (
|
||||
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
||||
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Card className="gap-3 p-5">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
|
||||
{wallet.history.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{wallet.history.map((entry) => {
|
||||
const label = entryLabel(entry);
|
||||
return (
|
||||
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
|
||||
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatDate(entry.eventAt, i18n.language)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 gap-1">
|
||||
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
|
||||
{label && <span className="min-w-0 truncate">{label}</span>}
|
||||
</span>
|
||||
{entry.points !== 0 && (
|
||||
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
||||
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</PageContainer>
|
||||
)}
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</StatDrillProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,8 +201,15 @@ function LockedProgress({item, progress}) {
|
||||
const {t} = useTranslation();
|
||||
const count = Math.min(progress, item.target);
|
||||
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
|
||||
// Playtime months compare two numbers: no list of games shows them.
|
||||
const target = useDrillTarget({
|
||||
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`),
|
||||
});
|
||||
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
|
||||
const Wrapper = target ? 'button' : 'div';
|
||||
return (
|
||||
<div className="flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground">
|
||||
<Wrapper {...(target && {type: 'button', onClick: target.onClick})}
|
||||
className={cn(className, target && 'min-h-11 text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring rounded-sm')}>
|
||||
<span className="flex items-center gap-1">
|
||||
<LockKeyhole className="size-3 shrink-0"/>
|
||||
<span title={text}>{text}</span>
|
||||
@@ -202,6 +218,6 @@ function LockedProgress({item, progress}) {
|
||||
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,34 @@ describe('PointsPage', () => {
|
||||
const bar = within(item).getByRole('progressbar');
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('7');
|
||||
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
||||
expect(within(item).queryByRole('button', {name: 'POINTS.BUY'})).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the games behind a locked bar, and the history of a month', async () => {
|
||||
const page = (appid) => json(200, {content: [{appid, name: `G${appid}`, status: 'FINISHED', at: '2026-09-01T10:00:00Z'}],
|
||||
page: 0, size: 40, totalElements: 1, totalPages: 1, untracked: 3});
|
||||
await renderPage({
|
||||
'GET /api/rewards/progress/GAMES_FINISHED/games': page(1),
|
||||
'GET /api/rewards/history/MONTH/2026-09/games': page(2),
|
||||
});
|
||||
|
||||
const bar = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
|
||||
fireEvent.click(within(bar).getByRole('button'));
|
||||
expect(await screen.findByText('G1')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.FINISHED.BEFORE:3')).toBeTruthy();
|
||||
fireEvent.keyDown(document.activeElement, {key: 'Escape'});
|
||||
await waitFor(() => expect(screen.queryByText('G1')).toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: '2026-09'}));
|
||||
expect(await screen.findByText('G2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no button on a playtime-month bar', async () => {
|
||||
await renderPage({'GET /api/rewards/wallet': json(200, wallet({
|
||||
catalog: [{code: 'BADGE_MONTHS', slot: 'BADGE', price: 0, track: 'PLAY_MORE_MONTHS', target: 3}],
|
||||
}))});
|
||||
|
||||
const item = screen.getByText('POINTS.ITEMS.BADGE_MONTHS').closest('li');
|
||||
expect(within(item).queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import PageBackground from '../components/PageBackground.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import TileGrid from '../components/TileGrid.jsx';
|
||||
import {StatCount, StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
|
||||
// Games per tile size; Core sends at most 8 playing and 12 finished games.
|
||||
@@ -177,8 +178,11 @@ export default function PublicProfilePage() {
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile} editing={owner && editing}
|
||||
defaultLayout={DEFAULT_PROFILE} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
<StatDrillProvider source="profile" ticket={profile.ticket}>
|
||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
|
||||
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
|
||||
onCancel={() => setEditing(false)}/>
|
||||
</StatDrillProvider>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
@@ -187,22 +191,25 @@ export default function PublicProfilePage() {
|
||||
function Summary({library, compact}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const stats = [
|
||||
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
|
||||
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES'), 'TOTAL', library.totalGames],
|
||||
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
|
||||
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED')],
|
||||
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED'), 'FINISHED_ALL', library.finishedGames],
|
||||
];
|
||||
if (library.averageCompletion != null) {
|
||||
stats.push(
|
||||
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')],
|
||||
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES'), 'PERFECT', library.perfectGames],
|
||||
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}>
|
||||
{stats.map(([Icon, value, label]) => (
|
||||
{stats.map(([Icon, value, label, key, count]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<Icon className="mx-auto mb-1 size-4 text-primary"/>
|
||||
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
|
||||
<div className="text-xl md:text-2xl font-bold tabular-nums">
|
||||
<StatCount params={count > 0 ? {key} : null} title={label}
|
||||
label={`${label}: ${value}`}>{value}</StatCount>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -225,7 +232,10 @@ function StatusBar({byStatus}) {
|
||||
<li key={s} className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
||||
{t(`BACKLOG.STATUS.${s}`)}
|
||||
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
|
||||
<StatCount params={byStatus[s] > 0 ? {key: 'STATUS', arg: s} : null} title={t(`BACKLOG.STATUS.${s}`)}
|
||||
label={`${t(`BACKLOG.STATUS.${s}`)}: ${byStatus[s] ?? 0}`}>
|
||||
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
|
||||
</StatCount>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -15,12 +15,13 @@ const library = {
|
||||
};
|
||||
|
||||
// `me` only for a test with a session: without one the page asks nothing about the viewer.
|
||||
function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200} = {}) {
|
||||
function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200, extra = {}} = {}) {
|
||||
fetchRoutes({
|
||||
[`GET /api/public/profiles/${encodeURIComponent(name)}`]:
|
||||
json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}),
|
||||
'GET /api/public/profiles/library': json(libraryStatus, answer),
|
||||
'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}),
|
||||
...extra,
|
||||
...(me && {
|
||||
'GET /api/users/me': json(200, me),
|
||||
'PUT /api/users/settings/profile-tiles': json(204, {}),
|
||||
@@ -260,4 +261,33 @@ describe('PublicProfilePage', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('opensTheGamesBehindAStatusCountWithTheTicket', async () => {
|
||||
mockBackends({extra: {'GET /api/public/profiles/library/stats/games': json(200, {
|
||||
content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})}});
|
||||
const {container} = renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'}));
|
||||
|
||||
expect(await screen.findByText('Backlogged')).toBeTruthy();
|
||||
const call = fetch.mock.calls.find(([url]) => url.includes('/stats/games'));
|
||||
expect(call[0]).toContain('key=STATUS&arg=BACKLOG');
|
||||
expect(call[1].headers['X-Profile-Ticket']).toBe('the-ticket');
|
||||
expect(container.querySelector('a[href^="/library"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('rendersNoButtonForAGroupTheOwnerDoesNotShare', async () => {
|
||||
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
|
||||
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
|
||||
try {
|
||||
mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('STATS.HABITS.TITLE');
|
||||
|
||||
expect(screen.queryByRole('button', {name: /STATS\.PILE/})).toBeNull();
|
||||
} finally {
|
||||
delete library.stats;
|
||||
}
|
||||
});
|
||||
});
|
||||
+33
-30
@@ -12,6 +12,7 @@ import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import PlatformStats from '../components/stats/PlatformStats.jsx';
|
||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
|
||||
const ALL_GROUPS = [];
|
||||
@@ -21,38 +22,40 @@ export default function StatsPage() {
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
|
||||
</div>
|
||||
|
||||
{failed && (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
|
||||
{t('STATS.ERROR_LOAD')}
|
||||
<Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
|
||||
<StatDrillProvider source="own">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!stats && !failed && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Skeleton className="h-48 rounded-xl"/>
|
||||
<Skeleton className="h-48 rounded-xl"/>
|
||||
</div>
|
||||
)}
|
||||
{failed && (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
|
||||
{t('STATS.ERROR_LOAD')}
|
||||
<Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
||||
<PileStats pile={stats.pile}/>
|
||||
<TrendStats trend={stats.trend}/>
|
||||
<TopGamesStats topGames={stats.topGames}/>
|
||||
<TasteStats taste={stats.taste}/>
|
||||
<PlatformStats platforms={stats.platforms}/>
|
||||
<HabitsStats habits={stats.habits}/>
|
||||
<FinishedStats finished={stats.finished}/>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
{!stats && !failed && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Skeleton className="h-48 rounded-xl"/>
|
||||
<Skeleton className="h-48 rounded-xl"/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
||||
<PileStats pile={stats.pile}/>
|
||||
<TrendStats trend={stats.trend}/>
|
||||
<TopGamesStats topGames={stats.topGames}/>
|
||||
<TasteStats taste={stats.taste}/>
|
||||
<PlatformStats platforms={stats.platforms}/>
|
||||
<HabitsStats habits={stats.habits}/>
|
||||
<FinishedStats finished={stats.finished}/>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</StatDrillProvider>
|
||||
);
|
||||
}
|
||||
@@ -89,4 +89,36 @@ describe('StatsPage', () => {
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('opensTheGamesBehindARowWithTheRawName', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/library/stats': json(200, stats()),
|
||||
'GET /api/library/stats/games': json(200, {content: [{appid: 620, name: 'Portal 2', status: 'BACKLOG', playtimeMinutes: 60}],
|
||||
page: 0, size: 40, totalElements: 1, totalPages: 1}),
|
||||
});
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: /STATS\.GENRE/}));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeTruthy();
|
||||
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=GENRE&arg=Shooter&page=0$/));
|
||||
});
|
||||
|
||||
it('linksAStatusCountToTheFilteredLibrary', async () => {
|
||||
answerStats(json(200, stats()));
|
||||
renderPage();
|
||||
|
||||
const link = await screen.findByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 5'});
|
||||
|
||||
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
|
||||
});
|
||||
|
||||
it('leavesTheMoonTripsAlone', async () => {
|
||||
answerStats(json(200, stats()));
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('STATS.HABITS.MOON');
|
||||
|
||||
expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user