First commit

This commit is contained in:
mdares
2026-02-07 18:08:42 -06:00
commit b7a86a2d1c
57 changed files with 9188 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth/requireUser";
const readTeacherEmails = (): string[] =>
(process.env.TEACHER_EMAILS ?? "")
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
export const requireTeacher = async () => {
const user = await requireUser("/teacher");
if (!user?.email) {
redirect("/");
}
const allowed = readTeacherEmails();
if (allowed.length === 0) {
redirect("/");
}
if (!allowed.includes(user.email.toLowerCase())) {
redirect("/");
}
return user;
};

16
lib/auth/requireUser.ts Normal file
View File

@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { supabaseServer } from "@/lib/supabase/server";
export const requireUser = async (redirectTo: string) => {
const supabase = await supabaseServer();
if (!supabase) {
return null;
}
const { data } = await supabase.auth.getUser();
if (!data.user) {
redirect(`/auth/login?redirectTo=${encodeURIComponent(redirectTo)}`);
}
return data.user;
};

20
lib/data/courseCatalog.ts Normal file
View File

@@ -0,0 +1,20 @@
import { mockCourses } from "@/lib/data/mockCourses";
import { getTeacherCourseBySlug, getTeacherCourses } from "@/lib/data/teacherCourses";
import type { Course } from "@/types/course";
export const getAllCourses = (): Course[] => {
const teacherCourses = getTeacherCourses();
if (teacherCourses.length === 0) return mockCourses;
const teacherSlugs = new Set(teacherCourses.map((course) => course.slug));
const baseCourses = mockCourses.filter((course) => !teacherSlugs.has(course.slug));
return [...baseCourses, ...teacherCourses];
};
export const getCourseBySlug = (slug: string): Course | undefined => {
const teacher = getTeacherCourseBySlug(slug);
if (teacher) return teacher;
return mockCourses.find((course) => course.slug === slug);
};
export const getAllCourseSlugs = (): string[] => getAllCourses().map((course) => course.slug);

View File

@@ -0,0 +1,37 @@
import type { CaseStudy } from "@/types/caseStudy";
export const mockCaseStudies: CaseStudy[] = [
{
slug: "acme-v-zenith",
title: "Acme v. Zenith: Non-Compete Enforcement",
citation: "2021 App. Ct. 402",
year: 2021,
summary: "Dispute over enforceability of a cross-state non-compete with broad scope language.",
level: "Intermediate",
topic: "Employment",
keyTerms: ["Reasonableness", "Geographic scope", "Public policy"],
},
{
slug: "state-v-garcia",
title: "State v. Garcia: Evidence Admissibility",
citation: "2019 Sup. Ct. 88",
year: 2019,
summary: "Examines when digital communications meet admissibility and chain-of-custody standards.",
level: "Advanced",
topic: "Criminal Procedure",
keyTerms: ["Authentication", "Hearsay exception", "Prejudice test"],
},
{
slug: "harbor-bank-v-orchid",
title: "Harbor Bank v. Orchid Labs",
citation: "2023 Com. Ct. 110",
year: 2023,
summary: "Breach of financing covenants and acceleration remedies in a distressed credit event.",
level: "Beginner",
topic: "Commercial",
keyTerms: ["Default", "Covenant breach", "Acceleration"],
},
];
export const getCaseStudyBySlug = (slug: string) =>
mockCaseStudies.find((caseStudy) => caseStudy.slug === slug);

