From b7a86a2d1cfdedde6bf156552c587774d52a3ba5 Mon Sep 17 00:00:00 2001 From: mdares Date: Sat, 7 Feb 2026 18:08:42 -0600 Subject: [PATCH] First commit --- .env.local.example | 3 + .eslintrc.json | 3 + .gitignore | 10 + app/(auth)/auth/callback/route.ts | 15 + app/(auth)/auth/login/page.tsx | 15 + app/(auth)/auth/signup/page.tsx | 86 + app/(protected)/courses/[slug]/learn/page.tsx | 214 + app/(protected)/practice/[slug]/page.tsx | 159 + .../teacher/courses/[slug]/edit/page.tsx | 13 + .../courses/[slug]/lessons/new/page.tsx | 13 + app/(protected)/teacher/courses/new/page.tsx | 7 + app/(protected)/teacher/page.tsx | 7 + app/(public)/assistant/page.tsx | 11 + app/(public)/case-studies/[slug]/page.tsx | 59 + app/(public)/case-studies/page.tsx | 85 + app/(public)/courses/[slug]/page.tsx | 135 + app/(public)/courses/page.tsx | 115 + app/(public)/page.tsx | 79 + app/(public)/practice/page.tsx | 41 + app/globals.css | 85 + app/layout.tsx | 25 + components/AssistantDrawer.tsx | 115 + components/CourseCard.tsx | 43 + components/Footer.tsx | 10 + components/LessonRow.tsx | 55 + components/Navbar.tsx | 126 + components/ProgressBar.tsx | 17 + components/Tabs.tsx | 26 + components/auth/LoginForm.tsx | 95 + components/teacher/TeacherDashboardClient.tsx | 68 + components/teacher/TeacherEditCourseForm.tsx | 176 + components/teacher/TeacherNewCourseForm.tsx | 112 + components/teacher/TeacherNewLessonForm.tsx | 135 + lib/auth/requireTeacher.ts | 26 + lib/auth/requireUser.ts | 16 + lib/data/courseCatalog.ts | 20 + lib/data/mockCaseStudies.ts | 37 + lib/data/mockCourses.ts | 84 + lib/data/mockPractice.ts | 45 + lib/data/teacherCourses.ts | 133 + lib/progress/localProgress.ts | 86 + lib/supabase/browser.ts | 18 + lib/supabase/middleware.ts | 49 + lib/supabase/server.ts | 36 + middleware.ts | 45 + next-env.d.ts | 6 + next.config.ts | 12 + package-lock.json | 6280 +++++++++++++++++ package.json | 29 + postcss.config.mjs | 8 + public/images/hero-reference.png | Bin 0 -> 1166873 bytes public/images/logo.png | Bin 0 -> 400411 bytes tailwind.config.ts | 22 + tsconfig.json | 28 + types/caseStudy.ts | 12 + types/course.ts | 24 + types/practice.ts | 14 + 57 files changed, 9188 insertions(+) create mode 100644 .env.local.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 app/(auth)/auth/callback/route.ts create mode 100644 app/(auth)/auth/login/page.tsx create mode 100644 app/(auth)/auth/signup/page.tsx create mode 100644 app/(protected)/courses/[slug]/learn/page.tsx create mode 100644 app/(protected)/practice/[slug]/page.tsx create mode 100644 app/(protected)/teacher/courses/[slug]/edit/page.tsx create mode 100644 app/(protected)/teacher/courses/[slug]/lessons/new/page.tsx create mode 100644 app/(protected)/teacher/courses/new/page.tsx create mode 100644 app/(protected)/teacher/page.tsx create mode 100644 app/(public)/assistant/page.tsx create mode 100644 app/(public)/case-studies/[slug]/page.tsx create mode 100644 app/(public)/case-studies/page.tsx create mode 100644 app/(public)/courses/[slug]/page.tsx create mode 100644 app/(public)/courses/page.tsx create mode 100644 app/(public)/page.tsx create mode 100644 app/(public)/practice/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 components/AssistantDrawer.tsx create mode 100644 components/CourseCard.tsx create mode 100644 components/Footer.tsx create mode 100644 components/LessonRow.tsx create mode 100644 components/Navbar.tsx create mode 100644 components/ProgressBar.tsx create mode 100644 components/Tabs.tsx create mode 100644 components/auth/LoginForm.tsx create mode 100644 components/teacher/TeacherDashboardClient.tsx create mode 100644 components/teacher/TeacherEditCourseForm.tsx create mode 100644 components/teacher/TeacherNewCourseForm.tsx create mode 100644 components/teacher/TeacherNewLessonForm.tsx create mode 100644 lib/auth/requireTeacher.ts create mode 100644 lib/auth/requireUser.ts create mode 100644 lib/data/courseCatalog.ts create mode 100644 lib/data/mockCaseStudies.ts create mode 100644 lib/data/mockCourses.ts create mode 100644 lib/data/mockPractice.ts create mode 100644 lib/data/teacherCourses.ts create mode 100644 lib/progress/localProgress.ts create mode 100644 lib/supabase/browser.ts create mode 100644 lib/supabase/middleware.ts create mode 100644 lib/supabase/server.ts create mode 100644 middleware.ts create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/images/hero-reference.png create mode 100644 public/images/logo.png create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types/caseStudy.ts create mode 100644 types/course.ts create mode 100644 types/practice.ts diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..bc808a2 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,3 @@ +NEXT_PUBLIC_SUPABASE_URL=YOUR_URL +NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY +TEACHER_EMAILS=teacher@example.com diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caae059 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +.next +out +dist +.env.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts new file mode 100644 index 0000000..2602626 --- /dev/null +++ b/app/(auth)/auth/callback/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { supabaseServer } from "@/lib/supabase/server"; + +export async function GET(request: Request) { + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + const redirectTo = requestUrl.searchParams.get("redirectTo") ?? "/courses"; + + const supabase = await supabaseServer(); + if (code && supabase) { + await supabase.auth.exchangeCodeForSession(code); + } + + return NextResponse.redirect(new URL(redirectTo, requestUrl.origin)); +} diff --git a/app/(auth)/auth/login/page.tsx b/app/(auth)/auth/login/page.tsx new file mode 100644 index 0000000..ac05314 --- /dev/null +++ b/app/(auth)/auth/login/page.tsx @@ -0,0 +1,15 @@ +import LoginForm from "@/components/auth/LoginForm"; + +type LoginPageProps = { + searchParams: Promise<{ + redirectTo?: string | string[]; + }>; +}; + +export default async function LoginPage({ searchParams }: LoginPageProps) { + const params = await searchParams; + const redirectValue = params.redirectTo; + const redirectTo = Array.isArray(redirectValue) ? redirectValue[0] : redirectValue; + + return ; +} diff --git a/app/(auth)/auth/signup/page.tsx b/app/(auth)/auth/signup/page.tsx new file mode 100644 index 0000000..00b613a --- /dev/null +++ b/app/(auth)/auth/signup/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { supabaseBrowser } from "@/lib/supabase/browser"; + +export default function SignupPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setLoading(true); + + const client = supabaseBrowser(); + if (!client) { + setLoading(false); + setError("Supabase is not configured. Add NEXT_PUBLIC_SUPABASE_* to .env.local."); + return; + } + + const { error: signUpError } = await client.auth.signUp({ email, password }); + setLoading(false); + + if (signUpError) { + setError(signUpError.message); + return; + } + + router.push("/courses"); + }; + + return ( +
+

Sign up

+

Create your account to unlock course player and practice.

+ +
+ + + + + {error ?

{error}

: null} + + +
+ +

+ Already have an account?{" "} + + Login + +

+
+ ); +} diff --git a/app/(protected)/courses/[slug]/learn/page.tsx b/app/(protected)/courses/[slug]/learn/page.tsx new file mode 100644 index 0000000..1b1cb16 --- /dev/null +++ b/app/(protected)/courses/[slug]/learn/page.tsx @@ -0,0 +1,214 @@ +"use client"; + +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} +
+ ); + } + + 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}.

