diff --git a/app/(course)/courses/[slug]/introduction/page.tsx b/app/(course)/courses/[slug]/introduction/page.tsx
index 91277aa..5ce056e 100644
--- a/app/(course)/courses/[slug]/introduction/page.tsx
+++ b/app/(course)/courses/[slug]/introduction/page.tsx
@@ -7,7 +7,7 @@ import { StageHeader } from "@/components/stage/stage-header";
import { StageTabs } from "@/components/stage/stage-tabs";
import { getIntroductionStatus, StageStatus } from "@/types/stage-status";
-import { useCourseContext } from "@/app/(course)/layout";
+import { NotFound } from "@/components/common/not-found";
import {
Callbacks,
CourseAssessment,
@@ -21,37 +21,49 @@ import {
useCreateUserCourse,
useUpdateUserCourse,
} from "@/hooks/use-user-course";
+import { useCourseStore, useIsNewUserCourse } from "@/stores/course-store";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
export default function CourseIntroductionPage() {
const router = useRouter();
-
const { session } = useSession();
- const {
- course,
- userCourse: contextUserCourse,
- isNew,
- updateUserCourse: updateContextUserCourse,
- attempts,
- } = useCourseContext();
- const [userCourse, setUserCourse] = useState(contextUserCourse);
- const status = useMemo(() => getIntroductionStatus(userCourse), [userCourse]);
+ const { course, userCourse, attempts, setUserCourse } = useCourseStore();
+ const isNew = useIsNewUserCourse();
+
+ const [localUserCourse, setLocalUserCourse] = useState(
+ userCourse || {
+ course_slug: course?.slug || "",
+ proficiency: null,
+ cadence: null,
+ accountability: null,
+ started_at: new Date().toISOString(),
+ completed_stage_count: 0,
+ activated: false,
+ repository: "",
+ },
+ );
+
+ const status = useMemo(
+ () => getIntroductionStatus(localUserCourse),
+ [localUserCourse],
+ );
const callbacks: Callbacks = {
onProficiencyChange: (proficiency) => {
- setUserCourse((prev) => ({ ...prev, proficiency }));
+ setLocalUserCourse((prev) => ({ ...prev, proficiency }));
},
onCadenceChange: (cadence) => {
- setUserCourse((prev) => ({ ...prev, cadence }));
+ setLocalUserCourse((prev) => ({ ...prev, cadence }));
},
onAccountabilityChange: (accountability) => {
- setUserCourse((prev) => ({ ...prev, accountability }));
+ setLocalUserCourse((prev) => ({ ...prev, accountability }));
},
};
const navigateToNextStage = (currentStageSlug?: string) => {
+ if (!course) return;
const targetPath = currentStageSlug
? `/courses/${course.slug}/stages/${currentStageSlug}`
: `/courses/${course.slug}/setup`;
@@ -59,14 +71,14 @@ export default function CourseIntroductionPage() {
};
const hasChanges =
- userCourse.proficiency !== contextUserCourse.proficiency ||
- userCourse.cadence !== contextUserCourse.cadence ||
- userCourse.accountability !== contextUserCourse.accountability;
+ localUserCourse.proficiency !== userCourse?.proficiency ||
+ localUserCourse.cadence !== userCourse?.cadence ||
+ localUserCourse.accountability !== userCourse?.accountability;
const { mutate: createUserCourse } = useCreateUserCourse({
onSuccess: (data) => {
console.log("User course created successfully:", data);
- updateContextUserCourse(data);
+ setUserCourse(data);
navigateToNextStage(data.current_stage_slug);
},
onError: (error) => {
@@ -74,10 +86,11 @@ export default function CourseIntroductionPage() {
},
});
- const { mutate: updateUserCourse } = useUpdateUserCourse(course.slug, {
+ const { mutate: updateUserCourse } = useUpdateUserCourse(course?.slug || "", {
onSuccess: () => {
console.log("User course updated successfully");
- navigateToNextStage(userCourse.current_stage_slug);
+ setUserCourse(localUserCourse);
+ navigateToNextStage(localUserCourse.current_stage_slug);
},
onError: (error) => {
console.error("Failed to update user course:", error);
@@ -85,24 +98,28 @@ export default function CourseIntroductionPage() {
});
const handleContinue = () => {
+ if (!course) return;
+
if (isNew) {
createUserCourse({
course_slug: course.slug,
- proficiency: userCourse.proficiency || "beginner",
- cadence: userCourse.cadence || "once_week",
- accountability: userCourse.accountability || false,
+ proficiency: localUserCourse.proficiency || "beginner",
+ cadence: localUserCourse.cadence || "once_week",
+ accountability: localUserCourse.accountability || false,
});
} else if (hasChanges) {
updateUserCourse({
- proficiency: userCourse.proficiency || "beginner",
- cadence: userCourse.cadence || "once_week",
- accountability: userCourse.accountability || false,
+ proficiency: localUserCourse.proficiency || "beginner",
+ cadence: localUserCourse.cadence || "once_week",
+ accountability: localUserCourse.accountability || false,
});
} else {
- navigateToNextStage(userCourse.current_stage_slug);
+ navigateToNextStage(localUserCourse.current_stage_slug);
}
};
+ if (!course) return ;
+
return (
<>
diff --git a/app/(course)/courses/[slug]/setup/page.tsx b/app/(course)/courses/[slug]/setup/page.tsx
index 24a0b46..01aec87 100644
--- a/app/(course)/courses/[slug]/setup/page.tsx
+++ b/app/(course)/courses/[slug]/setup/page.tsx
@@ -8,24 +8,25 @@ import { GenericCard } from "@/components/stage/generic-card";
import { StageHeader } from "@/components/stage/stage-header";
import { StageTabs } from "@/components/stage/stage-tabs";
-import { useCourseContext } from "@/app/(course)/layout";
+import { NotFound } from "@/components/common/not-found";
import { Attempts } from "@/components/course/course-attempts";
import { useSession } from "@/components/provider/auth-provider";
import Overlay from "@/components/stage/overlay";
import { StageCompleted } from "@/components/stage/stage-completed";
import { useUserCourseStatus } from "@/hooks/use-user-course-status";
+import { useCourseStore } from "@/stores/course-store";
import { getSetupStatus, StageStatus } from "@/types/stage-status";
import Link from "next/link";
import { useMemo } from "react";
export default function CourseSetupPage() {
- const { course, userCourse, attempts } = useCourseContext();
+ const { course, userCourse, attempts } = useCourseStore();
const { session } = useSession();
- const projectName = `stackclass-${course.slug}`;
+ const projectName = `stackclass-${course?.slug}`;
const { status: userCourseStatus } = useUserCourseStatus(
- course.slug,
+ course?.slug || "",
userCourse,
);
@@ -35,6 +36,8 @@ export default function CourseSetupPage() {
[userCourseStatus],
);
+ if (!course || !userCourse) return ;
+
return (
<>
();
- const { attempts } = useCourseContext();
+ const { attempts } = useCourseStore();
const { session } = useSession();
const {
diff --git a/app/(course)/layout.tsx b/app/(course)/layout.tsx
index 38529c2..99b66a4 100644
--- a/app/(course)/layout.tsx
+++ b/app/(course)/layout.tsx
@@ -1,158 +1,5 @@
-"use client";
+import CourseLayout from "@/components/layout/course-layout";
-import { redirect, useParams } from "next/navigation";
-import { createContext, useContext, useEffect, useMemo, useState } from "react";
-
-import CourseHeader from "@/components/course/course-header";
-import { CourseSidebar } from "@/components/course/course-sidebar";
-import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
-import { toast } from "sonner";
-
-import { ErrorMessage } from "@/components/common/error-message";
-import { Loading } from "@/components/common/loading";
-import { NotFound } from "@/components/common/not-found";
-import { useSession } from "@/components/provider/auth-provider";
-import { useAttempts, useGetCourse } from "@/hooks/use-course";
-import { useStages } from "@/hooks/use-stage";
-import { useUserCourse } from "@/hooks/use-user-course";
-import { useUserStages } from "@/hooks/use-user-stage";
-import type { CourseDetail, UserCourse } from "@/types/course";
-import type { StageWithState } from "@/types/stage";
-import { Attempt } from "@/types/attempt";
-
-interface CourseContextValue {
- course: CourseDetail;
- userCourse: UserCourse;
- stages: StageWithState[];
- isNew: boolean;
- updateUserCourse: (newUserCourse: UserCourse) => void;
- attempts: Attempt[];
-}
-
-const CourseContext = createContext(null);
-
-export function useCourseContext() {
- const context = useContext(CourseContext);
- if (!context) {
- throw new Error("useCourse must be used within a CourseLayout");
- }
- return context;
-}
-
-export default function CourseLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- const { slug } = useParams<{ slug: string }>();
-
- // Checking if the session is valid. If it's not,
- // we are redirecting to the course overview page.
- const { session, isLoading: sessionLoading } = useSession();
- if (!sessionLoading && !session) {
- toast.error("Please sign in to access this course.");
- redirect(`/courses/${slug}/overview`);
- }
-
- // Fetch course details
- const {
- data: course,
- isLoading: courseLoading,
- error: courseError,
- } = useGetCourse(slug);
-
- // Fetch user course details
- const { data: rawUserCourse, isLoading: userCourseLoading } = useUserCourse(
- slug,
- { retry: false },
- );
-
- // Fetch all stages
- const {
- data: stages,
- isLoading: stagesLoading,
- error: stagesError,
- } = useStages(slug);
-
- // Fetching all user stages for a course
- const { data: userStages } = useUserStages(slug, { retry: false });
-
- const [userCourseData, setUserCourseData] = useState(null);
-
- useEffect(() => {
- if (rawUserCourse) {
- setUserCourseData(rawUserCourse);
- }
- }, [rawUserCourse]);
-
- const updateUserCourse = (newUserCourse: UserCourse) => {
- setUserCourseData(newUserCourse);
- };
-
- const { userCourse, isNew } = useMemo(() => {
- if (userCourseData) return { userCourse: userCourseData, isNew: false };
-
- return {
- userCourse: {
- course_slug: slug,
- proficiency: null,
- cadence: null,
- accountability: null,
- started_at: new Date().toISOString(),
- completed_stage_count: 0,
- activated: false,
- repository: "",
- },
- isNew: true,
- };
- }, [userCourseData, slug]);
-
- const stagesWithState = useMemo(() => {
- if (!stages) return [];
-
- return stages.map((stage) => {
- const userStage =
- userStages?.find((us) => us.stage_slug === stage.slug) || null;
- return { stage, userStage };
- });
- }, [stages, userStages]);
-
- const { data: attempts = [], isLoading: attemptsLoading } = useAttempts(slug);
-
- const isLoading =
- sessionLoading ||
- courseLoading ||
- stagesLoading ||
- userCourseLoading ||
- attemptsLoading;
-
- if (isLoading) return ;
-
- if (courseError)
- return ;
- if (stagesError) return ;
-
- if (!course) return ;
- if (!stages) return ;
-
- const contextValue: CourseContextValue = {
- course,
- userCourse,
- stages: stagesWithState,
- isNew,
- updateUserCourse,
- attempts,
- };
-
- return (
-
-
-
-
-
- {children}
-
-
-
- );
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return {children};
}
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index 5651ec3..6b0070a 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -1,5 +1,5 @@
-import Footer from "@/components/layout/footer";
-import Header from "@/components/layout/header";
+import Footer from "@/components/common/footer";
+import Header from "@/components/common/header";
export default function MainLayout({
children,
diff --git a/components/layout/footer.tsx b/components/common/footer.tsx
similarity index 100%
rename from components/layout/footer.tsx
rename to components/common/footer.tsx
diff --git a/components/layout/header.tsx b/components/common/header.tsx
similarity index 100%
rename from components/layout/header.tsx
rename to components/common/header.tsx
diff --git a/components/course/course-nav-bootstrap.tsx b/components/course/course-nav-bootstrap.tsx
index bfede1f..513af8d 100644
--- a/components/course/course-nav-bootstrap.tsx
+++ b/components/course/course-nav-bootstrap.tsx
@@ -1,19 +1,20 @@
"use client";
-import { useCourseContext } from "@/app/(course)/layout";
+import { ArrowRight, Settings2 } from "lucide-react";
+import Link from "next/link";
+
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useNavigation } from "@/hooks/use-navigation";
+import { useCourseStore } from "@/stores/course-store";
import { getIntroductionStatus, getSetupStatus } from "@/types/stage-status";
-import { ArrowRight, Settings2 } from "lucide-react";
-import Link from "next/link";
import { StatusIcon } from "../stage/stage-status";
export function CourseNavBootstrap({ slug }: { slug: string }) {
- const { userCourse } = useCourseContext();
+ const { userCourse } = useCourseStore();
const { currentSlug } = useNavigation();
const introStatus = getIntroductionStatus(userCourse);
diff --git a/components/course/course-sidebar.tsx b/components/course/course-sidebar.tsx
index d52fcaf..890de40 100644
--- a/components/course/course-sidebar.tsx
+++ b/components/course/course-sidebar.tsx
@@ -19,15 +19,14 @@ import {
} from "@/components/ui/sidebar";
import { Loading } from "@/components/common/loading";
+import { NotFound } from "@/components/common/not-found";
import { CourseNavBootstrap } from "@/components/course/course-nav-bootstrap";
import { CourseNavExtensions } from "@/components/course/course-nav-extensions";
import { CourseNavStages } from "@/components/course/course-nav-stages";
import { CourseSwitcher } from "@/components/course/course-switcher";
-
-import type { StageWithState } from "@/types/stage";
-
-import { useCourseContext } from "@/app/(course)/layout";
import { useExtensions } from "@/hooks/use-extension";
+import { useCourseStore } from "@/stores/course-store";
+import type { StageWithState } from "@/types/stage";
interface ExtensionGroup {
title: string;
@@ -39,7 +38,7 @@ export function CourseSidebar({
...props
}: React.ComponentProps) {
const { toggleSidebar } = useSidebar();
- const { course, stages } = useCourseContext();
+ const { course, stages } = useCourseStore();
const { slug } = useParams<{ slug: string }>();
const [extensionGroups, setExtensionGroups] = React.useState<
@@ -87,6 +86,7 @@ export function CourseSidebar({
const baseStages = stages.filter((stage) => !stage.stage.extension_slug);
if (loading) return ;
+ if (!course) return ;
return (
diff --git a/components/layout/course-layout.tsx b/components/layout/course-layout.tsx
new file mode 100644
index 0000000..27bfd43
--- /dev/null
+++ b/components/layout/course-layout.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { redirect, useParams } from "next/navigation";
+
+import CourseHeader from "@/components/course/course-header";
+import { CourseSidebar } from "@/components/course/course-sidebar";
+import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
+import { toast } from "sonner";
+
+import { ErrorMessage } from "@/components/common/error-message";
+import { Loading } from "@/components/common/loading";
+import { NotFound } from "@/components/common/not-found";
+import { useSession } from "@/components/provider/auth-provider";
+import { useCourseData } from "@/hooks/use-course-data";
+import { useCourseStore } from "@/stores/course-store";
+
+export default function CourseLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const { slug } = useParams<{ slug: string }>();
+
+ // Checking if the session is valid. If it's not,
+ // we are redirecting to the course overview page.
+ const { session, isLoading: sessionLoading } = useSession();
+ if (!sessionLoading && !session) {
+ toast.error("Please sign in to access this course.");
+ redirect(`/courses/${slug}/overview`);
+ }
+
+ const isLoading = useCourseStore((state) => state.isLoading);
+ const error = useCourseStore((state) => state.error);
+ const course = useCourseStore((state) => state.course);
+
+ useCourseData(slug as string);
+
+ if (isLoading || sessionLoading)
+ return ;
+
+ if (error) return ;
+ if (!course) return ;
+
+ return (
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/hooks/use-course-data.ts b/hooks/use-course-data.ts
new file mode 100644
index 0000000..0dc1d57
--- /dev/null
+++ b/hooks/use-course-data.ts
@@ -0,0 +1,101 @@
+import { useEffect } from "react";
+
+import { useAttempts, useGetCourse } from "@/hooks/use-course";
+import { useStages } from "@/hooks/use-stage";
+import { useUserCourse } from "@/hooks/use-user-course";
+import { useUserStages } from "@/hooks/use-user-stage";
+import { useCourseStore } from "@/stores/course-store";
+import { StageWithState } from "@/types/stage";
+
+/**
+ * Hook to sync React Query results directly into zustand store
+ */
+export const useCourseData = (slug: string) => {
+ const {
+ setCourse,
+ setUserCourse,
+ setStages,
+ setAttempts,
+ setLoading,
+ setError,
+ } = useCourseStore();
+
+ const courseQuery = useGetCourse(slug);
+ useEffect(() => {
+ if (
+ courseQuery.data &&
+ courseQuery.data !== useCourseStore.getState().course
+ ) {
+ setCourse(courseQuery.data);
+ }
+ }, [courseQuery.data, setCourse]);
+
+ const userCourseQuery = useUserCourse(slug, { retry: false });
+ useEffect(() => {
+ if (
+ userCourseQuery.data &&
+ userCourseQuery.data !== useCourseStore.getState().userCourse
+ ) {
+ setUserCourse(userCourseQuery.data);
+ }
+ }, [userCourseQuery.data, setUserCourse]);
+
+ const stagesQuery = useStages(slug);
+ const userStagesQuery = useUserStages(slug, { retry: false });
+ useEffect(() => {
+ if (stagesQuery.data && userStagesQuery.data) {
+ const stagesWithState: StageWithState[] = stagesQuery.data.map(
+ (stage) => {
+ const userStage =
+ userStagesQuery.data.find((us) => us.stage_slug === stage.slug) ||
+ null;
+ return { stage, userStage };
+ },
+ );
+
+ if (stagesWithState !== useCourseStore.getState().stages) {
+ setStages(stagesWithState);
+ }
+ }
+ }, [stagesQuery.data, userStagesQuery.data, setStages]);
+
+ const attemptsQuery = useAttempts(slug);
+ useEffect(() => {
+ if (
+ attemptsQuery.data &&
+ attemptsQuery.data !== useCourseStore.getState().attempts
+ ) {
+ setAttempts(attemptsQuery.data);
+ }
+ }, [attemptsQuery.data, setAttempts]);
+
+ useEffect(() => {
+ const loading =
+ courseQuery.isLoading ||
+ userCourseQuery.isLoading ||
+ stagesQuery.isLoading ||
+ attemptsQuery.isLoading;
+ setLoading(loading);
+ }, [
+ courseQuery.isLoading,
+ userCourseQuery.isLoading,
+ stagesQuery.isLoading,
+ attemptsQuery.isLoading,
+ setLoading,
+ ]);
+
+ useEffect(() => {
+ const error = courseQuery.error || stagesQuery.error || attemptsQuery.error;
+ if (error) {
+ setError(error.message || "Failed to load course data");
+ }
+ }, [courseQuery.error, stagesQuery.error, attemptsQuery.error, setError]);
+
+ return {
+ isLoading:
+ courseQuery.isLoading ||
+ userCourseQuery.isLoading ||
+ stagesQuery.isLoading ||
+ attemptsQuery.isLoading,
+ };
+};
diff --git a/hooks/use-navigation.ts b/hooks/use-navigation.ts
index b29d337..f829f6e 100644
--- a/hooks/use-navigation.ts
+++ b/hooks/use-navigation.ts
@@ -1,15 +1,15 @@
"use client";
-import { useCourseContext } from "@/app/(course)/layout";
import {
getIntroductionStatus,
getSetupStatus,
StageStatus,
} from "@/types/stage-status";
import { useParams, usePathname } from "next/navigation";
+import { useCourseStore } from "@/stores/course-store";
export function useNavigation() {
- const { stages, userCourse } = useCourseContext();
+ const { stages, userCourse } = useCourseStore();
const { slug } = useParams<{ slug: string }>();
const pathname = usePathname();
diff --git a/package.json b/package.json
index 6123c59..c23d5a1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "stackclass-frontend",
- "version": "0.24.7",
+ "version": "0.25.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -36,7 +36,8 @@
"react-markdown": "^10.1.0",
"server-only": "^0.0.1",
"sonner": "^2.0.6",
- "tailwind-merge": "^3.3.1"
+ "tailwind-merge": "^3.3.1",
+ "zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/stores/course-store.ts b/stores/course-store.ts
new file mode 100644
index 0000000..7c889c4
--- /dev/null
+++ b/stores/course-store.ts
@@ -0,0 +1,58 @@
+"use client";
+
+import { Attempt } from "@/types/attempt";
+import { CourseDetail, UserCourse } from "@/types/course";
+import { StageWithState } from "@/types/stage";
+import { create } from "zustand";
+
+interface CourseStore {
+ // State
+ course: CourseDetail | null;
+ userCourse: UserCourse | null;
+ stages: StageWithState[];
+ attempts: Attempt[];
+ isLoading: boolean;
+ error: string | null;
+
+ // Actions
+ setCourse: (course: CourseDetail) => void;
+ setUserCourse: (userCourse: UserCourse) => void;
+ setStages: (stages: StageWithState[]) => void;
+ setAttempts: (attempts: Attempt[]) => void;
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ reset: () => void;
+}
+
+export const useCourseStore = create((set) => ({
+ // Initial state
+ course: null,
+ userCourse: null,
+ stages: [],
+ attempts: [],
+ isLoading: false,
+ error: null,
+
+ // Actions
+ setCourse: (course) => set({ course }),
+ setUserCourse: (userCourse) => set({ userCourse }),
+ setStages: (stages) => set({ stages }),
+ setAttempts: (attempts) => set({ attempts }),
+ setLoading: (isLoading) => set({ isLoading }),
+ setError: (error) => set({ error }),
+ reset: () =>
+ set({
+ course: null,
+ userCourse: null,
+ stages: [],
+ attempts: [],
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+// Helper hook to check if user course is new
+export const useIsNewUserCourse = () => {
+ const userCourse = useCourseStore((state) => state.userCourse);
+ return !userCourse || userCourse.proficiency === null;
+};