Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react";
// slow loading
const VideoPlayerModule = lazy(() => import("./VideoPlayerModule"));
const PDFViewerModule = lazy(() => import("./PDFViewModule"));
// quiz view is inline btw
const QuizView = lazy(() => import("./QuizView"));
interface CoursePlayerWrapperProps {
activity: {
type: string;
Expand Down Expand Up @@ -95,16 +95,15 @@ export default function CoursePlayerWrapper({ activity, onComplete, submitting }
</div>
);
case "quiz":
// can import and use the exisiting quiz view
const quizConfig = content as QuizBlockConfig;
if (!quizConfig.quizQuestions?.length) {
return (
<div className="mt-6 p-6 rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] text-center">
<p className="text-[var(--text-muted)]">No quiz questions have been added to this block yet.</p>
</div>
);
}
return <div className="mt-6 text-[var(--text-muted)] text-center p-6">Quiz renderer — wire in QuizView component here.</div>;
return (
<Suspense fallback={<ModuleSkeleton />}>
<QuizView
config={content as QuizBlockConfig}
onComplete={onComplete}
submitting={submitting}
/>
</Suspense>
);
case "storyline":
return (
<div className="mt-6 space-y-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";

import React, { useState } from "react";
import { CheckCircle, Loader2 } from "lucide-react";
import { QuizBlockConfig } from "@/app/lib/course.types";

interface QuizViewProps {
config: QuizBlockConfig;
onComplete: (points?: number) => void;
submitting: boolean;
}

export default function QuizView({ config, onComplete, submitting }: QuizViewProps) {
const [answers, setAnswers] = useState<Record<number, string>>({});
const [submitted, setSubmitted] = useState(false);

const questions = config.quizQuestions || [];

const handleSubmit = () => {
setSubmitted(true);
const correct = questions.filter((q, i) =>
answers[i]?.trim().toLowerCase() === q.answer?.trim().toLowerCase()
).length;

const accuracy = questions.length > 0 ? (correct / questions.length) * 100 : 0;
// accuracy is used by completeActivity via handleComplete's overridePoints mapping
// handleComplete in page.tsx: accuracy = overridePoints ? Math.round((overridePoints / 30) * 100) : 100
// So we pass points equivalent to accuracy out of 30.
// Wait, the page.tsx expects overridePoints out of 30 if we want to influence accuracy.
// If we pass 10 here, page.tsx will calculate accuracy as (10/30)*100 = 33%.
// So we should pass the accuracy percentage scaled to 30.
const accuracyScaledTo30 = Math.round((accuracy / 100) * 30);
onComplete(accuracyScaledTo30);
};

const allAnswered = questions.every((_, i) => answers[i]?.trim());

if (questions.length === 0) {
return (
<div className="mt-6 p-6 rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] text-center">
<p className="text-[var(--text-muted)]">No quiz questions have been added to this block yet.</p>
</div>
);
}

return (
<div className="mt-6 space-y-6">
{questions.map((q, i) => (
<div key={i} className="bg-[var(--bg-secondary)] rounded-xl p-5 border border-[var(--border-color)]">
<p className="font-bold text-[var(--text-main)] mb-3">{i + 1}. {q.question}</p>
{q.options && q.options.length > 0 ? (
<div className="space-y-2">
{q.options.map((opt: string, oi: number) => (
<label key={oi} className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${answers[i] === opt ? 'bg-blue-500/10 border border-blue-500/50' : 'bg-[var(--bg-primary)] border border-[var(--border-color)] hover:bg-[var(--bg-tertiary)]'}`}>
<input
type="radio"
name={`q-${i}`}
value={opt}
checked={answers[i] === opt}
onChange={() => setAnswers({ ...answers, [i]: opt })}
disabled={submitting || submitted}
className="text-blue-600"
/>
<span className="text-sm text-[var(--text-main)]">{opt}</span>
</label>
))}
</div>
) : (
<textarea
value={answers[i] || ''}
onChange={e => setAnswers({ ...answers, [i]: e.target.value })}
disabled={submitting || submitted}
className="w-full p-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 text-[var(--text-main)]"
rows={3}
placeholder="Your answer..."
/>
)}
</div>
))}
<div className="text-center">
<button
onClick={handleSubmit}
disabled={!allAnswered || submitting || submitted}
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-bold transition-all disabled:opacity-50 inline-flex items-center gap-2"
>
{submitting ? <Loader2 className="animate-spin" size={18} /> : <CheckCircle size={18} />}
{submitting ? 'Submitting...' : 'Submit Answers'}
</button>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useOrganizations } from "@/app/components/organizations/OrganizationCon
import { useAuth } from "@/app/components/AuthCOntext";
import InteractiveSandbox from "@/app/components/InteractiveSandbox";
import { ArrowLeft, CheckCircle, Clock, Zap, Loader2 } from "lucide-react";
import CoursePlayerWrapper from "./activities/[activityid]/CoursePlayerWrapper";
import CoursePlayerWrapper from "./activities/[activityId]/CoursePlayerWrapper";

export default function StudentActivityPage() {
const params = useParams<{ id: string; moduleId: string; activityId: string }>();
Expand Down Expand Up @@ -129,104 +129,13 @@ export default function StudentActivityPage() {
<Clock size={14} /> {activity.durationMinutes || 10} min
</span>
</div>
<h1 className="text-2xl font-extrabold text-slate-900">{activity.title}</h1>
{activity.type === 'sandbox' && (
<div className="mt-6">
<InteractiveSandbox onComplete={(pts) => handleComplete(pts)} />
<div className="mt-4 text-center">
<button
onClick={() => handleComplete(30)}
disabled={submitting}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-xl font-bold transition-all disabled:opacity-50 inline-flex items-center gap-2"
>
{submitting ? <Loader2 className="animate-spin" size={18} /> : <CheckCircle size={18} />}
{submitting ? 'Submitting...' : 'Mark as Complete'}
</button>
</div>
</div>
)}
{activity.type === 'quiz' && activity.content?.quizQuestions && (
<QuizView questions={activity.content.quizQuestions} onComplete={handleComplete} submitting={submitting} />
)}
{(activity.type === 'video' || activity.type === 'storyline') && (
<div className="mt-6 space-y-4">
<div className="bg-slate-50 rounded-xl p-8 text-center">
<p className="text-slate-600">{activity.content?.description || 'Review the material below.'}</p>
</div>
<div className="text-center">
<button
onClick={() => handleComplete(20)}
disabled={submitting}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-bold transition-all disabled:opacity-50 inline-flex items-center gap-2"
>
{submitting ? <Loader2 className="animate-spin" size={18} /> : <CheckCircle size={18} />}
{submitting ? 'Submitting...' : 'Mark as Complete'}
</button>
</div>
</div>
)}
</div>
</div>
);
}

function QuizView({ questions, onComplete, submitting }: { questions: any[]; onComplete: (pts?: number) => void; submitting: boolean }) {
const [answers, setAnswers] = useState<Record<number, string>>({});
const [submitted, setSubmitted] = useState(false);

const handleSubmit = () => {
setSubmitted(true);
const correct = questions.filter((q, i) => answers[i]?.trim().toLowerCase() === q.answer?.trim().toLowerCase()).length;
const accuracy = questions.length > 0 ? Math.round((correct / questions.length) * 100) : 0;
const points = Math.round(accuracy / 10) * 3;
onComplete(points);
};

const allAnswered = questions.every((_, i) => answers[i]?.trim());
<h1 className="text-2xl font-extrabold text-slate-900 mb-6">{activity.title}</h1>

return (
<div className="mt-6 space-y-6">
{questions.map((q, i) => (
<div key={i} className="bg-slate-50 rounded-xl p-5 border border-slate-100">
<p className="font-bold text-slate-800 mb-3">{i + 1}. {q.question}</p>
{q.options ? (
<div className="space-y-2">
{q.options.map((opt: string, oi: number) => (
<label key={oi} className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${answers[i] === opt ? 'bg-blue-100 border border-blue-300' : 'bg-white border border-slate-200 hover:bg-slate-50'}`}>
<input
type="radio"
name={`q-${i}`}
value={opt}
checked={answers[i] === opt}
onChange={() => setAnswers({ ...answers, [i]: opt })}
disabled={submitting}
className="text-blue-600"
/>
<span className="text-sm text-slate-700">{opt}</span>
</label>
))}
</div>
) : (
<textarea
value={answers[i] || ''}
onChange={e => setAnswers({ ...answers, [i]: e.target.value })}
disabled={submitting}
className="w-full p-3 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20"
rows={3}
placeholder="Your answer..."
/>
)}
</div>
))}
<div className="text-center">
<button
onClick={handleSubmit}
disabled={!allAnswered || submitting}
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-bold transition-all disabled:opacity-50 inline-flex items-center gap-2"
>
{submitting ? <Loader2 className="animate-spin" size={18} /> : <CheckCircle size={18} />}
{submitting ? 'Submitting...' : 'Submit Answers'}
</button>
<CoursePlayerWrapper
activity={activity}
onComplete={handleComplete}
submitting={submitting}
/>
</div>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion app/StudentPortal/courses/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default function StudentCourseDetail() {
}

const modules = course.modules ? Object.values(course.modules) : [];
const totalActivities = modules.reduce((acc: number, mod: any) => acc + Object.keys(mod.activityIds || {}).length, 0);

return (
<div className="max-w-4xl mx-auto space-y-8">
Expand All @@ -63,7 +64,7 @@ export default function StudentCourseDetail() {
</div>
<div className="bg-slate-50 rounded-xl p-4 text-center">
<Zap className="mx-auto text-purple-500 mb-1" size={24} />
<div className="text-2xl font-bold text-slate-800">{Object.keys(course.activityIds || {}).length}</div>
<div className="text-2xl font-bold text-slate-800">{totalActivities}</div>
<div className="text-xs text-slate-500 font-medium">Activities</div>
</div>
<div className="bg-slate-50 rounded-xl p-4 text-center">
Expand Down
54 changes: 35 additions & 19 deletions app/StudentPortal/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ interface RecommendedCourse {
export default function StudentDashboard() {
const { announce } = useAccessibility();
const { user, profile } = useAuth();
const { getOrganizationsForStudent, getStudentXp, getXpProgress, getStreak, getOrgCoursesWithData } = useOrganizations();
const { getOrganizationsForStudent, getStudentXp, getXpProgress, getStreak, getOrgCoursesWithData, getCourseProgress } = useOrganizations();
const [orgLevelData, setOrgLevelData] = useState<{ orgName: string; orgId: string; level: number; currentXp: number; nextLevelXp: number } | null>(null);
const [streak, setStreak] = useState<{ current: number; longest: number } | null>(null);
const [allCourses, setAllCourses] = useState<any[]>([]);
const [courseProgress, setCourseProgress] = useState<Record<string, number>>({});

useEffect(() => {
if (!user?.uid) return;
Expand All @@ -44,31 +45,46 @@ export default function StudentDashboard() {
setOrgLevelData({ orgName: org.name, orgId: org.id, ...progress });
const courses = await getOrgCoursesWithData(org.id);
setAllCourses(courses);

const progressData: Record<string, number> = {};
await Promise.all(courses.map(async (course) => {
if (course.id) {
progressData[course.id] = await getCourseProgress(course.id, user.uid);
}
}));
setCourseProgress(progressData);
});
getStreak(user.uid).then(data => setStreak(data));
}, [user?.uid, getOrganizationsForStudent, getStudentXp, getXpProgress, getStreak, getOrgCoursesWithData]);
}, [user?.uid, getOrganizationsForStudent, getStudentXp, getXpProgress, getStreak, getOrgCoursesWithData, getCourseProgress]);

const inProgressCourses = allCourses.filter(c => courseProgress[c.id] > 0 && courseProgress[c.id] < 100);
const notStartedCourses = allCourses.filter(c => !courseProgress[c.id] || courseProgress[c.id] === 0);
const completedCourses = allCourses.filter(c => courseProgress[c.id] === 100);

const continueLearningCourses: ContinueLearningCourse[] = allCourses.length > 0 ? allCourses.slice(0, 3).map(c => ({
const continueLearningCourses: ContinueLearningCourse[] = [...inProgressCourses, ...notStartedCourses].slice(0, 3).map(c => ({
id: c.id,
title: c.title,
status: "in-progress" as const,
progress: 0,
status: "in-progress",
progress: courseProgress[c.id] || 0,
metricText: `${Object.keys(c.modules || {}).length} Modules`,
footerText: "Continue",
})) : [];
footerText: courseProgress[c.id] > 0 ? "Continue" : "Start",
}));

const recommendedCourses: RecommendedCourse[] = allCourses.length > 3 ? allCourses.slice(3).map((c, i) => ({
id: c.id,
title: c.title,
category: (["SCIENCE", "DESIGN", "TECH", "ROBOTICS"])[i % 4] as "SCIENCE" | "DESIGN" | "TECH" | "ROBOTICS",
rating: 4.8,
lessonsCount: Object.keys(c.modules || {}).length,
bgGradient: ["from-blue-900 to-indigo-950", "from-orange-950 to-amber-900", "from-slate-900 to-sky-950", "from-emerald-950 to-teal-900"][i % 4],
})) : [];
const recommendedCourses: RecommendedCourse[] = allCourses
.filter(c => !courseProgress[c.id] || courseProgress[c.id] < 100)
.map((c, i) => ({
id: c.id,
title: c.title,
category: (["SCIENCE", "DESIGN", "TECH", "ROBOTICS"])[i % 4] as "SCIENCE" | "DESIGN" | "TECH" | "ROBOTICS",
rating: 4.8,
lessonsCount: Object.keys(c.modules || {}).length,
bgGradient: ["from-blue-900 to-indigo-950", "from-orange-950 to-amber-900", "from-slate-900 to-sky-950", "from-emerald-950 to-teal-900"][i % 4],
}));
const [carouselIndex, setCarouselIndex] = useState(0);

const handleNextCarousel = () => {
if (carouselIndex < recommendedCourses.length - 1) {
const maxIndex = Math.max(0, recommendedCourses.length - 4);
if (carouselIndex < maxIndex) {
setCarouselIndex((prev) => prev + 1);
announce("Showing next recommended courses");
}
Expand Down Expand Up @@ -275,8 +291,8 @@ export default function StudentDashboard() {
<button
type="button"
onClick={handleNextCarousel}
disabled={carouselIndex >= recommendedCourses.length - 1}
className={`w-[2rem] h-[2rem] rounded-full border border-[var(--border-color)] bg-[var(--bg-primary)] flex items-center justify-center font-bold text-[0.8rem] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#ff6b35] ${carouselIndex >= recommendedCourses.length - 1
disabled={carouselIndex >= Math.max(0, recommendedCourses.length - 4)}
className={`w-[2rem] h-[2rem] rounded-full border border-[var(--border-color)] bg-[var(--bg-primary)] flex items-center justify-center font-bold text-[0.8rem] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#ff6b35] ${carouselIndex >= Math.max(0, recommendedCourses.length - 4)
? "opacity-40 cursor-not-allowed text-[var(--text-muted)]"
: "hover:bg-[#ff6b35] hover:text-white text-[var(--text-main)]"
}`}
Expand All @@ -289,7 +305,7 @@ export default function StudentDashboard() {
<div className="overflow-hidden">
<div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-[1.2rem] transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${carouselIndex * 5}%)` }}
style={{ transform: `translateX(-${carouselIndex * (100 / 4)}%)` }}
>
{recommendedCourses.map((course) => {
const badgeStyles = {
Expand Down
Loading