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.
This commit is contained in:
lobo committed 2026-10-01 10:44:22 +02:00
1 parent b811255c16
commit b129f6a6e4
11 files changed
+348 -2

No files matched your search

+4
View File
@@ -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" - **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` 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`. (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, - **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. 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 "Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
+24
View File
@@ -51,6 +51,7 @@
"ROLE": "Rolle", "ROLE": "Rolle",
"CREATED_DATE": "Mitglied seit", "CREATED_DATE": "Mitglied seit",
"LAST_LOGIN": "Letzter Login", "LAST_LOGIN": "Letzter Login",
"OPEN_WELCOME": "Willkommensseite erneut anzeigen",
"DARK_THEME": "Dunkles Design", "DARK_THEME": "Dunkles Design",
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache",
"LANGUAGE_OPTIONS": { "LANGUAGE_OPTIONS": {
@@ -635,6 +636,29 @@
"LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.", "LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.",
"EMPTY": "Noch nichts Neues." "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": { "ADMIN_NEWS": {
"NEW": "Neuer Eintrag", "NEW": "Neuer Eintrag",
"EDIT": "Eintrag bearbeiten", "EDIT": "Eintrag bearbeiten",
+24
View File
@@ -51,6 +51,7 @@
"ROLE": "Role", "ROLE": "Role",
"CREATED_DATE": "Member since", "CREATED_DATE": "Member since",
"LAST_LOGIN": "Last login", "LAST_LOGIN": "Last login",
"OPEN_WELCOME": "Show the welcome page again",
"DARK_THEME": "Dark theme", "DARK_THEME": "Dark theme",
"LANGUAGE": "Language", "LANGUAGE": "Language",
"LANGUAGE_OPTIONS": { "LANGUAGE_OPTIONS": {
@@ -635,6 +636,29 @@
"LOAD_ERROR": "The news could not be loaded.", "LOAD_ERROR": "The news could not be loaded.",
"EMPTY": "Nothing new yet." "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": { "ADMIN_NEWS": {
"NEW": "New entry", "NEW": "New entry",
"EDIT": "Edit entry", "EDIT": "Edit entry",
+4 -1
View File
@@ -11,6 +11,8 @@ import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx'; import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx'; import AdminNewsPage from './pages/AdminNewsPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.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 AdminUsersPage from './pages/AdminUsersPage.jsx';
import GameLibrary from './pages/GameLibrary.jsx'; import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx'; import GameDetail from './pages/GameDetail.jsx';
@@ -65,7 +67,7 @@ function App() {
<Route path="/login" element={<LoginPage/>}/> <Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/> <Route path="/register" element={<RegistrationPage/>}/>
<Route element={<RequireSession/>}> <Route element={<RequireSession/>}>
<Route path="/dashboard" element={<Dashboard/>}/> <Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
<Route path="/library" element={<GameLibrary/>}/> <Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/> <Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/> <Route path="/wishlist" element={<WishlistPage/>}/>
@@ -74,6 +76,7 @@ function App() {
<Route path="/import" element={<ImportPage/>}/> <Route path="/import" element={<ImportPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/> <Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/> <Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/> <Route path="/admin/hints" element={<AdminHintsPage/>}/>
<Route path="/admin/news" element={<AdminNewsPage/>}/> <Route path="/admin/news" element={<AdminNewsPage/>}/>
+6 -1
View File
@@ -1,6 +1,8 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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 {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {formatDateTime} from '../utils/format.js'; import {formatDateTime} from '../utils/format.js';
@@ -38,6 +40,9 @@ export default function AccountInfoCard() {
</div> </div>
))} ))}
</dl> </dl>
<Button asChild variant="link" className="mt-3 h-auto p-0">
<Link to="/welcome"><Rocket/>{t('SETTINGS.OPEN_WELCOME')}</Link>
</Button>
</CardContent> </CardContent>
</Card> </Card>
); );
+11
View File
@@ -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 ? <Navigate to="/welcome" replace/> : children;
}
+46
View File
@@ -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(<WelcomeGate><p>dashboard</p></WelcomeGate>, {
path: '/dashboard', entries: ['/dashboard'], routes: {'/welcome': <p>welcome</p>},
});
};
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'));
});
});
+6
View File
@@ -88,6 +88,12 @@ describe('SettingsPage public profile', () => {
await act(async () => auth.answer()); 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 () => { it('turns the public profile off and shows the link while it is on', async () => {
renderPage(profile()); renderPage(profile());
+94
View File
@@ -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 (
<PageContainer className="space-y-6 px-4 py-6 md:px-8">
<div className="space-y-2">
<h1 className="text-2xl font-bold">{t('WELCOME.TITLE')}</h1>
<p className="text-muted-foreground">{t('WELCOME.INTRO')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{FEATURES.map(({key, to, icon: Icon}) => (
<Card key={key} className="p-4">
<Icon className="size-5 text-primary"/>
<h2 className="font-semibold">{t(`WELCOME.FEATURE_${key}`)}</h2>
<p className="text-sm text-muted-foreground">{t(`WELCOME.FEATURE_${key}_TEXT`)}</p>
{to && (
<Button asChild variant="link" className="h-auto justify-start p-0">
<Link to={to}>{t('WELCOME.OPEN')}</Link>
</Button>
)}
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>{t('WELCOME.NEXT_STEPS')}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{steps.map(({key, to, done}) => (
<li key={key} className="flex items-center gap-2">
{done
? <CircleCheck className="size-5 text-primary"/>
: <Circle className="size-5 text-muted-foreground"/>}
<Link to={to} className="hover:underline">{t(`WELCOME.STEP_${key}`)}</Link>
{done && <span className="sr-only">{t('WELCOME.DONE')}</span>}
</li>
))}
</ul>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button onClick={finish}>{t('WELCOME.GO')}</Button>
<Button variant="outline" onClick={finish}>{t('WELCOME.SKIP')}</Button>
</div>
</PageContainer>
);
}
+122
View File
@@ -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(<WelcomePage/>, {
path: '/welcome', entries: ['/welcome'], routes: {'/dashboard': <p>dashboard</p>},
});
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 <p>dashboard</p>;
}
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(<WelcomePage/>, {
path: '/welcome', entries: ['/welcome'],
routes: {'/dashboard': <WelcomeGate><DashboardStub/></WelcomeGate>},
});
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);
});
});
+7
View File
@@ -51,3 +51,10 @@ export const saveSettings = (changes) => {
queue = result.catch(() => {}); queue = result.catch(() => {});
return result; 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),
});