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:
parent
0d8a25e73f
commit
83d14d23a2
9 changed files with 388 additions and 36 deletions
|
|
@ -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 { BrowserRouter, MemoryRouter } from 'react-router-dom'
|
||||
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()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
authenticate()
|
||||
const user = userEvent.setup()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Inbox,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
|
|
@ -52,6 +51,9 @@ import {
|
|||
ScheduledInterviewPage,
|
||||
} from '../features/interviews'
|
||||
import { UsersPage } from '../features/users'
|
||||
import { ProfilePage } from '../features/users'
|
||||
import { AppFooter } from '../components/layout/AppFooter'
|
||||
import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities'
|
||||
import type {
|
||||
CandidateMatch,
|
||||
CreateJobInput,
|
||||
|
|
@ -292,12 +294,6 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
|
|||
icon={<CircleHelp size={20} />}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
<NavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
icon={<Inbox size={20} />}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
<NavItem
|
||||
to="/calendar"
|
||||
label="Calendar"
|
||||
|
|
@ -312,35 +308,34 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
|
|||
/>
|
||||
</nav>
|
||||
<div className="sidebar-bottom">
|
||||
<div className="user-chip">
|
||||
<span className="avatar tiny">{user?.email.slice(0, 2).toUpperCase() ?? 'U'}</span>
|
||||
<span>
|
||||
<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 className="sidebar-signout" onClick={onLogout}>
|
||||
<LogOut size={18} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/jobs" replace />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/jobs/new" element={<CreateJobPage />} />
|
||||
<Route path="/jobs/:jobId" element={<JobDetailPage />} />
|
||||
<Route path="/jobs/:jobId/questions/new" element={<QuestionPage />} />
|
||||
<Route path="/questions" element={<QuestionLibraryPage />} />
|
||||
<Route path="/participants" element={<ParticipantsPage />} />
|
||||
<Route path="/participants/new" element={<CreateParticipantPage />} />
|
||||
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<WorkspaceUtilities user={user} />
|
||||
<div className="workspace-content">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/jobs" replace />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/jobs/new" element={<CreateJobPage />} />
|
||||
<Route path="/jobs/:jobId" element={<JobDetailPage />} />
|
||||
<Route path="/jobs/:jobId/questions/new" element={<QuestionPage />} />
|
||||
<Route path="/questions" element={<QuestionLibraryPage />} />
|
||||
<Route path="/participants" element={<ParticipantsPage />} />
|
||||
<Route path="/participants/new" element={<CreateParticipantPage />} />
|
||||
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<AppFooter />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
31
frontend/src/components/layout/AppFooter.tsx
Normal file
31
frontend/src/components/layout/AppFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
frontend/src/components/layout/WorkspaceUtilities.tsx
Normal file
21
frontend/src/components/layout/WorkspaceUtilities.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/config/navigation.ts
Normal file
29
frontend/src/config/navigation.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
53
frontend/src/features/users/ProfilePage.tsx
Normal file
53
frontend/src/features/users/ProfilePage.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { ProfilePage } from './ProfilePage'
|
||||
export { UsersPage } from './UsersPage'
|
||||
|
|
|
|||
|
|
@ -455,9 +455,26 @@ button {
|
|||
margin-top: auto;
|
||||
padding: 1rem 0.4rem 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.sidebar-signout {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
|
|
@ -480,9 +497,150 @@ button {
|
|||
}
|
||||
.main-content {
|
||||
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;
|
||||
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 {
|
||||
|
|
@ -2117,6 +2275,22 @@ fieldset {
|
|||
width: 100%;
|
||||
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) {
|
||||
|
|
@ -2223,6 +2397,18 @@ fieldset {
|
|||
.schedule-form-actions .button {
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue