UI/UX Polishing: CBT dashboard, sidebar tiers, landing pricing, and exam disclaimer

This commit is contained in:
2026-07-09 17:05:40 +05:30
parent b80d73ec26
commit 22b167b985
15 changed files with 481 additions and 202 deletions

View File

@@ -32,6 +32,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
const resolvedParams = use(params);
const examId = resolvedParams.examId;
const [mounted, setMounted] = useState(false);
const [examType, setExamType] = useState<string>('Test');
const [showCalc, setShowCalc] = useState(false);
const [showScratchpad, setShowScratchpad] = useState(false);
const [showPeriodicTable, setShowPeriodicTable] = useState(false);
@@ -61,7 +62,10 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
submitAttemptToServer,
resetExamState,
status,
attemptId
attemptId,
currentQuestionId,
getQuestion,
examState
} = useCBTStore();
const { formattedTime, isTimedOut } = useExamTimer();
@@ -75,6 +79,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
const testRes = await fetch(`${apiUrl}/api/tests/${examId}?user_id=b0e17378-864d-439e-8806-708dc48a891b`);
if (!testRes.ok) throw new Error("Failed to fetch test structure");
const testData = await testRes.json();
setExamType(testData.type);
// Extract all question IDs from sections
const questionIds: string[] = [];
@@ -82,23 +87,31 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
questionIds.push(...section.questions);
});
// Fetch full question objects
const fetchedQuestions = await Promise.all(
questionIds.map(async (id, idx) => {
const qRes = await fetch(`${apiUrl}/api/questions/${id}`);
const qData = await qRes.json();
return {
id: qData.id,
number: idx + 1,
subject: qData.subject,
type: qData.type,
text_en: qData.question_text,
text_hi: qData.question_text_hi || qData.question_text,
options: Object.values(qData.options || {}),
options_hi: Object.values(qData.options_hi || qData.options || {}),
};
})
);
const qRes = await fetch(`${apiUrl}/api/tests/${examId}/questions`);
if (!qRes.ok) throw new Error("Failed to fetch questions in bulk");
const allQuestionsData = await qRes.json();
// Map questions by ID for fast lookup
const questionsMap = new Map();
allQuestionsData.forEach((q: any) => {
questionsMap.set(q.id, q);
});
// Construct fetchedQuestions retaining the exact order of questionIds
const fetchedQuestions = questionIds.map((id, idx) => {
const qData = questionsMap.get(id);
if (!qData) return null; // Fallback in case of mismatch
return {
id: qData.id,
number: idx + 1,
subject: qData.subject,
type: qData.type,
text_en: qData.question_text,
text_hi: qData.question_text_hi || qData.question_text,
options: Object.values(qData.options || {}),
options_hi: Object.values(qData.options_hi || qData.options || {}),
};
}).filter(Boolean) as any[];
initExam(examId, fetchedQuestions, testData.total_time * 60);
setMounted(true);
@@ -221,6 +234,10 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
</ul>
</div>
<div className="mb-8 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl text-yellow-600 dark:text-yellow-500 text-sm text-left">
<strong>Disclaimer:</strong> Only the score obtained in your <em>first attempt</em> will be considered for growth tracking and performance evaluation. Any subsequent attempts are intended solely for practice and will not be included in the official assessment.
</div>
<div className="mb-8 p-4 bg-muted rounded-xl flex items-center justify-between">
<div>
<p className="font-semibold">Preferred Theme</p>
@@ -247,19 +264,34 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
<ExamResult
payload={payload}
attemptId={attemptId}
onRetake={() => {
onRetake={examType === 'DPS' ? () => {
resetExamState();
window.location.reload();
}}
} : undefined}
/>
)
}
const currentQuestion = questions[currentQuestionIndex];
const currentState = answers[currentQuestion.id];
const currentQuestion = getQuestion(currentQuestionId)
const currentState = currentQuestion ? examState?.answers[currentQuestion.id] : null
// Derive palette stats
const paletteStats = Object.values(answers).reduce(
const handleOptionSelect = (qId: string, letter: string, type: string) => {
if (type === 'Multiple_Choice') {
const currentSelected = currentState?.selected_answer || "";
const selectedArray = currentSelected.split(',').filter(Boolean);
if (selectedArray.includes(letter)) {
const newArray = selectedArray.filter((l: string) => l !== letter).sort();
selectAnswer(qId, newArray.length > 0 ? newArray.join(',') : null);
} else {
selectedArray.push(letter);
selectAnswer(qId, selectedArray.sort().join(','));
}
} else {
selectAnswer(qId, letter);
}
}
// Effect to close overlay tools on EscapealetteStats = Object.values(answers).reduce(
(acc, val) => {
acc[val.status]++;
return acc;
@@ -322,9 +354,9 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
<div className="flex items-center gap-3 md:gap-6">
<div className="hidden lg:flex items-center gap-2 mr-4">
<Button variant="ghost" size="sm" onClick={() => setShowCalc(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:text-foreground"><CalculatorIcon size={14}/> Calc</Button>
<Button variant="ghost" size="sm" onClick={() => setShowPeriodicTable(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:text-foreground"><FlaskConical size={14}/> Periodic Table</Button>
<Button variant="ghost" size="sm" onClick={() => setShowScratchpad(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:text-foreground"><Edit3 size={14}/> Scratchpad</Button>
<Button variant="ghost" size="sm" onClick={() => setShowCalc(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:bg-[#00F0FF] hover:text-black transition-colors"><CalculatorIcon size={14}/> Calculator</Button>
<Button variant="ghost" size="sm" onClick={() => setShowPeriodicTable(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:bg-[#00F0FF] hover:text-black transition-colors"><FlaskConical size={14}/> Periodic Table</Button>
<Button variant="ghost" size="sm" onClick={() => setShowScratchpad(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:bg-[#00F0FF] hover:text-black transition-colors"><Edit3 size={14}/> Scratchpad</Button>
</div>
<div className={`flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 rounded-xl font-mono text-base md:text-lg tracking-wider font-medium border border-border ${isTimedOut ? 'bg-red-500/10 text-red-500' : 'bg-secondary/50 text-foreground'}`}>
@@ -368,7 +400,15 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
</div>
<div className="space-y-3 mt-8">
{(currentQuestion.type === 'Subjective' || currentQuestion.type === 'Numerical') ? (
{currentQuestion.type === 'Numerical' ? (
<input
type="text"
className="w-full p-4 rounded-xl border border-border bg-background focus:ring-2 focus:ring-primary focus:border-transparent text-base"
placeholder="Type your numerical answer here..."
value={currentState?.selected_answer || ""}
onChange={(e) => selectAnswer(currentQuestion.id, e.target.value)}
/>
) : currentQuestion.type === 'Subjective' ? (
<textarea
className="w-full min-h-[150px] p-4 rounded-xl border border-border bg-background focus:ring-2 focus:ring-primary focus:border-transparent resize-y text-base"
placeholder="Type your answer or explanation here..."
@@ -378,16 +418,19 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
) : (
currentQuestion.options?.map((option: string, idx: number) => {
const letter = String.fromCharCode(65 + idx);
const isSelected = currentState?.selected_answer === letter;
const isSelected = currentQuestion.type === 'Multiple_Choice'
? (currentState?.selected_answer || "").split(',').includes(letter)
: currentState?.selected_answer === letter;
return (
<button
key={idx}
onClick={() => selectAnswer(currentQuestion.id, letter)}
onClick={() => handleOptionSelect(currentQuestion.id, letter, currentQuestion.type)}
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 flex items-center gap-4 group focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
${isSelected ? 'border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]' : 'border-border bg-background hover:border-primary/50 hover:bg-secondary/20'}
`}
>
<div className={`flex-shrink-0 size-8 rounded-lg border flex items-center justify-center font-medium transition-colors
<div className={`flex-shrink-0 size-8 ${currentQuestion.type === 'Multiple_Choice' ? 'rounded-md' : 'rounded-full'} border flex items-center justify-center font-medium transition-colors
${isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5'}
`}>
{letter}
@@ -424,7 +467,15 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
</div>
<div className="space-y-3 mt-8">
{(currentQuestion.type === 'Subjective' || currentQuestion.type === 'Numerical') ? (
{currentQuestion.type === 'Numerical' ? (
<input
type="text"
className="w-full p-4 rounded-xl border border-border bg-background focus:ring-2 focus:ring-primary focus:border-transparent text-base"
placeholder="अपना संख्यात्मक उत्तर यहाँ टाइप करें..."
value={currentState?.selected_answer || ""}
onChange={(e) => selectAnswer(currentQuestion.id, e.target.value)}
/>
) : currentQuestion.type === 'Subjective' ? (
<textarea
className="w-full min-h-[150px] p-4 rounded-xl border border-border bg-background focus:ring-2 focus:ring-primary focus:border-transparent resize-y text-base"
placeholder="अपना उत्तर या स्पष्टीकरण यहाँ टाइप करें..."
@@ -434,16 +485,19 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
) : (
currentQuestion.options_hi?.map((option: string, idx: number) => {
const letter = String.fromCharCode(65 + idx);
const isSelected = currentState?.selected_answer === letter;
const isSelected = currentQuestion.type === 'Multiple_Choice'
? (currentState?.selected_answer || "").split(',').includes(letter)
: currentState?.selected_answer === letter;
return (
<button
key={idx}
onClick={() => selectAnswer(currentQuestion.id, letter)}
onClick={() => handleOptionSelect(currentQuestion.id, letter, currentQuestion.type)}
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 flex items-center gap-4 group focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
${isSelected ? 'border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]' : 'border-border bg-background hover:border-primary/50 hover:bg-secondary/20'}
`}
>
<div className={`flex-shrink-0 size-8 rounded-lg border flex items-center justify-center font-medium transition-colors
<div className={`flex-shrink-0 size-8 ${currentQuestion.type === 'Multiple_Choice' ? 'rounded-md' : 'rounded-full'} border flex items-center justify-center font-medium transition-colors
${isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5'}
`}>
{letter}