Pending course, rest ready for launch
This commit is contained in:
113
app/(protected)/practice/[slug]/actions.ts
Normal file
113
app/(protected)/practice/[slug]/actions.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireUser } from "@/lib/auth/requireUser";
|
||||
import { mockPracticeModules } from "@/lib/data/mockPractice";
|
||||
import { db } from "@/lib/prisma";
|
||||
import { refreshStudyRecommendations } from "@/lib/recommendations";
|
||||
|
||||
type SubmitAttemptInput = {
|
||||
slug: string;
|
||||
selectedAnswers: number[];
|
||||
};
|
||||
|
||||
type PracticePrismaClient = {
|
||||
miniGame: {
|
||||
upsert: (args: object) => Promise<{ id: string }>;
|
||||
};
|
||||
miniGameAttempt: {
|
||||
create: (args: object) => Promise<unknown>;
|
||||
findMany: (args: object) => Promise<
|
||||
{ id: string; scorePercent: number; correctCount: number; totalQuestions: number; completedAt: Date }[]
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
function toDifficulty(level?: string): "BEGINNER" | "INTERMEDIATE" | "ADVANCED" {
|
||||
if (level === "Beginner") return "BEGINNER";
|
||||
if (level === "Advanced") return "ADVANCED";
|
||||
return "INTERMEDIATE";
|
||||
}
|
||||
|
||||
export async function submitPracticeAttempt({ slug, selectedAnswers }: SubmitAttemptInput) {
|
||||
const user = await requireUser();
|
||||
if (!user?.id) return { success: false as const, error: "Unauthorized" };
|
||||
|
||||
const practiceModule = mockPracticeModules.find((item) => item.slug === slug && item.isInteractive && item.questions?.length);
|
||||
if (!practiceModule || !practiceModule.questions) return { success: false as const, error: "Practice module not found" };
|
||||
|
||||
const correctCount = practiceModule.questions.reduce((acc, question, index) => {
|
||||
return acc + (selectedAnswers[index] === question.answerIndex ? 1 : 0);
|
||||
}, 0);
|
||||
const total = practiceModule.questions.length;
|
||||
const scorePercent = Math.round((correctCount / total) * 100);
|
||||
const prismaMini = db as unknown as PracticePrismaClient;
|
||||
|
||||
try {
|
||||
const miniGame = await prismaMini.miniGame.upsert({
|
||||
where: { slug: practiceModule.slug },
|
||||
update: {
|
||||
title: practiceModule.title,
|
||||
description: practiceModule.description,
|
||||
isActive: true,
|
||||
difficulty: toDifficulty(practiceModule.difficulty),
|
||||
},
|
||||
create: {
|
||||
slug: practiceModule.slug,
|
||||
title: practiceModule.title,
|
||||
description: practiceModule.description,
|
||||
isActive: true,
|
||||
difficulty: toDifficulty(practiceModule.difficulty),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prismaMini.miniGameAttempt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
miniGameId: miniGame.id,
|
||||
scorePercent,
|
||||
correctCount,
|
||||
totalQuestions: total,
|
||||
},
|
||||
});
|
||||
|
||||
await refreshStudyRecommendations(user.id);
|
||||
} catch {
|
||||
return { success: false as const, error: "Mini-game tables are not migrated yet" };
|
||||
}
|
||||
revalidatePath("/profile");
|
||||
|
||||
return { success: true as const, scorePercent, correctCount, total };
|
||||
}
|
||||
|
||||
export async function getPracticeAttempts(slug: string) {
|
||||
const user = await requireUser();
|
||||
if (!user?.id) return [];
|
||||
|
||||
try {
|
||||
const attempts = await (db as unknown as PracticePrismaClient).miniGameAttempt.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
miniGame: {
|
||||
slug,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
completedAt: "desc",
|
||||
},
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
scorePercent: true,
|
||||
correctCount: true,
|
||||
totalQuestions: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return attempts;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
90
app/(protected)/practice/[slug]/page.tsx
Executable file → Normal file
90
app/(protected)/practice/[slug]/page.tsx
Executable file → Normal file
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import ProgressBar from "@/components/ProgressBar";
|
||||
import { getPracticeBySlug, mockPracticeModules } from "@/lib/data/mockPractice";
|
||||
|
||||
const attemptsKey = (slug: string) => `acve.practice-attempts.${slug}`;
|
||||
import { getPracticeAttempts, submitPracticeAttempt } from "@/app/(protected)/practice/[slug]/actions";
|
||||
|
||||
type AttemptRecord = {
|
||||
completedAt: string;
|
||||
score: number;
|
||||
total: number;
|
||||
id: string;
|
||||
scorePercent: number;
|
||||
correctCount: number;
|
||||
totalQuestions: number;
|
||||
completedAt: Date;
|
||||
};
|
||||
|
||||
export default function PracticeExercisePage() {
|
||||
@@ -23,21 +24,16 @@ export default function PracticeExercisePage() {
|
||||
const [score, setScore] = useState(0);
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<number[]>([]);
|
||||
const [attempts, setAttempts] = useState<AttemptRecord[]>([]);
|
||||
const [isSaving, startTransition] = useTransition();
|
||||
|
||||
const loadAttempts = () => {
|
||||
if (!practiceModule) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const raw = window.localStorage.getItem(attemptsKey(practiceModule.slug));
|
||||
if (!raw) {
|
||||
setAttempts([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setAttempts(JSON.parse(raw) as AttemptRecord[]);
|
||||
} catch {
|
||||
setAttempts([]);
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await getPracticeAttempts(practiceModule.slug);
|
||||
setAttempts(result as AttemptRecord[]);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,21 +75,23 @@ export default function PracticeExercisePage() {
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (selected === null) return;
|
||||
setSelectedAnswers((prev) => {
|
||||
const nextAnswers = [...prev];
|
||||
nextAnswers[index] = selected;
|
||||
return nextAnswers;
|
||||
});
|
||||
|
||||
if (index + 1 >= total) {
|
||||
if (typeof window !== "undefined") {
|
||||
const raw = window.localStorage.getItem(attemptsKey(practiceModule.slug));
|
||||
const parsed = raw ? ((JSON.parse(raw) as AttemptRecord[]) ?? []) : [];
|
||||
const nextAttempts = [
|
||||
{
|
||||
completedAt: new Date().toISOString(),
|
||||
score,
|
||||
total,
|
||||
},
|
||||
...parsed,
|
||||
].slice(0, 5);
|
||||
window.localStorage.setItem(attemptsKey(practiceModule.slug), JSON.stringify(nextAttempts));
|
||||
setAttempts(nextAttempts);
|
||||
}
|
||||
const finalAnswers = [...selectedAnswers];
|
||||
finalAnswers[index] = selected;
|
||||
startTransition(async () => {
|
||||
await submitPracticeAttempt({
|
||||
slug: practiceModule.slug,
|
||||
selectedAnswers: finalAnswers,
|
||||
});
|
||||
loadAttempts();
|
||||
});
|
||||
setFinished(true);
|
||||
return;
|
||||
}
|
||||
@@ -106,6 +104,7 @@ export default function PracticeExercisePage() {
|
||||
setIndex(0);
|
||||
setScore(0);
|
||||
setSelected(null);
|
||||
setSelectedAnswers([]);
|
||||
setFinished(false);
|
||||
};
|
||||
|
||||
@@ -115,6 +114,7 @@ export default function PracticeExercisePage() {
|
||||
setIndex(0);
|
||||
setScore(0);
|
||||
setSelected(null);
|
||||
setSelectedAnswers([]);
|
||||
};
|
||||
|
||||
if (finished) {
|
||||
@@ -130,9 +130,6 @@ export default function PracticeExercisePage() {
|
||||
<button className="acve-button-primary px-5 py-2 text-sm font-semibold hover:brightness-105" onClick={restart} type="button">
|
||||
Retake quiz
|
||||
</button>
|
||||
<button className="rounded-md border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" type="button">
|
||||
Review answers (placeholder)
|
||||
</button>
|
||||
<Link className="rounded-md border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" href="/practice">
|
||||
Back to modules
|
||||
</Link>
|
||||
@@ -140,14 +137,15 @@ export default function PracticeExercisePage() {
|
||||
</section>
|
||||
|
||||
<section className="acve-panel p-4">
|
||||
<h2 className="text-base font-semibold text-slate-800">Attempt History (Mock)</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800">Attempt History</h2>
|
||||
{attempts.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{attempts.map((attempt) => (
|
||||
<li key={attempt.completedAt} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||
Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()}
|
||||
<li key={attempt.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||
Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "}
|
||||
{new Date(attempt.completedAt).toLocaleString()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -175,7 +173,7 @@ export default function PracticeExercisePage() {
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Difficulty</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">Intermediate</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">{practiceModule.difficulty ?? "Intermediate"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
@@ -189,14 +187,15 @@ export default function PracticeExercisePage() {
|
||||
</section>
|
||||
|
||||
<section className="acve-panel p-4">
|
||||
<h2 className="text-base font-semibold text-slate-800">Attempt History (Mock)</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800">Attempt History</h2>
|
||||
{attempts.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet for this module.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{attempts.map((attempt) => (
|
||||
<li key={attempt.completedAt} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||
Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()}
|
||||
<li key={attempt.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||
Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "}
|
||||
{new Date(attempt.completedAt).toLocaleString()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -237,14 +236,15 @@ export default function PracticeExercisePage() {
|
||||
</section>
|
||||
|
||||
<section className="acve-panel p-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Attempt History (Mock)</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800">Attempt History</h2>
|
||||
{attempts.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{attempts.map((attempt) => (
|
||||
<li key={attempt.completedAt} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||
Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()}
|
||||
<li key={attempt.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||
Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "}
|
||||
{new Date(attempt.completedAt).toLocaleString()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -285,7 +285,7 @@ export default function PracticeExercisePage() {
|
||||
|
||||
<button
|
||||
className="acve-button-primary mt-6 px-6 py-2 text-sm font-semibold hover:brightness-105 disabled:opacity-50"
|
||||
disabled={selected === null}
|
||||
disabled={selected === null || isSaving}
|
||||
onClick={next}
|
||||
type="button"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user