Klnimri commited on
Commit
e91fc5d
·
verified ·
1 Parent(s): b2793f3

Upload 23 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+ WORKDIR /app
3
+
4
+ COPY package.json package-lock.json* ./
5
+ RUN npm install
6
+
7
+ COPY . .
8
+
9
+ ENV NODE_ENV=production
10
+ ENV NEXT_TELEMETRY_DISABLED=1
11
+ ENV PORT=7860
12
+ ENV HOSTNAME=0.0.0.0
13
+
14
+ RUN npm run build
15
+
16
+ EXPOSE 7860
17
+ CMD ["npm","run","start"]
README.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: SGS Psychometrics Assessment (A–F)
3
+ emoji: 🧠
4
+ colorFrom: green
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ # SGS Psychometrics Assessment (A–F)
11
+
12
+ - Next.js 14 + React 18
13
+ - TailwindCSS 3
14
+ - Radix UI (radio/progress)
15
+ - Framer Motion animations
16
+ - Free recruiter delivery: **Google Sheets webhook** (Apps Script)
17
+
18
+ ## Required Hugging Face Secrets
19
+ - `RESULTS_WEBHOOK_URL`
20
+ - `RESULTS_WEBHOOK_TOKEN`
21
+
22
+ ## Optional Secrets
23
+ - `HF_TOKEN` (only if you want AI narrative analysis)
24
+ - `HF_MODEL` (default: `HuggingFaceH4/zephyr-7b-beta`)
25
+
26
+ ## Notes
27
+ - Candidate never sees scores or results.
28
+ - Recruiters receive results through the Google Sheet via webhook.
next-env.d.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
next.config.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = { reactStrictMode: true };
3
+ module.exports = nextConfig;
package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "sgs-psychometrics-af",
3
+ "private": true,
4
+ "version": "2.0.0",
5
+ "scripts": {
6
+ "dev": "next dev -p 7860 -H 0.0.0.0",
7
+ "build": "next build",
8
+ "start": "next start -p 7860 -H 0.0.0.0"
9
+ },
10
+ "dependencies": {
11
+ "next": "14.2.14",
12
+ "react": "18.2.0",
13
+ "react-dom": "18.2.0",
14
+ "framer-motion": "11.0.0",
15
+ "lucide-react": "0.454.0",
16
+ "@radix-ui/react-progress": "1.1.0",
17
+ "@radix-ui/react-radio-group": "1.2.0",
18
+ "@radix-ui/react-slot": "1.1.0",
19
+ "clsx": "2.1.1",
20
+ "tailwind-merge": "2.5.4"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "5.6.3",
24
+ "@types/node": "20.17.5",
25
+ "@types/react": "18.2.79",
26
+ "@types/react-dom": "18.2.25",
27
+ "tailwindcss": "3.4.17",
28
+ "postcss": "8.4.38",
29
+ "autoprefixer": "10.4.20"
30
+ }
31
+ }
postcss.config.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ module.exports = {
2
+ plugins: { tailwindcss: {}, autoprefixer: {} }
3
+ };
src/app/api/submit/route.ts CHANGED
@@ -6,12 +6,6 @@ function safeJsonParse(text: string): any | null {
6
  try { return JSON.parse(text); } catch { return null; }
7
  }
8
 
9
- function extractFirstText(hf: any): string {
10
- if (Array.isArray(hf) && hf.length && typeof hf[0]?.generated_text === "string") return hf[0].generated_text;
11
- if (typeof hf?.generated_text === "string") return hf.generated_text;
12
- return JSON.stringify(hf);
13
- }
14
-
15
  function tryParseJson(text: string): any | null {
16
  const match = text.match(/\{[\s\S]*\}/);
17
  if (!match) return null;
@@ -32,7 +26,7 @@ export async function POST(req: Request) {
32
  );
33
  }
34
 
35
- // Optional AI analysis for the text scenario (occupational narrative, NOT clinical diagnosis)
36
  let ai: any = null;
37
  const hfToken = process.env.HF_TOKEN;
38
  const model = process.env.HF_MODEL || "HuggingFaceH4/zephyr-7b-beta";
@@ -50,23 +44,30 @@ CANDIDATE RESPONSE:
50
  ${textScenario}
51
  `;
52
 
53
- const r = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
 
54
  method: "POST",
55
  headers: {
56
  "Authorization": `Bearer ${hfToken}`,
57
  "Content-Type": "application/json"
58
  },
59
  body: JSON.stringify({
60
- inputs: prompt,
61
- parameters: { max_new_tokens: 260, temperature: 0.2, return_full_text: false }
 
 
 
62
  })
63
  });
64
 
65
  const hf = await r.json();
66
- const txt = extractFirstText(hf);
67
- const parsed = tryParseJson(txt);
 
 
 
68
  ai = parsed
69
- ? { ok: true, model, parsed, raw: hf }
70
  : { ok: false, model, error: "AI output was not valid JSON.", raw: hf };
71
  }
72
 
 
6
  try { return JSON.parse(text); } catch { return null; }
7
  }
8
 
 
 
 
 
 
 
9
  function tryParseJson(text: string): any | null {
10
  const match = text.match(/\{[\s\S]*\}/);
11
  if (!match) return null;
 
26
  );
27
  }
28
 
29
+ // Optional AI narrative scoring for the scenario (occupational assessment only; not clinical diagnosis)
30
  let ai: any = null;
31
  const hfToken = process.env.HF_TOKEN;
32
  const model = process.env.HF_MODEL || "HuggingFaceH4/zephyr-7b-beta";
 
44
  ${textScenario}
45
  `;
46
 
47
+ // Hugging Face Inference Providers routing proxy (OpenAI-compatible) citeturn4view0
48
+ const r = await fetch("https://router.huggingface.co/hf-inference/v1/chat/completions", {
49
  method: "POST",
50
  headers: {
51
  "Authorization": `Bearer ${hfToken}`,
52
  "Content-Type": "application/json"
53
  },
54
  body: JSON.stringify({
55
+ model,
56
+ messages: [{ role: "user", content: prompt }],
57
+ max_tokens: 350,
58
+ temperature: 0.2,
59
+ stream: false
60
  })
61
  });
62
 
63
  const hf = await r.json();
64
+
65
+ // OpenAI-like response: choices[0].message.content
66
+ const content = hf?.choices?.[0]?.message?.content ?? "";
67
+ const parsed = typeof content === "string" ? tryParseJson(content) : null;
68
+
69
  ai = parsed
70
+ ? { ok: true, model, parsed }
71
  : { ok: false, model, error: "AI output was not valid JSON.", raw: hf };
72
  }
73
 
src/app/globals.css CHANGED
@@ -2,5 +2,18 @@
2
  @tailwind components;
3
  @tailwind utilities;
4
 
