From be4ca2ed780f084705b2f19fea854935d75c96fa Mon Sep 17 00:00:00 2001 From: Marcelo Date: Tue, 17 Feb 2026 00:07:00 +0000 Subject: [PATCH] advance --- .env.local.example | 2 + .eslintrc.json | 0 .gitignore | 3 + app/(auth)/auth/callback/route.ts | 0 app/(auth)/auth/login/page.tsx | 8 +- app/(auth)/auth/signup/page.tsx | 9 + .../courses/[slug]/learn/actions.ts | 92 + app/(protected)/courses/[slug]/learn/page.tsx | 304 +-- app/(protected)/org/page.tsx | 47 + app/(protected)/practice/[slug]/page.tsx | 194 +- app/(protected)/teacher/actions.ts | 386 +++ .../teacher/courses/[slug]/edit/page.tsx | 42 +- .../lessons/[lessonId]/LessonEditorForm.tsx | 120 + .../[slug]/lessons/[lessonId]/page.tsx | 73 + .../courses/[slug]/lessons/new/page.tsx | 0 .../teacher/courses/[slug]/page.tsx | 14 + app/(protected)/teacher/courses/new/page.tsx | 0 app/(protected)/teacher/page.tsx | 108 +- app/(protected)/teacher/uploads/page.tsx | 7 + app/(public)/assistant/page.tsx | 18 +- app/(public)/case-studies/[slug]/page.tsx | 61 +- app/(public)/case-studies/page.tsx | 82 +- app/(public)/courses/[slug]/page.tsx | 283 ++- app/(public)/courses/page.tsx | 178 +- app/(public)/page.tsx | 40 +- app/(public)/practice/page.tsx | 45 +- app/globals copy.css | 154 ++ app/globals.css | 99 +- app/layout.tsx | 2 +- components.json | 23 + components/AssistantDrawer.tsx | 127 +- components/CourseCard.tsx | 123 +- components/Footer.tsx | 6 +- components/LessonRow.tsx | 52 +- components/Navbar.tsx | 142 +- components/ProgressBar.tsx | 6 +- components/Tabs.tsx | 31 +- components/auth/LoginForm.tsx | 123 +- components/courses/StudentClassroomClient.tsx | 218 ++ components/teacher/TeacherDashboardClient.tsx | 45 +- components/teacher/TeacherEditCourseForm.tsx | 493 ++-- components/teacher/TeacherNewCourseForm.tsx | 178 +- components/teacher/TeacherNewLessonForm.tsx | 0 .../teacher/TeacherUploadsLibraryClient.tsx | 140 ++ components/teacher/VideoUpload.tsx | 95 + components/ui/badge.tsx | 36 + components/ui/button.tsx | 57 + components/ui/card.tsx | 76 + components/ui/input.tsx | 22 + components/ui/scroll-area.tsx | 48 + components/ui/sheet.tsx | 140 ++ components/ui/sonner.tsx | 31 + components/ui/tabs.tsx | 55 + frontend_plan.md | 252 ++ lib/auth/clientAuth.ts | 36 + lib/auth/demoAuth.ts | 34 + lib/auth/requireTeacher.ts | 67 +- lib/auth/requireUser.ts | 28 +- lib/auth/teacherAllowlist.ts | 16 + lib/data/courseCatalog.ts | 14 +- lib/data/mockCaseStudies.ts | 0 lib/data/mockCourses.ts | 4 + lib/data/mockPractice.ts | 0 lib/data/teacherCourses.ts | 1 + lib/logger.ts | 21 + lib/prisma.ts | 24 + lib/progress/localProgress.ts | 0 lib/supabase/browser.ts | 9 +- lib/supabase/config.ts | 28 + lib/supabase/middleware.ts | 8 +- lib/supabase/server.ts | 9 +- lib/utils.ts | 6 + lib/validations/course.ts | 12 + middleware.ts | 84 +- next-env.d.ts | 0 next.config.ts | 0 package-lock.json | 2115 ++++++++++++++++- package.json | 25 +- postcss.config.mjs | 0 prisma.config.ts | 15 + prisma/schema.prisma | 215 ++ prisma/seed.ts | 107 + public/images/hero-reference.png | Bin public/images/logo.png | Bin student_experience_planish.md | 197 ++ tailwind.config.ts | 65 +- tsconfig.json | 0 tsconfig.tsbuildinfo | 1 + types/caseStudy.ts | 0 types/course.ts | 2 + types/index.ts | 5 + types/practice.ts | 0 92 files changed, 6850 insertions(+), 1188 deletions(-) mode change 100644 => 100755 .env.local.example mode change 100644 => 100755 .eslintrc.json mode change 100644 => 100755 .gitignore mode change 100644 => 100755 app/(auth)/auth/callback/route.ts mode change 100644 => 100755 app/(auth)/auth/login/page.tsx mode change 100644 => 100755 app/(auth)/auth/signup/page.tsx create mode 100644 app/(protected)/courses/[slug]/learn/actions.ts mode change 100644 => 100755 app/(protected)/courses/[slug]/learn/page.tsx create mode 100644 app/(protected)/org/page.tsx mode change 100644 => 100755 app/(protected)/practice/[slug]/page.tsx create mode 100644 app/(protected)/teacher/actions.ts mode change 100644 => 100755 app/(protected)/teacher/courses/[slug]/edit/page.tsx create mode 100644 app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/LessonEditorForm.tsx create mode 100644 app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/page.tsx mode change 100644 => 100755 app/(protected)/teacher/courses/[slug]/lessons/new/page.tsx create mode 100644 app/(protected)/teacher/courses/[slug]/page.tsx mode change 100644 => 100755 app/(protected)/teacher/courses/new/page.tsx mode change 100644 => 100755 app/(protected)/teacher/page.tsx create mode 100644 app/(protected)/teacher/uploads/page.tsx mode change 100644 => 100755 app/(public)/assistant/page.tsx mode change 100644 => 100755 app/(public)/case-studies/[slug]/page.tsx mode change 100644 => 100755 app/(public)/case-studies/page.tsx mode change 100644 => 100755 app/(public)/courses/[slug]/page.tsx mode change 100644 => 100755 app/(public)/courses/page.tsx mode change 100644 => 100755 app/(public)/page.tsx mode change 100644 => 100755 app/(public)/practice/page.tsx create mode 100755 app/globals copy.css mode change 100644 => 100755 app/globals.css mode change 100644 => 100755 app/layout.tsx create mode 100755 components.json mode change 100644 => 100755 components/AssistantDrawer.tsx mode change 100644 => 100755 components/CourseCard.tsx mode change 100644 => 100755 components/Footer.tsx mode change 100644 => 100755 components/LessonRow.tsx mode change 100644 => 100755 components/Navbar.tsx mode change 100644 => 100755 components/ProgressBar.tsx mode change 100644 => 100755 components/Tabs.tsx mode change 100644 => 100755 components/auth/LoginForm.tsx create mode 100644 components/courses/StudentClassroomClient.tsx mode change 100644 => 100755 components/teacher/TeacherDashboardClient.tsx mode change 100644 => 100755 components/teacher/TeacherEditCourseForm.tsx mode change 100644 => 100755 components/teacher/TeacherNewCourseForm.tsx mode change 100644 => 100755 components/teacher/TeacherNewLessonForm.tsx create mode 100644 components/teacher/TeacherUploadsLibraryClient.tsx create mode 100644 components/teacher/VideoUpload.tsx create mode 100755 components/ui/badge.tsx create mode 100755 components/ui/button.tsx create mode 100755 components/ui/card.tsx create mode 100755 components/ui/input.tsx create mode 100755 components/ui/scroll-area.tsx create mode 100755 components/ui/sheet.tsx create mode 100644 components/ui/sonner.tsx create mode 100755 components/ui/tabs.tsx create mode 100755 frontend_plan.md create mode 100644 lib/auth/clientAuth.ts create mode 100644 lib/auth/demoAuth.ts mode change 100644 => 100755 lib/auth/requireTeacher.ts mode change 100644 => 100755 lib/auth/requireUser.ts create mode 100644 lib/auth/teacherAllowlist.ts mode change 100644 => 100755 lib/data/courseCatalog.ts mode change 100644 => 100755 lib/data/mockCaseStudies.ts mode change 100644 => 100755 lib/data/mockCourses.ts mode change 100644 => 100755 lib/data/mockPractice.ts mode change 100644 => 100755 lib/data/teacherCourses.ts create mode 100644 lib/logger.ts create mode 100755 lib/prisma.ts mode change 100644 => 100755 lib/progress/localProgress.ts mode change 100644 => 100755 lib/supabase/browser.ts create mode 100644 lib/supabase/config.ts mode change 100644 => 100755 lib/supabase/middleware.ts mode change 100644 => 100755 lib/supabase/server.ts create mode 100755 lib/utils.ts create mode 100755 lib/validations/course.ts mode change 100644 => 100755 middleware.ts mode change 100644 => 100755 next-env.d.ts mode change 100644 => 100755 next.config.ts mode change 100644 => 100755 package-lock.json mode change 100644 => 100755 package.json mode change 100644 => 100755 postcss.config.mjs create mode 100755 prisma.config.ts create mode 100755 prisma/schema.prisma create mode 100755 prisma/seed.ts mode change 100644 => 100755 public/images/hero-reference.png mode change 100644 => 100755 public/images/logo.png create mode 100644 student_experience_planish.md mode change 100644 => 100755 tailwind.config.ts mode change 100644 => 100755 tsconfig.json create mode 100644 tsconfig.tsbuildinfo mode change 100644 => 100755 types/caseStudy.ts mode change 100644 => 100755 types/course.ts create mode 100644 types/index.ts mode change 100644 => 100755 types/practice.ts diff --git a/.env.local.example b/.env.local.example old mode 100644 new mode 100755 index bc808a2..f3304ba --- a/.env.local.example +++ b/.env.local.example @@ -1,3 +1,5 @@ NEXT_PUBLIC_SUPABASE_URL=YOUR_URL NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY TEACHER_EMAILS=teacher@example.com +DATABASE_URL= +DIRECT_URL= diff --git a/.eslintrc.json b/.eslintrc.json old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index caae059..7302da5 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,12 @@ node_modules .next out dist +.env .env.local npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* + +/generated/prisma diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts old mode 100644 new mode 100755 diff --git a/app/(auth)/auth/login/page.tsx b/app/(auth)/auth/login/page.tsx old mode 100644 new mode 100755 index ac05314..bd4b43b --- a/app/(auth)/auth/login/page.tsx +++ b/app/(auth)/auth/login/page.tsx @@ -3,6 +3,8 @@ import LoginForm from "@/components/auth/LoginForm"; type LoginPageProps = { searchParams: Promise<{ redirectTo?: string | string[]; + role?: string | string[]; + forgot?: string | string[]; }>; }; @@ -10,6 +12,10 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { const params = await searchParams; const redirectValue = params.redirectTo; const redirectTo = Array.isArray(redirectValue) ? redirectValue[0] : redirectValue; + const roleValue = params.role; + const role = Array.isArray(roleValue) ? roleValue[0] : roleValue; + const forgotValue = params.forgot; + const forgot = Array.isArray(forgotValue) ? forgotValue[0] : forgotValue; - return ; + return ; } diff --git a/app/(auth)/auth/signup/page.tsx b/app/(auth)/auth/signup/page.tsx old mode 100644 new mode 100755 index 00b613a..d2c493a --- a/app/(auth)/auth/signup/page.tsx +++ b/app/(auth)/auth/signup/page.tsx @@ -81,6 +81,15 @@ export default function SignupPage() { Login

+

+ Are you a teacher?{" "} + + Login here + +

+

+ Teacher accounts are invite-only. If you received an invite, use the email provided. +

); } diff --git a/app/(protected)/courses/[slug]/learn/actions.ts b/app/(protected)/courses/[slug]/learn/actions.ts new file mode 100644 index 0000000..70ff816 --- /dev/null +++ b/app/(protected)/courses/[slug]/learn/actions.ts @@ -0,0 +1,92 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireUser } from "@/lib/auth/requireUser"; +import { db } from "@/lib/prisma"; + +type ToggleLessonCompleteInput = { + courseSlug: string; + lessonId: string; +}; + +export async function toggleLessonComplete({ courseSlug, lessonId }: ToggleLessonCompleteInput) { + const user = await requireUser(); + + if (!user?.id) { + return { success: false, error: "Unauthorized" }; + } + + const lesson = await db.lesson.findUnique({ + where: { id: lessonId }, + select: { + id: true, + module: { + select: { + courseId: true, + course: { + select: { + slug: true, + }, + }, + }, + }, + }, + }); + + if (!lesson || lesson.module.course.slug !== courseSlug) { + return { success: false, error: "Lesson not found" }; + } + + const enrollment = await db.enrollment.findUnique({ + where: { + userId_courseId: { + userId: user.id, + courseId: lesson.module.courseId, + }, + }, + select: { id: true }, + }); + + if (!enrollment) { + return { success: false, error: "Not enrolled in this course" }; + } + + const existingProgress = await db.userProgress.findUnique({ + where: { + userId_lessonId: { + userId: user.id, + lessonId, + }, + }, + select: { + id: true, + isCompleted: true, + }, + }); + + const nextCompleted = !existingProgress?.isCompleted; + + if (existingProgress) { + await db.userProgress.update({ + where: { id: existingProgress.id }, + data: { + isCompleted: nextCompleted, + finishedAt: nextCompleted ? new Date() : null, + lastPlayedAt: new Date(), + }, + }); + } else { + await db.userProgress.create({ + data: { + userId: user.id, + lessonId, + isCompleted: true, + finishedAt: new Date(), + }, + }); + } + + revalidatePath(`/courses/${courseSlug}/learn`); + + return { success: true, isCompleted: nextCompleted }; +} diff --git a/app/(protected)/courses/[slug]/learn/page.tsx b/app/(protected)/courses/[slug]/learn/page.tsx old mode 100644 new mode 100755 index 1b1cb16..4cd0923 --- a/app/(protected)/courses/[slug]/learn/page.tsx +++ b/app/(protected)/courses/[slug]/learn/page.tsx @@ -1,214 +1,130 @@ -"use client"; +import { notFound, redirect } from "next/navigation"; +import { db } from "@/lib/prisma"; +import { requireUser } from "@/lib/auth/requireUser"; +import StudentClassroomClient from "@/components/courses/StudentClassroomClient"; -import { useEffect, useState } from "react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import LessonRow from "@/components/LessonRow"; -import ProgressBar from "@/components/ProgressBar"; -import { getCourseBySlug } from "@/lib/data/courseCatalog"; -import { - getCourseProgress, - getCourseProgressPercent, - markLessonComplete, - setLastLesson, -} from "@/lib/progress/localProgress"; -import { teacherCoursesUpdatedEventName } from "@/lib/data/teacherCourses"; -import { supabaseBrowser } from "@/lib/supabase/browser"; -import type { Course, Lesson } from "@/types/course"; - -const lessonContent = (lesson: Lesson) => { - if (lesson.type === "video") { - return ( -
-
-
- Video placeholder ({lesson.minutes} min) -
-
- {lesson.videoUrl ? ( -

- Demo video URL: {lesson.videoUrl} -

- ) : null} -
- ); +function getText(value: unknown): string { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object") { + const record = value as Record; + if (typeof record.es === "string") return record.es; + if (typeof record.en === "string") return record.en; } + return ""; +} - if (lesson.type === "reading") { - return ( -
-

- Reading placeholder content for lesson: {lesson.title}. Replace with full lesson text and references in Phase 2. -

-
- ); - } - - return ( -
-

Interactive placeholder for lesson: {lesson.title}.

- -
- ); +type PageProps = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ lesson?: string }>; }; -export default function CourseLearnPage() { - const params = useParams<{ slug: string }>(); - const slug = params.slug; - const [course, setCourse] = useState(() => getCourseBySlug(slug)); - const [hasResolvedCourse, setHasResolvedCourse] = useState(false); +export default async function CourseLearnPage({ params, searchParams }: PageProps) { + const { slug } = await params; + const { lesson: requestedLessonId } = await searchParams; - const [userId, setUserId] = useState("guest"); - const [isAuthed, setIsAuthed] = useState(false); - const [selectedLessonId, setSelectedLessonId] = useState(null); - const [completedLessonIds, setCompletedLessonIds] = useState([]); - const [progress, setProgress] = useState(0); - - useEffect(() => { - const loadCourse = () => { - setCourse(getCourseBySlug(slug)); - setHasResolvedCourse(true); - }; - loadCourse(); - window.addEventListener(teacherCoursesUpdatedEventName, loadCourse); - return () => window.removeEventListener(teacherCoursesUpdatedEventName, loadCourse); - }, [slug]); - - useEffect(() => { - const client = supabaseBrowser(); - if (!client) return; - - client.auth.getUser().then(({ data }) => { - setUserId(data.user?.id ?? "guest"); - setIsAuthed(Boolean(data.user)); - }); - - const { data } = client.auth.onAuthStateChange((_event, session) => { - setUserId(session?.user?.id ?? "guest"); - setIsAuthed(Boolean(session?.user)); - }); - - return () => data.subscription.unsubscribe(); - }, []); - - useEffect(() => { - if (!course) return; - - const progressState = getCourseProgress(userId, course.slug); - const fallback = course.lessons[0]?.id ?? null; - setSelectedLessonId(progressState.lastLessonId ?? fallback); - setCompletedLessonIds(progressState.completedLessonIds); - setProgress(getCourseProgressPercent(userId, course.slug, course.lessons.length)); - }, [course, userId]); - - const completionSet = new Set(completedLessonIds); - - if (!course && !hasResolvedCourse) { - return ( -
-

Loading course...

-
- ); + const user = await requireUser(); + if (!user?.id) { + redirect(`/courses/${slug}`); } + const course = await db.course.findUnique({ + where: { slug }, + select: { + id: true, + slug: true, + title: true, + price: true, + modules: { + orderBy: { orderIndex: "asc" }, + select: { + id: true, + title: true, + lessons: { + orderBy: { orderIndex: "asc" }, + select: { + id: true, + title: true, + description: true, + videoUrl: true, + estimatedDuration: true, + }, + }, + }, + }, + }, + }); + if (!course) { - return ( -
-

Course not found

-

The requested course slug does not exist in mock data.

- - Back to courses - -
- ); + notFound(); } - const selectedLesson = - course.lessons.find((lesson) => lesson.id === selectedLessonId) ?? course.lessons[0]; + let enrollment = await db.enrollment.findUnique({ + where: { + userId_courseId: { + userId: user.id, + courseId: course.id, + }, + }, + select: { id: true }, + }); - if (!selectedLesson) { - return ( -
-

No lessons available

-

This course currently has no lessons configured.

- - Back to course - -
- ); + if (!enrollment) { + const isFree = Number(course.price) === 0; + if (isFree) { + enrollment = await db.enrollment.create({ + data: { + userId: user.id, + courseId: course.id, + amountPaid: 0, + }, + select: { id: true }, + }); + } else { + redirect(`/courses/${slug}`); + } } - const isLocked = (lesson: Lesson) => !lesson.isPreview && !isAuthed; + const completedProgress = await db.userProgress.findMany({ + where: { + userId: user.id, + isCompleted: true, + lesson: { + module: { + courseId: course.id, + }, + }, + }, + select: { + lessonId: true, + }, + }); - const onSelectLesson = (lesson: Lesson) => { - if (isLocked(lesson)) return; - setSelectedLessonId(lesson.id); - setLastLesson(userId, course.slug, lesson.id); - }; + const modules = course.modules.map((module) => ({ + id: module.id, + title: getText(module.title) || "Untitled module", + lessons: module.lessons.map((lesson) => ({ + id: lesson.id, + title: getText(lesson.title) || "Untitled lesson", + description: getText(lesson.description), + videoUrl: lesson.videoUrl, + estimatedDuration: lesson.estimatedDuration, + })), + })); - const onMarkComplete = () => { - if (!selectedLesson) return; - markLessonComplete(userId, course.slug, selectedLesson.id); - const progressState = getCourseProgress(userId, course.slug); - setCompletedLessonIds(progressState.completedLessonIds); - setProgress(getCourseProgressPercent(userId, course.slug, course.lessons.length)); - }; + const flattenedLessonIds = modules.flatMap((module) => module.lessons.map((lesson) => lesson.id)); + const initialSelectedLessonId = + requestedLessonId && flattenedLessonIds.includes(requestedLessonId) + ? requestedLessonId + : flattenedLessonIds[0] ?? ""; return ( -
-
-
-
- - {"<-"} - Back to Course - -

Course Content

-
-
- -
-
-
- -
- {course.lessons.map((lesson, index) => ( -
- onSelectLesson(lesson)} - /> - {completionSet.has(lesson.id) ? ( -

Completed

- ) : null} -
- ))} -
- -
-
-
-

{selectedLesson.title}

-

- {selectedLesson.type} | {selectedLesson.minutes} min -

-
- -
- - {lessonContent(selectedLesson)} -
-
+ progress.lessonId)} + /> ); } diff --git a/app/(protected)/org/page.tsx b/app/(protected)/org/page.tsx new file mode 100644 index 0000000..77cb960 --- /dev/null +++ b/app/(protected)/org/page.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { requireUser } from "@/lib/auth/requireUser"; + +export default async function OrgDashboardPage() { + await requireUser(); + + return ( +
+
+

Organization Dashboard (Placeholder)

+

+ Org admin tools are frontend placeholders for seat management, usage analytics, and assignment flows. +

+

+ Org admin only (access model pending backend) +

+
+ +
+
+

Seats

+

12 / 20 used

+
+
+

Active learners

+

9

+
+
+

Courses assigned

+

4

+
+
+ +
+

Assignment Queue (Placeholder)

+
    +
  • Assign "Contract Analysis Practice" to Team A (disabled)
  • +
  • Invite 3 learners to "Legal English Foundations" (disabled)
  • +
  • Export monthly progress report (disabled)
  • +
+ + Back to home + +
+
+ ); +} diff --git a/app/(protected)/practice/[slug]/page.tsx b/app/(protected)/practice/[slug]/page.tsx old mode 100644 new mode 100755 index 9de279e..1c2c8e6 --- a/app/(protected)/practice/[slug]/page.tsx +++ b/app/(protected)/practice/[slug]/page.tsx @@ -1,19 +1,49 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } 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}`; + +type AttemptRecord = { + completedAt: string; + score: number; + total: number; +}; + export default function PracticeExercisePage() { const params = useParams<{ slug: string }>(); const practiceModule = getPracticeBySlug(params.slug); + const [started, setStarted] = useState(false); const [index, setIndex] = useState(0); const [score, setScore] = useState(0); const [finished, setFinished] = useState(false); const [selected, setSelected] = useState(null); + const [attempts, setAttempts] = useState([]); + + 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([]); + } + }; + + useEffect(() => { + loadAttempts(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [practiceModule?.slug]); if (!practiceModule) { return ( @@ -50,6 +80,20 @@ export default function PracticeExercisePage() { const next = () => { 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); + } setFinished(true); return; } @@ -58,31 +102,120 @@ export default function PracticeExercisePage() { }; const restart = () => { + setStarted(true); setIndex(0); setScore(0); setSelected(null); setFinished(false); }; + const start = () => { + setStarted(true); + setFinished(false); + setIndex(0); + setScore(0); + setSelected(null); + }; + if (finished) { return ( -
-

Exercise complete

-

- Final score: {score}/{total} -

- +
+
+

Practice Results

+

Exercise complete

+

+ Final score: {score}/{total} +

+
+ + + + Back to modules + +
+
+ +
+

Attempt History (Mock)

+ {attempts.length === 0 ? ( +

No attempts recorded yet.

+ ) : ( +
    + {attempts.map((attempt) => ( +
  • + Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()} +
  • + ))} +
+ )} +
+
+ ); + } + + if (!started) { + return ( +
+
+

Practice Session

+

{practiceModule.title}

+

{practiceModule.description}

+
+
+

Questions

+

{total}

+
+
+

Estimated time

+

{Math.max(3, total * 2)} min

+
+
+

Difficulty

+

Intermediate

+
+
+
+ + + Back to modules + +
+
+ +
+

Attempt History (Mock)

+ {attempts.length === 0 ? ( +

No attempts recorded yet for this module.

+ ) : ( +
    + {attempts.map((attempt) => ( +
  • + Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()} +
  • + ))} +
+ )} +
); } return ( -
-
-

Practice and Exercises

-

Master Your Skills

+
+
+
+

+ {practiceModule.title} | Question {index + 1} / {total} +

+

Score: {score}/{total}

+
+
@@ -95,28 +228,33 @@ export default function PracticeExercisePage() { isActive ? "border-brand bg-white shadow-sm" : "border-slate-300 bg-white" }`} > -
{moduleIndex === 0 ? "A/" : moduleIndex === 1 ? "[]" : "O"}
-

{module.title}

-

{module.description}

+
{moduleIndex === 0 ? "A/" : moduleIndex === 1 ? "[]" : "O"}
+

{module.title}

+

{module.description}

); })}
-
-

- Question {index + 1} / {total} -

-

Score: {score}/{total}

-
- +

Attempt History (Mock)

+ {attempts.length === 0 ? ( +

No attempts recorded yet.

+ ) : ( +
    + {attempts.map((attempt) => ( +
  • + Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()} +
  • + ))} +
+ )}
-
-

Spanish Term

-

{current.prompt.replace("Spanish term: ", "")}

+
+

Prompt

+

{current.prompt.replace("Spanish term: ", "")}

@@ -135,7 +273,7 @@ export default function PracticeExercisePage() { return (