Compare commits

...
4 Commits
Author SHA1 Message Date
lobo 0154202ddc feat(points): equipped looks on dashboard and public profile
Build, Test & Push Frontend / docker (push) Successful in 23s
Build, Test & Push Frontend / quality-check (push) Successful in 23s
Avatar borders frame the avatar on the dashboard, badges show next to the
heading, banner frames wrap the hero image, and equipped accents set the
color on both pages.
2026-10-01 13:17:23 +02:00
lobo ca3d34bad0 feat(points): balance, history and shop page
Add /points page with wallet balance, shop grouped by slot and ledger
history with game names. The wallet loads once in PreferencesContext after
login, so the header shows a balance chip and the link. Premium accents
gold and teal override the free accent while equipped. German and English.
2026-10-01 13:17:04 +02:00
lobo 22633a9e45 feat(stats): add platform statistics and landing blocks
Add PlatformStats component to stats page. Extract YearBars from
FinishedStats for reuse on landing. Add three blocks to landing: games
per platform, top genres, finished per year. Update translations.
2026-10-01 11:10:59 +02:00
lobo b129f6a6e4 feat(welcome): add welcome page for new users after first login
Shows six feature cards and two next steps (Steam, import). The dashboard
redirects to it while Auth settings.welcomeSeenAt is null; "Let's go" and
"Skip" set the stamp. Settings links to it to reopen later.
2026-10-01 10:44:22 +02:00
38 changed files with 1159 additions and 36 deletions

No files matched your search

