From 83d14d23a2dbe5fb696681cdaa54d9adecbfad5f Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sun, 20 Sep 2026 07:21:10 +0200 Subject: [PATCH] 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> --- frontend/README.md | 7 +- frontend/src/app/App.test.tsx | 33 ++- frontend/src/app/App.tsx | 59 +++--- frontend/src/components/layout/AppFooter.tsx | 31 +++ .../components/layout/WorkspaceUtilities.tsx | 21 ++ frontend/src/config/navigation.ts | 29 +++ frontend/src/features/users/ProfilePage.tsx | 53 +++++ frontend/src/features/users/index.ts | 1 + frontend/src/styles/global.css | 190 +++++++++++++++++- 9 files changed, 388 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/layout/AppFooter.tsx create mode 100644 frontend/src/components/layout/WorkspaceUtilities.tsx create mode 100644 frontend/src/config/navigation.ts create mode 100644 frontend/src/features/users/ProfilePage.tsx diff --git a/frontend/README.md b/frontend/README.md index 685ed2e..b30f4d2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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/features` groups pages and components by business capability. - `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/types` contains API and domain types. - `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: - **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. - **Interviewer sessions** for changing status, editing prominent shared documentation, and 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 `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 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 diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index 9cda5ca..2d681eb 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -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() diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 5680b6a..1cb7463 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -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={} close={() => setOpen(false)} /> - } - close={() => setOpen(false)} - /> void }) { />
-
- {user?.email.slice(0, 2).toUpperCase() ?? 'U'} - - {user?.email ?? 'Signed in'} - {user?.role ?? 'User'} - -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
) diff --git a/frontend/src/components/layout/AppFooter.tsx b/frontend/src/components/layout/AppFooter.tsx new file mode 100644 index 0000000..535255c --- /dev/null +++ b/frontend/src/components/layout/AppFooter.tsx @@ -0,0 +1,31 @@ +import { Link } from 'react-router-dom' +import { footerLinkGroups } from '../../config/navigation' + +export function AppFooter() { + return ( +
+
+ +
+ GO GUESS +

Structured interviews, shared decisions, better hiring.

+
+
+ +

Go Guess recruitment workspace

+
+ ) +} diff --git a/frontend/src/components/layout/WorkspaceUtilities.tsx b/frontend/src/components/layout/WorkspaceUtilities.tsx new file mode 100644 index 0000000..fcdd84a --- /dev/null +++ b/frontend/src/components/layout/WorkspaceUtilities.tsx @@ -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 ( + + ) +} diff --git a/frontend/src/config/navigation.ts b/frontend/src/config/navigation.ts new file mode 100644 index 0000000..1b608ae --- /dev/null +++ b/frontend/src/config/navigation.ts @@ -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' }, + ], + }, +] diff --git a/frontend/src/features/users/ProfilePage.tsx b/frontend/src/features/users/ProfilePage.tsx new file mode 100644 index 0000000..1797821 --- /dev/null +++ b/frontend/src/features/users/ProfilePage.tsx @@ -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 ( + <> +
+
+ Your account +

{user.displayName}

+

Your signed-in identity and workspace access.

+
+
+
+
+ +
+

{user.displayName}

+ {user.role} +
+
+
+
+
+
+
{user.email}
+
+
+
+
+
{user.id}
+
+
+
+
+
{user.role}
+
+
+
+ + ) +} diff --git a/frontend/src/features/users/index.ts b/frontend/src/features/users/index.ts index 67adc02..74e5cbc 100644 --- a/frontend/src/features/users/index.ts +++ b/frontend/src/features/users/index.ts @@ -1 +1,2 @@ +export { ProfilePage } from './ProfilePage' export { UsersPage } from './UsersPage' diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 92ea345..99b15f0 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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) {