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 (