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 -1
View File
@@ -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>
);
+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'));
});
});