feat: add account navigation and footer

Move Inbox into a top-right account area, add a profile page for the authenticated user, and introduce a configurable sitemap and useful-links footer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-20 07:21:10 +02:00
parent 0d8a25e73f
commit 83d14d23a2
9 changed files with 388 additions and 36 deletions

View file

@ -26,6 +26,7 @@ npm test -- src/app/App.test.tsx -t "filters jobs by title"
- `src/app` contains the application shell, providers, routes, and route-level tests. - `src/app` contains the application shell, providers, routes, and route-level tests.
- `src/features` groups pages and components by business capability. - `src/features` groups pages and components by business capability.
- `src/components` contains UI shared by multiple features. - `src/components` contains UI shared by multiple features.
- `src/config/navigation.ts` defines configurable footer sitemap and useful-link groups.
- `src/api/client.ts` is the typed HTTP boundary. - `src/api/client.ts` is the typed HTTP boundary.
- `src/types` contains API and domain types. - `src/types` contains API and domain types.
- `src/styles/global.css` contains shared tokens and layout styles. - `src/styles/global.css` contains shared tokens and layout styles.
@ -97,13 +98,17 @@ form for the date, location, co-interviewers, and candidate-visible shared docum
The authenticated workspace also includes: The authenticated workspace also includes:
- **Users** for listing and creating co-interviewers. - **Users** for listing and creating co-interviewers.
- **Inbox** for accepting or declining interview assignments. - **Inbox** in the top-right account navigation for accepting or declining assignments.
- **Profile** in the top-right account navigation for the logged-in user's identity and role.
- **Calendar** for interviews grouped by date. - **Calendar** for interviews grouped by date.
- **Interviewer sessions** for changing status, editing prominent shared documentation, and - **Interviewer sessions** for changing status, editing prominent shared documentation, and
posting private notes while an interview is started. Notes and shared-document changes arrive posting private notes while an interview is started. Notes and shared-document changes arrive
live through an authenticated streaming `fetch`; the app intentionally does not use live through an authenticated streaming `fetch`; the app intentionally does not use
`EventSource`, because the request needs the JWT authorization header. `EventSource`, because the request needs the JWT authorization header.
The authenticated layout ends with a sitemap and useful-links footer. Update
`src/config/navigation.ts` to add, remove, or reorder footer destinations.
Participants open the generated `/participant/:invitationId` URL without signing in. The opaque Participants open the generated `/participant/:invitationId` URL without signing in. The opaque
invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown
in a pull-request-style code panel with a review comment field. Answers are saved when navigating in a pull-request-style code panel with a review comment field. Answers are saved when navigating

View file

