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
@@ -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
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
|
||||
@@ -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/>}/>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, {}));
|
||||
}),
|
||||
|
||||
@@ -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')}/>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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