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) => (
-
-
- setSelectedAnswers((prev) => ({
- ...prev,
- [q.quizId]: opt.id,
- }))
- }
- disabled={submitting || (isCountdown && timeLeft === 0)}
- className="form-radio"
- />
- {opt.value}
-
- ))}
-
-
+
+
+
+ {
+ 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) => (
+
+
+
+ {opt.value}
+
+
+ ))}
+
+
+
))}
{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
+
+
+ router.push(
+ `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/attempts`
+ )
+ }
+ >
+ {t('ws-quizzes.back')||'Back to attempts'}
+
+
+ {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 */}
+
+
+ {isStarting ? (
+ <>
+
+ Starting Quiz...
+ >
+ ) : (
+ <>
+
+ Start Quiz
+ >
+ )}
+
+
+
+ {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 ? (
+
viewAttemptDetailed(att)}>
+ View Details
+
+ ) : 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 (
-
+
{translate('ws-quizzes.question_status_title', 'Question Progress')}
- {/* Optional Progress Overview */}
+ {/* Progress overview */}
{answeredCount} / {questions.length}{' '}
{translate('ws-quizzes.answered_status_short', 'answered')}
-
+
0 ? (answeredCount / questions.length) * 100 : 0}%`,
- }}
+ style={{ width: `${pct}%` }}
+ role="progressbar"
aria-valuenow={answeredCount}
aria-valuemin={0}
aria-valuemax={questions.length}
- role="progressbar"
aria-label={translate(
'ws-quizzes.quiz_progress_label',
'Quiz Progress'
)}
- >
+ />
-
{questions.map((q, idx) => {
- const isAnswered = Boolean(selectedAnswers[q.quizId]);
- const questionNumber = idx + 1;
+ const answered = Array.isArray(selectedAnswers[q.quizId])
+ ? (selectedAnswers[q.quizId] as string[]).length > 0
+ : Boolean(selectedAnswers[q.quizId]);
+ const labelText = answered
+ ? translate('ws-quizzes.answered_state', 'Answered')
+ : translate('ws-quizzes.unanswered_state', 'Unanswered');
+ const icon = answered
+ ? translate('ws-quizzes.answered_icon', '✓')
+ : translate('ws-quizzes.unanswered_icon', '○');
return (
onQuestionJump(idx)} // Pass index or ID
- aria-label={translate(
- 'ws-quizzes.jump_to_question_aria',
- `Question ${questionNumber}, ${isAnswered ? translate('ws-quizzes.answered_state', 'Answered') : translate('ws-quizzes.unanswered_state', 'Unanswered')}`,
- { number: questionNumber }
- )}
- className={`flex h-9 w-full items-center justify-center rounded-md border text-xs font-medium transition-all duration-150 ease-in-out hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card focus-visible:outline-none active:opacity-60 ${
- isAnswered
+ onClick={() => onQuestionJump(idx)}
+ aria-label={`${translate(
+ 'ws-quizzes.jump_to_question',
+ 'Jump to question',
+ { number: idx + 1 }
+ )} ${idx + 1}, ${labelText}`}
+ className={
+ `flex h-9 w-full items-center justify-center rounded-md border text-xs font-medium transition ` +
+ (answered
? 'border-primary bg-primary text-primary-foreground'
- : 'border-border bg-background text-muted-foreground hover:bg-muted'
- } `}
+ : 'border-border bg-background text-muted-foreground hover:bg-muted')
+ }
>
-
- {' '}
- {/* Icon is decorative if main label is sufficient */}
- {isAnswered
- ? translate('ws-quizzes.answered_icon', '✓')
- : translate('ws-quizzes.unanswered_icon', '⚪')}
+
+ {icon}
- {questionNumber}
+ {idx + 1}
);
})}
-
+
);
-};
-
-export default QuizStatusSidebar;
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
index b867849da..d4e5c12d2 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
@@ -1,12 +1,13 @@
+// File: app/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
+'use client';
+
import { Button } from '@tuturuuu/ui/button';
+import Link from 'next/link';
import React from 'react';
-export default function BeforeTakeQuizSection({
- t,
- quizMeta,
- dueDateStr,
- onClickStart,
-}: {
+// File: app/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
+
+interface BeforeTakeQuizSectionProps {
t: (key: string, options?: Record
) => string;
quizMeta: {
setName: string;
@@ -15,44 +16,76 @@ export default function BeforeTakeQuizSection({
timeLimitMinutes: number | null;
};
dueDateStr?: string | null;
+ instruction?: string | null;
onClickStart: () => void;
-}) {
+ /**
+ * If provided, render a link to view past attempts.
+ * e.g. `/dashboard/[wsId]/…/results`
+ */
+ viewResultsUrl?: string;
+}
+
+export default function BeforeTakeQuizSection({
+ t,
+ quizMeta,
+ dueDateStr,
+ instruction,
+ onClickStart,
+ viewResultsUrl,
+}: BeforeTakeQuizSectionProps) {
return (
-
+
+ {/* Due date */}
{dueDateStr && (
-
+
{t('ws-quizzes.due_on') || 'Due on'}:{' '}
{new Date(dueDateStr).toLocaleString()}
)}
-
{quizMeta.setName}
- {quizMeta.attemptLimit !== null ? (
-
- {t('ws-quizzes.attempts') || 'Attempts'}: {quizMeta.attemptsSoFar} /{' '}
- {quizMeta.attemptLimit}
-
- ) : (
-
- {t('ws-quizzes.attempts') || 'Attempts'}: {quizMeta.attemptsSoFar} /{' '}
- {t('ws-quizzes.unlimited')}
-
+
+ {/* Instruction (Markdown or simple text) */}
+ {instruction && (
+
+ {/* If your instruction is raw HTML, use dangerouslySetInnerHTML */}
+
{instruction}
+
)}
- {quizMeta.timeLimitMinutes !== null ? (
-
- {t('ws-quizzes.time_limit') || 'Time Limit'}:{' '}
- {quizMeta.timeLimitMinutes} {t('ws-quizzes.minutes') || 'minutes'}
-
- ) : (
-
- {t('ws-quizzes.no_time_limit') || 'No time limit'}
-
+
+ {/* Title & stats */}
+
{quizMeta.setName}
+
+ {t('ws-quizzes.attempts') || 'Attempts'}: {quizMeta.attemptsSoFar} /{' '}
+ {quizMeta.attemptLimit ?? (t('ws-quizzes.unlimited') || 'Unlimited')}
+
+
+ {quizMeta.timeLimitMinutes != null
+ ? `${t('ws-quizzes.time_limit') || 'Time Limit'}: ${
+ quizMeta.timeLimitMinutes
+ } ${t('ws-quizzes.minutes') || 'minutes'}`
+ : t('ws-quizzes.no_time_limit') || 'No time limit'}
+
+
+ {/* Primary action */}
+
+
+ {t('ws-quizzes.take_quiz') || 'Take Quiz'}
+
+
+
+ {/* Optional: view past attempts */}
+ {viewResultsUrl && (
+
)}
-
- {t('ws-quizzes.take_quiz') || 'Take Quiz'}
-
);
}
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 863740d51..7d2d0e456 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
@@ -1,5 +1,6 @@
'use client';
+import BeforeTakingQuizWhole, { AttemptSummary } from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-taking-quiz-whole';
import QuizStatusSidebar, {
Question,
} from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar';
@@ -9,6 +10,8 @@ import ShowResultSummarySection from '@/app/[locale]/(dashboard)/[wsId]/quiz-set
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 { Checkbox } from '@tuturuuu/ui/checkbox';
+import { LoadingIndicator } from '@tuturuuu/ui/custom/loading-indicator';
import { ListCheck } from '@tuturuuu/ui/icons';
import { Label } from '@tuturuuu/ui/label';
import { RadioGroup, RadioGroupItem } from '@tuturuuu/ui/radio-group';
@@ -20,11 +23,23 @@ type TakeResponse = {
setId: string;
setName: string;
timeLimitMinutes: number | null;
+ releasePointsImmediately: boolean;
attemptLimit: number | null;
attemptsSoFar: number;
allowViewResults: boolean;
- questions: Question[];
+ availableDate: string | null;
dueDate: string | null;
+ resultsReleased: boolean;
+ attempts: AttemptSummary[];
+ explanationMode: 0 | 1 | 2;
+ instruction: any;
+ questions: Array<{
+ quizId: string;
+ question: string;
+ score: number;
+ multiple: boolean;
+ options: { id: string; value: string }[];
+ }>;
};
type SubmitResult = {
@@ -48,7 +63,7 @@ export default function TakingQuizClient({
const t = useTranslations();
const router = useRouter();
- // ─── STATE ───────────────────────────────────────────────────────────────────
+ // ─── STATE ────────────────────────────────────────────────────────────────
const [sidebarVisible, setSidebarVisible] = useState(false);
const [loadingMeta, setLoadingMeta] = useState(true);
@@ -57,20 +72,22 @@ export default function TakingQuizClient({
const [hasStarted, setHasStarted] = useState(false);
const [isPastDue, setIsPastDue] = useState(false);
+ const [isAvailable, setIsAvailable] = useState(true);
const [dueDateStr, setDueDateStr] = useState
(null);
const [timeLeft, setTimeLeft] = useState(null);
const timerRef = useRef(null);
+ // Now can be string (radio) or string[] (checkbox)
const [selectedAnswers, setSelectedAnswers] = useState<
- Record
+ Record
>({});
const [submitting, setSubmitting] = useState(false);
const [submitResult, setSubmitResult] = useState(null);
const [submitError, setSubmitError] = useState(null);
- // ─── HELPERS ─────────────────────────────────────────────────────────────────
+ // ─── HELPERS ────────────────────────────────────────────────────────────────
const STORAGE_KEY = `quiz_start_${setId}`;
const ANSWERS_KEY = `quiz_answers_${setId}`;
const totalSeconds = quizMeta?.timeLimitMinutes
@@ -87,13 +104,17 @@ export default function TakingQuizClient({
Math.floor((Date.now() - startTs) / 1000);
const buildSubmissionPayload = () => ({
- answers: Object.entries(selectedAnswers).map(([quizId, optionId]) => ({
- quizId,
- selectedOptionId: optionId,
- })),
+ answers: Object.entries(selectedAnswers)
+ .map(([quizId, val]) => {
+ if (Array.isArray(val)) {
+ return val.map((v) => ({ quizId, selectedOptionId: v }));
+ }
+ return { quizId, selectedOptionId: val };
+ })
+ .flat(),
});
- // ─── FETCH METADATA ────────────────────────────────────────────────────────────
+ // ─── FETCH METADATA ─────────────────────────────────────────────────────────
useEffect(() => {
async function fetchMeta() {
setLoadingMeta(true);
@@ -105,20 +126,19 @@ export default function TakingQuizClient({
if (!res.ok) {
setMetaError((json as any).error || 'Unknown error');
- setLoadingMeta(false);
- return;
+ return setLoadingMeta(false);
}
+
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 */
- }
- }
+
+ // restore answers
+ const saved = localStorage.getItem(ANSWERS_KEY);
+ if (saved) {
+ try {
+ setSelectedAnswers(JSON.parse(saved));
+ } catch {}
}
+ // due date
if ('dueDate' in json && json.dueDate) {
setDueDateStr(json.dueDate);
if (new Date(json.dueDate) < new Date()) {
@@ -126,16 +146,21 @@ export default function TakingQuizClient({
}
}
+ // 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)) {
- if (totalSeconds !== null) {
+ setHasStarted(true);
+ if (totalSeconds != null) {
const elapsed = computeElapsedSeconds(startTs);
- setHasStarted(true);
setTimeLeft(elapsed >= totalSeconds ? 0 : totalSeconds - elapsed);
} else {
- setHasStarted(true);
setTimeLeft(computeElapsedSeconds(startTs));
}
}
@@ -148,9 +173,7 @@ export default function TakingQuizClient({
}
fetchMeta();
return () => {
- if (timerRef.current) {
- clearInterval(timerRef.current);
- }
+ timerRef.current && clearInterval(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setId]);
@@ -158,15 +181,12 @@ export default function TakingQuizClient({
// ─── TIMER LOGIC ─────────────────────────────────────────────────────────────
useEffect(() => {
if (!hasStarted || !quizMeta) return;
-
- if (totalSeconds !== null && timeLeft === 0) {
+ if (totalSeconds != null && timeLeft === 0) {
handleSubmit();
return;
}
-
timerRef.current && clearInterval(timerRef.current);
-
- if (totalSeconds !== null) {
+ if (totalSeconds != null) {
timerRef.current = setInterval(() => {
setTimeLeft((prev) =>
prev === null
@@ -181,12 +201,11 @@ export default function TakingQuizClient({
setTimeLeft((prev) => (prev === null ? 1 : prev + 1));
}, 1000);
}
-
return () => void clearInterval(timerRef.current!);
}, [hasStarted, quizMeta]);
useEffect(() => {
- if (hasStarted && totalSeconds !== null && timeLeft === 0) {
+ if (hasStarted && totalSeconds != null && timeLeft === 0) {
handleSubmit();
}
}, [timeLeft, hasStarted, totalSeconds]);
@@ -197,7 +216,10 @@ export default function TakingQuizClient({
const nowMs = Date.now();
try {
localStorage.setItem(STORAGE_KEY, nowMs.toString());
- } catch {}
+ } catch {
+ console.warn('Failed to save start timestamp to localStorage');
+ // Fallback: use session storage
+ }
setHasStarted(true);
setTimeLeft(totalSeconds ?? 0);
};
@@ -232,34 +254,12 @@ export default function TakingQuizClient({
}
}
- // ─── RENDER ───────────────────────────────────────────────────────────────────
- if (loadingMeta) {
- return {t('ws-quizzes.loading') || 'Loading...'}
;
- }
- if (metaError) {
- return {metaError}
;
- }
- if (!quizMeta) {
- return null;
- }
+ // ─── RENDER ─────────────────────────────────────────────────────────────────
+ if (loadingMeta) return ;
+ if (metaError) return {metaError}
;
+ if (!quizMeta) return null;
- // Past due?
- if (isPastDue) {
- return (
-
- );
- }
-
- // After submit: show result summary
+ // After submit
if (submitResult) {
return (
0) {
return (
@@ -308,10 +308,10 @@ export default function TakingQuizClient({
);
}
- // ─── “Not started yet”: no attempts left? ────────────────────────────────────
+ // No attempts left
if (
!hasStarted &&
- quizMeta.attemptLimit !== null &&
+ quizMeta.attemptLimit != null &&
quizMeta.attemptsSoFar >= quizMeta.attemptLimit
) {
return (
@@ -347,38 +347,42 @@ export default function TakingQuizClient({
);
}
- // ─── “Take Quiz” button ──────────────────────────────────────────────────────
+ // Take Quiz button + instruction
if (!hasStarted) {
+ // return (
+ //
+ // );
return (
-
);
}
- // ─── QUIZ FORM ───────────────────────────────────────────────────────────────
- const isCountdown = totalSeconds !== null;
+ // Quiz form
+ const isCountdown = totalSeconds != null;
return (
-
-
-
setSidebarVisible(!sidebarVisible)}
- >
-
+ {/* Mobile header */}
+
+
+ setSidebarVisible(!sidebarVisible)}>
+
-
- {sidebarVisible && quizMeta && (
-
- )}
-
+ {sidebarVisible && quizMeta && (
+
+ )}
-
+
{quizMeta.setName}
-
+
;
+ };
+ const questionsInfo = (qRaw as QRow[]).map((r) => ({
+ quizId: r.quiz_id,
+ question: r.workspace_quizzes.question,
+ scoreWeight: r.workspace_quizzes.score,
+ options: r.quiz_options.map((o) => ({
+ id: o.id,
+ value: o.value,
+ isCorrect: o.is_correct,
+ explanation: o.explanation,
+ })),
+ }));
+ const maxPossibleScore = questionsInfo.reduce(
+ (sum, q) => sum + q.scoreWeight,
+ 0
+ );
+
+ // 5) Load this attempt’s answers
+ const { data: ansRows, error: ansErr } = await sb
+ .from('workspace_quiz_attempt_answers')
+ .select('quiz_id, selected_option_id, is_correct, score_awarded')
+ .eq('attempt_id', attemptId);
+ if (ansErr) {
+ return NextResponse.json(
+ { error: 'Error fetching answers' },
+ { status: 500 }
+ );
+ }
+ const ansMap = new Map(ansRows.map((a) => [a.quiz_id, a]));
+
+ // 6) Build detailed questions
+ const detailed = questionsInfo.map((q) => {
+ const a = ansMap.get(q.quizId);
+ return {
+ quizId: q.quizId,
+ question: q.question,
+ scoreWeight: q.scoreWeight,
+ selectedOptionId: a?.selected_option_id || null,
+ isCorrect: a?.is_correct ?? false,
+ scoreAwarded: a?.score_awarded ?? 0,
+ options: q.options.map((opt) => {
+ // Only show explanation per explanation_mode:
+ let explanation: string | null = null;
+ if (explanation_mode === 2) explanation = opt.explanation;
+ else if (explanation_mode === 1 && opt.isCorrect)
+ explanation = opt.explanation;
+ return {
+ id: opt.id,
+ value: opt.value,
+ isCorrect: opt.isCorrect,
+ explanation,
+ };
+ }),
+ };
+ });
+
+ return NextResponse.json({
+ attemptId: attRow.id,
+ attemptNumber: attRow.attempt_number,
+ totalScore: attRow.total_score ?? 0,
+ maxPossibleScore,
+ startedAt: attRow.started_at,
+ completedAt: attRow.completed_at,
+ durationSeconds,
+ explanationMode: explanation_mode,
+ questions: detailed,
+ });
+}
diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/route.ts
new file mode 100644
index 000000000..6e67fda7d
--- /dev/null
+++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/route.ts
@@ -0,0 +1,88 @@
+// File: app/api/quiz-sets/[setId]/attempts/route.ts
+import { createClient } from '@tuturuuu/supabase/next/server';
+import { NextRequest, NextResponse } from 'next/server';
+
+interface QuizSetRow {
+ release_points_immediately: boolean;
+}
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: { setId: string } }
+) {
+ const { setId } = params;
+ const sb = await createClient();
+
+ // 1) Auth
+ const {
+ data: { user },
+ error: uErr,
+ } = await sb.auth.getUser();
+ if (uErr || !user) {
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
+ }
+ const userId = user.id;
+
+ // 2) Check allow_view_results flag
+ const { data: setRow, error: sErr } = await sb
+ .from('workspace_quiz_sets')
+ .select('release_points_immediately')
+ .eq('id', setId)
+ .maybeSingle();
+
+ if (sErr || !setRow) {
+ return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 });
+ }
+
+ const { release_points_immediately } = setRow;
+ if (!release_points_immediately ) {
+ return NextResponse.json(
+ { error: 'Results are not yet released' },
+ { status: 403 }
+ );
+ }
+
+ // 3) Fetch all attempts for this user & set
+ const { data: attempts, error: aErr } = await sb
+ .from('workspace_quiz_attempts')
+ .select(
+ `
+ id,
+ attempt_number,
+ total_score,
+ started_at,
+ completed_at
+ `
+ )
+ .eq('user_id', userId)
+ .eq('set_id', setId)
+ .order('attempt_number', { ascending: false });
+ if (aErr) {
+ return NextResponse.json(
+ { error: 'Error fetching attempts' },
+ { status: 500 }
+ );
+ }
+ if (!attempts.length) {
+ return NextResponse.json({ attempts: [] });
+ }
+
+ // 4) For each attempt, compute durationSeconds
+ const summaries = attempts.map((att) => {
+ const started = new Date(att.started_at).getTime();
+ const completed = att.completed_at
+ ? new Date(att.completed_at).getTime()
+ : Date.now();
+ const durationSeconds = Math.round((completed - started) / 1000);
+ return {
+ attemptId: att.id,
+ attemptNumber: att.attempt_number,
+ totalScore: att.total_score ?? 0,
+ startedAt: att.started_at,
+ completedAt: att.completed_at,
+ durationSeconds,
+ };
+ });
+
+ return NextResponse.json({ attempts: summaries });
+}
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 0e783cd6d..f9e039172 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,184 +1,96 @@
-import { createClient } from '@tuturuuu/supabase/next/server';
+// app/api/quiz-sets/[setId]/results/route.ts
import { NextRequest, NextResponse } from 'next/server';
-
-type AttemptAnswer = {
- quizId: string;
- question: string;
- selectedOption: string | null;
- correctOption: string;
- isCorrect: boolean;
- scoreAwarded: number;
-};
-type AttemptDTO = {
- attemptId: string;
- attemptNumber: number;
- totalScore: number;
- maxPossibleScore: number;
- startedAt: string;
- completedAt: string | null;
- answers: AttemptAnswer[];
-};
+import { createClient } from '@tuturuuu/supabase/next/server';
export async function GET(
- _request: NextRequest,
- { params }: { params: Promise<{ setId: string }> }
+ _req: NextRequest,
+ { params }: { params: { setId: string } }
) {
- const { setId } = await params;
- const supabase = await createClient();
+ const setId = params.setId;
+ const sb = await createClient();
- // 1) Auth
- const {
- data: { user },
- error: userErr,
- } = await supabase.auth.getUser();
- if (userErr || !user) {
- return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
- }
- const userId = user.id;
+ // Auth
+ const { data:{ user }, error: uErr } = await sb.auth.getUser();
+ if (uErr||!user) return NextResponse.json({ error:'Not authenticated' },{status:401});
+ const uid = user.id;
- // 2) Always allow if they have any attempts—and if allow_view_results is true
- const { data: setRow, error: setErr } = await supabase
+ // Check allow_view_results
+ const { data: s, error: sErr } = await sb
.from('workspace_quiz_sets')
.select('allow_view_results')
.eq('id', setId)
.maybeSingle();
-
- if (setErr || !setRow) {
- return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 });
- }
- if (!setRow.allow_view_results) {
- return NextResponse.json(
- { error: 'Viewing results is disabled' },
- { status: 403 }
- );
+ if (sErr||!s) return NextResponse.json({ error:'Not found' },{status:404});
+ if (!s.allow_view_results) {
+ return NextResponse.json({ error:'Viewing disabled' }, { status:403 });
}
- // 3) Fetch question info (correct answers + weight)
- const { data: questionsRaw, error: qErr } = await supabase
+ // Fetch correct answers & weight
+ const { data: qRaw, error: qErr } = await sb
.from('quiz_set_quizzes')
- .select(
- `
+ .select(`
quiz_id,
- workspace_quizzes (
- question,
- score
- ),
- quiz_options!inner (
- value
- )
- `
- )
+ 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: 'Error fetching questions' },
- { status: 500 }
- );
- }
-
- const questionInfo = (questionsRaw || []).map((row: any) => ({
- quizId: row.quiz_id,
- question: row.workspace_quizzes.question,
- scoreWeight: row.workspace_quizzes.score,
- correctOptionValue: row.quiz_options.value,
+ type Q = { quiz_id: string, workspace_quizzes:{score:number}, quiz_options:{value:string} };
+ const info = (qRaw as Q[]).map(r=>({
+ quizId: r.quiz_id,
+ weight: r.workspace_quizzes.score,
+ correct: r.quiz_options.value
}));
- const maxPossibleScore = questionInfo.reduce((s, q) => s + q.scoreWeight, 0);
+ const maxScore = info.reduce((a,c)=>a+c.weight, 0);
- // 4) Fetch all attempts by user
- const { data: attemptsData, error: attemptsErr } = await supabase
+ // Fetch attempts
+ const { data: aData, error: aErr } = await sb
.from('workspace_quiz_attempts')
- .select(
- `
+ .select(`
id,
attempt_number,
total_score,
- started_at,
- completed_at
- `
- )
- .eq('user_id', userId)
+ submitted_at,
+ duration_seconds
+ `)
+ .eq('user_id', uid)
.eq('set_id', setId)
- .order('attempt_number', { ascending: false });
-
- if (attemptsErr) {
- return NextResponse.json(
- { error: 'Error fetching attempts' },
- { status: 500 }
- );
- }
- const attempts = attemptsData || [];
- if (!attempts.length) {
- return NextResponse.json({ error: 'No attempts found' }, { status: 404 });
- }
+ .order('attempt_number',{ascending:false});
+ if (aErr) return NextResponse.json({ error:'Attempt fetch error' }, { status:500 });
- // 5) For each attempt, fetch its answers
- const resultDTOs: AttemptDTO[] = [];
-
- for (const att of attempts) {
- const { data: answerRows, error: ansErr } = await supabase
+ 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);
-
- if (ansErr) {
- return NextResponse.json(
- { error: 'Error fetching attempt answers' },
- { status: 500 }
- );
- }
-
- const aMap = new Map(answerRows!.map((a: any) => [a.quiz_id, a]));
-
- const answers = await Promise.all(
- questionInfo.map(async (qi) => {
- const a = aMap.get(qi.quizId);
- if (a) {
- const { data: selOpt, error: selErr } = await supabase
- .from('quiz_options')
- .select('value')
- .eq('id', a.selected_option_id)
- .maybeSingle();
-
- return {
- quizId: qi.quizId,
- question: qi.question,
- selectedOption: selErr || !selOpt ? null : selOpt.value,
- correctOption: qi.correctOptionValue,
- isCorrect: a.is_correct,
- scoreAwarded: a.score_awarded,
- };
- } else {
- return {
- quizId: qi.quizId,
- question: qi.question,
- selectedOption: null,
- correctOption: qi.correctOptionValue,
- isCorrect: false,
- scoreAwarded: 0,
- };
- }
- })
- );
-
- resultDTOs.push({
- attemptId: att.id,
- attemptNumber: att.attempt_number,
- totalScore: att.total_score ?? 0,
- maxPossibleScore,
- startedAt: att.started_at,
- completedAt: att.completed_at,
- 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
+ };
});
- }
- return NextResponse.json({ attempts: resultDTOs });
+ 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]/take/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts
index 4158938bf..aa2545093 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
@@ -1,3 +1,4 @@
+// File: app/api/quiz-sets/[setId]/take/route.ts
import { createClient } from '@tuturuuu/supabase/next/server';
import { NextRequest, NextResponse } from 'next/server';
@@ -6,29 +7,37 @@ type RawRow = {
workspace_quizzes: {
question: string;
score: number;
- quiz_options: { id: string; value: string }[];
+ instruction: any; // JSONB
+ quiz_options: { id: string; value: string; is_correct: boolean }[];
};
};
+type AttemptSummary = {
+ attemptId: string;
+ attemptNumber: number;
+ submittedAt: string; // ISO timestamp
+ durationSeconds: number;
+};
+
export async function GET(
- _request: NextRequest,
- { params }: { params: Promise<{ setId: string }> }
+ _req: NextRequest,
+ { params }: { params: { setId: string } }
) {
- const { setId } = await params;
- const supabase = await createClient();
+ const setId = params.setId;
+ const sb = await createClient();
// 1) Auth
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) Fetch quiz-set metadata
- const { data: setRow, error: setErr } = await supabase
+ // 2) Fetch quiz-set metadata (+ new cols)
+ const { data: setRow, error: sErr } = await sb
.from('workspace_quiz_sets')
.select(
`
@@ -36,74 +45,126 @@ export async function GET(
name,
attempt_limit,
time_limit_minutes,
+ available_date,
due_date,
- release_points_immediately
+ release_points_immediately,
+ results_released,
+ explanation_mode,
+ instruction
`
)
.eq('id', setId)
.maybeSingle();
-
- if (setErr || !setRow) {
+ if (sErr || !setRow) {
return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 });
}
-
const {
name: setName,
- attempt_limit,
- time_limit_minutes,
- due_date,
- release_points_immediately,
+ attempt_limit: attemptLimit,
+ time_limit_minutes: timeLimitMinutes,
+ available_date: availableDate,
+ due_date: dueDate,
+ release_points_immediately: releasePointsImmediately,
+ results_released: resultsReleased,
+ explanation_mode: explanationMode,
+ instruction,
} = setRow;
- // 3) due_date enforcement
- if (new Date(due_date) < new Date()) {
+ const now = new Date();
+
+ // 3) Availability & due checks
+ if (availableDate && new Date(availableDate) > now) {
+ return NextResponse.json(
+ { error: 'Quiz not yet available', availableDate },
+ { status: 403 }
+ );
+ }
+ if (dueDate && new Date(dueDate) < now) {
return NextResponse.json(
- { error: 'Quiz is past its due date', dueDate: due_date },
+ { error: 'Quiz past due', dueDate },
{ status: 403 }
);
}
- // 4) Count previous attempts
- const { data: prevAttempts, error: attErr } = await supabase
+ // 4) Count how many attempts user already made
+ const { data: prev, error: aErr } = await sb
.from('workspace_quiz_attempts')
.select('attempt_number', { count: 'exact', head: false })
.eq('user_id', userId)
.eq('set_id', setId);
-
- if (attErr) {
+ if (aErr) {
return NextResponse.json(
{ error: 'Error counting attempts' },
{ status: 500 }
);
}
- const attemptsCount = prevAttempts?.length ?? 0;
+ const attemptsSoFar = prev?.length ?? 0;
- // 5) If limit reached, block
- if (attempt_limit !== null && attemptsCount >= attempt_limit) {
+ // 5) Enforce attempt limit
+ if (attemptLimit !== null && attemptsSoFar >= attemptLimit) {
return NextResponse.json(
{
- error: 'Maximum attempts reached',
- attemptsSoFar: attemptsCount,
- attemptLimit: attempt_limit,
- dueDate: due_date,
+ error: 'Max attempts reached',
+ attemptsSoFar,
+ attemptLimit,
allowViewResults: false,
},
{ status: 403 }
);
}
- // 6) If release is immediate AND they’ve already done ≥1 attempt, return past attempts directly
- if (release_points_immediately && attemptsCount > 0) {
- // Fetch and return their attempts (very basic summary; frontend can call /results for detail)
+ // 6) Build `allowViewResults` from immediate‐release flag
+ const allowViewResults = Boolean(releasePointsImmediately) || resultsReleased;
+
+ // 7) Fetch all previous attempts summary
+ const { data: rawAttempts, error: attErr } = await sb
+ .from('workspace_quiz_attempts')
+ .select('id,attempt_number,started_at,completed_at')
+ .eq('user_id', userId)
+ .eq('set_id', setId)
+ .order('attempt_number', { ascending: false });
+ if (attErr) {
+ return NextResponse.json(
+ { error: 'Error fetching attempts' },
+ { status: 500 }
+ );
+ }
+ const attempts: AttemptSummary[] = (rawAttempts || []).map((row) => {
+ const started = new Date(row.started_at).getTime();
+ const completed = row.completed_at
+ ? new Date(row.completed_at).getTime()
+ : Date.now();
+ const durationSeconds = Math.floor((completed - started) / 1000);
+ return {
+ attemptId: row.id,
+ attemptNumber: row.attempt_number,
+ submittedAt: row.completed_at ?? row.started_at,
+ durationSeconds,
+ };
+ });
+
+ // 8) Early‐exit if they’ve already done one attempt _and_ results are viewable
+ if (allowViewResults && attemptsSoFar > 0) {
return NextResponse.json({
- message: 'Results are viewable immediately',
- attemptsSoFar: attemptsCount,
- allowViewResults: true,
+ setId,
+ setName,
+ timeLimitMinutes,
+ releasePointsImmediately,
+ resultsReleased,
+ attemptLimit,
+ attemptsSoFar,
+ allowViewResults,
+ availableDate,
+ dueDate,
+ attempts,
+ explanationMode,
+ instruction,
+ questions: [], // no need to send questions
});
}
- // 7) Otherwise, return questions for taking
- const { data: rawData, error: quizErr } = await supabase
+ // 9) Otherwise fetch questions+options as before
+ const { data: rawQ, error: qErr } = await sb
.from('quiz_set_quizzes')
.select(
`
@@ -111,39 +172,50 @@ export async function GET(
workspace_quizzes (
question,
score,
+ instruction,
quiz_options (
id,
- value
+ value,
+ is_correct
)
)
`
)
.eq('set_id', setId);
-
- if (quizErr) {
+ if (qErr) {
return NextResponse.json(
{ error: 'Error fetching questions' },
{ status: 500 }
);
}
-
- const questions = (rawData as RawRow[]).map((row) => ({
- quizId: row.quiz_id,
- question: row.workspace_quizzes.question,
- score: row.workspace_quizzes.score,
- options: row.workspace_quizzes.quiz_options.map((o) => ({
+ const questions = (rawQ as RawRow[]).map((r) => ({
+ quizId: r.quiz_id,
+ question: r.workspace_quizzes.question,
+ score: r.workspace_quizzes.score,
+ multiple:
+ r.workspace_quizzes.quiz_options.filter((o) => o.is_correct).length > 1,
+ options: r.workspace_quizzes.quiz_options.map((o) => ({
id: o.id,
value: o.value,
})),
+ instruction: r.workspace_quizzes.instruction ?? instruction,
}));
+ // 10) Return the full TakeResponse
return NextResponse.json({
setId,
setName,
- attemptLimit: attempt_limit,
- timeLimitMinutes: time_limit_minutes,
- attemptsSoFar: attemptsCount,
- dueDate: due_date,
+ timeLimitMinutes,
+ releasePointsImmediately,
+ attemptLimit,
+ attemptsSoFar,
+ resultsReleased,
+ allowViewResults,
+ availableDate,
+ dueDate,
+ attempts,
+ explanationMode,
+ instruction,
questions,
});
}
diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts
index 43d446ed3..f8c595c25 100644
--- a/packages/types/src/supabase.ts
+++ b/packages/types/src/supabase.ts
@@ -5852,27 +5852,33 @@ export type Database = {
Row: {
attempt_number: number;
completed_at: string | null;
+ duration_seconds: number | null;
id: string;
set_id: string;
started_at: string;
+ submitted_at: string;
total_score: number | null;
user_id: string;
};
Insert: {
attempt_number: number;
completed_at?: string | null;
+ duration_seconds?: number | null;
id?: string;
set_id: string;
started_at?: string;
+ submitted_at?: string;
total_score?: number | null;
user_id: string;
};
Update: {
attempt_number?: number;
completed_at?: string | null;
+ duration_seconds?: number | null;
id?: string;
set_id?: string;
started_at?: string;
+ submitted_at?: string;
total_score?: number | null;
user_id?: string;
};
@@ -5911,33 +5917,45 @@ export type Database = {
Row: {
allow_view_results: boolean;
attempt_limit: number | null;
+ available_date: string;
created_at: string;
due_date: string;
+ explanation_mode: number;
id: string;
+ instruction: Json | null;
name: string;
release_points_immediately: boolean;
+ results_released: boolean;
time_limit_minutes: number | null;
ws_id: string | null;
};
Insert: {
allow_view_results?: boolean;
attempt_limit?: number | null;
+ available_date?: string;
created_at?: string;
due_date?: string;
+ explanation_mode?: number;
id?: string;
+ instruction?: Json | null;
name?: string;
release_points_immediately?: boolean;
+ results_released?: boolean;
time_limit_minutes?: number | null;
ws_id?: string | null;
};
Update: {
allow_view_results?: boolean;
attempt_limit?: number | null;
+ available_date?: string;
created_at?: string;
due_date?: string;
+ explanation_mode?: number;
id?: string;
+ instruction?: Json | null;
name?: string;
release_points_immediately?: boolean;
+ results_released?: boolean;
time_limit_minutes?: number | null;
ws_id?: string | null;
};
@@ -5955,6 +5973,7 @@ export type Database = {
Row: {
created_at: string;
id: string;
+ instruction: Json | null;
question: string;
score: number;
ws_id: string;
@@ -5962,6 +5981,7 @@ export type Database = {
Insert: {
created_at?: string;
id?: string;
+ instruction?: Json | null;
question: string;
score?: number;
ws_id: string;
@@ -5969,6 +5989,7 @@ export type Database = {
Update: {
created_at?: string;
id?: string;
+ instruction?: Json | null;
question?: string;
score?: number;
ws_id?: string;
From 8cdede681d14aaa7c7b6e9826707b9ecf24a311c Mon Sep 17 00:00:00 2001
From: Nhung
Date: Mon, 16 Jun 2025 17:54:23 +0700
Subject: [PATCH 03/17] feat (Taking Quiz UI): add taking quiz button, dark
light styling
---
.../20250616085119_new_migration.sql | 5 +
.../[courseId]/modules/[moduleId]/page.tsx | 1 +
.../show-attempt-detail-section.tsx | 152 ++++++++
.../show-result-summary-section.tsx | 214 +++++++++++
.../attempt-summary-view.tsx | 353 ++++++++++++++++++
.../quiz-sets/[setId]/result/page.tsx | 269 +++++++++++++
.../[setId]/take/before-taking-quiz-whole.tsx | 276 ++++++++------
.../quiz-sets/[setId]/take/page.tsx | 3 +-
.../[setId]/take/quiz-status-sidebar.tsx | 0
.../[setId]/take/taking-quiz-client.tsx | 138 ++-----
.../[setId]/take/time-elapsed-status.tsx | 0
.../modules/[moduleId]/quizzes/client-ai.tsx | 3 +
.../[moduleId]/quizzes/client-quizzes.tsx | 23 +-
.../modules/[moduleId]/quizzes/page.tsx | 18 +-
.../[setId]/attempts/[attemptId]/page.tsx | 154 --------
.../quiz-sets/[setId]/attempts/page.tsx | 93 -----
.../[wsId]/quiz-sets/[setId]/result/page.tsx | 3 -
.../sections/before-take-quiz-section.tsx | 91 -----
.../take/sections/past-due-section.tsx | 49 ---
.../sections/show-result-summary-section.tsx | 57 ---
.../[setId]/attempts/[attemptId]/route.ts | 213 +++++++----
.../quiz-sets/[setId]/attempts/route.ts | 88 -----
.../[wsId]/quiz-sets/[setId]/take/route.ts | 47 ++-
packages/types/src/supabase.ts | 6 +-
24 files changed, 1369 insertions(+), 887 deletions(-)
create mode 100644 apps/db/supabase/migrations/20250616085119_new_migration.sql
create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-attempt-detail-section.tsx
create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-result-summary-section.tsx
create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx
create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx
rename apps/upskii/src/app/[locale]/(dashboard)/[wsId]/{ => courses/[courseId]/modules/[moduleId]}/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx (52%)
rename apps/upskii/src/app/[locale]/(dashboard)/[wsId]/{ => courses/[courseId]/modules/[moduleId]}/quiz-sets/[setId]/take/page.tsx (71%)
rename apps/upskii/src/app/[locale]/(dashboard)/[wsId]/{ => courses/[courseId]/modules/[moduleId]}/quiz-sets/[setId]/take/quiz-status-sidebar.tsx (100%)
rename apps/upskii/src/app/[locale]/(dashboard)/[wsId]/{ => courses/[courseId]/modules/[moduleId]}/quiz-sets/[setId]/take/taking-quiz-client.tsx (78%)
rename apps/upskii/src/app/[locale]/(dashboard)/[wsId]/{ => courses/[courseId]/modules/[moduleId]}/quiz-sets/[setId]/take/time-elapsed-status.tsx (100%)
delete mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/page.tsx
delete mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/page.tsx
delete mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx
delete mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
delete mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section.tsx
delete mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/show-result-summary-section.tsx
delete mode 100644 apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/route.ts
diff --git a/apps/db/supabase/migrations/20250616085119_new_migration.sql b/apps/db/supabase/migrations/20250616085119_new_migration.sql
new file mode 100644
index 000000000..1e363f106
--- /dev/null
+++ b/apps/db/supabase/migrations/20250616085119_new_migration.sql
@@ -0,0 +1,5 @@
+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;
+
+
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 0bacb2c0a..4924d5e5f 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 {
+ t: (key: string) => string;
+ detail: AttemptDetailDTO;
+}
+
+export default function ShowAttemptDetailSection({
+ t,
+ detail,
+}: ShowAttemptDetailProps) {
+ 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('ws-quizzes.started_at') || 'Started at'}:{' '}
+ {fmtDate(detail.startedAt)}
+
+ {detail.completedAt && (
+
+ {t('ws-quizzes.completed_at') || 'Completed at'}:{' '}
+ {fmtDate(detail.completedAt)}
+
+ )}
+
+ {t('ws-quizzes.duration') || 'Duration'}:{' '}
+ {fmtDuration(detail.durationSeconds)}
+
+
+
+ {/* Questions */}
+ {detail.questions.map((q, idx) => {
+ const selId = q.selectedOptionId ?? null;
+ return (
+
+
+
+ {idx + 1}. {q.question}{' '}
+
+ ({t('ws-quizzes.points') || 'Points'}: {q.scoreWeight})
+
+
+
+
+ {q.options.map((opt) => {
+ const chosen = selId === opt.id;
+ return (
+
+ {opt.isCorrect ? (
+
+ ) : chosen ? (
+
+ ) : (
+
+ )}
+
+
+ {opt.value}{' '}
+ {opt.isCorrect && (
+
+ {t('ws-quizzes.correct') || '(Correct)'}
+
+ )}
+ {chosen && !opt.isCorrect && (
+
+ {t('ws-quizzes.your_answer') || '(Your answer)'}
+
+ )}
+
+ {opt.explanation && (
+
+ {opt.explanation}
+
+ )}
+
+
+ );
+ })}
+
+
+ {t('ws-quizzes.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 000000000..f9fad5826
--- /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,214 @@
+'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 { useRouter } from 'next/navigation';
+
+export interface ShowResultSummarySectionProps {
+ t: (key: string) => string;
+ submitResult: {
+ attemptNumber: number;
+ totalScore: number;
+ maxPossibleScore: number;
+ };
+ quizMeta: {
+ attemptLimit: number | null;
+ setName: string;
+ attemptsSoFar: number;
+ timeLimitMinutes: number | null;
+ };
+ wsId: string;
+ courseId: string;
+ moduleId: string;
+ setId: string;
+}
+
+export default function ShowResultSummarySection({
+ t,
+ submitResult,
+ quizMeta,
+ wsId,
+ courseId,
+ moduleId,
+ setId,
+}: ShowResultSummarySectionProps) {
+ 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('ws-quizzes.quiz-completed') || 'Quiz Completed!'}
+
+
{quizMeta.setName}
+
+
+ {/* Score Card */}
+
+
+
+
+ {t('ws-quizzes.your-score') || 'Your Score'}
+
+
+
+
+
+ {submitResult.totalScore} / {submitResult.maxPossibleScore}
+
+
+ {scorePercentage}%
+
+
+
+
+
+
+ 0%
+ 50%
+ 100%
+
+
+
+
+ {/* Attempt Information */}
+
+
+
+
+
+
+
+
+
+ {t('ws-quizzes.attempt') || 'Attempt'}
+
+
+ #{submitResult.attemptNumber} {t('ws-quizzes.of') || 'of'}{' '}
+ {quizMeta.attemptLimit ??
+ (t('ws-quizzes.unlimited') || 'Unlimited')}
+
+
+
+
+ {attemptsRemaining !== null && (
+
+
+
+
+
+
+ {t('ws-quizzes.attempts-remaining') || 'Attempts Remaining'}
+
+
+ {attemptsRemaining} {t('ws-quizzes.left') || 'left'}
+
+
+
+ )}
+
+
+
+
+ {/* Performance Feedback */}
+
+
+
+
+ {scorePercentage >= 90
+ ? t('ws-quizzes.excellent-work') || 'Excellent Work!'
+ : scorePercentage >= 70
+ ? t('ws-quizzes.good-job') || 'Good Job!'
+ : scorePercentage >= 50
+ ? t('ws-quizzes.keep-practicing') || 'Keep Practicing!'
+ : t('ws-quizzes.needs-improvement') || 'Needs Improvement'}
+
+
+ {scorePercentage >= 90
+ ? t('ws-quizzes.outstanding-performance') ||
+ 'Outstanding performance! You have mastered this material.'
+ : scorePercentage >= 70
+ ? t('ws-quizzes.solid-understanding') ||
+ 'You show a solid understanding of the material.'
+ : scorePercentage >= 50
+ ? t('ws-quizzes.room-for-improvement') ||
+ "There's room for improvement. Consider reviewing the material."
+ : t('ws-quizzes.review-recommended') ||
+ 'We recommend reviewing the material and trying again.'}
+
+
+
+
+
+
+
+ {/* Action Buttons */}
+
+ router.push(`/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/take`)}
+ className="flex items-center gap-2"
+ >
+
+ {t('ws-quizzes.back-take-quiz') || 'Back to Quiz Page'}
+
+
+
+ {/* Additional Info */}
+
+
+ {t('ws-quizzes.quiz-completed-at') || 'Quiz completed at'}{' '}
+ {new Date().toLocaleString()}
+
+ {quizMeta.timeLimitMinutes && (
+
+ {t('ws-quizzes.time-limit') || 'Time limit'}:{' '}
+ {quizMeta.timeLimitMinutes} {t('ws-quizzes.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 000000000..91588ce21
--- /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,353 @@
+'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';
+
+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 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 */}
+
+
Attempt Summary
+
+ Review your quiz attempt details and responses
+
+
+ Back to Quiz
+
+
+
+ {/* Attempt Overview */}
+
+
+
+
+ Attempt #{summary.attemptNumber}
+
+
+
+
+ {/* Submission Info */}
+
+
+
+
+
+
Submitted
+
+ {fmtDate(summary.submittedAt)}
+
+
+
+
+ {/* Duration */}
+
+
+
+
+
+
Duration
+
+ {fmtDur(summary.durationSeconds)}
+
+
+
+
+ {/* Completion Rate */}
+
+
+
+
+
+
Completion
+
+ {answeredQuestions} of {totalQuestions} questions
+
+
+
+
+
+ {/* Progress Bar */}
+
+
+
+ Progress
+
+
+ {completionRate}%
+
+
+
+
+
+
+
+ {/* Questions Section */}
+ {summary.questions && summary.questions.length > 0 && (
+
+
+
+
+ Questions & Responses
+
+
+ {summary.questions.length} questions
+
+
+
+
+ {summary.questions.map((q, index) => {
+ const isAnswered = q.selectedOptionId !== null;
+ const selectedOptionText = getSelectedOptionText(q);
+
+ return (
+
+
+
+
+
+ Q{index + 1}.
+
+ {q.question}
+
+
+ {isAnswered ? (
+
+
+
+ Answered
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {/* Selected Answer */}
+
+
+ Your Response:
+
+ {isAnswered && selectedOptionText ? (
+
+
+
+
+
+ {selectedOptionText}
+
+
+
+
+ ) : (
+
+
+
+
+ No answer provided
+
+
+
+ )}
+
+
+ {/* All Options */}
+
+
+ Available Options:
+
+
+ {q.options.map((option) => {
+ const isSelected = option.id === q.selectedOptionId;
+ return (
+
+
+
+ {isSelected && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Summary Stats */}
+
+
+
+
+
+ {summary.attemptNumber}
+
+
+ Attempt Number
+
+
+
+
+ {answeredQuestions}
+
+
+ Questions Answered
+
+
+
+
+ {totalQuestions - answeredQuestions}
+
+
+ Questions Skipped
+
+
+
+
+ {fmtDur(summary.durationSeconds)}
+
+
+ Time Taken
+
+
+
+
+
+
+
+ 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
new file mode 100644
index 000000000..746ff79cd
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx
@@ -0,0 +1,269 @@
+// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/result/page.tsx
+'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';
+
+// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/result/page.tsx
+
+// 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 QuizResultPage({
+ params: { wsId, courseId, moduleId, setId },
+}: {
+ params: {
+ 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}
+
+
router.back()}>
+ Go back
+
+
+ );
+ }
+
+ if (!detail) {
+ // Shouldn't happen, but guard anyway
+ return null;
+ }
+
+ if ('totalScore' in detail) {
+ return (
+
+ {/* Summary */}
+
+
+ {/* Detailed per-question breakdown */}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
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]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx
similarity index 52%
rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx
rename to apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx
index 3cad9a060..87223e8e9 100644
--- 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]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx
@@ -1,4 +1,5 @@
-/* eslint-disable no-undef */
+'use client';
+
import { Alert, AlertDescription } from '@tuturuuu/ui/alert';
import { Badge } from '@tuturuuu/ui/badge';
import { Button } from '@tuturuuu/ui/button';
@@ -10,6 +11,8 @@ import {
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,
@@ -37,70 +40,92 @@ interface QuizData {
attemptLimit: number | null;
attemptsSoFar: number;
timeLimitMinutes: number | null;
+ allowViewOldAttempts: boolean;
explanationMode: 0 | 1 | 2;
instruction: string | null;
- releasePointsImmediately: boolean;
+ resultsReleased: boolean;
attempts: AttemptSummary[];
}
interface BeforeTakingQuizWholeProps {
+ wsId: string;
+ courseId: string;
+ moduleId: string;
+ setId: string;
quizData: QuizData;
isPastDue: boolean;
isAvailable: boolean;
onStart: () => void;
}
-const formatDate = (dateString: string | null) => {
- if (!dateString) return null;
+function formatDate(dateString: string | null) {
+ if (!dateString) return '—';
return new Date(dateString).toLocaleString('en-US', {
- weekday: 'long',
- year: 'numeric',
- month: 'long',
+ weekday: 'short',
+ month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
-};
-const formatDuration = (secs: number) => {
- const m = Math.floor(secs / 60)
+}
+
+function formatDuration(sec: number) {
+ const m = Math.floor(sec / 60)
.toString()
.padStart(2, '0');
- const s = (secs % 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 [isStarting, setIsStarting] = useState(false);
const router = useRouter();
+ const [isStarting, setIsStarting] = useState(false);
const attemptsRemaining = quizData.attemptLimit
? quizData.attemptLimit - quizData.attemptsSoFar
: null;
- // You cannot start again if points are released
+ // MARK: modify if logic change to (cannot retake if result release)
+ // once results are released, no more attempts allowed
const canRetake =
isAvailable &&
!isPastDue &&
- !((attemptsRemaining == 0) && quizData.releasePointsImmediately);
+ (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));
+
+ const canViewOldAttemptsNoResults =
+ quizData.attemptsSoFar > 0 &&
+ quizData.allowViewOldAttempts &&
+ !quizData.resultsReleased;
+
+ const canViewOldAttemptsResults = quizData.resultsReleased;
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);
+ onStart();
+ }, 500);
};
- const viewAttemptDetailed = (att: AttemptSummary) => {
+ const viewAttempt = (att: AttemptSummary) => {
router.push(
- `/dashboard/quizzes/${quizData.setId}/attempts/${att.attemptId}`
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${quizData.setId}/result?attemptId=${att.attemptId}`
);
};
@@ -117,50 +142,77 @@ export default function BeforeTakingQuizWhole({
{/* Start Button */}
-
-
- {isStarting ? (
- <>
-
- Starting Quiz...
- >
- ) : (
- <>
-
- Start Quiz
- >
- )}
-
-
-
- {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.'}
-
-
+ {canRetake && (
+
+
+ {isStarting ? (
+ <>
+
+ Starting 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'}
+
+
+ )}
+ {canViewResult && (
+
+
{
+ router.push(
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${quizData.setId}/result?attemptId=${quizData.attempts[0]?.attemptId}`
+ );
+ }}
+ 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
+
+
+ View result of your final attempt
+
+
+ )}
+ {!isAvailable ? (
+
+
+ This quiz is not yet available. Please check back later.
+
+
+ ) : (
+ !quizData.resultsReleased &&
+ (isPastDue || attemptsRemaining == 0) && (
+
+
+ {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.'}
+
+
+ )
+ )}
- {/* 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.'}
-
-
- )}
-
+ {/* Info & Schedule */}
{/* Quiz Information */}
@@ -179,8 +231,7 @@ export default function BeforeTakingQuizWhole({
-
-
+
Time Limit
@@ -193,36 +244,33 @@ export default function BeforeTakingQuizWhole({
-
-
+
- Attempts
+ Attempts Used
- {quizData.attemptsSoFar} of{' '}
- {quizData.attemptLimit || 'unlimited'} used
+ {quizData.attemptsSoFar} /{' '}
+ {quizData.attemptLimit || '∞'}{' '}
-
-
+
Explanations
-
+
{quizData.explanationMode === 0
- ? 'None during or after'
+ ? 'None'
: quizData.explanationMode === 1
- ? 'Correct-only after release'
+ ? 'Correct only after release'
: 'All after release'}
- {/* Schedule Information */}
@@ -240,37 +288,27 @@ export default function BeforeTakingQuizWhole({
)}
-
- {formatDate(quizData.availableDate) ||
- 'Immediately available'}
-
+
{formatDate(quizData.availableDate)}
-
-
-
+
Due Date
{isPastDue && (
-
+
)}
-
- {formatDate(quizData.dueDate) || 'No due date set'}
-
+
{formatDate(quizData.dueDate)}
-
- {attemptsRemaining !== null && (
+ {attemptsRemaining != null && (
<>
-
-
- {attemptsRemaining} attempt
- {attemptsRemaining !== 1 ? 's' : ''} remaining
-
-
+
+ {attemptsRemaining} attempt
+ {attemptsRemaining !== 1 ? 's' : ''} remaining
+
>
)}
@@ -281,15 +319,15 @@ export default function BeforeTakingQuizWhole({
Instructions
-
- Please read carefully before starting
-
+ Read before you begin
{quizData.instruction ? (
-
- {quizData.instruction}
-
+ // {quizData.instruction}
+
) : (
• Make sure you have a stable internet connection
@@ -297,27 +335,24 @@ export default function BeforeTakingQuizWhole({
• 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
+
You have {quizData.timeLimitMinutes} minutes
)}
)}
- {quizData.attempts.length > 0 && (
+ {/* Past Attempts */}
+ {quizData.attemptsSoFar > 0 && (
Past Attempts
- {quizData.releasePointsImmediately
- ? 'You can review your detailed answers below.'
- : 'You can view summary until points are released.'}
+ {canViewOldAttemptsNoResults
+ ? 'Click “View Details” to view your answers'
+ : canViewOldAttemptsResults
+ ? 'Click “View Details” to view your results'
+ : 'Results pending release'}
@@ -327,15 +362,18 @@ export default function BeforeTakingQuizWhole({
className="flex items-center justify-between"
>
- Attempt #{att.attemptNumber} —{' '}
- {formatDate(att.submittedAt)} — duration{' '}
- {formatDuration(att.durationSeconds)}
+ #{att.attemptNumber} at{' '}
+ {formatDate(att.submittedAt)} (
+ {formatDuration(att.durationSeconds)})
- {quizData.releasePointsImmediately ? (
- viewAttemptDetailed(att)}>
- View Details
+ {(canViewOldAttemptsResults ||
+ canViewOldAttemptsNoResults) && (
+ viewAttempt(att)}>
+ {canViewOldAttemptsResults
+ ? 'View Results'
+ : 'View Details'}
- ) : null}
+ )}
))}
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 71%
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 ee238a7aa..b1deac99d 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,5 @@
-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]/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
similarity index 100%
rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx
rename to apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx
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]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx
similarity index 78%
rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx
rename to apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx
index 7d2d0e456..c7138b0cc 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]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx
@@ -1,13 +1,9 @@
'use client';
-import BeforeTakingQuizWhole, { AttemptSummary } from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-taking-quiz-whole';
-import QuizStatusSidebar, {
- Question,
-} from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar';
-import BeforeTakeQuizSection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section';
-import PastDueSection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section';
-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 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';
@@ -26,7 +22,7 @@ type TakeResponse = {
releasePointsImmediately: boolean;
attemptLimit: number | null;
attemptsSoFar: number;
- allowViewResults: boolean;
+ allowViewOldAttempts: boolean;
availableDate: string | null;
dueDate: string | null;
resultsReleased: boolean;
@@ -84,7 +80,6 @@ export default function TakingQuizClient({
>({});
const [submitting, setSubmitting] = useState(false);
- const [submitResult, setSubmitResult] = useState(null);
const [submitError, setSubmitError] = useState(null);
// ─── HELPERS ────────────────────────────────────────────────────────────────
@@ -114,6 +109,12 @@ export default function TakingQuizClient({
.flat(),
});
+ useEffect(() => {
+ localStorage.removeItem(ANSWERS_KEY);
+ localStorage.removeItem(STORAGE_KEY);
+ clearStartTimestamp();
+ }, [setId]);
+
// ─── FETCH METADATA ─────────────────────────────────────────────────────────
useEffect(() => {
async function fetchMeta() {
@@ -245,8 +246,15 @@ export default function TakingQuizClient({
return setSubmitting(false);
}
+ localStorage.removeItem(ANSWERS_KEY);
+ localStorage.removeItem(STORAGE_KEY);
clearStartTimestamp();
- setSubmitResult(json as SubmitResult);
+ if ('attemptId' in json) {
+ router.push(
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/result?attemptId=${json.attemptId}`
+ );
+ }
+ // setSubmitResult(json as SubmitResult);
} catch {
setSubmitError('Network error submitting.');
} finally {
@@ -259,116 +267,18 @@ export default function TakingQuizClient({
if (metaError) return {metaError}
;
if (!quizMeta) return null;
- // After submit
- if (submitResult) {
- return (
-
- );
- }
-
- // Immediate-release
- if (!hasStarted && quizMeta.allowViewResults && quizMeta.attemptsSoFar > 0) {
- return (
-
-
{quizMeta.setName}
-
- {t('ws-quizzes.results_available') ||
- 'Your previous attempt(s) have been scored.'}
-
-
- router.push(
- `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/results`
- )
- }
- >
- {t('ws-quizzes.view_results') || 'View Results'}
-
- {dueDateStr && (
-
- {t('ws-quizzes.due_on') || 'Due on'}:{' '}
- {new Date(dueDateStr).toLocaleString()}
-
- )}
-
- );
- }
-
- // No attempts left
- if (
- !hasStarted &&
- quizMeta.attemptLimit != null &&
- quizMeta.attemptsSoFar >= quizMeta.attemptLimit
- ) {
- return (
-
-
{quizMeta.setName}
- {dueDateStr && (
-
- {t('ws-quizzes.due_on') || 'Due on'}:{' '}
- {new Date(dueDateStr).toLocaleString()}
-
- )}
-
- {t('ws-quizzes.attempts') || 'Attempts'}: {quizMeta.attemptsSoFar} /{' '}
- {quizMeta.attemptLimit}
-
- {quizMeta.allowViewResults ? (
-
- router.push(
- `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/results`
- )
- }
- >
- {t('ws-quizzes.view_results') || 'View Results'}
-
- ) : (
-
- {t('ws-quizzes.no_attempts_left') || 'You have no attempts left.'}
-
- )}
-
- );
- }
-
// Take Quiz button + instruction
if (!hasStarted) {
- // return (
- //
- // );
return (
);
}
@@ -502,7 +412,7 @@ export default function TakingQuizClient({
{
+ router.push(
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/take`
+ );
+ };
+
if (quizSets) {
return (
<>
@@ -95,11 +103,18 @@ export default function ClientQuizzes({
key={set.setId}
className="col-span-full flex w-full flex-col gap-4"
>
-
-
- {set.setName}
-
+
+
+ {set.setName}
+
+
navigateQuizTake(set.setId)}
+ >
+ Take Quiz
+
{/*
@@ -81,7 +91,7 @@ export default async function ModuleQuizzesPage({ params }: Props) {
)}
*/}
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
deleted file mode 100644
index 11ac93873..000000000
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/page.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-'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
-
-
- router.push(
- `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/attempts`
- )
- }
- >
- {t('ws-quizzes.back')||'Back to attempts'}
-
-
- {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
deleted file mode 100644
index e5a9f9452..000000000
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/attempts/page.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-'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]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx
deleted file mode 100644
index 11df9bb33..000000000
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function Page() {
- return Hello
;
-}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
deleted file mode 100644
index d4e5c12d2..000000000
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-// File: app/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
-'use client';
-
-import { Button } from '@tuturuuu/ui/button';
-import Link from 'next/link';
-import React from 'react';
-
-// File: app/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx
-
-interface BeforeTakeQuizSectionProps {
- t: (key: string, options?: Record) => string;
- quizMeta: {
- setName: string;
- attemptsSoFar: number;
- attemptLimit: number | null;
- timeLimitMinutes: number | null;
- };
- dueDateStr?: string | null;
- instruction?: string | null;
- onClickStart: () => void;
- /**
- * If provided, render a link to view past attempts.
- * e.g. `/dashboard/[wsId]/…/results`
- */
- viewResultsUrl?: string;
-}
-
-export default function BeforeTakeQuizSection({
- t,
- quizMeta,
- dueDateStr,
- instruction,
- onClickStart,
- viewResultsUrl,
-}: BeforeTakeQuizSectionProps) {
- return (
-
- {/* Due date */}
- {dueDateStr && (
-
- {t('ws-quizzes.due_on') || 'Due on'}:{' '}
- {new Date(dueDateStr).toLocaleString()}
-
- )}
-
- {/* Instruction (Markdown or simple text) */}
- {instruction && (
-
- {/* If your instruction is raw HTML, use dangerouslySetInnerHTML */}
-
{instruction}
-
- )}
-
- {/* Title & stats */}
-
{quizMeta.setName}
-
- {t('ws-quizzes.attempts') || 'Attempts'}: {quizMeta.attemptsSoFar} /{' '}
- {quizMeta.attemptLimit ?? (t('ws-quizzes.unlimited') || 'Unlimited')}
-
-
- {quizMeta.timeLimitMinutes != null
- ? `${t('ws-quizzes.time_limit') || 'Time Limit'}: ${
- quizMeta.timeLimitMinutes
- } ${t('ws-quizzes.minutes') || 'minutes'}`
- : t('ws-quizzes.no_time_limit') || 'No time limit'}
-
-
- {/* Primary action */}
-
-
- {t('ws-quizzes.take_quiz') || 'Take Quiz'}
-
-
-
- {/* Optional: view past attempts */}
- {viewResultsUrl && (
-
- )}
-
- );
-}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section.tsx
deleted file mode 100644
index 1a5e2e4c5..000000000
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { Button } from '@tuturuuu/ui/button';
-import React from 'react';
-
-export default function PastDueSection({
- t,
- quizMeta,
- dueDateStr,
- wsId,
- courseId,
- moduleId,
- setId,
- router,
-}: {
- t: (key: string) => string;
- quizMeta: {
- setName: string;
- allowViewResults: boolean;
- };
- dueDateStr?: string | null;
- wsId: string;
- courseId: string;
- moduleId: string;
- setId: string;
- router: {
- push: (url: string) => void;
- };
-}) {
- return (
-
-
{quizMeta.setName}
-
- {t('ws-quizzes.quiz_past_due') ||
- `This quiz was due on ${new Date(dueDateStr!).toLocaleString()}.`}
-
- {quizMeta.allowViewResults && (
-
- router.push(
- `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/results`
- )
- }
- >
- {t('ws-quizzes.view_results') || 'View Results'}
-
- )}
-
- );
-}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/show-result-summary-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/show-result-summary-section.tsx
deleted file mode 100644
index 51113323e..000000000
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/show-result-summary-section.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Button } from '@tuturuuu/ui/button';
-
-export default function ShowResultSummarySection({
- t,
- submitResult,
- quizMeta,
- wsId,
- courseId,
- moduleId,
- router,
-}: {
- t: (key: string) => string;
- submitResult: {
- attemptNumber: number;
- totalScore: number;
- maxPossibleScore: number;
- };
- quizMeta: {
- attemptLimit: number | null;
- setName: string;
- attemptsSoFar: number;
- timeLimitMinutes: number | null;
- };
- wsId: string;
- courseId: string;
- moduleId: string;
- router: {
- push: (url: string) => void;
- };
-}) {
- return (
-
-
- {t('ws-quizzes.results') || 'Results'}
-
-
- {t('ws-quizzes.attempt')} #{submitResult.attemptNumber}{' '}
- {t('ws-quizzes.of')}{' '}
- {quizMeta.attemptLimit ?? t('ws-quizzes.unlimited')}
-
-
- {t('ws-quizzes.score')}: {submitResult.totalScore} /{' '}
- {submitResult.maxPossibleScore}
-
-
- router.push(
- `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes`
- )
- }
- >
- {t('ws-quizzes.done') || 'Done'}
-
-
- );
-}
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 a937eb54c..ce9099b3e 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
@@ -1,15 +1,15 @@
-// File: app/api/quiz-sets/[setId]/attempts/[attemptId]/route.ts
+// app/api/quiz-sets/[setId]/attempts/[attemptId]/route.ts
import { createClient } from '@tuturuuu/supabase/next/server';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
- _request: NextRequest,
+ _req: NextRequest,
{ params }: { params: { setId: string; attemptId: string } }
) {
const { setId, attemptId } = params;
const sb = await createClient();
- // 1) Auth
+ // 1) Authenticate
const {
data: { user },
error: uErr,
@@ -19,28 +19,25 @@ export async function GET(
}
const userId = user.id;
- // 2) Check allow_view_results
+ // 2) Load our two flags
const { data: setRow, error: sErr } = await sb
.from('workspace_quiz_sets')
- .select('release_points_immediately, results_released, explanation_mode')
+ .select('allow_view_old_attempts, results_released, explanation_mode')
.eq('id', setId)
.maybeSingle();
if (sErr || !setRow) {
return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 });
}
- const { release_points_immediately, results_released, explanation_mode } =
- setRow;
- if (!release_points_immediately && !results_released) {
- return NextResponse.json(
- { error: 'Results are not yet released' },
- { status: 403 }
- );
- }
+ const {
+ allow_view_old_attempts: allowViewOldAttempts,
+ results_released: allowViewResult,
+ explanation_mode,
+ } = setRow;
- // 3) Load attempt (ensure user & set match)
+ // 3) Load attempt header
const { data: attRow, error: attErr } = await sb
.from('workspace_quiz_attempts')
- .select('id,attempt_number,total_score,started_at,completed_at')
+ .select('id, attempt_number, started_at, completed_at')
.eq('id', attemptId)
.eq('user_id', userId)
.eq('set_id', setId)
@@ -48,106 +45,158 @@ export async function GET(
if (attErr || !attRow) {
return NextResponse.json({ error: 'Attempt not found' }, { status: 404 });
}
-
- // Compute duration
- const started = new Date(attRow.started_at).getTime();
- const completed = attRow.completed_at
+ const startedMs = new Date(attRow.started_at).getTime();
+ const completedMs = attRow.completed_at
? new Date(attRow.completed_at).getTime()
: Date.now();
- const durationSeconds = Math.round((completed - started) / 1000);
+ const durationSeconds = Math.round((completedMs - startedMs) / 1000);
- // 4) Fetch all questions + options + weight
- const { data: qRaw, error: qErr } = await sb
- .from('quiz_set_quizzes')
- .select(
- `
- quiz_id,
- workspace_quizzes ( question, score ),
- quiz_options ( id, value, is_correct, explanation )
- `
- )
- .eq('set_id', setId);
- if (qErr) {
- return NextResponse.json(
- { error: 'Error fetching questions' },
- { status: 500 }
- );
+ // 4) Branch #1: neither summary‐only nor full detail allowed ⇒ return only summary
+ if (!allowViewOldAttempts && !allowViewResult) {
+ return NextResponse.json({
+ attemptId: attRow.id,
+ attemptNumber: attRow.attempt_number,
+ submittedAt: attRow.completed_at,
+ durationSeconds,
+ });
}
- type QRow = {
- quiz_id: string;
- workspace_quizzes: { question: string; score: number };
- quiz_options: Array<{
- id: string;
- value: string;
- is_correct: boolean;
- explanation: string | null;
- }>;
- };
- const questionsInfo = (qRaw as QRow[]).map((r) => ({
- quizId: r.quiz_id,
- question: r.workspace_quizzes.question,
- scoreWeight: r.workspace_quizzes.score,
- options: r.quiz_options.map((o) => ({
- id: o.id,
- value: o.value,
- isCorrect: o.is_correct,
- explanation: o.explanation,
- })),
- }));
- const maxPossibleScore = questionsInfo.reduce(
- (sum, q) => sum + q.scoreWeight,
- 0
- );
-
- // 5) Load this attempt’s answers
const { data: ansRows, error: ansErr } = await sb
.from('workspace_quiz_attempt_answers')
.select('quiz_id, selected_option_id, is_correct, score_awarded')
.eq('attempt_id', attemptId);
- if (ansErr) {
- return NextResponse.json(
- { error: 'Error fetching answers' },
- { status: 500 }
- );
+ if (ansErr || !ansRows) {
+ return NextResponse.json({ error: 'Error fetching answers' }, { status: 500 });
}
const ansMap = new Map(ansRows.map((a) => [a.quiz_id, a]));
- // 6) Build detailed questions
- const detailed = questionsInfo.map((q) => {
- const a = ansMap.get(q.quizId);
+ // 6) Branch #2: summary‐only allowed (old attempts) ⇒ show question+user‐answer, but no correctness or points
+ if (allowViewOldAttempts && !allowViewResult) {
+ // re-fetch questions including options
+ const { data: sumQRaw, error: sumQErr } = await sb
+ .from('quiz_set_quizzes')
+ .select(`
+ quiz_id,
+ workspace_quizzes (
+ question,
+ quiz_options (
+ id,
+ value
+ )
+ )
+ `)
+ .eq('set_id', setId);
+ if (sumQErr || !sumQRaw) {
+ return NextResponse.json({ error: 'Error fetching summary questions' }, { status: 500 });
+ }
+
+ type SumRow = {
+ quiz_id: string;
+ workspace_quizzes: {
+ question: string;
+ quiz_options: Array<{
+ id: string;
+ value: string;
+ }>;
+ };
+ };
+
+ const questions = (sumQRaw as SumRow[]).map((r) => {
+ const a = ansMap.get(r.quiz_id);
+ return {
+ quizId: r.quiz_id,
+ question: r.workspace_quizzes.question,
+ selectedOptionId: a?.selected_option_id ?? null,
+ options: r.workspace_quizzes.quiz_options.map((opt) => ({
+ id: opt.id,
+ value: opt.value,
+ })),
+ };
+ });
+
+ return NextResponse.json({
+ attemptId: attRow.id,
+ attemptNumber: attRow.attempt_number,
+ submittedAt: attRow.completed_at,
+ durationSeconds,
+ questions,
+ });
+ }
+
+ // 7) Branch #3: full detail allowed (results_released) ⇒ include correctness, score, explanations
+ // 7a) we need each question’s options, weights, explanations
+ const { data: fullQRaw, error: fullQErr } = await sb
+ .from('quiz_set_quizzes')
+ .select(`
+ quiz_id,
+ workspace_quizzes (
+ question,
+ score,
+ quiz_options (
+ id,
+ value,
+ is_correct,
+ explanation
+ )
+ )
+ `)
+ .eq('set_id', setId);
+ if (fullQErr || !fullQRaw) {
+ return NextResponse.json({ error: 'Error fetching full questions' }, { status: 500 });
+ }
+ type FullRow = {
+ quiz_id: string;
+ workspace_quizzes: {
+ question: string;
+ score: number;
+ quiz_options: Array<{
+ id: string;
+ value: string;
+ is_correct: boolean;
+ explanation: string | null;
+ }>;
+ };
+ };
+ const detailedQuestions = (fullQRaw as FullRow[]).map((r) => {
+ const a = ansMap.get(r.quiz_id);
return {
- quizId: q.quizId,
- question: q.question,
- scoreWeight: q.scoreWeight,
- selectedOptionId: a?.selected_option_id || null,
+ quizId: r.quiz_id,
+ question: r.workspace_quizzes.question,
+ scoreWeight: r.workspace_quizzes.score,
+ selectedOptionId: a?.selected_option_id ?? null,
isCorrect: a?.is_correct ?? false,
scoreAwarded: a?.score_awarded ?? 0,
- options: q.options.map((opt) => {
- // Only show explanation per explanation_mode:
+ options: r.workspace_quizzes.quiz_options.map((opt) => {
let explanation: string | null = null;
- if (explanation_mode === 2) explanation = opt.explanation;
- else if (explanation_mode === 1 && opt.isCorrect)
+ if (explanation_mode === 2) {
+ explanation = opt.explanation;
+ } else if (explanation_mode === 1 && opt.is_correct) {
explanation = opt.explanation;
+ }
return {
id: opt.id,
value: opt.value,
- isCorrect: opt.isCorrect,
+ isCorrect: opt.is_correct,
explanation,
};
}),
};
});
+ const maxPossibleScore = detailedQuestions.reduce(
+ (sum, q) => sum + q.scoreWeight,
+ 0
+ );
+
return NextResponse.json({
attemptId: attRow.id,
attemptNumber: attRow.attempt_number,
- totalScore: attRow.total_score ?? 0,
+ totalScore: ansRows.reduce((sum, a) => sum + (a.score_awarded ?? 0), 0),
maxPossibleScore,
startedAt: attRow.started_at,
completedAt: attRow.completed_at,
durationSeconds,
explanationMode: explanation_mode,
- questions: detailed,
+ questions: detailedQuestions,
});
}
diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/route.ts
deleted file mode 100644
index 6e67fda7d..000000000
--- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/route.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-// File: app/api/quiz-sets/[setId]/attempts/route.ts
-import { createClient } from '@tuturuuu/supabase/next/server';
-import { NextRequest, NextResponse } from 'next/server';
-
-interface QuizSetRow {
- release_points_immediately: boolean;
-}
-
-export async function GET(
- _request: NextRequest,
- { params }: { params: { setId: string } }
-) {
- const { setId } = params;
- const sb = await createClient();
-
- // 1) Auth
- const {
- data: { user },
- error: uErr,
- } = await sb.auth.getUser();
- if (uErr || !user) {
- return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
- }
- const userId = user.id;
-
- // 2) Check allow_view_results flag
- const { data: setRow, error: sErr } = await sb
- .from('workspace_quiz_sets')
- .select('release_points_immediately')
- .eq('id', setId)
- .maybeSingle();
-
- if (sErr || !setRow) {
- return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 });
- }
-
- const { release_points_immediately } = setRow;
- if (!release_points_immediately ) {
- return NextResponse.json(
- { error: 'Results are not yet released' },
- { status: 403 }
- );
- }
-
- // 3) Fetch all attempts for this user & set
- const { data: attempts, error: aErr } = await sb
- .from('workspace_quiz_attempts')
- .select(
- `
- id,
- attempt_number,
- total_score,
- started_at,
- completed_at
- `
- )
- .eq('user_id', userId)
- .eq('set_id', setId)
- .order('attempt_number', { ascending: false });
- if (aErr) {
- return NextResponse.json(
- { error: 'Error fetching attempts' },
- { status: 500 }
- );
- }
- if (!attempts.length) {
- return NextResponse.json({ attempts: [] });
- }
-
- // 4) For each attempt, compute durationSeconds
- const summaries = attempts.map((att) => {
- const started = new Date(att.started_at).getTime();
- const completed = att.completed_at
- ? new Date(att.completed_at).getTime()
- : Date.now();
- const durationSeconds = Math.round((completed - started) / 1000);
- return {
- attemptId: att.id,
- attemptNumber: att.attempt_number,
- totalScore: att.total_score ?? 0,
- startedAt: att.started_at,
- completedAt: att.completed_at,
- durationSeconds,
- };
- });
-
- return NextResponse.json({ attempts: summaries });
-}
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 aa2545093..63e78fd9c 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
@@ -47,7 +47,7 @@ export async function GET(
time_limit_minutes,
available_date,
due_date,
- release_points_immediately,
+ allow_view_old_attempts,
results_released,
explanation_mode,
instruction
@@ -64,7 +64,7 @@ export async function GET(
time_limit_minutes: timeLimitMinutes,
available_date: availableDate,
due_date: dueDate,
- release_points_immediately: releasePointsImmediately,
+ allow_view_old_attempts: allowViewOldAttempts,
results_released: resultsReleased,
explanation_mode: explanationMode,
instruction,
@@ -113,9 +113,6 @@ export async function GET(
);
}
- // 6) Build `allowViewResults` from immediate‐release flag
- const allowViewResults = Boolean(releasePointsImmediately) || resultsReleased;
-
// 7) Fetch all previous attempts summary
const { data: rawAttempts, error: attErr } = await sb
.from('workspace_quiz_attempts')
@@ -144,24 +141,24 @@ export async function GET(
});
// 8) Early‐exit if they’ve already done one attempt _and_ results are viewable
- if (allowViewResults && attemptsSoFar > 0) {
- return NextResponse.json({
- setId,
- setName,
- timeLimitMinutes,
- releasePointsImmediately,
- resultsReleased,
- attemptLimit,
- attemptsSoFar,
- allowViewResults,
- availableDate,
- dueDate,
- attempts,
- explanationMode,
- instruction,
- questions: [], // no need to send questions
- });
- }
+ // if (allowViewResults && attemptsSoFar > 0) {
+ // return NextResponse.json({
+ // setId,
+ // setName,
+ // timeLimitMinutes,
+ // releasePointsImmediately,
+ // resultsReleased,
+ // attemptLimit,
+ // attemptsSoFar,
+ // allowViewResults,
+ // availableDate,
+ // dueDate,
+ // attempts,
+ // explanationMode,
+ // instruction,
+ // questions: [], // no need to send questions
+ // });
+ // }
// 9) Otherwise fetch questions+options as before
const { data: rawQ, error: qErr } = await sb
@@ -206,11 +203,11 @@ export async function GET(
setId,
setName,
timeLimitMinutes,
- releasePointsImmediately,
+ allowViewOldAttempts,
attemptLimit,
attemptsSoFar,
resultsReleased,
- allowViewResults,
+ // allowViewResults,
availableDate,
dueDate,
attempts,
diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts
index f8c595c25..6eb59c7ce 100644
--- a/packages/types/src/supabase.ts
+++ b/packages/types/src/supabase.ts
@@ -5915,6 +5915,7 @@ export type Database = {
};
workspace_quiz_sets: {
Row: {
+ allow_view_old_attempts: boolean;
allow_view_results: boolean;
attempt_limit: number | null;
available_date: string;
@@ -5924,12 +5925,12 @@ export type Database = {
id: string;
instruction: Json | null;
name: string;
- release_points_immediately: boolean;
results_released: boolean;
time_limit_minutes: number | null;
ws_id: string | null;
};
Insert: {
+ allow_view_old_attempts?: boolean;
allow_view_results?: boolean;
attempt_limit?: number | null;
available_date?: string;
@@ -5939,12 +5940,12 @@ export type Database = {
id?: string;
instruction?: Json | null;
name?: string;
- release_points_immediately?: boolean;
results_released?: boolean;
time_limit_minutes?: number | null;
ws_id?: string | null;
};
Update: {
+ allow_view_old_attempts?: boolean;
allow_view_results?: boolean;
attempt_limit?: number | null;
available_date?: string;
@@ -5954,7 +5955,6 @@ export type Database = {
id?: string;
instruction?: Json | null;
name?: string;
- release_points_immediately?: boolean;
results_released?: boolean;
time_limit_minutes?: number | null;
ws_id?: string | null;
From 1d0ce000ff99470ffa3e3b92dae9a9faa047c854 Mon Sep 17 00:00:00 2001
From: Nhung
Date: Mon, 16 Jun 2025 19:27:32 +0700
Subject: [PATCH 04/17] feat (Taking Quiz UI): add translation
---
apps/upskii/messages/en.json | 176 ++++++++++++++---
apps/upskii/messages/vi.json | 183 ++++++++++++++----
.../show-attempt-detail-section.tsx | 24 ++-
.../show-result-summary-section.tsx | 64 +++---
.../attempt-summary-view.tsx | 66 ++++---
.../quiz-sets/[setId]/result/page.tsx | 34 +++-
.../[setId]/take/before-taking-quiz-whole.tsx | 102 +++++-----
.../[setId]/take/quiz-status-sidebar.tsx | 47 ++---
.../[setId]/take/taking-quiz-client.tsx | 54 +++---
.../[setId]/take/time-elapsed-status.tsx | 12 +-
.../[wsId]/quiz-sets/[setId]/take/route.ts | 1 -
11 files changed, 507 insertions(+), 256 deletions(-)
diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json
index 083682bd5..a98319275 100644
--- a/apps/upskii/messages/en.json
+++ b/apps/upskii/messages/en.json
@@ -3844,40 +3844,154 @@
"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",
+ "answered_icon": "✓",
+ "unanswered_icon": "○",
+ "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": {
+ "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-attempt": "View result of your final attempt",
+ "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": "#{number} 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 6871fff7f..c0b4f282d 100644
--- a/apps/upskii/messages/vi.json
+++ b/apps/upskii/messages/vi.json
@@ -3845,40 +3845,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",
+ "answered_icon": "✓",
+ "unanswered_icon": "○",
+ "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": {
+ "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-attempt": "Xem kết quả lần làm bài cuối cùng",
+ "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": "#{number} 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]/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 03e585e47..a9e177df2 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
@@ -1,10 +1,9 @@
-// File: app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/show-attempt-detail-section.tsx
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
import { CheckCircle, Circle, XCircle } from 'lucide-react';
+import { useTranslations } from 'next-intl';
-// File: app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/show-attempt-detail-section.tsx
export interface AttemptDetailDTO {
attemptId: string;
@@ -33,14 +32,13 @@ export interface AttemptDetailDTO {
}
export interface ShowAttemptDetailProps {
- t: (key: string) => string;
detail: AttemptDetailDTO;
}
export default function ShowAttemptDetailSection({
- t,
detail,
}: ShowAttemptDetailProps) {
+ const t = useTranslations('ws-quizzes');
const fmtDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleString() : '—';
const fmtDuration = (secs: number) => {
@@ -56,17 +54,17 @@ export default function ShowAttemptDetailSection({
{/* Metadata */}
- {t('ws-quizzes.started_at') || 'Started at'}:{' '}
+ {t('results.started_at') || 'Started at'}:{' '}
{fmtDate(detail.startedAt)}
{detail.completedAt && (
- {t('ws-quizzes.completed_at') || 'Completed at'}:{' '}
+ {t('results.completed_at') || 'Completed at'}:{' '}
{fmtDate(detail.completedAt)}
)}
- {t('ws-quizzes.duration') || 'Duration'}:{' '}
+ {t('results.duration') || 'Duration'}:{' '}
{fmtDuration(detail.durationSeconds)}
@@ -87,7 +85,7 @@ export default function ShowAttemptDetailSection({
{idx + 1}. {q.question}{' '}
- ({t('ws-quizzes.points') || 'Points'}: {q.scoreWeight})
+ ({t('results.points') || 'Points'}: {q.scoreWeight})
@@ -100,9 +98,9 @@ export default function ShowAttemptDetailSection({
className="flex items-center space-x-2 rounded-md border border-dynamic-purple/40 p-2 transition-colors hover:bg-secondary/10 md:space-x-4 md:p-4"
aria-label={`${opt.value} ${
opt.isCorrect
- ? t('ws-quizzes.correct_option') || '(Correct)'
+ ? t('results.correct_option') || '(Correct)'
: chosen
- ? t('ws-quizzes.your_answer') || '(Your answer)'
+ ? t('results.your_answer') || '(Your answer)'
: ''
}`}
>
@@ -118,12 +116,12 @@ export default function ShowAttemptDetailSection({
{opt.value}{' '}
{opt.isCorrect && (
- {t('ws-quizzes.correct') || '(Correct)'}
+ {t('results.correct') || '(Correct)'}
)}
{chosen && !opt.isCorrect && (
- {t('ws-quizzes.your_answer') || '(Your answer)'}
+ {t('results.your_answer') || '(Your answer)'}
)}
@@ -138,7 +136,7 @@ export default function ShowAttemptDetailSection({
})}
- {t('ws-quizzes.score_awarded') || 'Score Awarded'}:{' '}
+ {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
index f9fad5826..dbf743348 100644
--- 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
@@ -5,16 +5,11 @@ 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 { CheckCircle, RotateCcw, Target, Trophy } from 'lucide-react';
+import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
export interface ShowResultSummarySectionProps {
- t: (key: string) => string;
submitResult: {
attemptNumber: number;
totalScore: number;
@@ -25,6 +20,7 @@ export interface ShowResultSummarySectionProps {
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;
@@ -33,7 +29,6 @@ export interface ShowResultSummarySectionProps {
}
export default function ShowResultSummarySection({
- t,
submitResult,
quizMeta,
wsId,
@@ -41,6 +36,7 @@ export default function ShowResultSummarySection({
moduleId,
setId,
}: ShowResultSummarySectionProps) {
+ const t = useTranslations('ws-quizzes');
const router = useRouter();
const scorePercentage = Math.round(
@@ -75,7 +71,7 @@ export default function ShowResultSummarySection({
- {t('ws-quizzes.quiz-completed') || 'Quiz Completed!'}
+ {t('results.quiz-completed') || 'Quiz Completed!'}
{quizMeta.setName}
@@ -85,7 +81,7 @@ export default function ShowResultSummarySection({
- {t('ws-quizzes.your-score') || 'Your Score'}
+ {t('results.your-score') || 'Your Score'}
@@ -123,12 +119,12 @@ export default function ShowResultSummarySection({
- {t('ws-quizzes.attempt') || 'Attempt'}
+ {t('results.attempt') || 'Attempt'}
- #{submitResult.attemptNumber} {t('ws-quizzes.of') || 'of'}{' '}
+ #{submitResult.attemptNumber} {t('results.of') || 'of'}{' '}
{quizMeta.attemptLimit ??
- (t('ws-quizzes.unlimited') || 'Unlimited')}
+ (t('results.unlimited') || 'Unlimited')}
@@ -140,10 +136,10 @@ export default function ShowResultSummarySection({
- {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({
router.push(`/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/take`)}
+ onClick={() =>
+ router.push(
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/take`
+ )
+ }
className="flex items-center gap-2"
>
- {t('ws-quizzes.back-take-quiz') || 'Back to Quiz Page'}
+ {t('results.back-take-quiz') || 'Back to Quiz Page'}
{/* 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')}
- Back to Quiz
+ {t('summary.back_to_quiz')}
@@ -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) && (
viewAttempt(att)}>
{canViewOldAttemptsResults
- ? 'View Results'
- : 'View Details'}
+ ? t('past-attempts.view-results-button')
+ : t('past-attempts.view-details-button')}
)}
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 (
{sidebarVisible && quizMeta && (
)}
@@ -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
+ }
}}
/>
@@ -378,7 +380,9 @@ export default function TakingQuizClient({
ANSWERS_KEY,
JSON.stringify(next)
);
- } catch {}
+ } catch {
+ // Ignore localStorage errors
+ }
}}
className="space-y-2"
>
@@ -419,8 +423,8 @@ export default function TakingQuizClient({
}`}
>
{submitting
- ? t('ws-quizzes.submitting') || 'Submitting...'
- : t('ws-quizzes.submit') || 'Submit'}
+ ? t('submitting') || 'Submitting...'
+ : t('submit') || 'Submit'}
@@ -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'}
Date: Mon, 16 Jun 2025 19:36:16 +0700
Subject: [PATCH 05/17] ops (Taking Quiz UI): fix deploy
---
.../quiz-sets/[setId]/result/page.tsx | 296 +-----------------
.../[setId]/result/quiz-result-client.tsx | 294 +++++++++++++++++
.../[wsId]/quiz-sets/[setId]/submit/route.ts | 246 ++++++++-------
3 files changed, 434 insertions(+), 402 deletions(-)
create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client.tsx
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 e8a7a5f2f..34460eadd 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,293 +1,23 @@
-'use client';
+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';
-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 QuizResultPage({
- params: { wsId, courseId, moduleId, setId },
+export default async function QuizResultPage({
+ params,
}: {
- params: {
+ params: Promise<{
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}
-
-
router.back()}>
- Go back
-
-
- );
- }
-
- 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}
+
+
router.back()}>
+ Go back
+
+
+ );
+ }
+
+ 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
+ ),
+
+ // quizset 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({
setSidebarVisible(!sidebarVisible)}>
-
+
{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 (
+
+ );
if (!quizMeta) return null;
// Take Quiz button + instruction
@@ -296,10 +304,7 @@ export default function TakingQuizClient({
setSidebarVisible(!sidebarVisible)}>
-
+
{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 (
);
}
+
+interface InstructionEditorProps {
+ quizSetId: string;
+ instruction: JSONContent;
+ setInstruction: (instruction: JSONContent) => void;
+}
+
+function InstructionEditor({
+ quizSetId,
+ instruction,
+ setInstruction,
+}: InstructionEditorProps) {
+ const t = useTranslations();
+
+ const [saving, setSaving] = useState(false);
+
+ const INSTRUCTION_EDITOR_KEY = `instrction-quiz-set-${quizSetId}`;
+
+ const handleSave = async () => {
+ setSaving(true);
+ localStorage.setItem(INSTRUCTION_EDITOR_KEY, JSON.stringify(instruction));
+ setSaving(false);
+ };
+
+ return (
+
+
+ {t('quiz.edit_instruction') || 'Edit Quiz Instruction'}
+
+
+
+
+
+
+ {saving
+ ? t('common.saving') || 'Saving...'
+ : t('quiz.save_instruction') || 'Save Instruction'}
+
+
+
+ );
+}
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 55ad51161..dd7e35367 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
@@ -1,44 +1,54 @@
import { createClient } from '@tuturuuu/supabase/next/server';
import { NextRequest, NextResponse } from 'next/server';
-type RawRow = {
- quiz_id: string;
- workspace_quizzes: {
- question: string;
- score: number;
- instruction: any; // JSONB
- quiz_options: { id: string; value: string; is_correct: boolean }[];
- };
-};
-
-type AttemptSummary = {
+export type AttemptSummary = {
attemptId: string;
attemptNumber: number;
- submittedAt: string; // ISO timestamp
+ submittedAt: string; // ISO
durationSeconds: number;
};
-interface Params {
- params: Promise<{
- setId: string;
+export interface 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[];
+ questions: Array<{
+ quizId: string;
+ question: string;
+ score: number;
+ multiple: boolean;
+ options: { id: string; value: string }[];
}>;
+ // NEW: explicit flags for UI
+ isAvailable: boolean;
+ isPastDue: boolean;
+ hasReachedMax: boolean;
}
-export async function GET(_req: NextRequest, { params }: Params) {
- const { setId } = await params;
+export async function GET(_req: NextRequest, { params }: { params: { setId: string } }) {
+ const { setId } = params;
const sb = await createClient();
// 1) Auth
const {
data: { user },
- error: uErr,
+ error: userErr,
} = await sb.auth.getUser();
- if (uErr || !user) {
+ if (userErr || !user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userId = user.id;
- // 2) Fetch quiz-set metadata (+ new cols)
+ // 2) Fetch quiz set metadata
const { data: setRow, error: sErr } = await sb
.from('workspace_quiz_sets')
.select(
@@ -60,6 +70,7 @@ export async function GET(_req: NextRequest, { params }: Params) {
if (sErr || !setRow) {
return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 });
}
+
const {
name: setName,
attempt_limit: attemptLimit,
@@ -72,50 +83,18 @@ export async function GET(_req: NextRequest, { params }: Params) {
instruction,
} = setRow;
- const now = new Date();
-
- // 3) Availability & due checks
- if (availableDate && new Date(availableDate) > now) {
- return NextResponse.json(
- { error: 'Quiz not yet available', availableDate },
- { status: 403 }
- );
- }
- if (dueDate && new Date(dueDate) < now) {
- return NextResponse.json(
- { error: 'Quiz past due', dueDate },
- { status: 403 }
- );
- }
-
- // 4) Count how many attempts user already made
- const { data: prev, error: aErr } = await sb
+ // 3) Count attempts so far
+ const { data: prevAttempts, error: aErr } = await sb
.from('workspace_quiz_attempts')
- .select('attempt_number', { count: 'exact', head: false })
+ .select('attempt_number', { head: false })
.eq('user_id', userId)
.eq('set_id', setId);
if (aErr) {
- return NextResponse.json(
- { error: 'Error counting attempts' },
- { status: 500 }
- );
- }
- const attemptsSoFar = prev?.length ?? 0;
-
- // 5) Enforce attempt limit
- if (attemptLimit !== null && attemptsSoFar >= attemptLimit) {
- return NextResponse.json(
- {
- error: 'Max attempts reached',
- attemptsSoFar,
- attemptLimit,
- allowViewResults: false,
- },
- { status: 403 }
- );
+ return NextResponse.json({ error: 'Error counting attempts' }, { status: 500 });
}
+ const attemptsSoFar = prevAttempts?.length ?? 0;
- // 7) Fetch all previous attempts summary
+ // 4) Build summaries of past attempts
const { data: rawAttempts, error: attErr } = await sb
.from('workspace_quiz_attempts')
.select('id,attempt_number,started_at,completed_at')
@@ -123,46 +102,22 @@ export async function GET(_req: NextRequest, { params }: Params) {
.eq('set_id', setId)
.order('attempt_number', { ascending: false });
if (attErr) {
- return NextResponse.json(
- { error: 'Error fetching attempts' },
- { status: 500 }
- );
+ return NextResponse.json({ error: 'Error fetching attempts' }, { status: 500 });
}
const attempts: AttemptSummary[] = (rawAttempts || []).map((row) => {
const started = new Date(row.started_at).getTime();
const completed = row.completed_at
? new Date(row.completed_at).getTime()
: Date.now();
- const durationSeconds = Math.floor((completed - started) / 1000);
return {
attemptId: row.id,
attemptNumber: row.attempt_number,
submittedAt: row.completed_at ?? row.started_at,
- durationSeconds,
+ durationSeconds: Math.floor((completed - started) / 1000),
};
});
- // 8) Early‐exit if they’ve already done one attempt _and_ results are viewable
- // if (allowViewResults && attemptsSoFar > 0) {
- // return NextResponse.json({
- // setId,
- // setName,
- // timeLimitMinutes,
- // releasePointsImmediately,
- // resultsReleased,
- // attemptLimit,
- // attemptsSoFar,
- // allowViewResults,
- // availableDate,
- // dueDate,
- // attempts,
- // explanationMode,
- // instruction,
- // questions: [], // no need to send questions
- // });
- // }
-
- // 9) Otherwise fetch questions+options as before
+ // 5) Fetch questions & options
const { data: rawQ, error: qErr } = await sb
.from('quiz_set_quizzes')
.select(
@@ -182,39 +137,46 @@ export async function GET(_req: NextRequest, { params }: Params) {
)
.eq('set_id', setId);
if (qErr) {
- return NextResponse.json(
- { error: 'Error fetching questions' },
- { status: 500 }
- );
+ return NextResponse.json({ error: 'Error fetching questions' }, { status: 500 });
}
- const questions = (rawQ as RawRow[]).map((r) => ({
+
+ const questions = (rawQ || []).map((r: any) => ({
quizId: r.quiz_id,
question: r.workspace_quizzes.question,
score: r.workspace_quizzes.score,
multiple:
- r.workspace_quizzes.quiz_options.filter((o) => o.is_correct).length > 1,
- options: r.workspace_quizzes.quiz_options.map((o) => ({
+ r.workspace_quizzes.quiz_options.filter((o: any) => o.is_correct).length > 1,
+ options: r.workspace_quizzes.quiz_options.map((o: any) => ({
id: o.id,
value: o.value,
})),
- instruction: r.workspace_quizzes.instruction ?? instruction,
}));
- // 10) Return the full TakeResponse
- return NextResponse.json({
+ // 6) Derive UI flags
+ const now = new Date();
+ const isAvailable = !availableDate || new Date(availableDate) <= now;
+ const isPastDue = !!dueDate && new Date(dueDate) < now;
+ const hasReachedMax = attemptLimit !== null && attemptsSoFar >= attemptLimit;
+
+ // 7) Return unified 200 response
+ const payload: TakeResponse = {
setId,
setName,
timeLimitMinutes,
- allowViewOldAttempts,
attemptLimit,
attemptsSoFar,
- resultsReleased,
- // allowViewResults,
+ allowViewOldAttempts,
availableDate,
dueDate,
- attempts,
- explanationMode,
+ resultsReleased,
+ explanationMode: explanationMode as 0 | 1 | 2,
instruction,
+ attempts,
questions,
- });
+ isAvailable,
+ isPastDue,
+ hasReachedMax,
+ };
+
+ return NextResponse.json(payload);
}
From d218347f5a86b46e0103f6a18add50b0d5fa7e56 Mon Sep 17 00:00:00 2001
From: Nhung
Date: Wed, 18 Jun 2025 17:14:15 +0700
Subject: [PATCH 10/17] style(Taking Quiz UI - Answering Question): styling for
input and question status
---
apps/upskii/messages/en.json | 3 +--
apps/upskii/messages/vi.json | 3 +--
.../[setId]/take/quiz-status-sidebar.tsx | 25 ++++++++++++++-----
.../[setId]/take/taking-quiz-client.tsx | 24 +++++++++++++-----
4 files changed, 39 insertions(+), 16 deletions(-)
diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json
index e24dcbdaf..257615b40 100644
--- a/apps/upskii/messages/en.json
+++ b/apps/upskii/messages/en.json
@@ -3857,8 +3857,6 @@
"question_navigation_label": "Jump to question",
"answered_state": "Answered",
"unanswered_state": "Unanswered",
- "answered_icon": "✓",
- "unanswered_icon": "○",
"jump_to_question": "Jump to question"
},
"time": {
@@ -3872,6 +3870,7 @@
"second": "second"
},
"errors": {
+ "error-type-submit": "Error when submitting test",
"unknown-error": "Unknown error",
"network-error": "Network error",
"submission-failed": "Submission failed.",
diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json
index 0d5e9d0fe..3a0ede3c3 100644
--- a/apps/upskii/messages/vi.json
+++ b/apps/upskii/messages/vi.json
@@ -3858,8 +3858,6 @@
"question_navigation_label": "Chuyển đến câu hỏi",
"answered_state": "Đã trả lời",
"unanswered_state": "Chưa trả lời",
- "answered_icon": "✓",
- "unanswered_icon": "○",
"jump_to_question": "Chuyển đến câu hỏi"
},
"time": {
@@ -3875,6 +3873,7 @@
"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",
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 3de587275..4023f4a0e 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 { CheckIcon, Circle } from '@tuturuuu/ui/icons';
import { useTranslations } from 'next-intl';
import React, { useCallback } from 'react';
@@ -56,9 +57,11 @@ export default function QuizStatusSidebar({
{answeredCount} / {questions.length} {t('answered_status_short')}
-
+
-
+ {/*
{icon}
-
+ */}
+ {answered ? (
+
+ ) : (
+
+ )}
{idx + 1}
);
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 17a364463..aec9927ee 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
@@ -12,6 +12,7 @@ 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';
@@ -214,6 +215,17 @@ export default function TakingQuizClient({
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 = () => {
@@ -345,6 +357,7 @@ export default function TakingQuizClient({
{
let nextArr = selArray.slice();
if (checked) {
@@ -367,7 +380,7 @@ export default function TakingQuizClient({
}
}}
/>
-
+
{opt.value}
@@ -402,8 +415,9 @@ export default function TakingQuizClient({
disabled={
submitting || (isCountdown && timeLeft === 0)
}
+ className="h-5.5 w-5.5 border-dynamic-purple/80 bg-dynamic-purple/20"
/>
-
+
{opt.value}
@@ -415,13 +429,11 @@ export default function TakingQuizClient({
);
})}
- {submitError &&
{submitError}
}
-
-
+
Date: Wed, 18 Jun 2025 19:18:27 +0700
Subject: [PATCH 11/17] style(Taking Quiz UI - Answering Question): styling for
displaying scores
---
apps/upskii/messages/en.json | 5 +-
apps/upskii/messages/vi.json | 5 +-
.../[setId]/take/before-taking-quiz-whole.tsx | 132 ++++++++++++------
.../[setId]/take/taking-quiz-client.tsx | 6 +-
.../[wsId]/quiz-sets/[setId]/take/route.ts | 66 +++++----
5 files changed, 143 insertions(+), 71 deletions(-)
diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json
index 257615b40..8dbc853b5 100644
--- a/apps/upskii/messages/en.json
+++ b/apps/upskii/messages/en.json
@@ -3895,7 +3895,8 @@
"quiz-not-available": "Quiz not yet available",
"no-attempts-remaining": "No attempts remaining",
"view-result": "View Result",
- "view-final-attempt": "View result of your final attempt",
+ "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."
@@ -3937,7 +3938,7 @@
"results-pending": "Results pending release",
"view-results-button": "View Results",
"view-details-button": "View Details",
- "attempt-info": "#{number} at {date} ({duration})"
+ "attempt-info": " at {date} ({duration})"
},
"summary": {
"title": "Attempt Summary",
diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json
index 3a0ede3c3..63ba3e665 100644
--- a/apps/upskii/messages/vi.json
+++ b/apps/upskii/messages/vi.json
@@ -3898,7 +3898,8 @@
"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-attempt": "Xem kết quả lần làm bài cuối cùng",
+ "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."
@@ -3940,7 +3941,7 @@
"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": "#{number} lúc {date} ({duration})"
+ "attempt-info": " lúc {date} ({duration})"
},
"summary": {
"title": "Tóm tắt lần làm bài",
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 ea4df2921..9ca924fcc 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
@@ -18,6 +18,7 @@ import {
Calendar,
CheckCircle,
Clock,
+ FileChartColumnIncreasing,
Info,
Play,
RotateCcw,
@@ -31,6 +32,7 @@ export interface AttemptSummary {
attemptId: string;
attemptNumber: number;
submittedAt: string; // ISO timestamp
+ totalScore: number | null; // null if not graded yet
durationSeconds: number;
}
@@ -46,6 +48,7 @@ interface QuizData {
explanationMode: 0 | 1 | 2;
instruction: string | null;
resultsReleased: boolean;
+ maxScore: number;
attempts: AttemptSummary[];
}
@@ -96,8 +99,7 @@ export default function BeforeTakingQuizWhole({
? quizData.attemptLimit - quizData.attemptsSoFar
: null;
- // MARK: modify if logic change to (cannot retake if result release)
- // once results are released, no more attempts allowed
+ // Determine if the quiz can be retaken
const canRetake =
isAvailable &&
!isPastDue &&
@@ -106,17 +108,25 @@ export default function BeforeTakingQuizWhole({
// 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));
+ 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;
+ quizData.attemptsSoFar > 0 && quizData.allowViewOldAttempts;
+ // !quizData.resultsReleased;
- const canViewOldAttemptsResults = 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);
@@ -167,24 +177,49 @@ export default function BeforeTakingQuizWhole({
)}
- {canViewResult && (
-
-
{
- router.push(
- `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${quizData.setId}/result?attemptId=${quizData.attempts[0]?.attemptId}`
- );
- }}
- 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"
+
+ {quizData.attempts[0] &&
+ quizData.attempts[0].totalScore != null &&
+ canViewTotalPointsOnly && (
+
- {t('quiz.view-result')}
-
-
- {t('quiz.view-final-attempt')}
-
-
- )}
+
+
+
+ {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 && (
+ {
+ router.push(
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${quizData.setId}/result?attemptId=${quizData.attempts[0]?.attemptId}`
+ );
+ }}
+ className={`border ${!canRetake ? 'border-dynamic-purple bg-dynamic-purple/20 hover:bg-primary-foreground' : 'border-dynamic-purple/20 bg-primary-foreground/30 hover:bg-dynamic-purple/10'} mt-3 px-5 py-1.5 text-primary hover:text-dynamic-purple md:mt-0`}
+ >
+
+ {t('quiz.view-result')}
+
+ )}
+
+
+ )}
{!isAvailable ? (
@@ -194,10 +229,12 @@ export default function BeforeTakingQuizWhole({
) : (
!quizData.resultsReleased &&
(isPastDue || attemptsRemaining == 0) && (
-
+
-
+
{isPastDue
? t('quiz.quiz-past-due-message')
: t('quiz.no-attempts-message')}
@@ -208,7 +245,7 @@ export default function BeforeTakingQuizWhole({
{/* Info & Schedule */}
-
+
{/* Quiz Information */}
@@ -361,17 +398,30 @@ export default function BeforeTakingQuizWhole({
{quizData.attempts.map((att) => (
-
- {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)}) */}
+
+
+ {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]/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 aec9927ee..3247a6c33 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
@@ -5,6 +5,7 @@ import BeforeTakingQuizWhole, {
} 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';
@@ -21,19 +22,20 @@ type TakeResponse = {
setId: string;
setName: string;
timeLimitMinutes: number | null;
- releasePointsImmediately: boolean;
attemptLimit: number | null;
attemptsSoFar: number;
allowViewOldAttempts: boolean;
availableDate: string | null;
dueDate: string | null;
resultsReleased: boolean;
- attempts: AttemptSummary[];
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 }[];
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 dd7e35367..63a94454a 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
@@ -1,10 +1,12 @@
import { createClient } from '@tuturuuu/supabase/next/server';
+import { Json } from '@tuturuuu/types/supabase';
import { NextRequest, NextResponse } from 'next/server';
export type AttemptSummary = {
attemptId: string;
attemptNumber: number;
- submittedAt: string; // ISO
+ submittedAt: string;
+ totalScore: number | null;
durationSeconds: number;
};
@@ -21,20 +23,24 @@ export interface TakeResponse {
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 }[];
}>;
- // NEW: explicit flags for UI
isAvailable: boolean;
isPastDue: boolean;
hasReachedMax: boolean;
}
-export async function GET(_req: NextRequest, { params }: { params: { setId: string } }) {
+export async function GET(
+ _req: NextRequest,
+ { params }: { params: { setId: string } }
+) {
const { setId } = params;
const sb = await createClient();
@@ -48,7 +54,7 @@ export async function GET(_req: NextRequest, { params }: { params: { setId: stri
}
const userId = user.id;
- // 2) Fetch quiz set metadata
+ // 2) Fetch metadata
const { data: setRow, error: sErr } = await sb
.from('workspace_quiz_sets')
.select(
@@ -70,7 +76,6 @@ export async function GET(_req: NextRequest, { params }: { params: { setId: stri
if (sErr || !setRow) {
return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 });
}
-
const {
name: setName,
attempt_limit: attemptLimit,
@@ -83,41 +88,48 @@ export async function GET(_req: NextRequest, { params }: { params: { setId: stri
instruction,
} = setRow;
- // 3) Count attempts so far
- const { data: prevAttempts, error: aErr } = await sb
+ // 3) Count attempts
+ const { data: prev, error: aErr } = await sb
.from('workspace_quiz_attempts')
.select('attempt_number', { head: false })
.eq('user_id', userId)
.eq('set_id', setId);
if (aErr) {
- return NextResponse.json({ error: 'Error counting attempts' }, { status: 500 });
+ return NextResponse.json(
+ { error: 'Error counting attempts' },
+ { status: 500 }
+ );
}
- const attemptsSoFar = prevAttempts?.length ?? 0;
+ const attemptsSoFar = prev?.length ?? 0;
- // 4) Build summaries of past attempts
+ // 4) Summaries
const { data: rawAttempts, error: attErr } = await sb
.from('workspace_quiz_attempts')
- .select('id,attempt_number,started_at,completed_at')
+ .select('id,attempt_number,started_at,completed_at,total_score')
.eq('user_id', userId)
.eq('set_id', setId)
.order('attempt_number', { ascending: false });
if (attErr) {
- return NextResponse.json({ error: 'Error fetching attempts' }, { status: 500 });
+ return NextResponse.json(
+ { error: 'Error fetching attempts' },
+ { status: 500 }
+ );
}
- const attempts: AttemptSummary[] = (rawAttempts || []).map((row) => {
- const started = new Date(row.started_at).getTime();
- const completed = row.completed_at
+ const attempts = (rawAttempts || []).map((row) => {
+ const startMs = new Date(row.started_at).getTime();
+ const endMs = row.completed_at
? new Date(row.completed_at).getTime()
: Date.now();
return {
attemptId: row.id,
+ totalScore: resultsReleased ? row.total_score : null,
attemptNumber: row.attempt_number,
submittedAt: row.completed_at ?? row.started_at,
- durationSeconds: Math.floor((completed - started) / 1000),
+ durationSeconds: Math.floor((endMs - startMs) / 1000),
};
});
- // 5) Fetch questions & options
+ // 5) Questions
const { data: rawQ, error: qErr } = await sb
.from('quiz_set_quizzes')
.select(
@@ -137,28 +149,33 @@ export async function GET(_req: NextRequest, { params }: { params: { setId: stri
)
.eq('set_id', setId);
if (qErr) {
- return NextResponse.json({ error: 'Error fetching questions' }, { status: 500 });
+ return NextResponse.json(
+ { error: 'Error fetching questions' },
+ { status: 500 }
+ );
}
-
- const questions = (rawQ || []).map((r: any) => ({
+ const questions = (rawQ || []).map((r) => ({
quizId: r.quiz_id,
question: r.workspace_quizzes.question,
score: r.workspace_quizzes.score,
+ instruction: r.workspace_quizzes.instruction,
multiple:
- r.workspace_quizzes.quiz_options.filter((o: any) => o.is_correct).length > 1,
- options: r.workspace_quizzes.quiz_options.map((o: any) => ({
+ r.workspace_quizzes.quiz_options.filter((o) => o.is_correct).length > 1,
+ options: r.workspace_quizzes.quiz_options.map((o) => ({
id: o.id,
value: o.value,
})),
}));
- // 6) Derive UI flags
+ const maxScore = questions.reduce((a, c) => a + c.score, 0);
+
+ // 6) Flags
const now = new Date();
const isAvailable = !availableDate || new Date(availableDate) <= now;
const isPastDue = !!dueDate && new Date(dueDate) < now;
const hasReachedMax = attemptLimit !== null && attemptsSoFar >= attemptLimit;
- // 7) Return unified 200 response
+ // 7) Payload
const payload: TakeResponse = {
setId,
setName,
@@ -172,6 +189,7 @@ export async function GET(_req: NextRequest, { params }: { params: { setId: stri
explanationMode: explanationMode as 0 | 1 | 2,
instruction,
attempts,
+ maxScore,
questions,
isAvailable,
isPastDue,
From 90fc2345b5a664d0d869599d8ebb0666b8db706d Mon Sep 17 00:00:00 2001
From: Nhung
Date: Wed, 18 Jun 2025 19:22:22 +0700
Subject: [PATCH 12/17] fix (Taking Quiz UI): fix deploy
---
.../workspaces/[wsId]/quiz-sets/[setId]/take/route.ts | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
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 63a94454a..ed4efaa60 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
@@ -37,11 +37,17 @@ export interface TakeResponse {
hasReachedMax: boolean;
}
+interface Params {
+ params: Promise<{
+ setId: string;
+ }>;
+}
+
export async function GET(
_req: NextRequest,
- { params }: { params: { setId: string } }
+ { params }: Params
) {
- const { setId } = params;
+ const { setId } = await params;
const sb = await createClient();
// 1) Auth
From e1c7076840ff7837c45286535fb856a31811708e Mon Sep 17 00:00:00 2001
From: Nhung
Date: Wed, 18 Jun 2025 19:31:52 +0700
Subject: [PATCH 13/17] fix (Taking Quiz UI): duration not recorded to backend
---
.../quiz-sets/[setId]/take/taking-quiz-client.tsx | 15 ++++++++++++---
.../[wsId]/quiz-sets/[setId]/submit/route.ts | 10 ++++++----
2 files changed, 18 insertions(+), 7 deletions(-)
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 3247a6c33..c41da2658 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
@@ -113,6 +113,9 @@ export default function TakingQuizClient({
return { quizId, selectedOptionId: val };
})
.flat(),
+ durationSeconds: computeElapsedSeconds(
+ Number(localStorage.getItem(STORAGE_KEY))
+ ),
});
// Only for debugging purposes
@@ -382,7 +385,10 @@ export default function TakingQuizClient({
}
}}
/>
-
+
{opt.value}
@@ -419,7 +425,10 @@ export default function TakingQuizClient({
}
className="h-5.5 w-5.5 border-dynamic-purple/80 bg-dynamic-purple/20"
/>
-
+
{opt.value}
@@ -435,7 +444,7 @@ export default function TakingQuizClient({
;
+ durationSeconds: number;
};
interface Params {
@@ -166,7 +167,8 @@ export async function POST(request: NextRequest, { params }: Params) {
set_id: setId,
attempt_number: newAttemptNumber,
total_score: totalScore,
- duration_seconds: 0, // we’ll patch this below
+ // duration_seconds: 0, // we’ll patch this below
+ duration_seconds: body.durationSeconds, // we’ll patch this below
})
.select('id, started_at')
.single();
@@ -199,9 +201,9 @@ export async function POST(request: NextRequest, { params }: Params) {
.from('workspace_quiz_attempts')
.update({
completed_at: completedAt,
- duration_seconds: Math.floor(
- (Date.now() - new Date(insAtt.started_at).getTime()) / 1000
- ),
+ // duration_seconds: Math.floor(
+ // (Date.now() - new Date(insAtt.started_at).getTime()) / 1000
+ // ),
})
.eq('id', insAtt.id);
From a025caebbb4cbf4eae9833a66b06ca0f5071a476 Mon Sep 17 00:00:00 2001
From: Nhung
Date: Wed, 18 Jun 2025 19:36:13 +0700
Subject: [PATCH 14/17] revert(Taking Quiz UI): revert working form
---
.../(dashboard)/[wsId]/quiz-sets/form.tsx | 233 +-----------------
1 file changed, 9 insertions(+), 224 deletions(-)
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/form.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/form.tsx
index e866d9773..e16701153 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/form.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/form.tsx
@@ -14,13 +14,8 @@ import { useForm } from '@tuturuuu/ui/hooks/use-form';
import { toast } from '@tuturuuu/ui/hooks/use-toast';
import { Input } from '@tuturuuu/ui/input';
import { zodResolver } from '@tuturuuu/ui/resolvers';
-import { Switch } from '@tuturuuu/ui/switch';
-import { RichTextEditor } from '@tuturuuu/ui/text-editor/editor';
-import { Textarea } from '@tuturuuu/ui/textarea';
-import { JSONContent } from '@tuturuuu/ui/tiptap';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
-import { useState } from 'react';
import * as z from 'zod';
interface Props {
@@ -33,17 +28,8 @@ interface Props {
const FormSchema = z.object({
id: z.string().optional(),
- name: z.string().min(1, { message: 'Name is required' }),
+ name: z.string().min(1),
moduleId: z.string().optional(),
- attemptLimit: z.number().int().min(0).optional(),
- timeLimitMinutes: z.number().int().min(0).optional(),
- allowViewResults: z.boolean().default(true),
- releasePointsImmediately: z.boolean().default(true),
- availableDate: z.string().optional(),
- dueDate: z.string().optional(),
- explanationMode: z
- .union([z.literal(0), z.literal(1), z.literal(2)])
- .default(0),
});
export default function CourseModuleForm({
@@ -54,10 +40,6 @@ export default function CourseModuleForm({
}: Props) {
const t = useTranslations();
const router = useRouter();
- const [instruction, setInstruction] = useState({
- type: 'doc',
- content: [],
- });
const form = useForm({
resolver: zodResolver(FormSchema),
@@ -72,7 +54,7 @@ export default function CourseModuleForm({
const isValid = form.formState.isValid;
const isSubmitting = form.formState.isSubmitting;
- const disabled = !isDirty || !isValid || isSubmitting || !instruction;
+ const disabled = !isDirty || !isValid || isSubmitting;
const onSubmit = async (data: z.infer) => {
try {
@@ -106,8 +88,7 @@ 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')}
-
-
- {/* use native select or custom*/}
-
- {t('ws-quiz-sets.explanation_none')}
-
-
- {t('ws-quiz-sets.explanation_during')}
-
-
- {t('ws-quiz-sets.explanation_after')}
-
-
-
-
- )}
- />
-
- {/* Instruction JSON */}
- {/* (
-
- {t('ws-quiz-sets.instruction')}
-
-
-
)}
- /> */}
- {/*
-
-
*/}
+ />
{data?.id ? t('ws-quiz-sets.edit') : t('ws-quiz-sets.create')}
@@ -270,62 +113,4 @@ export default function CourseModuleForm({
);
-}
-
-interface InstructionEditorProps {
- quizSetId: string;
- instruction: JSONContent;
- setInstruction: (instruction: JSONContent) => void;
-}
-
-function InstructionEditor({
- quizSetId,
- instruction,
- setInstruction,
-}: InstructionEditorProps) {
- const t = useTranslations();
-
- const [saving, setSaving] = useState(false);
-
- const INSTRUCTION_EDITOR_KEY = `instrction-quiz-set-${quizSetId}`;
-
- const handleSave = async () => {
- setSaving(true);
- localStorage.setItem(INSTRUCTION_EDITOR_KEY, JSON.stringify(instruction));
- setSaving(false);
- };
-
- return (
-
-
- {t('quiz.edit_instruction') || 'Edit Quiz Instruction'}
-
-
-
-
-
-
- {saving
- ? t('common.saving') || 'Saving...'
- : t('quiz.save_instruction') || 'Save Instruction'}
-
-
-
- );
-}
+}
\ No newline at end of file
From d465f775d16a5c42b19ae7c4b2e000bcf4689093 Mon Sep 17 00:00:00 2001
From: Puppychan <32950625+Puppychan@users.noreply.github.com>
Date: Wed, 18 Jun 2025 12:38:50 +0000
Subject: [PATCH 15/17] style: apply prettier formatting
---
.../quiz-sets/[setId]/take/quiz-status-sidebar.tsx | 4 +---
.../src/app/[locale]/(dashboard)/[wsId]/quiz-sets/form.tsx | 2 +-
.../api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts | 5 +----
3 files changed, 3 insertions(+), 8 deletions(-)
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 4023f4a0e..f2c9c2c1e 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
@@ -59,9 +59,7 @@ export default function QuizStatusSidebar({
);
-}
\ No newline at end of file
+}
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 ed4efaa60..a286747d3 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
@@ -43,10 +43,7 @@ interface Params {
}>;
}
-export async function GET(
- _req: NextRequest,
- { params }: Params
-) {
+export async function GET(_req: NextRequest, { params }: Params) {
const { setId } = await params;
const sb = await createClient();
From cc0b7f2a22999e0bbabb3a9ce0737f78aed74e22 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?V=C3=B5=20Ho=C3=A0ng=20Ph=C3=BAc?=
Date: Thu, 19 Jun 2025 00:48:38 +0700
Subject: [PATCH 16/17] chore(db): consolidate database schema
---
.../db/supabase/migrations/20250615104201_new_migration.sql | 3 ---
.../db/supabase/migrations/20250615190931_new_migration.sql | 3 ---
.../db/supabase/migrations/20250616085119_new_migration.sql | 5 -----
...migration.sql => 20250618174721_enhance_quiz_taking.sql} | 6 ++++++
4 files changed, 6 insertions(+), 11 deletions(-)
delete mode 100644 apps/db/supabase/migrations/20250615104201_new_migration.sql
delete mode 100644 apps/db/supabase/migrations/20250615190931_new_migration.sql
delete mode 100644 apps/db/supabase/migrations/20250616085119_new_migration.sql
rename apps/db/supabase/migrations/{20250615100747_new_migration.sql => 20250618174721_enhance_quiz_taking.sql} (57%)
diff --git a/apps/db/supabase/migrations/20250615104201_new_migration.sql b/apps/db/supabase/migrations/20250615104201_new_migration.sql
deleted file mode 100644
index c3135222c..000000000
--- a/apps/db/supabase/migrations/20250615104201_new_migration.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index 2c9f58f58..000000000
--- a/apps/db/supabase/migrations/20250615190931_new_migration.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-alter table "public"."workspace_quiz_sets" add column "results_released" boolean not null default false;
-
-
diff --git a/apps/db/supabase/migrations/20250616085119_new_migration.sql b/apps/db/supabase/migrations/20250616085119_new_migration.sql
deleted file mode 100644
index 1e363f106..000000000
--- a/apps/db/supabase/migrations/20250616085119_new_migration.sql
+++ /dev/null
@@ -1,5 +0,0 @@
-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;
-
-
diff --git a/apps/db/supabase/migrations/20250615100747_new_migration.sql b/apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql
similarity index 57%
rename from apps/db/supabase/migrations/20250615100747_new_migration.sql
rename to apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql
index be758721e..ba0ff9192 100644
--- a/apps/db/supabase/migrations/20250615100747_new_migration.sql
+++ b/apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql
@@ -8,4 +8,10 @@ alter table "public"."workspace_quiz_sets" add column "explanation_mode" smallin
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
From e2d71ddaa4c5dba3552c4ea1a1efd8f7fb47a08c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?V=C3=B5=20Ho=C3=A0ng=20Ph=C3=BAc?=
Date: Thu, 19 Jun 2025 00:55:10 +0700
Subject: [PATCH 17/17] refactor: standardize customer ID naming and update
database schema for consistency
---
.../api/[wsId]/[productId]/cancel/route.ts | 2 +-
.../api/[wsId]/[productId]/payment/route.ts | 2 +-
packages/types/src/supabase.ts | 156 +++++++++---------
3 files changed, 80 insertions(+), 80 deletions(-)
diff --git a/apps/web/src/app/api/[wsId]/[productId]/cancel/route.ts b/apps/web/src/app/api/[wsId]/[productId]/cancel/route.ts
index 61cd1b6f2..b194ec26a 100644
--- a/apps/web/src/app/api/[wsId]/[productId]/cancel/route.ts
+++ b/apps/web/src/app/api/[wsId]/[productId]/cancel/route.ts
@@ -28,7 +28,7 @@
// // cancel_at_period_end: true, // This is the user-friendly way to cancel
// // });
// const session = await api.customerSessions.create({
-// customerExternalId: '00000000-0000-0000-0000-000000000001',
+// customerId: '00000000-0000-0000-0000-000000000001',
// });
// const result = await api.customers.getExternal({
// externalId: user.id,
diff --git a/apps/web/src/app/api/[wsId]/[productId]/payment/route.ts b/apps/web/src/app/api/[wsId]/[productId]/payment/route.ts
index b8bcd8364..136f60ca5 100644
--- a/apps/web/src/app/api/[wsId]/[productId]/payment/route.ts
+++ b/apps/web/src/app/api/[wsId]/[productId]/payment/route.ts
@@ -60,7 +60,7 @@ export async function GET(
const checkoutSession = await api.checkouts.create({
products: [productId],
successUrl: `http://localhost:7803/${wsId}/billing/success`,
- customerExternalId: user?.id || '',
+ customerId: user?.id || '',
metadata: {
wsId: wsId,
},
diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts
index 38dc07735..2e737acf1 100644
--- a/packages/types/src/supabase.ts
+++ b/packages/types/src/supabase.ts
@@ -7475,16 +7475,16 @@ export type Database = {
};
count_search_users: {
Args:
+ | { search_query: string }
| {
- role_filter?: string;
search_query: string;
enabled_filter?: boolean;
- }
- | { search_query: string };
+ role_filter?: string;
+ };
Returns: number;
};
create_ai_chat: {
- Args: { model: string; title: string; message: string };
+ Args: { title: string; model: string; message: string };
Returns: string;
};
generate_cross_app_token: {
@@ -7498,9 +7498,9 @@ export type Database = {
| {
p_origin_app: string;
p_user_id: string;
+ p_session_data?: Json;
p_target_app: string;
p_expiry_seconds?: number;
- p_session_data?: Json;
};
Returns: string;
};
@@ -7522,9 +7522,9 @@ export type Database = {
get_daily_prompt_completion_tokens: {
Args: { past_days?: number };
Returns: {
- total_completion_tokens: number;
- total_prompt_tokens: number;
day: string;
+ total_prompt_tokens: number;
+ total_completion_tokens: number;
}[];
};
get_finance_invoices_count: {
@@ -7550,9 +7550,9 @@ export type Database = {
get_hourly_prompt_completion_tokens: {
Args: { past_hours?: number };
Returns: {
- hour: string;
total_completion_tokens: number;
total_prompt_tokens: number;
+ hour: string;
}[];
};
get_inventory_batches_count: {
@@ -7566,21 +7566,21 @@ export type Database = {
get_inventory_products: {
Args: {
_category_ids?: string[];
- _ws_id?: string;
_warehouse_ids?: string[];
_has_unit?: boolean;
+ _ws_id?: string;
};
Returns: {
- unit: string;
- id: string;
- name: string;
- manufacturer: string;
unit_id: string;
category: string;
price: number;
- amount: number;
ws_id: string;
created_at: string;
+ id: string;
+ amount: number;
+ name: string;
+ manufacturer: string;
+ unit: string;
}[];
};
get_inventory_products_count: {
@@ -7610,9 +7610,9 @@ export type Database = {
get_monthly_prompt_completion_tokens: {
Args: { past_months?: number };
Returns: {
- total_prompt_tokens: number;
- month: string;
total_completion_tokens: number;
+ month: string;
+ total_prompt_tokens: number;
}[];
};
get_pending_event_participants: {
@@ -7622,68 +7622,68 @@ export type Database = {
get_possible_excluded_groups: {
Args: { _ws_id: string; included_groups: string[] };
Returns: {
- amount: number;
- ws_id: string;
- name: string;
id: string;
+ name: string;
+ ws_id: string;
+ amount: number;
}[];
};
get_possible_excluded_tags: {
- Args: { included_tags: string[]; _ws_id: string };
+ Args: { _ws_id: string; included_tags: string[] };
Returns: {
- amount: number;
ws_id: string;
- name: string;
+ amount: number;
id: string;
+ name: string;
}[];
};
get_session_statistics: {
Args: Record;
Returns: {
total_count: number;
- completed_count: number;
- active_count: number;
unique_users_count: number;
latest_session_date: string;
+ completed_count: number;
+ active_count: number;
}[];
};
get_session_templates: {
Args: {
+ workspace_id: string;
user_id_param: string;
limit_count?: number;
- workspace_id: string;
};
Returns: {
- category_name: string;
- tags: string[];
- task_id: string;
- category_id: string;
- description: string;
- title: string;
last_used: string;
+ task_name: string;
avg_duration: number;
- usage_count: number;
category_color: string;
- task_name: string;
+ usage_count: number;
+ title: string;
+ description: string;
+ category_id: string;
+ task_id: string;
+ tags: string[];
+ category_name: string;
}[];
};
get_submission_statistics: {
Args: Record;
Returns: {
- unique_users_count: number;
latest_submission_date: string;
+ unique_users_count: number;
total_count: number;
}[];
};
get_transaction_categories_with_amount: {
Args: Record;
Returns: {
+ ws_id: string;
amount: number;
+ id: string;
name: string;
is_expense: boolean;
- ws_id: string;
created_at: string;
- id: string;
}[];
};
get_user_role: {
@@ -7693,34 +7693,34 @@ export type Database = {
get_user_session_stats: {
Args: { user_id: string };
Returns: {
+ current_session_age: unknown;
total_sessions: number;
active_sessions: number;
- current_session_age: unknown;
}[];
};
get_user_sessions: {
Args: { user_id: string };
Returns: {
- created_at: string;
- updated_at: string;
user_agent: string;
- ip: string;
- is_current: boolean;
+ created_at: string;
session_id: string;
+ is_current: boolean;
+ ip: string;
+ updated_at: string;
}[];
};
get_user_tasks: {
Args: { _board_id: string };
Returns: {
- board_id: string;
- id: string;
- name: string;
+ end_date: string;
description: string;
priority: number;
completed: boolean;
start_date: string;
- end_date: string;
list_id: string;
+ board_id: string;
+ id: string;
+ name: string;
}[];
};
get_workspace_drive_size: {
@@ -7736,24 +7736,24 @@ export type Database = {
Returns: number;
};
get_workspace_transactions_count: {
- Args: { start_date?: string; end_date?: string; ws_id: string };
+ Args: { end_date?: string; start_date?: string; ws_id: string };
Returns: number;
};
get_workspace_user_groups: {
Args: {
included_tags: string[];
- excluded_tags: string[];
search_query: string;
+ excluded_tags: string[];
_ws_id: string;
};
Returns: {
created_at: string;
- id: string;
- name: string;
- notes: string;
- ws_id: string;
- tags: string[];
tag_count: number;
+ tags: string[];
+ ws_id: string;
+ notes: string;
+ name: string;
+ id: string;
}[];
};
get_workspace_user_groups_count: {
@@ -7768,6 +7768,7 @@ export type Database = {
search_query: string;
};
Returns: {
+ created_at: string;
id: string;
avatar_url: string;
full_name: string;
@@ -7786,7 +7787,6 @@ export type Database = {
groups: string[];
group_count: number;
linked_users: Json;
- created_at: string;
updated_at: string;
}[];
};
@@ -7799,19 +7799,19 @@ export type Database = {
Returns: number;
};
get_workspace_wallets_expense: {
- Args: { end_date?: string; ws_id: string; start_date?: string };
+ Args: { ws_id: string; end_date?: string; start_date?: string };
Returns: number;
};
get_workspace_wallets_income: {
- Args: { end_date?: string; ws_id: string; start_date?: string };
+ Args: { end_date?: string; start_date?: string; ws_id: string };
Returns: number;
};
has_other_owner: {
- Args: { _ws_id: string; _user_id: string };
+ Args: { _user_id: string; _ws_id: string };
Returns: boolean;
};
insert_ai_chat_message: {
- Args: { chat_id: string; source: string; message: string };
+ Args: { source: string; message: string; chat_id: string };
Returns: undefined;
};
is_list_accessible: {
@@ -7835,11 +7835,11 @@ export type Database = {
Returns: boolean;
};
is_nova_user_id_in_team: {
- Args: { _team_id: string; _user_id: string };
+ Args: { _user_id: string; _team_id: string };
Returns: boolean;
};
is_org_member: {
- Args: { _org_id: string; _user_id: string };
+ Args: { _user_id: string; _org_id: string };
Returns: boolean;
};
is_project_member: {
@@ -7851,7 +7851,7 @@ export type Database = {
Returns: boolean;
};
is_task_board_member: {
- Args: { _board_id: string; _user_id: string };
+ Args: { _user_id: string; _board_id: string };
Returns: boolean;
};
is_user_task_in_board: {
@@ -7863,11 +7863,11 @@ export type Database = {
Returns: Json;
};
nova_get_challenge_with_user_stats: {
- Args: { user_id: string; challenge_id: string };
+ Args: { challenge_id: string; user_id: string };
Returns: Json;
};
nova_get_user_daily_sessions: {
- Args: { user_id: string; challenge_id: string };
+ Args: { challenge_id: string; user_id: string };
Returns: number;
};
nova_get_user_total_sessions: {
@@ -7883,7 +7883,7 @@ export type Database = {
Returns: number;
};
revoke_user_session: {
- Args: { target_user_id: string; session_id: string };
+ Args: { session_id: string; target_user_id: string };
Returns: boolean;
};
search_users: {
@@ -7897,36 +7897,36 @@ export type Database = {
enabled_filter?: boolean;
};
Returns: {
- allow_challenge_management: boolean;
- allow_manage_all_challenges: boolean;
+ deleted: boolean;
+ avatar_url: string;
+ handle: string;
bio: string;
created_at: string;
user_id: string;
enabled: boolean;
- team_name: string[];
- birthday: string;
- new_email: string;
- email: string;
+ allow_challenge_management: boolean;
+ allow_manage_all_challenges: boolean;
allow_role_management: boolean;
+ email: string;
+ new_email: string;
+ birthday: string;
+ team_name: string[];
id: string;
display_name: string;
- deleted: boolean;
- avatar_url: string;
- handle: string;
}[];
};
search_users_by_name: {
Args: {
- min_similarity?: number;
- result_limit?: number;
search_query: string;
+ result_limit?: number;
+ min_similarity?: number;
};
Returns: {
+ id: string;
+ handle: string;
display_name: string;
- relevance: number;
avatar_url: string;
- handle: string;
- id: string;
+ relevance: number;
}[];
};
sum_quiz_scores: {
@@ -7940,7 +7940,7 @@ export type Database = {
Returns: boolean;
};
transactions_have_same_amount: {
- Args: { transaction_id_2: string; transaction_id_1: string };
+ Args: { transaction_id_1: string; transaction_id_2: string };
Returns: boolean;
};
update_expired_sessions: {