Spaces:
Runtime error
Runtime error
| import { create } from "zustand"; | |
| import { persist } from "zustand/middleware"; | |
| import { mockMedicineUnit } from "@/data/mock_medicine"; | |
| import { mockCalculusUnit } from "@/data/mock_calculus"; | |
| /* βββββββββββββββββββ Types βββββββββββββββββββ */ | |
| export interface StageData { | |
| stageId: string; | |
| topic: string; | |
| module: string; | |
| component: string; | |
| skin: string; | |
| config: { | |
| data: Record<string, unknown>; | |
| initialState: Record<string, unknown>; | |
| }; | |
| validation: { type: string; condition: Record<string, unknown> }; | |
| feedback: { success: string; error: string; hint: string }; | |
| } | |
| export interface CourseNode { | |
| id: string; | |
| title: string; | |
| description: string; | |
| type: string; | |
| status: "completed" | "available" | "locked"; | |
| stages: StageData[]; | |
| } | |
| export interface Course { | |
| unitId: string; | |
| unitTitle: string; | |
| nodes: CourseNode[]; | |
| } | |
| export interface RemedyNode { | |
| id: string; | |
| courseId: string; | |
| sourceNodeId: string; | |
| title: string; | |
| status: "available" | "completed"; | |
| } | |
| export interface CourseState { | |
| /* ββ Data ββ */ | |
| courses: Record<string, Course>; // keyed by unitId | |
| /* ββ Progress ββ */ | |
| nodeStatuses: Record<string, "completed" | "available" | "locked">; // keyed by node id | |
| completedNodes: string[]; | |
| /* ββ Remedy ββ */ | |
| remedyNodes: RemedyNode[]; | |
| } | |
| export interface CourseActions { | |
| /* ββ Load ββ */ | |
| loadMockCourses: () => void; | |
| /* ββ Query ββ */ | |
| getCourse: (unitId: string) => Course | undefined; | |
| getNode: (nodeId: string) => CourseNode | undefined; | |
| getNodeStatus: (nodeId: string) => "completed" | "available" | "locked"; | |
| getNodesForCourse: (unitId: string) => CourseNode[]; | |
| getCourseProgress: (unitId: string) => number; // 0-100 | |
| /* ββ Mutations ββ */ | |
| completeNode: (nodeId: string) => void; | |
| unlockNode: (nodeId: string) => void; | |
| resetProgress: () => void; | |
| /* ββ Remedy ββ */ | |
| addRemedyNode: (courseId: string, sourceNodeId: string, sourceTitle: string) => void; | |
| completeRemedyNode: (remedyId: string) => void; | |
| getRemedyNodesForCourse: (courseId: string) => RemedyNode[]; | |
| } | |
| /* βββββββββββββββββββ Helpers βββββββββββββββββββ */ | |
| function buildInitialStatuses(courses: Record<string, Course>): Record<string, string> { | |
| const statuses: Record<string, string> = {}; | |
| for (const course of Object.values(courses)) { | |
| for (const node of course.nodes) { | |
| statuses[node.id] = node.status; | |
| } | |
| } | |
| return statuses; | |
| } | |
| /* βββββββββββββββββββ Store βββββββββββββββββββ */ | |
| const useCourseStore = create<CourseState & CourseActions>()( | |
| persist( | |
| (set, get) => ({ | |
| courses: {}, | |
| nodeStatuses: {}, | |
| completedNodes: [], | |
| remedyNodes: [], | |
| /* ββ Load mock data ββ */ | |
| loadMockCourses: () => { | |
| const medCourse = mockMedicineUnit as unknown as Course; | |
| const calcCourse = mockCalculusUnit as unknown as Course; | |
| const courses: Record<string, Course> = { | |
| [medCourse.unitId]: medCourse, | |
| [calcCourse.unitId]: calcCourse, | |
| }; | |
| // Only initialize statuses if they haven't been set yet | |
| const currentStatuses = get().nodeStatuses; | |
| const hasExisting = Object.keys(currentStatuses).length > 0; | |
| set({ | |
| courses, | |
| nodeStatuses: hasExisting | |
| ? currentStatuses | |
| : (buildInitialStatuses(courses) as Record<string, "completed" | "available" | "locked">), | |
| }); | |
| }, | |
| /* ββ Query ββ */ | |
| getCourse: (unitId) => get().courses[unitId], | |
| getNode: (nodeId) => { | |
| for (const course of Object.values(get().courses)) { | |
| const node = course.nodes.find((n) => n.id === nodeId); | |
| if (node) { | |
| // Merge stored status | |
| return { | |
| ...node, | |
| status: get().nodeStatuses[node.id] ?? node.status, | |
| }; | |
| } | |
| } | |
| return undefined; | |
| }, | |
| getNodeStatus: (nodeId) => | |
| get().nodeStatuses[nodeId] ?? "locked", | |
| getNodesForCourse: (unitId) => { | |
| const course = get().courses[unitId]; | |
| if (!course) return []; | |
| return course.nodes.map((n) => ({ | |
| ...n, | |
| status: get().nodeStatuses[n.id] ?? n.status, | |
| })); | |
| }, | |
| getCourseProgress: (unitId) => { | |
| const course = get().courses[unitId]; | |
| if (!course || course.nodes.length === 0) return 0; | |
| const completed = course.nodes.filter( | |
| (n) => get().nodeStatuses[n.id] === "completed" | |
| ).length; | |
| return Math.round((completed / course.nodes.length) * 100); | |
| }, | |
| /* ββ Mutations ββ */ | |
| completeNode: (nodeId) => { | |
| const state = get(); | |
| // Always mark remedy nodes for this source as completed (regardless of courses loaded) | |
| const updatedRemedyNodes = state.remedyNodes.map((r) => | |
| r.sourceNodeId === nodeId && r.status === "available" | |
| ? { ...r, status: "completed" as const } | |
| : r | |
| ); | |
| const remedyChanged = updatedRemedyNodes.some( | |
| (r, i) => r.status !== state.remedyNodes[i].status | |
| ); | |
| if (remedyChanged) { | |
| set({ remedyNodes: updatedRemedyNodes }); | |
| } | |
| // Find which course this node belongs to | |
| let courseId: string | null = null; | |
| let nodeIndex = -1; | |
| for (const course of Object.values(state.courses)) { | |
| const idx = course.nodes.findIndex((n) => n.id === nodeId); | |
| if (idx !== -1) { | |
| courseId = course.unitId; | |
| nodeIndex = idx; | |
| break; | |
| } | |
| } | |
| if (!courseId || nodeIndex === -1) return; | |
| const course = state.courses[courseId]; | |
| const newStatuses = { ...state.nodeStatuses, [nodeId]: "completed" as const }; | |
| const newCompleted = [...state.completedNodes]; | |
| if (!newCompleted.includes(nodeId)) { | |
| newCompleted.push(nodeId); | |
| } | |
| // Auto-unlock the next node in the same course | |
| if (nodeIndex < course.nodes.length - 1) { | |
| const nextNode = course.nodes[nodeIndex + 1]; | |
| if (newStatuses[nextNode.id] === "locked") { | |
| newStatuses[nextNode.id] = "available"; | |
| } | |
| } | |
| set({ | |
| nodeStatuses: newStatuses, | |
| completedNodes: newCompleted, | |
| remedyNodes: updatedRemedyNodes, | |
| }); | |
| }, | |
| unlockNode: (nodeId) => | |
| set((s) => ({ | |
| nodeStatuses: { ...s.nodeStatuses, [nodeId]: "available" }, | |
| })), | |
| resetProgress: () => { | |
| const courses = get().courses; | |
| set({ | |
| nodeStatuses: buildInitialStatuses(courses) as Record<string, "completed" | "available" | "locked">, | |
| completedNodes: [], | |
| remedyNodes: [], | |
| }); | |
| }, | |
| /* ββ Remedy ββ */ | |
| addRemedyNode: (courseId, sourceNodeId, sourceTitle) => { | |
| const existing = get().remedyNodes; | |
| // Don't add duplicate remedy for same source | |
| if (existing.some((r) => r.sourceNodeId === sourceNodeId && r.status === "available")) return; | |
| const id = `remedy-${sourceNodeId}-${Date.now()}`; | |
| set({ | |
| remedyNodes: [ | |
| ...existing, | |
| { id, courseId, sourceNodeId, title: `π θ£εΌ·: ${sourceTitle}`, status: "available" }, | |
| ], | |
| }); | |
| }, | |
| completeRemedyNode: (remedyId) => { | |
| set({ | |
| remedyNodes: get().remedyNodes.map((r) => | |
| r.id === remedyId ? { ...r, status: "completed" } : r | |
| ), | |
| }); | |
| }, | |
| getRemedyNodesForCourse: (courseId) => | |
| get().remedyNodes.filter((r) => r.courseId === courseId), | |
| }), | |
| { | |
| name: "learn8-courses", | |
| partialize: (state) => ({ | |
| nodeStatuses: state.nodeStatuses, | |
| completedNodes: state.completedNodes, | |
| remedyNodes: state.remedyNodes, | |
| }), | |
| } | |
| ) | |
| ); | |
| export default useCourseStore; | |