84
lib/data/mockCourses.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { Course } from "@/types/course";
export const mockCourses: Course[] = [
{
slug: "legal-english-foundations",
title: "Legal English Foundations",
level: "Beginner",
summary: "Build legal vocabulary, core writing patterns, and court terminology.",
rating: 4.8,
weeks: 4,
lessonsCount: 6,
students: 1240,
instructor: "Ana Morales, Esq.",
lessons: [
{ id: "l1", title: "Introduction to Legal Registers", type: "video", minutes: 12, isPreview: true },
{ id: "l2", title: "Contract Vocabulary Basics", type: "reading", minutes: 10 },
{ id: "l3", title: "Clause Spotting Drill", type: "interactive", minutes: 15 },
{ id: "l4", title: "Writing a Formal Email", type: "reading", minutes: 9 },
{ id: "l5", title: "Courtroom Terminology", type: "video", minutes: 14 },
{ id: "l6", title: "Final Vocabulary Check", type: "interactive", minutes: 18 },
],
},
{
slug: "contract-analysis-practice",
title: "Contract Analysis Practice",
level: "Intermediate",
summary: "Analyze contract sections and identify risk-heavy clauses quickly.",
rating: 4.7,
weeks: 5,
lessonsCount: 7,
students: 820,
instructor: "Daniel Kim, LL.M.",
lessons: [
{ id: "l1", title: "How to Read a Service Agreement", type: "video", minutes: 16, isPreview: true },
{ id: "l2", title: "Indemnity Clauses", type: "reading", minutes: 12 },
{ id: "l3", title: "Liability Cap Walkthrough", type: "video", minutes: 11 },
{ id: "l4", title: "Negotiation Red Flags", type: "interactive", minutes: 17 },
{ id: "l5", title: "Warranty Language", type: "reading", minutes: 8 },
{ id: "l6", title: "Termination Mechanics", type: "video", minutes: 13 },
{ id: "l7", title: "Mini Review Exercise", type: "interactive", minutes: 20 },
],
},
{
slug: "litigation-brief-writing",
title: "Litigation Brief Writing",
level: "Advanced",
summary: "Craft persuasive briefs with strong authority usage and argument flow.",
rating: 4.9,
weeks: 6,
lessonsCount: 8,
students: 430,
instructor: "Priya Shah, J.D.",
lessons: [
{ id: "l1", title: "Brief Structure and Strategy", type: "video", minutes: 19, isPreview: true },
{ id: "l2", title: "Rule Synthesis", type: "reading", minutes: 14 },
{ id: "l3", title: "Fact Framing Techniques", type: "video", minutes: 11 },
{ id: "l4", title: "Citation Signals Deep Dive", type: "reading", minutes: 15 },
{ id: "l5", title: "Counterargument Blocks", type: "interactive", minutes: 18 },
{ id: "l6", title: "Oral Argument Prep", type: "video", minutes: 16 },
{ id: "l7", title: "Editing for Precision", type: "interactive", minutes: 20 },
{ id: "l8", title: "Submission Checklist", type: "reading", minutes: 9 },
],
},
{
slug: "cross-border-ip-strategy",
title: "Cross-Border IP Strategy",
level: "Advanced",
summary: "Understand IP enforcement strategy across multiple jurisdictions.",
rating: 4.6,
weeks: 4,
lessonsCount: 5,
students: 290,
instructor: "Luca Bianchi, Counsel",
lessons: [
{ id: "l1", title: "International IP Map", type: "video", minutes: 13, isPreview: true },
{ id: "l2", title: "Venue and Jurisdiction", type: "reading", minutes: 11 },
{ id: "l3", title: "Enforcement Cost Tradeoffs", type: "interactive", minutes: 14 },
{ id: "l4", title: "Injunction Strategy", type: "video", minutes: 10 },
{ id: "l5", title: "Portfolio Action Plan", type: "interactive", minutes: 17 },
],
},
];
export const getCourseBySlug = (slug: string) => mockCourses.find((course) => course.slug === slug);

45
lib/data/mockPractice.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { PracticeModule } from "@/types/practice";
export const mockPracticeModules: PracticeModule[] = [
{
slug: "translation",
title: "Legal Translation Challenge",
description: "Translate legal terms accurately in context with timed multiple-choice questions.",
isInteractive: true,
questions: [
{
id: "q1",
prompt: "Spanish term: incumplimiento contractual",
choices: ["Contractual compliance", "Breach of contract", "Contract interpretation", "Mutual assent"],
answerIndex: 1,
},
{
id: "q2",
prompt: "Spanish term: medida cautelar",
choices: ["Class action", "Summary judgment", "Injunctive relief", "Arbitration clause"],
answerIndex: 2,
},
{
id: "q3",
prompt: "Spanish term: fuerza mayor",
choices: ["Strict liability", "Force majeure", "Punitive damages", "Specific performance"],
answerIndex: 1,
},
],
},
{
slug: "term-matching",
title: "Term Matching Game",
description: "Pair legal terms with practical definitions.",
isInteractive: false,
},
{
slug: "contract-clauses",
title: "Contract Clause Practice",
description: "Identify weak and risky clause drafting choices.",
isInteractive: false,
},
];
export const getPracticeBySlug = (slug: string) =>
mockPracticeModules.find((module) => module.slug === slug);

