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; initialState: Record; }; validation: { type: string; condition: Record }; 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; // keyed by unitId /* ── Progress ── */ nodeStatuses: Record; // 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): Record { const statuses: Record = {}; for (const course of Object.values(courses)) { for (const node of course.nodes) { statuses[node.id] = node.status; } } return statuses; } /* ═══════════════════ Store ═══════════════════ */ const useCourseStore = create()( 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 = { [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), }); }, /* ── 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, 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;