Public Access
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:
1 parent
22633a9e45
commit
ca3d34bad0
16 files changed
+556
-9
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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})});
|
||||
Reference in new issue
Block a user