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; +};