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
@@ -7,6 +7,7 @@ import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
import WalletLoader from '../test/WalletLoader.jsx';
|
||||
|
||||
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()}}));
|
||||
|
||||
const game = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import BacklogCard from '../components/BacklogCard.jsx';
|
||||
import Confetti from '../components/Confetti.jsx';
|
||||
import GameAchievements from '../components/GameAchievements.jsx';
|
||||
import GameHeroCard from '../components/GameHeroCard.jsx';
|
||||
import GamePointsLine from '../components/GamePointsLine.jsx';
|
||||
import ManualGameCard from '../components/ManualGameCard.jsx';
|
||||
import CompletedHint from '../components/CompletedHint.jsx';
|
||||
import FinishedHint from '../components/FinishedHint.jsx';
|
||||
@@ -95,6 +96,8 @@ export default function GameDetail() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<GamePointsLine key={`points-${data.appid}`} game={data}/>
|
||||
|
||||
{/* The finished question also answers "are you playing it?". */}
|
||||
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
||||
<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';
|
||||
|
||||
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.
|
||||
vi.mock('../components/GameCorrectionDialog.jsx', () => ({
|
||||
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {resetBrokenImageReports} from '../utils/brokenImages.js';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
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()}}));
|
||||
|
||||
const game = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import FitText from '../components/FitText.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
@@ -281,6 +282,7 @@ function PickCard({game}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
const check = usePointsCheck();
|
||||
const reason = reasonText(game.reason, t, i18n.language);
|
||||
|
||||
const startPlaying = async () => {
|
||||
@@ -294,6 +296,7 @@ function PickCard({game}) {
|
||||
}
|
||||
setStarted(true);
|
||||
toast.success(t('BACKLOG.SAVED'));
|
||||
check(game.appid, {status: 'PLAYING', tell: true});
|
||||
} catch (err) {
|
||||
console.error('Start playing error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
|
||||
@@ -8,7 +8,7 @@ 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: {success: vi.fn(), error: vi.fn()}}));
|
||||
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
|
||||
|
||||
const game = (appid, extra = {}) => ({
|
||||
appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null,
|
||||
@@ -33,6 +33,9 @@ function mockBackend(answer, routes = {}) {
|
||||
fetchRoutes({
|
||||
'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)),
|
||||
'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,
|
||||
});
|
||||
}
|
||||
@@ -323,6 +326,17 @@ describe('PlayNextPage', () => {
|
||||
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 () => {
|
||||
mockBackend(() => [row('NEXT_UP', game(7))], {'PUT /api/library/games/7/status': json(500, {})});
|
||||
renderPage();
|
||||
|
||||
@@ -19,7 +19,8 @@ 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', 'UNLOCK'];
|
||||
const HOW_TO = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED', 'MONTH',
|
||||
'STREAK', 'STARTER', 'UNLOCK'];
|
||||
|
||||
export default function PointsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
|
||||
@@ -61,6 +61,15 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await renderPage();
|
||||
|
||||
|
||||
@@ -5,14 +5,18 @@ import {toast} from 'sonner';
|
||||
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import QuestionCard from '../components/QuestionCard.jsx';
|
||||
import usePointsCheck from '../hooks/usePointsCheck.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
|
||||
* question is open; a yes changes the game like the game page does.
|
||||
*/
|
||||
export default function QuestionsPage() {
|
||||
const {t} = useTranslation();
|
||||
const check = usePointsCheck();
|
||||
const [items, setItems] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
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.
|
||||
setItems((current) => current.flatMap((entry) => (entry.appid === item.appid ? [data.next].filter(Boolean) : [entry])));
|
||||
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) {
|
||||
toast(t('QUESTIONS.GONE'));
|
||||
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',
|
||||
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 mock = fetchRoutes(routes);
|
||||
const mock = fetchRoutes({...WALLET, ...routes});
|
||||
render(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>);
|
||||
return mock;
|
||||
};
|
||||
@@ -45,6 +49,7 @@ describe('QuestionsPage', () => {
|
||||
const mock = renderPage({
|
||||
'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}),
|
||||
'POST /api/library/questions/1': json(200, {next: null}),
|
||||
'GET /api/rewards/games/1': json(200, booked(1)),
|
||||
});
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
@@ -73,6 +78,7 @@ describe('QuestionsPage', () => {
|
||||
renderPage({
|
||||
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
|
||||
'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');
|
||||
|
||||
@@ -82,6 +88,36 @@ describe('QuestionsPage', () => {
|
||||
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 () => {
|
||||
const mock = renderPage({
|
||||
'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}),
|
||||
|
||||
Reference in new issue
Block a user