@@ -796,26 +806,29 @@ function InvitationsView({
Invite an eligible candidate and share their private participant URL.
-
+
+ {eligible.map(({ participant }) => (
+
+
+
+
+ {participant.firstName} {participant.lastName}
+
+ {participant.email}
+
+
+
+ ))}
+ {!eligible.length &&
All eligible candidates have been invited.
}
+
{error && }
{invitations.map((invitation) => (
@@ -834,12 +847,84 @@ function InvitationsView({
>
Copy URL
+ {['accepted', 'completed'].includes(invitation.status) && (
+
+ )}
))}
{!invitations.length && (
)}
+ {review && setReview(undefined)} />}
+
+ )
+}
+
+function InvitationAnswerReview({
+ interview,
+ onClose,
+}: {
+ interview: Interview
+ onClose: () => void
+}) {
+ return (
+
Reference answer
@@ -1066,6 +1156,9 @@ function QuestionForm({
: [],
)
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('')
@@ -1080,6 +1173,7 @@ function QuestionForm({
} else {
setReferenceAnswer('')
}
+ if (nextType !== 'code_review') setCodeSnippet('')
}
function addOption() {
@@ -1104,6 +1198,7 @@ function QuestionForm({
type,
options: choiceType ? options : [],
referenceAnswer: choiceType ? '' : referenceAnswer.trim(),
+ codeSnippet: type === 'code_review' ? codeSnippet : '',
})
if (!initialQuestion) {
setText('')
@@ -1111,6 +1206,7 @@ function QuestionForm({
setOptions([])
setOptionDraft('')
setReferenceAnswer('')
+ setCodeSnippet('')
}
} catch (requestError) {
setError(errorMessage(requestError))
@@ -1145,6 +1241,17 @@ function QuestionForm({
+ {type === 'code_review' && (
+
+
+ )}
{!choiceType && (
+
+
+ {codeSnippet.split('\n').map((line, index) => {
+ const added = line.startsWith('+') && !line.startsWith('+++')
+ return (
+
+
+ {index + 1}
+
+ {line || ' '}
+
+ )
+ })}
+
+
+ )
+}
+
+function CodeReviewAnswer({
+ codeSnippet,
+ answer,
+ onChange,
+}: {
+ codeSnippet: string
+ answer: string
+ onChange: (answer: string) => void
+}) {
+ return (
+
+
+
+
+
+ )
+}
+
function ParticipantInterviewPage() {
- const token = useParams().token ?? ''
+ const token = useParams().invitationId ?? ''
const [interview, setInterview] = useState()
const [answers, setAnswers] = useState>({})
const [index, setIndex] = useState(0)
@@ -1737,6 +1893,7 @@ function ParticipantInterviewPage() {
const [working, setWorking] = useState(false)
const [error, setError] = useState('')
const finishing = useRef(false)
+ const saveQueues = useRef>>({})
useEffect(() => {
let active = true
@@ -1788,13 +1945,21 @@ function ParticipantInterviewPage() {
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 api.interviews.answer(
- token,
- currentQuestion.id,
- answers[String(currentQuestion.id)] ?? '',
- )
+ await saveQueues.current[String(currentQuestion.id)]
}
async function move(target: number) {
@@ -1868,6 +2033,9 @@ function ParticipantInterviewPage() {
Welcome, {interview.invitation.participantName}
{interview.job.title}
{interview.job.description}
+
+ By accepting, you agree to begin the timed interview and submit your responses.
+
{interview.job.durationMinutes} minutes
@@ -1878,7 +2046,7 @@ function ParticipantInterviewPage() {
{error && }
@@ -1946,12 +2114,7 @@ function ParticipantInterviewPage() {
type="radio"
name={`question-${currentQuestion.id}`}
checked={answer === option}
- onChange={() =>
- setAnswers((current) => ({
- ...current,
- [String(currentQuestion.id)]: option,
- }))
- }
+ onChange={() => updateAnswer(currentQuestion.id, option)}
/>
{option}
@@ -1969,29 +2132,27 @@ function ParticipantInterviewPage() {
const next = event.target.checked
? [...selectedOptions, option]
: selectedOptions.filter((item) => item !== option)
- setAnswers((current) => ({
- ...current,
- [String(currentQuestion.id)]: next.join('\n'),
- }))
+ updateAnswer(currentQuestion.id, next.join('\n'))
}}
/>
{option}
))}
+ ) : currentQuestion.type === 'code_review' ? (
+ updateAnswer(currentQuestion.id, value)}
+ />
) : (
@@ -2037,7 +2198,7 @@ function ParticipantInterviewPage() {
disabled={working}
onClick={() => void finish()}
>
- Finish
+ Submit
@@ -2083,10 +2244,10 @@ export default function App() {
authStorage.clear()
setAuthenticated(false)
}
- if (location.pathname.startsWith('/interview/')) {
+ if (location.pathname.startsWith('/participant/')) {
return (
- } />
+ } />
} />
)
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 371cb6d..f613e2f 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -117,6 +117,8 @@ export const api = {
request(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
candidates: (jobId: number) => request(`/jobs/${jobId}/candidates`),
invitations: (jobId: number) => request(`/jobs/${jobId}/invitations`),
+ invitation: (jobId: number, invitationId: number) =>
+ request(`/jobs/${jobId}/invitations/${invitationId}`),
invite: (jobId: number, participantId: number) =>
request(`/jobs/${jobId}/invitations`, {
method: 'POST',
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 6e18f1b..55ba2af 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -19,6 +19,12 @@
--radius: 16px;
--shadow: 0 12px 35px rgba(14, 42, 56, 0.08);
}
+.eligible-row {
+ grid-template-columns: auto 1fr;
+}
+.eligible-row .button {
+ grid-column: 1 / 3;
+}
* {
box-sizing: border-box;
@@ -853,12 +859,35 @@ td small {
flex: 1;
margin: 0;
}
+.eligible-list {
+ display: grid;
+ gap: 0.75rem;
+ padding: 1.2rem 1.5rem;
+ border-bottom: 1px solid var(--line);
+}
+.eligible-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 0.8rem;
+ padding: 0.85rem;
+ border: 1px solid var(--line);
+ border-radius: 11px;
+ background: #f8fbfb;
+}
+.eligible-row > div {
+ display: flex;
+ flex-direction: column;
+}
+.eligible-row small {
+ color: var(--muted);
+}
.invitation-list {
padding: 0 1.5rem;
}
.invitation-row {
display: grid;
- grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.5fr) auto;
+ grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.5fr) auto auto;
gap: 1rem;
align-items: center;
padding: 1rem 0;
@@ -877,6 +906,51 @@ td small {
text-overflow: ellipsis;
white-space: nowrap;
}
+.answer-review {
+ border-top: 1px solid var(--line);
+}
+.review-heading-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+}
+.review-question-list {
+ padding: 0 1.5rem 1.5rem;
+}
+.review-question {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 1rem;
+ padding: 1.4rem 0;
+ border-bottom: 1px solid var(--line);
+}
+.review-question h3 {
+ margin: 0.35rem 0 1rem;
+}
+.review-options,
+.participant-answer {
+ margin-top: 0.8rem;
+ padding: 0.75rem;
+ border-radius: 8px;
+ background: #f5f8f9;
+}
+.review-options strong,
+.participant-answer strong {
+ display: block;
+ margin-bottom: 0.3rem;
+ color: var(--muted);
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+.participant-answer p {
+ margin: 0;
+ white-space: pre-wrap;
+}
+.participant-answer.unanswered {
+ color: #934141;
+ background: #fff0f0;
+}
.public-interview {
min-height: 100vh;
@@ -914,6 +988,11 @@ td small {
color: #526872;
margin-bottom: 2rem;
}
+.agreement-copy {
+ padding: 0.85rem 1rem;
+ border-radius: 9px;
+ background: #f5f8f9;
+}
.welcome-facts span,
.timer {
display: inline-flex;
@@ -991,6 +1070,55 @@ td small {
.participant-options input {
width: auto;
}
+.code-review-answer {
+ display: grid;
+ gap: 1.4rem;
+}
+.code-review-panel {
+ overflow: hidden;
+ border: 1px solid #b9c8cf;
+ border-radius: 10px;
+ background: #ffffff;
+}
+.code-review-panel > header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.7rem 0.9rem;
+ border-bottom: 1px solid #c9d5da;
+ background: #f6f8fa;
+}
+.code-lines {
+ overflow-x: auto;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.82rem;
+}
+.code-line {
+ display: grid;
+ grid-template-columns: 52px minmax(max-content, 1fr);
+ min-height: 24px;
+ line-height: 24px;
+ background: #ffffff;
+}
+.code-line.added {
+ background: #dafbe1;
+}
+.line-number {
+ padding: 0 0.75rem;
+ border-right: 1px solid #d8dee4;
+ color: #6e7781;
+ background: #f6f8fa;
+ text-align: right;
+ user-select: none;
+}
+.code-line.added .line-number {
+ background: #ccffd8;
+}
+.code-line code {
+ padding: 0 0.8rem;
+ color: #24292f;
+ white-space: pre;
+}
.interview-controls {
display: flex;
justify-content: flex-end;
@@ -1039,6 +1167,17 @@ td small {
white-space: pre-wrap;
margin: 0;
}
+.interviewer-code-snippet {
+ overflow-x: auto;
+ margin: 0.75rem 0 0;
+ padding: 0.8rem;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ color: #dbe8ed;
+ background: #102633;
+ font-size: 0.78rem;
+ white-space: pre;
+}
.ai-label {
display: inline-flex;
align-items: center;
@@ -1654,6 +1793,10 @@ fieldset {
flex-direction: column;
gap: 0.7rem;
}
+ .review-heading-actions {
+ align-items: flex-start;
+ flex-direction: column;
+ }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 25c88a1..f2a79fd 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -17,6 +17,7 @@ export interface Question {
type: QuestionType
options: string[]
referenceAnswer: string
+ codeSnippet: string
deprecated: boolean
}
@@ -85,7 +86,10 @@ export type CreateJobInput = Omit<
Job,
'id' | 'questions' | 'createdAt' | 'status' | 'durationMinutes'
>
-export type CreateQuestionInput = Pick
+export type CreateQuestionInput = Pick<
+ Question,
+ 'text' | 'type' | 'options' | 'referenceAnswer' | 'codeSnippet'
+>
export interface CreateParticipantInput {
firstName: string
lastName: string