Skip to content

Commit eaeed81

Browse files
committed
feat(client): add Interview Prep pool browse and interactive MCQ view (#21)
1 parent c187444 commit eaeed81

3 files changed

Lines changed: 227 additions & 1 deletion

File tree

client/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const Clans = lazy(() => import('./pages/Clans'));
2828
const Profile = lazy(() => import('./pages/Profile'));
2929
const ClanChiefPanel = lazy(() => import('./pages/ClanChiefPanel'));
3030
const Missions = lazy(() => import('./pages/Missions'));
31+
const InterviewPrep = lazy(() => import('./pages/InterviewPrep'));
3132
const PendingTasks = lazy(() => import('./pages/PendingTasks'));
3233
const Settings = lazy(() => import('./pages/Settings'));
3334
const Badges = lazy(() => import('./pages/Badges'));
@@ -87,6 +88,7 @@ function App() {
8788
<Route path="/challenge/:id" element={<ChallengeDetails />} />
8889
<Route path="/submission/:id" element={<SubmissionDetails />} />
8990
<Route path="/missions" element={<Missions />} />
91+
<Route path="/interview-prep" element={<InterviewPrep />} />
9092
<Route path="/pending-tasks" element={<PendingTasks />} />
9193
<Route path="/settings" element={<Settings />} />
9294
<Route path="/badges" element={<Badges />} />

client/src/components/Navbar.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
FiSettings,
1414
FiChevronDown,
1515
FiFolder,
16-
FiStar
16+
FiStar,
17+
FiHelpCircle
1718
} from "react-icons/fi";
1819
import { motion, AnimatePresence } from "framer-motion";
1920
import { clsx } from "clsx";
@@ -31,6 +32,7 @@ const Navbar = ({ onLogout }) => {
3132
const items = [
3233
{ name: "Dashboard", path: "/dashboard", icon: FiGrid },
3334
{ name: "Leaderboard", path: "/leaderboard", icon: FiAward },
35+
{ name: "Interview Prep", path: "/interview-prep", icon: FiHelpCircle },
3436
{ name: "Clan", path: "/clans", icon: FiUsers },
3537
{ name: "Archives", path: "/resources", icon: FiFolder },
3638
];

client/src/pages/InterviewPrep.jsx

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import React, { useMemo, useState } from 'react';
2+
import { useQuery, useQueryClient } from '@tanstack/react-query';
3+
import { FiX, FiCheck, FiClock, FiLock, FiHelpCircle } from 'react-icons/fi';
4+
import toast from 'react-hot-toast';
5+
import { api } from '../lib/api';
6+
import PageHeader from '../components/PageHeader';
7+
import SkeletonCard from '../components/SkeletonCard';
8+
import EmptyState from '../components/EmptyState';
9+
10+
// Friendly "unlocks in" string for a future timestamp.
11+
const untilLabel = (iso) => {
12+
const ms = new Date(iso).getTime() - Date.now();
13+
if (ms <= 0) return 'now';
14+
const days = Math.floor(ms / 86400000);
15+
const hours = Math.floor((ms % 86400000) / 3600000);
16+
if (days > 0) return `${days}d ${hours}h`;
17+
const mins = Math.floor((ms % 3600000) / 60000);
18+
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
19+
};
20+
21+
const STATE_STYLES = {
22+
Mastered: 'text-emerald-400 border-emerald-400/30 bg-emerald-400/10',
23+
NeedsReview: 'text-amber-400 border-amber-400/30 bg-amber-400/10',
24+
Unattempted: 'text-white/50 border-white/10 bg-white/5',
25+
};
26+
27+
const StateBadge = ({ item }) => {
28+
const { masteryStatus, locked, nextAttemptAt } = item;
29+
if (masteryStatus === 'Mastered') {
30+
return <span className={`rounded-full border px-2 py-0.5 text-xs ${STATE_STYLES.Mastered}`}><FiCheck className="inline" /> Mastered</span>;
31+
}
32+
if (masteryStatus === 'NeedsReview') {
33+
return (
34+
<span className={`rounded-full border px-2 py-0.5 text-xs ${STATE_STYLES.NeedsReview}`}>
35+
{locked ? <><FiLock className="inline" /> {untilLabel(nextAttemptAt)}</> : <><FiClock className="inline" /> Due for review</>}
36+
</span>
37+
);
38+
}
39+
return <span className={`rounded-full border px-2 py-0.5 text-xs ${STATE_STYLES.Unattempted}`}>Unattempted</span>;
40+
};
41+
42+
const McqModal = ({ question, onClose, onGraded }) => {
43+
const [selected, setSelected] = useState(null);
44+
const [result, setResult] = useState(null);
45+
const [submitting, setSubmitting] = useState(false);
46+
47+
const submit = async () => {
48+
if (selected == null) return;
49+
setSubmitting(true);
50+
try {
51+
const res = await api.post('/api/submissions/mcq', {
52+
challengeId: question._id,
53+
selectedOption: selected,
54+
});
55+
setResult(res.data.data);
56+
onGraded();
57+
} catch (err) {
58+
toast.error(err?.response?.data?.message || 'Could not submit answer');
59+
} finally {
60+
setSubmitting(false);
61+
}
62+
};
63+
64+
const optionClass = (i) => {
65+
if (!result) return selected === i ? 'border-indigo-400 bg-indigo-400/10' : 'border-white/10 hover:border-white/25';
66+
if (i === result.correctOption) return 'border-emerald-400 bg-emerald-400/10';
67+
if (i === selected) return 'border-red-400 bg-red-400/10';
68+
return 'border-white/10 opacity-60';
69+
};
70+
71+
return (
72+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
73+
<div className="w-full max-w-lg rounded-2xl border border-white/10 bg-surface-1 p-6" onClick={(e) => e.stopPropagation()}>
74+
<div className="mb-4 flex items-start justify-between">
75+
<h3 className="text-lg font-semibold text-white">{question.title}</h3>
76+
<button onClick={onClose} className="text-white/40 hover:text-white"><FiX /></button>
77+
</div>
78+
<p className="mb-4 whitespace-pre-wrap text-sm text-white/70">{question.description}</p>
79+
80+
<div className="space-y-2">
81+
{question.options.map((opt, i) => (
82+
<label key={i} className={`flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2 text-sm text-white/90 transition ${optionClass(i)}`}>
83+
<input
84+
type="radio"
85+
name="mcq"
86+
disabled={!!result}
87+
checked={selected === i}
88+
onChange={() => setSelected(i)}
89+
/>
90+
{opt}
91+
</label>
92+
))}
93+
</div>
94+
95+
{result ? (
96+
<div className="mt-4 space-y-2 text-sm">
97+
{result.correct ? (
98+
<p className="text-emerald-400">Correct! +{result.awardedPoints} points — mastered.</p>
99+
) : (
100+
<p className="text-red-400">
101+
Not quite. Locked for review — unlocks in {untilLabel(result.nextAttemptAt)}.
102+
</p>
103+
)}
104+
{result.explanation && <p className="text-white/60">{result.explanation}</p>}
105+
<button onClick={onClose} className="mt-2 w-full rounded-lg bg-white/10 py-2 text-white hover:bg-white/15">Close</button>
106+
</div>
107+
) : (
108+
<button
109+
onClick={submit}
110+
disabled={selected == null || submitting}
111+
className="mt-5 w-full rounded-lg bg-indigo-500 py-2 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
112+
>
113+
{submitting ? 'Submitting…' : 'Submit answer'}
114+
</button>
115+
)}
116+
</div>
117+
</div>
118+
);
119+
};
120+
121+
const InterviewPrep = () => {
122+
const queryClient = useQueryClient();
123+
const [activeTag, setActiveTag] = useState(null);
124+
const [openMcq, setOpenMcq] = useState(null);
125+
126+
const { data: items = [], isLoading } = useQuery({
127+
queryKey: ['domain-pool'],
128+
queryFn: async () => {
129+
const res = await api.get('/api/challenges/domain');
130+
return res.data.data.items || [];
131+
},
132+
});
133+
134+
const tags = useMemo(() => {
135+
const set = new Set();
136+
items.forEach((it) => (it.tags || []).forEach((t) => set.add(t)));
137+
return Array.from(set).sort();
138+
}, [items]);
139+
140+
const visible = useMemo(
141+
() => (activeTag ? items.filter((it) => (it.tags || []).includes(activeTag)) : items),
142+
[items, activeTag]
143+
);
144+
145+
const openQuestion = (item) => {
146+
if (item.type !== 'mcq') return; // written handled in the written view
147+
if (item.masteryStatus === 'Mastered') return;
148+
if (item.locked) {
149+
toast(`Locked — unlocks in ${untilLabel(item.nextAttemptAt)}`);
150+
return;
151+
}
152+
setOpenMcq(item);
153+
};
154+
155+
return (
156+
<div className="mx-auto max-w-5xl px-4 py-6">
157+
<PageHeader title="Interview Prep" subtitle="Practise domain questions — no deadline, just mastery." />
158+
159+
{tags.length > 0 && (
160+
<div className="mb-5 flex flex-wrap gap-2">
161+
<button
162+
onClick={() => setActiveTag(null)}
163+
className={`rounded-full px-3 py-1 text-sm ${activeTag == null ? 'bg-indigo-500 text-white' : 'bg-white/5 text-white/60'}`}
164+
>
165+
All
166+
</button>
167+
{tags.map((t) => (
168+
<button
169+
key={t}
170+
onClick={() => setActiveTag(t)}
171+
className={`rounded-full px-3 py-1 text-sm ${activeTag === t ? 'bg-indigo-500 text-white' : 'bg-white/5 text-white/60'}`}
172+
>
173+
{t}
174+
</button>
175+
))}
176+
</div>
177+
)}
178+
179+
{isLoading ? (
180+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
181+
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
182+
</div>
183+
) : visible.length === 0 ? (
184+
<EmptyState title="No questions yet" description="Domain questions will appear here once an organiser adds them." icon={FiHelpCircle} />
185+
) : (
186+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
187+
{visible.map((item) => {
188+
const answerable = item.type === 'mcq' && item.masteryStatus !== 'Mastered' && !item.locked;
189+
return (
190+
<button
191+
key={item._id}
192+
onClick={() => openQuestion(item)}
193+
className={`flex flex-col items-start gap-3 rounded-xl border border-white/10 bg-surface-1 p-4 text-left transition ${answerable ? 'hover:border-indigo-400/40' : 'cursor-default'}`}
194+
>
195+
<div className="flex w-full items-center justify-between">
196+
<span className="rounded bg-white/5 px-2 py-0.5 text-xs uppercase tracking-wide text-white/50">
197+
{item.type === 'mcq' ? 'MCQ' : 'Written'}
198+
</span>
199+
<StateBadge item={item} />
200+
</div>
201+
<h3 className="font-medium text-white">{item.title}</h3>
202+
<div className="mt-auto flex flex-wrap gap-1">
203+
<span className="text-xs text-white/40">{item.difficulty} · {item.points} pts</span>
204+
</div>
205+
</button>
206+
);
207+
})}
208+
</div>
209+
)}
210+
211+
{openMcq && (
212+
<McqModal
213+
question={openMcq}
214+
onClose={() => setOpenMcq(null)}
215+
onGraded={() => queryClient.invalidateQueries({ queryKey: ['domain-pool'] })}
216+
/>
217+
)}
218+
</div>
219+
);
220+
};
221+
222+
export default InterviewPrep;

0 commit comments

Comments
 (0)