133
lib/data/teacherCourses.ts Normal file
View File

@@ -0,0 +1,133 @@
import type { Course, CourseLevel, Lesson } from "@/types/course";
const STORAGE_KEY = "acve.teacher-courses.v1";
const TEACHER_UPDATED_EVENT = "acve:teacher-courses-updated";
export type TeacherCourseInput = {
title: string;
level: CourseLevel;
summary: string;
instructor: string;
weeks: number;
};
export type TeacherCoursePatch = Partial<Omit<Course, "slug" | "lessons">> & {
summary?: string;
title?: string;
level?: CourseLevel;
instructor?: string;
weeks?: number;
};
const isBrowser = () => typeof window !== "undefined";
const readStore = (): Course[] => {
if (!isBrowser()) return [];
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
try {
return JSON.parse(raw) as Course[];
} catch {
return [];
}
};
const writeStore = (courses: Course[]) => {
if (!isBrowser()) return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(courses));
window.dispatchEvent(new Event(TEACHER_UPDATED_EVENT));
};
const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
const lessonId = (index: number) => `lesson-${index}`;
const normalizeCourse = (course: Course): Course => ({
...course,
lessonsCount: course.lessons.length,
});
export const teacherCoursesUpdatedEventName = TEACHER_UPDATED_EVENT;
export const getTeacherCourses = (): Course[] => readStore().map(normalizeCourse);
export const getTeacherCourseBySlug = (slug: string): Course | undefined =>
getTeacherCourses().find((course) => course.slug === slug);
export const ensureUniqueTeacherCourseSlug = (title: string, reservedSlugs: string[]) => {
const baseSlug = slugify(title) || "untitled-course";
let slug = baseSlug;
let index = 2;
const pool = new Set(reservedSlugs.map((item) => item.toLowerCase()));
while (pool.has(slug.toLowerCase())) {
slug = `${baseSlug}-${index}`;
index += 1;
}
return slug;
};
export const createTeacherCourse = (input: TeacherCourseInput, reservedSlugs: string[]) => {
const courses = getTeacherCourses();
const slug = ensureUniqueTeacherCourseSlug(input.title, reservedSlugs);
const starterLesson: Lesson = {
id: lessonId(1),
title: "Course introduction",
type: "video",
minutes: 8,
isPreview: true,
videoUrl: "https://example.com/video-placeholder",
};
const next: Course = {
slug,
title: input.title,
level: input.level,
summary: input.summary,
rating: 5,
weeks: Math.max(1, input.weeks),
lessonsCount: 1,
students: 0,
instructor: input.instructor,
lessons: [starterLesson],
};
writeStore([...courses, next]);
return next;
};
export const updateTeacherCourse = (slug: string, patch: TeacherCoursePatch) => {
const courses = getTeacherCourses();
const nextCourses = courses.map((course) =>
course.slug === slug ? normalizeCourse({ ...course, ...patch }) : course,
);
writeStore(nextCourses);
return nextCourses.find((course) => course.slug === slug);
};
export const addLessonToTeacherCourse = (slug: string, lesson: Omit<Lesson, "id">) => {
const courses = getTeacherCourses();
const nextCourses = courses.map((course) => {
if (course.slug !== slug) return course;
const nextLesson: Lesson = {
...lesson,
id: lessonId(course.lessons.length + 1),
};
return normalizeCourse({
...course,
lessons: [...course.lessons, nextLesson],
});
});
writeStore(nextCourses);
return nextCourses.find((course) => course.slug === slug);
};

