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

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