(null);
useEffect(() => {
- fetch('/api/admin/stats')
- .then(r => r.json())
- .then(data => { setStats(data); setLoading(false); })
- .catch(() => setLoading(false));
+ const fetchStats = async () => {
+ try {
+ setError(null);
+ const res = await fetch('/api/admin/stats');
+ if (!res.ok) {
+ throw new Error(`Failed to load stats: ${res.status}`);
+ }
+ const data = await res.json();
+ if (data.error) {
+ throw new Error(data.error);
+ }
+ setStats(data);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to load dashboard stats';
+ setError(message);
+ console.error('Admin stats error:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchStats();
}, []);
if (loading) {
@@ -53,6 +71,31 @@ export default function AdminDashboard() {
);
}
+ if (error) {
+ return (
+
+
+
+
+
+ Error Loading Dashboard
+
+
+
+ {error}
+
+
+
+
+ );
+ }
+
return (
{/* Header */}
diff --git a/src/app/api/admin/courses/[courseId]/content-blocks/route.ts b/src/app/api/admin/courses/[courseId]/content-blocks/route.ts
new file mode 100644
index 0000000..5e49864
--- /dev/null
+++ b/src/app/api/admin/courses/[courseId]/content-blocks/route.ts
@@ -0,0 +1,158 @@
+/**
+ * Admin Content Block Management API
+ * POST - Create a content block for a lesson
+ * PUT - Update/reorder content blocks
+ * DELETE - Delete a content block
+ * Requires ADMIN role.
+ */
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma-client";
+import { requireAdmin } from "@/lib/middleware/requireAdmin";
+import { z } from "zod";
+
+const CreateBlockSchema = z.object({
+ lessonId: z.string(),
+ type: z.enum(["TEXT", "VIDEO", "YOUTUBE", "CODE", "QUIZ"]),
+ textContent: z.string().optional(),
+ videoUrl: z.string().optional(),
+ codeLanguage: z.string().optional(),
+ quizData: z.any().optional(),
+});
+
+const UpdateBlockSchema = z.object({
+ blockId: z.string(),
+ type: z.enum(["TEXT", "VIDEO", "YOUTUBE", "CODE", "QUIZ"]).optional(),
+ textContent: z.string().nullable().optional(),
+ videoUrl: z.string().nullable().optional(),
+ codeLanguage: z.string().nullable().optional(),
+ quizData: z.any().optional(),
+ order: z.number().int().min(0).optional(),
+});
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ courseId: string }> }
+) {
+ const check = await requireAdmin();
+ if (check instanceof Response) return check as any;
+ const { courseId } = await params;
+
+ const course = await prisma.course.findUnique({
+ where: { id: courseId },
+ });
+ if (!course) {
+ return NextResponse.json({ error: "Course not found" }, { status: 404 });
+ }
+
+ const body = await request.json();
+ const parse = CreateBlockSchema.safeParse(body);
+ if (!parse.success) {
+ return NextResponse.json(
+ { error: "Validation failed", details: parse.error.issues },
+ { status: 400 }
+ );
+ }
+
+ // Verify lesson belongs to course
+ const lesson = await prisma.lesson.findFirst({
+ where: { id: parse.data.lessonId, courseId },
+ });
+ if (!lesson) {
+ return NextResponse.json({ error: "Lesson not found" }, { status: 404 });
+ }
+
+ const maxOrder = await prisma.contentBlock.aggregate({
+ where: { lessonId: parse.data.lessonId },
+ _max: { order: true },
+ });
+
+ const block = await prisma.contentBlock.create({
+ data: {
+ lessonId: parse.data.lessonId,
+ type: parse.data.type as any,
+ textContent: parse.data.textContent || null,
+ videoUrl: parse.data.videoUrl || null,
+ codeLanguage: parse.data.codeLanguage || null,
+ quizData: parse.data.quizData || undefined,
+ order: (maxOrder._max.order ?? -1) + 1,
+ },
+ });
+
+ return NextResponse.json(block, { status: 201 });
+}
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ courseId: string }> }
+) {
+ const check = await requireAdmin();
+ if (check instanceof Response) return check as any;
+ const { courseId } = await params;
+
+ const course = await prisma.course.findUnique({
+ where: { id: courseId },
+ });
+ if (!course) {
+ return NextResponse.json({ error: "Course not found" }, { status: 404 });
+ }
+
+ const body = await request.json();
+
+ // Batch reorder: { blocks: [{id, order}] }
+ if (body.blocks && Array.isArray(body.blocks)) {
+ const updates = body.blocks.map((b: { id: string; order: number }) =>
+ prisma.contentBlock.update({ where: { id: b.id }, data: { order: b.order } })
+ );
+ await prisma.$transaction(updates);
+ return NextResponse.json({ message: "Blocks reordered" });
+ }
+
+ const parse = UpdateBlockSchema.safeParse(body);
+ if (!parse.success) {
+ return NextResponse.json(
+ { error: "Validation failed", details: parse.error.issues },
+ { status: 400 }
+ );
+ }
+
+ const { blockId, ...updates } = parse.data;
+ const block = await prisma.contentBlock.update({
+ where: { id: blockId },
+ data: {
+ ...(updates.type && { type: updates.type as any }),
+ ...(updates.textContent !== undefined && { textContent: updates.textContent }),
+ ...(updates.videoUrl !== undefined && { videoUrl: updates.videoUrl }),
+ ...(updates.codeLanguage !== undefined && { codeLanguage: updates.codeLanguage }),
+ ...(updates.quizData !== undefined && { quizData: updates.quizData }),
+ ...(updates.order !== undefined && { order: updates.order }),
+ },
+ });
+
+ return NextResponse.json(block);
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ courseId: string }> }
+) {
+ const check = await requireAdmin();
+ if (check instanceof Response) return check as any;
+ const { courseId } = await params;
+
+ const course = await prisma.course.findUnique({
+ where: { id: courseId },
+ });
+ if (!course) {
+ return NextResponse.json({ error: "Course not found" }, { status: 404 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const blockId = searchParams.get("id");
+ if (!blockId) {
+ return NextResponse.json({ error: "Block ID required" }, { status: 400 });
+ }
+
+ await prisma.contentBlock.delete({ where: { id: blockId } });
+
+ return NextResponse.json({ message: "Content block deleted" });
+}
diff --git a/src/app/api/admin/courses/[courseId]/lessons/route.ts b/src/app/api/admin/courses/[courseId]/lessons/route.ts
index e379bd8..1b77494 100644
--- a/src/app/api/admin/courses/[courseId]/lessons/route.ts
+++ b/src/app/api/admin/courses/[courseId]/lessons/route.ts
@@ -14,15 +14,18 @@ import { z } from "zod";
const LessonCreateSchema = z.object({
title: z.string().min(3, "Title must be at least 3 characters"),
- content: z.string().min(1, "Content is required"),
+ content: z.string().optional(),
+ sectionId: z.string().optional(),
duration: z.number().int().positive().optional(),
});
const LessonUpdateSchema = z.object({
lessonId: z.string().min(1),
title: z.string().min(3).optional(),
- content: z.string().min(1).optional(),
+ content: z.string().optional(),
+ sectionId: z.string().nullable().optional(),
duration: z.number().int().positive().nullable().optional(),
+ order: z.number().int().min(0).optional(),
});
export async function GET(
@@ -42,13 +45,59 @@ export async function GET(
slug: true,
status: true,
level: true,
+ sections: {
+ orderBy: { order: "asc" },
+ select: {
+ id: true,
+ title: true,
+ order: true,
+ lessons: {
+ orderBy: { order: "asc" },
+ select: {
+ id: true,
+ title: true,
+ content: true,
+ duration: true,
+ order: true,
+ contentBlocks: {
+ orderBy: { order: "asc" },
+ select: {
+ id: true,
+ type: true,
+ order: true,
+ textContent: true,
+ videoUrl: true,
+ codeLanguage: true,
+ quizData: true,
+ },
+ },
+ createdAt: true,
+ updatedAt: true,
+ },
+ },
+ },
+ },
lessons: {
- orderBy: { createdAt: "asc" },
+ where: { sectionId: null },
+ orderBy: { order: "asc" },
select: {
id: true,
title: true,
content: true,
duration: true,
+ order: true,
+ contentBlocks: {
+ orderBy: { order: "asc" },
+ select: {
+ id: true,
+ type: true,
+ order: true,
+ textContent: true,
+ videoUrl: true,
+ codeLanguage: true,
+ quizData: true,
+ },
+ },
createdAt: true,
updatedAt: true,
},
@@ -86,12 +135,29 @@ export async function POST(
);
}
+ // Verify section exists if provided
+ if (parse.data.sectionId) {
+ const section = await prisma.section.findFirst({
+ where: { id: parse.data.sectionId, courseId },
+ });
+ if (!section) {
+ return NextResponse.json({ error: "Section not found" }, { status: 404 });
+ }
+ }
+
+ const maxOrder = await prisma.lesson.aggregate({
+ where: { courseId, sectionId: parse.data.sectionId || null },
+ _max: { order: true },
+ });
+
const lesson = await prisma.lesson.create({
data: {
courseId,
title: parse.data.title,
- content: parse.data.content,
+ content: parse.data.content || null,
+ sectionId: parse.data.sectionId || null,
duration: parse.data.duration ?? null,
+ order: (maxOrder._max.order ?? -1) + 1,
},
});
@@ -124,7 +190,7 @@ export async function PUT(
);
}
- const { lessonId, title, content, duration } = parse.data;
+ const { lessonId, title, content, sectionId, duration, order } = parse.data;
// Ensure lesson belongs to this course
const existing = await prisma.lesson.findFirst({
@@ -134,12 +200,24 @@ export async function PUT(
return NextResponse.json({ error: "Lesson not found" }, { status: 404 });
}
+ // Verify section exists if provided
+ if (sectionId !== undefined && sectionId !== null) {
+ const section = await prisma.section.findFirst({
+ where: { id: sectionId, courseId },
+ });
+ if (!section) {
+ return NextResponse.json({ error: "Section not found" }, { status: 404 });
+ }
+ }
+
const lesson = await prisma.lesson.update({
where: { id: lessonId },
data: {
...(title !== undefined && { title }),
...(content !== undefined && { content }),
+ ...(sectionId !== undefined && { sectionId }),
...(duration !== undefined && { duration }),
+ ...(order !== undefined && { order }),
},
});
diff --git a/src/app/api/admin/courses/[courseId]/sections/route.ts b/src/app/api/admin/courses/[courseId]/sections/route.ts
new file mode 100644
index 0000000..761c8cd
--- /dev/null
+++ b/src/app/api/admin/courses/[courseId]/sections/route.ts
@@ -0,0 +1,136 @@
+/**
+ * Admin Section Management API
+ * POST - Create a section
+ * PUT - Update/reorder sections
+ * DELETE - Delete a section
+ * Requires ADMIN role.
+ */
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma-client";
+import { requireAdmin } from "@/lib/middleware/requireAdmin";
+import { z } from "zod";
+
+const CreateSectionSchema = z.object({
+ title: z.string().min(1, "Title is required"),
+});
+
+const UpdateSectionSchema = z.object({
+ sectionId: z.string(),
+ title: z.string().min(1).optional(),
+ order: z.number().int().min(0).optional(),
+});
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ courseId: string }> }
+) {
+ const check = await requireAdmin();
+ if (check instanceof Response) return check as any;
+ const { courseId } = await params;
+
+ const course = await prisma.course.findUnique({
+ where: { id: courseId },
+ });
+ if (!course) {
+ return NextResponse.json({ error: "Course not found" }, { status: 404 });
+ }
+
+ const body = await request.json();
+ const parse = CreateSectionSchema.safeParse(body);
+ if (!parse.success) {
+ return NextResponse.json(
+ { error: "Validation failed", details: parse.error.issues },
+ { status: 400 }
+ );
+ }
+
+ const maxOrder = await prisma.section.aggregate({
+ where: { courseId },
+ _max: { order: true },
+ });
+
+ const section = await prisma.section.create({
+ data: {
+ courseId,
+ title: parse.data.title,
+ order: (maxOrder._max.order ?? -1) + 1,
+ },
+ });
+
+ return NextResponse.json(section, { status: 201 });
+}
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ courseId: string }> }
+) {
+ const check = await requireAdmin();
+ if (check instanceof Response) return check as any;
+ const { courseId } = await params;
+
+ const course = await prisma.course.findUnique({
+ where: { id: courseId },
+ });
+ if (!course) {
+ return NextResponse.json({ error: "Course not found" }, { status: 404 });
+ }
+
+ const body = await request.json();
+
+ // Batch reorder: { sections: [{id, order}] }
+ if (body.sections && Array.isArray(body.sections)) {
+ const updates = body.sections.map((s: { id: string; order: number }) =>
+ prisma.section.update({ where: { id: s.id }, data: { order: s.order } })
+ );
+ await prisma.$transaction(updates);
+ return NextResponse.json({ message: "Sections reordered" });
+ }
+
+ const parse = UpdateSectionSchema.safeParse(body);
+ if (!parse.success) {
+ return NextResponse.json(
+ { error: "Validation failed", details: parse.error.issues },
+ { status: 400 }
+ );
+ }
+
+ const { sectionId, ...updates } = parse.data;
+ const section = await prisma.section.update({
+ where: { id: sectionId },
+ data: {
+ ...(updates.title && { title: updates.title }),
+ ...(updates.order !== undefined && { order: updates.order }),
+ },
+ });
+
+ return NextResponse.json(section);
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ courseId: string }> }
+) {
+ const check = await requireAdmin();
+ if (check instanceof Response) return check as any;
+ const { courseId } = await params;
+
+ const course = await prisma.course.findUnique({
+ where: { id: courseId },
+ });
+ if (!course) {
+ return NextResponse.json({ error: "Course not found" }, { status: 404 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const sectionId = searchParams.get("id");
+ if (!sectionId) {
+ return NextResponse.json({ error: "Section ID required" }, { status: 400 });
+ }
+
+ // Delete section and move its lessons to unsectioned
+ await prisma.section.delete({
+ where: { id: sectionId },
+ });
+
+ return NextResponse.json({ message: "Section deleted" });
+}
diff --git a/src/app/api/files/route.ts b/src/app/api/files/route.ts
index 3e4b42d..09946d6 100644
--- a/src/app/api/files/route.ts
+++ b/src/app/api/files/route.ts
@@ -17,7 +17,7 @@ export async function POST(request: Request) {
.keyvalues({ project: "0G_simulation" });
// Build gateway URL for preview/download
-const gatewayUrl = "https://gateway.pinata.cloud";
+const gatewayUrl = "https://ipfs.io";
const fileUrl = `${gatewayUrl}/ipfs/${upload.cid}`;
diff --git a/src/app/api/user/course/claim-nft/route.ts b/src/app/api/user/course/claim-nft/route.ts
index b18a76d..f4a02bb 100644
--- a/src/app/api/user/course/claim-nft/route.ts
+++ b/src/app/api/user/course/claim-nft/route.ts
@@ -86,6 +86,12 @@ export async function POST(request: NextRequest) {
);
}
+ // DEBUG: Log which address is being used
+ logger.info(
+ `Claim NFT: providedAddress=${providedAddress}, selectedAddress=${userAddress}, isPrimary=${user.wallets.find(w => w.isPrimary)?.address}`,
+ "nft-claim-debug"
+ );
+
// 3. Verify course completion
const userCourse = await prisma.userCourse.findUnique({
where: {
diff --git a/src/app/api/user/course/complete/route.ts b/src/app/api/user/course/complete/route.ts
index a68aa5d..a636ae7 100644
--- a/src/app/api/user/course/complete/route.ts
+++ b/src/app/api/user/course/complete/route.ts
@@ -33,7 +33,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
- const { courseSlug } = body;
+ const { courseSlug, quizPassed, quizScore } = body;
if (!courseSlug) {
return NextResponse.json({ error: "Missing courseSlug" }, { status: HttpStatus.BAD_REQUEST });
}
@@ -43,23 +43,72 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Course not found" }, { status: HttpStatus.NOT_FOUND });
}
- // 1. Mark completion in DB first
+ // Get existing progress to check completion state
+ const existingProgress = await prisma.userCourse.findUnique({
+ where: { userId_courseId: { userId: session.user.id, courseId: course.id } }
+ });
+
+ // 1. VALIDATION: Check if course was already completed
+ if (existingProgress?.completed) {
+ logger.warn(
+ `User ${session.user.id} attempted to complete already-completed course ${courseSlug}`,
+ "course-api",
+ { userId: session.user.id, courseSlug, alreadyCompleted: true }
+ );
+ return NextResponse.json(
+ { success: true, message: "Course already completed", userCourse: existingProgress },
+ { status: HttpStatus.OK } // Return OK to be idempotent, but don't re-award XP
+ );
+ }
+
+ // 2. VALIDATION: Verify quiz requirement if provided
+ if (quizPassed === false) {
+ logger.warn(
+ `User ${session.user.id} cannot complete course ${courseSlug} - quiz not passed (score: ${quizScore})`,
+ "course-api",
+ { userId: session.user.id, courseSlug, quizScore }
+ );
+ return NextResponse.json(
+ { success: false, error: "Quiz must be passed to complete course", quizScore },
+ { status: HttpStatus.BAD_REQUEST }
+ );
+ }
+
+ if (quizPassed === true && typeof quizScore !== 'number') {
+ return NextResponse.json(
+ { success: false, error: "Invalid quiz data" },
+ { status: HttpStatus.BAD_REQUEST }
+ );
+ }
+
+ // 3. Mark completion in DB
const userCourse = await prisma.userCourse.upsert({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
- update: { progress: 100, completed: true, finishedAt: new Date() },
- create: { userId: session.user.id, courseId: course.id, progress: 100, completed: true, finishedAt: new Date() }
+ update: {
+ progress: 100,
+ completed: true,
+ quizPassed: quizPassed ?? null,
+ quizScore: quizScore ?? null,
+ finishedAt: new Date()
+ },
+ create: {
+ userId: session.user.id,
+ courseId: course.id,
+ progress: 100,
+ completed: true,
+ quizPassed: quizPassed ?? null,
+ quizScore: quizScore ?? null,
+ finishedAt: new Date()
+ }
});
- // 2. Award XP and update levels (Standard Lesson XP + 50 Bonus for course completion)
+ // 4. Award XP and update levels (Standard Lesson XP + 50 Bonus for course completion)
const bonusXp = 50;
const totalXpAwarded = 10 + bonusXp; // 10 for the final module + 50 for the course
await addXpAndProgress(session.user.id, undefined, totalXpAwarded);
- // 3. Send completion email (only if not already completed before, email is available, and mailer is configured)
- const wasAlreadyCompleted = userCourse.completed &&
- (userCourse as any).finishedAt &&
- new Date((userCourse as any).finishedAt).getTime() < Date.now() - 5000;
-
+ // 5. Send completion email
+ const wasAlreadyCompleted = existingProgress?.completed;
if (!wasAlreadyCompleted && session.user.email && process.env.EMAIL_HOST) {
sendCourseCompletionEmail({
to: session.user.email,
@@ -70,16 +119,25 @@ export async function POST(request: NextRequest) {
}).catch(err => logger.warn('Course completion email failed', 'email', { error: String(err) }));
}
- logger.info(`User ${session.user.id} completed course ${courseSlug}`, "course-api");
+ logger.info(`User ${session.user.id} completed course ${courseSlug}`, "course-api", {
+ userId: session.user.id,
+ courseSlug,
+ quizPassed,
+ quizScore,
+ xpAwarded: totalXpAwarded
+ });
return NextResponse.json({
success: true,
message: "Course marked complete",
userCourse,
- bonusXp
+ xpAwarded: totalXpAwarded
});
} catch (error) {
- logger.error("Course completion Error", "CourseCompleteAPI", undefined, error);
- return NextResponse.json({ error: "Internal server error" }, { status: HttpStatus.INTERNAL_ERROR });
+ logger.error(`Course completion error`, "course-api", undefined, error);
+ return NextResponse.json(
+ { success: false, error: "Internal server error" },
+ { status: HttpStatus.INTERNAL_ERROR }
+ );
}
}
diff --git a/src/app/api/user/course/progress/route.ts b/src/app/api/user/course/progress/route.ts
index 30dc78c..a869b5a 100644
--- a/src/app/api/user/course/progress/route.ts
+++ b/src/app/api/user/course/progress/route.ts
@@ -2,10 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma-client';
-import { logger } from '@/lib/monitoring';
-import { addXpAndProgress } from '@/lib/gamification';
import arcjet, { shield, slidingWindow } from "@/lib/arcjet";
import { HttpStatus } from "@/lib/api-response";
+import { logger } from "@/lib/monitoring";
export async function GET(request: NextRequest) {
try {
@@ -84,20 +83,14 @@ export async function POST(request: NextRequest) {
});
}
- // Check if new modules were completed to award XP
+ // Check if new modules were completed
const existingProgress = await prisma.userCourse.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
- select: { completedModules: true }
+ select: { completedModules: true, completed: true }
});
- const oldCompleted = (existingProgress?.completedModules as any[]) || [];
- const newCompleted = (completedModules as any[]) || [];
-
- // If the new list has more items than the old list, award XP
- // Simple logic: if new items added, award XP once for this update
- if (newCompleted.length > oldCompleted.length) {
- await addXpAndProgress(session.user.id);
- }
+ // NOTE: Do NOT award XP here - only award on /complete endpoint once
+ // This prevents XP duplication and ensures users get XP only on official completion
let progress = 0;
if (typeof completedCount === 'number' && typeof totalModules === 'number') {
diff --git a/src/app/api/user/profile-data/route.ts b/src/app/api/user/profile-data/route.ts
index 573d8b4..5c2c999 100644
--- a/src/app/api/user/profile-data/route.ts
+++ b/src/app/api/user/profile-data/route.ts
@@ -173,6 +173,9 @@ export async function GET(request: Request) {
image: user.image,
role: user.role,
createdAt: user.createdAt,
+ xp: user.xp || 0,
+ level: user.level || 1,
+ streak: user.streak || 0,
ensName: user.wallets.find(w => w.ensName)?.ensName || null,
ensAvatar: user.wallets.find(w => w.ensAvatar)?.ensAvatar || null,
walletAddress: user.wallets[0]?.address || null,
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 8c4af1d..0208ffe 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -64,6 +64,7 @@ interface ProfileData {
metadata: any;
contractAddress: string | null;
transactionHash: string | null;
+ ownerAddress?: string | null;
chainId: number | null;
createdAt: string;
type: string;
@@ -350,7 +351,7 @@ export default function DashboardPage() {
- {n.transactionHash && n.transactionHash.length > 2 && !/^0x0+$/.test(n.transactionHash) &&
}
+ {n.transactionHash && n.transactionHash.length > 2 && !/^0x0+$/.test(n.transactionHash) &&
}
diff --git a/src/app/donate/page.tsx b/src/app/donate/page.tsx
index 7c82124..ac7556e 100644
--- a/src/app/donate/page.tsx
+++ b/src/app/donate/page.tsx
@@ -216,7 +216,7 @@ function DonateContent() {
projectId: projectId || undefined,
amount,
txHash: generatedTxHash,
- chainId: selectedChain.id === 'polygon' ? 80002 : 1,
+ chainId: selectedChain.id === 'polygon' ? 137 : 1,
message: message || undefined,
}),
});
diff --git a/src/app/globals.css b/src/app/globals.css
index 6200cca..2f1b6be 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -220,10 +220,48 @@ h1, h2, h3 {
:root:not(.dark) .via-slate-900 { --tw-gradient-via: var(--card) !important; }
:root:not(.dark) .via-slate-800 { --tw-gradient-via: var(--muted) !important; }
+/* ── Slate-700 backgrounds (inputs, skeletons, loading states) ── */
+:root:not(.dark) .bg-slate-700 { background-color: var(--border) !important; }
+:root:not(.dark) .bg-slate-700\/50 { background-color: color-mix(in srgb, var(--muted) 70%, transparent) !important; }
+:root:not(.dark) .bg-slate-700\/30 { background-color: color-mix(in srgb, var(--muted) 50%, transparent) !important; }
+
+/* ── Gray backgrounds (OAuth buttons, mixed UI) ── */
+:root:not(.dark) .bg-gray-900 { background-color: var(--foreground) !important; }
+:root:not(.dark) .bg-gray-800 { background-color: color-mix(in srgb, var(--foreground) 85%, var(--background)) !important; }
+:root:not(.dark) .bg-gray-50 { background-color: var(--muted) !important; }
+
+/* ── Gray text ── */
+:root:not(.dark) .text-gray-900 { color: var(--foreground) !important; }
+:root:not(.dark) .text-gray-400 { color: var(--muted-foreground) !important; }
+:root:not(.dark) .text-gray-300 { color: color-mix(in srgb, var(--foreground) 70%, var(--muted-foreground)) !important; }
+
+/* ── Gray borders ── */
+:root:not(.dark) .border-gray-300 { border-color: var(--border) !important; }
+:root:not(.dark) .border-gray-700 { border-color: var(--border) !important; }
+
+/* ── Cyan text in light mode — ensure accessible contrast ── */
+:root:not(.dark) .text-cyan-200 { color: #0e7490 !important; }
+:root:not(.dark) .text-cyan-100 { color: #155e75 !important; }
+:root:not(.dark) .text-cyan-400 { color: #0891b2 !important; }
+
+/* ── Emerald / teal text in light mode — ensure accessible contrast ── */
+:root:not(.dark) .text-emerald-200 { color: #047857 !important; }
+:root:not(.dark) .text-emerald-400 { color: #059669 !important; }
+:root:not(.dark) .text-teal-200 { color: #0d9488 !important; }
+
+/* ── Blue-900 based gradient backgrounds (how-it-works, cards) ── */
+:root:not(.dark) .from-blue-900\/20 { --tw-gradient-from: color-mix(in srgb, var(--primary) 8%, transparent) !important; }
+:root:not(.dark) .via-teal-800\/10 { --tw-gradient-via: color-mix(in srgb, var(--secondary) 6%, transparent) !important; }
+:root:not(.dark) .to-emerald-900\/10 { --tw-gradient-to: color-mix(in srgb, #059669 6%, transparent) !important; }
+
/* ── Hover states ── */
:root:not(.dark) .hover\:bg-white\/5:hover { background-color: color-mix(in srgb, var(--muted) 50%, transparent) !important; }
:root:not(.dark) .hover\:bg-white\/10:hover { background-color: color-mix(in srgb, var(--muted) 70%, transparent) !important; }
:root:not(.dark) .hover\:text-white:hover { color: var(--foreground) !important; }
+:root:not(.dark) .hover\:bg-gray-50:hover { background-color: var(--muted) !important; }
+:root:not(.dark) .hover\:bg-gray-900:hover { background-color: color-mix(in srgb, var(--foreground) 90%, var(--background)) !important; }
+:root:not(.dark) .hover\:bg-slate-700:hover { background-color: color-mix(in srgb, var(--muted) 90%, var(--border)) !important; }
+:root:not(.dark) .hover\:bg-emerald-400\/10:hover { background-color: color-mix(in srgb, #059669 10%, transparent) !important; }
/* ── Divide colors ── */
:root:not(.dark) .divide-white\/10 > :not([hidden]) ~ :not([hidden]) { border-color: var(--border) !important; }
@@ -234,10 +272,69 @@ h1, h2, h3 {
/* ── Placeholder text ── */
:root:not(.dark) .placeholder\:text-slate-500::placeholder { color: var(--muted-foreground) !important; }
+:root:not(.dark) .placeholder\:text-slate-400::placeholder { color: var(--muted-foreground) !important; }
+
+/* ── Cyan border opacity variants for light mode ── */
+:root:not(.dark) .border-cyan-400\/20 { border-color: color-mix(in srgb, #0891b2 25%, var(--border)) !important; }
+:root:not(.dark) .border-cyan-400\/40 { border-color: color-mix(in srgb, #0891b2 45%, var(--border)) !important; }
+:root:not(.dark) .border-cyan-300\/20 { border-color: color-mix(in srgb, #0891b2 20%, var(--border)) !important; }
+:root:not(.dark) .border-emerald-300\/30 { border-color: color-mix(in srgb, #059669 30%, var(--border)) !important; }
+:root:not(.dark) .border-teal-300\/30 { border-color: color-mix(in srgb, #0d9488 30%, var(--border)) !important; }
+
+/* ── Cyan / emerald bg opacity variants for light mode ── */
+:root:not(.dark) .bg-cyan-500\/10 { background-color: color-mix(in srgb, #0891b2 10%, var(--card)) !important; }
+:root:not(.dark) .bg-cyan-500\/5 { background-color: color-mix(in srgb, #0891b2 5%, var(--card)) !important; }
+:root:not(.dark) .bg-cyan-400\/20 { background-color: color-mix(in srgb, #0891b2 15%, var(--card)) !important; }
+:root:not(.dark) .bg-emerald-400\/10 { background-color: color-mix(in srgb, #059669 10%, var(--card)) !important; }
+:root:not(.dark) .bg-teal-400\/10 { background-color: color-mix(in srgb, #0d9488 10%, var(--card)) !important; }
+
+/* ── Shadow adjustments for light mode (reduce glow, use soft shadows) ── */
+:root:not(.dark) .shadow-cyan-glow { box-shadow: 0 10px 40px rgba(8, 145, 178, 0.08) !important; }
+:root:not(.dark) .shadow-emerald-glow { box-shadow: 0 10px 40px rgba(5, 150, 105, 0.08) !important; }
+:root:not(.dark) .shadow-cyan-500\/20 { --tw-shadow-color: rgba(8, 145, 178, 0.1) !important; }
+
+/* ── Light mode card elevation and polish ── */
+:root:not(.dark) [data-slot="card"] {
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04) !important;
+}
+:root:not(.dark) [data-slot="card"]:hover {
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04) !important;
+}
/* ── Ensure the global grid is less visible in light mode ── */
:root:not(.dark) canvas[class*="fixed"] {
- opacity: 0.4 !important;
+ opacity: 0.25 !important;
+}
+
+/* ── Light mode gradient text adjustments for readability ── */
+:root:not(.dark) .from-cyan-300\/90 { --tw-gradient-from: #0891b2 !important; }
+:root:not(.dark) .via-slate-100\/85 { --tw-gradient-via: #334155 !important; }
+:root:not(.dark) .to-teal-200\/90 { --tw-gradient-to: #0d9488 !important; }
+
+/* ── Backdrop blur for light mode (softer) ── */
+:root:not(.dark) .backdrop-blur-md { backdrop-filter: blur(8px) !important; }
+:root:not(.dark) .backdrop-blur-xl { backdrop-filter: blur(16px) !important; }
+:root:not(.dark) .backdrop-blur-sm { backdrop-filter: blur(4px) !important; }
+
+/* ── Global UX polish: transitions and interactivity ── */
+[data-slot="button"] {
+ transition: all 0.2s ease-out;
+}
+[data-slot="button"]:active:not(:disabled) {
+ transform: scale(0.98);
+}
+[data-slot="card"] {
+ transition: box-shadow 0.2s ease, border-color 0.2s ease;
+}
+
+/* ── Focus-visible accessibility ring for interactive elements ── */
+:root:not(.dark) [data-slot="button"]:focus-visible {
+ box-shadow: 0 0 0 3px rgba(8, 145, 178, 0.3);
+}
+
+/* ── Light mode: soften animated gradient orbs in hero ── */
+:root:not(.dark) .animate-pulse {
+ animation-duration: 4s;
}
@layer base {
diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx
index 8d50e1d..de79653 100644
--- a/src/app/learn/page.tsx
+++ b/src/app/learn/page.tsx
@@ -206,9 +206,17 @@ export default function CoursesPage() {
{course.category}