From b129f6a6e49499a5d3eff6fd313c10bd24b9798d Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 10:44:22 +0200 Subject: [PATCH] feat(welcome): add welcome page for new users after first login Shows six feature cards and two next steps (Steam, import). The dashboard redirects to it while Auth settings.welcomeSeenAt is null; "Let's go" and "Skip" set the stamp. Settings links to it to reopen later. --- README.md | 4 + public/i18n/de.json | 24 ++++++ public/i18n/en.json | 24 ++++++ src/App.jsx | 5 +- src/components/AccountInfoCard.jsx | 7 +- src/components/WelcomeGate.jsx | 11 +++ src/components/WelcomeGate.test.jsx | 46 +++++++++++ src/pages/SettingsPage.test.jsx | 6 ++ src/pages/WelcomePage.jsx | 94 +++++++++++++++++++++ src/pages/WelcomePage.test.jsx | 122 ++++++++++++++++++++++++++++ src/utils/settings.js | 7 ++ 11 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 src/components/WelcomeGate.jsx create mode 100644 src/components/WelcomeGate.test.jsx create mode 100644 src/pages/WelcomePage.jsx create mode 100644 src/pages/WelcomePage.test.jsx diff --git a/README.md b/README.md index 51d758a..b6a44a2 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ To Be Played is split into three repositories: - **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it" marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new` (menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`. +- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the + dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from + `/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page + links to it again. - **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own. "Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for diff --git a/public/i18n/de.json b/public/i18n/de.json index 2eb1213..e763ced 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -51,6 +51,7 @@ "ROLE": "Rolle", "CREATED_DATE": "Mitglied seit", "LAST_LOGIN": "Letzter Login", + "OPEN_WELCOME": "Willkommensseite erneut anzeigen", "DARK_THEME": "Dunkles Design", "LANGUAGE": "Sprache", "LANGUAGE_OPTIONS": { @@ -635,6 +636,29 @@ "LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.", "EMPTY": "Noch nichts Neues." }, + "WELCOME": { + "TITLE": "Willkommen bei ToBePlayed", + "INTRO": "ToBePlayed sammelt deine Spiele an einem Ort: was du besitzt, was du spielst, was als Nächstes kommt.", + "FEATURE_LIBRARY": "Bibliothek und Pile of Shame", + "FEATURE_LIBRARY_TEXT": "Alle deine Spiele mit Spielzeit, Filtern und Suche. Der Pile zeigt, was du nie angefangen hast.", + "FEATURE_STATUS": "Status", + "FEATURE_STATUS_TEXT": "Markiere ein Spiel als spielend, durch, komplett oder abgebrochen, mit Bewertung und Notizen.", + "FEATURE_STATS": "Deine Statistiken", + "FEATURE_STATS_TEXT": "Summen, der Pile in Stunden, meistgespielte Spiele, Genres und durchgespielte Spiele pro Jahr.", + "FEATURE_PROFILE": "Öffentliches Profil", + "FEATURE_PROFILE_TEXT": "Teile deine Statistiken und was du spielst per Link. Aus, bis du es einschaltest.", + "FEATURE_IMPORT": "Import", + "FEATURE_IMPORT_TEXT": "Hole Spiele anderer Plattformen herein: Liste, CSV, Exporte oder ein Bookmarklet.", + "FEATURE_DASHBOARD": "Dein Dashboard", + "FEATURE_DASHBOARD_TEXT": "Ordne, verstecke und vergrößere die Kacheln. Es folgt dir aufs Handy.", + "OPEN": "Öffnen", + "NEXT_STEPS": "Nächste Schritte", + "STEP_STEAM": "Steam-Konto verknüpfen", + "STEP_IMPORT": "Deine Spiele importieren", + "DONE": "Erledigt", + "GO": "Los geht's", + "SKIP": "Überspringen" + }, "ADMIN_NEWS": { "NEW": "Neuer Eintrag", "EDIT": "Eintrag bearbeiten", diff --git a/public/i18n/en.json b/public/i18n/en.json index 2464089..09f4d13 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -51,6 +51,7 @@ "ROLE": "Role", "CREATED_DATE": "Member since", "LAST_LOGIN": "Last login", + "OPEN_WELCOME": "Show the welcome page again", "DARK_THEME": "Dark theme", "LANGUAGE": "Language", "LANGUAGE_OPTIONS": { @@ -635,6 +636,29 @@ "LOAD_ERROR": "The news could not be loaded.", "EMPTY": "Nothing new yet." }, + "WELCOME": { + "TITLE": "Welcome to ToBePlayed", + "INTRO": "ToBePlayed keeps your games in one place: what you own, what you play, what is next.", + "FEATURE_LIBRARY": "Library and Pile of Shame", + "FEATURE_LIBRARY_TEXT": "All your games with playtime, filters and search. The pile shows what you never started.", + "FEATURE_STATUS": "Statuses", + "FEATURE_STATUS_TEXT": "Mark a game as playing, finished, completed or dropped, with rating and notes.", + "FEATURE_STATS": "Your stats", + "FEATURE_STATS_TEXT": "Totals, the pile in hours, most played games, genres and finished games per year.", + "FEATURE_PROFILE": "Public profile", + "FEATURE_PROFILE_TEXT": "Share your stats and what you play with a link. Off until you switch it on.", + "FEATURE_IMPORT": "Import", + "FEATURE_IMPORT_TEXT": "Bring in games from other platforms: a list, a CSV, exports or a bookmarklet.", + "FEATURE_DASHBOARD": "Your dashboard", + "FEATURE_DASHBOARD_TEXT": "Order, hide and resize the tiles. It follows you to the phone.", + "OPEN": "Open", + "NEXT_STEPS": "Next steps", + "STEP_STEAM": "Link your Steam account", + "STEP_IMPORT": "Import your games", + "DONE": "Done", + "GO": "Let's go", + "SKIP": "Skip" + }, "ADMIN_NEWS": { "NEW": "New entry", "EDIT": "Edit entry", diff --git a/src/App.jsx b/src/App.jsx index 3b1c5c7..57e632b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,8 @@ import SettingsPage from './pages/SettingsPage.jsx'; import AdminHintsPage from './pages/AdminHintsPage.jsx'; import AdminNewsPage from './pages/AdminNewsPage.jsx'; import WhatsNewPage from './pages/WhatsNewPage.jsx'; +import WelcomePage from './pages/WelcomePage.jsx'; +import WelcomeGate from './components/WelcomeGate.jsx'; import AdminUsersPage from './pages/AdminUsersPage.jsx'; import GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; @@ -65,7 +67,7 @@ function App() { }/> }/> }> - }/> + }/> }/> }/> }/> @@ -74,6 +76,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/components/AccountInfoCard.jsx b/src/components/AccountInfoCard.jsx index ac70f43..5ffe207 100644 --- a/src/components/AccountInfoCard.jsx +++ b/src/components/AccountInfoCard.jsx @@ -1,6 +1,8 @@ +import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {UserRound} from 'lucide-react'; +import {Rocket, UserRound} from 'lucide-react'; +import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {formatDateTime} from '../utils/format.js'; @@ -38,6 +40,9 @@ export default function AccountInfoCard() { ))} + ); diff --git a/src/components/WelcomeGate.jsx b/src/components/WelcomeGate.jsx new file mode 100644 index 0000000..74ba9c5 --- /dev/null +++ b/src/components/WelcomeGate.jsx @@ -0,0 +1,11 @@ +import {Navigate} from 'react-router-dom'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; + +/** + * Opens the welcome page for a user who has not seen it. Only null counts: undefined is "still loading". + * Relies on Auth sending `welcomeSeenAt: null` explicitly (no NON_NULL Jackson config). + */ +export default function WelcomeGate({children}) { + const {user} = useCurrentUserContext(); + return user?.settings?.welcomeSeenAt === null ? : children; +} diff --git a/src/components/WelcomeGate.test.jsx b/src/components/WelcomeGate.test.jsx new file mode 100644 index 0000000..3ed414d --- /dev/null +++ b/src/components/WelcomeGate.test.jsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, screen, waitFor} from '@testing-library/react'; +import WelcomeGate from './WelcomeGate.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; +import {renderWithProviders} from '../test/renderWithProviders.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +const me = (settings) => ({id: 1, email: 'a@b.de', role: 'USER', settings}); + +const renderGate = (settings) => { + fetchRoutes({'GET /api/users/me': json(200, me(settings))}); + return renderWithProviders(

dashboard

, { + path: '/dashboard', entries: ['/dashboard'], routes: {'/welcome':

welcome

}, + }); +}; + +describe('WelcomeGate', () => { + beforeEach(() => localStorage.setItem('tbp_session', '1')); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it('opens the welcome page when the user has not seen it', async () => { + const {router} = renderGate({welcomeSeenAt: null}); + + await screen.findByText('welcome'); + expect(router.state.location.pathname).toBe('/welcome'); + }); + + it('shows the children once the welcome page was seen', async () => { + renderGate({welcomeSeenAt: '2026-10-01T10:00:00Z'}); + + expect(await screen.findByText('dashboard')).toBeTruthy(); + }); + + it('shows the children when /me has no such field', async () => { + const {router} = renderGate({}); + + expect(await screen.findByText('dashboard')).toBeTruthy(); + await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard')); + }); +}); diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index ff0b10f..e72dd4e 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -88,6 +88,12 @@ describe('SettingsPage public profile', () => { await act(async () => auth.answer()); }); + it('links to the welcome page', async () => { + renderPage(profile()); + + expect((await screen.findByRole('link', {name: 'SETTINGS.OPEN_WELCOME'})).getAttribute('href')).toBe('/welcome'); + }); + it('turns the public profile off and shows the link while it is on', async () => { renderPage(profile()); diff --git a/src/pages/WelcomePage.jsx b/src/pages/WelcomePage.jsx new file mode 100644 index 0000000..a7f1632 --- /dev/null +++ b/src/pages/WelcomePage.jsx @@ -0,0 +1,94 @@ +import {useEffect, useState} from 'react'; +import {Link, useNavigate} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {ChartBarBig, Circle, CircleCheck, LayoutGrid, Library, ListChecks, Upload, UserRound} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import PageContainer from '../components/PageContainer.jsx'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {request} from '../utils/api.js'; +import {markWelcomeSeen} from '../utils/settings.js'; + +// The dashboard card has no link: /dashboard passes the gate, "Let's go" opens it. +const FEATURES = [ + {key: 'LIBRARY', to: '/library', icon: Library}, + {key: 'STATUS', to: '/library', icon: ListChecks}, + {key: 'STATS', to: '/stats', icon: ChartBarBig}, + {key: 'PROFILE', to: '/settings', icon: UserRound}, + {key: 'IMPORT', to: '/import', icon: Upload}, + {key: 'DASHBOARD', to: null, icon: LayoutGrid}, +]; + +/** The page a new user sees once after the first login. */ +export default function WelcomePage() { + const {t} = useTranslation(); + const navigate = useNavigate(); + const {user, updateUser} = useCurrentUserContext(); + const [summary, setSummary] = useState(null); + + useEffect(() => { + (async () => { + const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary`); + if (ok && data != null) setSummary(data); + })(); + }, []); + + const steps = [ + {key: 'STEAM', to: '/settings', done: !!user?.steamId}, + {key: 'IMPORT', to: '/import', done: summary?.total > 0}, + ]; + + // Wait for the PUT: the dashboard reloads /me and would get the old null stamp. A failed PUT is ignored: the + // page may come back at the next login. + const finish = async () => { + updateUser((u) => ({...u, settings: {...u.settings, welcomeSeenAt: new Date().toISOString()}})); + await markWelcomeSeen().catch(() => undefined); + navigate('/dashboard'); + }; + + return ( + +
+

{t('WELCOME.TITLE')}

+

{t('WELCOME.INTRO')}

+
+
+ {FEATURES.map(({key, to, icon: Icon}) => ( + + +

{t(`WELCOME.FEATURE_${key}`)}

+

{t(`WELCOME.FEATURE_${key}_TEXT`)}

+ {to && ( + + )} +
+ ))} +
+ + + {t('WELCOME.NEXT_STEPS')} + + +
    + {steps.map(({key, to, done}) => ( +
  • + {done + ? + : } + {t(`WELCOME.STEP_${key}`)} + {done && {t('WELCOME.DONE')}} +
  • + ))} +
+
+
+
+ + +
+
+ ); +} diff --git a/src/pages/WelcomePage.test.jsx b/src/pages/WelcomePage.test.jsx new file mode 100644 index 0000000..aaaf633 --- /dev/null +++ b/src/pages/WelcomePage.test.jsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; +import {useEffect} from 'react'; +import {toast} from 'sonner'; +import WelcomePage from './WelcomePage.jsx'; +import WelcomeGate from '../components/WelcomeGate.jsx'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; +import {renderWithProviders} from '../test/renderWithProviders.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {error: vi.fn()}})); + +const me = (overrides = {}) => ({id: 1, email: 'a@b.de', role: 'USER', steamId: null, + settings: {welcomeSeenAt: null}, ...overrides}); +const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT'); + +const renderPage = async ({user = me(), summary = json(200, {total: 0}), seen = json(204)} = {}) => { + fetchRoutes({ + 'GET /api/users/me': json(200, user), + 'GET /api/library/summary': summary, + 'PUT /api/users/settings/welcome-seen': seen, + }); + const result = renderWithProviders(, { + path: '/welcome', entries: ['/welcome'], routes: {'/dashboard':

dashboard

}, + }); + await act(async () => undefined); + return result; +}; + +describe('WelcomePage', () => { + beforeEach(() => localStorage.setItem('tbp_session', '1')); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('shows six feature cards, five of them with a link to their page', async () => { + await renderPage(); + + expect(screen.getAllByRole('heading', {level: 2})).toHaveLength(6); + expect(screen.getAllByRole('link', {name: 'WELCOME.OPEN'}).map((link) => link.getAttribute('href'))) + .toEqual(['/library', '/library', '/stats', '/settings', '/import']); + }); + + it('checks the steps that are done', async () => { + await renderPage({user: me({steamId: '7656'}), summary: json(200, {total: 1})}); + + expect(await screen.findAllByText('WELCOME.DONE')).toHaveLength(2); + }); + + it('checks nothing for a new user without games', async () => { + await renderPage(); + + expect(screen.queryByText('WELCOME.DONE')).toBeNull(); + }); + + it('renders without checks when the summary fails', async () => { + await renderPage({summary: json(500, {})}); + + expect(screen.getByText('WELCOME.TITLE')).toBeTruthy(); + expect(screen.queryByText('WELCOME.DONE')).toBeNull(); + }); + + it.each(['WELCOME.GO', 'WELCOME.SKIP'])('"%s" stamps once and opens the dashboard', async (label) => { + const {router} = await renderPage(); + + fireEvent.click(screen.getByText(label)); + + await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard')); + expect(putCalls().map(([url]) => url)).toEqual([expect.stringContaining('/api/users/settings/welcome-seen')]); + }); + + it('still opens the dashboard, silently, when the stamp fails', async () => { + const {router} = await renderPage({seen: json(500, {})}); + + fireEvent.click(screen.getByText('WELCOME.GO')); + + await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard')); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('staysOnTheDashboardWhenItReloadsTheUserBeforeTheStampLands', async () => { + let stamped = false; + function DashboardStub() { + const {reload} = useCurrentUserContext(); + useEffect(() => { reload(); }, [reload]); + return

dashboard

; + } + fetchRoutes({ + 'GET /api/users/me': () => json(200, me({settings: {welcomeSeenAt: stamped ? '2026-10-01T10:00:00Z' : null}})), + 'GET /api/library/summary': json(200, {total: 0}), + 'PUT /api/users/settings/welcome-seen': () => new Promise((resolve) => setTimeout(() => { + stamped = true; + resolve(json(204)); + }, 20)), + }); + const {router} = renderWithProviders(, { + path: '/welcome', entries: ['/welcome'], + routes: {'/dashboard': }, + }); + await screen.findByText('WELCOME.TITLE'); + + fireEvent.click(screen.getByText('WELCOME.GO')); + + await waitFor(() => expect(stamped).toBe(true)); + await act(async () => new Promise((resolve) => setTimeout(resolve, 30))); + expect(router.state.location.pathname).toBe('/dashboard'); + }); + + it('sendsTheStampWithATimeout', async () => { + await renderPage({seen: () => new Promise(() => undefined)}); + + fireEvent.click(screen.getByText('WELCOME.GO')); + + await waitFor(() => expect(putCalls()).toHaveLength(1)); + expect(putCalls()[0][1].signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/src/utils/settings.js b/src/utils/settings.js index 3466b0a..1a16541 100644 --- a/src/utils/settings.js +++ b/src/utils/settings.js @@ -51,3 +51,10 @@ export const saveSettings = (changes) => { queue = result.catch(() => {}); return result; }; + +/** PUT on Auth, 204: the welcome page was seen. The caller handles the response status. */ +export const markWelcomeSeen = () => + apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/welcome-seen`, { + method: 'PUT', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + });