Public Access
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0154202ddc | ||
|
|
ca3d34bad0 | ||
|
|
22633a9e45 | ||
|
|
b129f6a6e4 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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/>}/>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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, {}));
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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')}/>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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})});
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in new issue
Block a user