go-guess/frontend/src/App.tsx
Daniel Noulet 6153017b62 review
2026-09-19 20:56:27 +02:00

2261 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ArrowLeft,
ArrowRight,
BriefcaseBusiness,
Check,
ChevronRight,
CircleHelp,
Clock3,
Copy,
FileText,
LayoutDashboard,
LogOut,
Menu,
Pencil,
Plus,
Search,
Send,
Sparkles,
Trash2,
Upload,
UserRound,
UsersRound,
X,
} from 'lucide-react'
import {
createContext,
type FormEvent,
type ReactNode,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import {
Link,
NavLink,
Navigate,
Route,
Routes,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom'
import { api, ApiError, authStorage } from './api'
import type {
CandidateMatch,
CreateJobInput,
CreateParticipantInput,
CreateQuestionInput,
Interview,
Invitation,
Job,
JobStatus,
Participant,
Question,
QuestionType,
} from './types'
interface AppData {
jobs: Job[]
participants: Participant[]
loading: boolean
error: string
createJob: (input: CreateJobInput) => Promise<Job>
createParticipant: (input: CreateParticipantInput) => Promise<Participant>
reload: () => Promise<void>
}
const DataContext = createContext<AppData | null>(null)
function useData() {
const value = useContext(DataContext)
if (!value) throw new Error('useData must be used within DataProvider')
return value
}
function errorMessage(error: unknown) {
return error instanceof ApiError || error instanceof Error
? error.message
: 'Something went wrong'
}
function DataProvider({ children }: { children: ReactNode }) {
const [jobs, setJobs] = useState<Job[]>([])
const [participants, setParticipants] = useState<Participant[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
async function reload() {
setLoading(true)
setError('')
try {
const [jobItems, participantItems] = await Promise.all([
api.jobs.list(),
api.participants.list(),
])
setJobs(jobItems)
setParticipants(participantItems)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setLoading(false)
}
}
useEffect(() => {
let active = true
void Promise.all([api.jobs.list(), api.participants.list()])
.then(([jobItems, participantItems]) => {
if (!active) return
setJobs(jobItems)
setParticipants(participantItems)
})
.catch((requestError: unknown) => {
if (active) setError(errorMessage(requestError))
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [])
async function createJob(input: CreateJobInput) {
const created = await api.jobs.create(input)
setJobs((current) => [created, ...current])
return created
}
async function createParticipant(input: CreateParticipantInput) {
const created = await api.participants.create(input)
setParticipants((current) => [created, ...current])
return created
}
return (
<DataContext.Provider
value={{ jobs, participants, loading, error, createJob, createParticipant, reload }}
>
{children}
</DataContext.Provider>
)
}
function Logo() {
return (
<Link className="logo" to="/jobs" aria-label="Go Guess home">
<span className="logo-mark" aria-hidden="true">
G
</span>
<span>
<strong>GO GUESS</strong>
<small>Talent workspace</small>
</span>
</Link>
)
}
function LoginPage({ onLogin }: { onLogin: () => void }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setLoading(true)
setError('')
const form = new FormData(event.currentTarget)
try {
const auth = await api.login(String(form.get('email')), String(form.get('password')))
authStorage.save(auth)
onLogin()
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setLoading(false)
}
}
return (
<main className="login-shell">
<section className="login-story">
<Logo />
<div className="story-copy">
<span className="eyebrow">Better hiring, clearly connected</span>
<h1>Find the right person for every journey.</h1>
<p>Bring roles, assessments, and candidate insight together in one workspace.</p>
<div className="story-stat">
<Sparkles />
<span>
<strong>Skills-first matching</strong>
<small>Structured signals for confident decisions</small>
</span>
</div>
</div>
<p className="login-foot">Designed for fair, collaborative recruitment.</p>
</section>
<section className="login-panel" aria-labelledby="login-title">
<div className="login-card">
<span className="eyebrow">Welcome back</span>
<h2 id="login-title">Sign in to your workspace</h2>
<p className="muted">Use your work account to continue.</p>
<form onSubmit={submit} className="stack-form">
<FormField label="Email address" htmlFor="email">
<input id="email" name="email" type="email" autoComplete="email" required />
</FormField>
<FormField label="Password" htmlFor="password">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</FormField>
{error && <ErrorAlert message={error} />}
<button className="button primary wide" type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
</button>
</form>
<p className="demo-note">
<CircleHelp size={16} /> Contact your administrator for access.
</p>
</div>
</section>
</main>
)
}
function AppLayout({ onLogout }: { onLogout: () => void }) {
const [open, setOpen] = useState(false)
const user = authStorage.user()
return (
<div className="app-shell">
<header className="mobile-header">
<Logo />
<button className="icon-button" onClick={() => setOpen(true)} aria-label="Open navigation">
<Menu />
</button>
</header>
{open && (
<button
className="nav-backdrop"
onClick={() => setOpen(false)}
aria-label="Close navigation"
/>
)}
<aside className={`sidebar ${open ? 'open' : ''}`}>
<div className="sidebar-top">
<Logo />
<button
className="icon-button mobile-only"
onClick={() => setOpen(false)}
aria-label="Close navigation"
>
<X />
</button>
</div>
<nav aria-label="Primary navigation">
<p className="nav-label">Workspace</p>
<NavItem
to="/jobs"
label="Jobs"
icon={<BriefcaseBusiness size={20} />}
close={() => setOpen(false)}
/>
<NavItem
to="/participants"
label="Participants"
icon={<UsersRound size={20} />}
close={() => setOpen(false)}
/>
<NavItem
to="/questions"
label="Question library"
icon={<CircleHelp size={20} />}
close={() => setOpen(false)}
/>
</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>
</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="*" element={<NotFound />} />
</Routes>
</main>
</div>
)
}
function NavItem({
to,
label,
icon,
close,
}: {
to: string
label: string
icon: ReactNode
close: () => void
}) {
return (
<NavLink
to={to}
onClick={close}
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
>
{icon}
{label}
</NavLink>
)
}
function PageHeader({
eyebrow,
title,
description,
action,
}: {
eyebrow: string
title: string
description?: string
action?: ReactNode
}) {
return (
<header className="page-header">
<div>
<span className="eyebrow">{eyebrow}</span>
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
{action}
</header>
)
}
function JobsPage() {
const { jobs, loading, error, reload } = useData()
const [query, setQuery] = useState('')
const filtered = jobs.filter((job) =>
`${job.title} ${job.position} ${job.seniority} ${job.labels.join(' ')}`
.toLowerCase()
.includes(query.toLowerCase()),
)
return (
<>
<PageHeader
eyebrow="Recruitment"
title="Job postings"
description="Create roles, build assessments, and compare candidate fit."
action={
<Link to="/jobs/new" className="button primary">
<Plus size={18} /> Create job
</Link>
}
/>
{error && <ErrorAlert message={error} retry={reload} />}
<section className="metrics">
<Metric label="Job postings" value={String(jobs.length)} />
<Metric label="Participants" value="—" />
<Metric
label="Assessments"
value={String(jobs.reduce((sum, job) => sum + job.questions.length, 0))}
/>
</section>
<section className="card">
<div className="toolbar">
<label className="search-field">
<Search size={18} />
<span className="sr-only">Search jobs</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search jobs…"
/>
</label>
<span className="result-count">{filtered.length} jobs</span>
</div>
{loading ? (
<Loading />
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Role</th>
<th>Seniority</th>
<th>Position</th>
<th>Status</th>
<th>Questions</th>
<th>
<span className="sr-only">View</span>
</th>
</tr>
</thead>
<tbody>
{filtered.map((job) => (
<tr key={job.id}>
<td>
<Link className="table-title" to={`/jobs/${job.id}`}>
{job.title}
</Link>
<small>{job.labels.join(' · ') || 'No labels'}</small>
</td>
<td>
<StatusBadge value={job.seniority} />
</td>
<td>{job.position}</td>
<td>
<StatusBadge value={job.status} />
</td>
<td>{job.questions.length}</td>
<td>
<Link
className="icon-link"
to={`/jobs/${job.id}`}
aria-label={`View ${job.title}`}
>
<ChevronRight />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!loading && !filtered.length && !error && (
<EmptyState title="No jobs found" description="Create a job or adjust your search." />
)}
</section>
</>
)
}
function JobDetailPage() {
const id = Number(useParams().jobId)
const [job, setJob] = useState<Job>()
const [matches, setMatches] = useState<CandidateMatch[]>([])
const [invitations, setInvitations] = useState<Invitation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [tab, setTab] = useState<'overview' | 'questions' | 'candidates' | 'invitations'>(
'overview',
)
async function load() {
setLoading(true)
setError('')
try {
const [jobValue, candidates, invitationItems] = await Promise.all([
api.jobs.get(id),
api.jobs.candidates(id),
api.jobs.invitations(id),
])
setJob(jobValue)
setMatches(candidates)
setInvitations(invitationItems)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setLoading(false)
}
}
useEffect(() => {
let active = true
void Promise.all([api.jobs.get(id), api.jobs.candidates(id), api.jobs.invitations(id)])
.then(([jobValue, candidates, invitationItems]) => {
if (!active) return
setJob(jobValue)
setMatches(candidates)
setInvitations(invitationItems)
})
.catch((requestError: unknown) => {
if (active) setError(errorMessage(requestError))
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [id])
if (loading) return <Loading />
if (error) return <ErrorAlert message={error} retry={load} />
if (!job) return <NotFound />
return (
<>
<Link className="back-link" to="/jobs">
<ArrowLeft size={17} /> All jobs
</Link>
<PageHeader
eyebrow={`${job.seniority} · ${job.position}`}
title={job.title}
description={job.description}
/>
<div className="tabs" role="tablist">
{(['overview', 'questions', 'candidates', 'invitations'] as const).map((item) => (
<button
key={item}
role="tab"
aria-selected={tab === item}
className={tab === item ? 'active' : ''}
onClick={() => setTab(item)}
>
{item === 'overview'
? 'Overview'
: item === 'questions'
? `Questions (${job.questions.length})`
: item === 'candidates'
? `Candidate match (${matches.length})`
: `Invitations (${invitations.length})`}
</button>
))}
</div>
{tab === 'overview' && (
<div className="detail-grid">
<section className="card prose">
<h2>About the role</h2>
<p>{job.description}</p>
<h3>Required skills</h3>
<SkillList skills={job.requiredSkills} />
<h3>Additional skills</h3>
<SkillList skills={job.additionalSkills} />
</section>
<aside className="card facts">
<h2>Job details</h2>
<Fact label="Seniority" value={job.seniority} />
<Fact label="Position" value={job.position} />
<Fact label="Status" value={job.status} />
<Fact label="Duration" value={`${job.durationMinutes} minutes`} />
<Fact label="Labels" value={job.labels.join(', ') || 'None'} />
<Fact label="Created" value={new Date(job.createdAt).toLocaleDateString()} />
<JobSettings job={job} onUpdated={setJob} />
</aside>
</div>
)}
{tab === 'questions' && <JobQuestionsView job={job} onUpdated={setJob} />}
{tab === 'candidates' && <CandidateMatchView matches={matches} />}
{tab === 'invitations' && (
<InvitationsView
job={job}
matches={matches}
invitations={invitations}
onInvited={(invitation) => setInvitations((current) => [invitation, ...current])}
/>
)}
</>
)
}
function JobSettings({ job, onUpdated }: { job: Job; onUpdated: (job: Job) => void }) {
const [status, setStatus] = useState<JobStatus>(job.status)
const [durationMinutes, setDurationMinutes] = useState(job.durationMinutes)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function save(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
try {
onUpdated(await api.jobs.update(job.id, { status, durationMinutes }))
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setSaving(false)
}
}
return (
<form className="inline-settings" onSubmit={save}>
<FormField label="Interview status" htmlFor="job-status">
<select
id="job-status"
value={status}
onChange={(event) => setStatus(event.target.value as JobStatus)}
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="deprecated">Deprecated</option>
</select>
</FormField>
<FormField label="Duration (minutes)" htmlFor="job-duration">
<input
id="job-duration"
type="number"
min="1"
required
value={durationMinutes}
onChange={(event) => setDurationMinutes(Number(event.target.value))}
/>
</FormField>
{error && <ErrorAlert message={error} />}
<button className="button secondary wide" type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save interview settings'}
</button>
</form>
)
}
function JobQuestionsView({ job, onUpdated }: { job: Job; onUpdated: (job: Job) => void }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Question[]>([])
const [error, setError] = useState('')
const attached = new Set(job.questions.map((question) => question.id))
useEffect(() => {
let active = true
const timeout = window.setTimeout(() => {
void api.questions
.list(query)
.then((questions) => {
if (active) setResults(questions)
})
.catch((requestError) => {
if (active) setError(errorMessage(requestError))
})
}, 200)
return () => {
active = false
window.clearTimeout(timeout)
}
}, [query])
async function attach(question: Question) {
setError('')
try {
await api.jobs.attachQuestion(job.id, question.id)
onUpdated({ ...job, questions: [...job.questions, question] })
} catch (requestError) {
setError(errorMessage(requestError))
}
}
async function detach(question: Question) {
setError('')
try {
await api.jobs.detachQuestion(job.id, question.id)
onUpdated({
...job,
questions: job.questions.filter((item) => item.id !== question.id),
})
} catch (requestError) {
setError(errorMessage(requestError))
}
}
return (
<div className="split-panels">
<section className="card">
<div className="section-heading">
<div>
<h2>Assessment questions</h2>
<p>Detach removes only this job’s link to the library question.</p>
</div>
</div>
<div className="question-list">
{job.questions.map((question, index) => (
<QuestionRow
key={question.id}
question={question}
number={index + 1}
action={
<button
className="button ghost danger"
type="button"
onClick={() => void detach(question)}
aria-label={`Detach ${question.text}`}
>
<Trash2 size={16} /> Detach
</button>
}
/>
))}
{!job.questions.length && (
<EmptyState
title="No questions yet"
description="Search the library to attach a question."
/>
)}
</div>
</section>
<section className="card">
<div className="section-heading">
<div>
<h2>Question library</h2>
<p>Search reusable, active questions.</p>
</div>
<Link className="button secondary" to="/questions">
Manage library
</Link>
</div>
<div className="panel-padding">
<label className="search-field">
<Search size={18} />
<span className="sr-only">Search question library</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search question library…"
/>
</label>
{error && <ErrorAlert message={error} />}
</div>
<div className="question-list compact">
{results
.filter((question) => !question.deprecated && !attached.has(question.id))
.map((question) => (
<QuestionRow
key={question.id}
question={question}
action={
<button
className="button secondary"
type="button"
onClick={() => void attach(question)}
>
<Plus size={16} /> Attach
</button>
}
/>
))}
</div>
</section>
</div>
)
}
function InvitationsView({
job,
matches,
invitations,
onInvited,
}: {
job: Job
matches: CandidateMatch[]
invitations: Invitation[]
onInvited: (invitation: Invitation) => void
}) {
const [sendingId, setSendingId] = useState<number>()
const [review, setReview] = useState<Interview>()
const [reviewingId, setReviewingId] = useState<number>()
const [error, setError] = useState('')
const invitedIds = new Set(invitations.map((invitation) => invitation.participantId))
const eligible = matches.filter((match) => !invitedIds.has(match.participant.id))
async function send(participantId: number) {
setSendingId(participantId)
setError('')
try {
onInvited(await api.jobs.invite(job.id, participantId))
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setSendingId(undefined)
}
}
async function copy(url: string) {
try {
await navigator.clipboard.writeText(url)
} catch {
setError('Could not copy the participant URL')
}
}
async function reviewAnswers(invitationId: number) {
setReviewingId(invitationId)
setError('')
try {
setReview(await api.jobs.invitation(job.id, invitationId))
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setReviewingId(undefined)
}
}
return (
<section className="card">
<div className="section-heading">
<div>
<h2>Interview invitations</h2>
<p>Invite an eligible candidate and share their private participant URL.</p>
</div>
</div>
<div className="eligible-list">
{eligible.map(({ participant }) => (
<article className="eligible-row" key={participant.id}>
<Avatar person={participant} />
<div>
<strong>
{participant.firstName} {participant.lastName}
</strong>
<small>{participant.email}</small>
</div>
<button
className="button primary"
type="button"
disabled={sendingId === participant.id}
onClick={() => void send(participant.id)}
>
<Send size={16} />{' '}
{sendingId === participant.id ? 'Generating…' : 'Generate invitation'}
</button>
</article>
))}
{!eligible.length && <p className="muted">All eligible candidates have been invited.</p>}
</div>
{error && <ErrorAlert message={error} />}
<div className="invitation-list">
{invitations.map((invitation) => (
<article className="invitation-row" key={invitation.id}>
<div>
<strong>{invitation.participantName}</strong>
<small>{invitation.participantEmail}</small>
</div>
<StatusBadge value={invitation.status} />
<code>{invitation.participantUrl}</code>
<button
className="button secondary"
type="button"
onClick={() => void copy(invitation.participantUrl)}
aria-label={`Copy interview URL for ${invitation.participantName}`}
>
<Copy size={16} /> Copy URL
</button>
{['accepted', 'completed'].includes(invitation.status) && (
<button
className="button secondary"
type="button"
disabled={reviewingId === invitation.id}
onClick={() => void reviewAnswers(invitation.id)}
>
{reviewingId === invitation.id ? 'Loading…' : 'Review answers'}
</button>
)}
</article>
))}
{!invitations.length && (
<EmptyState title="No invitations" description="Invite a candidate to this interview." />
)}
</div>
{review && <InvitationAnswerReview interview={review} onClose={() => setReview(undefined)} />}
</section>
)
}
function InvitationAnswerReview({
interview,
onClose,
}: {
interview: Interview
onClose: () => void
}) {
return (
<section className="answer-review" aria-labelledby="answer-review-title">
<div className="section-heading">
<div>
<span className="eyebrow">Participant submission</span>
<h2 id="answer-review-title">{interview.invitation.participantName}</h2>
<p>{interview.invitation.participantEmail}</p>
</div>
<div className="review-heading-actions">
<StatusBadge value={interview.invitation.status} />
<button className="button ghost" type="button" onClick={onClose}>
Close review
</button>
</div>
</div>
<div className="review-question-list">
{interview.job.questions.map((question, index) => {
const answer = interview.answers[String(question.id)]?.trim()
return (
<article className="review-question" key={question.id}>
<span className="question-number">{index + 1}</span>
<div>
<span className="question-type">{question.type.replace('_', ' ')}</span>
<h3>{question.text}</h3>
{question.type === 'code_review' && question.codeSnippet && (
<CodePanel codeSnippet={question.codeSnippet} />
)}
{(question.type === 'multiple_choice' || question.type === 'radio') &&
question.options.length > 0 && (
<div className="review-options">
<strong>Possible options</strong>
<span>{question.options.join(' · ')}</span>
</div>
)}
{(question.type === 'open' || question.type === 'code_review') &&
question.referenceAnswer && (
<div className="reference-answer">
<strong>Reference answer</strong>
<p>{question.referenceAnswer}</p>
</div>
)}
<div className={answer ? 'participant-answer' : 'participant-answer unanswered'}>
<strong>Participant answer</strong>
<p>{answer || 'Unanswered'}</p>
</div>
</div>
</article>
)
})}
</div>
</section>
)
}
function QuestionRow({
question,
number,
action,
}: {
question: Question
number?: number
action?: ReactNode
}) {
return (
<article className="question-row">
{number ? <span className="question-number">{number}</span> : <CircleHelp size={22} />}
<div>
<div className="question-meta">
<h3>{question.type.replace('_', ' ')}</h3>
{question.deprecated && <StatusBadge value="deprecated" />}
</div>
<p>{question.text}</p>
{question.options.length > 0 && <small>{question.options.join(' · ')}</small>}
{question.type === 'code_review' && question.codeSnippet && (
<pre className="interviewer-code-snippet">
<code>{question.codeSnippet}</code>
</pre>
)}
{question.referenceAnswer && (
<div className="reference-answer">
<strong>Reference answer</strong>
<p>{question.referenceAnswer}</p>
</div>
)}
</div>
{action && <div className="question-action">{action}</div>}
</article>
)
}
function CandidateMatchView({ matches }: { matches: CandidateMatch[] }) {
return (
<section className="card">
<div className="section-heading">
<div>
<h2>Candidate match</h2>
<p>Trait alignment calculated by the API.</p>
</div>
<span className="ai-label">
<Sparkles size={16} /> Match insight
</span>
</div>
<div className="match-list">
{matches.map((match, index) => (
<article className="match-card" key={match.participant.id}>
<span className="rank">{index + 1}</span>
<Avatar person={match.participant} />
<div className="match-person">
<Link to={`/participants/${match.participant.id}`}>
{match.participant.firstName} {match.participant.lastName}
</Link>
<small>{match.participant.email}</small>
</div>
<div className="score">
<div>
<span>Matched</span>
<strong>{match.matchedTraits.length}</strong>
</div>
<span className="score-track">
<span style={{ width: `${Math.min(match.score, 100)}%` }} />
</span>
</div>
<div className="score">
<div>
<span>Missing</span>
<strong>{match.missingTraits.length}</strong>
</div>
</div>
<div className="match-total">
<strong>{Math.round(match.score)}%</strong>
<small>Overall match</small>
</div>
</article>
))}
{!matches.length && (
<EmptyState
title="No candidate matches"
description="Matches appear when participants have extractable CV traits."
/>
)}
</div>
</section>
)
}
function CreateJobPage() {
const { createJob } = useData()
const navigate = useNavigate()
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
setError('')
const form = new FormData(event.currentTarget)
const split = (name: string) =>
String(form.get(name))
.split(',')
.map((item) => item.trim())
.filter(Boolean)
try {
const job = await createJob({
title: String(form.get('title')),
description: String(form.get('description')),
seniority: String(form.get('seniority')),
position: String(form.get('position')),
labels: split('labels'),
requiredSkills: split('requiredSkills'),
additionalSkills: split('additionalSkills'),
})
navigate(`/jobs/${job.id}`)
} catch (requestError) {
setError(errorMessage(requestError))
setSaving(false)
}
}
return (
<>
<Link className="back-link" to="/jobs">
<ArrowLeft size={17} /> Cancel
</Link>
<PageHeader
eyebrow="New posting"
title="Create a job"
description="Define the role and skills used for candidate matching."
/>
<form className="card form-card" onSubmit={submit}>
<div className="form-section">
<h2>Role information</h2>
<div className="form-grid">
<FormField label="Job title" htmlFor="title">
<input id="title" name="title" required />
</FormField>
<FormField label="Position" htmlFor="position">
<input id="position" name="position" required placeholder="e.g. Backend Engineer" />
</FormField>
<FormField label="Seniority" htmlFor="seniority">
<input id="seniority" name="seniority" required placeholder="e.g. Senior" />
</FormField>
<FormField label="Labels" htmlFor="labels" hint="Comma separated">
<input id="labels" name="labels" />
</FormField>
</div>
</div>
<div className="form-section">
<h2>Role profile</h2>
<FormField label="Description" htmlFor="description">
<textarea id="description" name="description" required rows={7} />
</FormField>
<FormField label="Required skills" htmlFor="requiredSkills" hint="Comma separated">
<input id="requiredSkills" name="requiredSkills" required />
</FormField>
<FormField label="Additional skills" htmlFor="additionalSkills" hint="Comma separated">
<input id="additionalSkills" name="additionalSkills" />
</FormField>
</div>
{error && <ErrorAlert message={error} />}
<FormActions saving={saving} cancelTo="/jobs" label="Create job" />
</form>
</>
)
}
function QuestionPage() {
const id = Number(useParams().jobId)
const navigate = useNavigate()
const [job, setJob] = useState<Job>()
const [loadError, setLoadError] = useState('')
useEffect(() => {
void api.jobs
.get(id)
.then(setJob)
.catch((value) => setLoadError(errorMessage(value)))
}, [id])
if (loadError) return <ErrorAlert message={loadError} />
if (!job) return <Loading />
async function createAndAttach(input: CreateQuestionInput) {
const question = await api.questions.create(input)
await api.jobs.attachQuestion(id, question.id)
navigate(`/jobs/${id}`)
}
return (
<>
<Link className="back-link" to={`/jobs/${id}`}>
<ArrowLeft size={17} /> {job.title}
</Link>
<PageHeader
eyebrow="Assessment builder"
title="Create a question"
description="Create a reusable library question and attach it to this job."
/>
<QuestionForm
submitLabel="Create and attach question"
onSubmit={createAndAttach}
onCancel={() => navigate(`/jobs/${id}`)}
/>
</>
)
}
function QuestionForm({
initialQuestion,
submitLabel,
onSubmit,
onCancel,
}: {
initialQuestion?: Question
submitLabel: string
onSubmit: (input: CreateQuestionInput) => Promise<void>
onCancel?: () => void
}) {
const [text, setText] = useState(initialQuestion?.text ?? '')
const [type, setType] = useState<QuestionType>(initialQuestion?.type ?? 'open')
const [options, setOptions] = useState(
initialQuestion?.type === 'multiple_choice' || initialQuestion?.type === 'radio'
? initialQuestion.options
: [],
)
const [referenceAnswer, setReferenceAnswer] = useState(initialQuestion?.referenceAnswer ?? '')
const [codeSnippet, setCodeSnippet] = useState(
initialQuestion?.type === 'code_review' ? initialQuestion.codeSnippet : '',
)
const [optionDraft, setOptionDraft] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const choiceType = type === 'multiple_choice' || type === 'radio'
function changeType(nextType: QuestionType) {
setType(nextType)
setError('')
if (nextType === 'open' || nextType === 'code_review') {
setOptions([])
setOptionDraft('')
} else {
setReferenceAnswer('')
}
if (nextType !== 'code_review') setCodeSnippet('')
}
function addOption() {
const option = optionDraft.trim()
if (!option) return
setOptions((current) => [...current, option])
setOptionDraft('')
setError('')
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (choiceType && options.length < 2) {
setError('Add at least two options before saving this question.')
return
}
setSaving(true)
setError('')
try {
await onSubmit({
text: text.trim(),
type,
options: choiceType ? options : [],
referenceAnswer: choiceType ? '' : referenceAnswer.trim(),
codeSnippet: type === 'code_review' ? codeSnippet : '',
})
if (!initialQuestion) {
setText('')
setType('open')
setOptions([])
setOptionDraft('')
setReferenceAnswer('')
setCodeSnippet('')
}
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setSaving(false)
}
}
return (
<form className="card form-card library-form" onSubmit={submit}>
<div className="form-section">
<h2>{initialQuestion ? 'Edit question' : 'Create question'}</h2>
<FormField label="Question prompt" htmlFor="question-form-text">
<textarea
id="question-form-text"
name="text"
rows={type === 'code_review' ? 10 : 5}
value={text}
onChange={(event) => setText(event.target.value)}
required
/>
</FormField>
<FormField label="Response type" htmlFor="question-form-type">
<select
id="question-form-type"
value={type}
onChange={(event) => changeType(event.target.value as QuestionType)}
>
<option value="open">Open answer</option>
<option value="multiple_choice">Multiple choice</option>
<option value="radio">Single choice</option>
<option value="code_review">Code review</option>
</select>
</FormField>
{type === 'code_review' && (
<FormField label="Code snippet" htmlFor="question-form-code-snippet">
<textarea
id="question-form-code-snippet"
rows={12}
value={codeSnippet}
onChange={(event) => setCodeSnippet(event.target.value)}
required
/>
</FormField>
)}
{!choiceType && (
<FormField
label={type === 'code_review' ? 'Expected answer' : 'Reference answer'}
htmlFor="question-form-reference-answer"
>
<textarea
id="question-form-reference-answer"
rows={5}
value={referenceAnswer}
onChange={(event) => setReferenceAnswer(event.target.value)}
/>
</FormField>
)}
{choiceType && (
<fieldset className="option-builder">
<legend className="field-label">Answer options</legend>
<div className="option-entry">
<input
aria-label="New option"
value={optionDraft}
onChange={(event) => setOptionDraft(event.target.value)}
placeholder="Type an option"
/>
<button
className="button secondary option-add"
type="button"
onClick={addOption}
disabled={!optionDraft.trim()}
aria-label="Add option"
>
<Plus size={18} />
</button>
</div>
<div className="option-list" aria-live="polite">
{options.map((option, index) => (
<div className="option-item" key={`${option}-${index}`}>
<span>{option}</span>
<button
className="icon-button"
type="button"
onClick={() =>
setOptions((current) => current.filter((_, itemIndex) => itemIndex !== index))
}
aria-label={`Remove option ${option}`}
>
<X size={17} />
</button>
</div>
))}
</div>
<small className="field-hint">Add at least two options.</small>
</fieldset>
)}
{error && <ErrorAlert message={error} />}
<div className="question-form-actions">
{onCancel && (
<button className="button ghost" type="button" onClick={onCancel} disabled={saving}>
Cancel
</button>
)}
<button className="button primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : submitLabel}
</button>
</div>
</div>
</form>
)
}
function QuestionLibraryPage() {
const [questions, setQuestions] = useState<Question[]>([])
const [query, setQuery] = useState('')
const [editing, setEditing] = useState<Question>()
const [formVersion, setFormVersion] = useState(0)
const [error, setError] = useState('')
async function load(search = query) {
setError('')
try {
setQuestions(await api.questions.list(search))
} catch (requestError) {
setError(errorMessage(requestError))
}
}
useEffect(() => {
let active = true
const timeout = window.setTimeout(() => {
void api.questions
.list(query)
.then((items) => {
if (active) setQuestions(items)
})
.catch((requestError) => {
if (active) setError(errorMessage(requestError))
})
}, 200)
return () => {
active = false
window.clearTimeout(timeout)
}
}, [query])
async function create(input: CreateQuestionInput) {
const created = await api.questions.create(input)
setQuestions((current) => [created, ...current])
setFormVersion((current) => current + 1)
}
async function update(input: CreateQuestionInput) {
if (!editing) return
const updated = await api.questions.update(editing.id, input)
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
setEditing(undefined)
}
async function toggle(question: Question) {
setError('')
try {
const updated = await api.questions.setDeprecated(question.id, !question.deprecated)
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
if (editing?.id === updated.id) setEditing(updated)
} catch (requestError) {
setError(errorMessage(requestError))
}
}
return (
<>
<PageHeader
eyebrow="Assessment builder"
title="Question library"
description="Create reusable interview questions and retire questions without deleting them."
/>
{error && <ErrorAlert message={error} retry={load} />}
<div className="library-grid">
<section className="card">
<div className="toolbar">
<label className="search-field">
<Search size={18} />
<span className="sr-only">Search questions</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search questions…"
/>
</label>
<span className="result-count">{questions.length} questions</span>
</div>
<div className="question-list">
{questions.map((question) => (
<QuestionRow
key={question.id}
question={question}
action={
<div className="library-question-actions">
<button
className="button ghost"
type="button"
onClick={() => setEditing(question)}
aria-label={`Edit ${question.text}`}
>
<Pencil size={16} /> Edit
</button>
<label className="toggle">
<input
type="checkbox"
checked={question.deprecated}
onChange={() => void toggle(question)}
aria-label={`Deprecated: ${question.text}`}
/>
<span>Deprecated</span>
</label>
</div>
}
/>
))}
{!questions.length && (
<EmptyState
title="No questions found"
description="Create a question or adjust your search."
/>
)}
</div>
</section>
<QuestionForm
key={editing ? `edit-${editing.id}` : `create-${formVersion}`}
initialQuestion={editing}
submitLabel={editing ? 'Save changes' : 'Create question'}
onSubmit={editing ? update : create}
onCancel={editing ? () => setEditing(undefined) : undefined}
/>
</div>
</>
)
}
function ParticipantsPage() {
const { participants, loading, error, reload } = useData()
const [query, setQuery] = useState('')
const filtered = participants.filter((person) =>
`${person.firstName} ${person.lastName} ${person.email} ${person.traits.join(' ')}`
.toLowerCase()
.includes(query.toLowerCase()),
)
return (
<>
<PageHeader
eyebrow="Talent pool"
title="Participants"
description="Manage candidate profiles and application documents."
action={
<Link to="/participants/new" className="button primary">
<Plus size={18} /> Add participant
</Link>
}
/>
{error && <ErrorAlert message={error} retry={reload} />}
<section className="card">
<div className="toolbar">
<label className="search-field">
<Search size={18} />
<span className="sr-only">Search participants</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search people or traits…"
/>
</label>
<span className="result-count">{filtered.length} participants</span>
</div>
{loading ? (
<Loading />
) : (
<div className="people-grid">
{filtered.map((person) => (
<Link className="person-card" to={`/participants/${person.id}`} key={person.id}>
<Avatar person={person} large />
<div>
<h2>
{person.firstName} {person.lastName}
</h2>
<p>{person.email}</p>
<small>{person.contactInfo}</small>
</div>
<div className="tag-list">
{person.traits.slice(0, 3).map((trait) => (
<span key={trait}>{trait}</span>
))}
</div>
<ChevronRight className="person-arrow" />
</Link>
))}
</div>
)}
{!loading && !filtered.length && !error && (
<EmptyState
title="No participants found"
description="Add a participant or adjust your search."
/>
)}
</section>
</>
)
}
function CreateParticipantPage() {
const { createParticipant } = useData()
const navigate = useNavigate()
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [photoPreview, setPhotoPreview] = useState('')
const [photo, setPhoto] = useState<File>()
const [cv, setCv] = useState<File>()
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
setError('')
const form = new FormData(event.currentTarget)
try {
const person = await createParticipant({
firstName: String(form.get('firstName')),
lastName: String(form.get('lastName')),
birthday: String(form.get('birthday')),
email: String(form.get('email')),
contactInfo: String(form.get('contactInfo')),
photo,
cv,
})
navigate(`/participants/${person.id}`)
} catch (requestError) {
setError(errorMessage(requestError))
setSaving(false)
}
}
return (
<>
<Link className="back-link" to="/participants">
<ArrowLeft size={17} /> Cancel
</Link>
<PageHeader
eyebrow="New profile"
title="Add a participant"
description="Create a candidate profile and attach application documents."
/>
<form className="card form-card" onSubmit={submit}>
<div className="form-section">
<h2>Profile photo</h2>
<div className="photo-upload">
<span className="avatar upload-avatar">
{photoPreview ? (
<img src={photoPreview} alt="Selected profile preview" />
) : (
<UserRound />
)}
</span>
<label className="button secondary file-button">
<Upload size={17} /> Choose photo
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
const file = event.target.files?.[0]
setPhoto(file)
if (file) setPhotoPreview(URL.createObjectURL(file))
}}
/>
</label>
<small>JPG, PNG or WebP.</small>
</div>
</div>
<div className="form-section">
<h2>Personal information</h2>
<div className="form-grid">
<FormField label="First name" htmlFor="firstName">
<input id="firstName" name="firstName" required />
</FormField>
<FormField label="Last name" htmlFor="lastName">
<input id="lastName" name="lastName" required />
</FormField>
<FormField label="Birthday" htmlFor="birthday">
<input id="birthday" name="birthday" type="date" />
</FormField>
<FormField label="Email address" htmlFor="email">
<input id="email" name="email" type="email" required />
</FormField>
</div>
<FormField
label="Contact information"
htmlFor="contactInfo"
hint="Phone, location, or preferred contact details."
>
<textarea id="contactInfo" name="contactInfo" rows={3} required />
</FormField>
</div>
<div className="form-section">
<h2>Curriculum vitae</h2>
<label className="dropzone">
<FileText size={28} />
<span>
<strong>{cv?.name ?? 'Upload CV'}</strong>
<small>PDF or Word document</small>
</span>
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(event) => setCv(event.target.files?.[0])}
/>
</label>
</div>
{error && <ErrorAlert message={error} />}
<FormActions saving={saving} cancelTo="/participants" label="Create participant" />
</form>
</>
)
}
function ParticipantDetailPage() {
const id = Number(useParams().participantId)
const [person, setPerson] = useState<Participant>()
const [error, setError] = useState('')
useEffect(() => {
void api.participants
.get(id)
.then(setPerson)
.catch((value) => setError(errorMessage(value)))
}, [id])
if (error) return <ErrorAlert message={error} />
if (!person) return <Loading />
async function downloadCV() {
try {
const blob = await api.participants.cv(id)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = person?.cvFilename ?? 'cv'
anchor.click()
URL.revokeObjectURL(url)
} catch (requestError) {
setError(errorMessage(requestError))
}
}
return (
<>
<Link className="back-link" to="/participants">
<ArrowLeft size={17} /> All participants
</Link>
<section className="profile-hero">
<Avatar person={person} large />
<div>
<span className="eyebrow">Candidate profile</span>
<h1>
{person.firstName} {person.lastName}
</h1>
<p>{person.email}</p>
</div>
<a className="button secondary" href={`mailto:${person.email}`}>
Contact candidate
</a>
</section>
<div className="detail-grid">
<section className="card prose">
<h2>Extracted traits</h2>
<div className="tag-list large">
{person.traits.map((trait) => (
<span key={trait}>{trait}</span>
))}
</div>
{!person.traits.length && <p className="muted">No traits extracted.</p>}
</section>
<aside className="card facts">
<h2>Contact & documents</h2>
<Fact label="Email" value={person.email} />
<Fact label="Contact information" value={person.contactInfo} />
<Fact label="Birthday" value={person.birthday ?? 'Not provided'} />
<Fact label="Created" value={new Date(person.createdAt).toLocaleDateString()} />
{person.cvUrl && (
<button className="document-row" type="button" onClick={downloadCV}>
<FileText />
<span>
<strong>{person.cvFilename}</strong>
<small>Download candidate CV</small>
</span>
</button>
)}
</aside>
</div>
</>
)
}
function Avatar({ person, large = false }: { person: Participant; large?: boolean }) {
const [source, setSource] = useState('')
useEffect(() => {
if (!person.photoUrl) return
let active = true
let objectUrl = ''
void api.participants
.photo(person.id)
.then((blob) => {
objectUrl = URL.createObjectURL(blob)
if (active) setSource(objectUrl)
})
.catch(() => undefined)
return () => {
active = false
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [person.id, person.photoUrl])
return (
<span className={`avatar ${large ? 'large' : ''}`}>
{source ? <img src={source} alt="" /> : `${person.firstName[0]}${person.lastName[0]}`}
</span>
)
}
function SkillList({ skills }: { skills: string[] }) {
return skills.length ? (
<ul className="check-list">
{skills.map((skill) => (
<li key={skill}>
<Check size={17} /> {skill}
</li>
))}
</ul>
) : (
<p className="muted">None specified.</p>
)
}
function FormField({
label,
htmlFor,
hint,
children,
}: {
label: string
htmlFor: string
hint?: string
children: ReactNode
}) {
return (
<div className="field">
<label className="field-label" htmlFor={htmlFor}>
{label}
</label>
{children}
{hint && <small>{hint}</small>}
</div>
)
}
function FormActions({
saving,
cancelTo,
label,
}: {
saving: boolean
cancelTo: string
label: string
}) {
return (
<div className="form-actions">
<Link className="button ghost" to={cancelTo}>
Cancel
</Link>
<button className="button primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : label}
<ArrowRight size={17} />
</button>
</div>
)
}
function ErrorAlert({ message, retry }: { message: string; retry?: () => void | Promise<void> }) {
return (
<div className="error-alert" role="alert">
<strong>Request failed</strong>
<span>{message}</span>
{retry && (
<button type="button" onClick={() => void retry()}>
Try again
</button>
)}
</div>
)
}
function Loading() {
return (
<div className="empty-state" role="status">
<span className="loading-spinner" />
<p>Loading…</p>
</div>
)
}
function StatusBadge({ value }: { value: string }) {
return <span className={`status status-${value.toLowerCase()}`}>{value}</span>
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<article className="metric">
<span>{label}</span>
<strong>{value}</strong>
</article>
)
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<div className="fact">
<small>{label}</small>
<strong>{value}</strong>
</div>
)
}
function EmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="empty-state">
<LayoutDashboard />
<h3>{title}</h3>
<p>{description}</p>
</div>
)
}
function CodePanel({ codeSnippet }: { codeSnippet: string }) {
return (
<section className="code-review-panel" aria-label="Code changes">
<header>
<FileText size={17} />
<strong>Code changes</strong>
</header>
<div className="code-lines">
{codeSnippet.split('\n').map((line, index) => {
const added = line.startsWith('+') && !line.startsWith('+++')
return (
<div className={added ? 'code-line added' : 'code-line'} key={index}>
<span className="line-number" aria-hidden="true">
{index + 1}
</span>
<code>{line || ' '}</code>
</div>
)
})}
</div>
</section>
)
}
function CodeReviewAnswer({
codeSnippet,
answer,
onChange,
}: {
codeSnippet: string
answer: string
onChange: (answer: string) => void
}) {
return (
<div className="code-review-answer">
<CodePanel codeSnippet={codeSnippet} />
<FormField label="Review comment" htmlFor="review-comment">
<textarea
id="review-comment"
rows={7}
value={answer}
onChange={(event) => onChange(event.target.value)}
placeholder="Describe issues, suggestions, and approval notes…"
/>
</FormField>
</div>
)
}
function ParticipantInterviewPage() {
const token = useParams().invitationId ?? ''
const [interview, setInterview] = useState<Interview>()
const [answers, setAnswers] = useState<Record<string, string>>({})
const [index, setIndex] = useState(0)
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [working, setWorking] = useState(false)
const [error, setError] = useState('')
const finishing = useRef(false)
const saveQueues = useRef<Record<string, Promise<void>>>({})
useEffect(() => {
let active = true
void api.interviews
.get(token)
.then((value) => {
if (!active) return
setInterview(value)
setAnswers(value.answers ?? {})
})
.catch((requestError) => {
if (active) setError(errorMessage(requestError))
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [token])
const acceptedAt = interview?.invitation.acceptedAt
const durationMinutes = interview?.job.durationMinutes
useEffect(() => {
if (!acceptedAt || !durationMinutes || interview?.invitation.completedAt) return
const update = () => {
const deadline = new Date(acceptedAt).getTime() + durationMinutes * 60_000
setRemainingSeconds(Math.max(0, Math.ceil((deadline - Date.now()) / 1000)))
}
update()
const interval = window.setInterval(update, 1000)
return () => window.clearInterval(interval)
}, [acceptedAt, durationMinutes, interview?.invitation.completedAt])
async function accept() {
setWorking(true)
setError('')
try {
const accepted = await api.interviews.accept(token)
setInterview(accepted)
setAnswers(accepted.answers ?? {})
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setWorking(false)
}
}
const questions = interview?.job.questions ?? []
const currentQuestion = questions[index]
function updateAnswer(questionId: number, answer: string) {
const key = String(questionId)
setAnswers((current) => ({ ...current, [key]: answer }))
setError('')
const previous = saveQueues.current[key] ?? Promise.resolve()
saveQueues.current[key] = previous
.then(() => api.interviews.answer(token, questionId, answer))
.catch((requestError) => {
setError(errorMessage(requestError))
})
}
async function persistCurrent() {
if (!currentQuestion) return
await saveQueues.current[String(currentQuestion.id)]
}
async function move(target: number) {
setWorking(true)
setError('')
try {
await persistCurrent()
setIndex(Math.max(0, Math.min(target, questions.length - 1)))
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setWorking(false)
}
}
async function finish() {
if (finishing.current) return
finishing.current = true
setWorking(true)
setError('')
try {
await persistCurrent()
setInterview(await api.interviews.finish(token))
} catch (requestError) {
finishing.current = false
setError(errorMessage(requestError))
} finally {
setWorking(false)
}
}
if (loading)
return (
<PublicInterviewShell>
<Loading />
</PublicInterviewShell>
)
if (error && !interview)
return (
<PublicInterviewShell>
<ErrorAlert message={error} />
</PublicInterviewShell>
)
if (!interview)
return (
<PublicInterviewShell>
<NotFound />
</PublicInterviewShell>
)
if (interview.invitation.completedAt || interview.invitation.status === 'completed') {
return (
<PublicInterviewShell>
<section className="interview-card completion-card">
<span className="completion-mark">
<Check />
</span>
<span className="eyebrow">Interview complete</span>
<h1>Thank you, {interview.invitation.participantName}.</h1>
<p>Your answers have been submitted. You may now close this page.</p>
</section>
</PublicInterviewShell>
)
}
if (!interview.invitation.acceptedAt) {
return (
<PublicInterviewShell>
<section className="interview-card welcome-card">
<span className="eyebrow">Interview invitation</span>
<h1>Welcome, {interview.invitation.participantName}</h1>
<h2>{interview.job.title}</h2>
<p>{interview.job.description}</p>
<p className="agreement-copy">
By accepting, you agree to begin the timed interview and submit your responses.
</p>
<div className="welcome-facts">
<span>
<Clock3 size={18} /> {interview.job.durationMinutes} minutes
</span>
<span>
<CircleHelp size={18} /> {questions.length} questions
</span>
</div>
{error && <ErrorAlert message={error} />}
<button className="button primary" type="button" disabled={working} onClick={accept}>
{working ? 'Accepting…' : 'Accept'}
<ArrowRight size={18} />
</button>
</section>
</PublicInterviewShell>
)
}
if (!currentQuestion) {
return (
<PublicInterviewShell>
<section className="interview-card welcome-card">
<h1>No interview questions</h1>
<p>This interview does not contain any questions.</p>
<button className="button primary" type="button" onClick={() => void finish()}>
Finish
</button>
</section>
</PublicInterviewShell>
)
}
const answer = answers[String(currentQuestion.id)] ?? ''
const selectedOptions = answer ? answer.split('\n') : []
const countdown = remainingSeconds ?? interview.job.durationMinutes * 60
const minutes = Math.floor(countdown / 60)
const seconds = countdown % 60
const progress = ((index + 1) / questions.length) * 100
return (
<PublicInterviewShell>
<section className="interview-card question-stage">
<header className="interview-header">
<div>
<span className="eyebrow">{interview.job.title}</span>
<strong>
Question {index + 1} of {questions.length}
</strong>
</div>
<time className={countdown < 60 ? 'timer urgent' : 'timer'}>
<Clock3 size={18} />
<span data-testid="countdown">
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</span>
</time>
</header>
<div
className="progress-track"
role="progressbar"
aria-label="Interview progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
>
<span style={{ width: `${progress}%` }} />
</div>
<div className="interview-question">
<span className="question-type">{currentQuestion.type.replace('_', ' ')}</span>
<h1>{currentQuestion.text}</h1>
{currentQuestion.type === 'radio' ? (
<fieldset className="participant-options">
<legend className="sr-only">Choose one answer</legend>
{currentQuestion.options.map((option) => (
<label key={option}>
<input
type="radio"
name={`question-${currentQuestion.id}`}
checked={answer === option}
onChange={() => updateAnswer(currentQuestion.id, option)}
/>
{option}
</label>
))}
</fieldset>
) : currentQuestion.type === 'multiple_choice' ? (
<fieldset className="participant-options">
<legend className="sr-only">Choose one or more answers</legend>
{currentQuestion.options.map((option) => (
<label key={option}>
<input
type="checkbox"
checked={selectedOptions.includes(option)}
onChange={(event) => {
const next = event.target.checked
? [...selectedOptions, option]
: selectedOptions.filter((item) => item !== option)
updateAnswer(currentQuestion.id, next.join('\n'))
}}
/>
{option}
</label>
))}
</fieldset>
) : currentQuestion.type === 'code_review' ? (
<CodeReviewAnswer
codeSnippet={currentQuestion.codeSnippet}
answer={answer}
onChange={(value) => updateAnswer(currentQuestion.id, value)}
/>
) : (
<label>
<span className="sr-only">Your answer</span>
<textarea
aria-label="Your answer"
rows={7}
value={answer}
onChange={(event) => updateAnswer(currentQuestion.id, event.target.value)}
placeholder="Type your answer…"
/>
</label>
)}
</div>
{error && <ErrorAlert message={error} />}
<footer className="interview-controls">
<button
className="button ghost"
type="button"
disabled={working || index === 0}
onClick={() => void move(0)}
>
First
</button>
<button
className="button secondary"
type="button"
disabled={working || index === 0}
onClick={() => void move(index - 1)}
>
<ArrowLeft size={17} /> Previous
</button>
<button
className="button secondary"
type="button"
disabled={working || index === questions.length - 1}
onClick={() => void move(index + 1)}
>
Next <ArrowRight size={17} />
</button>
<button
className="button ghost"
type="button"
disabled={working || index === questions.length - 1}
onClick={() => void move(questions.length - 1)}
>
Last
</button>
<button
className="button primary"
type="button"
disabled={working}
onClick={() => void finish()}
>
Submit
</button>
</footer>
</section>
</PublicInterviewShell>
)
}
function PublicInterviewShell({ children }: { children: ReactNode }) {
return (
<main className="public-interview">
<header>
<Logo />
</header>
{children}
</main>
)
}
function NotFound() {
return (
<section className="empty-page">
<span className="eyebrow">404</span>
<h1>Page not found</h1>
<p>The page you requested is not available.</p>
<Link className="button primary" to="/jobs">
Back to jobs
</Link>
</section>
)
}
export default function App() {
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
const navigate = useNavigate()
const location = useLocation()
function completeLogin() {
setAuthenticated(true)
navigate('/jobs', { replace: true })
}
function logout() {
authStorage.clear()
setAuthenticated(false)
}
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
)
}
if (!authenticated) return <LoginPage onLogin={completeLogin} />
return (
<DataProvider>
<AppLayout onLogout={logout} />
</DataProvider>
)
}