View File

@@ -0,0 +1,86 @@
export type CourseProgress = {
lastLessonId: string | null;
completedLessonIds: string[];
};
const STORAGE_KEY = "acve.local-progress.v1";
const PROGRESS_UPDATED_EVENT = "acve:progress-updated";
type ProgressStore = Record<string, Record<string, CourseProgress>>;
const defaultProgress: CourseProgress = {
lastLessonId: null,
completedLessonIds: [],
};
const isBrowser = () => typeof window !== "undefined";
const readStore = (): ProgressStore => {
if (!isBrowser()) return {};
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
try {
return JSON.parse(raw) as ProgressStore;
} catch {
return {};
}
};
const writeStore = (store: ProgressStore) => {
if (!isBrowser()) return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
window.dispatchEvent(new Event(PROGRESS_UPDATED_EVENT));
};
const getOrCreateProgress = (store: ProgressStore, userId: string, courseSlug: string) => {
if (!store[userId]) {
store[userId] = {};
}
if (!store[userId][courseSlug]) {
store[userId][courseSlug] = { ...defaultProgress };
}
return store[userId][courseSlug];
};
export const getCourseProgress = (userId: string, courseSlug: string): CourseProgress => {
const store = readStore();
return store[userId]?.[courseSlug] ?? { ...defaultProgress };
};
export const getCourseProgressPercent = (
userId: string,
courseSlug: string,
totalLessons: number,
): number => {
if (totalLessons <= 0) return 0;
const progress = getCourseProgress(userId, courseSlug);
return Math.round((progress.completedLessonIds.length / totalLessons) * 100);
};
export const setLastLesson = (userId: string, courseSlug: string, lessonId: string) => {
const store = readStore();
const progress = getOrCreateProgress(store, userId, courseSlug);
progress.lastLessonId = lessonId;
writeStore(store);
};
export const markLessonComplete = (userId: string, courseSlug: string, lessonId: string) => {
const store = readStore();
const progress = getOrCreateProgress(store, userId, courseSlug);
if (!progress.completedLessonIds.includes(lessonId)) {
progress.completedLessonIds = [...progress.completedLessonIds, lessonId];
}
progress.lastLessonId = lessonId;
writeStore(store);
};
export const clearCourseProgress = (userId: string, courseSlug: string) => {
const store = readStore();
if (!store[userId]) return;
delete store[userId][courseSlug];
writeStore(store);
};
export const progressUpdatedEventName = PROGRESS_UPDATED_EVENT;

18
lib/supabase/browser.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
let browserClient: SupabaseClient | null = null;
export const supabaseBrowser = (): SupabaseClient | null => {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
return null;
}
if (!browserClient) {
browserClient = createClient(url, anonKey);
}
return browserClient;
};

View File

@@ -0,0 +1,49 @@
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
export type SessionSnapshot = {
response: NextResponse;
isAuthed: boolean;
userEmail: string | null;
isConfigured: boolean;
};
export const updateSession = async (req: NextRequest): Promise<SessionSnapshot> => {
const response = NextResponse.next({ request: req });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
return {
response,
isAuthed: false,
userEmail: null,
isConfigured: false,
};
}
const supabase = createServerClient(url, anonKey, {
cookies: {
getAll: () => req.cookies.getAll(),
setAll: (
cookiesToSet: Array<{
name: string;
value: string;
options?: CookieOptions;
}>,
) => {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
});
const { data } = await supabase.auth.getUser();
return {
response,
isAuthed: Boolean(data?.user),
userEmail: data.user?.email ?? null,
isConfigured: true,
};
};

36
lib/supabase/server.ts Normal file
View File

@@ -0,0 +1,36 @@
import { cookies } from "next/headers";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
export const supabaseServer = async () => {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
return null;
}
const cookieStore = await cookies();
return createServerClient(url, anonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(
cookiesToSet: Array<{
name: string;
value: string;
options?: CookieOptions;
}>,
) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// Server Components may not be able to write cookies.
}
},
},
});
};