From b9f556059ff88aa7943d92f740cc5c0ee6671eb9 Mon Sep 17 00:00:00 2001 From: Nhung Date: Sun, 15 Jun 2025 17:24:02 +0700 Subject: [PATCH 01/17] style (Taking Quiz): update style for taking quiz [page --- .../[setId]/take/quiz-status-sidebar.tsx | 2 +- .../[setId]/take/taking-quiz-client.tsx | 83 ++++++++++++------- 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx index 59d1da8b4..88be91e22 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; const onQuestionJump = (questionIndex: number) => { - const element = document.getElementById(`question-${questionIndex}`); // or use questionId + const element = document.getElementById(`quiz-${questionIndex}`); // or use questionId if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }); element.focus(); // Optional: set focus to the question diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx index da5b09554..863740d51 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx @@ -8,7 +8,10 @@ import PastDueSection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/ import ShowResultSummarySection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/show-result-summary-section'; import TimeElapsedStatus from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status'; import { Button } from '@tuturuuu/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; import { ListCheck } from '@tuturuuu/ui/icons'; +import { Label } from '@tuturuuu/ui/label'; +import { RadioGroup, RadioGroupItem } from '@tuturuuu/ui/radio-group'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; @@ -69,6 +72,7 @@ export default function TakingQuizClient({ // ─── HELPERS ───────────────────────────────────────────────────────────────── const STORAGE_KEY = `quiz_start_${setId}`; + const ANSWERS_KEY = `quiz_answers_${setId}`; const totalSeconds = quizMeta?.timeLimitMinutes ? quizMeta.timeLimitMinutes * 60 : null; @@ -104,8 +108,17 @@ export default function TakingQuizClient({ setLoadingMeta(false); return; } - setQuizMeta(json as TakeResponse); + if ('questions' in json && json.questions) { + const saved = localStorage.getItem(ANSWERS_KEY); + if (saved) { + try { + setSelectedAnswers(JSON.parse(saved)); + } catch { + /* ignore invalid JSON */ + } + } + } if ('dueDate' in json && json.dueDate) { setDueDateStr(json.dueDate); if (new Date(json.dueDate) < new Date()) { @@ -394,40 +407,46 @@ export default function TakingQuizClient({ className="space-y-8" > {quizMeta.questions.map((q, idx) => ( -
-
- + + + {idx + 1}. {q.question}{' '} - + ({t('ws-quizzes.points') || 'Points'}: {q.score}) - -
-
- {q.options.map((opt) => ( - - ))} -
-
+ + + + { + const next = { ...selectedAnswers, [q.quizId]: value }; + setSelectedAnswers(next); + try { + localStorage.setItem(ANSWERS_KEY, JSON.stringify(next)); + } catch { + // Ignore localStorage errors + } + }} + className="space-y-2" + > + {q.options.map((opt) => ( +
+ + +
+ ))} +
+
+ ))} {submitError &&

{submitError}

} From 42896972d18a7681e186592c560e1cfc9f4c520b Mon Sep 17 00:00:00 2001 From: Nhung Date: Mon, 16 Jun 2025 02:30:36 +0700 Subject: [PATCH 02/17] style (Taking Quiz UI): add styling for before taking quiz --- .../20250615100747_new_migration.sql | 11 + .../20250615104201_new_migration.sql | 3 + .../20250615190931_new_migration.sql | 3 + .../modules/[moduleId]/quizzes/page.tsx | 1 - .../[setId]/attempts/[attemptId]/page.tsx | 154 ++++++++ .../quiz-sets/[setId]/attempts/page.tsx | 93 +++++ .../[setId]/take/before-taking-quiz-whole.tsx | 347 ++++++++++++++++++ .../[setId]/take/quiz-status-sidebar.tsx | 128 ++++--- .../sections/before-take-quiz-section.tsx | 103 ++++-- .../[setId]/take/taking-quiz-client.tsx | 327 ++++++++++------- .../[setId]/attempts/[attemptId]/route.ts | 153 ++++++++ .../quiz-sets/[setId]/attempts/route.ts | 88 +++++ .../[wsId]/quiz-sets/[setId]/results/route.ts | 222 ++++------- .../[wsId]/quiz-sets/[setId]/take/route.ts | 178 ++++++--- packages/types/src/supabase.ts | 21 ++ 15 files changed, 1388 insertions(+), 444 deletions(-) create mode 100644 apps/db/supabase/migrations/20250615100747_new_migration.sql create mode 100644 apps/db/supabase/migrations/20250615104201_new_migration.sql create mode 100644 apps/db/supabase/migrations/20250615190931_new_migration.sql create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/page.tsx create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/page.tsx create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx create mode 100644 apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/route.ts create mode 100644 apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/route.ts diff --git a/apps/db/supabase/migrations/20250615100747_new_migration.sql b/apps/db/supabase/migrations/20250615100747_new_migration.sql new file mode 100644 index 000000000..be758721e --- /dev/null +++ b/apps/db/supabase/migrations/20250615100747_new_migration.sql @@ -0,0 +1,11 @@ +alter table "public"."workspace_quiz_attempts" add column "duration_seconds" integer; + +alter table "public"."workspace_quiz_attempts" add column "submitted_at" timestamp with time zone not null default now(); + +alter table "public"."workspace_quiz_sets" add column "available_date" timestamp with time zone not null default now(); + +alter table "public"."workspace_quiz_sets" add column "explanation_mode" smallint not null default 0; + +alter table "public"."workspace_quiz_sets" add column "instruction" jsonb; + + diff --git a/apps/db/supabase/migrations/20250615104201_new_migration.sql b/apps/db/supabase/migrations/20250615104201_new_migration.sql new file mode 100644 index 000000000..c3135222c --- /dev/null +++ b/apps/db/supabase/migrations/20250615104201_new_migration.sql @@ -0,0 +1,3 @@ +alter table "public"."workspace_quizzes" add column "instruction" jsonb; + + diff --git a/apps/db/supabase/migrations/20250615190931_new_migration.sql b/apps/db/supabase/migrations/20250615190931_new_migration.sql new file mode 100644 index 000000000..2c9f58f58 --- /dev/null +++ b/apps/db/supabase/migrations/20250615190931_new_migration.sql @@ -0,0 +1,3 @@ +alter table "public"."workspace_quiz_sets" add column "results_released" boolean not null default false; + + diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx index 27b1cc1c2..79b42a01b 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx @@ -43,7 +43,6 @@ export default async function ModuleQuizzesPage({ params }: Props) { const { wsId, moduleId } = await params; const t = await getTranslations(); const quizSets = await getQuizzes(moduleId); - console.log('Quiz Sets:', quizSets); const moduleName = await getModuleName(moduleId); return (
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/page.tsx new file mode 100644 index 000000000..11ac93873 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/page.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { Button } from '@tuturuuu/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; + +interface Option { + id: string; + value: string; + isCorrect: boolean; + explanation: string | null; +} + +interface DetailQuestion { + quizId: string; + question: string; + scoreWeight: number; + selectedOptionId: string | null; + isCorrect: boolean; + scoreAwarded: number; + options: Option[]; +} + +interface AttemptDetail { + attemptId: string; + attemptNumber: number; + totalScore: number; + maxPossibleScore: number; + startedAt: string; + completedAt: string | null; + durationSeconds: number; + explanationMode: 0|1|2; + questions: DetailQuestion[]; +} + +export default function AttemptDetailPage({ + params, +}: { + params: { + wsId: string; + courseId: string; + moduleId: string; + setId: string; + attemptId: string; + }; +}) { + const { wsId, courseId, moduleId, setId, attemptId } = params; + const t = useTranslations(); + const router = useRouter(); + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + setLoading(true); + try { + const res = await fetch( + `/api/quiz-sets/${setId}/attempts/${attemptId}` + ); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || 'Error'); + setDetail(json); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + load(); + }, [setId, attemptId]); + + if (loading) return

{t('ws-quizzes.loading')||'Loading...'}

; + if (error) return

{error}

; + if (!detail) return null; + + return ( +
+

+ {t('ws-quizzes.attempt')} #{detail.attemptNumber} +

+

+ {t('ws-quizzes.score')}: {detail.totalScore} / {detail.maxPossibleScore} +

+

+ {t('ws-quizzes.duration')||'Duration'}: {Math.floor(detail.durationSeconds/60)}m{' '} + {detail.durationSeconds % 60}s +

+ + + {detail.questions.map((q, idx) => ( + + + + {idx + 1}. {q.question}{' '} + + ({t('ws-quizzes.points')||'Points'}: {q.scoreWeight}) + + + + + {q.options.map((opt) => { + const selected = opt.id === q.selectedOptionId; + const correct = opt.isCorrect; + const showExplanation = + detail.explanationMode === 2 || + (detail.explanationMode === 1 && correct); + + return ( +
+
+ + {opt.value} {selected && '←'} + + {correct && ( + + )} +
+ {showExplanation && opt.explanation && ( +

+ {opt.explanation} +

+ )} +
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/page.tsx new file mode 100644 index 000000000..e5a9f9452 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; +import { Button } from '@tuturuuu/ui/button'; + +interface AttemptSummary { + attemptId: string; + attemptNumber: number; + totalScore: number; + startedAt: string; + completedAt: string | null; + durationSeconds: number; +} + +export default function AttemptsListPage({ + params, +}: { + params: { wsId: string; courseId: string; moduleId: string; setId: string }; +}) { + const { wsId, courseId, moduleId, setId } = params; + const t = useTranslations(); + const router = useRouter(); + + const [attempts, setAttempts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + setLoading(true); + try { + const res = await fetch( + `/api/quiz-sets/${setId}/attempts` + ); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || 'Error'); + setAttempts(json.attempts); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + load(); + }, [setId]); + + if (loading) return

{t('ws-quizzes.loading')||'Loading...'}

; + if (error) return

{error}

; + if (!attempts.length) { + return

{t('ws-quizzes.no_attempts_found')||'No attempts found.'}

; + } + + return ( +
+

{t('ws-quizzes.past_attempts')||'Past Attempts'}

+ {attempts.map((att) => ( + { + router.push( + `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/attempts/${att.attemptId}` + ); + }}> + + + {t('ws-quizzes.attempt')} #{att.attemptNumber} + + + +
+

+ {t('ws-quizzes.score')}: {att.totalScore} +

+

+ {t('ws-quizzes.started_at')||'Started'}:{' '} + {new Date(att.startedAt).toLocaleString()} + {att.completedAt && ( + <> | {t('ws-quizzes.completed_at')||'Completed'}:{' '} + {new Date(att.completedAt).toLocaleString()} + )} +

+
+
+ {t('ws-quizzes.duration')||'Duration'}:{' '} + {Math.floor(att.durationSeconds/60)}m {att.durationSeconds%60}s +
+
+
+ ))} +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx new file mode 100644 index 000000000..3cad9a060 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx @@ -0,0 +1,347 @@ +/* eslint-disable no-undef */ +import { Alert, AlertDescription } from '@tuturuuu/ui/alert'; +import { Badge } from '@tuturuuu/ui/badge'; +import { Button } from '@tuturuuu/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@tuturuuu/ui/card'; +import { Separator } from '@tuturuuu/ui/separator'; +import { + AlertTriangle, + Calendar, + CheckCircle, + Clock, + Info, + Play, + RotateCcw, +} from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +export interface AttemptSummary { + attemptId: string; + attemptNumber: number; + submittedAt: string; // ISO timestamp + durationSeconds: number; +} + +interface QuizData { + setId: string; + setName: string; + availableDate: string | null; + dueDate: string | null; + attemptLimit: number | null; + attemptsSoFar: number; + timeLimitMinutes: number | null; + explanationMode: 0 | 1 | 2; + instruction: string | null; + releasePointsImmediately: boolean; + attempts: AttemptSummary[]; +} + +interface BeforeTakingQuizWholeProps { + quizData: QuizData; + isPastDue: boolean; + isAvailable: boolean; + onStart: () => void; +} + +const formatDate = (dateString: string | null) => { + if (!dateString) return null; + return new Date(dateString).toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; +const formatDuration = (secs: number) => { + const m = Math.floor(secs / 60) + .toString() + .padStart(2, '0'); + const s = (secs % 60).toString().padStart(2, '0'); + return `${m}:${s}`; +}; + +export default function BeforeTakingQuizWhole({ + quizData, + isPastDue, + isAvailable, + onStart, +}: BeforeTakingQuizWholeProps) { + const [isStarting, setIsStarting] = useState(false); + const router = useRouter(); + + const attemptsRemaining = quizData.attemptLimit + ? quizData.attemptLimit - quizData.attemptsSoFar + : null; + + // You cannot start again if points are released + const canRetake = + isAvailable && + !isPastDue && + !((attemptsRemaining == 0) && quizData.releasePointsImmediately); + + const handleStartQuiz = () => { + setIsStarting(true); + // Simulate navigation delay + setTimeout(() => { + alert('Starting quiz... (This would navigate to the actual quiz)'); + setIsStarting(false); + onStart(); // Call the onStart callback to handle actual quiz start logic + }, 2000); + }; + + const viewAttemptDetailed = (att: AttemptSummary) => { + router.push( + `/dashboard/quizzes/${quizData.setId}/attempts/${att.attemptId}` + ); + }; + + return ( +
+
+ {/* Header */} +
+

+ {quizData.setName} +

+

+ Review the information below before starting your exam +

+ + {/* Start Button */} +
+ + +

+ {canRetake + ? "Click the button above when you're ready to begin" + : quizData.releasePointsImmediately + ? 'Points have been released—no further attempts allowed.' + : 'You cannot start at this time.'} +

+
+
+ + {/* Status Alert */} + {!canRetake && !quizData.releasePointsImmediately && ( + + + + {isPastDue + ? 'This quiz is overdue and can no longer be taken.' + : !isAvailable + ? 'This quiz is not yet available.' + : 'You have no remaining attempts for this quiz.'} + + + )} + +
+ {/* Quiz Information */} + + + + + Quiz Information + + + +
+ + Quiz ID + + {quizData.setId} +
+ + + +
+ + Time Limit + +
+ + + {quizData.timeLimitMinutes + ? `${quizData.timeLimitMinutes} minutes` + : 'No time limit'} + +
+
+ +
+ + Attempts + +
+ + + {quizData.attemptsSoFar} of{' '} + {quizData.attemptLimit || 'unlimited'} used + +
+
+ +
+ + Explanations + + + {quizData.explanationMode === 0 + ? 'None during or after' + : quizData.explanationMode === 1 + ? 'Correct-only after release' + : 'All after release'} + +
+
+
+ + {/* Schedule Information */} + + + + + Schedule + + + +
+
+ + Available From + + {isAvailable && ( + + )} +
+

+ {formatDate(quizData.availableDate) || + 'Immediately available'} +

+
+ + + +
+
+ + Due Date + + {isPastDue && ( + + )} +
+

+ {formatDate(quizData.dueDate) || 'No due date set'} +

+
+ + {attemptsRemaining !== null && ( + <> + +
+

+ {attemptsRemaining} attempt + {attemptsRemaining !== 1 ? 's' : ''} remaining +

+
+ + )} +
+
+
+ + {/* Instructions */} + + + Instructions + + Please read carefully before starting + + + + {quizData.instruction ? ( +

+ {quizData.instruction} +

+ ) : ( +
+

• Make sure you have a stable internet connection

+

• You cannot pause the quiz once started

+

• All questions must be answered before submitting

+

• Your progress will be automatically saved

+ {quizData.timeLimitMinutes && ( +

+ • You have {quizData.timeLimitMinutes} minutes to complete + this quiz +

+ )} + {quizData.explanationMode === 0 && ( +

• Answer explanations will be shown after submission

+ )} +
+ )} +
+
+ + {quizData.attempts.length > 0 && ( + + + Past Attempts + + {quizData.releasePointsImmediately + ? 'You can review your detailed answers below.' + : 'You can view summary until points are released.'} + + + + {quizData.attempts.map((att) => ( +
+
+ Attempt #{att.attemptNumber} —{' '} + {formatDate(att.submittedAt)} — duration{' '} + {formatDuration(att.durationSeconds)} +
+ {quizData.releasePointsImmediately ? ( + + ) : null} +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx index 88be91e22..6cdd0ab98 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx @@ -1,125 +1,133 @@ -import { useCallback } from 'react'; +'use client'; -const onQuestionJump = (questionIndex: number) => { - const element = document.getElementById(`quiz-${questionIndex}`); // or use questionId - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); - element.focus(); // Optional: set focus to the question - } -}; -// ─── TYPES ───────────────────────────────────────────────────────────────────── - -type Option = { - id: string; - value: string; -}; +import React, { useCallback } from 'react'; export type Question = { quizId: string; question: string; score: number; - options: Option[]; + options: { id: string; value: string }[]; }; interface QuizStatusSidebarProps { questions: Question[]; - selectedAnswers: Record; + selectedAnswers: Record; t: (key: string, options?: Record) => string; } -const QuizStatusSidebar = ({ +export default function QuizStatusSidebar({ questions, selectedAnswers, t, -}: QuizStatusSidebarProps) => { +}: QuizStatusSidebarProps) { + // Count how many questions have at least one selected answer const answeredCount = questions.reduce((count, q) => { - return selectedAnswers[q.quizId] ? count + 1 : count; + const sel = selectedAnswers[q.quizId]; + if (Array.isArray(sel) ? sel.length > 0 : Boolean(sel)) { + return count + 1; + } + return count; }, 0); - // Fallback for t function if not provided or key is missing + // Translation helper (falls back to defaultText if t(key) === key) const translate = useCallback( (key: string, defaultText: string, options: Record = {}) => { - if (typeof t === 'function') { - const translation = t(key, options); - // i18next might return the key if not found, so check against that too - return translation === key ? defaultText : translation || defaultText; - } - return defaultText; + const msg = t(key, options); + return msg === key ? defaultText : msg; }, [t] ); + // Scroll smoothly to the question container + const onQuestionJump = useCallback((idx: number) => { + const el = document.getElementById(`quiz-${idx}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.focus(); + } + }, []); + + const pct = questions.length + ? Math.round((answeredCount / questions.length) * 100) + : 0; + return ( -

- {t('ws-quizzes.attempts-remaining') || 'Attempts Remaining'} + {t('results.attempts-remaining') || 'Attempts Remaining'}

- {attemptsRemaining} {t('ws-quizzes.left') || 'left'} + {attemptsRemaining} {t('results.left') || 'left'}

@@ -158,24 +154,24 @@ export default function ShowResultSummarySection({

{scorePercentage >= 90 - ? t('ws-quizzes.excellent-work') || 'Excellent Work!' + ? t('results.excellent-work') || 'Excellent Work!' : scorePercentage >= 70 - ? t('ws-quizzes.good-job') || 'Good Job!' + ? t('results.good-job') || 'Good Job!' : scorePercentage >= 50 - ? t('ws-quizzes.keep-practicing') || 'Keep Practicing!' - : t('ws-quizzes.needs-improvement') || 'Needs Improvement'} + ? t('results.keep-practicing') || 'Keep Practicing!' + : t('results.needs-improvement') || 'Needs Improvement'}

{scorePercentage >= 90 - ? t('ws-quizzes.outstanding-performance') || + ? t('results.outstanding-performance') || 'Outstanding performance! You have mastered this material.' : scorePercentage >= 70 - ? t('ws-quizzes.solid-understanding') || + ? t('results.solid-understanding') || 'You show a solid understanding of the material.' : scorePercentage >= 50 - ? t('ws-quizzes.room-for-improvement') || + ? t('results.room-for-improvement') || "There's room for improvement. Consider reviewing the material." - : t('ws-quizzes.review-recommended') || + : t('results.review-recommended') || 'We recommend reviewing the material and trying again.'}

@@ -188,24 +184,30 @@ export default function ShowResultSummarySection({
{/* Additional Info */}
-

- {t('ws-quizzes.quiz-completed-at') || 'Quiz completed at'}{' '} - {new Date().toLocaleString()} -

+ {quizMeta.completedAt && ( +

+ {t('results.quiz-completed-at') || 'Quiz completed at'}{' '} + {new Date(quizMeta.completedAt).toLocaleString()} +

+ )} {quizMeta.timeLimitMinutes && (

- {t('ws-quizzes.time-limit') || 'Time limit'}:{' '} - {quizMeta.timeLimitMinutes} {t('ws-quizzes.minutes') || 'minutes'} + {t('results.time-limit') || 'Time limit'}:{' '} + {quizMeta.timeLimitMinutes} {t('results.minutes') || 'minutes'}

)}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx index 91588ce21..61923c2b3 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx @@ -14,6 +14,7 @@ import { Timer, XCircle, } from 'lucide-react'; +import { useTranslations } from 'next-intl'; export interface AttemptSummaryDTO { attemptId: string; @@ -35,6 +36,7 @@ export default function AttemptSummaryView({ summary: AttemptSummaryDTO; backToTakeQuiz: () => void; }) { + const t = useTranslations('ws-quizzes'); const fmtDate = (iso: string | null) => iso ? new Date(iso).toLocaleString('en-US', { @@ -84,16 +86,16 @@ export default function AttemptSummaryView({
{/* Header */}
-

Attempt Summary

-

- Review your quiz attempt details and responses -

+

+ {t('summary.title')} +

+

{t('summary.description')}

@@ -102,7 +104,9 @@ export default function AttemptSummaryView({ - Attempt #{summary.attemptNumber} + {t('summary.attempt_number', { + number: summary.attemptNumber, + })} @@ -113,7 +117,9 @@ export default function AttemptSummaryView({
-

Submitted

+

+ {t('summary.submitted')} +

{fmtDate(summary.submittedAt)}

@@ -126,7 +132,9 @@ export default function AttemptSummaryView({
-

Duration

+

+ {t('summary.duration')} +

{fmtDur(summary.durationSeconds)}

@@ -139,9 +147,15 @@ export default function AttemptSummaryView({
-

Completion

+

+ {t('summary.completion')} +

- {answeredQuestions} of {totalQuestions} questions + {/* {answeredQuestions} of {totalQuestions} questions */} + {t('summary.answered_of_total', { + answered: answeredQuestions, + total: totalQuestions, + })}

@@ -151,7 +165,7 @@ export default function AttemptSummaryView({
- Progress + {t('summary.progress')} {completionRate}% @@ -168,10 +182,12 @@ export default function AttemptSummaryView({

- Questions & Responses + {t('summary.questions_and_responses')}

- {summary.questions.length} questions + {t('summary.questions_count', { + count: summary.questions.length, + })}
@@ -205,7 +221,7 @@ export default function AttemptSummaryView({ variant="default" className="bg-dynamic-green" > - Answered + {t('summary.answered')}
) : ( @@ -215,7 +231,7 @@ export default function AttemptSummaryView({ variant="secondary" className="bg-orange-100 text-orange-700" > - Skipped + {t('summary.skipped')}
)} @@ -228,7 +244,7 @@ export default function AttemptSummaryView({ {/* Selected Answer */}
- Your Response: + {t('summary.your_response')} {isAnswered && selectedOptionText ? (
@@ -246,7 +262,7 @@ export default function AttemptSummaryView({
- No answer provided + {t('summary.no_answer')}
@@ -256,7 +272,7 @@ export default function AttemptSummaryView({ {/* All Options */}
- Available Options: + {t('summary.available_options')}
{q.options.map((option) => { @@ -309,7 +325,9 @@ export default function AttemptSummaryView({ {summary.attemptNumber}
- Attempt Number + {t('summary.summary_stats.attempt_number', { + number: summary.attemptNumber, + })}
@@ -317,7 +335,7 @@ export default function AttemptSummaryView({ {answeredQuestions}
- Questions Answered + {t('summary.summary_stats.answered')}
@@ -325,7 +343,7 @@ export default function AttemptSummaryView({ {totalQuestions - answeredQuestions}
- Questions Skipped + {t('summary.summary_stats.skipped')}
@@ -333,7 +351,7 @@ export default function AttemptSummaryView({ {fmtDur(summary.durationSeconds)}
- Time Taken + {t('summary.summary_stats.time_taken')}
@@ -345,7 +363,7 @@ export default function AttemptSummaryView({ className="mt-3 w-full border border-dynamic-purple bg-dynamic-purple/20 md:w-auto" onClick={backToTakeQuiz} > - Back to Quiz + {t('summary.back_to_quiz')} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx index 746ff79cd..e8a7a5f2f 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx @@ -1,4 +1,3 @@ -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/result/page.tsx 'use client'; import ShowAttemptDetailSection, { @@ -15,8 +14,6 @@ import { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/result/page.tsx - // const dummyAttemptDetail: AttemptDetailDTO = { // attemptId: "att_123456", // attemptNumber: 1, @@ -239,7 +236,6 @@ export default function QuizResultPage({
{/* Summary */} {/* Detailed per-question breakdown */} - +
); } @@ -266,4 +263,31 @@ export default function QuizResultPage({ return ( ); + + // return ( + //
+ // {/* Summary */} + // + + // {/* Detailed per-question breakdown */} + // + //
+ // ); } diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx index 87223e8e9..5640f9c9b 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx @@ -22,6 +22,7 @@ import { Play, RotateCcw, } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; @@ -86,6 +87,7 @@ export default function BeforeTakingQuizWhole({ courseId, moduleId, }: BeforeTakingQuizWholeProps) { + const t = useTranslations('ws-quizzes'); const router = useRouter(); const [isStarting, setIsStarting] = useState(false); @@ -137,9 +139,7 @@ export default function BeforeTakingQuizWhole({

{quizData.setName}

-

- Review the information below before starting your exam -

+

{t('quiz.review-info')}

{/* Start Button */} {canRetake && ( @@ -152,25 +152,17 @@ export default function BeforeTakingQuizWhole({ {isStarting ? ( <>
- Starting Quiz... + {t('quiz.starting-quiz')} ) : ( <> - Start Quiz + {t('quiz.start-quiz')} )}

- {canRetake - ? 'Click to begin your attempt' - : quizData.resultsReleased - ? 'Results are out—no further attempts' - : isPastDue - ? 'Quiz is past due' - : !isAvailable - ? 'Quiz not yet available' - : 'No attempts remaining'} + {t('quiz.click-to-begin')}

)} @@ -185,17 +177,17 @@ export default function BeforeTakingQuizWhole({ }} className="border border-dynamic-purple bg-dynamic-purple/20 px-8 py-3 text-lg text-primary hover:bg-primary-foreground hover:text-dynamic-purple" > - View Result + {t('quiz.view-result')}

- View result of your final attempt + {t('quiz.view-final-attempt')}

)} {!isAvailable ? ( - This quiz is not yet available. Please check back later. + {t('quiz.quiz-not-available-message')} ) : ( @@ -204,8 +196,8 @@ export default function BeforeTakingQuizWhole({ {isPastDue - ? 'This quiz is past its due date. You cannot start a new attempt at this time.' - : 'You have no attempts remaining for this quiz.'} + ? t('quiz.quiz-past-due-message') + : t('quiz.no-attempts-message')} ) @@ -219,13 +211,13 @@ export default function BeforeTakingQuizWhole({ - Quiz Information + {t('info.quiz-information')}
- Quiz ID + {t('info.quiz-id')} {quizData.setId}
@@ -233,20 +225,20 @@ export default function BeforeTakingQuizWhole({
- Time Limit + {t('info.time-limit')}
{quizData.timeLimitMinutes - ? `${quizData.timeLimitMinutes} minutes` - : 'No time limit'} + ? `${quizData.timeLimitMinutes} ${t('info.minutes')}` + : t('info.no-time-limit')}
- Attempts Used + {t('info.attempts-used')}
@@ -258,14 +250,14 @@ export default function BeforeTakingQuizWhole({
- Explanations + {t('info.explanations')} {quizData.explanationMode === 0 - ? 'None' + ? t('info.explanation-modes.none') : quizData.explanationMode === 1 - ? 'Correct only after release' - : 'All after release'} + ? t('info.explanation-modes.correct-after-release') + : t('info.explanation-modes.all-after-release')}
@@ -275,14 +267,14 @@ export default function BeforeTakingQuizWhole({ - Schedule + {t('info.schedule')}
- Available From + {t('info.available-from')} {isAvailable && ( @@ -294,7 +286,7 @@ export default function BeforeTakingQuizWhole({
- Due Date + {t('info.due-date')} {isPastDue && ( @@ -306,8 +298,10 @@ export default function BeforeTakingQuizWhole({ <>

- {attemptsRemaining} attempt - {attemptsRemaining !== 1 ? 's' : ''} remaining + {attemptsRemaining}{' '} + {attemptsRemaining !== 1 + ? t('info.attempts-remaining') + : t('info.attempt-remaining')}

)} @@ -318,8 +312,8 @@ export default function BeforeTakingQuizWhole({ {/* Instructions */} - Instructions - Read before you begin + {t('instructions.title')} + {t('instructions.subtitle')} {quizData.instruction ? ( @@ -330,12 +324,17 @@ export default function BeforeTakingQuizWhole({ /> ) : (
-

• Make sure you have a stable internet connection

-

• You cannot pause the quiz once started

-

• All questions must be answered before submitting

-

• Your progress will be automatically saved

+

• {t('instructions.default.stable-connection')}

+

• {t('instructions.default.cannot-pause')}

+

• {t('instructions.default.answer-all')}

+

• {t('instructions.default.auto-save')}

{quizData.timeLimitMinutes && ( -
  • You have {quizData.timeLimitMinutes} minutes
  • +

    + •{' '} + {t('instructions.default.time-limit', { + minutes: quizData.timeLimitMinutes, + })} +

    )}
    )} @@ -346,13 +345,13 @@ export default function BeforeTakingQuizWhole({ {quizData.attemptsSoFar > 0 && ( - Past Attempts + {t('past-attempts.title')} {canViewOldAttemptsNoResults - ? 'Click “View Details” to view your answers' + ? t('past-attempts.view-answers') : canViewOldAttemptsResults - ? 'Click “View Details” to view your results' - : 'Results pending release'} + ? t('past-attempts.view-results') + : t('past-attempts.results-pending')} @@ -362,16 +361,21 @@ export default function BeforeTakingQuizWhole({ className="flex items-center justify-between" >
    - #{att.attemptNumber} at{' '} + {t('past-attempts.attempt-info', { + number: att.attemptNumber, + date: formatDate(att.submittedAt), + duration: formatDuration(att.durationSeconds), + })} + {/* #{att.attemptNumber} at{' '} {formatDate(att.submittedAt)} ( - {formatDuration(att.durationSeconds)}) + {formatDuration(att.durationSeconds)}) */}
    {(canViewOldAttemptsResults || canViewOldAttemptsNoResults) && ( )}
    diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx index 6cdd0ab98..3de587275 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import React, { useCallback } from 'react'; export type Question = { @@ -12,14 +13,13 @@ export type Question = { interface QuizStatusSidebarProps { questions: Question[]; selectedAnswers: Record; - t: (key: string, options?: Record) => string; } export default function QuizStatusSidebar({ questions, selectedAnswers, - t, }: QuizStatusSidebarProps) { + const t = useTranslations('ws-quizzes.quiz-status'); // Count how many questions have at least one selected answer const answeredCount = questions.reduce((count, q) => { const sel = selectedAnswers[q.quizId]; @@ -29,15 +29,6 @@ export default function QuizStatusSidebar({ return count; }, 0); - // Translation helper (falls back to defaultText if t(key) === key) - const translate = useCallback( - (key: string, defaultText: string, options: Record = {}) => { - const msg = t(key, options); - return msg === key ? defaultText : msg; - }, - [t] - ); - // Scroll smoothly to the question container const onQuestionJump = useCallback((idx: number) => { const el = document.getElementById(`quiz-${idx}`); @@ -54,20 +45,16 @@ export default function QuizStatusSidebar({ return (
    @@ -328,7 +328,7 @@ export default function TakingQuizClient({ {idx + 1}. {q.question}{' '} - ({t('ws-quizzes.points')}: {q.score}) + ({t('points')}: {q.score}) @@ -357,7 +357,9 @@ export default function TakingQuizClient({ ANSWERS_KEY, JSON.stringify(nextState) ); - } catch {} + } catch { + // Ignore localStorage errors + } }} />
    @@ -431,12 +435,10 @@ export default function TakingQuizClient({
    diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status.tsx index cdc961a8c..8cbc110f3 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status.tsx @@ -1,4 +1,5 @@ import { Eye, EyeClosed } from '@tuturuuu/ui/icons'; +import { useTranslations } from 'next-intl'; import { useState } from 'react'; // Format seconds as MM:SS @@ -11,23 +12,22 @@ const formatSeconds = (sec: number) => { }; interface TimeElapsedStatusProps { - t: (key: string, options?: Record) => string; isCountdown: boolean; timeLeft: number | null; } export default function TimeElapsedStatus({ - t, isCountdown, timeLeft, }: TimeElapsedStatusProps) { + const t = useTranslations('ws-quizzes.time'); const [isVisible, setIsVisible] = useState(true); const toggleVisibility = () => setIsVisible((prev) => !prev); const timerLabel = isCountdown - ? t('ws-quizzes.time_remaining') || 'Time Remaining' - : t('ws-quizzes.time_elapsed') || 'Time Elapsed'; + ? t('remaining') || 'Time Remaining' + : t('elapsed') || 'Time Elapsed'; const timerColorClass = isCountdown && timeLeft !== null && timeLeft <= 60 @@ -43,8 +43,8 @@ export default function TimeElapsedStatus({ timeLeft !== null ? formatSeconds(timeLeft) : '--:--' }` : timeLeft !== null - ? t('ws-quizzes.hidden_time_remaining') || 'Time Hidden' - : t('ws-quizzes.hidden_time_elapsed') || 'Time Hidden'} + ? t('hidden_remaining') || 'Time Hidden' + : t('hidden_elapsed') || 'Time Hidden'}

    -
    - ); - } - - if (!detail) { - // Shouldn't happen, but guard anyway - return null; - } - - if ('totalScore' in detail) { - return ( -
    - {/* Summary */} - - - {/* Detailed per-question breakdown */} - -
    - ); - } - + const { wsId, courseId, moduleId, setId } = await params; return ( - + ); - - // return ( - //
    - // {/* Summary */} - // - - // {/* Detailed per-question breakdown */} - // - //
    - // ); } diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client.tsx new file mode 100644 index 000000000..ca2c747c7 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client.tsx @@ -0,0 +1,294 @@ +'use client'; + +import ShowAttemptDetailSection, { + AttemptDetailDTO, +} from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-attempt-detail-section'; +import ShowResultSummarySection from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-result-summary-section'; +import AttemptSummaryView, { + AttemptSummaryDTO, +} from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view'; +import { Alert, AlertDescription } from '@tuturuuu/ui/alert'; +import { Button } from '@tuturuuu/ui/button'; +import { LoadingIndicator } from '@tuturuuu/ui/custom/loading-indicator'; +import { useTranslations } from 'next-intl'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +// const dummyAttemptDetail: AttemptDetailDTO = { +// attemptId: "att_123456", +// attemptNumber: 1, +// totalScore: 75, +// maxPossibleScore: 100, +// startedAt: "2024-03-20T10:00:00Z", +// completedAt: "2024-03-20T10:15:30Z", +// durationSeconds: 930, // 15 minutes and 30 seconds +// explanationMode: 2, // 0: no explanations, 1: only correct answers, 2: all explanations +// questions: [ +// { +// quizId: "q_1", +// question: "What is the capital of France?", +// scoreWeight: 25, +// selectedOptionId: "opt_2", +// isCorrect: true, +// scoreAwarded: 25, +// options: [ +// { +// id: "opt_1", +// value: "London", +// isCorrect: false, +// explanation: "London is the capital of the United Kingdom, not France." +// }, +// { +// id: "opt_2", +// value: "Paris", +// isCorrect: true, +// explanation: "Paris is the capital and largest city of France." +// }, +// { +// id: "opt_3", +// value: "Berlin", +// isCorrect: false, +// explanation: "Berlin is the capital of Germany, not France." +// }, +// { +// id: "opt_4", +// value: "Madrid", +// isCorrect: false, +// explanation: "Madrid is the capital of Spain, not France." +// } +// ] +// }, +// { +// quizId: "q_2", +// question: "Which planet is known as the Red Planet?", +// scoreWeight: 25, +// selectedOptionId: "opt_6", +// isCorrect: false, +// scoreAwarded: 25, +// options: [ +// { +// id: "opt_5", +// value: "Mars", +// isCorrect: true, +// explanation: "Mars is called the Red Planet because of its reddish appearance due to iron oxide on its surface." +// }, +// { +// id: "opt_6", +// value: "Venus", +// isCorrect: false, +// explanation: "Venus is often called Earth's twin due to its similar size and mass." +// }, +// { +// id: "opt_7", +// value: "Jupiter", +// isCorrect: false, +// explanation: "Jupiter is the largest planet in our solar system." +// } +// ] +// }, +// { +// quizId: "q_3", +// question: "What is the chemical symbol for gold?", +// scoreWeight: 25, +// selectedOptionId: "opt_9", +// isCorrect: true, +// scoreAwarded: 25, +// options: [ +// { +// id: "opt_8", +// value: "Ag", +// isCorrect: false, +// explanation: "Ag is the chemical symbol for silver." +// }, +// { +// id: "opt_9", +// value: "Au", +// isCorrect: true, +// explanation: "Au comes from the Latin word 'aurum' meaning gold." +// }, +// { +// id: "opt_10", +// value: "Fe", +// isCorrect: false, +// explanation: "Fe is the chemical symbol for iron." +// } +// ] +// }, +// { +// quizId: "q_4", +// question: "Who painted the Mona Lisa?", +// scoreWeight: 25, +// selectedOptionId: null, +// isCorrect: false, +// scoreAwarded: 0, +// options: [ +// { +// id: "opt_11", +// value: "Leonardo da Vinci", +// isCorrect: true, +// explanation: "Leonardo da Vinci painted the Mona Lisa between 1503 and 1519." +// }, +// { +// id: "opt_12", +// value: "Vincent van Gogh", +// isCorrect: false, +// explanation: "Van Gogh is known for works like 'Starry Night' and 'Sunflowers'." +// }, +// { +// id: "opt_13", +// value: "Pablo Picasso", +// isCorrect: false, +// explanation: "Picasso is known for works like 'Guernica' and pioneering Cubism." +// } +// ] +// } +// ] +// }; + +type ApiResponse = AttemptSummaryDTO | AttemptDetailDTO; + +export default function QuizResultClient({ + wsId, + courseId, + moduleId, + setId, +}: { + wsId: string; + courseId: string; + moduleId: string; + setId: string; +}) { + const t = useTranslations(); + const router = useRouter(); + const search = useSearchParams(); + const attemptId = search.get('attemptId'); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(null); + + useEffect(() => { + if (!attemptId) { + setError(t('ws-quizzes.no_attempt_specified') || 'No attempt specified'); + setLoading(false); + return; + } + + async function load() { + setLoading(true); + setError(null); + + try { + const res = await fetch( + `/api/v1/workspaces/${wsId}/quiz-sets/${setId}/attempts/${attemptId}`, + { cache: 'no-store' } + ); + const json = await res.json(); + + if (!res.ok) { + setError( + json.error || + t('ws-quizzes.failed_load') || + 'Failed to load results' + ); + } else { + setDetail(json); + } + } catch { + setError(t('ws-quizzes.network_error') || 'Network error'); + } finally { + setLoading(false); + } + } + + load(); + }, [attemptId, wsId, setId, t]); + + const backToTakeQuizPage = () => { + router.push( + `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/take` + ); + }; + + if (loading) { + return ; + } + + if (error) { + return ( +
    + + {error} + + +
    + ); + } + + if (!detail) { + // Shouldn't happen, but guard anyway + return null; + } + + if ('totalScore' in detail) { + return ( +
    + {/* Summary */} + + + {/* Detailed per-question breakdown */} + +
    + ); + } + + return ( + + ); + + // return ( + //
    + // {/* Summary */} + // + + // {/* Detailed per-question breakdown */} + // + //
    + // ); +} diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts index afd904b88..35a5e3ff8 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts @@ -1,3 +1,4 @@ +// File: app/api/quiz-sets/[setId]/submit/route.ts import { createClient } from '@tuturuuu/supabase/next/server'; import { NextRequest, NextResponse } from 'next/server'; @@ -8,89 +9,100 @@ type SubmissionBody = { }>; }; -type RawRow = { - quiz_id: string; - workspace_quizzes: { - score: number; - quiz_options: Array<{ - id: string; - is_correct: boolean; - }>; - }; -}; - export async function POST( request: NextRequest, - { params }: { params: Promise<{ setId: string }> } + { params }: { params: { setId: string } } ) { - const { setId } = await params; - const supabase = await createClient(); + const { setId } = params; + const sb = await createClient(); - // 1) Get current user + // 1) Authenticate const { data: { user }, - error: userErr, - } = await supabase.auth.getUser(); - if (userErr || !user) { + error: uErr, + } = await sb.auth.getUser(); + if (uErr || !user) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } const userId = user.id; - // 2) Parse request body + // 2) Parse and validate body let body: SubmissionBody; try { body = await request.json(); - } catch (e) { + } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } - const { answers } = body; - if (!Array.isArray(answers) || answers.length === 0) { + if (!Array.isArray(body.answers) || body.answers.length === 0) { return NextResponse.json({ error: 'No answers provided' }, { status: 400 }); } - // 3) Re-compute attempt_count for this user/set - const { data: prevAttempts, error: attErr } = await supabase + // 3) Count existing attempts + const { data: prevAtt, error: countErr } = await sb .from('workspace_quiz_attempts') .select('attempt_number', { count: 'exact', head: false }) .eq('user_id', userId) .eq('set_id', setId); - - if (attErr) { + if (countErr) { return NextResponse.json( { error: 'Error counting attempts' }, { status: 500 } ); } - const attemptsCount = prevAttempts?.length || 0; + const attemptsCount = prevAtt?.length ?? 0; - // 4) Fetch attempt_limit for this quiz set - const { data: setRow, error: setErr } = await supabase + // 4) Load quiz-set constraints & flags + const { data: setRow, error: setErr } = await sb .from('workspace_quiz_sets') - .select('attempt_limit') + .select( + ` + attempt_limit, + time_limit_minutes, + available_date, + due_date, + allow_view_old_attempts, + results_released, + explanation_mode + ` + ) .eq('id', setId) .maybeSingle(); - if (setErr || !setRow) { return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); } - const { attempt_limit } = setRow; - if ( - attempt_limit !== null && - attempt_limit !== undefined && - attemptsCount >= attempt_limit - ) { + const { + attempt_limit, + time_limit_minutes, + available_date, + due_date, + allow_view_old_attempts, + results_released, + explanation_mode, + } = setRow; + + // 5) Enforce limits & dates + const now = new Date(); + if (new Date(available_date) > now) { return NextResponse.json( - { error: 'Maximum attempts reached' }, + { error: 'Quiz not yet available', availableDate: available_date }, + { status: 403 } + ); + } + if (new Date(due_date) < now) { + return NextResponse.json( + { error: 'Quiz past due', dueDate: due_date }, + { status: 403 } + ); + } + if (attempt_limit !== null && attemptsCount >= attempt_limit) { + return NextResponse.json( + { error: 'Maximum attempts reached', attemptsSoFar: attemptsCount }, { status: 403 } ); } - // 5) We will create a new attempt row with attempt_number = attemptsCount + 1 - const newAttemptNumber = attemptsCount + 1; - - // 6) Fetch "correct" answers + per-question score for each quiz in this set. - // Notice we nest `quiz_options` under `workspace_quizzes`: - const { data: correctRaw, error: corrErr } = await supabase + // 6) Fetch correct answers & weights + const { data: correctRaw, error: corrErr } = await sb .from('quiz_set_quizzes') .select( ` @@ -105,94 +117,73 @@ export async function POST( ` ) .eq('set_id', setId); - if (corrErr) { return NextResponse.json( - { error: 'Error fetching correct answers' }, + { error: 'Error fetching answers' }, { status: 500 } ); } - - // 7) Tell TypeScript: "Trust me—this matches RawRow[]" - const correctRows = (correctRaw as unknown as RawRow[]) ?? []; - - // Build a map: quizId → { score: number, correctOptionId: string } - const quizMap = new Map(); - correctRows.forEach((row) => { - const qId = row.quiz_id; - const weight = row.workspace_quizzes.score; - - // Find exactly one correct option (is_correct = true) - const correctOption = row.workspace_quizzes.quiz_options.find( - (opt) => opt.is_correct - )?.id; - - quizMap.set(qId, { score: weight, correctOptionId: correctOption || '' }); + type R = { + quiz_id: string; + workspace_quizzes: { + score: number; + quiz_options: Array<{ id: string; is_correct: boolean }>; + }; + }; + const quizMap = new Map(); + (correctRaw as R[]).forEach((r) => { + const correctOpt = + r.workspace_quizzes.quiz_options.find((o) => o.is_correct)?.id || ''; + quizMap.set(r.quiz_id, { + score: r.workspace_quizzes.score, + correctId: correctOpt, + }); }); - // 8) Loop through submitted answers, compare to correctOptionId, sum up total_score + // 7) Score each submitted answer let totalScore = 0; - const answerInserts: Array<{ - quiz_id: string; - selected_option_id: string; - is_correct: boolean; - score_awarded: number; - }> = []; - - for (const { quizId, selectedOptionId } of answers) { - const qInfo = quizMap.get(quizId); - if (!qInfo) { - // If the quizId isn't in our map, ignore it - continue; - } - const { score: weight, correctOptionId } = qInfo; - const isCorrect = selectedOptionId === correctOptionId; - const awarded = isCorrect ? weight : 0; + const answersToInsert = body.answers.map(({ quizId, selectedOptionId }) => { + const info = quizMap.get(quizId); + const isCorrect = info?.correctId === selectedOptionId; + const awarded = isCorrect ? info!.score : 0; totalScore += awarded; - - answerInserts.push({ + return { quiz_id: quizId, selected_option_id: selectedOptionId, is_correct: isCorrect, score_awarded: awarded, - }); - } + }; + }); - // 9) Insert the attempt row - const { data: insertedAttempt, error: insErr } = await supabase + // 8) Create attempt + const newAttemptNumber = attemptsCount + 1; + const { data: insAtt, error: insErr } = await sb .from('workspace_quiz_attempts') - .insert([ - { - user_id: userId, - set_id: setId, - attempt_number: newAttemptNumber, - total_score: totalScore, - }, - ]) - .select('id') + .insert({ + user_id: userId, + set_id: setId, + attempt_number: newAttemptNumber, + total_score: totalScore, + duration_seconds: 0, // we’ll patch this below + }) + .select('id, started_at') .single(); - - if (insErr || !insertedAttempt) { + if (insErr || !insAtt) { return NextResponse.json( { error: 'Error inserting attempt' }, { status: 500 } ); } - const attemptId = insertedAttempt.id; - // 10) Insert each answer into workspace_quiz_attempt_answers - const { error: ansErr } = await supabase + // 9) Insert answers + const { error: ansErr } = await sb .from('workspace_quiz_attempt_answers') .insert( - answerInserts.map((a) => ({ - attempt_id: attemptId, - quiz_id: a.quiz_id, - selected_option_id: a.selected_option_id, - is_correct: a.is_correct, - score_awarded: a.score_awarded, + answersToInsert.map((a) => ({ + attempt_id: insAtt.id, + ...a, })) ); - if (ansErr) { return NextResponse.json( { error: 'Error inserting answers' }, @@ -200,25 +191,42 @@ export async function POST( ); } - // 11) Mark the attempt’s completed_at timestamp - const { error: updErr } = await supabase + // 10) Mark completed_at & compute duration + const completedAt = new Date().toISOString(); + await sb .from('workspace_quiz_attempts') - .update({ completed_at: new Date().toISOString() }) - .eq('id', attemptId); - - if (updErr) { - console.error('Warning: could not update completed_at', updErr); - // Not fatal—still return success - } - - // 12) Return the result to the client + .update({ + completed_at: completedAt, + duration_seconds: Math.floor( + (Date.now() - new Date(insAtt.started_at).getTime()) / 1000 + ), + }) + .eq('id', insAtt.id); + + // 11) Build the full response DTO return NextResponse.json({ - attemptId, + // attempt meta + attemptId: insAtt.id, attemptNumber: newAttemptNumber, totalScore, maxPossibleScore: Array.from(quizMap.values()).reduce( - (acc, { score }) => acc + score, + (sum, q) => sum + q.score, 0 ), + startedAt: insAtt.started_at, + completedAt, + durationSeconds: Math.floor( + (Date.now() - new Date(insAtt.started_at).getTime()) / 1000 + ), + + // quiz­set context + attemptLimit: attempt_limit, + attemptsSoFar: newAttemptNumber, + timeLimitMinutes: time_limit_minutes, + availableDate: available_date, + dueDate: due_date, + allowViewOldAttempts: allow_view_old_attempts, + resultsReleased: results_released, + explanationMode: explanation_mode, }); } From ba856952fe158ad347c88a2eb4b23369675725d5 Mon Sep 17 00:00:00 2001 From: Nhung Date: Mon, 16 Jun 2025 19:42:24 +0700 Subject: [PATCH 06/17] ops (Taking Quiz UI): fix deploy --- .../quiz-sets/[setId]/result/page.tsx | 1 - .../[setId]/attempts/[attemptId]/route.ts | 41 ++++-- .../[wsId]/quiz-sets/[setId]/results/route.ts | 125 +++++++++++------- .../[wsId]/quiz-sets/[setId]/submit/route.ts | 10 +- .../[wsId]/quiz-sets/[setId]/take/route.ts | 13 +- 5 files changed, 120 insertions(+), 70 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx index 34460eadd..5a50e27ad 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx @@ -1,4 +1,3 @@ -import ResultClient from '@/app/[locale]/(dashboard)/[wsId]/challenges/[challengeId]/results/client'; import QuizResultClient from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client'; export default async function QuizResultPage({ diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/route.ts index ce9099b3e..85f048bef 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/route.ts @@ -2,11 +2,15 @@ import { createClient } from '@tuturuuu/supabase/next/server'; import { NextRequest, NextResponse } from 'next/server'; -export async function GET( - _req: NextRequest, - { params }: { params: { setId: string; attemptId: string } } -) { - const { setId, attemptId } = params; +interface Params { + params: Promise<{ + setId: string; + attemptId: string; + }>; +} + +export async function GET(_req: NextRequest, { params }: Params) { + const { setId, attemptId } = await params; const sb = await createClient(); // 1) Authenticate @@ -66,7 +70,10 @@ export async function GET( .select('quiz_id, selected_option_id, is_correct, score_awarded') .eq('attempt_id', attemptId); if (ansErr || !ansRows) { - return NextResponse.json({ error: 'Error fetching answers' }, { status: 500 }); + return NextResponse.json( + { error: 'Error fetching answers' }, + { status: 500 } + ); } const ansMap = new Map(ansRows.map((a) => [a.quiz_id, a])); @@ -75,7 +82,8 @@ export async function GET( // re-fetch questions including options const { data: sumQRaw, error: sumQErr } = await sb .from('quiz_set_quizzes') - .select(` + .select( + ` quiz_id, workspace_quizzes ( question, @@ -84,10 +92,14 @@ export async function GET( value ) ) - `) + ` + ) .eq('set_id', setId); if (sumQErr || !sumQRaw) { - return NextResponse.json({ error: 'Error fetching summary questions' }, { status: 500 }); + return NextResponse.json( + { error: 'Error fetching summary questions' }, + { status: 500 } + ); } type SumRow = { @@ -127,7 +139,8 @@ export async function GET( // 7a) we need each question’s options, weights, explanations const { data: fullQRaw, error: fullQErr } = await sb .from('quiz_set_quizzes') - .select(` + .select( + ` quiz_id, workspace_quizzes ( question, @@ -139,10 +152,14 @@ export async function GET( explanation ) ) - `) + ` + ) .eq('set_id', setId); if (fullQErr || !fullQRaw) { - return NextResponse.json({ error: 'Error fetching full questions' }, { status: 500 }); + return NextResponse.json( + { error: 'Error fetching full questions' }, + { status: 500 } + ); } type FullRow = { quiz_id: string; diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts index f9e039172..5e4fc826f 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts @@ -1,17 +1,24 @@ // app/api/quiz-sets/[setId]/results/route.ts +import { createClient } from '@tuturuuu/supabase/next/server'; import { NextRequest, NextResponse } from 'next/server'; -import { createClient } from '@tuturuuu/supabase/next/server'; -export async function GET( - _req: NextRequest, - { params }: { params: { setId: string } } -) { - const setId = params.setId; - const sb = await createClient(); +interface Params { + params: Promise<{ + setId: string; + }>; +} + +export async function GET(_req: NextRequest, { params }: Params) { + const { setId } = await params; + const sb = await createClient(); // Auth - const { data:{ user }, error: uErr } = await sb.auth.getUser(); - if (uErr||!user) return NextResponse.json({ error:'Not authenticated' },{status:401}); + const { + data: { user }, + error: uErr, + } = await sb.auth.getUser(); + if (uErr || !user) + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); const uid = user.id; // Check allow_view_results @@ -20,77 +27,95 @@ export async function GET( .select('allow_view_results') .eq('id', setId) .maybeSingle(); - if (sErr||!s) return NextResponse.json({ error:'Not found' },{status:404}); + if (sErr || !s) + return NextResponse.json({ error: 'Not found' }, { status: 404 }); if (!s.allow_view_results) { - return NextResponse.json({ error:'Viewing disabled' }, { status:403 }); + return NextResponse.json({ error: 'Viewing disabled' }, { status: 403 }); } // Fetch correct answers & weight const { data: qRaw, error: qErr } = await sb .from('quiz_set_quizzes') - .select(` + .select( + ` quiz_id, workspace_quizzes(score), quiz_options!inner(value) -- only correct by join filter - `) + ` + ) .eq('set_id', setId) .eq('quiz_options.is_correct', true); - if (qErr) return NextResponse.json({ error:'Q fetch error' }, { status:500 }); + if (qErr) + return NextResponse.json({ error: 'Q fetch error' }, { status: 500 }); - type Q = { quiz_id: string, workspace_quizzes:{score:number}, quiz_options:{value:string} }; - const info = (qRaw as Q[]).map(r=>({ + type Q = { + quiz_id: string; + workspace_quizzes: { score: number }; + quiz_options: { value: string }; + }; + const info = (qRaw as unknown as Q[]).map((r) => ({ quizId: r.quiz_id, weight: r.workspace_quizzes.score, - correct: r.quiz_options.value + correct: r.quiz_options.value, })); - const maxScore = info.reduce((a,c)=>a+c.weight, 0); + const maxScore = info.reduce((a, c) => a + c.weight, 0); // Fetch attempts const { data: aData, error: aErr } = await sb .from('workspace_quiz_attempts') - .select(` + .select( + ` id, attempt_number, total_score, submitted_at, duration_seconds - `) + ` + ) .eq('user_id', uid) .eq('set_id', setId) - .order('attempt_number',{ascending:false}); - if (aErr) return NextResponse.json({ error:'Attempt fetch error' }, { status:500 }); + .order('attempt_number', { ascending: false }); + if (aErr) + return NextResponse.json({ error: 'Attempt fetch error' }, { status: 500 }); + + const results = await Promise.all( + aData!.map(async (att) => { + const { data: ansRows } = await sb + .from('workspace_quiz_attempt_answers') + .select('quiz_id,selected_option_id,is_correct,score_awarded') + .eq('attempt_id', att.id); - const results = await Promise.all(aData!.map(async att=>{ - const { data: ansRows } = await sb - .from('workspace_quiz_attempt_answers') - .select('quiz_id,selected_option_id,is_correct,score_awarded') - .eq('attempt_id',att.id); + const ansMap = new Map(ansRows!.map((r) => [r.quiz_id, r])); + const answers = info.map(async (qi) => { + const ar = ansMap.get(qi.quizId); + return { + quizId: qi.quizId, + correctOption: qi.correct, + selectedOption: ar + ? await (() => + sb + .from('quiz_options') + .select('value') + .eq('id', ar.selected_option_id) + .maybeSingle() + .then((r) => r.data?.value || null))() + : null, + isCorrect: ar?.is_correct ?? false, + scoreAwarded: ar?.score_awarded ?? 0, + }; + }); - const ansMap = new Map(ansRows!.map(r=>[r.quiz_id,r])); - const answers = info.map(async qi=>{ - const ar = ansMap.get(qi.quizId); return { - quizId: qi.quizId, - correctOption: qi.correct, - selectedOption: ar ? await (() => - sb.from('quiz_options').select('value').eq('id',ar.selected_option_id).maybeSingle() - .then(r=>r.data?.value||null) - )() : null, - isCorrect: ar?.is_correct ?? false, - scoreAwarded: ar?.score_awarded ?? 0 + attemptId: att.id, + attemptNumber: att.attempt_number, + totalScore: att.total_score ?? 0, + maxPossibleScore: maxScore, + submittedAt: att.submitted_at, + durationSeconds: att.duration_seconds, + answers, }; - }); - - return { - attemptId: att.id, - attemptNumber: att.attempt_number, - totalScore: att.total_score ?? 0, - maxPossibleScore: maxScore, - submittedAt: att.submitted_at, - durationSeconds: att.duration_seconds, - answers - }; - })); + }) + ); return NextResponse.json({ attempts: results }); } diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts index 35a5e3ff8..7c234bd56 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts @@ -9,11 +9,17 @@ type SubmissionBody = { }>; }; +interface Params { + params: Promise<{ + setId: string; + }>; +} + export async function POST( request: NextRequest, - { params }: { params: { setId: string } } + { params }: Params ) { - const { setId } = params; + const { setId } = await params; const sb = await createClient(); // 1) Authenticate diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts index 16fc8417c..55ad51161 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts @@ -18,11 +18,14 @@ type AttemptSummary = { durationSeconds: number; }; -export async function GET( - _req: NextRequest, - { params }: { params: { setId: string } } -) { - const setId = params.setId; +interface Params { + params: Promise<{ + setId: string; + }>; +} + +export async function GET(_req: NextRequest, { params }: Params) { + const { setId } = await params; const sb = await createClient(); // 1) Auth From c68286300dadd9a97edbdb4f557f17ac3c601e92 Mon Sep 17 00:00:00 2001 From: Nhung Date: Mon, 16 Jun 2025 19:49:40 +0700 Subject: [PATCH 07/17] ops (Taking Quiz UI): fix deploy --- .../api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts index 7c234bd56..7e821047f 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts @@ -1,4 +1,3 @@ -// File: app/api/quiz-sets/[setId]/submit/route.ts import { createClient } from '@tuturuuu/supabase/next/server'; import { NextRequest, NextResponse } from 'next/server'; From 05f77378d48dffe4510e036bb0d15456a4c72bc8 Mon Sep 17 00:00:00 2001 From: Puppychan <32950625+Puppychan@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:52:31 +0000 Subject: [PATCH 08/17] style: apply prettier formatting --- apps/upskii/messages/en.json | 1 - .../show-attempt-detail-section.tsx | 8 ++++---- .../[setId]/take/before-taking-quiz-whole.tsx | 2 +- .../[moduleId]/quiz-sets/[setId]/take/page.tsx | 3 +-- .../quiz-sets/[setId]/take/taking-quiz-client.tsx | 14 +++++--------- .../[wsId]/quiz-sets/[setId]/submit/route.ts | 5 +---- 6 files changed, 12 insertions(+), 21 deletions(-) diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index b916fdf12..e24dcbdaf 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -3992,7 +3992,6 @@ "your_answer": "(Your answer)", "correct_option": "(Correct)", "score_awarded": "Score Awarded" - } }, "ws-reports": { diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-attempt-detail-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-attempt-detail-section.tsx index a9e177df2..1603aad44 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-attempt-detail-section.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-attempt-detail-section.tsx @@ -4,7 +4,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; import { CheckCircle, Circle, XCircle } from 'lucide-react'; import { useTranslations } from 'next-intl'; - export interface AttemptDetailDTO { attemptId: string; attemptNumber: number; @@ -54,8 +53,7 @@ export default function ShowAttemptDetailSection({ {/* Metadata */}
    - {t('results.started_at') || 'Started at'}:{' '} - {fmtDate(detail.startedAt)} + {t('results.started_at') || 'Started at'}: {fmtDate(detail.startedAt)}
    {detail.completedAt && (
    @@ -135,7 +133,9 @@ export default function ShowAttemptDetailSection({ ); })} -
    +
    {t('results.score_awarded') || 'Score Awarded'}:{' '} {q.scoreAwarded} / {q.scoreWeight} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx index 5640f9c9b..d796f7f85 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx @@ -366,7 +366,7 @@ export default function BeforeTakingQuizWhole({ date: formatDate(att.submittedAt), duration: formatDuration(att.durationSeconds), })} - {/* #{att.attemptNumber} at{' '} + {/* #{att.attemptNumber} at{' '} {formatDate(att.submittedAt)} ( {formatDuration(att.durationSeconds)}) */}
    diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx index b1deac99d..239b31bc1 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx @@ -1,5 +1,4 @@ -import TakingQuizClient from "@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client"; - +import TakingQuizClient from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client'; export default async function TakeQuiz({ params, diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx index 7852bdea8..aad8a0f12 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx @@ -1,6 +1,8 @@ 'use client'; -import BeforeTakingQuizWhole, { AttemptSummary } from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole'; +import BeforeTakingQuizWhole, { + AttemptSummary, +} from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole'; import QuizStatusSidebar from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar'; import TimeElapsedStatus from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status'; import { Button } from '@tuturuuu/ui/button'; @@ -296,10 +298,7 @@ export default function TakingQuizClient({ - +
    {sidebarVisible && quizMeta && (
    - + ; } -export async function POST( - request: NextRequest, - { params }: Params -) { +export async function POST(request: NextRequest, { params }: Params) { const { setId } = await params; const sb = await createClient(); From b625bf7b20e98b6ff749c2a4a3addf18ab24e705 Mon Sep 17 00:00:00 2001 From: Nhung Date: Wed, 18 Jun 2025 16:08:19 +0700 Subject: [PATCH 09/17] fix (Taking Quiz UI): fix UI displayed when max attempt limit reached --- .../[setId]/take/before-taking-quiz-whole.tsx | 5 +- .../[setId]/take/taking-quiz-client.tsx | 24 +- .../(dashboard)/[wsId]/quiz-sets/form.tsx | 231 +++++++++++++++++- .../[wsId]/quiz-sets/[setId]/take/route.ts | 164 +++++-------- 4 files changed, 303 insertions(+), 121 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx index 5640f9c9b..9b7eea95f 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx @@ -21,6 +21,7 @@ import { Info, Play, RotateCcw, + TriangleAlert, } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; @@ -193,8 +194,10 @@ export default function BeforeTakingQuizWhole({ ) : ( !quizData.resultsReleased && (isPastDue || attemptsRemaining == 0) && ( - + + {isPastDue ? t('quiz.quiz-past-due-message') : t('quiz.no-attempts-message')} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx index 7852bdea8..17a364463 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx @@ -1,13 +1,15 @@ 'use client'; -import BeforeTakingQuizWhole, { AttemptSummary } from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole'; +import BeforeTakingQuizWhole, { + AttemptSummary, +} from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole'; import QuizStatusSidebar from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar'; import TimeElapsedStatus from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status'; import { Button } from '@tuturuuu/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; import { Checkbox } from '@tuturuuu/ui/checkbox'; import { LoadingIndicator } from '@tuturuuu/ui/custom/loading-indicator'; -import { ListCheck } from '@tuturuuu/ui/icons'; +import { ListCheck, TriangleAlert } from '@tuturuuu/ui/icons'; import { Label } from '@tuturuuu/ui/label'; import { RadioGroup, RadioGroupItem } from '@tuturuuu/ui/radio-group'; import { useTranslations } from 'next-intl'; @@ -266,7 +268,13 @@ export default function TakingQuizClient({ // ─── RENDER ───────────────────────────────────────────────────────────────── if (loadingMeta) return ; - if (metaError) return

    {metaError}

    ; + if (metaError) + return ( +
    + +

    {metaError}

    +
    + ); if (!quizMeta) return null; // Take Quiz button + instruction @@ -296,10 +304,7 @@ export default function TakingQuizClient({ - +
    {sidebarVisible && quizMeta && (
    - + ({ + type: 'doc', + content: [], + }); const form = useForm({ resolver: zodResolver(FormSchema), @@ -54,7 +72,7 @@ export default function CourseModuleForm({ const isValid = form.formState.isValid; const isSubmitting = form.formState.isSubmitting; - const disabled = !isDirty || !isValid || isSubmitting; + const disabled = !isDirty || !isValid || isSubmitting || !instruction; const onSubmit = async (data: z.infer) => { try { @@ -88,7 +106,8 @@ export default function CourseModuleForm({ return (
    - + + {/* Name */} {t('ws-quiz-sets.name')} - + + + + + )} + /> + + {/* Attempt Limit */} + ( + + {t('ws-quiz-sets.attempt_limit')} + + )} /> + {/* Time Limit Minutes */} + ( + + {t('ws-quiz-sets.time_limit_minutes')} + + + + + + )} + /> + + {/* Available Date */} + ( + + {t('ws-quiz-sets.available_date')} + + + + + + )} + /> + + {/* Due Date */} + ( + + {t('ws-quiz-sets.due_date')} + + + + + + )} + /> + + {/* Booleans */} + ( + + {t('ws-quiz-sets.allow_view_results')} + + + + + )} + /> + + ( + + + {t('ws-quiz-sets.release_points_immediately')} + + + + + + )} + /> + + {/* Explanation Mode */} + ( + + {t('ws-quiz-sets.explanation_mode')} + + + + + )} + /> + + {/* Instruction JSON */} + {/* ( + + {t('ws-quiz-sets.instruction')} + +