@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { BrowserRouter, MemoryRouter } from 'react-router-dom' import { BrowserRouter, MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -220,6 +220,37 @@ describe('Go Guess frontend', () => {
expect(screen.queryByRole('link', { name: 'Senior Go Engineer' })).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'Senior Go Engineer' })).not.toBeInTheDocument()
}) })
it('moves account links to the top and renders the logged-in user profile and footer', async () => {
authenticate()
renderApp('/profile')
expect(await screen.findByRole('heading', { level: 1, name: 'Admin' })).toBeInTheDocument()
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
const accountNavigation = screen.getByRole('navigation', { name: 'Account navigation' })
expect(within(accountNavigation).getByRole('link', { name: 'Inbox' })).toHaveAttribute(
'href',
'/inbox',
)
expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute(
'href',
'/profile',
)
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
expect(within(primaryNavigation).queryByRole('link', { name: 'Inbox' })).not.toBeInTheDocument()
const footerNavigation = screen.getByRole('navigation', { name: 'Footer navigation' })
expect(within(footerNavigation).getByRole('link', { name: 'Jobs' })).toHaveAttribute(
'href',
'/jobs',
)
expect(within(footerNavigation).getByRole('link', { name: 'Your profile' })).toHaveAttribute(
'href',
'/profile',
)
})
it('adds multiple choice options with the accessible plus button', async () => { it('adds multiple choice options with the accessible plus button', async () => {
authenticate() authenticate()
const user = userEvent.setup() const user = userEvent.setup()

View file

@ -16,7 +16,6 @@ import {
Plus, Plus,
Search, Search,
Send, Send,
Inbox,
Sparkles, Sparkles,
Trash2, Trash2,
Upload, Upload,
@ -52,6 +51,9 @@ import {
ScheduledInterviewPage, ScheduledInterviewPage,
} from '../features/interviews' } from '../features/interviews'
import { UsersPage } from '../features/users' import { UsersPage } from '../features/users'
import { ProfilePage } from '../features/users'
import { AppFooter } from '../components/layout/AppFooter'
import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities'
import type { import type {
CandidateMatch, CandidateMatch,
CreateJobInput, CreateJobInput,
@ -292,12 +294,6 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
icon={<CircleHelp size={20} />} icon={<CircleHelp size={20} />}
close={() => setOpen(false)} close={() => setOpen(false)}
/> />
<NavItem
to="/inbox"
label="Inbox"
icon={<Inbox size={20} />}
close={() => setOpen(false)}
/>
<NavItem <NavItem
to="/calendar" to="/calendar"
label="Calendar" label="Calendar"
@ -312,35 +308,34 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
/> />
</nav> </nav>
<div className="sidebar-bottom"> <div className="sidebar-bottom">
<div className="user-chip"> <button className="sidebar-signout" onClick={onLogout}>
<span className="avatar tiny">{user?.email.slice(0, 2).toUpperCase() ?? 'U'}</span> <LogOut size={18} />
<span> Sign out
<strong>{user?.email ?? 'Signed in'}</strong>
<small>{user?.role ?? 'User'}</small>
</span>
</div>
<button className="icon-button" onClick={onLogout} aria-label="Sign out">
<LogOut size={19} />
</button> </button>
</div> </div>
</aside> </aside>
<main className="main-content"> <main className="main-content">
<Routes> <WorkspaceUtilities user={user} />
<Route index element={<Navigate to="/jobs" replace />} /> <div className="workspace-content">
<Route path="/jobs" element={<JobsPage />} /> <Routes>
<Route path="/jobs/new" element={<CreateJobPage />} /> <Route index element={<Navigate to="/jobs" replace />} />
<Route path="/jobs/:jobId" element={<JobDetailPage />} /> <Route path="/jobs" element={<JobsPage />} />
<Route path="/jobs/:jobId/questions/new" element={<QuestionPage />} /> <Route path="/jobs/new" element={<CreateJobPage />} />
<Route path="/questions" element={<QuestionLibraryPage />} /> <Route path="/jobs/:jobId" element={<JobDetailPage />} />
<Route path="/participants" element={<ParticipantsPage />} /> <Route path="/jobs/:jobId/questions/new" element={<QuestionPage />} />
<Route path="/participants/new" element={<CreateParticipantPage />} /> <Route path="/questions" element={<QuestionLibraryPage />} />
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} /> <Route path="/participants" element={<ParticipantsPage />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/participants/new" element={<CreateParticipantPage />} />
<Route path="/inbox" element={<InboxPage />} /> <Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
<Route path="/calendar" element={<CalendarPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="*" element={<NotFound />} /> <Route path="/inbox" element={<InboxPage />} />
</Routes> <Route path="/calendar" element={<CalendarPage />} />
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<AppFooter />
</main> </main>
</div> </div>
) )

View file

@ -0,0 +1,31 @@
import { Link } from 'react-router-dom'
import { footerLinkGroups } from '../../config/navigation'
export function AppFooter() {
return (
<footer className="app-footer">
<div className="app-footer-brand">
<span className="logo-mark" aria-hidden="true">
G
</span>
<div>
<strong>GO GUESS</strong>
<p>Structured interviews, shared decisions, better hiring.</p>
</div>
</div>
<nav className="footer-navigation" aria-label="Footer navigation">
{footerLinkGroups.map((group) => (
<section key={group.title}>
<h2>{group.title}</h2>
{group.links.map((link) => (
<Link to={link.href} key={link.href}>
{link.label}
</Link>
))}
</section>
))}
</nav>
<p className="footer-meta">Go Guess recruitment workspace</p>
</footer>
)
}

View file

@ -0,0 +1,21 @@
import { Inbox } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import type { User } from '../../types'
export function WorkspaceUtilities({ user }: { user: User | null }) {
const profileLabel = user?.displayName || user?.email || 'Profile'
return (
<nav className="workspace-utilities" aria-label="Account navigation">
<NavLink className="utility-link" to="/inbox">
<Inbox size={18} aria-hidden="true" />
<span>Inbox</span>
</NavLink>
<NavLink className="utility-link profile-utility" to="/profile">
<span className="avatar tiny" aria-hidden="true">
{profileLabel.slice(0, 2).toUpperCase()}
</span>
<span>{profileLabel}</span>
</NavLink>
</nav>
)
}

View file

@ -0,0 +1,29 @@
export interface FooterLink {
label: string
href: string
}
export interface FooterLinkGroup {
title: string
links: FooterLink[]
}
export const footerLinkGroups: FooterLinkGroup[] = [
{
title: 'Sitemap',
links: [
{ label: 'Jobs', href: '/jobs' },
{ label: 'Participants', href: '/participants' },
{ label: 'Question library', href: '/questions' },
{ label: 'Calendar', href: '/calendar' },
],
},
{
title: 'Useful links',
links: [
{ label: 'Your inbox', href: '/inbox' },
{ label: 'Your profile', href: '/profile' },
{ label: 'Interview team', href: '/users' },
],
},
]

View file

@ -0,0 +1,53 @@
import { Mail, ShieldCheck, UserRound } from 'lucide-react'
import { authStorage } from '../../api/client'
export function ProfilePage() {
const user = authStorage.user()
if (!user) return null
return (
<>
<header className="page-header">
<div>
<span className="eyebrow">Your account</span>
<h1>{user.displayName}</h1>
<p>Your signed-in identity and workspace access.</p>
</div>
</header>
<section className="card profile-card">
<div className="profile-identity">
<span className="avatar profile-avatar" aria-hidden="true">
{user.displayName.slice(0, 2).toUpperCase()}
</span>
<div>
<h2>{user.displayName}</h2>
<span className={`status status-${user.role}`}>{user.role}</span>
</div>
</div>
<dl className="profile-details">
<div>
<dt>
<Mail size={18} aria-hidden="true" />
Email address
</dt>
<dd>{user.email}</dd>
</div>
<div>
<dt>
<UserRound size={18} aria-hidden="true" />
User ID
</dt>
<dd>{user.id}</dd>
</div>
<div>
<dt>
<ShieldCheck size={18} aria-hidden="true" />
Workspace role
</dt>
<dd>{user.role}</dd>
</div>
</dl>
</section>
</>
)
}

View file

@ -1 +1,2 @@
export { ProfilePage } from './ProfilePage'
export { UsersPage } from './UsersPage' export { UsersPage } from './UsersPage'

View file

@ -455,9 +455,26 @@ button {
margin-top: auto; margin-top: auto;
padding: 1rem 0.4rem 0; padding: 1rem 0.4rem 0;
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-signout {
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 0.55rem;
padding: 0.7rem;
color: #aac0ca;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: transparent;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
}
.sidebar-signout:hover {
color: white;
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.05);
} }
.user-chip { .user-chip {
display: flex; display: flex;
@ -480,9 +497,150 @@ button {
} }
.main-content { .main-content {
margin-left: 255px; margin-left: 255px;
padding: clamp(2rem, 4vw, 4.2rem) clamp(1.2rem, 4vw, 4.8rem); padding: clamp(1rem, 2vw, 1.8rem) clamp(1.2rem, 4vw, 4.8rem) 0;
max-width: 1550px; max-width: 1550px;
min-height: 100vh; min-height: 100vh;
display: flex;
flex-direction: column;
}
.workspace-utilities {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.55rem;
}
.utility-link {
min-height: 40px;
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.7rem;
color: #526872;
border: 1px solid transparent;
border-radius: 10px;
font-size: 0.84rem;
font-weight: 700;
transition:
color 0.2s,
border-color 0.2s,
background 0.2s;
}
.utility-link:hover,
.utility-link.active {
color: var(--navy);
border-color: #d5e3e6;
background: rgba(255, 255, 255, 0.62);
}
.profile-utility {
padding-left: 0.45rem;
}
.workspace-content {
flex: 1;
padding-top: clamp(1.6rem, 3vw, 3rem);
}
.app-footer {
display: grid;
grid-template-columns: minmax(220px, 1.25fr) minmax(300px, 1fr);
gap: 2rem;
margin-top: clamp(3rem, 6vw, 6rem);
padding: 2rem 0;
color: #667d88;
border-top: 1px solid #dbe6e8;
font-size: 0.82rem;
}
.app-footer-brand {
display: flex;
align-items: flex-start;
gap: 0.8rem;
}
.app-footer-brand strong {
color: var(--navy);
font-family: 'Manrope';
letter-spacing: 0.08em;
}
.app-footer-brand p {
max-width: 330px;
margin: 0.25rem 0 0;
line-height: 1.55;
}
.footer-navigation {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2rem;
}
.footer-navigation section {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
}
.footer-navigation h2 {
margin: 0 0 0.25rem;
color: var(--navy);
font-family: 'DM Sans';
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.09em;
}
.footer-navigation a:hover {
color: var(--cyan-dark);
}
.footer-meta {
grid-column: 1 / -1;
margin: 0;
padding-top: 1rem;
color: #8a9ca4;
border-top: 1px solid #e3ebed;
}
.profile-card {
max-width: 820px;
overflow: hidden;
}
.profile-identity {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
color: white;
background: var(--navy);
}
.profile-identity h2 {
margin: 0 0 0.4rem;
color: white;
}
.profile-avatar {
width: 64px;
height: 64px;
font-size: 1.15rem;
}
.profile-details {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 0;
}
.profile-details > div {
min-width: 0;
padding: 1.35rem 1.5rem;
border-right: 1px solid var(--line);
}
.profile-details > div:last-child {
border-right: 0;
}
.profile-details dt {
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.profile-details dd {
overflow-wrap: anywhere;
margin: 0.55rem 0 0;
color: var(--ink);
font-weight: 700;
} }
.page-header { .page-header {
@ -2117,6 +2275,22 @@ fieldset {
width: 100%; width: 100%;
margin-left: 0; margin-left: 0;
} }
.app-footer {
grid-template-columns: 1fr;
}
.footer-meta {
grid-column: 1;
}
.profile-details {
grid-template-columns: 1fr;
}
.profile-details > div {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.profile-details > div:last-child {
border-bottom: 0;
}
} }
@media (max-width: 560px) { @media (max-width: 560px) {
@ -2223,6 +2397,18 @@ fieldset {
.schedule-form-actions .button { .schedule-form-actions .button {
width: 100%; width: 100%;
} }
.workspace-utilities {
gap: 0.25rem;
}
.utility-link {
padding-inline: 0.5rem;
}
.profile-utility > span:last-child {
display: none;
}
.footer-navigation {
gap: 1.25rem;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {