From ca3d34bad01422a44965644986fe2b8ae675f927 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 13:17:04 +0200 Subject: [PATCH] feat(points): balance, history and shop page Add /points page with wallet balance, shop grouped by slot and ledger history with game names. The wallet loads once in PreferencesContext after login, so the header shows a balance chip and the link. Premium accents gold and teal override the free accent while equipped. German and English. --- README.md | 3 + public/i18n/de.json | 48 +++++++ public/i18n/en.json | 48 +++++++ src/App.jsx | 2 + src/App.test.jsx | 1 + src/components/AppHeader.jsx | 12 +- src/components/AppHeader.points.test.jsx | 38 ++++++ src/context/PreferencesContext.jsx | 20 ++- src/context/PreferencesContext.test.jsx | 1 + src/hooks/useSaveAppearance.test.jsx | 5 +- src/pages/PointsPage.jsx | 157 +++++++++++++++++++++++ src/pages/PointsPage.test.jsx | 128 ++++++++++++++++++ src/style.css | 44 +++++++ src/utils/appearance.js | 5 +- src/utils/appearance.test.js | 10 +- src/utils/rewards.js | 43 +++++++ 16 files changed, 556 insertions(+), 9 deletions(-) create mode 100644 src/components/AppHeader.points.test.jsx create mode 100644 src/pages/PointsPage.jsx create mode 100644 src/pages/PointsPage.test.jsx create mode 100644 src/utils/rewards.js diff --git a/README.md b/README.md index 56f9ca8..04b54aa 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ To Be Played is split into three repositories: - **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; 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 diff --git a/public/i18n/de.json b/public/i18n/de.json index 3fd0368..3228a48 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -294,6 +294,7 @@ "LIGHT_MODE": "Helles Design", "PLAY_NEXT": "Nächstes Spiel", "STATS": "Statistik", + "POINTS": "Pile Points", "ADMIN": "Admin", "VIEW_AS": { "MENU": "Ansehen als", @@ -801,6 +802,53 @@ "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." }, + "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": { "CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?" } diff --git a/public/i18n/en.json b/public/i18n/en.json index d3d9083..cbd771a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -294,6 +294,7 @@ "LIGHT_MODE": "Light mode", "PLAY_NEXT": "Play next", "STATS": "Stats", + "POINTS": "Pile Points", "ADMIN": "Admin", "VIEW_AS": { "MENU": "View as", @@ -801,6 +802,53 @@ "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." }, + "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": { "CONFIRM": "You have changes that are not saved. Leave this page and lose them?" } diff --git a/src/App.jsx b/src/App.jsx index 57e632b..2453874 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -20,6 +20,7 @@ import WishlistPage from './pages/WishlistPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx'; import StatsPage from './pages/StatsPage.jsx'; import ImportPage from './pages/ImportPage.jsx'; +import PointsPage from './pages/PointsPage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx'; import AppHeader from './components/AppHeader.jsx'; import RequireSession from './components/RequireSession.jsx'; @@ -74,6 +75,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/App.test.jsx b/src/App.test.jsx index 8c263cd..5559280 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -29,6 +29,7 @@ describe('App', () => { const library = () => (expired ? json(401, {}) : json(200, page)); fetchRoutes({ '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-count': json(200, {hasSteamId: true}), 'GET /api/library/steam-profile': json(200, null), diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 459aa58..311510b 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; 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 {Badge} from '@/components/ui/badge'; @@ -43,7 +43,7 @@ const navLinkClass = (isActive) => cn( export default function AppHeader({isAuthenticated, onLogout}) { const {t, i18n} = useTranslation(); - const {isDarkMode, setDarkMode, setLanguage} = usePreferences(); + const {isDarkMode, setDarkMode, setLanguage, wallet} = usePreferences(); const {pathname} = useLocation(); const steamProfile = useSteamProfile(isAuthenticated); const currentUser = useCurrentUser(isAuthenticated); @@ -126,6 +126,11 @@ export default function AppHeader({isAuthenticated, onLogout}) { {isAuthenticated && viewingAs && ( {t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})} )} + {isAuthenticated && wallet && ( + + )} {isAuthenticated ? ( open && setMenuOpenings((count) => count + 1)}> @@ -144,6 +149,9 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.SETTINGS')} + + {t('NAV.POINTS')} + {t('NAV.WHATS_NEW')} diff --git a/src/components/AppHeader.points.test.jsx b/src/components/AppHeader.points.test.jsx new file mode 100644 index 0000000..28d8f71 --- /dev/null +++ b/src/components/AppHeader.points.test.jsx @@ -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 ; +} + +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(<> {}}/>); + 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'); + }); +}); diff --git a/src/context/PreferencesContext.jsx b/src/context/PreferencesContext.jsx index 1fc4cd7..c051ab8 100644 --- a/src/context/PreferencesContext.jsx +++ b/src/context/PreferencesContext.jsx @@ -12,6 +12,7 @@ import { import {hasSession} from '../utils/auth.js'; import useSession from '../hooks/useSession.js'; import {LANGUAGES, saveSettings} from '../utils/settings.js'; +import {equippedAccent, loadWallet} from '../utils/rewards.js'; import {storage} from '../utils/storage.js'; 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. 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. useLayoutEffect(() => { - applyAppearance(appearance); + applyAppearance(appearance, equippedAccent(wallet?.equipped)); 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. const languagePicks = useRef(0); @@ -75,9 +85,10 @@ export function PreferencesProvider({children}) { } catch (err) { console.error('Preferences load error:', err); } + await reloadWallet(); })(); 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 @@ -124,6 +135,7 @@ export function PreferencesProvider({children}) { pendingChanges.current = {}; savedAppearance.current = DEFAULT_APPEARANCE; setAppearanceState(DEFAULT_APPEARANCE); + setWallet(null); }, []); // Without a session the next user must not get this look. @@ -153,6 +165,8 @@ export function PreferencesProvider({children}) { setLanguage, appearance, changeAppearance, + wallet, + reloadWallet, syncPreferences, resetAppearance, }} diff --git a/src/context/PreferencesContext.test.jsx b/src/context/PreferencesContext.test.jsx index c769a4b..1497809 100644 --- a/src/context/PreferencesContext.test.jsx +++ b/src/context/PreferencesContext.test.jsx @@ -35,6 +35,7 @@ afterEach(() => { const answerMe = (settings) => fetchRoutes({ 'GET /api/users/me': json(200, {displayName: 'Lobo', settings}), 'PUT /api/users/settings': json(204), + 'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}), }); describe('PreferencesProvider storage', () => { diff --git a/src/hooks/useSaveAppearance.test.jsx b/src/hooks/useSaveAppearance.test.jsx index 5e14d0f..552730e 100644 --- a/src/hooks/useSaveAppearance.test.jsx +++ b/src/hooks/useSaveAppearance.test.jsx @@ -24,6 +24,7 @@ const wrapper = ({children}) => ( const PUT = 'PUT /api/users/settings/appearance'; const ME = 'GET /api/users/me'; +const WALLET = 'GET /api/rewards/wallet'; const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper}); @@ -122,6 +123,7 @@ describe('useSaveAppearance', () => { let answerMe; fetchRoutes({ [PUT]: json(204), + [WALLET]: json(200, {}), [ME]: () => new Promise((resolve) => { 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 () => { 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'}}})); })}); const {result} = renderSave(); @@ -211,6 +213,7 @@ describe('useSaveAppearance', () => { let answerMe; fetchRoutes({ [PUT]: json(204), + [WALLET]: json(200, {}), [ME]: () => new Promise((resolve) => { answerMe = () => resolve(json(200, {})); }), diff --git a/src/pages/PointsPage.jsx b/src/pages/PointsPage.jsx new file mode 100644 index 0000000..5885f73 --- /dev/null +++ b/src/pages/PointsPage.jsx @@ -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 ( + + + + + ); + } + + 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 ( + + {entry.gameName ?? entry.ref} + + ); + } + if (entry.kind === 'BUY') return t(`POINTS.ITEMS.${entry.ref}`); + if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref; + return null; + }; + + return ( + +
+

{t('POINTS.TITLE')}

+

+ + {t('POINTS.BALANCE')}: {wallet.balance} +

+
+ + +

{t('POINTS.HOW_TO')}

+
    + {HOW_TO.map((kind) =>
  • {t(`POINTS.RULES.${kind}`)}
  • )} +
+
+ +
+

{t('POINTS.SHOP')}

+ {SLOTS.map((slot) => ( + +

{t(`POINTS.SLOTS.${slot}`)}

+
    + {wallet.catalog.filter((item) => item.slot === slot).map((item) => { + const owned = wallet.owned.includes(item.code); + const worn = wallet.equipped[slot] === item.code; + return ( +
  • + + {t(`POINTS.ITEMS.${item.code}`)} + {worn && {t('POINTS.EQUIPPED')}} + {owned ? ( + + ) : ( + <> + {item.price} + + + )} +
  • + ); + })} +
+
+ ))} +
+ + +

{t('POINTS.HISTORY')}

+ {wallet.history.length === 0 ? ( +

{t('POINTS.EMPTY_HISTORY')}

+ ) : ( +
    + {wallet.history.map((entry) => { + const label = entryLabel(entry); + return ( +
  • + + {formatDate(entry.eventAt, i18n.language)} + + + {t(`POINTS.KINDS.${entry.kind}`)} + {label && <> {label}} + + + {entry.points > 0 ? `+${entry.points}` : entry.points} + +
  • + ); + })} +
+ )} +
+
+ ); +} + +// 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 ; + } + if (look.accent) { + return ; + } + return ; +} diff --git a/src/pages/PointsPage.test.jsx b/src/pages/PointsPage.test.jsx new file mode 100644 index 0000000..6058a50 --- /dev/null +++ b/src/pages/PointsPage.test.jsx @@ -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 ; +} + +const renderPage = async (routes = {}) => { + fetchRoutes({'GET /api/users/me': json(200, {}), 'GET /api/rewards/wallet': json(200, wallet()), ...routes}); + renderWithProviders(<>, {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(<>, {entries: ['/points']}); + fireEvent.click(screen.getByText('load')); + await screen.findByText('POINTS.SHOP'); + return mock; +} diff --git a/src/style.css b/src/style.css index 15a3029..436eda3 100644 --- a/src/style.css +++ b/src/style.css @@ -182,6 +182,38 @@ --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 neighbouring hues, because a mix of a warm accent with the logo's teal turns olive. */ [data-accent="blue"] { @@ -214,6 +246,18 @@ --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 { * { @apply border-border outline-ring/50; diff --git a/src/utils/appearance.js b/src/utils/appearance.js index c2cd82c..7a5170d 100644 --- a/src/utils/appearance.js +++ b/src/utils/appearance.js @@ -37,8 +37,9 @@ export const readCachedAppearance = () => { export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance)); -export const applyAppearance = (appearance, root = document.documentElement) => { - root.dataset.accent = appearance.accent; +// accentOverride: a premium accent worn from the shop (Core owns it), which wins over the saved one. +export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => { + root.dataset.accent = accentOverride ?? appearance.accent; root.dataset.corners = appearance.corners; }; diff --git a/src/utils/appearance.test.js b/src/utils/appearance.test.js index 29f2224..cfa6020 100644 --- a/src/utils/appearance.test.js +++ b/src/utils/appearance.test.js @@ -44,9 +44,17 @@ describe('applyAppearance', () => { it('sets the presets as data attributes for the CSS', () => { 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.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'); + }); }); diff --git a/src/utils/rewards.js b/src/utils/rewards.js new file mode 100644 index 0000000..93ef8ae --- /dev/null +++ b/src/utils/rewards.js @@ -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})});