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

+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;
}