+10 -1
View File
@@ -30,7 +30,12 @@ To Be Played is split into three repositories:
repeats, and "Start playing" in one click. repeats, and "Start playing" in one click.
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in - **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods; years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
trips to the moon, the longest gaming day, games started but under one hour; finished games per year. trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent and a
banner frame, shown on the dashboard and the public profile. The balance is in the header.
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
@@ -102,6 +107,10 @@ To Be Played is split into three repositories:
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it" - **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new` marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`. (menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
links to it again.
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, - **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own. speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own.
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for "Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
+80 -1
View File
@@ -51,6 +51,7 @@
"ROLE": "Rolle", "ROLE": "Rolle",
"CREATED_DATE": "Mitglied seit", "CREATED_DATE": "Mitglied seit",
"LAST_LOGIN": "Letzter Login", "LAST_LOGIN": "Letzter Login",
"OPEN_WELCOME": "Willkommensseite erneut anzeigen",
"DARK_THEME": "Dunkles Design", "DARK_THEME": "Dunkles Design",
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache",
"LANGUAGE_OPTIONS": { "LANGUAGE_OPTIONS": {
@@ -270,7 +271,10 @@
"BIGGEST_LIBRARY": "Größte Bibliothek", "BIGGEST_LIBRARY": "Größte Bibliothek",
"BIGGEST_BACKLOG": "Größter Backlog", "BIGGEST_BACKLOG": "Größter Backlog",
"TOP_PLAYED": "Meistgespielte Spiele", "TOP_PLAYED": "Meistgespielte Spiele",
"TOP_OWNED": "Meistbesessene Spiele" "TOP_OWNED": "Meistbesessene Spiele",
"PLATFORMS": "Spiele pro Plattform",
"TOP_GENRES": "Top-Genres",
"FINISHED_PER_YEAR": "Abgeschlossen pro Jahr"
}, },
"MAIN": { "MAIN": {
"WELCOME": "Dein Game-Backlog unter Kontrolle", "WELCOME": "Dein Game-Backlog unter Kontrolle",
@@ -290,6 +294,7 @@
"LIGHT_MODE": "Helles Design", "LIGHT_MODE": "Helles Design",
"PLAY_NEXT": "Nächstes Spiel", "PLAY_NEXT": "Nächstes Spiel",
"STATS": "Statistik", "STATS": "Statistik",
"POINTS": "Pile Points",
"ADMIN": "Admin", "ADMIN": "Admin",
"VIEW_AS": { "VIEW_AS": {
"MENU": "Ansehen als", "MENU": "Ansehen als",
@@ -475,6 +480,10 @@
"BEFORE_one": "{{count}} weiteres Spiel wurde vor Beginn des Verlaufs durchgespielt.", "BEFORE_one": "{{count}} weiteres Spiel wurde vor Beginn des Verlaufs durchgespielt.",
"BEFORE_other": "{{count}} weitere Spiele wurden vor Beginn des Verlaufs durchgespielt." "BEFORE_other": "{{count}} weitere Spiele wurden vor Beginn des Verlaufs durchgespielt."
}, },
"PLATFORMS": {
"TITLE": "Spiele pro Plattform",
"EMPTY": "Noch keine Spiele."
},
"GENRE": { "GENRE": {
"ADVENTURE": "Adventure", "ADVENTURE": "Adventure",
"ARCADE": "Arcade", "ARCADE": "Arcade",
@@ -635,6 +644,29 @@
"LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.", "LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.",
"EMPTY": "Noch nichts Neues." "EMPTY": "Noch nichts Neues."
}, },
"WELCOME": {
"TITLE": "Willkommen bei ToBePlayed",
"INTRO": "ToBePlayed sammelt deine Spiele an einem Ort: was du besitzt, was du spielst, was als Nächstes kommt.",
"FEATURE_LIBRARY": "Bibliothek und Pile of Shame",
"FEATURE_LIBRARY_TEXT": "Alle deine Spiele mit Spielzeit, Filtern und Suche. Der Pile zeigt, was du nie angefangen hast.",
"FEATURE_STATUS": "Status",
"FEATURE_STATUS_TEXT": "Markiere ein Spiel als spielend, durch, komplett oder abgebrochen, mit Bewertung und Notizen.",
"FEATURE_STATS": "Deine Statistiken",
"FEATURE_STATS_TEXT": "Summen, der Pile in Stunden, meistgespielte Spiele, Genres und durchgespielte Spiele pro Jahr.",
"FEATURE_PROFILE": "Öffentliches Profil",
"FEATURE_PROFILE_TEXT": "Teile deine Statistiken und was du spielst per Link. Aus, bis du es einschaltest.",
"FEATURE_IMPORT": "Import",
"FEATURE_IMPORT_TEXT": "Hole Spiele anderer Plattformen herein: Liste, CSV, Exporte oder ein Bookmarklet.",
"FEATURE_DASHBOARD": "Dein Dashboard",
"FEATURE_DASHBOARD_TEXT": "Ordne, verstecke und vergrößere die Kacheln. Es folgt dir aufs Handy.",
"OPEN": "Öffnen",
"NEXT_STEPS": "Nächste Schritte",
"STEP_STEAM": "Steam-Konto verknüpfen",
"STEP_IMPORT": "Deine Spiele importieren",
"DONE": "Erledigt",
"GO": "Los geht's",
"SKIP": "Überspringen"
},
"ADMIN_NEWS": { "ADMIN_NEWS": {
"NEW": "Neuer Eintrag", "NEW": "Neuer Eintrag",
"EDIT": "Eintrag bearbeiten", "EDIT": "Eintrag bearbeiten",
@@ -770,6 +802,53 @@
"STORE_LIST_BROKEN": "Die Liste von der Store-Seite konnte nicht gelesen werden.", "STORE_LIST_BROKEN": "Die Liste von der Store-Seite konnte nicht gelesen werden.",
"STORE_LIST_WAITS": "Die Liste von der Store-Seite wartet, bis der laufende Import fertig ist. Lade diese Seite nicht neu." "STORE_LIST_WAITS": "Die Liste von der Store-Seite wartet, bis der laufende Import fertig ist. Lade diese Seite nicht neu."
}, },
"POINTS": {
"TITLE": "Pile Points",
"BALANCE": "Guthaben",
"SHOP": "Shop",
"HISTORY": "Verlauf",
"BUY": "Kaufen",
"EQUIP": "Anlegen",
"UNEQUIP": "Ablegen",
"EQUIPPED": "Getragen",
"EMPTY_HISTORY": "Noch keine Punkte. Spiel etwas von deinem Stapel!",
"ERROR_NOT_ENOUGH": "Nicht genug Punkte.",
"HOW_TO": "So bekommst du Punkte",
"RULES": {
"STARTED": "5 Punkte, wenn du ein Spiel von deinem Stapel startest und spielst (Steam-Spiele, einmal pro Spiel).",
"FINISHED": "30, 50 oder 80 Punkte, wenn du ein Spiel durchspielst (nach seiner Spielzeit), sofern du es wirklich gespielt hast. Einmal pro Spiel.",
"COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast.",
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche.",
"MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel."
},
"SLOTS": {
"AVATAR_BORDER": "Avatarrahmen",
"BADGE": "Abzeichen",
"ACCENT": "Akzent",
"BANNER_FRAME": "Bannerrahmen"
},
"ITEMS": {
"BORDER_GOLD": "Goldener Avatarrahmen",
"BORDER_NEON": "Neon-Avatarrahmen",
"BADGE_PILE_SLAYER": "Abzeichen Stapel-Bezwinger",
"BADGE_COMPLETIONIST": "Abzeichen Komplettierer",
"ACCENT_GOLD": "Akzent Gold",
"ACCENT_TEAL": "Akzent Türkis",
"FRAME_GOLD": "Goldener Bannerrahmen",
"FRAME_NEON": "Neon-Bannerrahmen"
},
"KINDS": {
"STARTED": "Gestartet",
"FINISHED": "Durchgespielt",
"FINISHED_MANUAL": "Durchgespielt (manuelles Spiel)",
"COMPLETED": "100-%-Bonus",
"MONTH": "Kleinerer Stapel im",
"STREAK": "Serien-Woche",
"STARTER": "Startpunkte",
"BUY": "Gekauft"
}
},
"UNSAVED_CHANGES": { "UNSAVED_CHANGES": {
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?" "CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
} }
+80 -1
View File
@@ -51,6 +51,7 @@
"ROLE": "Role", "ROLE": "Role",
"CREATED_DATE": "Member since", "CREATED_DATE": "Member since",
"LAST_LOGIN": "Last login", "LAST_LOGIN": "Last login",
"OPEN_WELCOME": "Show the welcome page again",
"DARK_THEME": "Dark theme", "DARK_THEME": "Dark theme",
"LANGUAGE": "Language", "LANGUAGE": "Language",
"LANGUAGE_OPTIONS": { "LANGUAGE_OPTIONS": {
@@ -270,7 +271,10 @@
"BIGGEST_LIBRARY": "Biggest library", "BIGGEST_LIBRARY": "Biggest library",
"BIGGEST_BACKLOG": "Biggest backlog", "BIGGEST_BACKLOG": "Biggest backlog",
"TOP_PLAYED": "Most played games", "TOP_PLAYED": "Most played games",
"TOP_OWNED": "Most owned games" "TOP_OWNED": "Most owned games",
"PLATFORMS": "Games per platform",
"TOP_GENRES": "Top genres",
"FINISHED_PER_YEAR": "Finished per year"
}, },
"MAIN": { "MAIN": {
"WELCOME": "Control your game backlog", "WELCOME": "Control your game backlog",
@@ -290,6 +294,7 @@
"LIGHT_MODE": "Light mode", "LIGHT_MODE": "Light mode",
"PLAY_NEXT": "Play next", "PLAY_NEXT": "Play next",
"STATS": "Stats", "STATS": "Stats",
"POINTS": "Pile Points",
"ADMIN": "Admin", "ADMIN": "Admin",
"VIEW_AS": { "VIEW_AS": {
"MENU": "View as", "MENU": "View as",
@@ -475,6 +480,10 @@
"BEFORE_one": "{{count}} more game was finished before the history started.", "BEFORE_one": "{{count}} more game was finished before the history started.",
"BEFORE_other": "{{count}} more games were finished before the history started." "BEFORE_other": "{{count}} more games were finished before the history started."
}, },
"PLATFORMS": {
"TITLE": "Games per platform",
"EMPTY": "No games yet."
},
"GENRE": { "GENRE": {
"ADVENTURE": "Adventure", "ADVENTURE": "Adventure",
"ARCADE": "Arcade", "ARCADE": "Arcade",
@@ -635,6 +644,29 @@
"LOAD_ERROR": "The news could not be loaded.", "LOAD_ERROR": "The news could not be loaded.",
"EMPTY": "Nothing new yet." "EMPTY": "Nothing new yet."
}, },
"WELCOME": {
"TITLE": "Welcome to ToBePlayed",
"INTRO": "ToBePlayed keeps your games in one place: what you own, what you play, what is next.",
"FEATURE_LIBRARY": "Library and Pile of Shame",
"FEATURE_LIBRARY_TEXT": "All your games with playtime, filters and search. The pile shows what you never started.",
"FEATURE_STATUS": "Statuses",
"FEATURE_STATUS_TEXT": "Mark a game as playing, finished, completed or dropped, with rating and notes.",
"FEATURE_STATS": "Your stats",
"FEATURE_STATS_TEXT": "Totals, the pile in hours, most played games, genres and finished games per year.",
"FEATURE_PROFILE": "Public profile",
"FEATURE_PROFILE_TEXT": "Share your stats and what you play with a link. Off until you switch it on.",
"FEATURE_IMPORT": "Import",
"FEATURE_IMPORT_TEXT": "Bring in games from other platforms: a list, a CSV, exports or a bookmarklet.",
"FEATURE_DASHBOARD": "Your dashboard",
"FEATURE_DASHBOARD_TEXT": "Order, hide and resize the tiles. It follows you to the phone.",
"OPEN": "Open",
"NEXT_STEPS": "Next steps",
"STEP_STEAM": "Link your Steam account",
"STEP_IMPORT": "Import your games",
"DONE": "Done",
"GO": "Let's go",
"SKIP": "Skip"
},
"ADMIN_NEWS": { "ADMIN_NEWS": {
"NEW": "New entry", "NEW": "New entry",
"EDIT": "Edit entry", "EDIT": "Edit entry",
@@ -770,6 +802,53 @@
"STORE_LIST_BROKEN": "The list from the store page could not be read.", "STORE_LIST_BROKEN": "The list from the store page could not be read.",
"STORE_LIST_WAITS": "The list from the store page waits until the running import is done. Do not reload this page." "STORE_LIST_WAITS": "The list from the store page waits until the running import is done. Do not reload this page."
}, },
"POINTS": {
"TITLE": "Pile Points",
"BALANCE": "Balance",
"SHOP": "Shop",
"HISTORY": "History",
"BUY": "Buy",
"EQUIP": "Equip",
"UNEQUIP": "Take off",
"EQUIPPED": "Worn",
"EMPTY_HISTORY": "No points yet. Play a game from your pile!",
"ERROR_NOT_ENOUGH": "Not enough points.",
"HOW_TO": "How to earn points",
"RULES": {
"STARTED": "5 points when you start a game from your pile and play it (Steam games, once per game).",
"FINISHED": "30, 50 or 80 points when you finish a game (by its time to beat), if you really played it. Once per game.",
"COMPLETED": "+40 points when you reach 100 % achievements in a completed game.",
"FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week.",
"MONTH": "40 points when your pile is smaller at the end of a month than at its start and you finished a game that month.",
"STREAK": "20 points for every week with a finished game after a week with a finished game."
},
"SLOTS": {
"AVATAR_BORDER": "Avatar border",
"BADGE": "Badge",
"ACCENT": "Accent",
"BANNER_FRAME": "Banner frame"
},
"ITEMS": {
"BORDER_GOLD": "Golden avatar border",
"BORDER_NEON": "Neon avatar border",
"BADGE_PILE_SLAYER": "Pile Slayer badge",
"BADGE_COMPLETIONIST": "Completionist badge",
"ACCENT_GOLD": "Gold accent",
"ACCENT_TEAL": "Teal accent",
"FRAME_GOLD": "Golden banner frame",
"FRAME_NEON": "Neon banner frame"
},
"KINDS": {
"STARTED": "Started",
"FINISHED": "Finished",
"FINISHED_MANUAL": "Finished (manual game)",
"COMPLETED": "100 % bonus",
"MONTH": "Smaller pile in",
"STREAK": "Streak week",
"STARTER": "Starter points",
"BUY": "Bought"
}
},
"UNSAVED_CHANGES": { "UNSAVED_CHANGES": {
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?" "CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
} }
+6 -1
View File
@@ -11,6 +11,8 @@ import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx'; import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx'; import AdminNewsPage from './pages/AdminNewsPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.jsx'; import WhatsNewPage from './pages/WhatsNewPage.jsx';
import WelcomePage from './pages/WelcomePage.jsx';
import WelcomeGate from './components/WelcomeGate.jsx';
import AdminUsersPage from './pages/AdminUsersPage.jsx'; import AdminUsersPage from './pages/AdminUsersPage.jsx';
import GameLibrary from './pages/GameLibrary.jsx'; import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx'; import GameDetail from './pages/GameDetail.jsx';
@@ -18,6 +20,7 @@ import WishlistPage from './pages/WishlistPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx'; import StatsPage from './pages/StatsPage.jsx';
import ImportPage from './pages/ImportPage.jsx'; import ImportPage from './pages/ImportPage.jsx';
import PointsPage from './pages/PointsPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx'; import AppHeader from './components/AppHeader.jsx';
import RequireSession from './components/RequireSession.jsx'; import RequireSession from './components/RequireSession.jsx';
@@ -65,15 +68,17 @@ function App() {
<Route path="/login" element={<LoginPage/>}/> <Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/> <Route path="/register" element={<RegistrationPage/>}/>
<Route element={<RequireSession/>}> <Route element={<RequireSession/>}>
<Route path="/dashboard" element={<Dashboard/>}/> <Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
<Route path="/library" element={<GameLibrary/>}/> <Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/> <Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/> <Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/> <Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/> <Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/> <Route path="/import" element={<ImportPage/>}/>
<Route path="/points" element={<PointsPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/> <Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/> <Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/> <Route path="/admin/hints" element={<AdminHintsPage/>}/>
<Route path="/admin/news" element={<AdminNewsPage/>}/> <Route path="/admin/news" element={<AdminNewsPage/>}/>
+1
View File
@@ -29,6 +29,7 @@ describe('App', () => {
const library = () => (expired ? json(401, {}) : json(200, page)); const library = () => (expired ? json(401, {}) : json(200, page));
fetchRoutes({ fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}), 'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
'GET /api/library/games': library, 'GET /api/library/games': library,
'GET /api/library/games-count': json(200, {hasSteamId: true}), 'GET /api/library/games-count': json(200, {hasSteamId: true}),
'GET /api/library/steam-profile': json(200, null), 'GET /api/library/steam-profile': json(200, null),
+6 -1
View File
@@ -1,6 +1,8 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {UserRound} from 'lucide-react'; import {Rocket, UserRound} from 'lucide-react';
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 {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {formatDateTime} from '../utils/format.js'; import {formatDateTime} from '../utils/format.js';
@@ -38,6 +40,9 @@ export default function AccountInfoCard() {
</div> </div>
))} ))}
</dl> </dl>
<Button asChild variant="link" className="mt-3 h-auto p-0">
<Link to="/welcome"><Rocket/>{t('SETTINGS.OPEN_WELCOME')}</Link>
</Button>
</CardContent> </CardContent>
</Card> </Card>
); );
+10 -2
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react'; import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -43,7 +43,7 @@ const navLinkClass = (isActive) => cn(
export default function AppHeader({isAuthenticated, onLogout}) { export default function AppHeader({isAuthenticated, onLogout}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences(); const {isDarkMode, setDarkMode, setLanguage, wallet} = usePreferences();
const {pathname} = useLocation(); const {pathname} = useLocation();
const steamProfile = useSteamProfile(isAuthenticated); const steamProfile = useSteamProfile(isAuthenticated);
const currentUser = useCurrentUser(isAuthenticated); const currentUser = useCurrentUser(isAuthenticated);
@@ -126,6 +126,11 @@ export default function AppHeader({isAuthenticated, onLogout}) {
{isAuthenticated && viewingAs && ( {isAuthenticated && viewingAs && (
<Badge variant="secondary" className="mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge> <Badge variant="secondary" className="mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
)} )}
{isAuthenticated && wallet && (
<Button asChild variant="ghost" size="sm" title={t('POINTS.TITLE')}>
<Link to="/points"><Coins className="text-primary"/><span className="tabular-nums">{wallet.balance}</span></Link>
</Button>
)}
{isAuthenticated ? ( {isAuthenticated ? (
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}> <DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -144,6 +149,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link> <Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link> <Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
+38
View File
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen} from '@testing-library/react';
import AppHeader from './AppHeader.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(() => {
cleanup();
localStorage.clear();
});
function Load() {
const {reloadWallet} = usePreferences();
return <button onClick={reloadWallet}>load</button>;
}
describe('AppHeader points chip', () => {
it('shows the balance as a link to the points page once the wallet is loaded', async () => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
expect(screen.queryByTitle('POINTS.TITLE')).toBeNull();
fireEvent.click(screen.getByText('load'));
const chip = await screen.findByTitle('POINTS.TITLE');
expect(chip.textContent).toBe('135');
expect(chip.getAttribute('href')).toBe('/points');
});
});
+9 -3
View File
@@ -5,25 +5,30 @@ import {Hash, LayoutGrid, Link2, RefreshCw} from 'lucide-react';
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} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import HeroBanner from './HeroBanner.jsx'; import HeroBanner from './HeroBanner.jsx';
import PointsBadge from './PointsBadge.jsx';
import SteamAvatar from './SteamAvatar.jsx'; import SteamAvatar from './SteamAvatar.jsx';
import SyncStatusBadge from './SyncStatusBadge.jsx'; import SyncStatusBadge from './SyncStatusBadge.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {lookClass} from '../utils/rewards.js';
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize}) { export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize, equipped}) {
const {t} = useTranslation(); const {t} = useTranslation();
const {user: userData} = useCurrentUserContext(); const {user: userData} = useCurrentUserContext();
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const syncBusy = forceSync.busy || sync.isRunning; const syncBusy = forceSync.busy || sync.isRunning;
return ( return (
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6"> <Card className={cn('relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6',
lookClass(equipped, 'BANNER_FRAME'))}>
{/* A picked game, else the game played most in the last two weeks. */} {/* A picked game, else the game played most in the last two weeks. */}
<HeroBanner appid={bannerAppid}/> <HeroBanner appid={bannerAppid}/>
<div className="relative flex min-w-0 items-center gap-4"> <div className="relative flex min-w-0 items-center gap-4">
{/* The slot is there before the avatar loads, so the title does not jump. */} {/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && ( {hasSteamId && (
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-primary/60"> <span className={cn('flex size-16 shrink-0 items-center justify-center rounded-full bg-muted',
lookClass(equipped, 'AVATAR_BORDER') ?? 'ring-2 ring-primary/60')}>
<SteamAvatar url={avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/> <SteamAvatar url={avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
</span> </span>
)} )}
@@ -32,6 +37,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate"> <h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋 {t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
</h1> </h1>
<PointsBadge equipped={equipped} className="mt-2"/>
{hasSteamId ? ( {hasSteamId ? (
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground"> <Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
<Hash className="size-3"/> <Hash className="size-3"/>
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen} from '@testing-library/react';
import DashboardWelcomeCard from './DashboardWelcomeCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(() => {
cleanup();
localStorage.clear();
});
const renderCard = async (equipped) => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', steamId: '1'}), 'GET /api/library/hero-art': json(200, {})});
renderWithProviders(<DashboardWelcomeCard sync={{}} forceSync={{}} equipped={equipped}/>);
await screen.findByText(/Lobo/);
};
describe('DashboardWelcomeCard looks', () => {
it('wears the border, the frame and the badge from the shop', async () => {
await renderCard({AVATAR_BORDER: 'BORDER_GOLD', BANNER_FRAME: 'FRAME_NEON', BADGE: 'BADGE_COMPLETIONIST'});
const card = screen.getByText(/Lobo/).closest('[data-slot="card"]');
expect(card.className).toContain('ring-fuchsia-500');
expect(card.querySelector('.rounded-full').className).toContain('ring-amber-400');
expect(screen.getByText('POINTS.ITEMS.BADGE_COMPLETIONIST')).toBeTruthy();
});
it('keeps the default avatar ring without a worn border', async () => {
await renderCard({});
expect(screen.getByText(/Lobo/).closest('[data-slot="card"]').querySelector('.rounded-full').className)
.toContain('ring-primary/60');
});
});
+16
View File
@@ -0,0 +1,16 @@
import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils';
import {equippedBadge} from '../utils/rewards.js';
/** The badge worn from the points shop as a small pill; nothing when none is worn. */
export default function PointsBadge({equipped, className}) {
const {t} = useTranslation();
const badge = equippedBadge(equipped);
if (!badge) return null;
return (
<span className={cn('inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium', badge.className, className)}>
<badge.Icon className="size-3.5"/>{t(`POINTS.ITEMS.${badge.code}`)}
</span>
);
}
+11
View File
@@ -0,0 +1,11 @@
import {Navigate} from 'react-router-dom';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
/**
* Opens the welcome page for a user who has not seen it. Only null counts: undefined is "still loading".
* Relies on Auth sending `welcomeSeenAt: null` explicitly (no NON_NULL Jackson config).
*/
export default function WelcomeGate({children}) {
const {user} = useCurrentUserContext();
return user?.settings?.welcomeSeenAt === null ? <Navigate to="/welcome" replace/> : children;
}
+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 WelcomeGate from './WelcomeGate.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const me = (settings) => ({id: 1, email: 'a@b.de', role: 'USER', settings});
const renderGate = (settings) => {
fetchRoutes({'GET /api/users/me': json(200, me(settings))});
return renderWithProviders(<WelcomeGate><p>dashboard</p></WelcomeGate>, {
path: '/dashboard', entries: ['/dashboard'], routes: {'/welcome': <p>welcome</p>},
});
};
describe('WelcomeGate', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('opens the welcome page when the user has not seen it', async () => {
const {router} = renderGate({welcomeSeenAt: null});
await screen.findByText('welcome');
expect(router.state.location.pathname).toBe('/welcome');
});
it('shows the children once the welcome page was seen', async () => {
renderGate({welcomeSeenAt: '2026-10-01T10:00:00Z'});
expect(await screen.findByText('dashboard')).toBeTruthy();
});
it('shows the children when /me has no such field', async () => {
const {router} = renderGate({});
expect(await screen.findByText('dashboard')).toBeTruthy();
await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard'));
});
});
+19 -13
View File
@@ -3,26 +3,32 @@ import {Flag} from 'lucide-react';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
export function YearBars({perYear}) {
const max = Math.max(1, ...perYear.map((y) => y.games));
return (
<ul className="space-y-2">
{perYear.map((y) => (
<li key={y.year} className="flex items-center gap-3 text-sm">
<span className="w-12 tabular-nums">{y.year}</span>
<div className="h-2 flex-1 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${(y.games / max) * 100}%`}}/>
</div>
<span className="w-8 text-right tabular-nums">{y.games}</span>
</li>
))}
</ul>
);
}
export default function FinishedStats({finished}) { export default function FinishedStats({finished}) {
const {t} = useTranslation(); const {t} = useTranslation();
const max = Math.max(1, ...finished.perYear.map((y) => y.games));
return ( return (
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}> <StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
{finished.perYear.length === 0 ? ( {finished.perYear.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p> <p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
) : ( ) : (
<ul className="space-y-2"> <YearBars perYear={finished.perYear}/>
{finished.perYear.map((y) => (
<li key={y.year} className="flex items-center gap-3 text-sm">
<span className="w-12 tabular-nums">{y.year}</span>
<div className="h-2 flex-1 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${(y.games / max) * 100}%`}}/>
</div>
<span className="w-8 text-right tabular-nums">{y.games}</span>
</li>
))}
</ul>
)} )}
{finished.finishedBeforeTracking > 0 && ( {finished.finishedBeforeTracking > 0 && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
+18
View File
@@ -0,0 +1,18 @@
import {useTranslation} from 'react-i18next';
import {MonitorSmartphone} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import {TagBars} from './TasteStats.jsx';
export default function PlatformStats({platforms}) {
const {t} = useTranslation();
return (
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}>
{platforms.perPlatform.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.PLATFORMS.EMPTY')}</p>
) : (
<TagBars tags={platforms.perPlatform}/>
)}
</StatsSection>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import StatsSection from './StatsSection.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
import {genreKey} from '../../utils/stats.js'; import {genreKey} from '../../utils/stats.js';
function TagBars({tags, label}) { export function TagBars({tags, label = (name) => name}) {
const {t} = useTranslation(); const {t} = useTranslation();
const max = Math.max(1, ...tags.map((tag) => tag.minutes)); const max = Math.max(1, ...tags.map((tag) => tag.minutes));
return ( return (
+2
View File
@@ -5,6 +5,7 @@ import TopGamesStats from './TopGamesStats.jsx';
import TasteStats from './TasteStats.jsx'; import TasteStats from './TasteStats.jsx';
import HabitsStats from './HabitsStats.jsx'; import HabitsStats from './HabitsStats.jsx';
import FinishedStats from './FinishedStats.jsx'; import FinishedStats from './FinishedStats.jsx';
import PlatformStats from './PlatformStats.jsx';
/** /**
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a * One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
@@ -22,6 +23,7 @@ export default function statGroupTile(id, stats, linked = true) {
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste}/> : null; case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste}/> : null;
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null; case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null; case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null;
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms}/> : null;
default: return null; default: return null;
} }
} }
+17 -3
View File
@@ -12,6 +12,7 @@ import {
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js'; import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {equippedAccent, loadWallet} from '../utils/rewards.js';
import {storage} from '../utils/storage.js'; import {storage} from '../utils/storage.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
@@ -49,11 +50,20 @@ export function PreferencesProvider({children}) {
// Grows on reset, so a load or save that started for the user before does not come back. // Grows on reset, so a load or save that started for the user before does not come back.
const generation = useRef(0); const generation = useRef(0);
// Pile Points: balance, shop and worn looks of Core; null until loaded.
const [wallet, setWallet] = useState(null);
// Layout effect: the cached look is set before the first paint. // Layout effect: the cached look is set before the first paint.
useLayoutEffect(() => { useLayoutEffect(() => {
applyAppearance(appearance); applyAppearance(appearance, equippedAccent(wallet?.equipped));
cacheAppearance(appearance); cacheAppearance(appearance);
}, [appearance]); }, [appearance, wallet]);
const reloadWallet = useCallback(async () => {
const started = generation.current;
const {ok, data} = await loadWallet();
if (ok && generation.current === started) setWallet(data);
}, []);
// Grows on every language pick, so a load that started before does not undo it. // Grows on every language pick, so a load that started before does not undo it.
const languagePicks = useRef(0); const languagePicks = useRef(0);
@@ -75,9 +85,10 @@ export function PreferencesProvider({children}) {
} catch (err) { } catch (err) {
console.error('Preferences load error:', err); console.error('Preferences load error:', err);
} }
await reloadWallet();
})(); })();
return syncing.current; return syncing.current;
}, [i18n]); }, [i18n, reloadWallet]);
/** /**
* Applies the changed fields at once, then saves them to Auth after a running load and after the saves before * Applies the changed fields at once, then saves them to Auth after a running load and after the saves before
@@ -124,6 +135,7 @@ export function PreferencesProvider({children}) {
pendingChanges.current = {}; pendingChanges.current = {};
savedAppearance.current = DEFAULT_APPEARANCE; savedAppearance.current = DEFAULT_APPEARANCE;
setAppearanceState(DEFAULT_APPEARANCE); setAppearanceState(DEFAULT_APPEARANCE);
setWallet(null);
}, []); }, []);
// Without a session the next user must not get this look. // Without a session the next user must not get this look.
@@ -153,6 +165,8 @@ export function PreferencesProvider({children}) {
setLanguage, setLanguage,
appearance, appearance,
changeAppearance, changeAppearance,
wallet,
reloadWallet,
syncPreferences, syncPreferences,
resetAppearance, resetAppearance,
}} }}
+1
View File
@@ -35,6 +35,7 @@ afterEach(() => {
const answerMe = (settings) => fetchRoutes({ const answerMe = (settings) => fetchRoutes({
'GET /api/users/me': json(200, {displayName: 'Lobo', settings}), 'GET /api/users/me': json(200, {displayName: 'Lobo', settings}),
'PUT /api/users/settings': json(204), 'PUT /api/users/settings': json(204),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
}); });
describe('PreferencesProvider storage', () => { describe('PreferencesProvider storage', () => {
+4 -1
View File
@@ -24,6 +24,7 @@ const wrapper = ({children}) => (
const PUT = 'PUT /api/users/settings/appearance'; const PUT = 'PUT /api/users/settings/appearance';
const ME = 'GET /api/users/me'; const ME = 'GET /api/users/me';
const WALLET = 'GET /api/rewards/wallet';
const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper}); const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper});
@@ -122,6 +123,7 @@ describe('useSaveAppearance', () => {
let answerMe; let answerMe;
fetchRoutes({ fetchRoutes({
[PUT]: json(204), [PUT]: json(204),
[WALLET]: json(200, {}),
[ME]: () => new Promise((resolve) => { [ME]: () => new Promise((resolve) => {
answerMe = () => resolve(json(200, {settings: {appearance: fromServer}})); answerMe = () => resolve(json(200, {settings: {appearance: fromServer}}));
}), }),
@@ -152,7 +154,7 @@ describe('useSaveAppearance', () => {
it('does not bring back the look of the user before after a reset', async () => { it('does not bring back the look of the user before after a reset', async () => {
let answerMe; let answerMe;
fetchRoutes({[ME]: () => new Promise((resolve) => { fetchRoutes({[WALLET]: json(200, {}), [ME]: () => new Promise((resolve) => {
answerMe = () => resolve(json(200, {settings: {appearance: {accent: 'red', corners: 'sharp'}}})); answerMe = () => resolve(json(200, {settings: {appearance: {accent: 'red', corners: 'sharp'}}}));
})}); })});
const {result} = renderSave(); const {result} = renderSave();
@@ -211,6 +213,7 @@ describe('useSaveAppearance', () => {
let answerMe; let answerMe;
fetchRoutes({ fetchRoutes({
[PUT]: json(204), [PUT]: json(204),
[WALLET]: json(200, {}),
[ME]: () => new Promise((resolve) => { [ME]: () => new Promise((resolve) => {
answerMe = () => resolve(json(200, {})); answerMe = () => resolve(json(200, {}));
}), }),
+2 -1
View File
@@ -60,7 +60,7 @@ const Dashboard = () => {
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0; const showLibrary = hasSteamId || steamData.count > 0;
const {appearance} = usePreferences(); const {appearance, wallet} = usePreferences();
const steamProfile = useSteamProfile(hasSteamId); const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = appearance.showBanner const bannerAppid = appearance.showBanner
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null ? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
@@ -127,6 +127,7 @@ const Dashboard = () => {
return ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl} <DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={wallet?.equipped}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/> sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing} <TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
+31
View File
@@ -11,7 +11,10 @@ import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import Logo from '../components/Logo.jsx'; import Logo from '../components/Logo.jsx';
import StatCard from '../components/StatCard.jsx'; import StatCard from '../components/StatCard.jsx';
import {YearBars} from '../components/stats/FinishedStats.jsx';
import {TagBars} from '../components/stats/TasteStats.jsx';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
import {genreKey} from '../utils/stats.js';
import usePublicStats from '../hooks/usePublicStats.js'; import usePublicStats from '../hooks/usePublicStats.js';
@@ -36,6 +39,15 @@ function RankedGames({title, games, formatValue}) {
); );
} }
function BarsCard({title, children}) {
return (
<Card className="gap-0 py-0">
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">{title}</h3>
<div className="px-5 py-4">{children}</div>
</Card>
);
}
export default function LandingPage() { export default function LandingPage() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {data, isLoading} = usePublicStats(); const {data, isLoading} = usePublicStats();
@@ -102,6 +114,25 @@ export default function LandingPage() {
/> />
)} )}
</div> </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> </section>
</div> </div>
); );
+45
View File
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import LandingPage from './LandingPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../hooks/useSession.js', () => ({default: () => ({active: false})}));
// Auth and Core answer the same path; one object serves both shapes.
const answer = (core) => fetchRoutes({'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], ...core})});
const renderPage = () => render(<MemoryRouter><LandingPage/></MemoryRouter>);
describe('LandingPage', () => {
afterEach(cleanup);
it('shows games per platform, top genres and finished per year', async () => {
answer({
gamesPerPlatform: [{name: 'PS5', games: 3, minutes: 600}],
topGenres: [{name: 'Shooter', games: 2, minutes: 1200}],
finishedPerYear: [{year: 2025, games: 4}],
});
renderPage();
expect(await screen.findByText('LANDING.PLATFORMS')).toBeTruthy();
expect(screen.getByText('PS5')).toBeTruthy();
expect(screen.getByText('LANDING.TOP_GENRES')).toBeTruthy();
expect(screen.getByText('STATS.GENRE.SHOOTER')).toBeTruthy();
expect(screen.getByText('LANDING.FINISHED_PER_YEAR')).toBeTruthy();
expect(screen.getByText('2025')).toBeTruthy();
});
it('hides the three blocks while their lists are empty', async () => {
answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []});
renderPage();
await screen.findByText('LANDING.STATS_TITLE');
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
for (const title of ['PLATFORMS', 'TOP_GENRES', 'FINISHED_PER_YEAR']) {
expect(screen.queryByText(`LANDING.${title}`)).toBeNull();
}
});
});
+157
View File
@@ -0,0 +1,157 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Coins} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import PageContainer from '../components/PageContainer.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK'];
export default function PointsPage() {
const {t, i18n} = useTranslation();
const {wallet, reloadWallet} = usePreferences();
if (!wallet) {
return (
<PageContainer className="p-4 md:p-8 space-y-6">
<Skeleton className="h-24 rounded-xl"/>
<Skeleton className="h-64 rounded-xl"/>
</PageContainer>
);
}
const act = async (send) => {
try {
const response = await send();
if (response.ok) {
await reloadWallet();
} else if (response.status === 409) {
const body = await response.json().catch(() => null);
if (body?.code === 'NOT_ENOUGH_POINTS') toast.error(t('POINTS.ERROR_NOT_ENOUGH'));
await reloadWallet();
} else if (response.status !== 401) {
toast.error(t('SETTINGS.ERROR_SAVE'));
}
} catch (err) {
console.error('Points save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
const entryLabel = (entry) => {
if (GAME_KINDS.includes(entry.kind)) {
return (
<Link to={`/library/${entry.ref}`} className="font-medium text-primary hover:underline">
{entry.gameName ?? entry.ref}
</Link>
);
}
if (entry.kind === 'BUY') return t(`POINTS.ITEMS.${entry.ref}`);
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
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>
<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;
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>
) : (
<>
<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="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="min-w-0 flex-1 truncate">
{t(`POINTS.KINDS.${entry.kind}`)}
{label && <> {label}</>}
</span>
<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>
);
}
// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot or a framed box.
function Preview({code}) {
const look = LOOKS[code];
if (!look) return null;
if (look.Icon) {
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
}
if (look.accent) {
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
}
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
}
+128
View File
@@ -0,0 +1,128 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import {toast} from 'sonner';
import PointsPage from './PointsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const wallet = (extra = {}) => ({
balance: 160,
catalog: [
{code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150},
{code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200},
{code: 'BADGE_PILE_SLAYER', slot: 'BADGE', price: 100},
],
owned: ['BADGE_PILE_SLAYER'],
equipped: {},
history: [
{kind: 'FINISHED', ref: '620', gameName: 'Portal 2', points: 50, eventAt: '2026-09-30T10:00:00Z'},
{kind: 'MONTH', ref: '2026-09', gameName: null, points: 40, eventAt: '2026-09-30T10:00:00Z'},
],
...extra,
});
// Loads the wallet like App does after the login, then shows the page.
function Loaded() {
const {syncPreferences} = usePreferences();
return <button onClick={syncPreferences}>load</button>;
}
const renderPage = async (routes = {}) => {
fetchRoutes({'GET /api/users/me': json(200, {}), 'GET /api/rewards/wallet': json(200, wallet()), ...routes});
renderWithProviders(<><Loaded/><PointsPage/></>, {entries: ['/points']});
fireEvent.click(screen.getByText('load'));
await screen.findByText('POINTS.SHOP');
};
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('PointsPage', () => {
it('groups the catalog by slot and disables Buy below the price', async () => {
await renderPage();
const border = screen.getByText('POINTS.SLOTS.AVATAR_BORDER').closest('[data-slot="card"]');
const gold = within(border).getByText('POINTS.ITEMS.BORDER_GOLD').closest('li');
const neon = within(border).getByText('POINTS.ITEMS.BORDER_NEON').closest('li');
expect(within(gold).getByRole('button', {name: 'POINTS.BUY'}).disabled).toBe(false);
expect(within(neon).getByRole('button', {name: 'POINTS.BUY'}).disabled).toBe(true);
expect(within(screen.getByText('POINTS.SLOTS.BADGE').closest('[data-slot="card"]'))
.getByRole('button', {name: 'POINTS.EQUIP'})).toBeTruthy();
});
it('shows the game name as a link and the ref for month rows', async () => {
await renderPage();
expect(screen.getByRole('link', {name: 'Portal 2'}).getAttribute('href')).toBe('/library/620');
expect(screen.getByText(/2026-09/)).toBeTruthy();
expect(screen.getByText('+50')).toBeTruthy();
});
it('buys, then loads the wallet again', async () => {
let balance = 160;
const mock = await renderPageWith(() => json(200, wallet({balance})), {
'POST /api/rewards/purchases': () => { balance = 10; return json(200, wallet({balance})); },
});
fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'}));
await waitFor(() => expect(screen.getByText('10')).toBeTruthy());
const post = mock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(JSON.parse(post[1].body)).toEqual({item: 'BORDER_GOLD'});
});
it('shows the not-enough message on a 409', async () => {
await renderPage({'POST /api/rewards/purchases': json(409, {code: 'NOT_ENOUGH_POINTS'})});
fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('POINTS.ERROR_NOT_ENOUGH'));
});
it('an ALREADY_OWNED 409 reloads the wallet without the not-enough message', async () => {
const mock = await renderPageWith(json(200, wallet()), {'POST /api/rewards/purchases': json(409, {code: 'ALREADY_OWNED'})});
fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'}));
await waitFor(() => expect(mock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(toast.error).not.toHaveBeenCalledWith('POINTS.ERROR_NOT_ENOUGH');
});
it('sends the equip and unequip PUT', async () => {
const puts = [];
await renderPage({'PUT /api/rewards/equipped/BADGE': (url, init) => { puts.push(JSON.parse(init.body)); return json(200, wallet()); }});
fireEvent.click(screen.getByRole('button', {name: 'POINTS.EQUIP'}));
await waitFor(() => expect(puts).toEqual([{item: 'BADGE_PILE_SLAYER'}]));
});
it('sends null to take a worn look off', async () => {
const puts = [];
await renderPage({
'GET /api/rewards/wallet': json(200, wallet({equipped: {BADGE: 'BADGE_PILE_SLAYER'}})),
'PUT /api/rewards/equipped/BADGE': (url, init) => { puts.push(JSON.parse(init.body)); return json(200, wallet()); },
});
fireEvent.click(screen.getByRole('button', {name: 'POINTS.UNEQUIP'}));
await waitFor(() => expect(puts).toEqual([{item: null}]));
});
});
// A wallet answer that changes between calls.
async function renderPageWith(walletAnswer, routes) {
const mock = fetchRoutes({'GET /api/users/me': json(200, {}), 'GET /api/rewards/wallet': walletAnswer, ...routes});
renderWithProviders(<><Loaded/><PointsPage/></>, {entries: ['/points']});
fireEvent.click(screen.getByText('load'));
await screen.findByText('POINTS.SHOP');
return mock;
}
+18 -2
View File
@@ -10,6 +10,8 @@ import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js'; import {formatDay} from '../utils/format.js';
import {equippedAccent, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx';
import {GAME_STATUSES} from '../utils/limits.js'; import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
@@ -35,6 +37,7 @@ export default function PublicProfilePage() {
const {name} = useParams(); const {name} = useParams();
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [library, setLibrary] = useState(null); const [library, setLibrary] = useState(null);
const [equipped, setEquipped] = useState({});
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [reloads, setReloads] = useState(0); const [reloads, setReloads] = useState(0);
@@ -45,6 +48,7 @@ export default function PublicProfilePage() {
let cancelled = false; let cancelled = false;
setProfile(null); setProfile(null);
setLibrary(null); setLibrary(null);
setEquipped({});
setError(null); setError(null);
(async () => { (async () => {
try { try {
@@ -69,9 +73,20 @@ export default function PublicProfilePage() {
return; return;
} }
const data = await libraryResponse.json(); const data = await libraryResponse.json();
// Looks are decoration: without them the profile still shows.
let looks = {};
try {
const looksResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/public/profiles/rewards`, {
headers: {'X-Profile-Ticket': found.ticket},
});
if (looksResponse.ok) looks = (await looksResponse.json()).equipped ?? {};
} catch (err) {
console.error('Public profile looks error:', err);
}
if (!cancelled) { if (!cancelled) {
setProfile(found); setProfile(found);
setLibrary(data); setLibrary(data);
setEquipped(looks);
} }
} catch (err) { } catch (err) {
console.error('Public profile load error:', err); console.error('Public profile load error:', err);
@@ -137,14 +152,15 @@ export default function PublicProfilePage() {
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's. // The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
return ( return (
<div data-accent={style.accent} data-corners={style.corners}> <div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
<PageBackground appid={style.backgroundAppid}/> <PageBackground appid={style.backgroundAppid}/>
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Card className="relative overflow-hidden p-6 md:p-8"> <Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
<HeroBanner appid={style.bannerAppid}/> <HeroBanner appid={style.bannerAppid}/>
<div className="relative"> <div className="relative">
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p> <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> <h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1>
<PointsBadge equipped={equipped} className="mt-2"/>
{memberSince && ( {memberSince && (
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p> <p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)} )}
+29 -1
View File
@@ -15,11 +15,12 @@ const library = {
}; };
// `me` only for a test with a session: without one the page asks nothing about the viewer. // `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} = {}) { function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200} = {}) {
fetchRoutes({ fetchRoutes({
[`GET /api/public/profiles/${encodeURIComponent(name)}`]: [`GET /api/public/profiles/${encodeURIComponent(name)}`]:
json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}), json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}),
'GET /api/public/profiles/library': json(libraryStatus, answer), 'GET /api/public/profiles/library': json(libraryStatus, answer),
'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}),
...(me && { ...(me && {
'GET /api/users/me': json(200, me), 'GET /api/users/me': json(200, me),
'PUT /api/users/settings/profile-tiles': json(204, {}), 'PUT /api/users/settings/profile-tiles': json(204, {}),
@@ -32,6 +33,33 @@ const renderPage = (name = 'Lobo') => renderWithProviders(<PublicProfilePage/>,
describe('PublicProfilePage', () => { describe('PublicProfilePage', () => {
afterEach(cleanup); afterEach(cleanup);
it('wears the premium accent and the badge of the owner', async () => {
mockBackends({style: {accent: 'red'}, equipped: {ACCENT: 'ACCENT_GOLD', BADGE: 'BADGE_PILE_SLAYER', BANNER_FRAME: 'FRAME_GOLD'}});
renderPage();
expect(await screen.findByText('POINTS.ITEMS.BADGE_PILE_SLAYER')).toBeTruthy();
const wrapper = screen.getByText('Lobo').closest('[data-accent]');
expect(wrapper.dataset.accent).toBe('gold');
expect(wrapper.querySelector('[data-slot="card"]').className).toContain('ring-amber-400');
expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket');
});
it('still shows the profile when the looks call fails', async () => {
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red');
});
it('ignores a code the app does not know', async () => {
mockBackends({style: {accent: 'red'}, equipped: {ACCENT: 'ACCENT_X', BADGE: 'BADGE_X'}});
renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red');
});
it('asks Auth for the ticket, then Core for the library with it', async () => { it('asks Auth for the ticket, then Core for the library with it', async () => {
mockBackends({name: 'Lo bo'}); mockBackends({name: 'Lo bo'});
renderPage('Lo bo'); renderPage('Lo bo');
+6
View File
@@ -88,6 +88,12 @@ describe('SettingsPage public profile', () => {
await act(async () => auth.answer()); await act(async () => auth.answer());
}); });
it('links to the welcome page', async () => {
renderPage(profile());
expect((await screen.findByRole('link', {name: 'SETTINGS.OPEN_WELCOME'})).getAttribute('href')).toBe('/welcome');
});
it('turns the public profile off and shows the link while it is on', async () => { it('turns the public profile off and shows the link while it is on', async () => {
renderPage(profile()); renderPage(profile());
+2
View File
@@ -11,6 +11,7 @@ import TopGamesStats from '../components/stats/TopGamesStats.jsx';
import TasteStats from '../components/stats/TasteStats.jsx'; import TasteStats from '../components/stats/TasteStats.jsx';
import HabitsStats from '../components/stats/HabitsStats.jsx'; import HabitsStats from '../components/stats/HabitsStats.jsx';
import FinishedStats from '../components/stats/FinishedStats.jsx'; import FinishedStats from '../components/stats/FinishedStats.jsx';
import PlatformStats from '../components/stats/PlatformStats.jsx';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
const ALL_GROUPS = []; const ALL_GROUPS = [];
@@ -47,6 +48,7 @@ export default function StatsPage() {
<TrendStats trend={stats.trend}/> <TrendStats trend={stats.trend}/>
<TopGamesStats topGames={stats.topGames}/> <TopGamesStats topGames={stats.topGames}/>
<TasteStats taste={stats.taste}/> <TasteStats taste={stats.taste}/>
<PlatformStats platforms={stats.platforms}/>
<HabitsStats habits={stats.habits}/> <HabitsStats habits={stats.habits}/>
<FinishedStats finished={stats.finished}/> <FinishedStats finished={stats.finished}/>
</div> </div>
+5 -1
View File
@@ -21,6 +21,7 @@ const stats = (extra = {}) => ({
taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}], moods: [{name: 'CHILL', games: 1, minutes: 900}]}, taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}], moods: [{name: 'CHILL', games: 1, minutes: 900}]},
habits: {moonTrips: 0.5, longestDay: {day: '2026-07-01', minutes: 200}, underOneHourGames: 1, underOneHourShare: 0.125}, habits: {moonTrips: 0.5, longestDay: {day: '2026-07-01', minutes: 200}, underOneHourGames: 1, underOneHourShare: 0.125},
finished: {perYear: [{year: 2026, games: 2}], finishedBeforeTracking: 1}, finished: {perYear: [{year: 2026, games: 2}], finishedBeforeTracking: 1},
platforms: {perPlatform: [{name: 'Steam', games: 10, minutes: 5000}, {name: 'PS5', games: 2, minutes: 600}]},
trend: {days: [{day: '2026-09-26', backlogGames: 6, neverPlayed: 4, backlogMinutes: 5000}, trend: {days: [{day: '2026-09-26', backlogGames: 6, neverPlayed: 4, backlogMinutes: 5000},
{day: '2026-09-27', backlogGames: 5, neverPlayed: 4, backlogMinutes: 4200}], thisMonth: {added: 1, done: 2}}, {day: '2026-09-27', backlogGames: 5, neverPlayed: 4, backlogMinutes: 4200}], thisMonth: {added: 1, done: 2}},
...extra, ...extra,
@@ -45,11 +46,12 @@ describe('StatsPage', () => {
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy(); expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
expect(fetch).toHaveBeenCalledOnce(); expect(fetch).toHaveBeenCalledOnce();
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/); expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/);
for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED']) { for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED', 'PLATFORMS']) {
expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy(); expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy();
} }
expect(screen.getAllByText('Portal 2')).toHaveLength(2); expect(screen.getAllByText('Portal 2')).toHaveLength(2);
expect(screen.getByText('Half-Life 2')).toBeTruthy(); expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('PS5')).toBeTruthy();
expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy(); expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy(); expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy();
expect(screen.getByTestId('pile-trend')).toBeTruthy(); expect(screen.getByTestId('pile-trend')).toBeTruthy();
@@ -64,6 +66,7 @@ describe('StatsPage', () => {
taste: {genres: [], moods: []}, taste: {genres: [], moods: []},
habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null}, habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null},
finished: {perYear: [], finishedBeforeTracking: 0}, finished: {perYear: [], finishedBeforeTracking: 0},
platforms: {perPlatform: []},
trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}}, trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}},
})); }));
renderPage(); renderPage();
@@ -72,6 +75,7 @@ describe('StatsPage', () => {
expect(screen.getByText('STATS.TOP_GAMES.EMPTY')).toBeTruthy(); expect(screen.getByText('STATS.TOP_GAMES.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.TASTE.EMPTY')).toBeTruthy(); expect(screen.getByText('STATS.TASTE.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.EMPTY')).toBeTruthy(); expect(screen.getByText('STATS.FINISHED.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.PLATFORMS.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.TREND.STARTS')).toBeTruthy(); expect(screen.getByText('STATS.TREND.STARTS')).toBeTruthy();
expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull(); expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull();
expect(screen.queryByTestId('pile-trend')).toBeNull(); expect(screen.queryByTestId('pile-trend')).toBeNull();
+94
View File
@@ -0,0 +1,94 @@
import {useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartBarBig, Circle, CircleCheck, LayoutGrid, Library, ListChecks, Upload, UserRound} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import PageContainer from '../components/PageContainer.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {request} from '../utils/api.js';
import {markWelcomeSeen} from '../utils/settings.js';
// The dashboard card has no link: /dashboard passes the gate, "Let's go" opens it.
const FEATURES = [
{key: 'LIBRARY', to: '/library', icon: Library},
{key: 'STATUS', to: '/library', icon: ListChecks},
{key: 'STATS', to: '/stats', icon: ChartBarBig},
{key: 'PROFILE', to: '/settings', icon: UserRound},
{key: 'IMPORT', to: '/import', icon: Upload},
{key: 'DASHBOARD', to: null, icon: LayoutGrid},
];
/** The page a new user sees once after the first login. */
export default function WelcomePage() {
const {t} = useTranslation();
const navigate = useNavigate();
const {user, updateUser} = useCurrentUserContext();
const [summary, setSummary] = useState(null);
useEffect(() => {
(async () => {
const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary`);
if (ok && data != null) setSummary(data);
})();
}, []);
const steps = [
{key: 'STEAM', to: '/settings', done: !!user?.steamId},
{key: 'IMPORT', to: '/import', done: summary?.total > 0},
];
// Wait for the PUT: the dashboard reloads /me and would get the old null stamp. A failed PUT is ignored: the
// page may come back at the next login.
const finish = async () => {
updateUser((u) => ({...u, settings: {...u.settings, welcomeSeenAt: new Date().toISOString()}}));
await markWelcomeSeen().catch(() => undefined);
navigate('/dashboard');
};
return (
<PageContainer className="space-y-6 px-4 py-6 md:px-8">
<div className="space-y-2">
<h1 className="text-2xl font-bold">{t('WELCOME.TITLE')}</h1>
<p className="text-muted-foreground">{t('WELCOME.INTRO')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{FEATURES.map(({key, to, icon: Icon}) => (
<Card key={key} className="p-4">
<Icon className="size-5 text-primary"/>
<h2 className="font-semibold">{t(`WELCOME.FEATURE_${key}`)}</h2>
<p className="text-sm text-muted-foreground">{t(`WELCOME.FEATURE_${key}_TEXT`)}</p>
{to && (
<Button asChild variant="link" className="h-auto justify-start p-0">
<Link to={to}>{t('WELCOME.OPEN')}</Link>
</Button>
)}
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>{t('WELCOME.NEXT_STEPS')}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{steps.map(({key, to, done}) => (
<li key={key} className="flex items-center gap-2">
{done
? <CircleCheck className="size-5 text-primary"/>
: <Circle className="size-5 text-muted-foreground"/>}
<Link to={to} className="hover:underline">{t(`WELCOME.STEP_${key}`)}</Link>
{done && <span className="sr-only">{t('WELCOME.DONE')}</span>}
</li>
))}
</ul>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button onClick={finish}>{t('WELCOME.GO')}</Button>
<Button variant="outline" onClick={finish}>{t('WELCOME.SKIP')}</Button>
</div>
</PageContainer>
);
}
+122
View File
@@ -0,0 +1,122 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {useEffect} from 'react';
import {toast} from 'sonner';
import WelcomePage from './WelcomePage.jsx';
import WelcomeGate from '../components/WelcomeGate.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.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('sonner', () => ({toast: {error: vi.fn()}}));
const me = (overrides = {}) => ({id: 1, email: 'a@b.de', role: 'USER', steamId: null,
settings: {welcomeSeenAt: null}, ...overrides});
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
const renderPage = async ({user = me(), summary = json(200, {total: 0}), seen = json(204)} = {}) => {
fetchRoutes({
'GET /api/users/me': json(200, user),
'GET /api/library/summary': summary,
'PUT /api/users/settings/welcome-seen': seen,
});
const result = renderWithProviders(<WelcomePage/>, {
path: '/welcome', entries: ['/welcome'], routes: {'/dashboard': <p>dashboard</p>},
});
await act(async () => undefined);
return result;
};
describe('WelcomePage', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('shows six feature cards, five of them with a link to their page', async () => {
await renderPage();
expect(screen.getAllByRole('heading', {level: 2})).toHaveLength(6);
expect(screen.getAllByRole('link', {name: 'WELCOME.OPEN'}).map((link) => link.getAttribute('href')))
.toEqual(['/library', '/library', '/stats', '/settings', '/import']);
});
it('checks the steps that are done', async () => {
await renderPage({user: me({steamId: '7656'}), summary: json(200, {total: 1})});
expect(await screen.findAllByText('WELCOME.DONE')).toHaveLength(2);
});
it('checks nothing for a new user without games', async () => {
await renderPage();
expect(screen.queryByText('WELCOME.DONE')).toBeNull();
});
it('renders without checks when the summary fails', async () => {
await renderPage({summary: json(500, {})});
expect(screen.getByText('WELCOME.TITLE')).toBeTruthy();
expect(screen.queryByText('WELCOME.DONE')).toBeNull();
});
it.each(['WELCOME.GO', 'WELCOME.SKIP'])('"%s" stamps once and opens the dashboard', async (label) => {
const {router} = await renderPage();
fireEvent.click(screen.getByText(label));
await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard'));
expect(putCalls().map(([url]) => url)).toEqual([expect.stringContaining('/api/users/settings/welcome-seen')]);
});
it('still opens the dashboard, silently, when the stamp fails', async () => {
const {router} = await renderPage({seen: json(500, {})});
fireEvent.click(screen.getByText('WELCOME.GO'));
await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard'));
expect(toast.error).not.toHaveBeenCalled();
});
it('staysOnTheDashboardWhenItReloadsTheUserBeforeTheStampLands', async () => {
let stamped = false;
function DashboardStub() {
const {reload} = useCurrentUserContext();
useEffect(() => { reload(); }, [reload]);
return <p>dashboard</p>;
}
fetchRoutes({
'GET /api/users/me': () => json(200, me({settings: {welcomeSeenAt: stamped ? '2026-10-01T10:00:00Z' : null}})),
'GET /api/library/summary': json(200, {total: 0}),
'PUT /api/users/settings/welcome-seen': () => new Promise((resolve) => setTimeout(() => {
stamped = true;
resolve(json(204));
}, 20)),
});
const {router} = renderWithProviders(<WelcomePage/>, {
path: '/welcome', entries: ['/welcome'],
routes: {'/dashboard': <WelcomeGate><DashboardStub/></WelcomeGate>},
});
await screen.findByText('WELCOME.TITLE');
fireEvent.click(screen.getByText('WELCOME.GO'));
await waitFor(() => expect(stamped).toBe(true));
await act(async () => new Promise((resolve) => setTimeout(resolve, 30)));
expect(router.state.location.pathname).toBe('/dashboard');
});
it('sendsTheStampWithATimeout', async () => {
await renderPage({seen: () => new Promise(() => undefined)});
fireEvent.click(screen.getByText('WELCOME.GO'));
await waitFor(() => expect(putCalls()).toHaveLength(1));
expect(putCalls()[0][1].signal).toBeInstanceOf(AbortSignal);
});
});
+44
View File
@@ -182,6 +182,38 @@
--color-ring: var(--ring); --color-ring: var(--ring);
} }
/* Premium accents of the points shop (utils/rewards.js). Not in Auth's whitelist: Core owns who may wear them. */
[data-accent="gold"] {
--primary: oklch(0.58 0.14 85);
--primary-foreground: oklch(0.985 0.02 90);
--accent: oklch(0.96 0.05 90);
--accent-foreground: oklch(0.45 0.11 80);
--ring: oklch(0.7 0.15 85);
}
.dark[data-accent="gold"],
.dark [data-accent="gold"] {
--primary: oklch(0.8 0.16 85);
--primary-foreground: oklch(0.28 0.06 80);
--accent: oklch(0.28 0.06 85);
--accent-foreground: oklch(0.92 0.09 90);
--ring: oklch(0.7 0.15 85);
}
[data-accent="teal"] {
--primary: oklch(0.6 0.11 185);
--primary-foreground: oklch(0.984 0.014 180);
--accent: oklch(0.95 0.05 180);
--accent-foreground: oklch(0.44 0.09 188);
--ring: oklch(0.7 0.12 183);
}
.dark[data-accent="teal"],
.dark [data-accent="teal"] {
--primary: oklch(0.77 0.13 182);
--primary-foreground: oklch(0.28 0.05 190);
--accent: oklch(0.28 0.05 187);
--accent-foreground: oklch(0.92 0.08 180);
--ring: oklch(0.7 0.12 183);
}
/* The three logo colors per accent (components/Logo.jsx). Violet is the logo as designed; the others use /* The three logo colors per accent (components/Logo.jsx). Violet is the logo as designed; the others use
neighbouring hues, because a mix of a warm accent with the logo's teal turns olive. */ neighbouring hues, because a mix of a warm accent with the logo's teal turns olive. */
[data-accent="blue"] { [data-accent="blue"] {
@@ -214,6 +246,18 @@
--logo-teal: #f59e0b; --logo-teal: #f59e0b;
} }
[data-accent="gold"] {
--logo-violet: #eab308;
--logo-blue: #f59e0b;
--logo-teal: #f97316;
}
[data-accent="teal"] {
--logo-violet: #14b8a6;
--logo-blue: #06b6d4;
--logo-teal: #22c55e;
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
+3 -2
View File
@@ -37,8 +37,9 @@ export const readCachedAppearance = () => {
export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance)); export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance));
export const applyAppearance = (appearance, root = document.documentElement) => { // accentOverride: a premium accent worn from the shop (Core owns it), which wins over the saved one.
root.dataset.accent = appearance.accent; export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
root.dataset.accent = accentOverride ?? appearance.accent;
root.dataset.corners = appearance.corners; root.dataset.corners = appearance.corners;
}; };
+9 -1
View File
@@ -44,9 +44,17 @@ describe('applyAppearance', () => {
it('sets the presets as data attributes for the CSS', () => { it('sets the presets as data attributes for the CSS', () => {
const root = document.createElement('html'); const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, root); applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, null, root);
expect(root.dataset.accent).toBe('orange'); expect(root.dataset.accent).toBe('orange');
expect(root.dataset.corners).toBe('round'); expect(root.dataset.corners).toBe('round');
}); });
it('lets a worn premium accent win over the saved one', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round'}, 'gold', root);
expect(root.dataset.accent).toBe('gold');
});
}); });
+43
View File
@@ -0,0 +1,43 @@
import {Swords, Trophy} from 'lucide-react';
import {apiFetch, request} from './api.js';
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME'];
// The shop catalog of Core (ShopItem) as looks: only these classes are ever applied, never a string from the server.
// An unknown code is skipped.
export const LOOKS = {
BORDER_GOLD: {className: 'ring-4 ring-amber-400'},
BORDER_NEON: {className: 'ring-4 ring-fuchsia-500 shadow-[0_0_14px] shadow-fuchsia-500/60'},
BADGE_PILE_SLAYER: {Icon: Swords, className: 'bg-amber-500/15 text-amber-500'},
BADGE_COMPLETIONIST: {Icon: Trophy, className: 'bg-sky-500/15 text-sky-500'},
ACCENT_GOLD: {accent: 'gold'},
ACCENT_TEAL: {accent: 'teal'},
FRAME_GOLD: {className: 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background'},
FRAME_NEON: {className: 'ring-2 ring-fuchsia-500 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-fuchsia-500/60'},
};
/** The premium accent worn, or null (then the appearance accent applies). */
export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? null;
/** The classes of the item worn in a slot, or undefined. */
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
/** The badge worn: `{code, Icon, className}` or null. */
export const equippedBadge = (equipped) => {
const look = LOOKS[equipped?.BADGE];
return look?.Icon ? {code: equipped.BADGE, ...look} : null;
};
/** GET /api/rewards/wallet on Core: `{ok, status, data}`. Data is the balance, catalog, owned codes, worn looks and history. */
export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
/** POST /api/rewards/purchases; the caller handles the status (409: ALREADY_OWNED or NOT_ENOUGH_POINTS). */
export const buyItem = (code) =>
apiFetch(`${REWARDS_URL}/purchases`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({item: code})});
/** PUT /api/rewards/equipped/{slot}; a null code takes the look off. */
export const equipItem = (slot, code) =>
apiFetch(`${REWARDS_URL}/equipped/${slot}`, {method: 'PUT', headers: JSON_HEADERS, body: JSON.stringify({item: code})});
+7
View File
@@ -51,3 +51,10 @@ export const saveSettings = (changes) => {
queue = result.catch(() => {}); queue = result.catch(() => {});
return result; return result;
}; };
/** PUT on Auth, 204: the welcome page was seen. The caller handles the response status. */
export const markWelcomeSeen = () =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/welcome-seen`, {
method: 'PUT',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
+1
View File
@@ -16,6 +16,7 @@ const STAT_TILES = {
STATS_TASTE: {M: 6, L: 12}, STATS_TASTE: {M: 6, L: 12},
STATS_HABITS: {M: 6, L: 12}, STATS_HABITS: {M: 6, L: 12},
STATS_FINISHED: {M: 6, L: 12}, STATS_FINISHED: {M: 6, L: 12},
STATS_PLATFORMS: {M: 6, L: 12},
}; };
export const DASHBOARD_TILES = { export const DASHBOARD_TILES = {