5
- html, body { height: 100%; }
6
- body { margin: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  @tailwind components;
3
  @tailwind utilities;
4
 
5
+ @keyframes floatSlow {
6
+ 0%, 100% { transform: translate(0, 0) scale(1); }
7
+ 50% { transform: translate(18px, 14px) scale(1.03); }
8
+ }
9
+ @keyframes floatSlow2 {
10
+ 0%, 100% { transform: translate(0, 0) scale(1); }
11
+ 50% { transform: translate(-16px, 10px) scale(1.02); }
12
+ }
13
+ @keyframes floatSlow3 {
14
+ 0%, 100% { transform: translate(-50%, 0) scale(1); }
15
+ 50% { transform: translate(-50%, -12px) scale(1.03); }
16
+ }
17
+ .animate-floatSlow { animation: floatSlow 9s ease-in-out infinite; }
18
+ .animate-floatSlow2 { animation: floatSlow2 11s ease-in-out infinite; }
19
+ .animate-floatSlow3 { animation: floatSlow3 13s ease-in-out infinite; }
src/components/assessment/AssessmentApp.tsx CHANGED
@@ -1,555 +1,448 @@
1
  "use client";
2
 
3
- import * as React from "react";
4
  import Image from "next/image";
5
- import { AnimatePresence, motion } from "framer-motion";
6
- import { ArrowLeft, ArrowRight, ClipboardList, ShieldCheck, Timer, Send, Lock, CheckCircle2 } from "lucide-react";
7
- import { Button } from "@/components/ui/Button";
8
- import { Progress } from "@/components/ui/Progress";
9
- import { RadioGroup, RadioItem } from "@/components/ui/RadioGroup";
10
- import { SECTIONS, TOTAL_SECONDS, MIN_SUBMIT_SECONDS, SectionKey } from "@/lib/sections";
11
- import { selectExamQuestions, SelectedQuestion } from "@/lib/questions";
12
- import { scoreExam, CandidateMeta, AnswerMap } from "@/lib/scoring";
13
-
14
- const STORAGE_KEY = "sgs_psycho_af_v2";
15
-
16
- function clamp(n:number,a:number,b:number){ return Math.max(a, Math.min(b, n)); }
17
- function fmtTime(s:number){
18
- const ss=Math.max(0, s);
19
- const hh = Math.floor(ss/3600);
20
- const mm = Math.floor((ss%3600)/60);
21
- const rr = Math.floor(ss%60);
22
- if (hh > 0) return `${String(hh).padStart(2,"0")}:${String(mm).padStart(2,"0")}:${String(rr).padStart(2,"0")}`;
23
- return `${String(mm).padStart(2,"0")}:${String(rr).padStart(2,"0")}`;
24
- }
25
 
26
- type SectionState = {
27
- sectionIndex: number; // 0..5 current section
28
- elapsedInSection: number; // seconds used in current section
29
- carrySeconds: number; // carried from previous sections
30
- completedSections: SectionKey[]; // locked
31
- };
32
-
33
- export function AssessmentApp() {
34
- const [meta, setMeta] = React.useState<CandidateMeta>({ candidateName:"", candidateId:"", jobTitle:"" });
35
-
36
- const [started, setStarted] = React.useState(false);
37
- const [startTs, setStartTs] = React.useState<number>(0);
38
-
39
- const [sectionState, setSectionState] = React.useState<SectionState>({
40
- sectionIndex: 0,
41
- elapsedInSection: 0,
42
- carrySeconds: 0,
43
- completedSections: []
44
- });
45
-
46
- const [selected, setSelected] = React.useState<SelectedQuestion[]>([]);
47
- const [answers, setAnswers] = React.useState<AnswerMap>({});
48
-
49
- const [qIndexWithin, setQIndexWithin] = React.useState(0);
50
- const [submitting, setSubmitting] = React.useState(false);
51
- const [submitted, setSubmitted] = React.useState(false);
52
- const [submitError, setSubmitError] = React.useState<string>("");
53
-
54
- // anti-refresh (best-effort)
55
- React.useEffect(() => {
56
- function beforeUnload(e: BeforeUnloadEvent) {
57
- if (started && !submitted) { e.preventDefault(); e.returnValue = ""; }
58
- }
59
- function keydown(e: KeyboardEvent) {
60
- if (!started || submitted) return;
61
- const k = e.key.toLowerCase();
62
- const ctrl = e.ctrlKey || e.metaKey;
63
- if (e.key === "F5" || (ctrl && k === "r")) { e.preventDefault(); e.stopPropagation(); }
64
- }
65
- window.addEventListener("beforeunload", beforeUnload);
66
- window.addEventListener("keydown", keydown, { capture: true });
67
- return () => {
68
- window.removeEventListener("beforeunload", beforeUnload);
69
- window.removeEventListener("keydown", keydown, { capture: true } as any);
70
- };
71
- }, [started, submitted]);
72
-
73
- // restore
74
- React.useEffect(() => {
75
- try {
76
- const raw = localStorage.getItem(STORAGE_KEY);
77
- if (!raw) return;
78
- const s = JSON.parse(raw);
79
- if (s?.meta) setMeta(s.meta);
80
- if (typeof s?.started === "boolean") setStarted(s.started);
81
- if (typeof s?.startTs === "number") setStartTs(s.startTs);
82
- if (s?.sectionState) setSectionState(s.sectionState);
83
- if (Array.isArray(s?.selected)) setSelected(s.selected);
84
- if (s?.answers) setAnswers(s.answers);
85
- if (typeof s?.qIndexWithin === "number") setQIndexWithin(s.qIndexWithin);
86
- if (typeof s?.submitted === "boolean") setSubmitted(s.submitted);
87
- } catch {}
88
- }, []);
89
-
90
- // persist
91
- React.useEffect(() => {
92
- try {
93
- localStorage.setItem(STORAGE_KEY, JSON.stringify({
94
- meta, started, startTs, sectionState, selected, answers, qIndexWithin, submitted
95
- }));
96
- } catch {}
97
- }, [meta, started, startTs, sectionState, selected, answers, qIndexWithin, submitted]);
98
-
99
- // derive current section key and questions
100
- const currentSection = SECTIONS[sectionState.sectionIndex];
101
- const currentKey = currentSection?.key ?? "A";
102
-
103
- const questionsInCurrent = React.useMemo(() => selected.filter(q => q.section === currentKey), [selected, currentKey]);
104
- const q = questionsInCurrent[qIndexWithin];
105
-
106
- // section budget remaining (does NOT borrow from future; carry only from previous)
107
- const sectionBudget = (currentSection?.allocationSeconds ?? 0) + sectionState.carrySeconds;
108
- const remainingInSection = Math.max(0, sectionBudget - sectionState.elapsedInSection);
109
-
110
- const elapsedTotal = started ? Math.floor((Date.now() - startTs) / 1000) : 0;
111
- const remainingTotal = Math.max(0, TOTAL_SECONDS - elapsedTotal);
112
-
113
- const answeredCount = React.useMemo(() => {
114
- let c = 0;
115
- for (const qq of selected) if ((answers[qq.baseId] ?? "").trim()) c++;
116
- return c;
117
- }, [selected, answers]);
118
- const progressPct = selected.length ? Math.round((answeredCount / selected.length) * 100) : 0;
119
-
120
- // ticking timer
121
- React.useEffect(() => {
122
- if (!started || submitted) return;
123
- const t = setInterval(() => {
124
- setSectionState(prev => {
125
- // increment elapsed in section by 1, but cap by budget
126
- if (remainingTotal <= 0) return prev;
127
- const nextElapsed = prev.elapsedInSection + 1;
128
- return { ...prev, elapsedInSection: nextElapsed };
129
- });
130
- }, 1000);
131
- return () => clearInterval(t);
132
- }, [started, submitted, remainingTotal]);
133
-
134
- // if section time runs out, auto-finish section (carry = 0)
135
- React.useEffect(() => {
136
- if (!started || submitted) return;
137
- if (remainingTotal <= 0) {
138
- // end test - allow submit (but min 20 minutes rule still applies); we will auto-submit
139
- void submitExam(true);
140
- return;
141
- }
142
- if (currentSection && remainingInSection === 0) {
143
- finishSection(true);
144
- }
145
- // eslint-disable-next-line react-hooks/exhaustive-deps
146
- }, [remainingInSection, remainingTotal, started, submitted]);
147
-
148
- const startDisabled = !meta.candidateName.trim() || !meta.candidateId.trim() || !meta.jobTitle.trim();
149
-
150
- function startExam() {
151
- if (startDisabled) return;
152
- const seed = `${meta.candidateId}-${Date.now()}`;
153
- const sel = selectExamQuestions(seed);
154
- setSelected(sel);
155
- setAnswers({});
156
- setQIndexWithin(0);
157
- setSectionState({ sectionIndex: 0, elapsedInSection: 0, carrySeconds: 0, completedSections: [] });
158
- setStarted(true);
159
- setSubmitted(false);
160
- setSubmitError("");
161
- setSubmitting(false);
162
- setStartTs(Date.now());
163
- }
164
 
165
- function resetAll() {
166
- setMeta({ candidateName:"", candidateId:"", jobTitle:"" });
167
- setStarted(false);
168
- setSubmitted(false);
169
- setSubmitError("");
170
- setSubmitting(false);
171
- setSelected([]);
172
- setAnswers({});
173
- setQIndexWithin(0);
174
- setSectionState({ sectionIndex: 0, elapsedInSection: 0, carrySeconds: 0, completedSections: [] });
175
- setStartTs(0);
176
- try { localStorage.removeItem(STORAGE_KEY); } catch {}
177
- }
178
 
179
- function setAnswer(baseId: string, value: string) {
180
- setAnswers(prev => ({ ...prev, [baseId]: value }));
181
- }
182
 
183
- function canMoveWithin(nextIndex: number) {
184
- return nextIndex >= 0 && nextIndex < questionsInCurrent.length;
185
- }
186
 
187
- function nextWithin() {
188
- const next = qIndexWithin + 1;
189
- if (canMoveWithin(next)) setQIndexWithin(next);
190
- }
191
- function prevWithin() {
192
- const prev = qIndexWithin - 1;
193
- if (canMoveWithin(prev)) setQIndexWithin(prev);
194
- }
195
 
196
- function sectionComplete(): boolean {
197
- // must answer every question in current section (including text)
198
- for (const qq of questionsInCurrent) {
199
- const a = (answers[qq.baseId] ?? "").trim();
200
- if (!a) return false;
201
- }
202
- return true;
203
- }
204
 
205
- function finishSection(auto=false) {
206
- if (!currentSection) return;
207
- if (!auto && !sectionComplete()) return;
208
 
209
- setSectionState(prev => {
210
- const finishedKey = currentSection.key;
211
- const leftover = Math.max(0, sectionBudget - prev.elapsedInSection); // carry forward
212
- const nextIndex = prev.sectionIndex + 1;
213
 
214
- const completed = prev.completedSections.includes(finishedKey)
215
- ? prev.completedSections
216
- : [...prev.completedSections, finishedKey];
217
 
218
- if (nextIndex >= SECTIONS.length) {
219
- // last section finished -> allow submit
220
- return { ...prev, completedSections: completed, carrySeconds: 0, elapsedInSection: prev.elapsedInSection };
 
 
 
 
 
 
 
 
 
221
  }
222
- return {
223
- sectionIndex: nextIndex,
224
- elapsedInSection: 0,
225
- carrySeconds: auto ? 0 : leftover,
226
- completedSections: completed
227
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
- setQIndexWithin(0);
231
- }
 
 
 
 
232
 
233
- async function submitExam(auto=false) {
234
- if (submitting || submitted) return;
235
 
236
- // validity rule: must spend at least 20 minutes before submission accepted (unless time ended and >20 min still required)
237
- if (!auto && elapsedTotal < MIN_SUBMIT_SECONDS) {
238
- setSubmitError("This assessment requires a minimum of 20 minutes to ensure validity. Please continue until 20 minutes have passed.");
239
  return;
240
  }
241
-
242
- // ensure all sections completed if manual submit
243
- if (!auto) {
244
- // must be at last section and complete it
245
- const lastKey = "F";
246
- if (currentKey !== lastKey || !sectionComplete()) {
247
- setSubmitError("Please complete the current section before submitting.");
248
- return;
249
- }
250
  }
251
 
252
- setSubmitting(true);
253
- setSubmitError("");
254
-
255
- try {
256
- // internal scoring (hidden)
257
- const scored = scoreExam(selected, answers);
258
-
259
- // AI narrative analysis only for the text scenario (B_TEXT01) if HF_TOKEN exists
260
- const textAnswer = (answers["B_TEXT01"] ?? "").trim();
261
-
262
- const payload = {
263
- submittedAt: new Date().toISOString(),
264
- candidate: meta,
265
- elapsedSeconds: elapsedTotal,
266
- progressPct,
267
- scores: scored,
268
- // include answers? keep lightweight to avoid large webhook payload; include only text + a few flags
269
- responses: {
270
- textScenario: textAnswer
271
- },
272
- examMeta: {
273
- version: "2.0.0",
274
- questionCount: selected.length
275
- }
276
- };
277
-
278
- const r = await fetch("/api/submit", {
279
- method: "POST",
280
- headers: { "Content-Type":"application/json" },
281
- body: JSON.stringify(payload)
282
- });
283
-
284
- const data = await r.json();
285
- if (!data?.ok) {
286
- throw new Error(data?.error || "Submission failed");
287
- }
288
 
289
- setSubmitted(true);
290
- setSubmitting(false);
291
- } catch (e: any) {
292
- setSubmitting(false);
293
- setSubmitError(e?.message ?? "Submission failed");
 
 
 
 
 
 
 
 
294
  }
295
- }
296
 
297
  // UI helpers
298
- const sectionProgress = questionsInCurrent.length
299
- ? Math.round(questionsInCurrent.reduce((c,qq)=>c+(((answers[qq.baseId]??"").trim())?1:0),0) / questionsInCurrent.length * 100)
300
- : 0;
301
-
302
- return (
303
- <div className="relative min-h-screen overflow-hidden">
304
- <div className="pointer-events-none absolute inset-0">
305
- <motion.div
306
- className="absolute -top-24 left-1/2 h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-gradient-to-br from-green-200 via-sky-200 to-indigo-200 blur-3xl opacity-60"
307
- animate={{ y: [0, 18, 0], scale: [1, 1.03, 1] }}
308
- transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
309
- />
310
- <motion.div
311
- className="absolute -bottom-40 -left-40 h-[520px] w-[520px] rounded-full bg-gradient-to-br from-amber-200 via-rose-200 to-fuchsia-200 blur-3xl opacity-50"
312
- animate={{ x: [0, 20, 0], rotate: [0, 8, 0] }}
313
- transition={{ duration: 10, repeat: Infinity, ease: "easeInOut" }}
314
- />
 
 
 
315
  </div>
 
 
316
 
317
- <header className="relative mx-auto max-w-6xl px-6 pt-8">
318
- <div className="flex flex-col gap-4 rounded-3xl border bg-white/70 p-6 shadow-sm backdrop-blur">
319
- <div className="flex flex-wrap items-center justify-between gap-4">
320
- <div className="flex items-start gap-4">
321
- <div className="mt-1">
322
- <Image src="/sgs-logo.png" alt="SGS" width={160} height={46} priority />
323
- </div>
324
- <div>
325
- <div className="flex items-center gap-2 text-sm text-neutral-600">
326
- <ShieldCheck className="h-4 w-4" />
327
- <span>Saudi Ground Services (SGS)</span>
 
 
 
 
 
 
 
 
328
  </div>
329
- <h1 className="mt-1 text-2xl font-semibold tracking-tight">Psychometrics Assessment</h1>
330
- <p className="mt-1 max-w-2xl text-sm text-neutral-600">
331
- This is an occupational assessment (not a medical diagnosis). Please answer honestly.
332
- </p>
333
  </div>
334
- </div>
335
 
336
- <div className="flex items-center gap-3">
337
- {started && !submitted ? (
338
- <>
339
- <div className="flex items-center gap-2 rounded-2xl border bg-white px-3 py-2 text-sm text-neutral-700">
340
- <Timer className="h-4 w-4" />
341
- <span className={remainingTotal <= 60 ? "text-rose-600 font-semibold" : ""}>{fmtTime(remainingTotal)}</span>
342
- </div>
343
- <div className="flex items-center gap-2 rounded-2xl border bg-white px-3 py-2 text-sm text-neutral-700">
344
- <Lock className="h-4 w-4" />
345
- <span>Section time: {fmtTime(remainingInSection)}</span>
346
- </div>
347
- </>
348
- ) : null}
349
-
350
- {!started ? (
351
- <Button onClick={startExam} disabled={startDisabled}>Start Test</Button>
352
- ) : (
353
- <div className="flex items-center gap-2 rounded-2xl border bg-white px-3 py-2 text-sm text-neutral-700">
354
- <ClipboardList className="h-4 w-4" />
355
- <span>{progressPct}% complete</span>
356
  </div>
357
- )}
358
  </div>
359
- </div>
360
 
361
- <div className="flex items-center gap-3">
362
- <Progress value={started ? progressPct : 0} />
363
- <span className="w-12 text-right text-xs text-neutral-600">{started ? progressPct : 0}%</span>
364
- </div>
365
- </div>
366
- </header>
367
-
368
- <main className="relative mx-auto max-w-6xl px-6 pb-16 pt-8">
369
- <div className="grid gap-6 lg:grid-cols-[360px_1fr]">
370
- <aside className="rounded-3xl border bg-white/70 p-5 shadow-sm backdrop-blur">
371
- <h2 className="text-sm font-semibold text-neutral-800">Candidate</h2>
372
- <div className="mt-3 grid gap-3">
373
- <Field label="Name" value={meta.candidateName} onChange={(v)=>setMeta(m=>({...m,candidateName:v}))} disabled={started} placeholder="Full name" />
374
- <Field label="Candidate ID" value={meta.candidateId} onChange={(v)=>setMeta(m=>({...m,candidateId:v}))} disabled={started} placeholder="e.g., SGS-000123" />
375
- <Field label="Job Title" value={meta.jobTitle} onChange={(v)=>setMeta(m=>({...m,jobTitle:v}))} disabled={started} placeholder="Role applied for" />
376
  </div>
 
377
 
378
- <div className="mt-5 rounded-2xl border bg-white p-4">
379
- <div className="flex items-center justify-between">
380
- <div>
381
- <div className="text-xs text-neutral-500">Current Section</div>
382
- <div className="mt-1 text-base font-semibold">{currentSection?.key}. {currentSection?.title}</div>
 
383
  </div>
384
- {started && !submitted ? (
385
- <div className="text-xs text-neutral-600">{sectionProgress}% done</div>
386
- ) : null}
 
 
387
  </div>
388
- <div className="mt-3 text-xs text-neutral-600">{currentSection?.description}</div>
389
-
390
- <div className="mt-4 grid gap-2 text-xs text-neutral-700">
391
- {SECTIONS.map((s, idx) => {
392
- const done = sectionState.completedSections.includes(s.key);
393
- const active = idx === sectionState.sectionIndex && started && !submitted;
394
- return (
395
- <div key={s.key} className={"flex items-center justify-between rounded-xl border px-3 py-2 " + (active ? "bg-neutral-900 text-white border-neutral-900" : "bg-white")}>
396
- <span className="font-medium">{s.key}. {s.title}</span>
397
- {done ? <CheckCircle2 className={"h-4 w-4 " + (active ? "text-white" : "text-green-700")} /> : <span className={active ? "text-white/80" : "text-neutral-500"}>{Math.round(s.allocationSeconds/60)}m</span>}
398
- </div>
399
- );
400
- })}
401
  </div>
402
  </div>
403
 
404
- <div className="mt-4 flex flex-wrap gap-2">
405
- <Button onClick={resetAll}>Reset</Button>
406
  </div>
407
 
408
- {submitError ? (
409
- <div className="mt-4 rounded-2xl border bg-white p-4 text-sm text-rose-700">
410
- {submitError}
411
- </div>
412
- ) : null}
413
- </aside>
414
-
415
- <section className="rounded-3xl border bg-white/70 p-5 shadow-sm backdrop-blur">
416
- {!started ? (
417
- <div className="rounded-2xl border bg-white p-6">
418
- <h3 className="text-lg font-semibold">Instructions</h3>
419
- <ul className="mt-2 list-disc pl-5 text-sm text-neutral-700">
420
- <li>Total duration: 60 minutes. Section time carries forward if you finish early.</li>
421
- <li>You can move back/forward inside the current section only.</li>
422
- <li>Minimum valid duration before submission: 20 minutes.</li>
423
- <li>Please avoid refreshing the page during the assessment.</li>
424
- </ul>
425
- </div>
426
- ) : submitted ? (
427
- <div className="rounded-2xl border bg-white p-8 text-center">
428
- <div className="mx-auto grid h-12 w-12 place-items-center rounded-2xl bg-green-50">
429
- <CheckCircle2 className="h-7 w-7 text-green-700" />
430
- </div>
431
- <h3 className="mt-4 text-xl font-semibold">Thank you for completing the assessment.</h3>
432
- <p className="mt-2 text-sm text-neutral-600">You may now close this page.</p>
433
- </div>
434
- ) : (
435
- <div className="grid gap-4">
436
- <AnimatePresence mode="wait">
437
- <motion.div
438
- key={q?.baseId ?? "none"}
439
- initial={{ opacity: 0, y: 10, filter: "blur(6px)" }}
440
- animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
441
- exit={{ opacity: 0, y: -10, filter: "blur(6px)" }}
442
- transition={{ duration: 0.22 }}
443
- className="rounded-2xl border bg-white p-6"
444
- >
445
- <div className="flex flex-wrap items-center justify-between gap-3">
446
- <div>
447
- <div className="text-xs text-neutral-500">
448
- Section: <span className="text-neutral-700">{currentSection.key}. {currentSection.title}</span>
449
- </div>
450
- <h3 className="mt-1 text-lg font-semibold">Question {qIndexWithin + 1} of {questionsInCurrent.length}</h3>
451
- <p className="mt-2 text-sm text-neutral-700">{q?.prompt}</p>
452
- </div>
453
- <div className="rounded-2xl border bg-neutral-50 px-3 py-2 text-xs text-neutral-700">
454
- Section progress: {sectionProgress}%
455
- </div>
456
- </div>
457
-
458
- <div className="mt-6">
459
- {q ? (
460
- <QuestionRenderer
461
- q={q}
462
- value={answers[q.baseId] ?? ""}
463
- onChange={(v)=>setAnswer(q.baseId, v)}
464
- />
465
- ) : null}
466
- </div>
467
- </motion.div>
468
- </AnimatePresence>
469
-
470
- <div className="flex flex-wrap items-center justify-between gap-3">
471
- <Button onClick={prevWithin} disabled={qIndexWithin === 0}>
472
- <ArrowLeft className="mr-2 h-4 w-4" /> Back
473
- </Button>
474
-
475
- <div className="flex items-center gap-2">
476
- <Button onClick={nextWithin} disabled={qIndexWithin >= questionsInCurrent.length - 1}>
477
- Next <ArrowRight className="ml-2 h-4 w-4" />
478
- </Button>
479
-
480
- <Button
481
- onClick={() => finishSection(false)}
482
- disabled={!sectionComplete()}
483
- className="bg-green-700 hover:bg-green-600 active:bg-green-800"
484
- title={!sectionComplete() ? "Answer all questions in this section to finish it" : "Finish this section"}
485
  >
486
  Finish Section
487
- </Button>
488
-
489
- {currentSection.key === "F" ? (
490
- <Button
491
- onClick={() => submitExam(false)}
492
- disabled={submitting || !sectionComplete()}
493
- className="bg-indigo-700 hover:bg-indigo-600 active:bg-indigo-800"
494
- title={elapsedTotal < MIN_SUBMIT_SECONDS ? "Must reach 20 minutes before submitting" : "Submit"}
495
- >
496
- Submit <Send className="ml-2 h-4 w-4" />
497
- </Button>
498
- ) : null}
499
- </div>
500
- </div>
501
 
502
- {elapsedTotal < MIN_SUBMIT_SECONDS ? (
503
- <div className="rounded-2xl border bg-white p-4 text-sm text-neutral-700">
504
- Submission becomes available after <span className="font-semibold">20 minutes</span>. Elapsed: <span className="font-semibold">{fmtTime(elapsedTotal)}</span>.
505
- </div>
506
- ) : null}
507
  </div>
508
  )}
509
- </section>
510
  </div>
511
- </main>
512
  </div>
513
  );
514
  }
515
 
516
- function Field({ label, value, onChange, disabled, placeholder }: {label:string; value:string; onChange:(v:string)=>void; disabled?:boolean; placeholder?:string;}) {
517
  return (
518
- <label className="grid gap-1">
519
- <span className="text-xs text-neutral-600">{label}</span>
520
- <input
521
- value={value}
522
- onChange={(e)=>onChange(e.target.value)}
523
- disabled={disabled}
524
- placeholder={placeholder}
525
- className="w-full rounded-2xl border bg-white px-4 py-2 text-sm outline-none transition focus:ring-2 focus:ring-neutral-300 disabled:opacity-60"
526
- />
527
- </label>
 
 
 
 
 
 
 
528
  );
529
  }
530
 
531
- function QuestionRenderer({
532
- q,
533
- value,
534
- onChange
535
- }: {
536
- q: SelectedQuestion;
537
- value: string;
538
- onChange: (v: string) => void;
539
- }) {
540
- if (q.type === "likert" || q.type === "mcq") {
541
- return (
542
- <RadioGroup value={value} onValueChange={onChange}>
543
- {(q.options ?? []).map((opt) => <RadioItem key={opt} value={opt} label={opt} />)}
544
- </RadioGroup>
545
- );
546
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  return (
548
- <textarea
549
- value={value}
550
- onChange={(e)=>onChange(e.target.value)}
551
- className="min-h-[160px] w-full rounded-2xl border bg-white px-4 py-3 text-sm outline-none transition focus:ring-2 focus:ring-neutral-300"
552
- placeholder="Write your response..."
553
- />
 
 
 
 
 
554
  );
555
  }
 
 
 
 
 
 
 
1
  "use client";
2
 
 
3
  import Image from "next/image";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
+ import { CandidateForm, CandidateMeta } from "./CandidateForm";
6
+ import { ExamQuestion, SECTIONS, SectionKey, buildExam } from "@/lib/questions";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
+ type ResponseValue = number | string; // likert: 1..5, mcq: option id, text: string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
+ function nowMs() { return Date.now(); }
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
+ const MIN_SUBMIT_MS = 20 * 60 * 1000;
 
 
13
 
14
+ export default function AssessmentApp() {
15
+ const [meta, setMeta] = useState<CandidateMeta | null>(null);
 
16
 
17
+ // Build exam once per session; seed includes a random component
18
+ const seedRef = useRef<string>(Math.random().toString(36).slice(2));
19
+ const { examBySection, flat } = useMemo(() => buildExam(seedRef.current), []);
 
 
 
 
 
20
 
21
+ const totalQuestions = flat.length;
22
+
23
+ const [sectionIdx, setSectionIdx] = useState(0);
24
+ const currentSection = SECTIONS[sectionIdx];
25
+ const sectionKey = currentSection.key;
26
+
27
+ const [qIdx, setQIdx] = useState(0);
 
28
 
29
+ const sectionQuestions: ExamQuestion[] = examBySection[sectionKey];
30
+ const currentQ = sectionQuestions[qIdx];
 
31
 
32
+ // Responses stored by baseId to avoid duplicates perception
33
+ const [responses, setResponses] = useState<Record<string, ResponseValue>>({});
 
 
34
 
35
+ // Timer logic with carry-over
36
+ const [startTs, setStartTs] = useState<number | null>(null);
37
+ const [elapsedMs, setElapsedMs] = useState(0);
38
 
39
+ const sectionTimeSpent = useRef<Record<SectionKey, number>>({ A: 0, B: 0, C: 0, D: 0, E: 0, F: 0 });
40
+ const sectionStartMs = useRef<number | null>(null);
41
+ const carryMs = useRef<number>(0);
42
+
43
+ const [sectionRemainingMs, setSectionRemainingMs] = useState(currentSection.minutes * 60 * 1000);
44
+
45
+ // Anti-refresh: warn on unload + persist minimal state
46
+ useEffect(() => {
47
+ const handler = (e: BeforeUnloadEvent) => {
48
+ if (startTs) {
49
+ e.preventDefault();
50
+ e.returnValue = "";
51
  }
52
+ };
53
+ window.addEventListener("beforeunload", handler);
54
+ return () => window.removeEventListener("beforeunload", handler);
55
+ }, [startTs]);
56
+
57
+ useEffect(() => {
58
+ if (!startTs) return;
59
+ const t = setInterval(() => {
60
+ setElapsedMs(nowMs() - startTs);
61
+ // Update remaining for current section (with carry)
62
+ const spentThisSection = sectionStartMs.current ? nowMs() - sectionStartMs.current : 0;
63
+ const budget = currentSection.minutes * 60 * 1000 + carryMs.current;
64
+ const remaining = Math.max(0, budget - spentThisSection);
65
+ setSectionRemainingMs(remaining);
66
+ }, 250);
67
+ return () => clearInterval(t);
68
+ }, [startTs, currentSection.minutes]);
69
+
70
+ const answeredCurrent = useMemo(() => {
71
+ const v = responses[currentQ.baseId];
72
+ if (currentQ.type === "text") return typeof v === "string" && v.trim().length >= (currentQ.minChars ?? 0);
73
+ return v !== undefined && v !== null && v !== "";
74
+ }, [responses, currentQ]);
75
+
76
+ const answeredCount = useMemo(() => Object.keys(responses).length, [responses]);
77
+ const progressPct = useMemo(() => Math.round((answeredCount / totalQuestions) * 100), [answeredCount, totalQuestions]);
78
+
79
+ const currentSectionAnsweredCount = useMemo(() => {
80
+ return sectionQuestions.filter(q => responses[q.baseId] !== undefined && responses[q.baseId] !== "").length;
81
+ }, [sectionQuestions, responses]);
82
+
83
+ const canGoPrev = qIdx > 0;
84
+ const canGoNext = qIdx < sectionQuestions.length - 1;
85
+
86
+ // Cannot skip unanswered: next is disabled unless current answered.
87
+ const nextDisabled = !answeredCurrent;
88
+
89
+ // Prevent going forward past first unanswered question even if user clicked quickly
90
+ const firstUnansweredIndex = useMemo(() => {
91
+ const idx = sectionQuestions.findIndex(q => {
92
+ const v = responses[q.baseId];
93
+ if (q.type === "text") return !(typeof v === "string" && v.trim().length >= (q.minChars ?? 0));
94
+ return v === undefined || v === null || v === "";
95
  });
96
+ return idx === -1 ? sectionQuestions.length - 1 : idx;
97
+ }, [sectionQuestions, responses]);
98
+
99
+ const setAnswer = (val: ResponseValue) => {
100
+ setResponses(prev => ({ ...prev, [currentQ.baseId]: val }));
101
+ };
102
+
103
+ const startAssessment = (m: CandidateMeta) => {
104
+ setMeta(m);
105
+ const ts = nowMs();
106
+ setStartTs(ts);
107
+ sectionStartMs.current = ts;
108
+ carryMs.current = 0;
109
+ setSectionRemainingMs(currentSection.minutes * 60 * 1000);
110
+ };
111
+
112
+ const gotoPrev = () => {
113
+ if (!canGoPrev) return;
114
+ setQIdx(i => Math.max(0, i - 1));
115
+ };
116
+
117
+ const gotoNext = () => {
118
+ if (!canGoNext || nextDisabled) return;
119
+ // Hard clamp: cannot pass first unanswered
120
+ setQIdx(i => {
121
+ const next = Math.min(sectionQuestions.length - 1, i + 1);
122
+ return Math.min(next, firstUnansweredIndex);
123
+ });
124
+ };
125
+
126
+ const finishSection = () => {
127
+ // must have all answered in section
128
+ const allAnswered = currentSectionAnsweredCount === sectionQuestions.length;
129
+ if (!allAnswered) return;
130
+
131
+ // compute carry-over
132
+ const spent = sectionStartMs.current ? nowMs() - sectionStartMs.current : 0;
133
+ const budget = currentSection.minutes * 60 * 1000 + carryMs.current;
134
+ const remaining = Math.max(0, budget - spent);
135
+
136
+ sectionTimeSpent.current[sectionKey] = spent;
137
+
138
+ // advance section
139
+ if (sectionIdx < SECTIONS.length - 1) {
140
+ setSectionIdx(s => s + 1);
141
+ setQIdx(0);
142
+ carryMs.current = remaining;
143
+ sectionStartMs.current = nowMs();
144
+ // remaining will refresh in interval
145
+ }
146
+ };
147
 
148
+ const canSubmit = useMemo(() => {
149
+ if (!startTs) return false;
150
+ if (elapsedMs < MIN_SUBMIT_MS) return false;
151
+ // all answered
152
+ return answeredCount >= totalQuestions;
153
+ }, [startTs, elapsedMs, answeredCount, totalQuestions]);
154
 
155
+ const submit = async () => {
156
+ if (!meta || !startTs) return;
157
 
158
+ if (elapsedMs < MIN_SUBMIT_MS) {
159
+ alert("You can submit the assessment only after 20 minutes have passed.");
 
160
  return;
161
  }
162
+ if (answeredCount < totalQuestions) {
163
+ alert("Please answer all questions before submitting.");
164
+ return;
 
 
 
 
 
 
165
  }
166
 
167
+ const payload = {
168
+ meta: {
169
+ fullName: meta.fullName,
170
+ jobTitle: meta.jobTitle,
171
+ passportNumber: meta.passportNumber
172
+ },
173
+ seed: seedRef.current,
174
+ startedAt: new Date(startTs).toISOString(),
175
+ submittedAt: new Date().toISOString(),
176
+ elapsedMs,
177
+ responses,
178
+ // include prompts used (for audit)
179
+ questions: flat.map(q => ({ baseId: q.baseId, section: q.section, type: q.type, prompt: q.prompt }))
180
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
+ const r = await fetch("/api/submit", {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/json" },
185
+ body: JSON.stringify(payload)
186
+ });
187
+
188
+ const j = await r.json();
189
+
190
+ // Candidate should NOT see results—just thank you
191
+ if (j?.ok) {
192
+ window.location.href = "/thank-you";
193
+ } else {
194
+ alert("Submission failed. Please try again or contact SGS recruitment.");
195
  }
196
+ };
197
 
198
  // UI helpers
199
+ const mmss = (ms: number) => {
200
+ const s = Math.max(0, Math.floor(ms / 1000));
201
+ const m = Math.floor(s / 60);
202
+ const r = s % 60;
203
+ return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
204
+ };
205
+
206
+ if (!meta) {
207
+ return (
208
+ <div className="min-h-screen relative overflow-hidden bg-zinc-50">
209
+ <Decor />
210
+ <div className="max-w-6xl mx-auto px-6 py-10">
211
+ <Header />
212
+ <div className="mt-10">
213
+ <CandidateForm onSubmit={startAssessment} />
214
+ </div>
215
+ <div className="mt-8 text-xs text-zinc-500 max-w-xl mx-auto text-center">
216
+ This assessment is for occupational screening and safety culture fit. It is not a medical diagnosis.
217
+ </div>
218
+ </div>
219
  </div>
220
+ );
221
+ }
222
 
223
+ return (
224
+ <div className="min-h-screen relative overflow-hidden bg-zinc-50">
225
+ <Decor />
226
+ <div className="max-w-6xl mx-auto px-6 py-10">
227
+ <Header />
228
+
229
+ <div className="mt-6 grid gap-4">
230
+ <div className="rounded-3xl border border-zinc-200 bg-white/70 backdrop-blur p-5 shadow-sm">
231
+ <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
232
+ <div className="flex items-center gap-3">
233
+ <div className="h-10 w-10 rounded-2xl bg-gradient-to-br from-emerald-500/15 to-sky-500/15 border border-zinc-200 grid place-items-center">
234
+ <span className="text-sm font-semibold text-zinc-700">{sectionKey}</span>
235
+ </div>
236
+ <div>
237
+ <div className="text-base font-semibold text-zinc-900">{currentSection.title}</div>
238
+ <div className="text-sm text-zinc-600">
239
+ Progress: <span className="font-medium text-zinc-900">{progressPct}%</span> •
240
+ Section: <span className="font-medium text-zinc-900">{currentSectionAnsweredCount}/{sectionQuestions.length}</span>
241
+ </div>
242
  </div>
 
 
 
 
243
  </div>
 
244
 
245
+ <div className="flex items-center gap-3">
246
+ <div className="rounded-2xl border border-zinc-200 bg-white px-4 py-2">
247
+ <div className="text-xs text-zinc-500">Section time left</div>
248
+ <div className="text-sm font-semibold text-zinc-900 tabular-nums">{mmss(sectionRemainingMs)}</div>
249
+ </div>
250
+ <div className="rounded-2xl border border-zinc-200 bg-white px-4 py-2">
251
+ <div className="text-xs text-zinc-500">Total elapsed</div>
252
+ <div className="text-sm font-semibold text-zinc-900 tabular-nums">{mmss(elapsedMs)}</div>
 
 
 
 
 
 
 
 
 
 
 
 
253
  </div>
254
+ </div>
255
  </div>
 
256
 
257
+ <div className="mt-4 h-2 w-full rounded-full bg-zinc-100 overflow-hidden">
258
+ <div
259
+ className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-sky-500 transition-all duration-500"
260
+ style={{ width: `${progressPct}%` }}
261
+ />
 
 
 
 
 
 
 
 
 
 
262
  </div>
263
+ </div>
264
 
265
+ <div className="rounded-3xl border border-zinc-200 bg-white/70 backdrop-blur p-6 shadow-sm">
266
+ <div className="flex items-start justify-between gap-4">
267
+ <div>
268
+ <div className="text-sm font-medium text-zinc-500">Question {qIdx + 1} of {sectionQuestions.length}</div>
269
+ <div className="mt-2 text-lg font-semibold text-zinc-900 leading-snug">
270
+ {currentQ.prompt}
271
  </div>
272
+ {!answeredCurrent && (
273
+ <div className="mt-2 text-sm text-rose-600">
274
+ Please answer to continue.
275
+ </div>
276
+ )}
277
  </div>
278
+ <div className="shrink-0 hidden sm:block">
279
+ <div className="h-12 w-12 rounded-2xl border border-zinc-200 bg-white grid place-items-center">
280
+ <span className="text-xs font-semibold text-zinc-700">{sectionKey}-{String(qIdx + 1).padStart(2,"0")}</span>
281
+ </div>
 
 
 
 
 
 
 
 
 
282
  </div>
283
  </div>
284
 
285
+ <div className="mt-5">
286
+ <QuestionBody q={currentQ} value={responses[currentQ.baseId]} onChange={setAnswer} />
287
  </div>
288
 
289
+ <div className="mt-6 flex items-center justify-between">
290
+ <button
291
+ onClick={gotoPrev}
292
+ disabled={!canGoPrev}
293
+ className="h-11 px-4 rounded-xl border border-zinc-200 bg-white text-zinc-900 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-zinc-50 transition"
294
+ >
295
+ Back
296
+ </button>
297
+
298
+ <div className="flex items-center gap-2">
299
+ {sectionIdx < SECTIONS.length - 1 ? (
300
+ <>
301
+ <button
302
+ onClick={gotoNext}
303
+ disabled={!canGoNext || nextDisabled}
304
+ className="h-11 px-4 rounded-xl bg-zinc-900 text-white disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-95 transition"
305
+ >
306
+ Next
307
+ </button>
308
+ <button
309
+ onClick={finishSection}
310
+ disabled={currentSectionAnsweredCount !== sectionQuestions.length}
311
+ className="h-11 px-4 rounded-xl border border-zinc-200 bg-white text-zinc-900 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-zinc-50 transition"
312
+ title="Finish the section when all questions are answered"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  >
314
  Finish Section
315
+ </button>
316
+ </>
317
+ ) : (
318
+ <button
319
+ onClick={submit}
320
+ disabled={!canSubmit}
321
+ className="h-11 px-5 rounded-xl bg-emerald-600 text-white disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-95 transition"
322
+ >
323
+ Submit
324
+ </button>
325
+ )}
326
+ </div>
327
+ </div>
 
328
 
329
+ {!canSubmit && sectionIdx === SECTIONS.length - 1 && (
330
+ <div className="mt-4 text-xs text-zinc-500">
331
+ Submission unlocks after <span className="font-semibold">20 minutes</span> and when all questions are answered.
 
 
332
  </div>
333
  )}
334
+ </div>
335
  </div>
336
+ </div>
337
  </div>
338
  );
339
  }
340
 
341
+ function Header() {
342
  return (
343
+ <div className="flex items-center justify-between">
344
+ <div className="flex items-center gap-3">
345
+ <div className="relative h-10 w-40">
346
+ <Image src="/sgs-logo.png" alt="SGS" fill className="object-contain" priority />
347
+ </div>
348
+ <div className="hidden md:block">
349
+ <div className="text-lg font-semibold text-zinc-900">Psychometrics Assessment</div>
350
+ <div className="text-sm text-zinc-600">Saudi Ground Services (SGS)</div>
351
+ </div>
352
+ </div>
353
+ <div className="hidden md:flex items-center gap-2 text-xs text-zinc-600">
354
+ <span className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-zinc-200 bg-white">
355
+ <span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
356
+ Secure session
357
+ </span>
358
+ </div>
359
+ </div>
360
  );
361
  }
362
 
363
+ function Decor() {
364
+ return (
365
+ <>
366
+ <div className="pointer-events-none absolute -top-24 -left-24 h-80 w-80 rounded-full bg-emerald-400/15 blur-3xl animate-floatSlow" />
367
+ <div className="pointer-events-none absolute top-40 -right-24 h-96 w-96 rounded-full bg-sky-400/15 blur-3xl animate-floatSlow2" />
368
+ <div className="pointer-events-none absolute bottom-0 left-1/2 -translate-x-1/2 h-96 w-[42rem] rounded-full bg-purple-400/10 blur-3xl animate-floatSlow3" />
369
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_30%_10%,rgba(16,185,129,0.12),transparent_45%),radial-gradient(circle_at_80%_30%,rgba(56,189,248,0.12),transparent_45%),radial-gradient(circle_at_60%_90%,rgba(168,85,247,0.10),transparent_55%)]" />
370
+ </>
371
+ );
372
+ }
373
+
374
+ function Likert({ value, onChange }: { value?: any; onChange: (n: number) => void }) {
375
+ const labels = ["Strongly disagree", "Disagree", "Neutral", "Agree", "Strongly agree"];
376
+ return (
377
+ <div className="grid gap-2">
378
+ <div className="grid grid-cols-1 sm:grid-cols-5 gap-2">
379
+ {labels.map((lab, i) => {
380
+ const v = i + 1;
381
+ const active = value === v;
382
+ return (
383
+ <button
384
+ key={lab}
385
+ onClick={() => onChange(v)}
386
+ className={[
387
+ "h-12 rounded-xl border transition",
388
+ active
389
+ ? "border-emerald-500 bg-emerald-500 text-white shadow-sm"
390
+ : "border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50"
391
+ ].join(" ")}
392
+ type="button"
393
+ >
394
+ <div className="text-xs font-semibold">{lab}</div>
395
+ </button>
396
+ );
397
+ })}
398
+ </div>
399
+ </div>
400
+ );
401
+ }
402
+
403
+ function MCQ({ q, value, onChange }: { q: ExamQuestion; value?: any; onChange: (s: string) => void }) {
404
+ return (
405
+ <div className="grid gap-2">
406
+ {(q.options || []).map((o) => {
407
+ const active = value === o.id;
408
+ return (
409
+ <button
410
+ key={o.id}
411
+ onClick={() => onChange(o.id)}
412
+ className={[
413
+ "w-full text-left p-4 rounded-2xl border transition",
414
+ active ? "border-sky-500 bg-sky-500/10" : "border-zinc-200 bg-white hover:bg-zinc-50"
415
+ ].join(" ")}
416
+ type="button"
417
+ >
418
+ <div className="text-sm font-medium text-zinc-900">{o.label}</div>
419
+ </button>
420
+ );
421
+ })}
422
+ </div>
423
+ );
424
+ }
425
+
426
+ function TextAnswer({ q, value, onChange }: { q: ExamQuestion; value?: any; onChange: (s: string) => void }) {
427
+ const v = typeof value === "string" ? value : "";
428
+ const min = q.minChars ?? 0;
429
  return (
430
+ <div className="grid gap-2">
431
+ <textarea
432
+ value={v}
433
+ onChange={(e) => onChange(e.target.value)}
434
+ className="min-h-[180px] w-full rounded-2xl border border-zinc-200 bg-white p-4 text-zinc-900 outline-none focus:ring-2 focus:ring-sky-500/30"
435
+ placeholder="Write your response here..."
436
+ />
437
+ <div className="text-xs text-zinc-500">
438
+ Minimum characters: <span className="font-semibold">{min}</span> • Current: <span className={v.trim().length >= min ? "font-semibold text-emerald-600" : "font-semibold text-rose-600"}>{v.trim().length}</span>
439
+ </div>
440
+ </div>
441
  );
442
  }
443
+
444
+ function QuestionBody({ q, value, onChange }: { q: ExamQuestion; value: any; onChange: (v: any) => void }) {
445
+ if (q.type === "likert") return <Likert value={value} onChange={onChange} />;
446
+ if (q.type === "mcq") return <MCQ q={q} value={value} onChange={onChange} />;
447
+ return <TextAnswer q={q} value={value} onChange={onChange} />;
448
+ }
src/components/assessment/CandidateForm.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+
5
+ export type CandidateMeta = {
6
+ fullName: string;
7
+ jobTitle: string;
8
+ passportNumber: string;
9
+ };
10
+
11
+ export function CandidateForm(props: {
12
+ onSubmit: (meta: CandidateMeta) => void;
13
+ }) {
14
+ const [fullName, setFullName] = useState("");
15
+ const [jobTitle, setJobTitle] = useState("");
16
+ const [passportNumber, setPassportNumber] = useState("");
17
+
18
+ const canStart = useMemo(() => {
19
+ return fullName.trim().length >= 3 && jobTitle.trim().length >= 2 && passportNumber.trim().length >= 4;
20
+ }, [fullName, jobTitle, passportNumber]);
21
+
22
+ return (
23
+ <div className="w-full max-w-xl mx-auto rounded-3xl border border-zinc-200 bg-white/70 backdrop-blur p-6 shadow-sm">
24
+ <div className="flex items-center gap-3 mb-4">
25
+ <div className="h-10 w-10 rounded-2xl bg-gradient-to-br from-emerald-500/15 to-sky-500/15 border border-zinc-200 grid place-items-center">
26
+ <span className="text-sm font-semibold text-zinc-700">SGS</span>
27
+ </div>
28
+ <div>
29
+ <div className="text-lg font-semibold text-zinc-900">Candidate Details</div>
30
+ <div className="text-sm text-zinc-600">Enter your information to begin the assessment.</div>
31
+ </div>
32
+ </div>
33
+
34
+ <div className="grid gap-4">
35
+ <label className="grid gap-1">
36
+ <span className="text-sm font-medium text-zinc-800">Full Name</span>
37
+ <input
38
+ value={fullName}
39
+ onChange={(e) => setFullName(e.target.value)}
40
+ className="h-11 rounded-xl border border-zinc-200 bg-white px-3 text-zinc-900 outline-none focus:ring-2 focus:ring-emerald-500/30"
41
+ placeholder="e.g., Khalid Al‑Nimri"
42
+ />
43
+ </label>
44
+
45
+ <label className="grid gap-1">
46
+ <span className="text-sm font-medium text-zinc-800">Job Title</span>
47
+ <input
48
+ value={jobTitle}
49
+ onChange={(e) => setJobTitle(e.target.value)}
50
+ className="h-11 rounded-xl border border-zinc-200 bg-white px-3 text-zinc-900 outline-none focus:ring-2 focus:ring-emerald-500/30"
51
+ placeholder="e.g., Ground Services Agent"
52
+ />
53
+ </label>
54
+
55
+ <label className="grid gap-1">
56
+ <span className="text-sm font-medium text-zinc-800">Passport Number</span>
57
+ <input
58
+ value={passportNumber}
59
+ onChange={(e) => setPassportNumber(e.target.value)}
60
+ className="h-11 rounded-xl border border-zinc-200 bg-white px-3 text-zinc-900 outline-none focus:ring-2 focus:ring-emerald-500/30"
61
+ placeholder="e.g., P1234567"
62
+ />
63
+ </label>
64
+
65
+ <button
66
+ disabled={!canStart}
67
+ onClick={() => canStart && props.onSubmit({ fullName: fullName.trim(), jobTitle: jobTitle.trim(), passportNumber: passportNumber.trim() })}
68
+ className="mt-2 h-11 rounded-xl bg-zinc-900 text-white font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-95 transition"
69
+ >
70
+ Start Assessment
71
+ </button>
72
+ </div>
73
+ </div>
74
+ );
75
+ }
src/lib/questions.ts CHANGED
@@ -1,324 +1,310 @@
1
- import { SectionKey } from "@/lib/sections";
2
 
3
  export type QuestionType = "likert" | "mcq" | "text";
4
 
5
- export type QuestionVariant = {
6
- prompt: string;
7
- options?: string[];
8
- mcqScores?: Record<string, number>; // 0..5
9
- reverse?: boolean; // for likert
10
- };
11
 
12
- export type QuestionBase = {
13
- id: string; // stable id (unique question)
14
  section: SectionKey;
15
  type: QuestionType;
16
- domain: string; // internal construct label
17
- variants: [QuestionVariant, QuestionVariant, QuestionVariant]; // 3 rephrases
18
- };
19
-
20
- export const LIKERT_5 = ["Strongly Disagree","Disagree","Neutral","Agree","Strongly Agree"] as const;
21
-
22
- function vLikert(p1: string, p2: string, p3: string, reverse=false): [QuestionVariant,QuestionVariant,QuestionVariant] {
23
- return [
24
- { prompt: p1, options: [...LIKERT_5], reverse },
25
- { prompt: p2, options: [...LIKERT_5], reverse },
26
- { prompt: p3, options: [...LIKERT_5], reverse }
27
- ];
28
  }
29
 
30
- function vMCQ(p1: string, p2: string, p3: string, options: string[], scores: Record<string,number>): [QuestionVariant,QuestionVariant,QuestionVariant] {
31
- return [
32
- { prompt: p1, options, mcqScores: scores },
33
- { prompt: p2, options, mcqScores: scores },
34
- { prompt: p3, options, mcqScores: scores }
35
- ];
 
 
 
 
 
36
  }
37
 
38
- function vText(p1: string, p2: string, p3: string): [QuestionVariant,QuestionVariant,QuestionVariant] {
39
- return [{ prompt: p1 }, { prompt: p2 }, { prompt: p3 }];
40
- }
 
 
 
 
 
 
 
41
 
42
  /**
43
- * 135 base questions across sections (A-F) with 3 variants each.
44
- * We generate them programmatically to keep code maintainable, but IDs are stable.
 
 
45
  */
46
- export const QUESTION_BASES: QuestionBase[] = (() => {
47
- const out: QuestionBase[] = [];
48
 
49
- // A: 27 (mostly likert with some reverse + attention checks)
50
- const A_items: Array<{domain:string; prompts:[string,string,string]; reverse?:boolean}> = [
51
- {domain:"Conscientiousness", prompts:[
52
- "I follow through on commitments even when no one is checking.",
53
- "Even without supervision, I complete what I promise.",
54
- "I reliably finish tasks I commit to, even unobserved."
55
- ]},
56
- {domain:"Conscientiousness_rev", reverse:true, prompts:[
57
- "I often leave tasks unfinished if they become inconvenient.",
58
- "If something is tedious, I may stop before it’s complete.",
59
- "When a task gets annoying, I sometimes abandon it."
60
- ]},
61
- {domain:"EmotionalStability", prompts:[
62
- "I stay calm and focused when unexpected issues arise.",
63
- "When things go wrong, I can keep my composure.",
64
- "I remain steady and effective during disruptions."
65
- ]},
66
- {domain:"ImpulseControl_rev", reverse:true, prompts:[
67
- "I sometimes act first and think later when stressed.",
68
- "Under stress, I may respond before fully thinking.",
69
- "When pressured, I can react impulsively."
70
- ]},
71
- {domain:"Adaptability", prompts:[
72
- "I adjust quickly when priorities or plans change.",
73
- "When schedules change, I adapt without losing quality.",
74
- "I can shift approach quickly when circumstances change."
75
- ]},
76
- {domain:"TeamOrientation", prompts:[
77
- "I actively support teammates when workload increases.",
78
- "When others are overloaded, I help to keep operations running.",
79
- "I step in to assist colleagues to ensure continuity."
80
- ]},
81
- {domain:"Integrity_rev", reverse:true, prompts:[
82
- "Small rule-bending is acceptable if it helps finish faster.",
83
- "Breaking minor rules is fine if it improves speed.",
84
- "If it saves time, small shortcuts are acceptable."
85
- ]},
86
- {domain:"StressTolerance", prompts:[
87
- "I can handle time pressure without making careless mistakes.",
88
- "Even when rushed, I keep my work accurate.",
89
- "Under time pressure, I remain careful and accurate."
90
- ]},
91
- {domain:"AttentionCheck", prompts:[
92
- "For quality control, please select 'Agree' for this statement.",
93
- "To confirm attention, choose 'Agree' here.",
94
- "Attention check: select 'Agree' for this item."
95
- ]},
96
  ];
97
- // Expand A_items to 27 by duplicating domains with different wording
98
- while (A_items.length < 27) {
99
- const i = A_items.length + 1;
100
- A_items.push({
101
- domain: "WorkReliability_" + i,
102
- prompts: [
103
- `I organize my work so that important tasks are completed on time. (${i})`,
104
- `I plan my tasks to meet deadlines consistently. (${i})`,
105
- `I schedule my work effectively to finish on time. (${i})`
106
- ]
107
- });
108
- }
109
- A_items.slice(0,27).forEach((it, idx) => {
110
- out.push({
111
- id: `A${String(idx+1).padStart(2,"0")}`,
112
  section: "A",
113
  type: "likert",
114
- domain: it.domain,
115
- variants: vLikert(it.prompts[0], it.prompts[1], it.prompts[2], Boolean(it.reverse))
 
 
116
  });
117
- });
118
 
119
- // B: 40 (mix likert + MCQ + 1 text scenario for AI)
120
- // 10 MCQ situational judgement (safety/procedure), 29 likert, 1 text
121
- const mcqOptions = [
122
- "Stop the task and follow the documented procedure immediately.",
123
- "Continue to avoid delay; fix it later if needed.",
124
- "Ask a colleague what they usually do and copy that.",
125
- "Proceed and report after the shift ends."
 
126
  ];
127
- const mcqScores = {
128
- "Stop the task and follow the documented procedure immediately.": 5,
129
- "Continue to avoid delay; fix it later if needed.": 0,
130
- "Ask a colleague what they usually do and copy that.": 2,
131
- "Proceed and report after the shift ends.": 1
132
- } as Record<string, number>;
133
-
134
- for (let k=1; k<=10; k++){
135
- out.push({
136
- id: `BMCQ${String(k).padStart(2,"0")}`,
137
  section: "B",
138
- type: "mcq",
139
- domain: "SafetyJudgment",
140
- variants: vMCQ(
141
- `You notice a step in the checklist is missing for the current task. What should you do first? (SJT ${k})`,
142
- `A required checklist step appears to be skipped. What is your first action? (SJT ${k})`,
143
- `You find the procedure is not being followed fully. What do you do first? (SJT ${k})`,
144
- mcqOptions,
145
- mcqScores
146
- )
147
  });
148
  }
149
-
150
- const B_likert_templates: Array<{domain:string; reverse?:boolean; p:(n:number)=>[string,string,string]}> = [
151
- {domain:"RuleAdherence", p:(n)=>[
152
- `I follow procedures even when it slows the work down. (${n})`,
153
- `Even if it takes longer, I stick to the correct procedure. (${n})`,
154
- `I do tasks the right way even if it costs time. (${n})`
155
- ]},
156
- {domain:"DecisionUnderPressure", p:(n)=>[
157
- `When pressure is high, I make decisions based on facts and procedure. (${n})`,
158
- `Under pressure, I prioritize safety and accuracy in decisions. (${n})`,
159
- `Even in urgency, I decide using rules and evidence. (${n})`
160
- ]},
161
- {domain:"AttentionToDetail", p:(n)=>[
162
- `I detect small errors before they become bigger problems. (${n})`,
163
- `I notice small issues early and correct them. (${n})`,
164
- `I catch details that could cause later errors. (${n})`
165
- ]},
166
- {domain:"RiskTaking_rev", reverse:true, p:(n)=>[
167
- `Taking calculated shortcuts is worth it if it speeds things up. (${n})`,
168
- `I’m willing to bypass steps when the outcome seems obvious. (${n})`,
169
- `If I’m confident, I may skip steps to move faster. (${n})`
170
- ]},
 
 
 
 
 
 
 
 
171
  ];
172
-
173
- let bcount = 0;
174
- for (let i=1; i<=29; i++){
175
- const t = B_likert_templates[(i-1)%B_likert_templates.length];
176
- bcount++;
177
- const [p1,p2,p3] = t.p(bcount);
178
- out.push({
179
- id: `B${String(i).padStart(2,"0")}`,
180
- section:"B",
181
- type:"likert",
182
- domain:t.domain,
183
- variants: vLikert(p1,p2,p3, Boolean(t.reverse))
184
  });
185
  }
186
-
187
- // 1 text scenario for AI narrative analysis (counts as 40th base)
188
- out.push({
189
- id: "B_TEXT01",
190
- section:"B",
191
- type:"text",
192
- domain:"OperationalScenario",
193
- variants: vText(
194
- "Scenario: A flight is delayed, passengers are frustrated, and the team is short-staffed. Describe your actions to keep operations safe and compliant.",
195
- "Scenario: During disruption and heavy workload, explain how you would prioritize tasks and communicate while maintaining safety.",
196
- "Scenario: Under operational pressure, explain step-by-step how you keep safety, rules, and service quality."
197
- )
198
  });
199
 
200
- // C: 20 likert (burnout risk, fatigue, recovery) with reverse items
201
- for (let i=1; i<=20; i++){
202
- const reverse = (i % 5 === 0);
203
- const base = i;
204
- out.push({
205
- id: `C${String(i).padStart(2,"0")}`,
206
- section:"C",
207
- type:"likert",
208
- domain: reverse ? "FatigueRisk_rev" : "StressTolerance",
209
- variants: vLikert(
210
- reverse ? `After demanding workdays, I struggle to recover before the next shift. (${base})` : `I can handle sustained workload without feeling overwhelmed. (${base})`,
211
- reverse ? `I often feel I have not recovered enough before the next workday. (${base})` : `I stay effective even when workload stays high for days. (${base})`,
212
- reverse ? `I frequently start shifts still tired from previous days. (${base})` : `I manage time pressure without accumulating excessive stress. (${base})`,
213
- reverse
214
- )
215
  });
216
  }
217
 
218
- // D: 20 likert (sustainability, error risk under load) with reverse items
219
- for (let i=1; i<=20; i++){
220
- const reverse = (i % 4 === 0);
221
- out.push({
222
- id: `D${String(i).padStart(2,"0")}`,
223
- section:"D",
224
- type:"likert",
225
- domain: reverse ? "ErrorRiskUnderLoad_rev" : "SustainedPerformance",
226
- variants: vLikert(
227
- reverse ? `When workload stays high, my accuracy drops noticeably. (${i})` : `I can maintain consistent performance across long busy periods. (${i})`,
228
- reverse ? `During extended busy periods, I make more avoidable mistakes. (${i})` : `Even with heavy demand, I keep my output stable. (${i})`,
229
- reverse ? `Under prolonged pressure, my attention slips. (${i})` : `I manage energy so I can perform reliably long-term. (${i})`,
230
- reverse
231
- )
 
232
  });
233
  }
234
 
235
- // E: 13 (psych safety & team fit) incl. speak-up and authority
236
- for (let i=1; i<=13; i++){
237
- const reverse = (i % 6 === 0);
238
- out.push({
239
- id: `E${String(i).padStart(2,"0")}`,
240
- section:"E",
241
- type:"likert",
242
- domain: reverse ? "SpeakUp_rev" : "SpeakUp",
243
- variants: vLikert(
244
- reverse ? `If a supervisor is wrong, it’s better to stay quiet to avoid conflict. (${i})` : `I would speak up respectfully if I notice a safety or compliance risk. (${i})`,
245
- reverse ? `I avoid raising concerns when senior staff are involved. (${i})` : `I raise issues early when I believe safety could be affected. (${i})`,
246
- reverse ? `It’s not my place to question decisions, even if risks exist. (${i})` : `I am comfortable reporting risks through the proper channels. (${i})`,
247
- reverse
248
- )
 
249
  });
250
  }
251
 
252
- // F: 15 (wellbeing & readiness) non-clinical, include help-seeking openness (no diagnosis)
253
- for (let i=1; i<=15; i++){
254
- const reverse = (i % 5 === 0);
255
- out.push({
256
- id: `F${String(i).padStart(2,"0")}`,
257
- section:"F",
258
- type:"likert",
259
- domain: reverse ? "HelpSeeking_rev" : "FunctionalReadiness",
260
- variants: vLikert(
261
- reverse ? `If I were struggling, I would avoid seeking support at work. (${i})` : `I manage stress in a way that keeps me functional and reliable at work. (${i})`,
262
- reverse ? `I would hide difficulties rather than ask for help or guidance. (${i})` : `I recognize early signs of fatigue and take appropriate steps to stay safe. (${i})`,
263
- reverse ? `I prefer to handle serious workload strain alone without involving anyone. (${i})` : `I can remain ready and effective across different shifts and demands. (${i})`,
264
- reverse
265
- )
 
266
  });
267
  }
268
 
269
- // Sanity: ensure counts
270
- return out;
271
- })();
272
 
273
- export type SelectedQuestion = {
274
- baseId: string;
275
- section: SectionKey;
276
- type: QuestionType;
277
- domain: string;
278
- variantIndex: 0|1|2;
279
- prompt: string;
280
- options?: string[];
281
- mcqScores?: Record<string, number>;
282
- reverse?: boolean;
283
- };
284
 
285
- export function selectExamQuestions(seed: string): SelectedQuestion[] {
286
- // Deterministic-ish shuffle from seed
287
- let h = 2166136261;
288
- for (let i=0;i<seed.length;i++){ h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); }
289
- function rand() { h += 0x6D2B79F5; let t = Math.imul(h ^ (h >>> 15), 1 | h); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }
290
- function pickVariant(): 0|1|2 { return (Math.floor(rand()*3) as 0|1|2); }
291
 
292
- const bySection: Record<SectionKey, QuestionBase[]> = { A:[],B:[],C:[],D:[],E:[],F:[] };
293
- for (const qb of QUESTION_BASES) bySection[qb.section].push(qb);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
- const order: SectionKey[] = ["A","B","C","D","E","F"];
296
- const selected: SelectedQuestion[] = [];
 
 
297
 
298
- for (const s of order) {
299
- const pool = bySection[s].slice();
300
- // shuffle pool deterministically
301
- for (let i=pool.length-1;i>0;i--){
302
- const j = Math.floor(rand()*(i+1));
303
- [pool[i], pool[j]] = [pool[j], pool[i]];
304
- }
305
- // take all (counts already exactly per section)
306
- for (const qb of pool){
307
- const vi = pickVariant();
308
- const v = qb.variants[vi];
309
- selected.push({
310
- baseId: qb.id,
311
- section: qb.section,
312
- type: qb.type,
313
- domain: qb.domain,
314
- variantIndex: vi,
315
- prompt: v.prompt,
316
- options: v.options,
317
- mcqScores: v.mcqScores,
318
- reverse: v.reverse
319
- });
320
- }
321
- }
322
 
323
- return selected;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  }
 
1
+ export type SectionKey = "A" | "B" | "C" | "D" | "E" | "F";
2
 
3
  export type QuestionType = "likert" | "mcq" | "text";
4
 
5
+ export type LikertScale = "frequency" | "agreement" | "confidence" | "capacity";
 
 
 
 
 
6
 
7
+ export interface BaseQuestion {
8
+ baseId: string; // unique within test
9
  section: SectionKey;
10
  type: QuestionType;
11
+ promptVariants: [string, string, string];
12
+ // Likert
13
+ scale?: LikertScale;
14
+ reverse?: boolean; // reverse-score (higher response => lower trait)
15
+ trait?: string; // internal trait tag
16
+ // MCQ
17
+ options?: { id: string; label: string; score: number }[];
18
+ // Text
19
+ minChars?: number;
 
 
 
20
  }
21
 
22
+ export interface ExamQuestion {
23
+ id: string; // baseId + variant index
24
+ baseId: string;
25
+ section: SectionKey;
26
+ type: QuestionType;
27
+ prompt: string;
28
+ scale?: LikertScale;
29
+ reverse?: boolean;
30
+ trait?: string;
31
+ options?: { id: string; label: string; score: number }[];
32
+ minChars?: number;
33
  }
34
 
35
+ export const SECTIONS: { key: SectionKey; title: string; minutes: number; weight: number; count: number }[] = [
36
+ { key: "A", title: "Psychometric & Personality", minutes: 12, weight: 0.20, count: 27 },
37
+ { key: "B", title: "Job-Focused Capability", minutes: 18, weight: 0.30, count: 40 },
38
+ { key: "C", title: "Work Stressors & Burnout Risk", minutes: 9, weight: 0.15, count: 20 },
39
+ { key: "D", title: "Workload Sustainability", minutes: 8, weight: 0.15, count: 20 },
40
+ { key: "E", title: "Psychological Safety & Team Fit", minutes: 6, weight: 0.10, count: 13 },
41
+ { key: "F", title: "Wellbeing & Functional Readiness", minutes: 7, weight: 0.10, count: 15 },
42
+ ];
43
+
44
+ function pad2(n: number) { return String(n).padStart(2, "0"); }
45
 
46
  /**
47
+ * Build the 135 base questions programmatically, with 3 rephrased variants each.
48
+ * IMPORTANT: baseId is unique inside a section, and selection guarantees:
49
+ * - exactly 1 variant per baseId (so no duplicates inside the section)
50
+ * - total 135 questions across the full test
51
  */
52
+ export function buildQuestionBank(): BaseQuestion[] {
53
+ const bank: BaseQuestion[] = [];
54
 
55
+ // ---- Section A (27) - Likert
56
+ const aTraits = [
57
+ { trait: "emotional_stability", scale: "agreement" as const, reverseEvery: 5 },
58
+ { trait: "conscientiousness", scale: "agreement" as const, reverseEvery: 7 },
59
+ { trait: "stress_tolerance", scale: "agreement" as const, reverseEvery: 6 },
60
+ { trait: "impulse_control", scale: "agreement" as const, reverseEvery: 8 },
61
+ { trait: "team_orientation", scale: "agreement" as const, reverseEvery: 9 },
62
+ { trait: "adaptability", scale: "agreement" as const, reverseEvery: 10 },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  ];
64
+ for (let i = 1; i <= 27; i++) {
65
+ const t = aTraits[(i - 1) % aTraits.length];
66
+ const reverse = i % t.reverseEvery === 0;
67
+ const stem = [
68
+ "I stay calm and consistent even when situations change quickly.",
69
+ "When plans change suddenly, I can adapt without losing focus.",
70
+ "I keep my emotions under control in tense situations."
71
+ ];
72
+ const v1 = stem[0];
73
+ const v2 = stem[1];
74
+ const v3 = stem[2];
75
+ bank.push({
76
+ baseId: `A${pad2(i)}`,
 
 
77
  section: "A",
78
  type: "likert",
79
+ promptVariants: [v1, v2, v3],
80
+ scale: t.scale,
81
+ reverse,
82
+ trait: t.trait
83
  });
84
+ }
85
 
86
+ // ---- Section B (40) - 29 Likert + 10 MCQ + 1 Text scenario
87
+ // Likert (B01..B29)
88
+ const bTraits = [
89
+ { trait: "attention_detail", scale: "agreement" as const },
90
+ { trait: "judgment", scale: "agreement" as const },
91
+ { trait: "procedures", scale: "agreement" as const },
92
+ { trait: "safety_mindset", scale: "agreement" as const },
93
+ { trait: "pressure_decision", scale: "agreement" as const },
94
  ];
95
+ for (let i = 1; i <= 29; i++) {
96
+ const t = bTraits[(i - 1) % bTraits.length];
97
+ const reverse = i % 9 === 0; // occasional reverse items
98
+ bank.push({
99
+ baseId: `B${pad2(i)}`,
 
 
 
 
 
100
  section: "B",
101
+ type: "likert",
102
+ promptVariants: [
103
+ "I follow procedures even when it would be faster to take a shortcut.",
104
+ "When under time pressure, I still prioritize safety and compliance.",
105
+ "I double-check critical details before I act."
106
+ ],
107
+ scale: t.scale,
108
+ reverse,
109
+ trait: t.trait
110
  });
111
  }
112
+ // MCQ (B30..B39) - scored choices (no diagnosis)
113
+ const mcqTemplates = [
114
+ {
115
+ prompt: [
116
+ "You notice a safety barrier is missing near an active work area. What do you do first?",
117
+ "A barrier is missing in an active zone. What should you do first?",
118
+ "You see an unsafe area without the required barrier. What is your first action?"
119
+ ],
120
+ options: [
121
+ { id: "a", label: "Continue and report it later", score: 0 },
122
+ { id: "b", label: "Stop the work and inform the supervisor / safety immediately", score: 5 },
123
+ { id: "c", label: "Ask a colleague to handle it while you continue", score: 2 },
124
+ { id: "d", label: "Ignore it if no one is nearby", score: 0 },
125
+ ],
126
+ trait: "safety_action"
127
+ },
128
+ {
129
+ prompt: [
130
+ "A passenger is upset and raising their voice at the counter. Best response?",
131
+ "A customer is angry and escalating. What is the best response?",
132
+ "An upset passenger is shouting. What should you do?"
133
+ ],
134
+ options: [
135
+ { id: "a", label: "Match their tone to show authority", score: 0 },
136
+ { id: "b", label: "Stay calm, listen, acknowledge, and follow escalation procedure", score: 5 },
137
+ { id: "c", label: "Walk away immediately without explanation", score: 1 },
138
+ { id: "d", label: "Promise anything to end the situation quickly", score: 1 },
139
+ ],
140
+ trait: "deescalation"
141
+ },
142
  ];
143
+ // Expand to 10 by cycling templates with slight variation in baseId
144
+ for (let i = 30; i <= 39; i++) {
145
+ const t = mcqTemplates[(i - 30) % mcqTemplates.length];
146
+ bank.push({
147
+ baseId: `B${pad2(i)}`,
148
+ section: "B",
149
+ type: "mcq",
150
+ promptVariants: [t.prompt[0], t.prompt[1], t.prompt[2]],
151
+ options: t.options,
152
+ trait: t.trait
 
 
153
  });
154
  }
155
+ // Text scenario (B40)
156
+ bank.push({
157
+ baseId: "B40",
158
+ section: "B",
159
+ type: "text",
160
+ promptVariants: [
161
+ "Scenario: A last-minute flight change causes crowding at the gate and a colleague looks overwhelmed. Describe what you would do, step by step, to maintain safety, follow procedures, and support the team.",
162
+ "Scenario: A sudden operational disruption increases pressure and the queue grows fast. Explain your step-by-step actions to stay compliant, communicate clearly, and keep things safe.",
163
+ "Scenario: During a busy shift, a disruption escalates stress. Describe your actions to prioritize safety, follow procedures, and coordinate with others."
164
+ ],
165
+ minChars: 120,
166
+ trait: "text_scenario"
167
  });
168
 
169
+ // ---- Section C (20) - Likert
170
+ for (let i = 1; i <= 20; i++) {
171
+ const reverse = i % 6 === 0;
172
+ bank.push({
173
+ baseId: `C${pad2(i)}`,
174
+ section: "C",
175
+ type: "likert",
176
+ promptVariants: [
177
+ "I can maintain quality when workload and time pressure increase.",
178
+ "I recover well after demanding shifts and return focused.",
179
+ "I notice early signs of fatigue and take appropriate steps."
180
+ ],
181
+ scale: "agreement",
182
+ reverse,
183
+ trait: "burnout_risk"
184
  });
185
  }
186
 
187
+ // ---- Section D (20) - Likert
188
+ for (let i = 1; i <= 20; i++) {
189
+ const reverse = i % 7 === 0;
190
+ bank.push({
191
+ baseId: `D${pad2(i)}`,
192
+ section: "D",
193
+ type: "likert",
194
+ promptVariants: [
195
+ "I can sustain consistent performance across long periods of demand.",
196
+ "Even with repetitive tasks, I stay attentive and avoid careless errors.",
197
+ "I manage my energy so I can perform reliably throughout the day."
198
+ ],
199
+ scale: "agreement",
200
+ reverse,
201
+ trait: "sustainability"
202
  });
203
  }
204
 
205
+ // ---- Section E (13) - Likert
206
+ for (let i = 1; i <= 13; i++) {
207
+ const reverse = i % 8 === 0;
208
+ bank.push({
209
+ baseId: `E${pad2(i)}`,
210
+ section: "E",
211
+ type: "likert",
212
+ promptVariants: [
213
+ "If I see a safety issue, I am comfortable speaking up even to senior staff.",
214
+ "I can raise concerns respectfully when I think a process is unsafe.",
215
+ "I accept feedback and adjust my behavior without becoming defensive."
216
+ ],
217
+ scale: "agreement",
218
+ reverse,
219
+ trait: "psych_safety"
220
  });
221
  }
222
 
223
+ // ---- Section F (15) - Likert (non-clinical functional readiness)
224
+ for (let i = 1; i <= 15; i++) {
225
+ const reverse = i % 6 === 0;
226
+ bank.push({
227
+ baseId: `F${pad2(i)}`,
228
+ section: "F",
229
+ type: "likert",
230
+ promptVariants: [
231
+ "I can manage stress without it affecting my work performance.",
232
+ "When I feel overwhelmed, I use healthy coping strategies to stay functional.",
233
+ "I am open to seeking support early if stress starts to impact my work."
234
+ ],
235
+ scale: "agreement",
236
+ reverse,
237
+ trait: "wellbeing_readiness"
238
  });
239
  }
240
 
241
+ return bank;
242
+ }
 
243
 
244
+ export function buildExam(seed?: string): {
245
+ examBySection: Record<SectionKey, ExamQuestion[]>;
246
+ flat: ExamQuestion[];
247
+ } {
248
+ const bank = buildQuestionBank();
249
+ const examBySection: any = { A: [], B: [], C: [], D: [], E: [], F: [] };
 
 
 
 
 
250
 
251
+ // Deterministic random if seed provided
252
+ let rng = mulberry32(hashSeed(seed || String(Date.now())));
253
+ function pickVariant(): 0 | 1 | 2 { return (Math.floor(rng() * 3) as 0 | 1 | 2); }
 
 
 
254
 
255
+ for (const b of bank) {
256
+ const v = pickVariant();
257
+ const q: ExamQuestion = {
258
+ id: `${b.baseId}_v${v + 1}`,
259
+ baseId: b.baseId,
260
+ section: b.section,
261
+ type: b.type,
262
+ prompt: b.promptVariants[v],
263
+ scale: b.scale,
264
+ reverse: b.reverse,
265
+ trait: b.trait,
266
+ options: b.options,
267
+ minChars: b.minChars
268
+ };
269
+ examBySection[b.section].push(q);
270
+ }
271
+
272
+ // Ensure NO duplicates by baseId within each section
273
+ (Object.keys(examBySection) as SectionKey[]).forEach((k) => {
274
+ const seen = new Set<string>();
275
+ examBySection[k] = examBySection[k].filter((q: ExamQuestion) => {
276
+ if (seen.has(q.baseId)) return false;
277
+ seen.add(q.baseId);
278
+ return true;
279
+ });
280
+ });
281
 
282
+ // Sort each section by baseId so navigation is stable (no weird shuffle duplicates perception)
283
+ (Object.keys(examBySection) as SectionKey[]).forEach((k) => {
284
+ examBySection[k].sort((a: ExamQuestion, b: ExamQuestion) => a.baseId.localeCompare(b.baseId));
285
+ });
286
 
287
+ const flat: ExamQuestion[] = ([] as ExamQuestion[]).concat(
288
+ examBySection.A, examBySection.B, examBySection.C, examBySection.D, examBySection.E, examBySection.F
289
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
+ return { examBySection, flat };
292
+ }
293
+
294
+ /** Utilities: deterministic RNG */
295
+ function hashSeed(s: string): number {
296
+ let h = 2166136261;
297
+ for (let i = 0; i < s.length; i++) {
298
+ h ^= s.charCodeAt(i);
299
+ h = Math.imul(h, 16777619);
300
+ }
301
+ return h >>> 0;
302
+ }
303
+ function mulberry32(a: number) {
304
+ return function () {
305
+ let t = (a += 0x6D2B79F5);
306
+ t = Math.imul(t ^ (t >>> 15), t | 1);
307
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
308
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
309
+ };
310
  }
tailwind.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ module.exports = {
3
+ content: ["./src/**/*.{js,ts,jsx,tsx}"],
4
+ theme: { extend: {} },
5
+ plugins: []
6
+ };
tsconfig.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "es2022"],
5
+ "strict": true,
6
+ "noEmit": true,
7
+ "module": "esnext",
8
+ "moduleResolution": "bundler",
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "jsx": "preserve",
12
+ "baseUrl": ".",
13
+ "paths": { "@/*": ["src/*"] },
14
+ "skipLibCheck": true
15
+ },
16
+ "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"],
17
+ "exclude": ["node_modules"]
18
+ }