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

+6
View File
@@ -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());
+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);
});
});