Public Access
feat(points): show points result on every status save
Core now settles on GET /api/rewards/games/{appid}. The Frontend
shows a toast, refreshes the header balance, and lists the paid
rows and reasons on the game page. Deploy Core first.
This commit is contained in:
1 parent
9adc606c57
commit
814decc781
20 files changed
+429
-12
No files matched your search
@@ -52,7 +52,20 @@ To Be Played is split into three repositories:
|
|||||||
until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more
|
until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more
|
||||||
play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS
|
play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS
|
||||||
variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still (canvas: one static frame, no loop) with the
|
variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still (canvas: one static frame, no loop) with the
|
||||||
system setting "reduce motion" or the "Reduce animations" switch in the settings.
|
system setting "reduce motion" or the "Reduce animations" switch in the settings. After every status save (game page,
|
||||||
|
Play Next "Start", Questions, add game) `usePointsCheck` asks Core `GET /api/rewards/games/{appid}`, reloads the wallet
|
||||||
|
when the balance moved and shows a toast ("+N points", "Already paid on ...", "No points yet: reason"). The game page
|
||||||
|
shows the paid rows and the reason per missing kind (`GamePointsLine`); `pointsNotice` in `utils/rewards.js` picks the
|
||||||
|
toast, the reason codes come from Core and `POINTS.REASONS` only maps them to texts.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Save["Status save"] --> Check["usePointsCheck"]
|
||||||
|
Check -->|"GET /api/rewards/games/{appid}"| Core["Core: settle + paid rows + reasons"]
|
||||||
|
Check --> Toast["Toast"]
|
||||||
|
Check -->|balance moved| Wallet["reloadWallet: header"]
|
||||||
|
Core --> Line["GamePointsLine on the game page"]
|
||||||
|
```
|
||||||
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
|
- **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.
|
finished games per year of all users.
|
||||||
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
||||||
|
|||||||
+33
-3
@@ -1181,11 +1181,41 @@
|
|||||||
"RULES": {
|
"RULES": {
|
||||||
"STARTED": "5 Punkte, wenn du ein Spiel von deinem Stapel startest und spielst (Steam-Spiele, einmal pro Spiel).",
|
"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.",
|
"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.",
|
"COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast. Es braucht ein belegtes Steam-Durchspielen; ein manuelles öffnet es nie.",
|
||||||
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche.",
|
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche (Montag bis Sonntag). Ein 4. Spiel in dieser Woche zahlt nichts.",
|
||||||
"MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
|
"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.",
|
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel.",
|
||||||
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst."
|
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst.",
|
||||||
|
"PROOF": "Ein Steam-Spiel braucht als Beweis frisches Spielen: den Abschluss-Erfolg (höchstens 30 Tage vor dem Durchspielen freigeschaltet), Spielzeit ab 30 Tage vor dem Durchspielen oder die halbe Normalzeit danach gespielt.",
|
||||||
|
"ONCE": "Jede Art zahlt einmal pro Spiel. Editionen eines Spiels zählen als eines.",
|
||||||
|
"PLAYSTATION": "PlayStation-Spielzeit zahlt nichts und ist kein Beweis. Ein Spiel, das mit einem PlayStation-Titel verknüpft ist, zahlt vorerst nichts.",
|
||||||
|
"HIDDEN": "Versteckte Spiele zahlen nichts.",
|
||||||
|
"CHECKED": "Punkte werden geprüft, wenn du einen Status änderst, ein Spiel oder die App öffnest, und jede Nacht.",
|
||||||
|
"STARTER": "Durchgespielte Spiele aus der Zeit vor dem Status-Verlauf bekamen einmal Startpunkte (20 pro Spiel, höchstens 250)."
|
||||||
|
},
|
||||||
|
"GAME": {
|
||||||
|
"PAID": "{{kind}}: {{points}} Punkte, bezahlt am {{date}}",
|
||||||
|
"OTHER_EDITION": "(andere Edition des Spiels)",
|
||||||
|
"WAITING": "Durchgespielt: wartet auf Beweis. Spiele das Spiel nach dem Durchspielen einmal.",
|
||||||
|
"WAITING_START": "Gestartet: spiele das Spiel einmal, um die 5 Punkte zu bekommen.",
|
||||||
|
"NONE": "{{kind}}: keine Punkte - {{reason}}",
|
||||||
|
"TOAST_BOOKED": "+{{count}} Punkte",
|
||||||
|
"TOAST_PAID": "Schon bezahlt am {{date}}",
|
||||||
|
"TOAST_NONE": "Noch keine Punkte: {{reason}}",
|
||||||
|
"TOAST_STATUS": "Dieser Status bringt keine Punkte"
|
||||||
|
},
|
||||||
|
"REASONS": {
|
||||||
|
"HIDDEN": "das Spiel ist ausgeblendet",
|
||||||
|
"PLAYSTATION": "PlayStation-Spiele zahlen nichts",
|
||||||
|
"PLAYSTATION_LINKED": "das Spiel ist mit einem PlayStation-Titel verknüpft (zahlt vorerst nichts)",
|
||||||
|
"BEFORE_HISTORY": "durchgespielt, bevor der Status-Verlauf begann (siehe Startpunkte)",
|
||||||
|
"MANUAL_GAME": "ein manuelles Spiel hat keine Spielzeit als Beweis",
|
||||||
|
"NO_PLAY_AFTER_START": "seit dem Start nicht gespielt",
|
||||||
|
"NO_PLAYTIME": "keine Spielzeit erfasst",
|
||||||
|
"NO_PROOF": "kein Beweis für frisches Spielen, spiele einmal nach dem Durchspielen",
|
||||||
|
"WEEKLY_LIMIT": "Wochenlimit erreicht",
|
||||||
|
"NEEDS_PROVEN_FINISH": "braucht zuerst ein bewiesenes Durchspielen",
|
||||||
|
"NOT_ALL_ACHIEVEMENTS": "nicht alle Erfolge freigeschaltet"
|
||||||
},
|
},
|
||||||
"SLOTS": {
|
"SLOTS": {
|
||||||
"AVATAR_BORDER": "Avatarrahmen",
|
"AVATAR_BORDER": "Avatarrahmen",
|
||||||
|
|||||||
+33
-3
@@ -1181,11 +1181,41 @@
|
|||||||
"RULES": {
|
"RULES": {
|
||||||
"STARTED": "5 points when you start a game from your pile and play it (Steam games, once per game).",
|
"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.",
|
"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.",
|
"COMPLETED": "+40 points when you reach 100 % achievements in a completed game. It needs a proven Steam finish; a manual finish never opens it.",
|
||||||
"FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week.",
|
"FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week (Monday to Sunday). A 4th finish in that week pays nothing.",
|
||||||
"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.",
|
"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.",
|
"STREAK": "20 points for every week with a finished game after a week with a finished game.",
|
||||||
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal."
|
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal.",
|
||||||
|
"PROOF": "A Steam finish needs proof of fresh play: the ending achievement (not older than 30 days before the finish), any play from 30 days before the finish on, or half of the normal time played after it.",
|
||||||
|
"ONCE": "Each kind pays once per game. Editions of one game count as one.",
|
||||||
|
"PLAYSTATION": "PlayStation play pays nothing and is no proof. A game linked to a PlayStation title pays nothing for now.",
|
||||||
|
"HIDDEN": "Hidden games pay nothing.",
|
||||||
|
"CHECKED": "Points are checked when you change a status, open a game or the app, and every night.",
|
||||||
|
"STARTER": "Finishes from before the status history got starter points once (20 per game, 250 at most)."
|
||||||
|
},
|
||||||
|
"GAME": {
|
||||||
|
"PAID": "{{kind}}: {{points}} points, paid on {{date}}",
|
||||||
|
"OTHER_EDITION": "(other edition of the game)",
|
||||||
|
"WAITING": "Finished: waiting for proof. Play the game once after finishing.",
|
||||||
|
"WAITING_START": "Started: play the game once to get the 5 points.",
|
||||||
|
"NONE": "{{kind}}: no points - {{reason}}",
|
||||||
|
"TOAST_BOOKED": "+{{count}} points",
|
||||||
|
"TOAST_PAID": "Already paid on {{date}}",
|
||||||
|
"TOAST_NONE": "No points yet: {{reason}}",
|
||||||
|
"TOAST_STATUS": "This status earns no points"
|
||||||
|
},
|
||||||
|
"REASONS": {
|
||||||
|
"HIDDEN": "the game is hidden",
|
||||||
|
"PLAYSTATION": "PlayStation games pay nothing",
|
||||||
|
"PLAYSTATION_LINKED": "the game is linked to a PlayStation title (pays nothing for now)",
|
||||||
|
"BEFORE_HISTORY": "finished before the status history began (see starter points)",
|
||||||
|
"MANUAL_GAME": "a manual game has no play time to prove it",
|
||||||
|
"NO_PLAY_AFTER_START": "no play since the start",
|
||||||
|
"NO_PLAYTIME": "no play time on record",
|
||||||
|
"NO_PROOF": "no proof of fresh play, play once after finishing",
|
||||||
|
"WEEKLY_LIMIT": "weekly limit reached",
|
||||||
|
"NEEDS_PROVEN_FINISH": "needs a proven finish first",
|
||||||
|
"NOT_ALL_ACHIEVEMENTS": "not all achievements unlocked"
|
||||||
},
|
},
|
||||||
"SLOTS": {
|
"SLOTS": {
|
||||||
"AVATAR_BORDER": "Avatar border",
|
"AVATAR_BORDER": "Avatar border",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
|
|||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import FitText from './FitText.jsx';
|
import FitText from './FitText.jsx';
|
||||||
import GameSearch from './GameSearch.jsx';
|
import GameSearch from './GameSearch.jsx';
|
||||||
|
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||||
import useStartImport from '../hooks/useStartImport.js';
|
import useStartImport from '../hooks/useStartImport.js';
|
||||||
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||||
import {addGame, hoursToMinutes} from '../utils/manualGames.js';
|
import {addGame, hoursToMinutes} from '../utils/manualGames.js';
|
||||||
@@ -91,6 +92,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
|
|||||||
const [status, setStatus] = useState('BACKLOG');
|
const [status, setStatus] = useState('BACKLOG');
|
||||||
const [hours, setHours] = useState('');
|
const [hours, setHours] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const check = usePointsCheck();
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -110,6 +112,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
|
|||||||
}
|
}
|
||||||
const {appid} = await response.json();
|
const {appid} = await response.json();
|
||||||
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
|
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
|
||||||
|
check(appid, {status, tell: true});
|
||||||
navigate(`/library/${appid}`);
|
navigate(`/library/${appid}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('add game error:', err);
|
console.error('add game error:', err);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
|||||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
|
||||||
|
|
||||||
const zelda = {
|
const zelda = {
|
||||||
appid: -1025, igdbId: 1025, name: 'Zelda', year: 2017, imageUrl: 'https://images.igdb.com/z.jpg',
|
appid: -1025, igdbId: 1025, name: 'Zelda', year: 2017, imageUrl: 'https://images.igdb.com/z.jpg',
|
||||||
@@ -23,6 +23,9 @@ function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Ninte
|
|||||||
'GET /api/library/game-search': found,
|
'GET /api/library/game-search': found,
|
||||||
'POST /api/library/games': post,
|
'POST /api/library/games': post,
|
||||||
'POST /api/library/imports': post,
|
'POST /api/library/imports': post,
|
||||||
|
'GET /api/rewards/games/-1025': json(200, {balance: 15, missing: {}, paid: [
|
||||||
|
{kind: 'FINISHED_MANUAL', appid: -1025, points: 15, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]}),
|
||||||
|
'GET /api/rewards/wallet': json(200, {balance: 15, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
});
|
});
|
||||||
renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
|
renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
|
||||||
routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}});
|
routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}});
|
||||||
@@ -79,6 +82,24 @@ describe('AddGamePanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks the points after adding a game as finished and shows the toast', async () => {
|
||||||
|
// jsdom has no scrollIntoView, and the Radix select calls it when it opens.
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
renderPanel();
|
||||||
|
search('zelda');
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
|
||||||
|
fireEvent.keyDown(screen.getByRole('combobox', {name: 'BACKLOG.STATUS_LABEL'}), {key: 'ArrowDown'});
|
||||||
|
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.STATUS.FINISHED'}));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}));
|
||||||
|
|
||||||
|
await screen.findByText('detail page');
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:15'));
|
||||||
|
const points = fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/-1025'));
|
||||||
|
expect(points).toHaveLength(1);
|
||||||
|
expect(JSON.parse(fetch.mock.calls.find(([, call]) => call?.method === 'POST')[1].body).status).toBe('FINISHED');
|
||||||
|
});
|
||||||
|
|
||||||
it('preselects no platform without a favorite among several, so Add waits for a pick', async () => {
|
it('preselects no platform without a favorite among several, so Add waits for a pick', async () => {
|
||||||
renderPanel(undefined, null);
|
renderPanel(undefined, null);
|
||||||
search('zelda');
|
search('zelda');
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||||
|
import {formatDate} from '../utils/format.js';
|
||||||
|
import {STATUS_NOT_REACHED} from '../utils/rewards.js';
|
||||||
|
|
||||||
|
const WAITING = {NO_PROOF: 'POINTS.GAME.WAITING', NO_PLAY_AFTER_START: 'POINTS.GAME.WAITING_START'};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the game earned: one line per paid row and one per kind that is missing, with the reason from Core. Loading it
|
||||||
|
* settles the points; a status change after the first load also shows the points toast.
|
||||||
|
*/
|
||||||
|
export default function GamePointsLine({game}) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const check = usePointsCheck();
|
||||||
|
const [points, setPoints] = useState(null);
|
||||||
|
const lastStatus = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const changed = lastStatus.current !== null && lastStatus.current !== game.status;
|
||||||
|
lastStatus.current = game.status;
|
||||||
|
let stale = false;
|
||||||
|
check(game.appid, {status: game.status, tell: changed}).then((data) => {
|
||||||
|
if (!stale && data) setPoints(data);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
}, [check, game.appid, game.status]);
|
||||||
|
|
||||||
|
if (!points) return null;
|
||||||
|
const kindText = (kind) => t(`POINTS.KINDS.${kind}`);
|
||||||
|
const lines = [
|
||||||
|
...points.paid.map((row) => t('POINTS.GAME.PAID', {
|
||||||
|
kind: kindText(row.kind), points: row.points,
|
||||||
|
date: formatDate(row.paidAt, i18n.language, {dateStyle: 'medium'}),
|
||||||
|
}) + (row.appid === game.appid ? '' : ` ${t('POINTS.GAME.OTHER_EDITION')}`)),
|
||||||
|
...Object.entries(points.missing).filter(([, reason]) => !STATUS_NOT_REACHED.includes(reason)).map(([kind, reason]) => (
|
||||||
|
WAITING[reason] ? t(WAITING[reason])
|
||||||
|
: t('POINTS.GAME.NONE', {kind: kindText(kind), reason: t(`POINTS.REASONS.${reason}`)}))),
|
||||||
|
];
|
||||||
|
return lines.map((line, i) => <p key={i} className="text-sm text-muted-foreground">{line}</p>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import GamePointsLine from './GamePointsLine.jsx';
|
||||||
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
|
||||||
|
|
||||||
|
const row = (kind, extra = {}) => ({kind, appid: 220, points: 30, paidAt: '2026-10-01T10:00:00Z', bookedNow: false, ...extra});
|
||||||
|
const wallet = (balance) => json(200, {balance, catalog: [], owned: [], equipped: {}, history: []});
|
||||||
|
|
||||||
|
const renderLine = (status, points) => {
|
||||||
|
const answers = Array.isArray(points) ? [...points] : [points];
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/rewards/games/220': () => json(200, answers.length > 1 ? answers.shift() : answers[0]),
|
||||||
|
'GET /api/rewards/wallet': wallet(30),
|
||||||
|
});
|
||||||
|
const tree = (s) => <PreferencesProvider><GamePointsLine game={{appid: 220, status: s}}/></PreferencesProvider>;
|
||||||
|
const view = render(tree(status));
|
||||||
|
return {view, tree};
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GamePointsLine', () => {
|
||||||
|
it('shows one line per paid row with its date and notes another edition', async () => {
|
||||||
|
renderLine('FINISHED', {balance: 35, missing: {}, paid: [row('STARTED', {points: 5}), row('FINISHED', {appid: 221})]});
|
||||||
|
|
||||||
|
expect(await screen.findAllByText(/^POINTS.GAME.PAID/)).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText(/POINTS.GAME.OTHER_EDITION/)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the waiting line for missing proof and a reason line for another missing kind', async () => {
|
||||||
|
renderLine('COMPLETED', {balance: 30, paid: [], missing: {FINISHED: 'NO_PROOF', COMPLETED: 'NEEDS_PROVEN_FINISH'}});
|
||||||
|
|
||||||
|
expect(await screen.findByText('POINTS.GAME.WAITING')).toBeTruthy();
|
||||||
|
expect(screen.getAllByText(/^POINTS.GAME.NONE/)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no line for a status the game never reached', async () => {
|
||||||
|
renderLine('BACKLOG', {balance: 30, paid: [], missing: {STARTED: 'NOT_STARTED', FINISHED: 'NOT_FINISHED', COMPLETED: 'NOT_COMPLETED'}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(pointsCalls()).toHaveLength(1));
|
||||||
|
expect(document.body.textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks again with a toast after a status change, not on the first load', async () => {
|
||||||
|
const {view, tree} = renderLine('PLAYING', [
|
||||||
|
{balance: 0, paid: [], missing: {STARTED: 'NO_PLAY_AFTER_START'}},
|
||||||
|
{balance: 30, paid: [row('FINISHED', {bookedNow: true})], missing: {}},
|
||||||
|
]);
|
||||||
|
await screen.findByText('POINTS.GAME.WAITING_START');
|
||||||
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
view.rerender(tree('FINISHED'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:30'));
|
||||||
|
expect(pointsCalls()).toHaveLength(2);
|
||||||
|
expect(await screen.findByText(/^POINTS.GAME.PAID/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads the wallet when the balance changed, so the header updates', async () => {
|
||||||
|
renderLine('FINISHED', {balance: 30, paid: [row('FINISHED')], missing: {}});
|
||||||
|
|
||||||
|
await screen.findByText(/^POINTS.GAME.PAID/);
|
||||||
|
|
||||||
|
await waitFor(() => expect(globalThis.fetch.mock.calls.some(([url]) => url.includes('/api/rewards/wallet'))).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing and no toast when the request fails', async () => {
|
||||||
|
fetchRoutes({'GET /api/rewards/games/220': json(404, {code: 'GAME_NOT_FOUND'})});
|
||||||
|
render(<PreferencesProvider><GamePointsLine game={{appid: 220, status: 'FINISHED'}}/></PreferencesProvider>);
|
||||||
|
|
||||||
|
await waitFor(() => expect(pointsCalls()).toHaveLength(1));
|
||||||
|
expect(document.body.textContent).toBe('');
|
||||||
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {useCallback, useEffect, useRef} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
|
import {formatDate} from '../utils/format.js';
|
||||||
|
import {loadGamePoints, pointsNotice} from '../utils/rewards.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `check(appid, {status, tell})`: asks Core what the game earned (Core settles first), reloads the wallet when
|
||||||
|
* the balance moved, so the header updates, and with `tell` shows a toast. Resolves to the answer, or null when the
|
||||||
|
* request failed (nothing is shown then).
|
||||||
|
*/
|
||||||
|
export default function usePointsCheck() {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const {wallet, reloadWallet} = usePreferences();
|
||||||
|
const balance = useRef(wallet?.balance);
|
||||||
|
useEffect(() => {
|
||||||
|
balance.current = wallet?.balance;
|
||||||
|
}, [wallet]);
|
||||||
|
|
||||||
|
return useCallback(async (appid, {status, tell}) => {
|
||||||
|
const {ok, data} = await loadGamePoints(appid);
|
||||||
|
if (!ok) return null;
|
||||||
|
if (data.balance !== balance.current) {
|
||||||
|
balance.current = data.balance;
|
||||||
|
reloadWallet();
|
||||||
|
}
|
||||||
|
if (tell) {
|
||||||
|
const notice = pointsNotice(data, status);
|
||||||
|
if (notice.type === 'booked') {
|
||||||
|
toast.success(t('POINTS.GAME.TOAST_BOOKED', {count: notice.points}));
|
||||||
|
} else if (notice.type === 'paid') {
|
||||||
|
toast(t('POINTS.GAME.TOAST_PAID', {date: formatDate(notice.date, i18n.language, {dateStyle: 'medium'})}));
|
||||||
|
} else if (notice.type === 'none') {
|
||||||
|
toast(t('POINTS.GAME.TOAST_NONE', {reason: t(`POINTS.REASONS.${notice.reason}`)}));
|
||||||
|
} else {
|
||||||
|
toast(t('POINTS.GAME.TOAST_STATUS'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, [t, i18n.language, reloadWallet]);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
|||||||
import WalletLoader from '../test/WalletLoader.jsx';
|
import WalletLoader from '../test/WalletLoader.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('../components/GamePointsLine.jsx', () => ({default: () => null}));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
const game = {
|
const game = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import BacklogCard from '../components/BacklogCard.jsx';
|
|||||||
import Confetti from '../components/Confetti.jsx';
|
import Confetti from '../components/Confetti.jsx';
|
||||||
import GameAchievements from '../components/GameAchievements.jsx';
|
import GameAchievements from '../components/GameAchievements.jsx';
|
||||||
import GameHeroCard from '../components/GameHeroCard.jsx';
|
import GameHeroCard from '../components/GameHeroCard.jsx';
|
||||||
|
import GamePointsLine from '../components/GamePointsLine.jsx';
|
||||||
import ManualGameCard from '../components/ManualGameCard.jsx';
|
import ManualGameCard from '../components/ManualGameCard.jsx';
|
||||||
import CompletedHint from '../components/CompletedHint.jsx';
|
import CompletedHint from '../components/CompletedHint.jsx';
|
||||||
import FinishedHint from '../components/FinishedHint.jsx';
|
import FinishedHint from '../components/FinishedHint.jsx';
|
||||||
@@ -95,6 +96,8 @@ export default function GameDetail() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<GamePointsLine key={`points-${data.appid}`} game={data}/>
|
||||||
|
|
||||||
{/* The finished question also answers "are you playing it?". */}
|
{/* The finished question also answers "are you playing it?". */}
|
||||||
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
||||||
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
|||||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('../components/GamePointsLine.jsx', () => ({default: () => null}));
|
||||||
// Stands in for a saved correction, which loads the game again.
|
// Stands in for a saved correction, which loads the game again.
|
||||||
vi.mock('../components/GameCorrectionDialog.jsx', () => ({
|
vi.mock('../components/GameCorrectionDialog.jsx', () => ({
|
||||||
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>,
|
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {resetBrokenImageReports} from '../utils/brokenImages.js';
|
|||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('../components/GamePointsLine.jsx', () => ({default: () => null}));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
const game = {
|
const game = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
|||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import FitText from '../components/FitText.jsx';
|
import FitText from '../components/FitText.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||||
import {saveGameStatus} from '../utils/backlog.js';
|
import {saveGameStatus} from '../utils/backlog.js';
|
||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
@@ -281,6 +282,7 @@ function PickCard({game}) {
|
|||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [started, setStarted] = useState(false);
|
const [started, setStarted] = useState(false);
|
||||||
|
const check = usePointsCheck();
|
||||||
const reason = reasonText(game.reason, t, i18n.language);
|
const reason = reasonText(game.reason, t, i18n.language);
|
||||||
|
|
||||||
const startPlaying = async () => {
|
const startPlaying = async () => {
|
||||||
@@ -294,6 +296,7 @@ function PickCard({game}) {
|
|||||||
}
|
}
|
||||||
setStarted(true);
|
setStarted(true);
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
toast.success(t('BACKLOG.SAVED'));
|
||||||
|
check(game.appid, {status: 'PLAYING', tell: true});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Start playing error:', err);
|
console.error('Start playing error:', err);
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
|||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
|
||||||
|
|
||||||
const game = (appid, extra = {}) => ({
|
const game = (appid, extra = {}) => ({
|
||||||
appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null,
|
appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null,
|
||||||
@@ -33,6 +33,9 @@ function mockBackend(answer, routes = {}) {
|
|||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/library/picker': (url) => json(200, {categories: answer(new URL(url, 'http://x').searchParams)}),
|
'GET /api/library/picker': (url) => json(200, {categories: answer(new URL(url, 'http://x').searchParams)}),
|
||||||
'PUT /api/library/games/7/status': (url, init) => json(200, JSON.parse(init.body)),
|
'PUT /api/library/games/7/status': (url, init) => json(200, JSON.parse(init.body)),
|
||||||
|
'GET /api/rewards/games/7': json(200, {balance: 5, missing: {}, paid: [
|
||||||
|
{kind: 'STARTED', appid: 7, points: 5, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]}),
|
||||||
|
'GET /api/rewards/wallet': json(200, {balance: 5, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
...routes,
|
...routes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -323,6 +326,17 @@ describe('PlayNextPage', () => {
|
|||||||
expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'});
|
expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the points toast after Start', async () => {
|
||||||
|
mockRows([row('NEXT_UP', game(7))]);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 7');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:5'));
|
||||||
|
expect(globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/7'))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('lets the user try again when saving fails', async () => {
|
it('lets the user try again when saving fails', async () => {
|
||||||
mockBackend(() => [row('NEXT_UP', game(7))], {'PUT /api/library/games/7/status': json(500, {})});
|
mockBackend(() => [row('NEXT_UP', game(7))], {'PUT /api/library/games/7/status': json(500, {})});
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {formatDate} from '../utils/format.js';
|
|||||||
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
|
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
|
||||||
|
|
||||||
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
|
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
|
||||||
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK', 'UNLOCK'];
|
const HOW_TO = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED', 'MONTH',
|
||||||
|
'STREAK', 'STARTER', 'UNLOCK'];
|
||||||
|
|
||||||
export default function PointsPage() {
|
export default function PointsPage() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('PointsPage', () => {
|
describe('PointsPage', () => {
|
||||||
|
it('lists all thirteen rules', async () => {
|
||||||
|
await renderPage();
|
||||||
|
|
||||||
|
const rules = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED',
|
||||||
|
'MONTH', 'STREAK', 'STARTER', 'UNLOCK'];
|
||||||
|
const shown = screen.getAllByRole('listitem').map((item) => item.textContent);
|
||||||
|
expect(shown.filter((text) => text.startsWith('POINTS.RULES.'))).toEqual(rules.map((rule) => `POINTS.RULES.${rule}`));
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the effect slots with a preview of each look', async () => {
|
it('shows the effect slots with a preview of each look', async () => {
|
||||||
await renderPage();
|
await renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import {toast} from 'sonner';
|
|||||||
|
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import QuestionCard from '../components/QuestionCard.jsx';
|
import QuestionCard from '../components/QuestionCard.jsx';
|
||||||
|
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||||
import {answerQuestion, fetchQuestions} from '../utils/questions.js';
|
import {answerQuestion, fetchQuestions} from '../utils/questions.js';
|
||||||
|
|
||||||
|
const STATUS_OF_QUESTION = {START: 'PLAYING', FINISHED: 'FINISHED', DROPPED: 'DROPPED'};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The open questions about the user's games: one card per game, answered one click at a time. Core decides which
|
* The open questions about the user's games: one card per game, answered one click at a time. Core decides which
|
||||||
* question is open; a yes changes the game like the game page does.
|
* question is open; a yes changes the game like the game page does.
|
||||||
*/
|
*/
|
||||||
export default function QuestionsPage() {
|
export default function QuestionsPage() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const check = usePointsCheck();
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [status, setStatus] = useState('loading');
|
const [status, setStatus] = useState('loading');
|
||||||
const [busyAppid, setBusyAppid] = useState(null);
|
const [busyAppid, setBusyAppid] = useState(null);
|
||||||
@@ -41,6 +45,8 @@ export default function QuestionsPage() {
|
|||||||
// The next question of the same game (Rate it? after Finished?) takes the place of the card.
|
// The next question of the same game (Rate it? after Finished?) takes the place of the card.
|
||||||
setItems((current) => current.flatMap((entry) => (entry.appid === item.appid ? [data.next].filter(Boolean) : [entry])));
|
setItems((current) => current.flatMap((entry) => (entry.appid === item.appid ? [data.next].filter(Boolean) : [entry])));
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
toast.success(t('BACKLOG.SAVED'));
|
||||||
|
const newStatus = choice.yes && STATUS_OF_QUESTION[item.question];
|
||||||
|
if (newStatus) check(item.appid, {status: newStatus, tell: true});
|
||||||
} else if (code === 404 || code === 409) {
|
} else if (code === 404 || code === 409) {
|
||||||
toast(t('QUESTIONS.GONE'));
|
toast(t('QUESTIONS.GONE'));
|
||||||
await load();
|
await load();
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error
|
|||||||
const card = (appid, question, name = `Game ${appid}`) => ({appid, name, imageUrl: null, question, status: 'PLAYING',
|
const card = (appid, question, name = `Game ${appid}`) => ({appid, name, imageUrl: null, question, status: 'PLAYING',
|
||||||
playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null});
|
playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null});
|
||||||
|
|
||||||
|
const booked = (appid) => ({balance: 30, missing: {}, paid: [{kind: 'FINISHED', appid, points: 30, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]});
|
||||||
|
const WALLET = {'GET /api/rewards/wallet': json(200, {balance: 30, catalog: [], owned: [], equipped: {}, history: []})};
|
||||||
|
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
|
||||||
|
|
||||||
const renderPage = (routes) => {
|
const renderPage = (routes) => {
|
||||||
const mock = fetchRoutes(routes);
|
const mock = fetchRoutes({...WALLET, ...routes});
|
||||||
render(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>);
|
render(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>);
|
||||||
return mock;
|
return mock;
|
||||||
};
|
};
|
||||||
@@ -45,6 +49,7 @@ describe('QuestionsPage', () => {
|
|||||||
const mock = renderPage({
|
const mock = renderPage({
|
||||||
'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}),
|
'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}),
|
||||||
'POST /api/library/questions/1': json(200, {next: null}),
|
'POST /api/library/questions/1': json(200, {next: null}),
|
||||||
|
'GET /api/rewards/games/1': json(200, booked(1)),
|
||||||
});
|
});
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
@@ -73,6 +78,7 @@ describe('QuestionsPage', () => {
|
|||||||
renderPage({
|
renderPage({
|
||||||
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
|
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
|
||||||
'POST /api/library/questions/1': json(200, {next: card(1, 'RATE')}),
|
'POST /api/library/questions/1': json(200, {next: card(1, 'RATE')}),
|
||||||
|
'GET /api/rewards/games/1': json(200, booked(1)),
|
||||||
});
|
});
|
||||||
await screen.findByText('QUESTIONS.FINISHED.ASK');
|
await screen.findByText('QUESTIONS.FINISHED.ASK');
|
||||||
|
|
||||||
@@ -82,6 +88,36 @@ describe('QuestionsPage', () => {
|
|||||||
expect(screen.queryByText('QUESTIONS.FINISHED.ASK')).toBeNull();
|
expect(screen.queryByText('QUESTIONS.FINISHED.ASK')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('showsThePointsToastAfterAYesToFinished', async () => {
|
||||||
|
renderPage({
|
||||||
|
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
|
||||||
|
'POST /api/library/questions/1': json(200, {next: null}),
|
||||||
|
'GET /api/rewards/games/1': json(200, booked(1)),
|
||||||
|
});
|
||||||
|
await screen.findByText('QUESTIONS.FINISHED.ASK');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:30'));
|
||||||
|
expect(pointsCalls()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asksForNoPointsAfterANoOrTheRatingAnswer', async () => {
|
||||||
|
renderPage({
|
||||||
|
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED'), card(2, 'RATE')]}),
|
||||||
|
'POST /api/library/questions/1': json(200, {next: null}),
|
||||||
|
'POST /api/library/questions/2': json(200, {next: null}),
|
||||||
|
});
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole('button', {name: 'QUESTIONS.NO'})[0]);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Game 1')).toBeNull());
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
|
||||||
|
await screen.findByText('QUESTIONS.EMPTY');
|
||||||
|
|
||||||
|
expect(pointsCalls()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('sendsTheRatingOfTheStars', async () => {
|
it('sendsTheRatingOfTheStars', async () => {
|
||||||
const mock = renderPage({
|
const mock = renderPage({
|
||||||
'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}),
|
'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}),
|
||||||
|
|||||||
@@ -120,6 +120,38 @@ export const cacheEquipped = (equipped) => storage.set(EQUIPPED_STORAGE_KEY, JSO
|
|||||||
/** GET /api/rewards/wallet on Core: `{ok, status, data}`. Data is the balance, catalog, owned codes, worn looks and history. */
|
/** 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`);
|
export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
|
||||||
|
|
||||||
|
/** Settles in Core, then answers the paid rows of the game and why a kind is missing: `{balance, paid, missing}`. */
|
||||||
|
export const loadGamePoints = (appid) => request(`${REWARDS_URL}/games/${appid}`);
|
||||||
|
|
||||||
|
const FINISH_KINDS = ['FINISHED', 'FINISHED_MANUAL'];
|
||||||
|
|
||||||
|
// The kind of points a status can earn; the other statuses (BACKLOG, DROPPED) earn none.
|
||||||
|
const KIND_OF_STATUS = {PLAYING: 'STARTED', FINISHED: 'FINISHED', COMPLETED: 'COMPLETED'};
|
||||||
|
const BLOCKED = ['HIDDEN', 'PLAYSTATION', 'PLAYSTATION_LINKED'];
|
||||||
|
/** The codes that only say the status never got there: no text, no line. */
|
||||||
|
export const STATUS_NOT_REACHED = ['NOT_STARTED', 'NOT_FINISHED', 'NOT_COMPLETED'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to tell the user after a status save, from the answer of `loadGamePoints`:
|
||||||
|
* `{type: 'booked', points}` (rows written by that call), `{type: 'paid', date}`, `{type: 'none', reason}` (a code
|
||||||
|
* of Core) or `{type: 'status'}` (this status earns no points).
|
||||||
|
*/
|
||||||
|
export const pointsNotice = (points, status) => {
|
||||||
|
const booked = points.paid.filter((row) => row.bookedNow).reduce((sum, row) => sum + row.points, 0);
|
||||||
|
if (booked > 0) return {type: 'booked', points: booked};
|
||||||
|
const kind = KIND_OF_STATUS[status];
|
||||||
|
if (!kind) return {type: 'status'};
|
||||||
|
const kinds = kind === 'FINISHED' ? FINISH_KINDS : [kind];
|
||||||
|
const row = points.paid.find((paid) => kinds.includes(paid.kind));
|
||||||
|
if (row) return {type: 'paid', date: row.paidAt};
|
||||||
|
const {missing} = points;
|
||||||
|
// A completion rests on a finish: its own reason comes after the reason of the finish.
|
||||||
|
const reasons = kind === 'COMPLETED' ? [missing.FINISHED, missing.COMPLETED] : [missing[kind]];
|
||||||
|
const reason = [...reasons, Object.values(missing).find((code) => BLOCKED.includes(code))]
|
||||||
|
.find((code) => code && !STATUS_NOT_REACHED.includes(code));
|
||||||
|
return reason ? {type: 'none', reason} : {type: 'status'};
|
||||||
|
};
|
||||||
|
|
||||||
/** POST /api/rewards/purchases; the caller handles the status (409: ALREADY_OWNED or NOT_ENOUGH_POINTS). */
|
/** POST /api/rewards/purchases; the caller handles the status (409: ALREADY_OWNED or NOT_ENOUGH_POINTS). */
|
||||||
export const buyItem = (code) =>
|
export const buyItem = (code) =>
|
||||||
apiFetch(`${REWARDS_URL}/purchases`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({item: code})});
|
apiFetch(`${REWARDS_URL}/purchases`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({item: code})});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
import {EFFECTS} from '../components/EffectBackground.jsx';
|
import {EFFECTS} from '../components/EffectBackground.jsx';
|
||||||
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js';
|
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS, pointsNotice} from './rewards.js';
|
||||||
|
|
||||||
describe('effects worn', () => {
|
describe('effects worn', () => {
|
||||||
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
|
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
|
||||||
@@ -47,3 +47,39 @@ describe('LOOKS backgrounds', () => {
|
|||||||
for (const look of Object.values(LOOKS)) if (look.effect) expect(EFFECTS).toContain(look.effect);
|
for (const look of Object.values(LOOKS)) if (look.effect) expect(EFFECTS).toContain(look.effect);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pointsNotice', () => {
|
||||||
|
const row = (kind, extra = {}) => ({kind, appid: 1, points: 30, paidAt: '2026-10-01T10:00:00Z', bookedNow: false, ...extra});
|
||||||
|
|
||||||
|
it('sums the rows booked by this call', () => {
|
||||||
|
const points = {paid: [row('STARTED', {points: 5, bookedNow: true}), row('FINISHED', {bookedNow: true})], missing: {}};
|
||||||
|
|
||||||
|
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'booked', points: 35});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the date of the row of the kind of the status', () => {
|
||||||
|
const points = {paid: [row('STARTED', {paidAt: '2026-09-01T10:00:00Z'}), row('FINISHED_MANUAL')], missing: {}};
|
||||||
|
|
||||||
|
expect(pointsNotice(points, 'PLAYING')).toEqual({type: 'paid', date: '2026-09-01T10:00:00Z'});
|
||||||
|
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'paid', date: '2026-10-01T10:00:00Z'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the reason of the missing kind', () => {
|
||||||
|
expect(pointsNotice({paid: [], missing: {FINISHED: 'NO_PROOF'}}, 'FINISHED')).toEqual({type: 'none', reason: 'NO_PROOF'});
|
||||||
|
expect(pointsNotice({paid: [], missing: {FINISHED: 'HIDDEN'}}, 'PLAYING')).toEqual({type: 'none', reason: 'HIDDEN'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains a COMPLETED game with an unpaid finish by the finish', () => {
|
||||||
|
const points = {paid: [], missing: {FINISHED: 'NO_PROOF', COMPLETED: 'NEEDS_PROVEN_FINISH'}};
|
||||||
|
|
||||||
|
expect(pointsNotice(points, 'COMPLETED')).toEqual({type: 'none', reason: 'NO_PROOF'});
|
||||||
|
expect(pointsNotice({paid: [row('FINISHED')], missing: {COMPLETED: 'NOT_ALL_ACHIEVEMENTS'}}, 'COMPLETED'))
|
||||||
|
.toEqual({type: 'none', reason: 'NOT_ALL_ACHIEVEMENTS'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the status earns no points for BACKLOG, DROPPED and a NOT_ reason', () => {
|
||||||
|
expect(pointsNotice({paid: [], missing: {}}, 'BACKLOG')).toEqual({type: 'status'});
|
||||||
|
expect(pointsNotice({paid: [row('FINISHED')], missing: {}}, 'DROPPED')).toEqual({type: 'status'});
|
||||||
|
expect(pointsNotice({paid: [], missing: {STARTED: 'NOT_STARTED'}}, 'PLAYING')).toEqual({type: 'status'});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user