diff --git a/apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql b/apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql
new file mode 100644
index 0000000000..ba0ff91923
--- /dev/null
+++ b/apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql
@@ -0,0 +1,17 @@
+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;
+
+alter table "public"."workspace_quizzes" add column "instruction" jsonb;
+
+alter table "public"."workspace_quiz_sets" add column "results_released" boolean not null default false;
+
+alter table "public"."workspace_quiz_sets" drop column "release_points_immediately";
+
+alter table "public"."workspace_quiz_sets" add column "allow_view_old_attempts" boolean not null default true;
\ No newline at end of file
diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json
index 07fb31007b..8dbc853b53 100644
--- a/apps/upskii/messages/en.json
+++ b/apps/upskii/messages/en.json
@@ -3846,40 +3846,153 @@
"edit": "Edit quiz",
"question": "Question",
"answer": "Answer",
- "question_status_title": "Question Progress",
- "answered_status_short": "answered",
- "quiz_progress_label": "Quiz Progress",
- "question_navigation_label": "Question Navigation",
- "jump_to_question_aria": "Question {{number}}, {{status}}",
- "answered_state": "Answered",
- "unanswered_state": "Unanswered",
- "answered_icon": "✓",
- "unanswered_icon": "⚪",
- "time_elapsed": "Time Elapsed",
- "hidden_time_elapsed": "Time Elapsed (hidden)",
- "hidden_time_remaining": "Hidden Timer",
- "edit_description": "Edit an existing quiz",
- "generation_error": "An error has occured when generating quizzes.",
- "generation_accepted": "AI-generated quizzes are accepted!",
- "please_answer_all": "Please answer all questions.",
- "loading": "Loading...",
- "results": "Results",
- "attempt": "Attempt",
- "of": "of",
- "unlimited": "Unlimited Attempts",
- "score": "Score",
- "done": "Done",
- "attempts": "Attempts",
- "time_limit": "Time Limit",
- "no_time_limit": "No Time Limit",
- "minutes": "Minutes",
- "take_quiz": "Take Quiz",
- "time_remaining": "Time Remaining",
"points": "Points",
"submitting": "Submitting...",
"submit": "Submit",
- "due_on": "Due on",
- "quiz_past_due": "This quiz is past its due date."
+ "quiz-status": {
+ "sidebar_aria": "Quiz progress sidebar",
+ "question_status_title": "Question Progress",
+ "answered_status_short": "answered",
+ "quiz_progress_label": "Quiz Progress",
+ "question_navigation_label": "Jump to question",
+ "answered_state": "Answered",
+ "unanswered_state": "Unanswered",
+ "jump_to_question": "Jump to question"
+ },
+ "time": {
+ "remaining": "Time Remaining",
+ "elapsed": "Time Elapsed",
+ "hidden_remaining": "Time Hidden",
+ "hidden_elapsed": "Time Hidden",
+ "hour": "hour",
+ "hours": "hours",
+ "minute": "minute",
+ "second": "second"
+ },
+ "errors": {
+ "error-type-submit": "Error when submitting test",
+ "unknown-error": "Unknown error",
+ "network-error": "Network error",
+ "submission-failed": "Submission failed.",
+ "network-error-submitting": "Network error submitting"
+ },
+ "quiz": {
+ "take-quiz": "Take Quiz",
+ "start-quiz": "Start Quiz",
+ "starting-quiz": "Starting Quiz...",
+ "quiz-instructions": "Quiz Instructions",
+ "attempt-limit": "Attempt Limit",
+ "due-date": "Due Date",
+ "available-date": "Available Date",
+ "attempts-remaining": "Attempts Remaining",
+ "past-due": "This quiz is past due",
+ "not-available": "This quiz is not yet available",
+ "unlimited": "Unlimited",
+ "review-info": "Review the information below before starting your exam",
+ "click-to-begin": "Click to begin your attempt",
+ "results-out": "Results are out—no further attempts",
+ "quiz-past-due": "Quiz is past due",
+ "quiz-not-available": "Quiz not yet available",
+ "no-attempts-remaining": "No attempts remaining",
+ "view-result": "View Result",
+ "view-final-total-score": "You got {score} over {maxScore} for your recent attempt.",
+ "view-final-attempt": "You got {score} over {maxScore} for your recent attempt. Click `View Result` to view your score details",
+ "quiz-not-available-message": "This quiz is not yet available. Please check back later.",
+ "quiz-past-due-message": "This quiz is past its due date. You cannot start a new attempt at this time.",
+ "no-attempts-message": "You have no attempts remaining for this quiz."
+ },
+ "info": {
+ "quiz-information": "Quiz Information",
+ "quiz-id": "Quiz ID",
+ "time-limit": "Time Limit",
+ "no-time-limit": "No time limit",
+ "minutes": "Minutes",
+ "attempts-used": "Attempts Used",
+ "explanations": "Explanations",
+ "schedule": "Schedule",
+ "available-from": "Available From",
+ "due-date": "Due Date",
+ "attempt-remaining": "attempt remaining",
+ "attempts-remaining": "attempts remaining",
+ "explanation-modes": {
+ "none": "None",
+ "correct-after-release": "Correct only after release",
+ "all-after-release": "All after release"
+ }
+ },
+ "instructions": {
+ "title": "Instructions",
+ "subtitle": "Read before you begin",
+ "default": {
+ "stable-connection": "Make sure you have a stable internet connection",
+ "cannot-pause": "You cannot pause the quiz once started",
+ "answer-all": "You should answer all questions before submitting",
+ "auto-save": "Your progress will be automatically saved",
+ "time-limit": "You have {minutes} minutes"
+ }
+ },
+ "past-attempts": {
+ "title": "Past Attempts",
+ "view-answers": "Click \"View Details\" to view your answers",
+ "view-results": "Click \"View Details\" to view your results",
+ "results-pending": "Results pending release",
+ "view-results-button": "View Results",
+ "view-details-button": "View Details",
+ "attempt-info": " at {date} ({duration})"
+ },
+ "summary": {
+ "title": "Attempt Summary",
+ "description": "Review your quiz attempt details and responses",
+ "back_to_quiz": "Back to Quiz",
+ "attempt_number": "Attempt #{number}",
+ "submitted": "Submitted",
+ "duration": "Duration",
+ "completion": "Completion",
+ "answered_of_total": "{answered} of {total} questions",
+ "progress": "Progress",
+ "questions_and_responses": "Questions & Responses",
+ "questions_count": "{count} questions",
+ "answered": "Answered",
+ "skipped": "Skipped",
+ "your_response": "Your Response:",
+ "no_answer": "No answer provided",
+ "available_options": "Available Options:",
+ "summary_stats": {
+ "attempt_number": "Attempt Number",
+ "answered": "Questions Answered",
+ "skipped": "Questions Skipped",
+ "time_taken": "Time Taken"
+ }
+ },
+ "results": {
+ "quiz-completed": "Quiz Completed!",
+ "your-score": "Your Score",
+ "attempt": "Attempt",
+ "of": "of",
+ "unlimited": "Unlimited",
+ "attempts-remaining": "Attempts Remaining",
+ "left": "left",
+ "excellent-work": "Excellent Work!",
+ "outstanding-performance": "Outstanding performance! You have mastered this material.",
+ "good-job": "Good Job!",
+ "solid-understanding": "You show a solid understanding of the material.",
+ "keep-practicing": "Keep Practicing!",
+ "room-for-improvement": "There's room for improvement. Consider reviewing the material.",
+ "needs-improvement": "Needs Improvement",
+ "review-recommended": "We recommend reviewing the material and trying again.",
+ "back-take-quiz": "Back to Quiz Page",
+ "quiz-completed-at": "Quiz completed at",
+ "time-limit": "Time limit",
+ "minutes": "minutes",
+ "started_at": "Started at",
+ "completed_at": "Completed at",
+ "duration": "Duration",
+ "points": "Points",
+ "correct": "(Correct)",
+ "your_answer": "(Your answer)",
+ "correct_option": "(Correct)",
+ "score_awarded": "Score Awarded"
+ }
},
"ws-reports": {
"report": "Report",
diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json
index d3e1ebc3e7..63ba3e665b 100644
--- a/apps/upskii/messages/vi.json
+++ b/apps/upskii/messages/vi.json
@@ -3847,40 +3847,155 @@
"edit": "Chỉnh sửa bộ trắc nghiệm",
"question": "Câu hỏi",
"answer": "Câu trả lời",
- "question_status_title": "Tiến độ câu hỏi",
- "answered_status_short": "đã trả lời",
- "quiz_progress_label": "Tiến độ bài kiểm tra",
- "question_navigation_label": "Điều hướng câu hỏi",
- "jump_to_question_aria": "Câu hỏi {{number}}, {{status}}",
- "answered_state": "Đã trả lời",
- "unanswered_state": "Chưa trả lời",
- "answered_icon": "✓",
- "unanswered_icon": "⚪",
- "time_elapsed": "Đã trôi qua",
- "hidden_time_elapsed": "Đã ẩn thời gian",
- "hidden_time_remaining": "Ẩn đếm ngược",
- "edit_description": "Chỉnh sửa bài kiểm tra hiện có",
- "generation_error": "Đã xảy ra lỗi khi tạo bộ câu hỏi.",
- "generation_accepted": "Các bộ câu hỏi do AI tạo ra đã được chấp nhận!",
- "please_answer_all": "Vui lòng trả lời tất cả các câu hỏi.",
- "loading": "Đang tải...",
- "results": "Kết quả",
- "attempt": "Lần thử",
- "of": "của",
- "unlimited": "Không giới hạn số lần thi",
- "score": "Điểm số",
- "done": "Hoàn thành",
- "attempts": "Số lần thử",
- "time_limit": "Giới hạn thời gian",
- "no_time_limit": "Không giới hạn thời gian",
- "minutes": "Phút",
- "take_quiz": "Làm bài kiểm tra",
- "time_remaining": "Thời gian còn lại",
- "points": "Điểm",
- "submitting": "Đang gửi...",
- "submit": "Nộp bài",
- "due_on": "Hạn nộp",
- "quiz_past_due": "Bài kiểm tra đã quá hạn"
+ "points": "Points",
+ "submitting": "Submitting...",
+ "submit": "Submit",
+ "quiz-status": {
+ "sidebar_aria": "Thanh bên tiến độ bài kiểm tra",
+ "question_status_title": "Tiến độ câu hỏi",
+ "answered_status_short": "đã trả lời",
+ "quiz_progress_label": "Tiến độ bài kiểm tra",
+ "question_navigation_label": "Chuyển đến câu hỏi",
+ "answered_state": "Đã trả lời",
+ "unanswered_state": "Chưa trả lời",
+ "jump_to_question": "Chuyển đến câu hỏi"
+ },
+ "time": {
+ "remaining": "Thời gian còn lại",
+ "elapsed": "Thời gian đã trôi qua",
+ "hidden_remaining": "Đã ẩn thời gian còn lại",
+ "hidden_elapsed": "Đã ẩn thời gian trôi qua",
+ "minutes": "phút",
+ "seconds": "giây",
+ "hour": "giờ",
+ "hours": "giờ",
+ "minute": "phút",
+ "second": "giấy"
+ },
+ "errors": {
+ "error-type-submit": "Đã có lỗi xảy ra khi nộp bài",
+ "unknown-error": "Lỗi không xác định",
+ "network-error": "Lỗi mạng",
+ "submission-failed": "Nộp bài thất bại",
+ "network-error-submitting": "Lỗi mạng khi nộp bài"
+ },
+ "quiz": {
+ "take-quiz": "Làm bài kiểm tra",
+ "start-quiz": "Bắt đầu làm bài",
+ "starting-quiz": "Đang bắt đầu...",
+ "quiz-instructions": "Hướng dẫn làm bài",
+ "attempt-limit": "Giới hạn lần làm",
+ "due-date": "Hạn nộp",
+ "available-date": "Ngày mở",
+ "attempts-remaining": "Số lần còn lại",
+ "past-due": "Bài kiểm tra đã quá hạn",
+ "not-available": "Bài kiểm tra chưa mở",
+ "unlimited": "Không giới hạn",
+ "review-info": "Xem kỹ thông tin bên dưới trước khi bắt đầu bài thi",
+ "click-to-begin": "Nhấp để bắt đầu lần làm bài của bạn",
+ "results-out": "Kết quả đã công bố—không thể làm thêm",
+ "quiz-past-due": "Bài kiểm tra đã quá hạn",
+ "quiz-not-available": "Bài kiểm tra chưa mở",
+ "no-attempts-remaining": "Không còn lần làm bài nào",
+ "view-result": "Xem kết quả",
+ "view-final-total-score": "Bạn đạt được {score} trên {maxScore}.",
+ "view-final-attempt": "Bạn đạt được {score} trên {maxScore}. Xem kết quả lần làm bài gần đây nhất",
+ "quiz-not-available-message": "Bài kiểm tra này chưa mở. Vui lòng quay lại sau.",
+ "quiz-past-due-message": "Bài kiểm tra này đã quá hạn nộp. Bạn không thể bắt đầu lần làm bài mới vào lúc này.",
+ "no-attempts-message": "Bạn không còn lần làm bài nào cho bài kiểm tra này."
+ },
+ "info": {
+ "quiz-information": "Thông tin bài kiểm tra",
+ "quiz-id": "Mã bài kiểm tra",
+ "time-limit": "Giới hạn thời gian",
+ "no-time-limit": "Không giới hạn thời gian",
+ "minutes": "Phút",
+ "attempts-used": "Số lần đã làm",
+ "explanations": "Giải thích",
+ "schedule": "Lịch trình",
+ "available-from": "Mở từ",
+ "due-date": "Hạn nộp",
+ "attempt-remaining": "lần còn lại",
+ "attempts-remaining": "lần còn lại",
+ "explanation-modes": {
+ "none": "Không có",
+ "correct-after-release": "Chỉ đáp án đúng sau khi công bố",
+ "all-after-release": "Tất cả sau khi công bố"
+ }
+ },
+ "instructions": {
+ "title": "Hướng dẫn",
+ "subtitle": "Đọc trước khi bắt đầu",
+ "default": {
+ "stable-connection": "Đảm bảo bạn có kết nối internet ổn định",
+ "cannot-pause": "Bạn không thể tạm dừng bài kiểm tra sau khi bắt đầu",
+ "answer-all": "Bạn nên trả lời tất cả câu hỏi trước khi nộp bài",
+ "auto-save": "Tiến trình của bạn sẽ được tự động lưu",
+ "time-limit": "Bạn có {minutes} phút"
+ }
+ },
+ "past-attempts": {
+ "title": "Lần làm bài trước",
+ "view-answers": "Nhấp \"Xem chi tiết\" để xem câu trả lời của bạn",
+ "view-results": "Nhấp \"Xem kết qủa\" để xem kết quả của bạn",
+ "results-pending": "Kết quả đang chờ công bố",
+ "view-results-button": "Xem kết quả",
+ "view-details-button": "Xem chi tiết",
+ "attempt-info": " lúc {date} ({duration})"
+ },
+ "summary": {
+ "title": "Tóm tắt lần làm bài",
+ "description": "Xem lại chi tiết và câu trả lời trong lần làm bài kiểm tra của bạn",
+ "back_to_quiz": "Quay lại làm bài",
+ "attempt_number": "Lần làm #{number}",
+ "submitted": "Đã nộp",
+ "duration": "Thời lượng",
+ "completion": "Hoàn thành",
+ "answered_of_total": "{answered} / {total} câu hỏi",
+ "progress": "Tiến độ",
+ "questions_and_responses": "Câu hỏi & Câu trả lời",
+ "questions_count": "{count} câu hỏi",
+ "answered": "Đã trả lời",
+ "skipped": "Bỏ qua",
+ "your_response": "Câu trả lời của bạn:",
+ "no_answer": "Chưa chọn câu trả lời",
+ "available_options": "Tùy chọn có sẵn:",
+ "summary_stats": {
+ "attempt_number": "Số lần làm",
+ "answered": "Câu hỏi đã trả lời",
+ "skipped": "Câu hỏi đã bỏ qua",
+ "time_taken": "Thời gian làm bài"
+ }
+ },
+ "results": {
+ "quiz-completed": "Đã hoàn thành bài kiểm tra!",
+ "your-score": "Điểm số của bạn",
+ "attempt": "Lần làm",
+ "of": "trên",
+ "unlimited": "Không giới hạn",
+ "attempts-remaining": "Số lần còn lại",
+ "left": "lần",
+ "excellent-work": "Làm rất tốt!",
+ "outstanding-performance": "Hiệu suất xuất sắc! Bạn đã nắm vững kiến thức này.",
+ "good-job": "Làm tốt lắm!",
+ "solid-understanding": "Bạn hiểu khá vững kiến thức.",
+ "keep-practicing": "Tiếp tục luyện tập!",
+ "room-for-improvement": "Còn chỗ cần cải thiện. Hãy xem lại nội dung.",
+ "needs-improvement": "Cần cải thiện",
+ "review-recommended": "Chúng tôi khuyên bạn nên ôn tập lại và thử lại.",
+ "back-take-quiz": "Quay lại trang làm bài",
+ "quiz-completed-at": "Hoàn thành bài kiểm tra lúc",
+ "time-limit": "Giới hạn thời gian",
+ "minutes": "phút",
+ "started_at": "Bắt đầu lúc",
+ "completed_at": "Hoàn thành lúc",
+ "duration": "Thời gian làm bài",
+ "points": "Điểm",
+ "correct": "(Đáp án đúng)",
+ "your_answer": "(Câu trả lời của bạn)",
+ "correct_option": "(Đáp án đúng)",
+ "score_awarded": "Điểm đạt được"
+ }
},
"ws-reports": {
"report": "Báo cáo",
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx
index 0bacb2c0af..4924d5e5ff 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx
@@ -138,6 +138,7 @@ export default async function UserGroupDetailsPage({ params }: Props) {
;
+ }>;
+}
+
+export interface ShowAttemptDetailProps {
+ detail: AttemptDetailDTO;
+}
+
+export default function ShowAttemptDetailSection({
+ detail,
+}: ShowAttemptDetailProps) {
+ const t = useTranslations('ws-quizzes');
+ const fmtDate = (iso: string | null) =>
+ iso ? new Date(iso).toLocaleString() : '—';
+ const fmtDuration = (secs: number) => {
+ const m = Math.floor(secs / 60)
+ .toString()
+ .padStart(2, '0');
+ const s = (secs % 60).toString().padStart(2, '0');
+ return `${m}:${s}`;
+ };
+
+ return (
+
+ {/* Metadata */}
+
+
+ {t('results.started_at') || 'Started at'}: {fmtDate(detail.startedAt)}
+
+ {detail.completedAt && (
+
+ {t('results.completed_at') || 'Completed at'}:{' '}
+ {fmtDate(detail.completedAt)}
+
+ )}
+
+ {t('results.duration') || 'Duration'}:{' '}
+ {fmtDuration(detail.durationSeconds)}
+
+
+
+ {/* Questions */}
+ {detail.questions.map((q, idx) => {
+ const selId = q.selectedOptionId ?? null;
+ return (
+
+
+
+ {idx + 1}. {q.question}{' '}
+
+ ({t('results.points') || 'Points'}: {q.scoreWeight})
+
+
+
+
+ {q.options.map((opt) => {
+ const chosen = selId === opt.id;
+ return (
+
+ {opt.isCorrect ? (
+
+ ) : chosen ? (
+
+ ) : (
+
+ )}
+
+
+ {opt.value}{' '}
+ {opt.isCorrect && (
+
+ {t('results.correct') || '(Correct)'}
+
+ )}
+ {chosen && !opt.isCorrect && (
+
+ {t('results.your_answer') || '(Your answer)'}
+
+ )}
+
+ {opt.explanation && (
+
+ {opt.explanation}
+
+ )}
+
+
+ );
+ })}
+
+
+ {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]/result/display-results/show-result-summary-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-result-summary-section.tsx
new file mode 100644
index 0000000000..dbf7433488
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-result-summary-section.tsx
@@ -0,0 +1,216 @@
+'use client';
+
+import { Badge } from '@tuturuuu/ui/badge';
+import { Button } from '@tuturuuu/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
+import { Progress } from '@tuturuuu/ui/progress';
+import { Separator } from '@tuturuuu/ui/separator';
+import { CheckCircle, RotateCcw, Target, Trophy } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { useRouter } from 'next/navigation';
+
+export interface ShowResultSummarySectionProps {
+ submitResult: {
+ attemptNumber: number;
+ totalScore: number;
+ maxPossibleScore: number;
+ };
+ quizMeta: {
+ attemptLimit: number | null;
+ setName: string;
+ attemptsSoFar: number;
+ timeLimitMinutes: number | null;
+ completedAt: string | null; // Optional, if you want to show when the quiz was completed
+ };
+ wsId: string;
+ courseId: string;
+ moduleId: string;
+ setId: string;
+}
+
+export default function ShowResultSummarySection({
+ submitResult,
+ quizMeta,
+ wsId,
+ courseId,
+ moduleId,
+ setId,
+}: ShowResultSummarySectionProps) {
+ const t = useTranslations('ws-quizzes');
+ const router = useRouter();
+
+ const scorePercentage = Math.round(
+ (submitResult.totalScore / submitResult.maxPossibleScore) * 100
+ );
+ const attemptsRemaining = quizMeta.attemptLimit
+ ? quizMeta.attemptLimit - quizMeta.attemptsSoFar
+ : null;
+ // const canRetake = attemptsRemaining === null || attemptsRemaining > 0;
+
+ const getScoreColor = (percentage: number) => {
+ if (percentage >= 90) return 'text-dynamic-green';
+ if (percentage >= 70) return 'text-dynamic-purple';
+ if (percentage >= 50) return 'text-yellow-600';
+ return 'text-rose-600';
+ };
+
+ const getScoreBadgeVariant = (percentage: number) => {
+ if (percentage >= 90) return 'default';
+ if (percentage >= 70) return 'secondary';
+ if (percentage >= 50) return 'outline';
+ return 'destructive';
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ {t('results.quiz-completed') || 'Quiz Completed!'}
+
+
{quizMeta.setName}
+
+
+ {/* Score Card */}
+
+
+
+
+ {t('results.your-score') || 'Your Score'}
+
+
+
+
+
+ {submitResult.totalScore} / {submitResult.maxPossibleScore}
+
+
+ {scorePercentage}%
+
+
+
+
+
+
+ 0%
+ 50%
+ 100%
+
+
+
+
+ {/* Attempt Information */}
+
+
+
+
+
+
+
+
+
+ {t('results.attempt') || 'Attempt'}
+
+
+ #{submitResult.attemptNumber} {t('results.of') || 'of'}{' '}
+ {quizMeta.attemptLimit ??
+ (t('results.unlimited') || 'Unlimited')}
+
+
+
+
+ {attemptsRemaining !== null && (
+
+
+
+
+
+
+ {t('results.attempts-remaining') || 'Attempts Remaining'}
+
+
+ {attemptsRemaining} {t('results.left') || 'left'}
+
+
+
+ )}
+
+
+
+
+ {/* Performance Feedback */}
+
+
+
+
+ {scorePercentage >= 90
+ ? t('results.excellent-work') || 'Excellent Work!'
+ : scorePercentage >= 70
+ ? t('results.good-job') || 'Good Job!'
+ : scorePercentage >= 50
+ ? t('results.keep-practicing') || 'Keep Practicing!'
+ : t('results.needs-improvement') || 'Needs Improvement'}
+
+
+ {scorePercentage >= 90
+ ? t('results.outstanding-performance') ||
+ 'Outstanding performance! You have mastered this material.'
+ : scorePercentage >= 70
+ ? t('results.solid-understanding') ||
+ 'You show a solid understanding of the material.'
+ : scorePercentage >= 50
+ ? t('results.room-for-improvement') ||
+ "There's room for improvement. Consider reviewing the material."
+ : t('results.review-recommended') ||
+ 'We recommend reviewing the material and trying again.'}
+
+
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+ {/* Additional Info */}
+
+ {quizMeta.completedAt && (
+
+ {t('results.quiz-completed-at') || 'Quiz completed at'}{' '}
+ {new Date(quizMeta.completedAt).toLocaleString()}
+
+ )}
+ {quizMeta.timeLimitMinutes && (
+
+ {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
new file mode 100644
index 0000000000..61923c2b39
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx
@@ -0,0 +1,371 @@
+'use client';
+
+import { Badge } from '@tuturuuu/ui/badge';
+import { Button } from '@tuturuuu/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
+import { Progress } from '@tuturuuu/ui/progress';
+import { Separator } from '@tuturuuu/ui/separator';
+import {
+ AlertCircle,
+ Calendar,
+ CheckCircle,
+ FileText,
+ Hash,
+ Timer,
+ XCircle,
+} from 'lucide-react';
+import { useTranslations } from 'next-intl';
+
+export interface AttemptSummaryDTO {
+ attemptId: string;
+ attemptNumber: number;
+ submittedAt: string | null;
+ durationSeconds: number;
+ questions: Array<{
+ quizId: string;
+ question: string;
+ selectedOptionId: string | null;
+ options: Array<{ id: string; value: string }>;
+ }>;
+}
+
+export default function AttemptSummaryView({
+ summary,
+ backToTakeQuiz,
+}: {
+ summary: AttemptSummaryDTO;
+ backToTakeQuiz: () => void;
+}) {
+ const t = useTranslations('ws-quizzes');
+ const fmtDate = (iso: string | null) =>
+ iso
+ ? new Date(iso).toLocaleString('en-US', {
+ weekday: 'short',
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ : '–';
+
+ const fmtDur = (secs: number) => {
+ const hours = Math.floor(secs / 3600);
+ const minutes = Math.floor((secs % 3600) / 60);
+ const seconds = secs % 60;
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m ${seconds}s`;
+ } else if (minutes > 0) {
+ return `${minutes}m ${seconds}s`;
+ } else {
+ return `${seconds}s`;
+ }
+ };
+
+ const getSelectedOptionText = (
+ question: AttemptSummaryDTO['questions'][0]
+ ) => {
+ if (!question.selectedOptionId) return null;
+ const selectedOption = question.options.find(
+ (opt) => opt.id === question.selectedOptionId
+ );
+ return selectedOption?.value || null;
+ };
+
+ const answeredQuestions = summary.questions.filter(
+ (q) => q.selectedOptionId !== null
+ ).length;
+ const totalQuestions = summary.questions.length;
+ const completionRate =
+ totalQuestions > 0
+ ? Math.round((answeredQuestions / totalQuestions) * 100)
+ : 0;
+
+ return (
+
+ {/* Header */}
+
+
+ {t('summary.title')}
+
+
{t('summary.description')}
+
+
+
+ {/* Attempt Overview */}
+
+
+
+
+ {t('summary.attempt_number', {
+ number: summary.attemptNumber,
+ })}
+
+
+
+
+ {/* Submission Info */}
+
+
+
+
+
+
+ {t('summary.submitted')}
+
+
+ {fmtDate(summary.submittedAt)}
+
+
+
+
+ {/* Duration */}
+
+
+
+
+
+
+ {t('summary.duration')}
+
+
+ {fmtDur(summary.durationSeconds)}
+
+
+
+
+ {/* Completion Rate */}
+
+
+
+
+
+
+ {t('summary.completion')}
+
+
+ {/* {answeredQuestions} of {totalQuestions} questions */}
+ {t('summary.answered_of_total', {
+ answered: answeredQuestions,
+ total: totalQuestions,
+ })}
+
+
+
+
+
+ {/* Progress Bar */}
+
+
+
+ {t('summary.progress')}
+
+
+ {completionRate}%
+
+
+
+
+
+
+
+ {/* Questions Section */}
+ {summary.questions && summary.questions.length > 0 && (
+
+
+
+
+ {t('summary.questions_and_responses')}
+
+
+ {t('summary.questions_count', {
+ count: summary.questions.length,
+ })}
+
+
+
+
+ {summary.questions.map((q, index) => {
+ const isAnswered = q.selectedOptionId !== null;
+ const selectedOptionText = getSelectedOptionText(q);
+
+ return (
+
+
+
+
+
+ Q{index + 1}.
+
+ {q.question}
+
+
+ {isAnswered ? (
+
+
+
+ {t('summary.answered')}
+
+
+ ) : (
+
+
+
+ {t('summary.skipped')}
+
+
+ )}
+
+
+
+
+
+
+ {/* Selected Answer */}
+
+
+ {t('summary.your_response')}
+
+ {isAnswered && selectedOptionText ? (
+
+
+
+
+
+ {selectedOptionText}
+
+
+
+
+ ) : (
+
+
+
+
+ {t('summary.no_answer')}
+
+
+
+ )}
+
+
+ {/* All Options */}
+
+
+ {t('summary.available_options')}
+
+
+ {q.options.map((option) => {
+ const isSelected = option.id === q.selectedOptionId;
+ return (
+
+
+
+ {isSelected && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Summary Stats */}
+
+
+
+
+
+ {summary.attemptNumber}
+
+
+ {t('summary.summary_stats.attempt_number', {
+ number: summary.attemptNumber,
+ })}
+
+
+
+
+ {answeredQuestions}
+
+
+ {t('summary.summary_stats.answered')}
+
+
+
+
+ {totalQuestions - answeredQuestions}
+
+
+ {t('summary.summary_stats.skipped')}
+
+
+
+
+ {fmtDur(summary.durationSeconds)}
+
+
+ {t('summary.summary_stats.time_taken')}
+
+
+
+
+
+
+
+
+
+ );
+}
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
new file mode 100644
index 0000000000..5a50e27ad8
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx
@@ -0,0 +1,22 @@
+import QuizResultClient from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client';
+
+export default async function QuizResultPage({
+ params,
+}: {
+ params: Promise<{
+ wsId: string;
+ courseId: string;
+ moduleId: string;
+ setId: string;
+ }>;
+}) {
+ const { wsId, courseId, moduleId, setId } = await params;
+ return (
+
+ );
+}
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 0000000000..ca2c747c73
--- /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/[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
new file mode 100644
index 0000000000..9ca924fcc7
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx
@@ -0,0 +1,442 @@
+'use client';
+
+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 { RichTextEditor } from '@tuturuuu/ui/text-editor/editor';
+import { JSONContent } from '@tuturuuu/ui/tiptap';
+import {
+ AlertTriangle,
+ Calendar,
+ CheckCircle,
+ Clock,
+ FileChartColumnIncreasing,
+ Info,
+ Play,
+ RotateCcw,
+ TriangleAlert,
+} from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+
+export interface AttemptSummary {
+ attemptId: string;
+ attemptNumber: number;
+ submittedAt: string; // ISO timestamp
+ totalScore: number | null; // null if not graded yet
+ durationSeconds: number;
+}
+
+interface QuizData {
+ setId: string;
+ setName: string;
+ availableDate: string | null;
+ dueDate: string | null;
+ attemptLimit: number | null;
+ attemptsSoFar: number;
+ timeLimitMinutes: number | null;
+ allowViewOldAttempts: boolean;
+ explanationMode: 0 | 1 | 2;
+ instruction: string | null;
+ resultsReleased: boolean;
+ maxScore: number;
+ attempts: AttemptSummary[];
+}
+
+interface BeforeTakingQuizWholeProps {
+ wsId: string;
+ courseId: string;
+ moduleId: string;
+ setId: string;
+ quizData: QuizData;
+ isPastDue: boolean;
+ isAvailable: boolean;
+ onStart: () => void;
+}
+
+function formatDate(dateString: string | null) {
+ if (!dateString) return '—';
+ return new Date(dateString).toLocaleString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function formatDuration(sec: number) {
+ const m = Math.floor(sec / 60)
+ .toString()
+ .padStart(2, '0');
+ const s = (sec % 60).toString().padStart(2, '0');
+ return `${m}:${s}`;
+}
+
+export default function BeforeTakingQuizWhole({
+ quizData,
+ isPastDue,
+ isAvailable,
+ onStart,
+ wsId,
+ courseId,
+ moduleId,
+}: BeforeTakingQuizWholeProps) {
+ const t = useTranslations('ws-quizzes');
+ const router = useRouter();
+ const [isStarting, setIsStarting] = useState(false);
+
+ const attemptsRemaining = quizData.attemptLimit
+ ? quizData.attemptLimit - quizData.attemptsSoFar
+ : null;
+
+ // Determine if the quiz can be retaken
+ const canRetake =
+ isAvailable &&
+ !isPastDue &&
+ (attemptsRemaining == null || attemptsRemaining > 0);
+ // && !quizData.resultsReleased;
+
+ // MARK: modify if logic changes to (can view result even not past due and attempt remain > 0)
+ // At that time, add 1 variable to check view old attempts with result released
+ const canViewResult = isAvailable && quizData.resultsReleased;
+ // (isPastDue || (!isPastDue && attemptsRemaining == 0));
+
+ // Can view old attempts details (but no points or only can view total points only)
+ const canViewOldAttemptsNoResults =
+ quizData.attemptsSoFar > 0 && quizData.allowViewOldAttempts;
+ // !quizData.resultsReleased;
+
+ const canViewTotalPointsOnly = quizData.resultsReleased;
+
+ console.log('Test', quizData.attempts[0]);
+
+ // const canViewOldAttemptsResults = quizData.resultsReleased;
+ // can view attempts with points in detailed explanation
+ const canViewOldAttemptsResults =
+ isAvailable &&
+ quizData.resultsReleased &&
+ (quizData.allowViewOldAttempts ||
+ (!quizData.allowViewOldAttempts && isPastDue));
+
+ const handleStartQuiz = () => {
+ setIsStarting(true);
+ setTimeout(() => {
+ setIsStarting(false);
+ onStart();
+ }, 500);
+ };
+
+ const viewAttempt = (att: AttemptSummary) => {
+ router.push(
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${quizData.setId}/result?attemptId=${att.attemptId}`
+ );
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ {quizData.setName}
+
+
{t('quiz.review-info')}
+
+ {/* Start Button */}
+ {canRetake && (
+
+
+
+ {t('quiz.click-to-begin')}
+
+
+ )}
+
+ {quizData.attempts[0] &&
+ quizData.attempts[0].totalScore != null &&
+ canViewTotalPointsOnly && (
+
+
+
+
+ {quizData.attempts[0].totalScore} / {quizData.maxScore}
+
+
+
+ {t(
+ canViewResult
+ ? 'quiz.view-final-attempt'
+ : 'quiz.view-final-total-score',
+ {
+ score: quizData.attempts[0]?.totalScore ?? '—',
+ maxScore: quizData.maxScore,
+ }
+ )}
+
+
+ {canViewResult && (
+
+ )}
+
+
+ )}
+ {!isAvailable ? (
+
+
+ {t('quiz.quiz-not-available-message')}
+
+
+ ) : (
+ !quizData.resultsReleased &&
+ (isPastDue || attemptsRemaining == 0) && (
+
+
+
+ {isPastDue
+ ? t('quiz.quiz-past-due-message')
+ : t('quiz.no-attempts-message')}
+
+
+ )
+ )}
+
+
+ {/* Info & Schedule */}
+
+ {/* Quiz Information */}
+
+
+
+
+ {t('info.quiz-information')}
+
+
+
+
+
+ {t('info.quiz-id')}
+
+ {quizData.setId}
+
+
+
+
+
+ {t('info.time-limit')}
+
+
+
+
+ {quizData.timeLimitMinutes
+ ? `${quizData.timeLimitMinutes} ${t('info.minutes')}`
+ : t('info.no-time-limit')}
+
+
+
+
+
+ {t('info.attempts-used')}
+
+
+
+
+ {quizData.attemptsSoFar} /{' '}
+ {quizData.attemptLimit || '∞'}{' '}
+
+
+
+
+
+ {t('info.explanations')}
+
+
+ {quizData.explanationMode === 0
+ ? t('info.explanation-modes.none')
+ : quizData.explanationMode === 1
+ ? t('info.explanation-modes.correct-after-release')
+ : t('info.explanation-modes.all-after-release')}
+
+
+
+
+
+
+
+
+
+ {t('info.schedule')}
+
+
+
+
+
+
+ {t('info.available-from')}
+
+ {isAvailable && (
+
+ )}
+
+
{formatDate(quizData.availableDate)}
+
+
+
+
+
+ {t('info.due-date')}
+
+ {isPastDue && (
+
+ )}
+
+
{formatDate(quizData.dueDate)}
+
+ {attemptsRemaining != null && (
+ <>
+
+
+ {attemptsRemaining}{' '}
+ {attemptsRemaining !== 1
+ ? t('info.attempts-remaining')
+ : t('info.attempt-remaining')}
+
+ >
+ )}
+
+
+
+
+ {/* Instructions */}
+
+
+ {t('instructions.title')}
+ {t('instructions.subtitle')}
+
+
+ {quizData.instruction ? (
+ // {quizData.instruction}
+
+ ) : (
+
+
• {t('instructions.default.stable-connection')}
+
• {t('instructions.default.cannot-pause')}
+
• {t('instructions.default.answer-all')}
+
• {t('instructions.default.auto-save')}
+ {quizData.timeLimitMinutes && (
+
+ •{' '}
+ {t('instructions.default.time-limit', {
+ minutes: quizData.timeLimitMinutes,
+ })}
+
+ )}
+
+ )}
+
+
+
+ {/* Past Attempts */}
+ {quizData.attemptsSoFar > 0 && (
+
+
+ {t('past-attempts.title')}
+
+ {canViewOldAttemptsNoResults
+ ? t('past-attempts.view-answers')
+ : canViewOldAttemptsResults
+ ? t('past-attempts.view-results')
+ : t('past-attempts.results-pending')}
+
+
+
+ {quizData.attempts.map((att) => (
+
+
+
+ {canViewTotalPointsOnly ? (
+ <>
+
+ {att.totalScore} / {quizData.maxScore}
+ {' '}
+ >
+ ) : (
+ t('past-attempts.results-pending')
+ )}
+
+
+
+ #{att.attemptNumber}
+
+ {t('past-attempts.attempt-info', {
+ number: att.attemptNumber,
+ date: formatDate(att.submittedAt),
+ duration: formatDuration(att.durationSeconds),
+ })}
+
+
+ {(canViewOldAttemptsResults ||
+ canViewOldAttemptsNoResults) && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx
similarity index 84%
rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx
rename to apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx
index ee238a7aab..239b31bc1b 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/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,4 +1,4 @@
-import TakingQuizClient from '@/app/[locale]/(dashboard)/[wsId]/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/quiz-status-sidebar.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx
new file mode 100644
index 0000000000..f2c9c2c1ec
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { CheckIcon, Circle } from '@tuturuuu/ui/icons';
+import { useTranslations } from 'next-intl';
+import React, { useCallback } from 'react';
+
+export type Question = {
+ quizId: string;
+ question: string;
+ score: number;
+ options: { id: string; value: string }[];
+};
+
+interface QuizStatusSidebarProps {
+ questions: Question[];
+ selectedAnswers: Record;
+}
+
+export default function QuizStatusSidebar({
+ questions,
+ selectedAnswers,
+}: 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];
+ if (Array.isArray(sel) ? sel.length > 0 : Boolean(sel)) {
+ return count + 1;
+ }
+ return count;
+ }, 0);
+
+ // 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 (
+
+ );
+}
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
new file mode 100644
index 0000000000..c41da2658b
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx
@@ -0,0 +1,472 @@
+'use client';
+
+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 { Json } from '@tuturuuu/types/supabase';
+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, TriangleAlert } from '@tuturuuu/ui/icons';
+import { Label } from '@tuturuuu/ui/label';
+import { RadioGroup, RadioGroupItem } from '@tuturuuu/ui/radio-group';
+import { toast } from '@tuturuuu/ui/sonner';
+import { useTranslations } from 'next-intl';
+import { useRouter } from 'next/navigation';
+import { useEffect, useRef, useState } from 'react';
+
+type TakeResponse = {
+ setId: string;
+ setName: string;
+ timeLimitMinutes: number | null;
+ attemptLimit: number | null;
+ attemptsSoFar: number;
+ allowViewOldAttempts: boolean;
+ availableDate: string | null;
+ dueDate: string | null;
+ resultsReleased: boolean;
+ explanationMode: 0 | 1 | 2;
+ instruction: any;
+ attempts: AttemptSummary[];
+ maxScore: number;
+ questions: Array<{
+ quizId: string;
+ question: string;
+ instruction: Json;
+ score: number;
+ multiple: boolean;
+ options: { id: string; value: string }[];
+ }>;
+};
+
+type SubmitResult = {
+ attemptId: string;
+ attemptNumber: number;
+ totalScore: number;
+ maxPossibleScore: number;
+};
+
+export default function TakingQuizClient({
+ wsId,
+ courseId,
+ moduleId,
+ setId,
+}: {
+ wsId: string;
+ courseId: string;
+ moduleId: string;
+ setId: string;
+}) {
+ const t = useTranslations('ws-quizzes');
+ const router = useRouter();
+
+ // ─── STATE ────────────────────────────────────────────────────────────────
+ const [sidebarVisible, setSidebarVisible] = useState(false);
+
+ const [loadingMeta, setLoadingMeta] = useState(true);
+ const [metaError, setMetaError] = useState(null);
+ const [quizMeta, setQuizMeta] = useState(null);
+
+ const [hasStarted, setHasStarted] = useState(false);
+ const [isPastDue, setIsPastDue] = useState(false);
+ const [isAvailable, setIsAvailable] = useState(true);
+
+ const [timeLeft, setTimeLeft] = useState(null);
+ // eslint-disable-next-line no-undef
+ const timerRef = useRef(null);
+
+ // Now can be string (radio) or string[] (checkbox)
+ const [selectedAnswers, setSelectedAnswers] = useState<
+ Record
+ >({});
+
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ // ─── HELPERS ────────────────────────────────────────────────────────────────
+ const STORAGE_KEY = `quiz_start_${setId}`;
+ const ANSWERS_KEY = `quiz_answers_${setId}`;
+ const totalSeconds = quizMeta?.timeLimitMinutes
+ ? quizMeta.timeLimitMinutes * 60
+ : null;
+
+ const clearStartTimestamp = () => {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // Ignore
+ }
+ };
+
+ const computeElapsedSeconds = (startTs: number) =>
+ Math.floor((Date.now() - startTs) / 1000);
+
+ const buildSubmissionPayload = () => ({
+ answers: Object.entries(selectedAnswers)
+ .map(([quizId, val]) => {
+ if (Array.isArray(val)) {
+ return val.map((v) => ({ quizId, selectedOptionId: v }));
+ }
+ return { quizId, selectedOptionId: val };
+ })
+ .flat(),
+ durationSeconds: computeElapsedSeconds(
+ Number(localStorage.getItem(STORAGE_KEY))
+ ),
+ });
+
+ // Only for debugging purposes
+ // useEffect(() => {
+ // localStorage.removeItem(ANSWERS_KEY);
+ // localStorage.removeItem(STORAGE_KEY);
+ // clearStartTimestamp();
+ // }, [setId]);
+
+ // ─── FETCH METADATA ─────────────────────────────────────────────────────────
+ useEffect(() => {
+ async function fetchMeta() {
+ setLoadingMeta(true);
+ try {
+ const res = await fetch(
+ `/api/v1/workspaces/${wsId}/quiz-sets/${setId}/take`
+ );
+ const json: TakeResponse | { error: string } = await res.json();
+
+ if (!res.ok) {
+ setMetaError((json as any).error || t('errors.unknown-error'));
+ return setLoadingMeta(false);
+ }
+
+ setQuizMeta(json as TakeResponse);
+
+ // restore answers
+ const saved = localStorage.getItem(ANSWERS_KEY);
+ if (saved) {
+ try {
+ setSelectedAnswers(JSON.parse(saved));
+ } catch {
+ // Ignore JSON parse errors
+ }
+ }
+ // due date
+ if ('dueDate' in json && json.dueDate) {
+ if (new Date(json.dueDate) < new Date()) {
+ setIsPastDue(true);
+ }
+ }
+
+ // available date
+ if ('availableDate' in json && json.availableDate) {
+ setIsAvailable(new Date(json.availableDate) <= new Date());
+ }
+
+ // resume timer
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const startTs = parseInt(stored, 10);
+ if (!isNaN(startTs)) {
+ setHasStarted(true);
+ if (totalSeconds != null) {
+ const elapsed = computeElapsedSeconds(startTs);
+ setTimeLeft(elapsed >= totalSeconds ? 0 : totalSeconds - elapsed);
+ } else {
+ setTimeLeft(computeElapsedSeconds(startTs));
+ }
+ }
+ }
+ } catch {
+ setMetaError(t('errors.network-error'));
+ } finally {
+ setLoadingMeta(false);
+ }
+ }
+ fetchMeta();
+ return () => {
+ timerRef.current && clearInterval(timerRef.current);
+ };
+ }, [setId]);
+
+ // ─── TIMER LOGIC ─────────────────────────────────────────────────────────────
+ useEffect(() => {
+ if (!hasStarted || !quizMeta) return;
+ if (totalSeconds != null && timeLeft === 0) {
+ handleSubmit();
+ return;
+ }
+ timerRef.current && clearInterval(timerRef.current);
+ if (totalSeconds != null) {
+ timerRef.current = setInterval(() => {
+ setTimeLeft((prev) =>
+ prev === null
+ ? null
+ : prev <= 1
+ ? (clearInterval(timerRef.current!), 0)
+ : prev - 1
+ );
+ }, 1000);
+ } else {
+ timerRef.current = setInterval(() => {
+ setTimeLeft((prev) => (prev === null ? 1 : prev + 1));
+ }, 1000);
+ }
+ return () => void clearInterval(timerRef.current!);
+ }, [hasStarted, quizMeta]);
+
+ useEffect(() => {
+ if (hasStarted && totalSeconds != null && timeLeft === 0) {
+ handleSubmit();
+ }
+ }, [timeLeft, hasStarted, totalSeconds]);
+ useEffect(() => {
+ if (submitError) {
+ toast(t('errors.error-type-submit'), {
+ description: submitError,
+ action: {
+ label: 'X',
+ onClick: () => console.log('Close'),
+ },
+ });
+ }
+ }, [submitError]);
+
+ // ─── EVENT HANDLERS ─────────────────────────────────────────────────────────
+ const onClickStart = () => {
+ if (!quizMeta) return;
+ const nowMs = Date.now();
+ try {
+ localStorage.setItem(STORAGE_KEY, nowMs.toString());
+ } catch {
+ // Ignore localStorage errors
+ // Fallback: use session storage
+ }
+ setHasStarted(true);
+ setTimeLeft(totalSeconds ?? 0);
+ };
+
+ async function handleSubmit() {
+ if (!quizMeta) return;
+ setSubmitting(true);
+ setSubmitError(null);
+
+ try {
+ const res = await fetch(
+ `/api/v1/workspaces/${wsId}/quiz-sets/${setId}/submit`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(buildSubmissionPayload()),
+ }
+ );
+ const json: SubmitResult | { error: string } = await res.json();
+
+ if (!res.ok) {
+ setSubmitError((json as any).error || t('errors.submission-failed'));
+ return setSubmitting(false);
+ }
+
+ localStorage.removeItem(ANSWERS_KEY);
+ localStorage.removeItem(STORAGE_KEY);
+ clearStartTimestamp();
+ if ('attemptId' in json) {
+ router.push(
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/result?attemptId=${json.attemptId}`
+ );
+ }
+ // setSubmitResult(json as SubmitResult);
+ } catch {
+ setSubmitError(t('errors.network-error-submitting'));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ // ─── RENDER ─────────────────────────────────────────────────────────────────
+ if (loadingMeta) return ;
+ if (metaError)
+ return (
+
+ );
+ if (!quizMeta) return null;
+
+ // Take Quiz button + instruction
+ if (!hasStarted) {
+ return (
+
+ );
+ }
+
+ // Quiz form
+ const isCountdown = totalSeconds != null;
+
+ return (
+
+ {/* Mobile header */}
+
+
+
+
+
+ {sidebarVisible && quizMeta && (
+
+ )}
+
+
+
+ {quizMeta.setName}
+
+
+
+
+
+ );
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/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
similarity index 83%
rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status.tsx
rename to apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status.tsx
index cdc961a8cf..8cbc110f32 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/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'}