+ +
+ ); +}; + +export default function CourseLearnPage() { + const params = useParams<{ slug: string }>(); + const slug = params.slug; + const [course, setCourse] = useState(() => getCourseBySlug(slug)); + const [hasResolvedCourse, setHasResolvedCourse] = useState(false); + + 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...

+
+ ); + } + + if (!course) { + return ( +
+

Course not found

+

The requested course slug does not exist in mock data.

+ + Back to courses + +
+ ); + } + + const selectedLesson = + course.lessons.find((lesson) => lesson.id === selectedLessonId) ?? course.lessons[0]; + + if (!selectedLesson) { + return ( +
+

No lessons available

+

This course currently has no lessons configured.

+ + Back to course + +
+ ); + } + + const isLocked = (lesson: Lesson) => !lesson.isPreview && !isAuthed; + + const onSelectLesson = (lesson: Lesson) => { + if (isLocked(lesson)) return; + setSelectedLessonId(lesson.id); + setLastLesson(userId, course.slug, lesson.id); + }; + + 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)); + }; + + 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)} +
+
+ ); +} diff --git a/app/(protected)/practice/[slug]/page.tsx b/app/(protected)/practice/[slug]/page.tsx new file mode 100644 index 0000000..9de279e --- /dev/null +++ b/app/(protected)/practice/[slug]/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { 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"; + +export default function PracticeExercisePage() { + const params = useParams<{ slug: string }>(); + const practiceModule = getPracticeBySlug(params.slug); + + const [index, setIndex] = useState(0); + const [score, setScore] = useState(0); + const [finished, setFinished] = useState(false); + const [selected, setSelected] = useState(null); + + if (!practiceModule) { + return ( +
+

Practice module not found

+

The requested practice slug does not exist in mock data.

+ + Back to practice + +
+ ); + } + + if (!practiceModule.isInteractive || !practiceModule.questions?.length) { + return ( +
+

{practiceModule.title}

+

This practice module is scaffolded and will be enabled in a later iteration.

+
+ ); + } + + const total = practiceModule.questions.length; + const current = practiceModule.questions[index]; + const progress = Math.round((index / total) * 100); + + const pick = (choiceIndex: number) => { + if (selected !== null) return; + setSelected(choiceIndex); + if (choiceIndex === current.answerIndex) { + setScore((value) => value + 1); + } + }; + + const next = () => { + if (index + 1 >= total) { + setFinished(true); + return; + } + setIndex((value) => value + 1); + setSelected(null); + }; + + const restart = () => { + setIndex(0); + setScore(0); + setSelected(null); + setFinished(false); + }; + + if (finished) { + return ( +
+

Exercise complete

+

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

+ +
+ ); + } + + return ( +
+
+

Practice and Exercises

+

Master Your Skills

+
+ +
+ {mockPracticeModules.map((module, moduleIndex) => { + const isActive = module.slug === practiceModule.slug; + return ( +
+
{moduleIndex === 0 ? "A/" : moduleIndex === 1 ? "[]" : "O"}
+

{module.title}

+

{module.description}

+
+ ); + })} +
+ +
+
+

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

+

Score: {score}/{total}

+
+ +
+ +
+
+

Spanish Term

+

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

+
+ +
+ {current.choices.map((choice, choiceIndex) => { + const isPicked = selected === choiceIndex; + const isCorrect = choiceIndex === current.answerIndex; + const stateClass = + selected === null + ? "border-slate-300 hover:bg-slate-50" + : isCorrect + ? "border-[#2f9d73] bg-[#edf9f4]" + : isPicked + ? "border-[#bf3c5f] bg-[#fff1f4]" + : "border-slate-300"; + + return ( + + ); + })} +
+ + +
+
+ ); +} diff --git a/app/(protected)/teacher/courses/[slug]/edit/page.tsx b/app/(protected)/teacher/courses/[slug]/edit/page.tsx new file mode 100644 index 0000000..d9a13d5 --- /dev/null +++ b/app/(protected)/teacher/courses/[slug]/edit/page.tsx @@ -0,0 +1,13 @@ +import { requireTeacher } from "@/lib/auth/requireTeacher"; +import TeacherEditCourseForm from "@/components/teacher/TeacherEditCourseForm"; + +type TeacherEditCoursePageProps = { + params: Promise<{ slug: string }>; +}; + +export default async function TeacherEditCoursePage({ params }: TeacherEditCoursePageProps) { + await requireTeacher(); + const { slug } = await params; + + return ; +} diff --git a/app/(protected)/teacher/courses/[slug]/lessons/new/page.tsx b/app/(protected)/teacher/courses/[slug]/lessons/new/page.tsx new file mode 100644 index 0000000..6ce3911 --- /dev/null +++ b/app/(protected)/teacher/courses/[slug]/lessons/new/page.tsx @@ -0,0 +1,13 @@ +import { requireTeacher } from "@/lib/auth/requireTeacher"; +import TeacherNewLessonForm from "@/components/teacher/TeacherNewLessonForm"; + +type TeacherNewLessonPageProps = { + params: Promise<{ slug: string }>; +}; + +export default async function TeacherNewLessonPage({ params }: TeacherNewLessonPageProps) { + await requireTeacher(); + const { slug } = await params; + + return ; +} diff --git a/app/(protected)/teacher/courses/new/page.tsx b/app/(protected)/teacher/courses/new/page.tsx new file mode 100644 index 0000000..70c72ca --- /dev/null +++ b/app/(protected)/teacher/courses/new/page.tsx @@ -0,0 +1,7 @@ +import { requireTeacher } from "@/lib/auth/requireTeacher"; +import TeacherNewCourseForm from "@/components/teacher/TeacherNewCourseForm"; + +export default async function TeacherNewCoursePage() { + await requireTeacher(); + return ; +} diff --git a/app/(protected)/teacher/page.tsx b/app/(protected)/teacher/page.tsx new file mode 100644 index 0000000..cd40099 --- /dev/null +++ b/app/(protected)/teacher/page.tsx @@ -0,0 +1,7 @@ +import { requireTeacher } from "@/lib/auth/requireTeacher"; +import TeacherDashboardClient from "@/components/teacher/TeacherDashboardClient"; + +export default async function TeacherDashboardPage() { + await requireTeacher(); + return ; +} diff --git a/app/(public)/assistant/page.tsx b/app/(public)/assistant/page.tsx new file mode 100644 index 0000000..02469f9 --- /dev/null +++ b/app/(public)/assistant/page.tsx @@ -0,0 +1,11 @@ +import AssistantDrawer from "@/components/AssistantDrawer"; + +export default function AssistantPage() { + return ( +
+

AI Assistant (Demo)

+

This page renders the same assistant UI as the global drawer.

+ +
+ ); +} diff --git a/app/(public)/case-studies/[slug]/page.tsx b/app/(public)/case-studies/[slug]/page.tsx new file mode 100644 index 0000000..f4bb229 --- /dev/null +++ b/app/(public)/case-studies/[slug]/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { getCaseStudyBySlug } from "@/lib/data/mockCaseStudies"; + +export default function CaseStudyDetailPage() { + const params = useParams<{ slug: string }>(); + const caseStudy = getCaseStudyBySlug(params.slug); + + if (!caseStudy) { + return ( +
+

Case study not found

+

The requested case study slug does not exist in mock data.

+ + Back to case studies + +
+ ); + } + + return ( +
+
+

+ {caseStudy.citation} ({caseStudy.year}) +

+

{caseStudy.title}

+

+ Topic: {caseStudy.topic} | Level: {caseStudy.level} +

+
+ +
+

Summary

+

{caseStudy.summary}

+
+ +
+

Key legal terms explained

+
+ {caseStudy.keyTerms.map((term, index) => ( +
+

{term}

+

Legal explanation block {index + 1}

+
+ ))} +
+
+ +
+ + Back to Case Library + +
+
+ ); +} diff --git a/app/(public)/case-studies/page.tsx b/app/(public)/case-studies/page.tsx new file mode 100644 index 0000000..581aeb2 --- /dev/null +++ b/app/(public)/case-studies/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { mockCaseStudies } from "@/lib/data/mockCaseStudies"; + +export default function CaseStudiesPage() { + const [activeSlug, setActiveSlug] = useState(mockCaseStudies[0]?.slug ?? ""); + const activeCase = mockCaseStudies.find((item) => item.slug === activeSlug) ?? mockCaseStudies[0]; + + return ( +
+
+

Case Studies

+

Learn from Landmark Cases

+

+ Real English law cases explained with key legal terms and practical insights. +

+
+ +
+
+ {mockCaseStudies.map((caseStudy) => { + const isActive = caseStudy.slug === activeCase.slug; + return ( + + ); + })} +
+ +
+
+
+

{activeCase.title}

+

+ {activeCase.citation} | {activeCase.year} +

+
+
+

{activeCase.level}

+

{activeCase.topic}

+
+
+ +
+

Case Summary

+

{activeCase.summary}

+
+ +
+

Key Legal Terms Explained

+
+ {activeCase.keyTerms.map((term) => ( +
+

{term}

+

Term explanation will be expanded in phase 2 content.

+
+ ))} +
+
+ + + Open detail page + +
+
+
+ ); +} diff --git a/app/(public)/courses/[slug]/page.tsx b/app/(public)/courses/[slug]/page.tsx new file mode 100644 index 0000000..0a69143 --- /dev/null +++ b/app/(public)/courses/[slug]/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import ProgressBar from "@/components/ProgressBar"; +import { getCourseBySlug } from "@/lib/data/courseCatalog"; +import { getCourseProgressPercent, progressUpdatedEventName } from "@/lib/progress/localProgress"; +import { teacherCoursesUpdatedEventName } from "@/lib/data/teacherCourses"; +import { supabaseBrowser } from "@/lib/supabase/browser"; +import type { Course } from "@/types/course"; + +export default function CourseDetailPage() { + const params = useParams<{ slug: string }>(); + const slug = params.slug; + const [course, setCourse] = useState(() => getCourseBySlug(slug)); + const [hasResolvedCourse, setHasResolvedCourse] = useState(false); + + const [userId, setUserId] = useState("guest"); + const [isAuthed, setIsAuthed] = useState(false); + 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 }) => { + const nextId = data.user?.id ?? "guest"; + setUserId(nextId); + 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 load = () => setProgress(getCourseProgressPercent(userId, course.slug, course.lessons.length)); + load(); + window.addEventListener(progressUpdatedEventName, load); + return () => window.removeEventListener(progressUpdatedEventName, load); + }, [course, userId]); + + if (!course && !hasResolvedCourse) { + return ( +
+

Loading course...

+
+ ); + } + + if (!course) { + return ( +
+

Course not found

+

The requested course slug does not exist in mock data.

+ + Back to courses + +
+ ); + } + + const redirect = `/courses/${course.slug}/learn`; + const loginUrl = `/auth/login?redirectTo=${encodeURIComponent(redirect)}`; + + return ( +
+
+ + {"<-"} + Back to Courses + + +
+ {course.level} + Contract Law +
+ +

{course.title}

+

{course.summary}

+ +
+ Rating {course.rating.toFixed(1)} + {course.students.toLocaleString()} students + {course.weeks} weeks + {course.lessonsCount} lessons +
+
+ + +
+ ); +} diff --git a/app/(public)/courses/page.tsx b/app/(public)/courses/page.tsx new file mode 100644 index 0000000..7f8b527 --- /dev/null +++ b/app/(public)/courses/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import CourseCard from "@/components/CourseCard"; +import Tabs from "@/components/Tabs"; +import { getAllCourses } from "@/lib/data/courseCatalog"; +import { + getCourseProgressPercent, + progressUpdatedEventName, +} from "@/lib/progress/localProgress"; +import { teacherCoursesUpdatedEventName } from "@/lib/data/teacherCourses"; +import { supabaseBrowser } from "@/lib/supabase/browser"; +import type { Course, CourseLevel } from "@/types/course"; + +const levels: CourseLevel[] = ["Beginner", "Intermediate", "Advanced"]; + +export default function CoursesPage() { + const [activeLevel, setActiveLevel] = useState("Beginner"); + const [userId, setUserId] = useState("guest"); + const [progressBySlug, setProgressBySlug] = useState>({}); + const [courses, setCourses] = useState(() => getAllCourses()); + + const counts = useMemo( + () => + levels.reduce( + (acc, level) => { + acc[level] = courses.filter((course) => course.level === level).length; + return acc; + }, + {} as Record, + ), + [courses], + ); + + useEffect(() => { + const loadCourses = () => { + setCourses(getAllCourses()); + }; + + loadCourses(); + window.addEventListener(teacherCoursesUpdatedEventName, loadCourses); + return () => window.removeEventListener(teacherCoursesUpdatedEventName, loadCourses); + }, []); + + useEffect(() => { + const client = supabaseBrowser(); + if (!client) return; + + client.auth.getUser().then(({ data }) => { + setUserId(data.user?.id ?? "guest"); + }); + + const { data } = client.auth.onAuthStateChange((_event, session) => { + setUserId(session?.user?.id ?? "guest"); + }); + + return () => data.subscription.unsubscribe(); + }, []); + + useEffect(() => { + const load = () => { + const nextProgress: Record = {}; + for (const course of courses) { + nextProgress[course.slug] = getCourseProgressPercent(userId, course.slug, course.lessons.length); + } + setProgressBySlug(nextProgress); + }; + + load(); + window.addEventListener(progressUpdatedEventName, load); + window.addEventListener(teacherCoursesUpdatedEventName, load); + return () => { + window.removeEventListener(progressUpdatedEventName, load); + window.removeEventListener(teacherCoursesUpdatedEventName, load); + }; + }, [courses, userId]); + + const filteredCourses = useMemo( + () => courses.filter((course) => course.level === activeLevel), + [activeLevel, courses], + ); + + return ( +
+
+
+ + {counts[activeLevel]} courses in this level +
+
+ +
+
+
C
+
+

{activeLevel} Level Courses

+

+ {activeLevel === "Beginner" + ? "Perfect for those new to English law. Build a strong foundation with fundamental concepts and terminology." + : activeLevel === "Intermediate" + ? "Deepen practical analysis skills with real-world drafting, contract review, and legal communication." + : "Master complex legal reasoning and advanced writing for high-impact legal practice."} +

+
+
+
+ +
+ {filteredCourses.map((course) => ( + + ))} +
+
+ ); +} diff --git a/app/(public)/page.tsx b/app/(public)/page.tsx new file mode 100644 index 0000000..b773a58 --- /dev/null +++ b/app/(public)/page.tsx @@ -0,0 +1,79 @@ +import Image from "next/image"; +import Link from "next/link"; + +const highlights = [ + "Courses designed for Latin American professionals", + "Real English law case studies and analysis", + "AI-powered legal assistant available 24/7", + "Interactive practice exercises and assessments", +]; + +export default function HomePage() { + return ( +
+
+
+
+

+ * + Professional Legal Education +

+

Learn English Law with Confidence

+

+ Courses, case studies, and guided practice designed for Latin American professionals and students. +

+
    + {highlights.map((item) => ( +
  • + + v + + {item} +
  • + ))} +
+ +
+ + Start Learning + + + Explore Courses + +
+
+ +
+
+ ACVE legal library +
+
+

AI

+

Legal Assistant Ready

+

Ask me anything about English Law

+
+
+
+ +
+ + Browse courses + + + Read case studies + + + Practice and exercises + +
+
+
+ ); +} diff --git a/app/(public)/practice/page.tsx b/app/(public)/practice/page.tsx new file mode 100644 index 0000000..8869e96 --- /dev/null +++ b/app/(public)/practice/page.tsx @@ -0,0 +1,41 @@ +import Link from "next/link"; +import { mockPracticeModules } from "@/lib/data/mockPractice"; + +export default function PracticePage() { + return ( +
+
+

Practice and Exercises

+

Master Your Skills

+

+ Interactive exercises designed to reinforce your understanding of English law concepts. +

+
+ +
+ {mockPracticeModules.map((module, index) => ( + +
{index === 0 ? "A/" : index === 1 ? "[]" : "O"}
+

{module.title}

+

{module.description}

+

+ {module.isInteractive ? "Interactive now" : "Coming soon"} +

+ + ))} +
+ +
+

+ Open a module to start the full interactive flow +

+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..29ba0ec --- /dev/null +++ b/app/globals.css @@ -0,0 +1,85 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light; + --acve-bg: #f3f3f5; + --acve-panel: #ffffff; + --acve-ink: #273040; + --acve-muted: #667085; + --acve-line: #d8dbe2; + --acve-brand: #98143f; + --acve-brand-soft: #f8eef2; + --acve-gold: #d4af37; + --acve-heading-font: "Palatino Linotype", "Book Antiqua", "Times New Roman", serif; + --acve-body-font: "Segoe UI", "Trebuchet MS", "Verdana", sans-serif; +} + +html, +body { + margin: 0; + min-height: 100%; + background: radial-gradient(circle at top right, #fcfcfd 0%, #f5f5f7 55%, #f0f0f2 100%); + color: var(--acve-ink); + font-family: var(--acve-body-font); + text-rendering: geometricPrecision; +} + +a { + color: inherit; + text-decoration: none; +} + +h1, +h2, +h3, +h4 { + font-family: var(--acve-heading-font); +} + +::selection { + background: #f2d6df; + color: #421020; +} + +.acve-shell { + background: linear-gradient(180deg, #f7f7f8 0%, #f2f3f5 100%); +} + +.acve-panel { + border: 1px solid var(--acve-line); + background: var(--acve-panel); + border-radius: 16px; +} + +.acve-heading { + color: var(--acve-brand); + font-family: var(--acve-heading-font); + letter-spacing: 0.01em; +} + +.acve-pill { + display: inline-flex; + align-items: center; + border: 1px solid var(--acve-line); + border-radius: 9999px; + background: #fafafa; + color: #384253; + padding: 8px 14px; + font-size: 0.95rem; +} + +.acve-button-primary { + background: var(--acve-brand); + color: #ffffff; + border-radius: 12px; + border: 1px solid var(--acve-brand); +} + +.acve-button-secondary { + border: 1px solid var(--acve-brand); + color: var(--acve-brand); + background: #ffffff; + border-radius: 12px; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..3d4a9ef --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import Navbar from "@/components/Navbar"; +import Footer from "@/components/Footer"; +import AssistantDrawer from "@/components/AssistantDrawer"; + +export const metadata: Metadata = { + title: "ACVE", + description: "ACVE Coursera/Udemy-style legal learning MVP", +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + +
+ +
{children}
+
+
+ + + + ); +} diff --git a/components/AssistantDrawer.tsx b/components/AssistantDrawer.tsx new file mode 100644 index 0000000..3eb2908 --- /dev/null +++ b/components/AssistantDrawer.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; + +type AssistantMessage = { + id: string; + role: "user" | "assistant"; + content: string; +}; + +type AssistantDrawerProps = { + mode?: "global" | "page"; +}; + +export const ASSISTANT_TOGGLE_EVENT = "acve:assistant-toggle"; + +export default function AssistantDrawer({ mode = "global" }: AssistantDrawerProps) { + const [isOpen, setIsOpen] = useState(mode === "page"); + const [messages, setMessages] = useState([ + { + id: "seed", + role: "assistant", + content: "Ask about courses, case studies, or practice. Demo responses are mocked for now.", + }, + ]); + const [input, setInput] = useState(""); + + useEffect(() => { + if (mode === "page") { + return; + } + + const onToggle = () => setIsOpen((current) => !current); + window.addEventListener(ASSISTANT_TOGGLE_EVENT, onToggle); + return () => window.removeEventListener(ASSISTANT_TOGGLE_EVENT, onToggle); + }, [mode]); + + const panelClasses = useMemo(() => { + if (mode === "page") { + return "mx-auto flex h-[70vh] max-w-3xl flex-col rounded-2xl border border-slate-300 bg-white shadow-xl"; + } + + return `fixed right-0 top-0 z-50 h-screen w-full max-w-md border-l border-slate-300 bg-white shadow-2xl transition-transform ${ + isOpen ? "translate-x-0" : "translate-x-full" + }`; + }, [isOpen, mode]); + + const send = (event: FormEvent) => { + event.preventDefault(); + const trimmed = input.trim(); + if (!trimmed) return; + + const now = Date.now().toString(); + setMessages((current) => [ + ...current, + { id: `u-${now}`, role: "user", content: trimmed }, + { id: `a-${now}`, role: "assistant", content: "(Demo) Assistant not connected yet." }, + ]); + setInput(""); + }; + + if (mode === "global" && !isOpen) { + return ( + + ); + } + + return ( + + ); +} diff --git a/components/CourseCard.tsx b/components/CourseCard.tsx new file mode 100644 index 0000000..15ac446 --- /dev/null +++ b/components/CourseCard.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import type { Course } from "@/types/course"; +import ProgressBar from "@/components/ProgressBar"; + +type CourseCardProps = { + course: Course; + progress?: number; +}; + +export default function CourseCard({ course, progress = 0 }: CourseCardProps) { + return ( + +
+
+ {course.level} + Rating {course.rating.toFixed(1)} +
+

{course.title}

+

{course.summary}

+
+ +
+ {progress > 0 ? ( +
+ +
+ ) : null} +
+ {course.weeks} weeks + {course.lessonsCount} lessons +
+
+ {course.students.toLocaleString()} students + {">"} +
+
By {course.instructor}
+
+ + ); +} diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..d5e01c3 --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,10 @@ +export default function Footer() { + return ( +
+
+ ACVE Centro de Estudios + Professional legal English learning +
+
+ ); +} diff --git a/components/LessonRow.tsx b/components/LessonRow.tsx new file mode 100644 index 0000000..834eaa5 --- /dev/null +++ b/components/LessonRow.tsx @@ -0,0 +1,55 @@ +import type { Lesson } from "@/types/course"; + +type LessonRowProps = { + index: number; + lesson: Lesson; + isActive: boolean; + isLocked: boolean; + onSelect: () => void; +}; + +const typeColors: Record = { + video: "bg-[#ffecee] text-[#ca4d6f]", + reading: "bg-[#ecfbf4] text-[#2f9d73]", + interactive: "bg-[#eef4ff] text-[#6288da]", +}; + +export default function LessonRow({ index, lesson, isActive, isLocked, onSelect }: LessonRowProps) { + return ( + + ); +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx new file mode 100644 index 0000000..cd4037e --- /dev/null +++ b/components/Navbar.tsx @@ -0,0 +1,126 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { ASSISTANT_TOGGLE_EVENT } from "@/components/AssistantDrawer"; +import { supabaseBrowser } from "@/lib/supabase/browser"; + +type NavLink = { + href: string; + label: string; +}; + +const navLinks: NavLink[] = [ + { href: "/", label: "Home" }, + { href: "/courses", label: "Courses" }, + { href: "/case-studies", label: "Case Studies" }, + { href: "/practice", label: "Practice" }, +]; + +export default function Navbar() { + const pathname = usePathname(); + const [userEmail, setUserEmail] = useState(null); + + const linkClass = (href: string) => + pathname === href || pathname?.startsWith(`${href}/`) + ? "rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-white shadow-sm" + : "rounded-xl px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:text-brand"; + + useEffect(() => { + const client = supabaseBrowser(); + if (!client) return; + + let mounted = true; + + client.auth.getUser().then(({ data }) => { + if (!mounted) return; + setUserEmail(data.user?.email ?? null); + }); + + const { data } = client.auth.onAuthStateChange((_event, session) => { + setUserEmail(session?.user?.email ?? null); + }); + + return () => { + mounted = false; + data.subscription.unsubscribe(); + }; + }, []); + + const authNode = useMemo(() => { + if (!userEmail) { + return ( +
+ + Login + + + Sign up + +
+ ); + } + + return ( +
+ {userEmail} + +
+ ); + }, [userEmail]); + + return ( +
+
+
+ +
+ ACVE logo +
+
+
ACVE
+
Centro de Estudios
+
+ + +
+ +
+ + {authNode} +
+
+ +
+ ); +} diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx new file mode 100644 index 0000000..d21fe44 --- /dev/null +++ b/components/ProgressBar.tsx @@ -0,0 +1,17 @@ +type ProgressBarProps = { + value: number; + label?: string; +}; + +export default function ProgressBar({ value, label }: ProgressBarProps) { + const clamped = Math.max(0, Math.min(100, value)); + + return ( +
+ {label ?
{label}
: null} +
+
+
+
+ ); +} diff --git a/components/Tabs.tsx b/components/Tabs.tsx new file mode 100644 index 0000000..e283cae --- /dev/null +++ b/components/Tabs.tsx @@ -0,0 +1,26 @@ +type TabsProps = { + options: readonly T[]; + active: T; + onChange: (value: T) => void; +}; + +export default function Tabs({ options, active, onChange }: TabsProps) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx new file mode 100644 index 0000000..9c4d63d --- /dev/null +++ b/components/auth/LoginForm.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { supabaseBrowser } from "@/lib/supabase/browser"; + +type LoginFormProps = { + redirectTo: string; +}; + +const normalizeRedirect = (redirectTo: string) => { + if (!redirectTo.startsWith("/")) return "/courses"; + return redirectTo; +}; + +export default function LoginForm({ redirectTo }: LoginFormProps) { + const router = useRouter(); + const safeRedirect = normalizeRedirect(redirectTo); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setLoading(true); + + const client = supabaseBrowser(); + if (!client) { + setLoading(false); + setError("Supabase is not configured. Add NEXT_PUBLIC_SUPABASE_* to .env.local."); + return; + } + + const { error: signInError } = await client.auth.signInWithPassword({ email, password }); + setLoading(false); + + if (signInError) { + setError(signInError.message); + return; + } + + router.push(safeRedirect); + }; + + return ( +
+

Login

+

Sign in to access protected learning routes.

+ +
+ + + + + {error ?

{error}

: null} + + +
+ +

+ New here?{" "} + + Create an account + +

+
+ ); +} diff --git a/components/teacher/TeacherDashboardClient.tsx b/components/teacher/TeacherDashboardClient.tsx new file mode 100644 index 0000000..80b2047 --- /dev/null +++ b/components/teacher/TeacherDashboardClient.tsx @@ -0,0 +1,68 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { getTeacherCourses, teacherCoursesUpdatedEventName } from "@/lib/data/teacherCourses"; +import type { Course } from "@/types/course"; + +export default function TeacherDashboardClient() { + const [courses, setCourses] = useState([]); + + useEffect(() => { + const load = () => setCourses(getTeacherCourses()); + load(); + window.addEventListener(teacherCoursesUpdatedEventName, load); + return () => window.removeEventListener(teacherCoursesUpdatedEventName, load); + }, []); + + return ( +
+
+
+

Teacher Dashboard

+

Manage teacher-created courses stored locally for MVP.

+
+ + Create course + +
+ + {courses.length === 0 ? ( +
+ No teacher-created courses yet. +
+ ) : ( +
+ {courses.map((course) => ( +
+
+
+

{course.level}

+

{course.title}

+

{course.summary}

+
+
+ + Edit + + + Add lesson + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/components/teacher/TeacherEditCourseForm.tsx b/components/teacher/TeacherEditCourseForm.tsx new file mode 100644 index 0000000..cdcb23f --- /dev/null +++ b/components/teacher/TeacherEditCourseForm.tsx @@ -0,0 +1,176 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { getTeacherCourseBySlug, teacherCoursesUpdatedEventName, updateTeacherCourse } from "@/lib/data/teacherCourses"; +import type { Course, CourseLevel } from "@/types/course"; + +const levels: CourseLevel[] = ["Beginner", "Intermediate", "Advanced"]; + +type TeacherEditCourseFormProps = { + slug: string; +}; + +export default function TeacherEditCourseForm({ slug }: TeacherEditCourseFormProps) { + const router = useRouter(); + const [course, setCourse] = useState(null); + const [title, setTitle] = useState(""); + const [level, setLevel] = useState("Beginner"); + const [summary, setSummary] = useState(""); + const [instructor, setInstructor] = useState(""); + const [weeks, setWeeks] = useState(4); + const [rating, setRating] = useState(5); + const [saved, setSaved] = useState(false); + + useEffect(() => { + const load = () => { + const found = getTeacherCourseBySlug(slug) ?? null; + setCourse(found); + if (!found) return; + setTitle(found.title); + setLevel(found.level); + setSummary(found.summary); + setInstructor(found.instructor); + setWeeks(found.weeks); + setRating(found.rating); + }; + + load(); + window.addEventListener(teacherCoursesUpdatedEventName, load); + return () => window.removeEventListener(teacherCoursesUpdatedEventName, load); + }, [slug]); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!course) return; + + updateTeacherCourse(course.slug, { + title: title.trim(), + level, + summary: summary.trim(), + instructor: instructor.trim(), + weeks: Math.max(1, weeks), + rating: Math.min(5, Math.max(0, rating)), + }); + setSaved(true); + window.setTimeout(() => setSaved(false), 1200); + }; + + if (!course) { + return ( +
+

Teacher course not found

+

This editor only works for courses created in the teacher area.

+ + Back to dashboard + +
+ ); + } + + return ( +
+
+

Edit Course

+ + + + + +