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.
This commit is contained in:
lobo committed 2026-10-01 13:17:04 +02:00
1 parent 22633a9e45
commit ca3d34bad0
16 files changed
+556 -9

No files matched your search

+2
View File
@@ -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() {
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/>
<Route path="/points" element={<PointsPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/>
+1
View File
@@ -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),
+10 -2
View File
@@ -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 && (
<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 ? (
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
<DropdownMenuTrigger asChild>
@@ -144,6 +149,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild>
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem>
+38
View File
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen} from '@testing-library/react';
import AppHeader from './AppHeader.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(() => {
cleanup();
localStorage.clear();
});
function Load() {
const {reloadWallet} = usePreferences();
return <button onClick={reloadWallet}>load</button>;
}
describe('AppHeader points chip', () => {
it('shows the balance as a link to the points page once the wallet is loaded', async () => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
expect(screen.queryByTitle('POINTS.TITLE')).toBeNull();
fireEvent.click(screen.getByText('load'));
const chip = await screen.findByTitle('POINTS.TITLE');
expect(chip.textContent).toBe('135');
expect(chip.getAttribute('href')).toBe('/points');
});
});
+17 -3
View File
@@ -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,
}}
+1
View File
@@ -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', () => {
+4 -1
View File
@@ -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, {}));
}),
+157
View File
@@ -0,0 +1,157 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Coins} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import PageContainer from '../components/PageContainer.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK'];
export default function PointsPage() {
const {t, i18n} = useTranslation();
const {wallet, reloadWallet} = usePreferences();
if (!wallet) {
return (
<PageContainer className="p-4 md:p-8 space-y-6">
<Skeleton className="h-24 rounded-xl"/>
<Skeleton className="h-64 rounded-xl"/>
</PageContainer>
);
}
const act = async (send) => {
try {
const response = await send();
if (response.ok) {
await reloadWallet();
} else if (response.status === 409) {
const body = await response.json().catch(() => null);
if (body?.code === 'NOT_ENOUGH_POINTS') toast.error(t('POINTS.ERROR_NOT_ENOUGH'));
await reloadWallet();
} else if (response.status !== 401) {
toast.error(t('SETTINGS.ERROR_SAVE'));
}
} catch (err) {
console.error('Points save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
const entryLabel = (entry) => {
if (GAME_KINDS.includes(entry.kind)) {
return (
<Link to={`/library/${entry.ref}`} className="font-medium text-primary hover:underline">
{entry.gameName ?? entry.ref}
</Link>
);
}
if (entry.kind === 'BUY') return t(`POINTS.ITEMS.${entry.ref}`);
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
return null;
};
return (
<PageContainer className="p-4 md:p-8 space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
<p className="mt-2 flex items-center gap-2 text-lg">
<Coins className="size-5 text-primary"/>
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
</p>
</div>
<Card className="gap-2 p-5">
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
</ul>
</Card>
<section className="space-y-4">
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
{SLOTS.map((slot) => (
<Card key={slot} className="gap-3 p-5">
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
<ul className="divide-y">
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
const owned = wallet.owned.includes(item.code);
const worn = wallet.equipped[slot] === item.code;
return (
<li key={item.code} className="flex items-center gap-3 py-3">
<Preview code={item.code}/>
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
{owned ? (
<Button size="sm" variant="outline"
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
</Button>
) : (
<>
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
<Button size="sm" disabled={wallet.balance < item.price}
onClick={() => act(() => buyItem(item.code))}>
{t('POINTS.BUY')}
</Button>
</>
)}
</li>
);
})}
</ul>
</Card>
))}
</section>
<Card className="gap-3 p-5">
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
{wallet.history.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
) : (
<ul className="divide-y">
{wallet.history.map((entry) => {
const label = entryLabel(entry);
return (
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="flex items-center gap-3 py-2 text-sm">
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
{formatDate(entry.eventAt, i18n.language)}
</span>
<span className="min-w-0 flex-1 truncate">
{t(`POINTS.KINDS.${entry.kind}`)}
{label && <> {label}</>}
</span>
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
{entry.points > 0 ? `+${entry.points}` : entry.points}
</span>
</li>
);
})}
</ul>
)}
</Card>
</PageContainer>
);
}
// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot or a framed box.
function Preview({code}) {
const look = LOOKS[code];
if (!look) return null;
if (look.Icon) {
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
}
if (look.accent) {
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
}
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
}
+128
View File
@@ -0,0 +1,128 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import {toast} from 'sonner';
import PointsPage from './PointsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const wallet = (extra = {}) => ({
balance: 160,
catalog: [
{code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150},
{code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200},
{code: 'BADGE_PILE_SLAYER', slot: 'BADGE', price: 100},
],
owned: ['BADGE_PILE_SLAYER'],
equipped: {},
history: [
{kind: 'FINISHED', ref: '620', gameName: 'Portal 2', points: 50, eventAt: '2026-09-30T10:00:00Z'},
{kind: 'MONTH', ref: '2026-09', gameName: null, points: 40, eventAt: '2026-09-30T10:00:00Z'},
],
...extra,
});
// Loads the wallet like App does after the login, then shows the page.
function Loaded() {
const {syncPreferences} = usePreferences();
return <button onClick={syncPreferences}>load</button>;
}
const renderPage = async (routes = {}) => {
fetchRoutes({'GET /api/users/me': json(200, {}), 'GET /api/rewards/wallet': json(200, wallet()), ...routes});
renderWithProviders(<><Loaded/><PointsPage/></>, {entries: ['/points']});
fireEvent.click(screen.getByText('load'));
await screen.findByText('POINTS.SHOP');
};
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('PointsPage', () => {
it('groups the catalog by slot and disables Buy below the price', async () => {
await renderPage();
const border = screen.getByText('POINTS.SLOTS.AVATAR_BORDER').closest('[data-slot="card"]');
const gold = within(border).getByText('POINTS.ITEMS.BORDER_GOLD').closest('li');
const neon = within(border).getByText('POINTS.ITEMS.BORDER_NEON').closest('li');
expect(within(gold).getByRole('button', {name: 'POINTS.BUY'}).disabled).toBe(false);
expect(within(neon).getByRole('button', {name: 'POINTS.BUY'}).disabled).toBe(true);
expect(within(screen.getByText('POINTS.SLOTS.BADGE').closest('[data-slot="card"]'))
.getByRole('button', {name: 'POINTS.EQUIP'})).toBeTruthy();
});
it('shows the game name as a link and the ref for month rows', async () => {
await renderPage();
expect(screen.getByRole('link', {name: 'Portal 2'}).getAttribute('href')).toBe('/library/620');
expect(screen.getByText(/2026-09/)).toBeTruthy();
expect(screen.getByText('+50')).toBeTruthy();
});
it('buys, then loads the wallet again', async () => {
let balance = 160;
const mock = await renderPageWith(() => json(200, wallet({balance})), {
'POST /api/rewards/purchases': () => { balance = 10; return json(200, wallet({balance})); },
});
fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'}));
await waitFor(() => expect(screen.getByText('10')).toBeTruthy());
const post = mock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(JSON.parse(post[1].body)).toEqual({item: 'BORDER_GOLD'});
});
it('shows the not-enough message on a 409', async () => {
await renderPage({'POST /api/rewards/purchases': json(409, {code: 'NOT_ENOUGH_POINTS'})});
fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('POINTS.ERROR_NOT_ENOUGH'));
});
it('an ALREADY_OWNED 409 reloads the wallet without the not-enough message', async () => {
const mock = await renderPageWith(json(200, wallet()), {'POST /api/rewards/purchases': json(409, {code: 'ALREADY_OWNED'})});
fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'}));
await waitFor(() => expect(mock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(toast.error).not.toHaveBeenCalledWith('POINTS.ERROR_NOT_ENOUGH');
});
it('sends the equip and unequip PUT', async () => {
const puts = [];
await renderPage({'PUT /api/rewards/equipped/BADGE': (url, init) => { puts.push(JSON.parse(init.body)); return json(200, wallet()); }});
fireEvent.click(screen.getByRole('button', {name: 'POINTS.EQUIP'}));
await waitFor(() => expect(puts).toEqual([{item: 'BADGE_PILE_SLAYER'}]));
});
it('sends null to take a worn look off', async () => {
const puts = [];
await renderPage({
'GET /api/rewards/wallet': json(200, wallet({equipped: {BADGE: 'BADGE_PILE_SLAYER'}})),
'PUT /api/rewards/equipped/BADGE': (url, init) => { puts.push(JSON.parse(init.body)); return json(200, wallet()); },
});
fireEvent.click(screen.getByRole('button', {name: 'POINTS.UNEQUIP'}));
await waitFor(() => expect(puts).toEqual([{item: null}]));
});
});
// A wallet answer that changes between calls.
async function renderPageWith(walletAnswer, routes) {
const mock = fetchRoutes({'GET /api/users/me': json(200, {}), 'GET /api/rewards/wallet': walletAnswer, ...routes});
renderWithProviders(<><Loaded/><PointsPage/></>, {entries: ['/points']});
fireEvent.click(screen.getByText('load'));
await screen.findByText('POINTS.SHOP');
return mock;
}
+44
View File
@@ -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;
+3 -2
View File
@@ -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;
};
+9 -1
View File
@@ -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');
});
});
+43
View File
@@ -0,0 +1,43 @@
import {Swords, Trophy} from 'lucide-react';
import {apiFetch, request} from './api.js';
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME'];
// The shop catalog of Core (ShopItem) as looks: only these classes are ever applied, never a string from the server.
// An unknown code is skipped.
export const LOOKS = {
BORDER_GOLD: {className: 'ring-4 ring-amber-400'},
BORDER_NEON: {className: 'ring-4 ring-fuchsia-500 shadow-[0_0_14px] shadow-fuchsia-500/60'},
BADGE_PILE_SLAYER: {Icon: Swords, className: 'bg-amber-500/15 text-amber-500'},
BADGE_COMPLETIONIST: {Icon: Trophy, className: 'bg-sky-500/15 text-sky-500'},
ACCENT_GOLD: {accent: 'gold'},
ACCENT_TEAL: {accent: 'teal'},
FRAME_GOLD: {className: 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background'},
FRAME_NEON: {className: 'ring-2 ring-fuchsia-500 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-fuchsia-500/60'},
};
/** The premium accent worn, or null (then the appearance accent applies). */
export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? null;
/** The classes of the item worn in a slot, or undefined. */
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
/** The badge worn: `{code, Icon, className}` or null. */
export const equippedBadge = (equipped) => {
const look = LOOKS[equipped?.BADGE];
return look?.Icon ? {code: equipped.BADGE, ...look} : null;
};
/** GET /api/rewards/wallet on Core: `{ok, status, data}`. Data is the balance, catalog, owned codes, worn looks and history. */
export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
/** POST /api/rewards/purchases; the caller handles the status (409: ALREADY_OWNED or NOT_ENOUGH_POINTS). */
export const buyItem = (code) =>
apiFetch(`${REWARDS_URL}/purchases`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({item: code})});
/** PUT /api/rewards/equipped/{slot}; a null code takes the look off. */
export const equipItem = (slot, code) =>
apiFetch(`${REWARDS_URL}/equipped/${slot}`, {method: 'PUT', headers: JSON_HEADERS, body: JSON.stringify({item: code})});