Public Access
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:
1 parent
b811255c16
commit
b129f6a6e4
11 files changed
+348
-2
No files matched your search
+4
-1
@@ -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() {
|
||||
<Route path="/login" element={<LoginPage/>}/>
|
||||
<Route path="/register" element={<RegistrationPage/>}/>
|
||||
<Route element={<RequireSession/>}>
|
||||
<Route path="/dashboard" element={<Dashboard/>}/>
|
||||
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
|
||||
<Route path="/library" element={<GameLibrary/>}/>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
@@ -74,6 +76,7 @@ function App() {
|
||||
<Route path="/import" element={<ImportPage/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
||||
<Route path="/welcome" element={<WelcomePage/>}/>
|
||||
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
||||
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
|
||||
<Route path="/admin/news" element={<AdminNewsPage/>}/>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Button asChild variant="link" className="mt-3 h-auto p-0">
|
||||
<Link to="/welcome"><Rocket/>{t('SETTINGS.OPEN_WELCOME')}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
Reference in new issue
Block a user