diff --git a/.gitignore b/.gitignore index 1014dfc..4ff60e2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.turbo # Editor directories and files .vscode/* diff --git a/main/package.json b/main/package.json index 2d38ee4..37f392c 100644 --- a/main/package.json +++ b/main/package.json @@ -12,6 +12,7 @@ "prod": "vite build && vite preview" }, "dependencies": { + "@sage/ui": "workspace:*", "@hookform/resolvers": "^4.1.3", "@microsoft/clarity": "^1.0.2", "@radix-ui/react-dialog": "^1.1.15", diff --git a/main/src/components/ChatBotNavbar.tsx b/main/src/components/ChatBotNavbar.tsx index fc1db42..e1b756a 100644 --- a/main/src/components/ChatBotNavbar.tsx +++ b/main/src/components/ChatBotNavbar.tsx @@ -1,112 +1,53 @@ -import { Link, useLocation } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; -import { Route, MessagesSquare, MessageCirclePlus, UserRound } from "lucide-react"; -import { useEffect, useState } from "react"; +import { MessagesSquare } from "lucide-react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; -import { useChatbot } from "../hooks/useChatbot"; -import MobileNavbar from './MobileNavbar'; + DevEnvironmentBanner, + MobileNavbar, + NavBrand, + NavPrimaryLinks, + UserProfileMenu, +} from "@sage/ui"; import ChatSidebarContent from '@/components/chatbot/ChatSidebarContent'; +import { useRouteMode } from "../hooks/useRouteMode"; +import { PRIMARY_NAV_LINKS, MOBILE_NAV_LINKS } from "../lib/navLinks"; const ENVIRONMENT = import.meta.env.VITE_ENVIRONMENT as string | undefined; const ChatBotNavbar = () => { const { user, logout, profilePicture } = useAuth(); - const location = useLocation(); - const { initialLoad } = useChatbot(); - - const [isInWebapp, setIsInWebapp] = useState(false); - useEffect(() => { - initialLoad(); - }, []); - - useEffect(() => { - if (location.pathname === "/" || location.pathname === "/login" || location.pathname === "/signup" || location.pathname === "/forgot-password") { - setIsInWebapp(false); - } else { - setIsInWebapp(true); - } - }, [location]); + const { isInWebapp } = useRouteMode(); + const isDarkMode = !isInWebapp; return ( <> - {/* Desktop navbar */} - {ENVIRONMENT === 'development' && ( -
- Dev Environment -
- )} + - {/* Mobile navbar with chat sidebar */} } + isDarkMode={isDarkMode} + isDevelopment={ENVIRONMENT === 'development'} + user={user} + logout={logout} + sidebarIcon={} sidebarContent={(onClose) => } + navLinks={MOBILE_NAV_LINKS} /> ); }; -export default ChatBotNavbar; \ No newline at end of file +export default ChatBotNavbar; diff --git a/main/src/components/MobileNavbar.tsx b/main/src/components/MobileNavbar.tsx deleted file mode 100644 index 648d7c7..0000000 --- a/main/src/components/MobileNavbar.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { Route, Menu, MessageCirclePlus, UserRound, ArrowLeftFromLine} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - } from "./ui/dropdown-menu"; -import { Link } from "react-router-dom"; -import { useAuth } from "../context/AuthContext"; - -const ENVIRONMENT = import.meta.env.VITE_ENVIRONMENT as string | undefined; - -interface MobileNavbarProps { - isInWebapp: boolean; - sidebarContent?: (onClose: () => void) => React.ReactNode; - showSidebar?: boolean; - sidebarIcon?: React.ReactNode; - } - -const MobileNavbar: React.FC = ({ - isInWebapp, - sidebarContent, - showSidebar = true, - sidebarIcon - }) => { - const { user, logout } = useAuth(); - const [sidebarOpen, setSidebarOpen] = useState(false); - const closeButtonRef = useRef(null); - - useEffect(() => { - if (sidebarOpen) closeButtonRef.current?.focus(); - }, [sidebarOpen]); - - useEffect(() => { - if (!sidebarOpen) return; - const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && setSidebarOpen(false); - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - - }, [sidebarOpen]); - - useEffect(() => { - document.body.style.overflow = sidebarOpen ? "hidden" : ""; - }, [sidebarOpen]); - - return ( - <> - - - {/* Overlay */} - {showSidebar && ( -
setSidebarOpen(false)} - style={{ backgroundColor: "rgba(0,0,0,0.35)" }} - /> - )} - - {/* Sidebar */} - {showSidebar && ( -
- {/* Header */} -
- setSidebarOpen(false)}> - SAGE - - -
- - {/* Content */} - {sidebarContent?.(()=> setSidebarOpen(false))} -
- )} - - ); -}; - -export default MobileNavbar; \ No newline at end of file diff --git a/main/src/components/Navbar.tsx b/main/src/components/Navbar.tsx index 5c849cc..a901632 100644 --- a/main/src/components/Navbar.tsx +++ b/main/src/components/Navbar.tsx @@ -1,209 +1,84 @@ -import { Link, useLocation } from "react-router-dom"; +import { useState, useEffect } from "react"; import { useAuth } from "../context/AuthContext"; -import { Menu, MessageCirclePlus, Route, UserRound} from "lucide-react"; -import { useEffect, useState } from "react"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { + DevEnvironmentBanner, + MobileNavbar, + NavBrand, + NavPrimaryLinks, + UserProfileMenu, +} from "@sage/ui"; +import { useRouteMode } from "../hooks/useRouteMode"; +import { PRIMARY_NAV_LINKS, MOBILE_NAV_LINKS } from "../lib/navLinks"; -// check environment const ENVIRONMENT = import.meta.env.VITE_ENVIRONMENT as string | undefined; const Navbar = () => { const { user, logout, profilePicture } = useAuth(); - const [isInWebapp, setIsInWebapp] = useState(false); - const [isOnboardingActive, setIsOnboardingActive] = useState(false); - - let location = useLocation().pathname; + const [isOnboardingActive, setIsOnboardingActive] = useState(false); + const { isInWebapp } = useRouteMode(); useEffect(() => { const navHeight = ENVIRONMENT === 'development' ? '6rem' : '4.2rem'; document.documentElement.style.setProperty('--nav-height', navHeight); }, []); - useEffect(() => { - if (location === "/" || location === "/login" || location === "/signup" || location === "/forgot-password") { - setIsInWebapp(false); - } - else { - setIsInWebapp(true); - } - }, [location]); - - // onboarding modal active check useEffect(() => { const checkOnboarding = () => { const onboardingActive = document.body.hasAttribute('data-onboarding-active'); setIsOnboardingActive(onboardingActive); }; - + checkOnboarding(); - + const observer = new MutationObserver(checkOnboarding); observer.observe(document.body, { attributes: true, attributeFilter: ['data-onboarding-active'] }); - + return () => observer.disconnect(); }, []); - // Dark mode when: NOT in webapp OR onboarding is active - // Light mode when: in webapp AND onboarding is NOT active const useDarkMode = !isInWebapp || isOnboardingActive; + const useLightNav = !useDarkMode; return ( <> - {/* Standard navbar */} - <> - {ENVIRONMENT === 'development' && ( -
- Dev Environment -
- )} - - - - - {/* Mobile Navbar -- Dropdown navbar when screen width < md (768px) */} + + ); }; -export default Navbar; \ No newline at end of file +export default Navbar; diff --git a/main/src/components/Planner.tsx b/main/src/components/Planner.tsx index 8b33c61..63f017c 100644 --- a/main/src/components/Planner.tsx +++ b/main/src/components/Planner.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; -import Sidebar from "@/components/planner/Sidebar"; +import PlannerSidebarDesktop from "@/components/planner/PlannerSidebarDesktop"; import SemesterBox from "@/components/planner/SemesterBox"; import { HelpCircle, PlusCircle, SquareAsterisk, Save, Check, Loader2, RefreshCw, ChevronDown, Settings, Pencil, Plus, Copy, Trash2, Download } from "lucide-react"; import PlannerNavbar from "./PlannerNavbar"; +import PlannerSidebarMobile from "@/components/planner/PlannerSidebarMobile"; import { DropdownMenu, DropdownMenuContent, @@ -13,7 +14,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "./ui/dropdown-menu"; +} from "@sage/ui"; import { Toaster, toast } from "sonner"; import { calculateCatalogYear, calculateLatestYear, determineStudentType, getGPAState, isCurrentSemester } from "@/utils/studentInfo"; import YearDivider from "@/components/planner/YearDivider"; @@ -927,24 +928,29 @@ const Planner: React.FC = ({ semesters, requirements, transcriptDa <> - handleDropCourse('', -1, null, sourceYear, sourceSemesterIndex, courseId, false) - } - placedSuggestedCourses={placedSuggestedCourses} - allCompletedCourseCodes={allCompletedCourseCodes} - allPlannedCoursesWithOrder={allPlannedCoursesWithOrder} - onRestartOnboarding={onRestartOnboarding} - availableSemesters={availableSemesters} - onAddCourse={handleDropCourse} - semesters={allSemesters} - coursebookData={coursebookData} - gradesData={gradesData} - onOpenDiscovery={() => setShowDiscovery(true)} - coursebookSemester={coursebookSemester} + sidebarContent={(onClose) => ( + + handleDropCourse('', -1, null, sourceYear, sourceSemesterIndex, courseId, false) + } + placedSuggestedCourses={placedSuggestedCourses} + allCompletedCourseCodes={allCompletedCourseCodes} + allPlannedCoursesWithOrder={allPlannedCoursesWithOrder} + onRestartOnboarding={onRestartOnboarding} + availableSemesters={availableSemesters} + onAddCourse={handleDropCourse} + semesters={allSemesters} + coursebookData={coursebookData} + gradesData={gradesData} + onOpenDiscovery={() => setShowDiscovery(true)} + coursebookSemester={coursebookSemester} + /> + )} />
{/* Action buttons */} @@ -1002,7 +1008,7 @@ const Planner: React.FC = ({ semesters, requirements, transcriptDa
- ; - onToggleCategory: (index: number) => void; - transcriptData: any; - onDropCourse?: (courseId: string, sourceYear: string, sourceSemesterIndex: number) => void; - placedSuggestedCourses?: Set; - allCompletedCourseCodes?: string[]; - allPlannedCoursesWithOrder?: Array<{ - code: string; - yearKey: string; - semesterIndex: number; - semesterOrder: number; - }>; - availableSemesters?: Array<{yearKey: string, semesterIndex: number, title: string}>; - onAddCourse?: (targetYear: string, targetSemesterIndex: number, course: any, sourceYear: string, sourceSemesterIndex: number, courseId?: string, isSuggested?: boolean) => void; - onRestartOnboarding?: () => void; - semesters?: import("@/utils/plannerCredits").SemestersForCredits; - coursebookData?: Record; - gradesData?: Record; - onOpenDiscovery?: () => void; - coursebookSemester?: string | null + sidebarContent: (onClose: () => void) => ReactNode; } -const PlannerNavbar: React.FC = ({ - requirements, - expandedCategories, - onToggleCategory, - transcriptData, - onDropCourse, - placedSuggestedCourses, - allCompletedCourseCodes = [], - allPlannedCoursesWithOrder = [], - onRestartOnboarding, - availableSemesters, - onAddCourse, - semesters, - coursebookData, - gradesData, - onOpenDiscovery, - coursebookSemester, -}) => { +const PlannerNavbar: React.FC = ({ sidebarContent }) => { const { user, logout, profilePicture } = useAuth(); - const location = useLocation(); - - const [isInWebapp, setIsInWebapp] = useState(false); - - useEffect(() => { - if (location.pathname === "/" || location.pathname === "/login" || location.pathname === "/signup" || location.pathname === "/forgot-password") { - setIsInWebapp(false); - } else { - setIsInWebapp(true); - } - }, [location]); + const { isInWebapp } = useRouteMode(); + const isDarkMode = !isInWebapp; return ( <> - {/* Desktop navbar */} - {ENVIRONMENT === 'development' && ( -
- Dev Environment -
- )} + - {/* Mobile navbar with sidebar */} } - sidebarContent={(onClose) => ( - - )} + isDarkMode={isDarkMode} + isDevelopment={ENVIRONMENT === 'development'} + user={user} + logout={logout} + sidebarIcon={} + sidebarContent={sidebarContent} + navLinks={MOBILE_NAV_LINKS} /> ); }; -export default PlannerNavbar; \ No newline at end of file +export default PlannerNavbar; diff --git a/main/src/components/auth/forgot-password-form.tsx b/main/src/components/auth/forgot-password-form.tsx index fd97753..b50a3a0 100644 --- a/main/src/components/auth/forgot-password-form.tsx +++ b/main/src/components/auth/forgot-password-form.tsx @@ -6,7 +6,7 @@ import { sendPasswordResetEmail } from "firebase/auth"; import { auth } from "@/firebase-config"; import { Link } from "react-router-dom"; -import { Button } from "@/components/ui/button"; +import { Button } from "@sage/ui"; import { Form, FormControl, @@ -14,8 +14,8 @@ import { FormItem, FormLabel, FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; +} from "@sage/ui"; +import { Input } from "@sage/ui"; const formSchema = z.object({ email: z.string().email({ message: "Please enter a valid email address" }), diff --git a/main/src/components/auth/login-form.tsx b/main/src/components/auth/login-form.tsx index be53c2f..095f718 100644 --- a/main/src/components/auth/login-form.tsx +++ b/main/src/components/auth/login-form.tsx @@ -13,7 +13,7 @@ import { useAuth } from "@/context/AuthContext"; import { useNavigate, useLocation, Link } from "react-router-dom"; import { Toaster, toast } from "sonner"; -import { Button } from "@/components/ui/button"; +import { Button } from "@sage/ui"; import { Form, FormControl, @@ -21,9 +21,9 @@ import { FormItem, FormLabel, FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; +} from "@sage/ui"; +import { Input } from "@sage/ui"; +import { Separator } from "@sage/ui"; const VITE_CRUD_API = import.meta.env.VITE_CRUD_API; diff --git a/main/src/components/auth/signup-form.tsx b/main/src/components/auth/signup-form.tsx index 3ebb852..259727b 100644 --- a/main/src/components/auth/signup-form.tsx +++ b/main/src/components/auth/signup-form.tsx @@ -11,7 +11,7 @@ import { useAuth } from "@/context/AuthContext"; import { useNavigate } from "react-router-dom"; import { Toaster, toast } from "sonner"; -import { Button } from "@/components/ui/button"; +import { Button } from "@sage/ui"; import { Form, FormControl, @@ -19,9 +19,9 @@ import { FormItem, FormLabel, FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; +} from "@sage/ui"; +import { Input } from "@sage/ui"; +import { Separator } from "@sage/ui"; const VITE_CRUD_API = import.meta.env.VITE_CRUD_API; diff --git a/main/src/components/chatbot/ChatSidebarContent.tsx b/main/src/components/chatbot/ChatSidebarContent.tsx index ca7cea6..c4fc380 100644 --- a/main/src/components/chatbot/ChatSidebarContent.tsx +++ b/main/src/components/chatbot/ChatSidebarContent.tsx @@ -1,29 +1,27 @@ import { MessageCirclePlusIcon, Pencil, Trash2Icon } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useChatbot } from "@/hooks/useChatbot" +import { useState } from "react"; +import { useChatbotStore } from "@/stores/chatbotStore"; import { useAuth } from "@/context/AuthContext"; -import { chatEventEmitter } from "@/utils/chatEventEmitter"; import type { Conversation } from "@/types/chat"; interface ChatSidebarContentProps { - onClose: () => void; + onClose?: () => void; + layout?: "mobile" | "template"; } -const ChatSidebarContent: React.FC = ({ onClose }) => { +const ChatSidebarContent: React.FC = ({ onClose, layout = "mobile" }) => { const { user } = useAuth(); const { conversations, - conversation_id, + activeConversationId, error, loading, - setConversations, + setActiveConversationId, + startNewChat, deleteConversation, renameConversation, - setConversationId, - initialLoad - } = useChatbot(); + } = useChatbotStore(); - // Modal states const [showRenameModal, setShowRenameModal] = useState(false); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); @@ -32,31 +30,6 @@ const ChatSidebarContent: React.FC = ({ onClose }) => { const [conversationToDelete, setConversationToDelete] = useState(null); const [deleting, setDeleting] = useState(false); - useEffect(() => { - initialLoad(); - }, []); - - useEffect(() => { - // Sync conversations from ChatBot when they update - const handleConversationUpdate = (updatedConversations: Conversation[]) => { - setConversations(updatedConversations); - }; - - // Sync active conversation ID from ChatBot - const handleActiveConversationUpdate = (newId: string | null) => { - setConversationId(newId); - }; - - chatEventEmitter.on('conversationUpdate', handleConversationUpdate); - chatEventEmitter.on('activeConversationUpdate', handleActiveConversationUpdate); - chatEventEmitter.emit('requestConversations'); // Request current state from ChatBot on mount - - return () => { - chatEventEmitter.off('conversationUpdate', handleConversationUpdate); - chatEventEmitter.off('activeConversationUpdate', handleActiveConversationUpdate); - }; - }, []); - const groupConversationsByDate = (convs: Conversation[]) => { const today = new Date(); const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()); @@ -80,14 +53,14 @@ const ChatSidebarContent: React.FC = ({ onClose }) => { return { todayChats, pastChats }; }; - const truncateText = (text: string, maxLength: number = 24) => { + const truncateText = (text: string, maxLength = 24) => { if (text.length <= maxLength) return text; return text.substring(0, maxLength) + "..."; }; const renderConversationItem = (conv: Conversation) => { const displayName = conv.title || conv.conversation_name || conv.messages?.[0]?.content || "No messages"; - const active = conversation_id === conv.conversation_id; + const active = activeConversationId === conv.conversation_id; const displayText = truncateText(displayName, 20); return ( @@ -96,13 +69,8 @@ const ChatSidebarContent: React.FC = ({ onClose }) => { + const content = ( +
+ {loading &&

Loading conversations...

} + {error &&

{error}

} - {loading &&

Loading conversations...

} - {error &&

{error}

} - - {Array.isArray(conversations) && conversations.length > 0 ? ( -
- {todayChats.length > 0 && ( -
-

Today

-
    {todayChats.map(renderConversationItem)}
-
- )} + {Array.isArray(conversations) && conversations.length > 0 ? ( +
+ {todayChats.length > 0 && ( +
+

Today

+
    {todayChats.map(renderConversationItem)}
+
+ )} - {pastChats.length > 0 && ( -
-

Past Chats

-
    {pastChats.map(renderConversationItem)}
-
- )} -
- ) : ( -
- No conversations yet -
- )} -
+ {pastChats.length > 0 && ( +
+

Past Chats

+
    {pastChats.map(renderConversationItem)}
+
+ )} +
+ ) : ( +
+ No conversations yet +
+ )} +
+ ); - {/* Rename Modal */} + const modals = ( + <> {showRenameModal && (
setShowRenameModal(false)}>
e.stopPropagation()}> @@ -222,10 +168,10 @@ const ChatSidebarContent: React.FC = ({ onClose }) => {
)} - {/* Delete Modal */} {showDeleteModal && (
setShowDeleteModal(false)}>
e.stopPropagation()}> @@ -263,15 +208,13 @@ const ChatSidebarContent: React.FC = ({ onClose }) => { + + {content} +
+ + {modals} + + )} + + ); }; -export default ChatSidebarContent; \ No newline at end of file +export default ChatSidebarContent; diff --git a/main/src/components/chatbot/ChatSidebarShell.tsx b/main/src/components/chatbot/ChatSidebarShell.tsx new file mode 100644 index 0000000..d6969aa --- /dev/null +++ b/main/src/components/chatbot/ChatSidebarShell.tsx @@ -0,0 +1,68 @@ +import type { FC } from "react"; +import { MessageCirclePlusIcon, SquareAsterisk } from "lucide-react"; +import { SidebarTemplate } from "@sage/ui"; +import ChatSidebarContent from "@/components/chatbot/ChatSidebarContent"; + +interface ChatSidebarShellProps { + isCollapsed: boolean; + sidebarCollapsedDelayed: boolean; + onToggleCollapse: () => void; + onStartNewChat: () => void; +} + +const ChatSidebarShell: FC = ({ + isCollapsed, + sidebarCollapsedDelayed, + onToggleCollapse, + onStartNewChat, +}) => { + return ( + + ); +}; + +export default ChatSidebarShell; diff --git a/main/src/components/planner/ClassValidationA.tsx b/main/src/components/planner/ClassValidationA.tsx index e7f0829..cfa948e 100644 --- a/main/src/components/planner/ClassValidationA.tsx +++ b/main/src/components/planner/ClassValidationA.tsx @@ -1,8 +1,6 @@ import { Trash2Icon, SaveIcon, Pencil, PlusCircle } from "lucide-react"; import React, { useState, useRef } from "react"; -import { Button } from "../ui/button"; -import { Card, CardContent } from "../ui/card"; -import { Separator } from "../ui/separator"; +import { Button, Card, CardContent, Separator } from "@sage/ui"; interface ClassValidationAProps { onNext: (updatedTranscript: any) => void; diff --git a/main/src/components/planner/PlannerDiscoveryBanner.tsx b/main/src/components/planner/PlannerDiscoveryBanner.tsx new file mode 100644 index 0000000..9832ecd --- /dev/null +++ b/main/src/components/planner/PlannerDiscoveryBanner.tsx @@ -0,0 +1,29 @@ +import { Compass, ChevronRight } from "lucide-react"; +import { usePlannerStore } from "@/stores/plannerStore"; + +interface PlannerDiscoveryBannerProps { + onOpenDiscovery?: () => void; +} + +const PlannerDiscoveryBanner: React.FC = ({ onOpenDiscovery }) => { + const stagedCount = usePlannerStore((s) => s.stagedCourses.length); + return ( + + ); +}; + +export default PlannerDiscoveryBanner; diff --git a/main/src/components/planner/PlannerSidebarContent.tsx b/main/src/components/planner/PlannerSidebarContent.tsx deleted file mode 100644 index e3e7815..0000000 --- a/main/src/components/planner/PlannerSidebarContent.tsx +++ /dev/null @@ -1,474 +0,0 @@ -import React, { useRef } from "react"; -import { NotebookPen, Compass, ChevronRight } from "lucide-react"; -import RequirementCategory from "@/components/planner/RequirementCategory"; -import CoursesCarousel from "@/components/planner/CoursesCarousel"; -import { getCreditsBreakdownRecursive, getCompletionForCategory } from "@/utils/plannerCredits"; -import type { SemestersForCredits } from "@/utils/plannerCredits"; -import { usePlannerStore } from "@/stores/plannerStore"; - -interface PlannerSidebarContentProps { - onClose: () => void; - requirements: any[]; - expandedCategories: Record; - onToggleCategory: (index: number) => void; - transcriptData: any; - onDropCourse?: (courseId: string, sourceYear: string, sourceSemesterIndex: number) => void; - placedSuggestedCourses?: Set; - allCompletedCourseCodes?: string[]; - allPlannedCoursesWithOrder?: Array<{ - code: string; - yearKey: string; - semesterIndex: number; - semesterOrder: number; - }>; - onRestartOnboarding?: () => void; - availableSemesters?: Array<{ yearKey: string, semesterIndex: number, title: string }>; - onAddCourse?: (targetYear: string, targetSemesterIndex: number, course: any, sourceYear: string, sourceSemesterIndex: number, courseId?: string, isSuggested?: boolean) => void; - focusLabel?: string; - semesters?: SemestersForCredits; - coursebookData?: Record; - gradesData?: Record; - coursebookSemester?: string | null; - onOpenDiscovery?: () => void; -} - -const PlannerSidebarContent: React.FC = ({ - onClose, - requirements, - placedSuggestedCourses = new Set(), - allCompletedCourseCodes = [], - allPlannedCoursesWithOrder = [], - onRestartOnboarding, - availableSemesters = [], - onAddCourse, - focusLabel, - semesters, - coursebookData, - gradesData, - coursebookSemester, - onOpenDiscovery, -}) => { - const [autoExpandedCategories, setAutoExpandedCategories] = React.useState<{ [key: number]: boolean }>({}); - const [expandedSubcategories, setExpandedSubcategories] = React.useState>({}); - const prevSuggestedByKeyRef = useRef>>({}); - - // Course discovery store - const stagedCourses = usePlannerStore(s => s.stagedCourses); - const removeStagedCourse = usePlannerStore(s => s.removeStagedCourse); - - // Collect all suggested courses from all categories with their category paths - const allSuggestedCourses = React.useMemo(() => { - const courses: any[] = []; - - const collectSuggestedCourses = (categories: any[], parentPath: string[] = []) => { - if (!categories) return; - - categories.forEach((category) => { - const currentPath = [...parentPath, category.name]; - - if (category.suggested && category.suggested.length > 0) { - // Add category location to each suggested course - const coursesWithLocation = category.suggested.map((course: any) => ({ - ...course, - categoryPath: currentPath.join(' > ') - })); - courses.push(...coursesWithLocation); - } - if (category.categories && category.categories.length > 0) { - collectSuggestedCourses(category.categories, currentPath); - } - }); - }; - - requirements.forEach((req) => { - if (req.categories) { - collectSuggestedCourses(req.categories, [req.degree]); - } - }); - - return courses; - }, [requirements]); - - React.useEffect(() => { - if (!focusLabel) return; - setTimeout(() => { - document.querySelector('.highlight-pulse') - ?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 100); - }, [focusLabel]); - - React.useEffect(() => { - const getSuggestedCodes = (c: any): Set => - new Set((c?.suggested || []).map((s: any) => String(s.code || s.course_code || "").trim().toUpperCase()).filter((x: string) => !!x)); - - const buildSuggestedByKey = (categories: any[], reqIdx: number, parentIdx: string): Record> => { - const out: Record> = {}; - categories.forEach((category, catIdx) => { - const key = `${reqIdx}-${parentIdx}-${catIdx}`; - out[key] = getSuggestedCodes(category); - if (category.categories?.length) { - Object.assign(out, buildSuggestedByKey(category.categories, reqIdx, `${parentIdx}-${catIdx}`)); - } - }); - return out; - }; - - const newSuggestedByKey: Record> = {}; - requirements.forEach((req, reqIdx) => { - if (req.categories?.length) { - Object.assign(newSuggestedByKey, buildSuggestedByKey(req.categories, reqIdx, "0")); - } - }); - - const keysWithNewSuggestions = new Set(); - Object.entries(newSuggestedByKey).forEach(([key, newCodes]) => { - const prevCodes = prevSuggestedByKeyRef.current[key]; - if (!prevCodes) return; - if (newCodes.size > prevCodes.size) keysWithNewSuggestions.add(key); - }); - - const withAncestors = new Set(keysWithNewSuggestions); - keysWithNewSuggestions.forEach((key) => { - let k = key; - while (true) { - const lastDash = k.lastIndexOf("-"); - if (lastDash <= 0) break; - k = k.slice(0, lastDash); - withAncestors.add(k); - } - }); - const keysToExpand = withAncestors; - - const prevHadAny = Object.keys(prevSuggestedByKeyRef.current).length > 0; - prevSuggestedByKeyRef.current = newSuggestedByKey; - - // On initial load: expand every section that has suggested courses, plus ancestors - const keysWithSuggestedOnInitial = new Set(); - if (!prevHadAny) { - Object.entries(newSuggestedByKey).forEach(([key, codes]) => { - if (codes.size > 0) keysWithSuggestedOnInitial.add(key); - }); - keysWithSuggestedOnInitial.forEach((key) => { - let k = key; - while (true) { - const lastDash = k.lastIndexOf("-"); - if (lastDash <= 0) break; - k = k.slice(0, lastDash); - keysWithSuggestedOnInitial.add(k); - } - }); - } - - const initializeCategories = (categories: any[], reqIdx: number, parentIdx: string) => { - const result: Record = {}; - categories.forEach((category, catIdx) => { - const key = `${reqIdx}-${parentIdx}-${catIdx}`; - const defaultExpanded = prevHadAny - ? category.progress < category.total - : keysWithSuggestedOnInitial.has(key) || category.progress < category.total; - const gotNewSuggestions = keysToExpand.has(key); - let expanded: boolean; - if (prevHadAny && expandedSubcategories[key] === false && !gotNewSuggestions) { - expanded = false; - } else if (gotNewSuggestions) { - expanded = true; - } else if (prevHadAny && key in expandedSubcategories) { - expanded = expandedSubcategories[key]; - } else { - expanded = defaultExpanded; - } - result[key] = expanded; - if (category.categories?.length) { - Object.assign(result, initializeCategories(category.categories, reqIdx, `${parentIdx}-${catIdx}`)); - } - }); - return result; - }; - - const initialState: Record = {}; - requirements.forEach((req, reqIdx) => { - if (req.categories?.length) { - Object.assign(initialState, initializeCategories(req.categories, reqIdx, "0")); - } - }); - - setExpandedSubcategories(initialState); - - const reqKeysWithNew = new Set(); - keysToExpand.forEach((k) => { - const reqIdx = parseInt(k.split("-")[0], 10); - if (!isNaN(reqIdx)) reqKeysWithNew.add(reqIdx); - }); - - if (!prevHadAny) { - const initialReqState: { [key: number]: boolean } = {}; - requirements.forEach((req, reqIdx) => { - const isIncomplete = req.progress < req.total; - const hasSuggested = req.categories?.some((c: any) => c.suggested?.length); - const hasContent = !!(req.categories?.length); - initialReqState[reqIdx] = (isIncomplete && hasContent) || !!hasSuggested; - }); - setAutoExpandedCategories(initialReqState); - } else if (reqKeysWithNew.size > 0) { - setAutoExpandedCategories((prev) => { - const next = { ...prev }; - reqKeysWithNew.forEach((idx) => { next[idx] = true; }); - return next; - }); - } - }, [requirements]); - - const handleToggleSubcategory = (key: string) => { - setExpandedSubcategories((prev) => ({ - ...prev, - [key]: !prev[key], - })); - }; - - const hasCompletion = (category: any): boolean => { - if (category.progress > 0) return true; - - if (category.classes && category.classes.length > 0) { - const hasCompletedClasses = category.classes.some((course: any) => - course.status === "completed" || course.status === "in progress" - ); - if (hasCompletedClasses) return true; - } - - if (category.categories && category.categories.length > 0) { - return category.categories.some((subcat: any) => hasCompletion(subcat)); - } - - return false; - }; - - const filterCategories = (categories: any[]): any[] => { - return categories.filter(category => { - const categoryName = category.name?.toUpperCase() || ''; - const isOR = categoryName === 'OR'; - const isAND = categoryName === 'AND'; - - if (isAND && !hasCompletion(category)) { - return false; - } - - if (isOR && category.categories && category.categories.length > 0) { - const childrenWithCompletion = category.categories.filter((child: any) => { - return hasCompletion(child); - }); - - if (childrenWithCompletion.length === 0) { - return false; - } - } - - return true; - }); - }; - - const renderCategories = (categories: any[], reqIdx: number, parentPath: string = "0", parentIsOR: boolean = false) => { - const filteredCategories = filterCategories(categories); - - return filteredCategories.map((category, catIdx) => { - const originalIdx = categories.indexOf(category); - const currentCatIdx = `${reqIdx}-${parentPath}-${originalIdx}`; - const nextParentPath = `${parentPath}-${originalIdx}`; - const categoryName = category.name?.toUpperCase() || ''; - const isOR = categoryName === 'OR'; - const isAND = categoryName === 'AND'; - - let displayName = category.name; - if (isOR) { - displayName = "Track Options"; - } else if (isAND && parentIsOR) { - displayName = `Track ${catIdx + 1}`; - } - - let subCategoriesToRender = category.categories || []; - if (isOR && subCategoriesToRender.length > 0) { - subCategoriesToRender = subCategoriesToRender.filter((child: any) => - hasCompletion(child) - ); - } - - const completion = semesters ? getCompletionForCategory(category, semesters) : { completed: category.progress, total: category.total, isCreditBased: true }; - const creditsBreakdown = completion.isCreditBased && semesters ? getCreditsBreakdownRecursive(category, semesters) : undefined; - - return ( - handleToggleSubcategory(currentCatIdx)} - hasSubcategories={subCategoriesToRender.length > 0} - creditsBreakdown={creditsBreakdown} - footnote={category.footnote} - rules={category.rules} - > - {category.classes && category.classes.length > 0 ? ( - - ) : subCategoriesToRender.length > 0 ? null : ( - !category.suggested?.length && ( -
- No courses in this category -
- ) - )} - - {category.suggested && category.suggested.length > 0 && ( - <> -
- - Suggested Courses - -
- - - )} - - {category.prereq_blocked && category.prereq_blocked.length > 0 && ( - <> -
- Needs Prerequisites -
- - - )} - {subCategoriesToRender.length > 0 && - renderCategories(subCategoriesToRender, reqIdx, nextParentPath, isOR)} -
- ); - }); - }; - - return ( -
-
- -
- - {/* Course Discovery banner */} - - - {/* Staged courses */} - {stagedCourses.length > 0 && ( -
-
-
- - Staged · {stagedCourses.length} course{stagedCourses.length !== 1 ? 's' : ''} -
- -
- -
- )} - -

- Degree Requirements -

- -
- {requirements.map((req, reqIdx) => { - const reqCompletion = semesters ? getCompletionForCategory(req, semesters) : { completed: req.progress, total: req.total, isCreditBased: true }; - const reqCreditsBreakdown = reqCompletion.isCreditBased && semesters ? getCreditsBreakdownRecursive(req, semesters) : undefined; - return ( - { - setAutoExpandedCategories((prev) => ({ - ...prev, - [reqIdx]: !prev[reqIdx], - })); - }} - hasSubcategories={req.categories && req.categories.length > 0} - creditsBreakdown={reqCreditsBreakdown} - footnote={(req as any).footnote} - rules={(req as any).rules} - > - {req.categories && req.categories.length > 0 ? ( - renderCategories(req.categories, reqIdx) - ) : ( -
- No categories available -
- )} -
- ); - })} -
-
- ); -}; - -export default PlannerSidebarContent; \ No newline at end of file diff --git a/main/src/components/planner/PlannerSidebarDesktop.tsx b/main/src/components/planner/PlannerSidebarDesktop.tsx new file mode 100644 index 0000000..cb9335a --- /dev/null +++ b/main/src/components/planner/PlannerSidebarDesktop.tsx @@ -0,0 +1,354 @@ +import React from "react"; +import { NotebookPen, Compass } from "lucide-react"; +import { useDrop } from "react-dnd"; +import RequirementCategory from '@/components/planner/RequirementCategory'; +import CoursesCarousel from '@/components/planner/CoursesCarousel'; +import PlannerDiscoveryBanner from '@/components/planner/PlannerDiscoveryBanner'; +import { getCreditsBreakdownRecursive, getCompletionForCategory } from "@/utils/plannerCredits"; +import type { SemestersForCredits } from "@/utils/plannerCredits"; +import { filterCategories, hasCompletion } from "@/utils/plannerSidebarUtils"; +import { usePlannerSidebarCategories } from "@/hooks/usePlannerSidebarCategories"; +import { CartItem } from "./CourseDiscoveryModal"; +import { usePlannerStore } from '@/stores/plannerStore'; +import { SidebarTemplate } from "@sage/ui"; + +interface PlannerSidebarDesktopProps { + requirements: { + degree: string; + progress: number; + total: number; + credits_completed: number; + credits: number; + categories: { + name: string; + progress: number; + total: number; + credits_completed: number; + credits: number; + classes: { + code: string; + name: string; + credits: number; + status?: string; + semester?: string; + }[]; + categories?: any[]; + suggested?: { + code: string; + name: string; + corequisites: string[]; + excluded: string[]; + required_core: boolean; + repeatable_for_hours: number; + notes: string; + description: string; + }[]; + }[]; + }[]; + expandedCategories: { [key: number]: boolean }; + onToggleCategory: (index: number) => void; + transcriptData: any; + onDropCourse?: (courseId: string, sourceYear: string, sourceSemesterIndex: number) => void; + isExpanded?: boolean; + onToggleExpanded?: () => void; + placedSuggestedCourses?: Set; + allCompletedCourseCodes?: string[]; + allPlannedCoursesWithOrder?: Array<{ + code: string; + yearKey: string; + semesterIndex: number; + semesterOrder: number; + }>; + onRestartOnboarding?: () => void; + focusLabel?: string; + semesters?: SemestersForCredits; + coursebookData?: Record; + gradesData?: Record; + coursebookSemester?: string | null; + onOpenDiscovery?: () => void; + discoveryCart?: CartItem[]; +} + +const PlannerSidebarDesktop: React.FC = ({ + requirements, + onDropCourse, + semesters, + isExpanded: externalIsExpanded, + onToggleExpanded, + placedSuggestedCourses = new Set(), + allCompletedCourseCodes = [], + allPlannedCoursesWithOrder = [], + onRestartOnboarding, + focusLabel, + coursebookData = {}, + gradesData = {}, + coursebookSemester, + onOpenDiscovery, +}) => { + const [internalIsExpanded, setInternalIsExpanded] = React.useState(true); + const isExpanded = externalIsExpanded !== undefined ? externalIsExpanded : internalIsExpanded; + + const { + autoExpandedCategories, + setAutoExpandedCategories, + expandedSubcategories, + handleToggleSubcategory, + allSuggestedCourses, + } = usePlannerSidebarCategories({ requirements, focusLabel }); + + const stagedCourses = usePlannerStore((s) => s.stagedCourses); + const removeStagedCourse = usePlannerStore((s) => s.removeStagedCourse); + + const [{ isOver }, drop] = useDrop( + () => ({ + accept: "COURSE", + drop: (item: any) => { + if (item.courseId && item.sourceYear !== undefined && item.sourceSemesterIndex !== undefined && onDropCourse) { + onDropCourse(item.courseId, item.sourceYear, item.sourceSemesterIndex); + } + }, + collect: (monitor) => ({ isOver: monitor.isOver() }), + }), + [onDropCourse] + ); + + const handleToggleSidebar = () => { + if (onToggleExpanded) { + onToggleExpanded(); + } else { + setInternalIsExpanded(!internalIsExpanded); + } + }; + + const normalizedPlaced = new Set([...placedSuggestedCourses].map((c) => c.toLowerCase().replace(/\s+/g, ''))); + const allStagedPlaced = stagedCourses.every((c) => normalizedPlaced.has(c.course_id.toLowerCase().replace(/\s+/g, ''))); + + const renderCategoryContent = (category: any, reqIdx: number, nextParentPath: string, isOR: boolean, subCategoriesToRender: any[]): React.ReactNode => { + const suggestedCodes = new Set((category.suggested || []).map((c: any) => c.code)); + const filteredPrereqBlocked = (category.prereq_blocked || []).filter((c: any) => !suggestedCodes.has(c.code)); + + return ( + <> + {category.classes?.length > 0 ? ( + + ) : subCategoriesToRender.length > 0 ? null : ( + !category.suggested?.length && ( + category.evaluatable === false ? ( +
Contact your advisor for more info on how to complete this requirement.
+ ) : ( +
No courses in this category
+ ) + ) + )} + + {category.suggested?.length > 0 && ( + <> +
+ Suggested Courses +
+ + + )} + + {filteredPrereqBlocked.length > 0 && ( + <> +
+ Needs Prerequisites +
+ + + )} + + {subCategoriesToRender.length > 0 && renderCategories(subCategoriesToRender, reqIdx, nextParentPath, isOR)} + + ); + }; + + const renderCategories = (categories: any[], reqIdx: number, parentPath = "0", parentIsOR = false): React.ReactNode[] => { + const filtered = filterCategories(categories); + return filtered.map((category, catIdx) => { + const originalIdx = categories.indexOf(category); + const currentCatIdx = `${reqIdx}-${parentPath}-${originalIdx}`; + const nextParentPath = `${parentPath}-${originalIdx}`; + const categoryName = category.name?.toUpperCase() || ''; + const isOR = categoryName === 'OR'; + const isAND = categoryName === 'AND'; + + let displayName = category.name; + if (isOR) displayName = "Track Options"; + else if (isAND && parentIsOR) displayName = `Track ${catIdx + 1}`; + + let subCategoriesToRender = category.categories || []; + if (isOR && subCategoriesToRender.length > 0) { + subCategoriesToRender = subCategoriesToRender.filter((child: any) => hasCompletion(child)); + } + + const content = renderCategoryContent(category, reqIdx, nextParentPath, isOR, subCategoriesToRender); + const parts = displayName?.split('|').map((p: string) => p.trim()) || [displayName]; + const completion = semesters ? getCompletionForCategory(category, semesters) : { completed: category.progress, total: category.total, isCreditBased: true }; + const creditsBreakdown = completion.isCreditBased && semesters ? getCreditsBreakdownRecursive(category, semesters) : undefined; + + if (parts.length > 1) { + return parts.reduceRight((inner: React.ReactNode, part: string, i: number) => { + const partKey = `${currentCatIdx}-part-${i}`; + return ( + handleToggleSubcategory(partKey)} + hasSubcategories={i < parts.length - 1 || subCategoriesToRender.length > 0} + creditsBreakdown={i === 0 ? creditsBreakdown : undefined} + footnote={i === 0 ? category.footnote : undefined} + rules={i === 0 ? category.rules : undefined} + > + {inner} + + ); + }, content); + } + + return ( + handleToggleSubcategory(currentCatIdx)} + hasSubcategories={subCategoriesToRender.length > 0} + creditsBreakdown={creditsBreakdown} + footnote={category.footnote} + rules={category.rules} + > + {content} + + ); + }); + }; + + const primaryAction = { + label: "Edit plans", + icon: , + onClick: () => { + if (document.querySelector('.driver-active-element')) return; + onRestartOnboarding?.(); + }, + dataTour: "edit-plans", + }; + + const collapsedActions = [ + primaryAction, + ...(onOpenDiscovery + ? [{ label: "Discover Courses", icon: , onClick: () => onOpenDiscovery() }] + : []), + ]; + + return ( +
+ + + + {stagedCourses.length > 0 && ( +
+
+
+ + Staged · {stagedCourses.length} course{stagedCourses.length !== 1 ? 's' : ''} +
+ +
+ +
+ )} + +

Degree Requirements

+ +
+ {requirements.map((req, reqIdx) => { + const reqCompletion = semesters ? getCompletionForCategory(req, semesters) : { completed: req.progress, total: req.total, isCreditBased: true }; + const reqCreditsBreakdown = reqCompletion.isCreditBased && semesters ? getCreditsBreakdownRecursive(req, semesters) : undefined; + return ( + setAutoExpandedCategories((prev) => ({ ...prev, [reqIdx]: !prev[reqIdx] }))} + hasSubcategories={req.categories?.length > 0} + isFirstCategory={reqIdx === 0} + creditsBreakdown={reqCreditsBreakdown} + footnote={(req as any).footnote} + rules={(req as any).rules} + > + {req.categories?.length > 0 ? ( + renderCategories(req.categories, reqIdx) + ) : ( +
No categories available
+ )} +
+ ); + })} +
+
+ } + /> +
+ ); +}; + +export default PlannerSidebarDesktop; diff --git a/main/src/components/planner/PlannerSidebarMobile.tsx b/main/src/components/planner/PlannerSidebarMobile.tsx new file mode 100644 index 0000000..3a0169c --- /dev/null +++ b/main/src/components/planner/PlannerSidebarMobile.tsx @@ -0,0 +1,274 @@ +import React from "react"; +import { NotebookPen } from "lucide-react"; +import RequirementCategory from "@/components/planner/RequirementCategory"; +import CoursesCarousel from "@/components/planner/CoursesCarousel"; +import PlannerDiscoveryBanner from "@/components/planner/PlannerDiscoveryBanner"; +import { getCreditsBreakdownRecursive, getCompletionForCategory } from "@/utils/plannerCredits"; +import type { SemestersForCredits } from "@/utils/plannerCredits"; +import { filterCategories, hasCompletion } from "@/utils/plannerSidebarUtils"; +import { usePlannerSidebarCategories } from "@/hooks/usePlannerSidebarCategories"; +import { usePlannerStore } from "@/stores/plannerStore"; + +interface PlannerSidebarMobileProps { + onClose: () => void; + requirements: any[]; + expandedCategories: Record; + onToggleCategory: (index: number) => void; + transcriptData: any; + onDropCourse?: (courseId: string, sourceYear: string, sourceSemesterIndex: number) => void; + placedSuggestedCourses?: Set; + allCompletedCourseCodes?: string[]; + allPlannedCoursesWithOrder?: Array<{ + code: string; + yearKey: string; + semesterIndex: number; + semesterOrder: number; + }>; + onRestartOnboarding?: () => void; + availableSemesters?: Array<{ yearKey: string; semesterIndex: number; title: string }>; + onAddCourse?: (targetYear: string, targetSemesterIndex: number, course: any, sourceYear: string, sourceSemesterIndex: number, courseId?: string, isSuggested?: boolean) => void; + focusLabel?: string; + semesters?: SemestersForCredits; + coursebookData?: Record; + gradesData?: Record; + coursebookSemester?: string | null; + onOpenDiscovery?: () => void; +} + +const PlannerSidebarMobile: React.FC = ({ + onClose, + requirements, + placedSuggestedCourses = new Set(), + allCompletedCourseCodes = [], + allPlannedCoursesWithOrder = [], + onRestartOnboarding, + availableSemesters = [], + onAddCourse, + focusLabel, + semesters, + coursebookData, + gradesData, + coursebookSemester, + onOpenDiscovery, +}) => { + const { + autoExpandedCategories, + setAutoExpandedCategories, + expandedSubcategories, + handleToggleSubcategory, + allSuggestedCourses, + } = usePlannerSidebarCategories({ requirements, focusLabel }); + + const stagedCourses = usePlannerStore((s) => s.stagedCourses); + const removeStagedCourse = usePlannerStore((s) => s.removeStagedCourse); + + const renderCategoryContent = (category: any, reqIdx: number, nextParentPath: string, isOR: boolean, subCategoriesToRender: any[]): React.ReactNode => { + const suggestedCodes = new Set((category.suggested || []).map((c: any) => c.code)); + const filteredPrereqBlocked = (category.prereq_blocked || []).filter((c: any) => !suggestedCodes.has(c.code)); + + return ( + <> + {category.classes?.length > 0 ? ( + + ) : subCategoriesToRender.length > 0 ? null : ( + !category.suggested?.length && ( + category.evaluatable === false ? ( +
Contact your advisor for more info on how to complete this requirement.
+ ) : ( +
No courses in this category
+ ) + ) + )} + + {category.suggested?.length > 0 && ( + <> +
+ Suggested Courses +
+ + + )} + + {filteredPrereqBlocked.length > 0 && ( + <> +
+ Needs Prerequisites +
+ + + )} + + {subCategoriesToRender.length > 0 && renderCategories(subCategoriesToRender, reqIdx, nextParentPath, isOR)} + + ); + }; + + const renderCategories = (categories: any[], reqIdx: number, parentPath = "0", parentIsOR = false): React.ReactNode[] => { + const filtered = filterCategories(categories); + return filtered.map((category, catIdx) => { + const originalIdx = categories.indexOf(category); + const currentCatIdx = `${reqIdx}-${parentPath}-${originalIdx}`; + const nextParentPath = `${parentPath}-${originalIdx}`; + const categoryName = category.name?.toUpperCase() || ''; + const isOR = categoryName === 'OR'; + const isAND = categoryName === 'AND'; + + let displayName = category.name; + if (isOR) displayName = "Track Options"; + else if (isAND && parentIsOR) displayName = `Track ${catIdx + 1}`; + + let subCategoriesToRender = category.categories || []; + if (isOR && subCategoriesToRender.length > 0) { + subCategoriesToRender = subCategoriesToRender.filter((child: any) => hasCompletion(child)); + } + + const content = renderCategoryContent(category, reqIdx, nextParentPath, isOR, subCategoriesToRender); + const parts = displayName?.split('|').map((p: string) => p.trim()) || [displayName]; + const completion = semesters ? getCompletionForCategory(category, semesters) : { completed: category.progress, total: category.total, isCreditBased: true }; + const creditsBreakdown = completion.isCreditBased && semesters ? getCreditsBreakdownRecursive(category, semesters) : undefined; + + if (parts.length > 1) { + return parts.reduceRight((inner: React.ReactNode, part: string, i: number) => { + const partKey = `${currentCatIdx}-part-${i}`; + return ( + handleToggleSubcategory(partKey)} + hasSubcategories={i < parts.length - 1 || subCategoriesToRender.length > 0} + creditsBreakdown={i === 0 ? creditsBreakdown : undefined} + footnote={i === 0 ? category.footnote : undefined} + rules={i === 0 ? category.rules : undefined} + > + {inner} + + ); + }, content); + } + + return ( + handleToggleSubcategory(currentCatIdx)} + hasSubcategories={subCategoriesToRender.length > 0} + creditsBreakdown={creditsBreakdown} + footnote={category.footnote} + rules={category.rules} + > + {content} + + ); + }); + }; + + return ( +
+
+ +
+ + + + {stagedCourses.length > 0 && ( +
+
+
+ + Staged · {stagedCourses.length} course{stagedCourses.length !== 1 ? 's' : ''} +
+ +
+ +
+ )} + +

Degree Requirements

+ +
+ {requirements.map((req, reqIdx) => { + const reqCompletion = semesters ? getCompletionForCategory(req, semesters) : { completed: req.progress, total: req.total, isCreditBased: true }; + const reqCreditsBreakdown = reqCompletion.isCreditBased && semesters ? getCreditsBreakdownRecursive(req, semesters) : undefined; + return ( + setAutoExpandedCategories((prev) => ({ ...prev, [reqIdx]: !prev[reqIdx] }))} + hasSubcategories={req.categories?.length > 0} + creditsBreakdown={reqCreditsBreakdown} + footnote={(req as any).footnote} + rules={(req as any).rules} + > + {req.categories?.length > 0 ? ( + renderCategories(req.categories, reqIdx) + ) : ( +
No categories available
+ )} +
+ ); + })} +
+
+ ); +}; + +export default PlannerSidebarMobile; diff --git a/main/src/components/planner/ProgramValidationA.tsx b/main/src/components/planner/ProgramValidationA.tsx index 999b83a..a81ffee 100644 --- a/main/src/components/planner/ProgramValidationA.tsx +++ b/main/src/components/planner/ProgramValidationA.tsx @@ -1,7 +1,6 @@ import { Pencil, PlusIcon, TriangleAlert } from "lucide-react"; import { useState, useMemo, useEffect, useRef } from "react"; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; +import { Button, Card, CardContent } from '@sage/ui'; import ProgramValidationB from "./ProgramValidationB"; import { Select, @@ -9,7 +8,7 @@ import { SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' +} from '@sage/ui' import { useAuth } from "@/context/AuthContext"; import { getCurrentCatalogYear } from "@/utils/studentInfo"; diff --git a/main/src/components/planner/ProgramValidationB.tsx b/main/src/components/planner/ProgramValidationB.tsx index a04a8d5..ba89e85 100644 --- a/main/src/components/planner/ProgramValidationB.tsx +++ b/main/src/components/planner/ProgramValidationB.tsx @@ -1,14 +1,12 @@ import React, { useState } from "react"; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; +import { Button, Card, CardContent, Searchbox } from '@sage/ui'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; -import { Searchbox } from "@/components/ui/SearchBox"; +} from '@sage/ui'; interface ProgramValidationBProps { program: any; diff --git a/main/src/components/planner/SemesterBox.tsx b/main/src/components/planner/SemesterBox.tsx index 4f48eef..7c8db6a 100644 --- a/main/src/components/planner/SemesterBox.tsx +++ b/main/src/components/planner/SemesterBox.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Lock, Unlock, MoreVertical, Trash2, Eraser, TriangleAlert, ChevronUp, Calendar, Download } from "lucide-react"; import CourseBox from "@/components/planner/CourseBox"; import { useDrop } from "react-dnd"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@sage/ui"; import { Course } from "@/types/course"; import { getScheduleButtonState, validateCourseLoad } from '@/utils/courseValidation'; import { Warning } from "@/types/warning"; diff --git a/main/src/components/planner/Sidebar.tsx b/main/src/components/planner/Sidebar.tsx deleted file mode 100644 index a06bc7f..0000000 --- a/main/src/components/planner/Sidebar.tsx +++ /dev/null @@ -1,728 +0,0 @@ -import React, { useState, useEffect, useRef } from "react"; -import { NotebookPen, ArrowLeftToLine, PanelLeftDashed, ArrowRightToLine, Compass, ChevronRight } from "lucide-react"; -import { useDrop } from "react-dnd"; -import RequirementCategory from '@/components/planner/RequirementCategory'; -import CoursesCarousel from '@/components/planner/CoursesCarousel'; -import { getCreditsBreakdownRecursive, getCompletionForCategory } from "@/utils/plannerCredits"; -import type { SemestersForCredits } from "@/utils/plannerCredits"; -import { CartItem } from "./CourseDiscoveryModal"; -import { usePlannerStore } from '@/stores/plannerStore'; - -interface SidebarProps { - requirements: { - degree: string; - progress: number; - total: number; - credits_completed: number; - credits: number; - categories: { - name: string; - progress: number; - total: number; - credits_completed: number; - credits: number; - classes: { - code: string; - name: string; - credits: number; - status?: string; - semester?: string; - }[]; - categories?: any[]; - suggested?: { - code: string; - name: string; - corequisites: string[]; - excluded: string[]; - required_core: boolean; - repeatable_for_hours: number; - notes: string; - description: string; - }[]; - }[]; - }[]; - expandedCategories: { [key: number]: boolean }; - onToggleCategory: (index: number) => void; - transcriptData: any; - onDropCourse?: (courseId: string, sourceYear: string, sourceSemesterIndex: number) => void; - isExpanded?: boolean; - onToggleExpanded?: () => void; - placedSuggestedCourses?: Set; - allCompletedCourseCodes?: string[]; - allPlannedCoursesWithOrder?: Array<{ - code: string; - yearKey: string; - semesterIndex: number; - semesterOrder: number; - }>; - onRestartOnboarding?: () => void; - focusLabel?: string; - semesters?: SemestersForCredits; - coursebookData?: Record; - gradesData?: Record; - coursebookSemester?: string | null; - onOpenDiscovery?: () => void; - discoveryCart?: CartItem[]; -} - -const Sidebar: React.FC = ({ - requirements, - onDropCourse, - semesters, - isExpanded: externalIsExpanded, - onToggleExpanded, - placedSuggestedCourses = new Set(), - allCompletedCourseCodes = [], - allPlannedCoursesWithOrder = [], - onRestartOnboarding, - focusLabel, - coursebookData = {}, - gradesData = {}, - coursebookSemester, - onOpenDiscovery, -}) => { - const [internalIsExpanded, setInternalIsExpanded] = useState(true); - const isExpanded = externalIsExpanded !== undefined ? externalIsExpanded : internalIsExpanded; - const [expandedSubcategories, setExpandedSubcategories] = useState>({}); - const [autoExpandedCategories, setAutoExpandedCategories] = useState<{ [key: number]: boolean }>({}); - const [highlightedKey, setHighlightedKey] = useState(null); - const prevSuggestedByKeyRef = useRef>>({}); - - // discovery of courses hooks - const stagedCourses = usePlannerStore(s => s.stagedCourses); - const removeStagedCourse = usePlannerStore(s => s.removeStagedCourse); - - // Collect all suggested courses from all categories with their category paths - const allSuggestedCourses = React.useMemo(() => { - const courses: any[] = []; - - const collectSuggestedCourses = (categories: any[], parentPath: string[] = []) => { - if (!categories) return; - - categories.forEach((category) => { - const currentPath = [...parentPath, category.name]; - - if (category.suggested && category.suggested.length > 0) { - // Add category location to each suggested course - const coursesWithLocation = category.suggested.map((course: any) => ({ - ...course, - categoryPath: currentPath.join(' > ') - })); - courses.push(...coursesWithLocation); - } - if (category.categories && category.categories.length > 0) { - collectSuggestedCourses(category.categories, currentPath); - } - }); - }; - - requirements.forEach((req) => { - if (req.categories) { - collectSuggestedCourses(req.categories, [req.degree]); - } - }); - - return courses; - }, [requirements]); - - const [{ isOver }, drop] = useDrop( - () => ({ - accept: "COURSE", - drop: (item: any) => { - if (item.courseId && item.sourceYear !== undefined && item.sourceSemesterIndex !== undefined && onDropCourse) { - onDropCourse(item.courseId, item.sourceYear, item.sourceSemesterIndex); - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }), - [onDropCourse] - ); - - const handleToggleSidebar = () => { - if (onToggleExpanded) { - onToggleExpanded(); - } else { - setInternalIsExpanded(!internalIsExpanded); - } - }; - - // parent category - only expand reqs that have categories with new suggestions - - useEffect(() => { - const getSuggestedCodes = (c: any): Set => - new Set((c?.suggested || []).map((s: any) => String(s.code || s.course_code || "").trim().toUpperCase()).filter((x: string) => !!x)); - - const buildSuggestedByKey = ( - categories: any[], - reqIdx: number, - parentIdx: string - ): Record> => { - const out: Record> = {}; - categories.forEach((category, catIdx) => { - const key = `${reqIdx}-${parentIdx}-${catIdx}`; - out[key] = getSuggestedCodes(category); - if (category.categories?.length) { - Object.assign(out, buildSuggestedByKey(category.categories, reqIdx, `${parentIdx}-${catIdx}`)); - } - }); - return out; - }; - - const newSuggestedByKey: Record> = {}; - requirements.forEach((req, reqIdx) => { - if (req.categories?.length) { - Object.assign(newSuggestedByKey, buildSuggestedByKey(req.categories, reqIdx, "0")); - } - }); - - const keysWithNewSuggestions = new Set(); - Object.entries(newSuggestedByKey).forEach(([key, newCodes]) => { - const prevCodes = prevSuggestedByKeyRef.current[key]; - if (!prevCodes) return; // new category, will use initial expansion below - if (newCodes.size > prevCodes.size) keysWithNewSuggestions.add(key); - }); - const withAncestors = new Set(keysWithNewSuggestions); - keysWithNewSuggestions.forEach((key) => { - let k = key; - while (true) { - const lastDash = k.lastIndexOf("-"); - if (lastDash <= 0) break; - k = k.slice(0, lastDash); - withAncestors.add(k); - } - }); - const keysToExpand = withAncestors; - - const prevHadAny = Object.keys(prevSuggestedByKeyRef.current).length > 0; - prevSuggestedByKeyRef.current = newSuggestedByKey; - - // On initial load: expand every section that has suggested courses (or contains one), plus ancestors - const keysWithSuggestedOnInitial = new Set(); - if (!prevHadAny) { - Object.entries(newSuggestedByKey).forEach(([key, codes]) => { - if (codes.size > 0) keysWithSuggestedOnInitial.add(key); - }); - keysWithSuggestedOnInitial.forEach((key) => { - let k = key; - while (true) { - const lastDash = k.lastIndexOf("-"); - if (lastDash <= 0) break; - k = k.slice(0, lastDash); - keysWithSuggestedOnInitial.add(k); - } - }); - } - - const initializeCategories = (categories: any[], reqIdx: number, parentIdx: string) => { - const result: Record = {}; - categories.forEach((category, catIdx) => { - const key = `${reqIdx}-${parentIdx}-${catIdx}`; - const isIncomplete = category.progress < category.total && category.total > 0; - const defaultExpanded = prevHadAny - ? isIncomplete - : keysWithSuggestedOnInitial.has(key) || isIncomplete; - const gotNewSuggestions = keysToExpand.has(key); - let expanded: boolean; - if (prevHadAny && expandedSubcategories[key] === false && !gotNewSuggestions) { - expanded = false; - } else if (gotNewSuggestions) { - expanded = true; - } else if (prevHadAny && key in expandedSubcategories) { - expanded = expandedSubcategories[key]; - } else { - expanded = defaultExpanded; - } - result[key] = expanded; - const parts = (category.name || '').split('|').map((p: string) => p.trim()); - if (parts.length > 1) { - parts.forEach((_: string, i: number) => { - const partKey = `${key}-part-${i}`; - if (gotNewSuggestions) { - result[partKey] = true; - } else if (!(partKey in expandedSubcategories)) { - result[partKey] = defaultExpanded; - } else { - result[partKey] = expandedSubcategories[partKey]; - } - }); - } - if (category.categories?.length) { - Object.assign(result, initializeCategories(category.categories, reqIdx, `${parentIdx}-${catIdx}`)); - } - }); - return result; - }; - - const initialState: Record = {}; - requirements.forEach((req, reqIdx) => { - if (req.categories?.length) { - Object.assign(initialState, initializeCategories(req.categories, reqIdx, "0")); - } - }); - - setExpandedSubcategories(initialState); - - const reqKeysWithNew = new Set(); - keysToExpand.forEach((k) => { - const reqIdx = parseInt(k.split("-")[0], 10); - if (!isNaN(reqIdx)) reqKeysWithNew.add(reqIdx); - }); - - if (!prevHadAny) { - const initialReqState: { [key: number]: boolean } = {}; - requirements.forEach((req, reqIdx) => { - const isIncomplete = req.progress < req.total; - const hasSuggested = req.categories?.some((c: any) => c.suggested?.length); - const hasContent = !!(req.categories?.length); - initialReqState[reqIdx] = (isIncomplete && hasContent) || !!hasSuggested; - }); - setAutoExpandedCategories(initialReqState); - } else if (reqKeysWithNew.size > 0) { - setAutoExpandedCategories((prev) => { - const next = { ...prev }; - reqKeysWithNew.forEach((idx) => { next[idx] = true; }); - return next; - }); - } - }, [requirements]); - - // profile -> planner hotlink - useEffect(() => { - if (!focusLabel) return; - - const newExpanded = { ...expandedSubcategories }; - let foundKey: string | null = null; - - const findAndExpand = (categories: any[], reqIdx: number, parentPath: string = "0"): boolean => { - return categories.some((category, catIdx) => { - const key = `${reqIdx}-${parentPath}-${catIdx}`; - - if (category.name.includes(focusLabel)) { - foundKey = key; - // don't touch newExpanded[key] — don't expand the target itself - return true; - } - - const childMatched = category.categories?.length - ? findAndExpand(category.categories, reqIdx, `${parentPath}-${catIdx}`) - : false; - - if (childMatched) { - newExpanded[key] = true; // expand ancestor only - } - - return childMatched; - }); - }; - - requirements.forEach((req, reqIdx) => { - const matched = findAndExpand(req.categories, reqIdx); - if (matched) setAutoExpandedCategories(prev => ({ ...prev, [reqIdx]: true })); - }); - - setExpandedSubcategories(newExpanded); - if (foundKey) setHighlightedKey(foundKey); - }, [focusLabel]); - - useEffect(() => { - if (!highlightedKey) return; - const el = document.querySelector(`[data-category-key="${highlightedKey}"]`); - el?.scrollIntoView({ behavior: "smooth", block: "center" }); - const timer = setTimeout(() => setHighlightedKey(null), 2000); - return () => clearTimeout(timer); - }, [highlightedKey]); - - const handleToggleSubcategory = (key: string) => { - setExpandedSubcategories((prev) => ({ - ...prev, - [key]: !prev[key], - })); - }; - - // Helper function to check if a category has any completion/progress - const hasCompletion = (category: any): boolean => { - // Check if the category itself has progress - if (category.progress > 0) return true; - - // Check if any classes are completed - if (category.classes && category.classes.length > 0) { - const hasCompletedClasses = category.classes.some((course: any) => - course.status === "completed" || course.status === "in progress" - ); - if (hasCompletedClasses) return true; - } - - if (category.suggested && category.suggested.length > 0) return true; - if (category.prereq_blocked && category.prereq_blocked.length > 0) return true; - - // Recursively check subcategories - if (category.categories && category.categories.length > 0) { - return category.categories.some((subcat: any) => hasCompletion(subcat)); - } - - return false; - }; - - // Helper function to filter categories based on OR/AND logic - const filterCategories = (categories: any[]): any[] => { - return categories.filter(category => { - const categoryName = category.name?.toUpperCase() || ''; - const isOR = categoryName === 'OR'; - const isAND = categoryName === 'AND'; - - // If it's an AND with no completion, don't show it - if (isAND && !hasCompletion(category)) { - return false; - } - - // If it's an OR, filter its children - if (isOR && category.categories && category.categories.length > 0) { - // Filter children to only show ANDs with completion - const childrenWithCompletion = category.categories.filter((child: any) => { - return hasCompletion(child); - }); - - // If OR has no children with completion, don't show it - if (childrenWithCompletion.length === 0) { - return false; - } - } - - return true; - }); - }; - - const renderCategoryContent = (category: any, reqIdx: number, nextParentPath: string, isOR: boolean, subCategoriesToRender: any[]): React.ReactNode => { - const suggestedCodes = new Set((category.suggested || []).map((c: any) => c.code)); - const filteredPrereqBlocked = (category.prereq_blocked || []).filter( - (c: any) => !suggestedCodes.has(c.code) - ); - - return ( - <> - {category.classes && category.classes.length > 0 ? ( - - ) : subCategoriesToRender.length > 0 ? null : ( - !category.suggested?.length && ( - category.evaluatable === false ? ( -
- Contact your advisor for more info on how to complete this requirement. -
- ) : ( -
No courses in this category
- ) - ) - )} - - {category.suggested && category.suggested.length > 0 && ( - <> -
- Suggested Courses -
- - - )} - - {filteredPrereqBlocked && filteredPrereqBlocked.length > 0 && ( - <> -
- Needs Prerequisites -
- - - )} - - {subCategoriesToRender.length > 0 && - renderCategories(subCategoriesToRender, reqIdx, nextParentPath, isOR)} - - ); - } - - const renderCategories = (categories: any[], reqIdx: number, parentPath: string = "0", parentIsOR: boolean = false): React.ReactNode[] => { - const filteredCategories = filterCategories(categories); - - return filteredCategories.map((category, catIdx) => { - const originalIdx = categories.indexOf(category); - const currentCatIdx = `${reqIdx}-${parentPath}-${originalIdx}`; - const nextParentPath = `${parentPath}-${originalIdx}`; - const categoryName = category.name?.toUpperCase() || ''; - const isOR = categoryName === 'OR'; - const isAND = categoryName === 'AND'; - - let displayName = category.name; - if (isOR) { - displayName = "Track Options"; - } else if (isAND && parentIsOR) { - displayName = `Track ${catIdx + 1}`; - } - - let subCategoriesToRender = category.categories || []; - if (isOR && subCategoriesToRender.length > 0) { - subCategoriesToRender = subCategoriesToRender.filter((child: any) => hasCompletion(child)); - } - - const content = renderCategoryContent(category, reqIdx, nextParentPath, isOR, subCategoriesToRender); - - const parts = displayName?.split('|').map((p: string) => p.trim()) || [displayName]; - - const completion = semesters ? getCompletionForCategory(category, semesters) : { completed: category.progress, total: category.total, isCreditBased: true }; - const creditsBreakdown = completion.isCreditBased && semesters ? getCreditsBreakdownRecursive(category, semesters) : undefined; - - if (parts.length > 1) { - - // Build from inside out — innermost part gets the content - return parts.reduceRight((inner: React.ReactNode, part: string, i: number) => { - const partKey = `${currentCatIdx}-part-${i}`; - return ( - handleToggleSubcategory(partKey)} - hasSubcategories={i < parts.length - 1 || subCategoriesToRender.length > 0} - creditsBreakdown={i === 0 ? creditsBreakdown : undefined} - footnote={i === 0 ? category.footnote : undefined} - rules={i === 0 ? category.rules : undefined} - > - {inner} - - ); - }, content); - } - - return ( - handleToggleSubcategory(currentCatIdx)} - hasSubcategories={subCategoriesToRender.length > 0} - creditsBreakdown={creditsBreakdown} - footnote={category.footnote} - rules={category.rules} - > - {content} - - ); - }); - }; - - const normalizedPlaced = new Set([...placedSuggestedCourses].map(c => c.toLowerCase().replace(/\s+/g, ''))); - const allStagedPlaced = stagedCourses.every(c => normalizedPlaced.has(c.course_id.toLowerCase().replace(/\s+/g, ''))); - - return ( - <> -
-
- {isExpanded ? ( -
-
- - -
- - - - {stagedCourses.length > 0 && ( -
-
-
- - Staged · {stagedCourses.length} course{stagedCourses.length !== 1 ? 's' : ''} -
- -
- -
- )} - - -

- Degree Requirements -

- -
- {requirements.map((req, reqIdx) => { - const reqCompletion = semesters ? getCompletionForCategory(req, semesters) : { completed: req.progress, total: req.total, isCreditBased: true }; - const reqCreditsBreakdown = reqCompletion.isCreditBased && semesters ? getCreditsBreakdownRecursive(req, semesters) : undefined; - return ( - { - setAutoExpandedCategories((prev) => ({ - ...prev, - [reqIdx]: !prev[reqIdx], - })); - }} - hasSubcategories={req.categories && req.categories.length > 0} - isFirstCategory={reqIdx === 0} - creditsBreakdown={reqCreditsBreakdown} - footnote={(req as any).footnote} - rules={(req as any).rules} - > - {req.categories && req.categories.length > 0 ? ( - renderCategories(req.categories, reqIdx) - ) : ( -
- No categories available -
- )} -
- ); - })} -
-
- ) : ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleToggleSidebar(); - } - }} - > - - - - - -
- -
- -
-
- )} -
-
- - ); -}; - -export default Sidebar; diff --git a/main/src/components/planner/YearDivider.tsx b/main/src/components/planner/YearDivider.tsx index e41e8b9..1b31d8e 100644 --- a/main/src/components/planner/YearDivider.tsx +++ b/main/src/components/planner/YearDivider.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ChevronUp, Download, Eraser, Menu, Plus, Trash2 } from 'lucide-react'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '@sage/ui'; import { exportYearAsCSV, exportYearAsJPG, exportYearAsPDF, exportYearAsPNG, SavedPlannerState } from '@/utils/planExport'; interface YearDividerProps { diff --git a/main/src/components/planner/utdgrades/sectioncard.tsx b/main/src/components/planner/utdgrades/sectioncard.tsx index ce2fb94..ffdccb8 100644 --- a/main/src/components/planner/utdgrades/sectioncard.tsx +++ b/main/src/components/planner/utdgrades/sectioncard.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@sage/ui"; import SectionContent from "@/components/planner/utdgrades/sectioncontent"; import { getAvgLetterGrade, getRMPColor, getGpaBadgeStyle } from "@/utils/grades"; import type { InstructorGrades } from "@/types/grades"; diff --git a/main/src/hooks/useChatbot.tsx b/main/src/hooks/useChatbot.tsx deleted file mode 100644 index 2b8df3b..0000000 --- a/main/src/hooks/useChatbot.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { useState } from 'react'; -import { useAuth } from '../context/AuthContext'; -import { Conversation } from "@/types/chat" - -const CONVERSATIONS_CACHE_EXPIRATION_TIME = 1000 * 60 * 60; - -export const useChatbot = () => { - const { user } = useAuth(); - const [conversations, setConversations] = useState([]); - const [conversation_id, setConversationId] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const CRUD_API = import.meta.env.VITE_CRUD_API; - - const isCacheValid = (timestamp: number, cacheUserId: any, cacheValidFor: number): boolean => { - if (!user?.uid || !timestamp || !cacheUserId) return false; - const currentTime = Date.now(); - return currentTime - timestamp < cacheValidFor && user.uid === cacheUserId; - }; - - - const initialLoad = async () => { - if (!user?.uid) return; - - const cachedData = localStorage.getItem("chatbot_conversation"); - if (cachedData) { - const { conversation_id, timestamp, cacheUserId } = JSON.parse(cachedData); - - if (timestamp && cacheUserId && isCacheValid(timestamp, cacheUserId, CONVERSATIONS_CACHE_EXPIRATION_TIME)) { - setConversationId(conversation_id || null); - - const cachedConversationsString = localStorage.getItem("chatbot_conversations"); - if (cachedConversationsString) { - const cachedConversations = JSON.parse(cachedConversationsString); - if ( - cachedConversations.timestamp && - cachedConversations.userId && - isCacheValid( - cachedConversations.timestamp, - cachedConversations.userId, - CONVERSATIONS_CACHE_EXPIRATION_TIME - ) - ) { - const cached = Array.isArray(cachedConversations.data) ? cachedConversations.data : []; - const processedConversations = cached.map((conv: Conversation) => ({ - ...conv, - title: conv.title || conv.conversation_name || conv.messages?.[0]?.content || "Untitled Conversation", - })); - const sorted = sortConversationsByDate(processedConversations); - setConversations(sorted); - return; - } - } - } else { - localStorage.removeItem("chatbot_conversation"); - } - } - - // No valid cache — fetch list (won't auto-load a thread; new chat screen by default) - await fetchConversation(); - }; - - const fetchConversation = async () => { - if (!user?.uid) { - console.warn("User ID is missing. Cannot fetch conversations."); - return; - } - - setLoading(true); - setError(null); - - try { - const cachedConversationsString = localStorage.getItem("chatbot_conversations"); - - if (cachedConversationsString) { - const cachedConversations = JSON.parse(cachedConversationsString); - if ( - cachedConversations.timestamp && - cachedConversations.userId && - isCacheValid( - cachedConversations.timestamp, - cachedConversations.userId, - CONVERSATIONS_CACHE_EXPIRATION_TIME - ) - ) { - const cached = Array.isArray(cachedConversations.data) ? cachedConversations.data : []; - const processedConversations = cached.map((conv: Conversation) => ({ - ...conv, - title: conv.title || conv.conversation_name || conv.messages?.[0]?.content || "Untitled Conversation", - })); - const sorted = sortConversationsByDate(processedConversations); - setConversations(sorted); - setLoading(false); - return cached; - } - } - - if (!CRUD_API) throw new Error("CRUD_API environment variable is missing."); - - const token = await user.getIdToken(); - if (!token) throw new Error("Failed to retrieve authentication token."); - - const response = await fetch(CRUD_API, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - userId: user?.uid, - action: "getConversations", - token, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch conversations: ${response.status} - ${errorText}`); - } - - const data = await response.json(); - - const convs: Conversation[] = Array.isArray(data) - ? data.map((conv: Conversation) => ({ - ...conv, - title: conv.title || conv.conversation_name || conv.messages?.[0]?.content || "Untitled Conversation", - })) - : []; - - const sorted = sortConversationsByDate(convs); - setConversations(sorted); - saveConversationsToCache(sorted); - return sorted; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to fetch conversations"; - setError(errorMessage); - console.error("Error fetching conversation:", err); - } finally { - setLoading(false); - } - }; - - - const saveConversationsToCache = (convs: any[]) => { - localStorage.setItem( - "chatbot_conversations", - JSON.stringify({ - data: convs, - timestamp: Date.now(), - userId: user?.uid, - }) - ); - }; - - const sortConversationsByDate = (convs: Conversation[]) => { - return convs.sort((a: Conversation, b: Conversation) => { - const aTime = new Date(a.messages?.[a.messages.length - 1]?.timestamp || 0).getTime(); - const bTime = new Date(b.messages?.[b.messages.length - 1]?.timestamp || 0).getTime(); - return bTime - aTime; - }); - }; - - const deleteConversation = async (conversationId: string) => { - if (!user?.uid) return; - setError(null); - - // Optimistic cache update - setConversations((prev) => prev.filter((item) => item.conversation_id !== conversationId)); - - const cachedString = localStorage.getItem("chatbot_conversations"); - if (cachedString) { - const cached = JSON.parse(cachedString); - if (cached?.data) { - localStorage.setItem("chatbot_conversations", JSON.stringify({ - ...cached, - data: cached.data.filter((item: Conversation) => item.conversation_id !== conversationId), - })); - } - } - - if (!CRUD_API) throw new Error("CRUD_API environment variable is missing."); - const token = await user.getIdToken(); - if (!token) throw new Error("Failed to retrieve authentication token."); - - const response = await fetch(CRUD_API, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: user.uid, action: "deleteConversation", token, conversationId }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to delete conversation: ${response.status} - ${errorText}`); - } - - // Remove from state - setConversations((prev) => prev.filter((item) => item.conversation_id !== conversationId)); - }; - - const renameConversation = async (conversationId: string, newTitle: string) => { - if (!user?.uid) { - console.warn("User ID is missing. Cannot rename conversation."); - return; - } - setError(null); - - try { - // Optimistic state update - same as desktop - setConversations((prev) => { - const updated = prev.map((conv) => - conv.conversation_id === conversationId - ? { ...conv, title: newTitle, conversation_name: newTitle } - : conv - ); - return updated; - }); - - // Update local storage - same as desktop - const cachedConversationsString = localStorage.getItem("chatbot_conversations"); - if (cachedConversationsString) { - const cached = JSON.parse(cachedConversationsString); - if (cached?.data) { - const updatedCache = { - ...cached, - data: cached.data.map((item: Conversation) => - item.conversation_id === conversationId ? { ...item, title: newTitle, conversation_name: newTitle } : item - ), - }; - localStorage.setItem("chatbot_conversations", JSON.stringify(updatedCache)); - } - } - - if (!CRUD_API) throw new Error("CRUD_API environment variable is missing."); - const token = await user.getIdToken(); - if (!token) throw new Error("Failed to retrieve authentication token."); - - const response = await fetch(CRUD_API, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - userId: user.uid, - action: "renameConversation", - token, - conversationId, - newName: newTitle, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - - if (response.status === 404) { // attempted rename of a deleted convo in backend - setConversations((prev) => prev.filter((item) => item.conversation_id !== conversation_id)); - saveConversationsToCache(conversations.filter((item) => item.conversation_id !== conversation_id)); - } - - throw new Error(`Failed to rename conversation: ${response.status} - ${errorText}`); - } - - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to rename conversation"; - setError(msg); - console.error("Mobile navbar: Error renaming conversation:", err); - } - }; - - return { - conversations, - conversation_id, - setConversations, - loading, - error, - fetchConversation, - deleteConversation, - renameConversation, - setConversationId, - initialLoad - }; -}; \ No newline at end of file diff --git a/main/src/hooks/usePlannerSidebarCategories.ts b/main/src/hooks/usePlannerSidebarCategories.ts new file mode 100644 index 0000000..824675d --- /dev/null +++ b/main/src/hooks/usePlannerSidebarCategories.ts @@ -0,0 +1,213 @@ +import { useState, useEffect, useRef, useMemo } from "react"; +import { collectAllSuggestedCourses } from "@/utils/plannerSidebarUtils"; + +interface UsePlannerSidebarCategoriesOptions { + requirements: any[]; + focusLabel?: string; +} + +interface UsePlannerSidebarCategoriesResult { + autoExpandedCategories: { [key: number]: boolean }; + setAutoExpandedCategories: React.Dispatch>; + expandedSubcategories: Record; + handleToggleSubcategory: (key: string) => void; + highlightedKey: string | null; + allSuggestedCourses: any[]; +} + +export function usePlannerSidebarCategories({ + requirements, + focusLabel, +}: UsePlannerSidebarCategoriesOptions): UsePlannerSidebarCategoriesResult { + const [expandedSubcategories, setExpandedSubcategories] = useState>({}); + const [autoExpandedCategories, setAutoExpandedCategories] = useState<{ [key: number]: boolean }>({}); + const [highlightedKey, setHighlightedKey] = useState(null); + const prevSuggestedByKeyRef = useRef>>({}); + + const allSuggestedCourses = useMemo(() => collectAllSuggestedCourses(requirements), [requirements]); + + useEffect(() => { + const getSuggestedCodes = (c: any): Set => + new Set((c?.suggested || []).map((s: any) => String(s.code || s.course_code || "").trim().toUpperCase()).filter(Boolean)); + + const buildSuggestedByKey = (categories: any[], reqIdx: number, parentIdx: string): Record> => { + const out: Record> = {}; + categories.forEach((category, catIdx) => { + const key = `${reqIdx}-${parentIdx}-${catIdx}`; + out[key] = getSuggestedCodes(category); + if (category.categories?.length) { + Object.assign(out, buildSuggestedByKey(category.categories, reqIdx, `${parentIdx}-${catIdx}`)); + } + }); + return out; + }; + + const newSuggestedByKey: Record> = {}; + requirements.forEach((req, reqIdx) => { + if (req.categories?.length) { + Object.assign(newSuggestedByKey, buildSuggestedByKey(req.categories, reqIdx, "0")); + } + }); + + const keysWithNewSuggestions = new Set(); + Object.entries(newSuggestedByKey).forEach(([key, newCodes]) => { + const prevCodes = prevSuggestedByKeyRef.current[key]; + if (!prevCodes) return; + if (newCodes.size > prevCodes.size) keysWithNewSuggestions.add(key); + }); + + const withAncestors = new Set(keysWithNewSuggestions); + keysWithNewSuggestions.forEach((key) => { + let k = key; + while (true) { + const lastDash = k.lastIndexOf("-"); + if (lastDash <= 0) break; + k = k.slice(0, lastDash); + withAncestors.add(k); + } + }); + const keysToExpand = withAncestors; + + const prevHadAny = Object.keys(prevSuggestedByKeyRef.current).length > 0; + prevSuggestedByKeyRef.current = newSuggestedByKey; + + const keysWithSuggestedOnInitial = new Set(); + if (!prevHadAny) { + Object.entries(newSuggestedByKey).forEach(([key, codes]) => { + if (codes.size > 0) keysWithSuggestedOnInitial.add(key); + }); + keysWithSuggestedOnInitial.forEach((key) => { + let k = key; + while (true) { + const lastDash = k.lastIndexOf("-"); + if (lastDash <= 0) break; + k = k.slice(0, lastDash); + keysWithSuggestedOnInitial.add(k); + } + }); + } + + const initializeCategories = (categories: any[], reqIdx: number, parentIdx: string): Record => { + const result: Record = {}; + categories.forEach((category, catIdx) => { + const key = `${reqIdx}-${parentIdx}-${catIdx}`; + const isIncomplete = category.progress < category.total && category.total > 0; + const defaultExpanded = prevHadAny + ? isIncomplete + : keysWithSuggestedOnInitial.has(key) || isIncomplete; + const gotNewSuggestions = keysToExpand.has(key); + let expanded: boolean; + if (prevHadAny && expandedSubcategories[key] === false && !gotNewSuggestions) { + expanded = false; + } else if (gotNewSuggestions) { + expanded = true; + } else if (prevHadAny && key in expandedSubcategories) { + expanded = expandedSubcategories[key]; + } else { + expanded = defaultExpanded; + } + result[key] = expanded; + const parts = (category.name || '').split('|').map((p: string) => p.trim()); + if (parts.length > 1) { + parts.forEach((_: string, i: number) => { + const partKey = `${key}-part-${i}`; + if (gotNewSuggestions) { + result[partKey] = true; + } else if (!(partKey in expandedSubcategories)) { + result[partKey] = defaultExpanded; + } else { + result[partKey] = expandedSubcategories[partKey]; + } + }); + } + if (category.categories?.length) { + Object.assign(result, initializeCategories(category.categories, reqIdx, `${parentIdx}-${catIdx}`)); + } + }); + return result; + }; + + const initialState: Record = {}; + requirements.forEach((req, reqIdx) => { + if (req.categories?.length) { + Object.assign(initialState, initializeCategories(req.categories, reqIdx, "0")); + } + }); + setExpandedSubcategories(initialState); + + const reqKeysWithNew = new Set(); + keysToExpand.forEach((k) => { + const reqIdx = parseInt(k.split("-")[0], 10); + if (!isNaN(reqIdx)) reqKeysWithNew.add(reqIdx); + }); + + if (!prevHadAny) { + const initialReqState: { [key: number]: boolean } = {}; + requirements.forEach((req, reqIdx) => { + const isIncomplete = req.progress < req.total; + const hasSuggested = req.categories?.some((c: any) => c.suggested?.length); + const hasContent = !!(req.categories?.length); + initialReqState[reqIdx] = (isIncomplete && hasContent) || !!hasSuggested; + }); + setAutoExpandedCategories(initialReqState); + } else if (reqKeysWithNew.size > 0) { + setAutoExpandedCategories((prev) => { + const next = { ...prev }; + reqKeysWithNew.forEach((idx) => { next[idx] = true; }); + return next; + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requirements]); + + useEffect(() => { + if (!focusLabel) return; + const newExpanded = { ...expandedSubcategories }; + let foundKey: string | null = null; + + const findAndExpand = (categories: any[], reqIdx: number, parentPath = "0"): boolean => { + return categories.some((category, catIdx) => { + const key = `${reqIdx}-${parentPath}-${catIdx}`; + if (category.name?.includes(focusLabel)) { + foundKey = key; + return true; + } + const childMatched = category.categories?.length + ? findAndExpand(category.categories, reqIdx, `${parentPath}-${catIdx}`) + : false; + if (childMatched) newExpanded[key] = true; + return childMatched; + }); + }; + + requirements.forEach((req, reqIdx) => { + const matched = findAndExpand(req.categories ?? [], reqIdx); + if (matched) setAutoExpandedCategories((prev) => ({ ...prev, [reqIdx]: true })); + }); + + setExpandedSubcategories(newExpanded); + if (foundKey) setHighlightedKey(foundKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusLabel]); + + useEffect(() => { + if (!highlightedKey) return; + const el = document.querySelector(`[data-category-key="${highlightedKey}"]`); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + const timer = setTimeout(() => setHighlightedKey(null), 2000); + return () => clearTimeout(timer); + }, [highlightedKey]); + + const handleToggleSubcategory = (key: string) => { + setExpandedSubcategories((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + return { + autoExpandedCategories, + setAutoExpandedCategories, + expandedSubcategories, + handleToggleSubcategory, + highlightedKey, + allSuggestedCourses, + }; +} diff --git a/main/src/hooks/useRouteMode.ts b/main/src/hooks/useRouteMode.ts new file mode 100644 index 0000000..915eb15 --- /dev/null +++ b/main/src/hooks/useRouteMode.ts @@ -0,0 +1,13 @@ +import { useLocation } from "react-router-dom"; + +const PUBLIC_ROUTES = ["/", "/login", "/signup", "/forgot-password"]; + +export function useRouteMode() { + const location = useLocation(); + const isInWebapp = !PUBLIC_ROUTES.includes(location.pathname); + + return { + isInWebapp, + pathname: location.pathname, + }; +} diff --git a/main/src/lib/navLinks.ts b/main/src/lib/navLinks.ts new file mode 100644 index 0000000..420551a --- /dev/null +++ b/main/src/lib/navLinks.ts @@ -0,0 +1,12 @@ +import { Route, MessageCirclePlus, UserRound } from "lucide-react"; +import type { NavLinkItem } from "@sage/ui"; + +export const PRIMARY_NAV_LINKS: NavLinkItem[] = [ + { to: "/planner", label: "Plan your degree", icon: Route }, + { to: "/chatbot", label: "Start a chat", icon: MessageCirclePlus }, +]; + +export const MOBILE_NAV_LINKS: NavLinkItem[] = [ + ...PRIMARY_NAV_LINKS, + { to: "/profile", label: "Your Profile", icon: UserRound }, +]; diff --git a/main/src/lib/utils.ts b/main/src/lib/utils.ts deleted file mode 100644 index bd0c391..0000000 --- a/main/src/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} diff --git a/main/src/pages/ChatBot.tsx b/main/src/pages/ChatBot.tsx index 3423510..f492e19 100644 --- a/main/src/pages/ChatBot.tsx +++ b/main/src/pages/ChatBot.tsx @@ -1,27 +1,17 @@ import React, { useEffect, useRef, useState } from "react"; import { useAuth } from "../context/AuthContext"; import { - ArrowLeftToLineIcon, - ArrowRightToLineIcon, CornerRightUpIcon, - MessageCirclePlusIcon, GraduationCapIcon, CalendarSearchIcon, - SquareAsterisk, - PanelLeftDashed, - Trash2, - Pencil, - Ellipsis, HelpCircle, } from "lucide-react"; import { v4 as uuidv4 } from "uuid"; import MessageDisplay from "@/components/chatbot/MessageDisplay"; -import { chatEventEmitter } from "../utils/chatEventEmitter"; -import { useChatbot } from "@/hooks/useChatbot"; -import { Message, Conversation } from "@/types/chat"; +import { useChatbotStore } from "@/stores/chatbotStore"; +import type { Message, Conversation } from "@/types/chat"; import { useChatbotTutorial } from "@/hooks/useChatbotTutorial"; - -const CONVERSATIONS_CACHE_EXPIRATION_TIME = 1000 * 60 * 60; +import ChatSidebarShell from "@/components/chatbot/ChatSidebarShell"; const hydrateMessages = (msgs: Message[]): Message[] => msgs.map((msg) => { @@ -34,79 +24,46 @@ const hydrateMessages = (msgs: Message[]): Message[] => if (parsed?.type === "schedule") { return { ...msg, type: "schedule" as const, variants: parsed.variants, content: "" }; } - } catch { /* plain string, leave as is */ } + } catch { /* plain string */ } } return msg; }); +const sortConversationsByDate = (convs: Conversation[]): Conversation[] => + [...convs].sort((a, b) => { + const aTime = new Date(a.messages?.[a.messages.length - 1]?.timestamp || 0).getTime(); + const bTime = new Date(b.messages?.[b.messages.length - 1]?.timestamp || 0).getTime(); + return bTime - aTime; + }); + +const CHAT_API = import.meta.env.VITE_CHAT_API as string | undefined; + const ChatBot: React.FC = () => { const { user, hasSeenChatbotTutorial } = useAuth(); - const [query, setQuery] = useState(""); - const handleClickQueryFlag = useRef(false); - const ellipsisButtonRef = useRef(null); - const renameModalRef = useRef(null); - const contextMenuRef = useRef(null); - const [messages, setMessages] = useState([]); const { conversations, - conversation_id, - error, + activeConversationId, loading, + initialLoad, + setActiveConversationId, + startNewChat: storeStartNewChat, setConversations, - deleteConversation, - renameConversation, - fetchConversation, - setConversationId, - initialLoad - } = useChatbot(); + } = useChatbotStore(); - const { startTutorial } = useChatbotTutorial({ user, hasSeenTutorial: hasSeenChatbotTutorial }); - - const updateConversations = (newConversations: Conversation[] | ((prev: Conversation[]) => Conversation[])) => { - if (typeof newConversations === 'function') { - setConversations((prevConversations) => { - const updatedList = newConversations(prevConversations); - console.log('ChatBot emitting conversation update:', updatedList.length, 'conversations'); - chatEventEmitter.emit('conversationUpdate', updatedList); - return updatedList; - }); - } else { - setConversations(newConversations); - console.log('ChatBot emitting conversation update:', newConversations.length, 'conversations'); - chatEventEmitter.emit('conversationUpdate', newConversations); - } - }; + const [query, setQuery] = useState(""); + const handleClickQueryFlag = useRef(false); + const textareaRef = useRef(null); + const chatContainerRef = useRef(null); + const [messages, setMessages] = useState([]); - const updateConversationId = (newId: string | null) => { - setConversationId(newId); - chatEventEmitter.emit('activeConversationUpdate', newId); - }; + const { startTutorial } = useChatbotTutorial({ user, hasSeenTutorial: hasSeenChatbotTutorial }); const [chatLoad, setChatLoad] = useState(false); const [chatError, setChatError] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsedDelayed, setSidebarCollapsedDelayed] = useState(false); - const [isNewConversation, setIsNewConversation] = useState(false); + const [isNewConversation, setIsNewConversation] = useState(false); const [generateSchedule, setGenerateSchedule] = useState(false); - - const [showContextMenu, setShowContextMenu] = useState(false); - const [contextMenuPosition, setContextMenuPosition] = useState({ top: 0, left: 0 }); - const contextButtonRefs = useRef<(HTMLLIElement | null)[]>([]); - - const textareaRef = useRef(null); - const chatContainerRef = useRef(null); - const [moreOptionsOpenId, setMoreOptionsOpenId] = useState(null); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [showRenameModal, setShowRenameModal] = useState(false); - const [newName, setNewName] = useState(""); - const [renaming, setRenaming] = useState(false); - const [conversationToRename, setConversationToRename] = useState(null); - const [conversationToDelete, setConversationToDelete] = useState(null); - const [deleting, setDeleting] = useState(false); - const conversationListRef = useRef(null); - - const CHAT_API = import.meta.env.VITE_CHAT_API as string | undefined; - const [mobileView, setMobileView] = useState(false); const advisingExampleQuestions = [ @@ -138,102 +95,38 @@ const ChatBot: React.FC = () => { }; const toggleSidebar = () => { - let sidebarDelay = 0; setSidebarCollapsed((prev) => !prev); - if (sidebarCollapsed) { - sidebarDelay = 80; - } - setTimeout(() => { - setSidebarCollapsedDelayed((prev) => !prev); - }, sidebarDelay); + const delay = sidebarCollapsed ? 80 : 0; + setTimeout(() => setSidebarCollapsedDelayed((prev) => !prev), delay); }; - const isCacheValid = ( - timestamp: number | null | undefined, - cacheUserId: string | null | undefined, - cacheValidFor: number - ): boolean => { - if (!user?.uid || !timestamp || !cacheUserId) return false; - const currentTime = Date.now(); - return currentTime - timestamp < cacheValidFor && user.uid === cacheUserId; - }; - - const handleOutsideClick = () => { - setMoreOptionsOpenId(null); - setShowContextMenu(false); - }; - - const saveConversationsToCache = (convs: Conversation[]) => { - localStorage.setItem( - "chatbot_conversations", - JSON.stringify({ - data: convs, - timestamp: Date.now(), - userId: user?.uid ?? null, - }) - ); - }; - - const startNewChat = () => { + const handleStartNewChat = () => { setChatError(null); - if (messages.length > 0 && conversation_id) { - updateConversations((prevConversations) => { - if (!Array.isArray(prevConversations)) return []; - const filteredConversations = prevConversations.filter((conv) => conv.conversation_id !== conversation_id); - return [ - { - conversation_id, - user_id: user?.uid || "test-user-123", - messages, - }, - ...filteredConversations, - ]; - }); + if (messages.length > 0 && activeConversationId) { + const filtered = conversations.filter((c) => c.conversation_id !== activeConversationId); + const updated = sortConversationsByDate([ + { conversation_id: activeConversationId, user_id: user?.uid || "test-user-123", messages }, + ...filtered, + ]); + setConversations(updated, user?.uid); } - const newConversationId = `conversation_${uuidv4()}`; - loadConversation(newConversationId, []); + setMessages([]); setIsNewConversation(true); - - if (chatContainerRef.current) { - chatContainerRef.current.scrollTop = 0; - } + storeStartNewChat(); localStorage.setItem( "chatbot_conversation", JSON.stringify({ messages: [], - conversation_id: newConversationId, + conversation_id: null, timestamp: Date.now(), cacheUserId: user?.uid ?? null, }) ); - setTimeout(() => { - textareaRef.current?.focus(); - }, 0); - }; - - const sortConversationsByDate = (convs: Conversation[]): Conversation[] => { - return [...convs].sort((a, b) => { - const aTime = new Date(a.messages?.[a.messages.length - 1]?.timestamp || 0).getTime(); - const bTime = new Date(b.messages?.[b.messages.length - 1]?.timestamp || 0).getTime(); - return bTime - aTime; - }); - }; - - const loadConversation = async (id: string, convMessages: Message[]) => { - updateConversationId(id); - setMessages(convMessages); - localStorage.setItem( - "chatbot_conversation", - JSON.stringify({ - messages: convMessages, - conversation_id: id, - timestamp: Date.now(), - cacheUserId: user?.uid ?? null, - }) - ); + if (chatContainerRef.current) chatContainerRef.current.scrollTop = 0; + setTimeout(() => textareaRef.current?.focus(), 0); }; const handleEnter = (e: React.KeyboardEvent) => { @@ -244,58 +137,40 @@ const ChatBot: React.FC = () => { }; const handleSendQuery = async () => { - if (!query.trim()) { - console.warn("Query is empty, aborting request."); - return; - } - - if (query.trim().length > 500) { - if (chatContainerRef.current) { - chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; - } - return; - } - - console.log("Sending query:", query); + if (!query.trim() || query.trim().length > 500) return; setChatLoad(true); setChatError(null); const userMessage: Message = { role: "user", content: query, timestamp: Date.now() }; - const updatedMessagesWithUser = [...messages, userMessage]; - setMessages(updatedMessagesWithUser); + const messagesWithUser = [...messages, userMessage]; + setMessages(messagesWithUser); localStorage.setItem( "chatbot_conversation", JSON.stringify({ - messages: updatedMessagesWithUser, - conversation_id, + messages: messagesWithUser, + conversation_id: activeConversationId, timestamp: Date.now(), cacheUserId: user?.uid ?? null, }) ); - const token = await user?.getIdToken(); - if (!token) throw new Error("Failed to retrieve authentication token."); - - if (!CHAT_API) { - console.error("CHAT_API is missing. Check your .env file."); - setChatLoad(false); - return; - } - - const requestBody: Record = { - id: user?.uid, - query: query, - generate_schedule: generateSchedule, - token: token - }; - - if (conversation_id) requestBody.conversation_id = conversation_id; - setQuery(""); try { + const token = await user?.getIdToken(); + if (!token) throw new Error("Failed to retrieve authentication token."); + if (!CHAT_API) throw new Error("CHAT_API is missing."); + + const requestBody: Record = { + id: user?.uid, + query: userMessage.content, + generate_schedule: generateSchedule, + token, + }; + if (activeConversationId) requestBody.conversation_id = activeConversationId; + const response = await fetch(CHAT_API, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -305,70 +180,52 @@ const ChatBot: React.FC = () => { if (!response.ok) { const errorText = await response.text(); let parsed: any = null; - try { - parsed = JSON.parse(errorText); - } catch { - // ignore parse error - } + try { parsed = JSON.parse(errorText); } catch { /* ignore */ } if (response.status === 401 && parsed?.error === "Daily query limit reached. Try again tomorrow.") { setChatError("Daily query limit reached. Try again tomorrow."); } else if (response.status === 404) { setChatError("This conversation no longer exists."); - setConversationId(null); + setActiveConversationId(null); } else { - throw new Error(`Failed to get chatbot response: ${response.status} - ${errorText}`); + throw new Error(`Chatbot API ${response.status}: ${errorText}`); } return; } const data = await response.json(); - if (!data.response) { - throw new Error("Chatbot API did not return a response."); - } + if (!data.response) throw new Error("Chatbot API did not return a response."); const botMessage: Message = data.type === "email" - ? { - role: "assistant", - content: JSON.stringify({ type: "email", variants: data.response.variants }), - type: "email", - variants: data.response.variants, - timestamp: Date.now(), - } + ? { role: "assistant", content: JSON.stringify({ type: "email", variants: data.response.variants }), type: "email", variants: data.response.variants, timestamp: Date.now() } : data.type === "schedule" - ? { role: "assistant", content: JSON.stringify({ type: "schedule", variants: data.response.variants }), type: "schedule", variants: data.response.variants, timestamp: Date.now() } - : { - role: "assistant", - content: data.response, - timestamp: Date.now(), - }; - - const updatedMessagesWithBot = [...updatedMessagesWithUser, botMessage]; - setMessages(updatedMessagesWithBot); - - const currentConvId: string = data.conversation_id || conversation_id || `conversation_${uuidv4()}`; - updateConversationId(currentConvId); - - updateConversations((prevConversations) => { - const filtered = prevConversations.filter((conv) => conv.conversation_id !== currentConvId); - const existingConv = prevConversations.find((conv) => conv.conversation_id === currentConvId); - const newConv = { - conversation_id: currentConvId!, - user_id: user?.uid || "test-user-123", - messages: updatedMessagesWithBot, - title: existingConv?.title || updatedMessagesWithBot[0]?.content || "Untitled Conversation", - }; - const updated = sortConversationsByDate([newConv, ...filtered]); - saveConversationsToCache(updated); - return updated; - }); + ? { role: "assistant", content: JSON.stringify({ type: "schedule", variants: data.response.variants }), type: "schedule", variants: data.response.variants, timestamp: Date.now() } + : { role: "assistant", content: data.response, timestamp: Date.now() }; + + const updatedMessages = [...messagesWithUser, botMessage]; + setMessages(updatedMessages); + + const currentConvId: string = data.conversation_id || activeConversationId || `conversation_${uuidv4()}`; + setActiveConversationId(currentConvId); + + const existingConv = conversations.find((c) => c.conversation_id === currentConvId); + const newConv: Conversation = { + conversation_id: currentConvId, + user_id: user?.uid || "test-user-123", + messages: updatedMessages, + title: existingConv?.title || updatedMessages[0]?.content || "Untitled Conversation", + }; + setConversations( + sortConversationsByDate([newConv, ...conversations.filter((c) => c.conversation_id !== currentConvId)]), + user?.uid + ); if (isNewConversation) setIsNewConversation(false); localStorage.setItem( "chatbot_conversation", JSON.stringify({ - messages: updatedMessagesWithBot, + messages: updatedMessages, conversation_id: currentConvId, timestamp: Date.now(), cacheUserId: user?.uid ?? null, @@ -382,141 +239,24 @@ const ChatBot: React.FC = () => { } }; - // accessibility stuff - const deleteModalRef = useRef(null); - - useEffect(() => { - if (showRenameModal && renameModalRef.current) { - renameModalRef.current.focus(); - } - }, [showRenameModal]); - - useEffect(() => { - if (showDeleteModal && deleteModalRef.current) { - deleteModalRef.current.focus(); - } - }, [showDeleteModal]); - - useEffect(() => { - if (moreOptionsOpenId && showContextMenu && contextMenuRef.current) { - const firstItem = contextMenuRef.current.querySelector('[role="menuitem"]'); - firstItem?.focus(); - } - }, [moreOptionsOpenId, showContextMenu]); - - const handleDeleteModalKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - setShowDeleteModal(false); - setConversationToDelete(null); - return; - } - if (e.key !== "Tab") return; - const focusable = deleteModalRef.current?.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - if (!focusable || focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (e.shiftKey) { - if (document.activeElement === first) { e.preventDefault(); last.focus(); } - } else { - if (document.activeElement === last) { e.preventDefault(); first.focus(); } - } - }; - - const handleRenameModalKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - setShowRenameModal(false); - setNewName(""); - return; - } - if (e.key !== "Tab") return; - const focusable = renameModalRef.current?.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - if (!focusable || focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (e.shiftKey) { - if (document.activeElement === first) { e.preventDefault(); last.focus(); } - } else { - if (document.activeElement === last) { e.preventDefault(); first.focus(); } - } - }; - - const handleContextMenuKeyDown = (e: React.KeyboardEvent) => { - const items = Array.from( - contextMenuRef.current?.querySelectorAll('[role="menuitem"]') ?? [] - ); - if (!items.length) return; - const currentIdx = items.indexOf(document.activeElement as HTMLElement); - - if (e.key === "ArrowDown") { - e.preventDefault(); - items[(currentIdx + 1) % items.length]?.focus(); - } - if (e.key === "ArrowUp") { - e.preventDefault(); - items[(currentIdx - 1 + items.length) % items.length]?.focus(); - } - if (e.key === "Escape") { - e.preventDefault(); - setMoreOptionsOpenId(null); - setShowContextMenu(false); - ellipsisButtonRef.current?.focus(); - } - }; - useEffect(() => { if (window.innerWidth < 768) setMobileView(true); - (async () => { - await initialLoad(); - })(); + if (user) initialLoad(user); adjustTextareaHeight(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const handleStorageChange = (e: StorageEvent) => { - if (e.key === 'chatbot_conversations' && e.newValue) { + if (e.key === "chatbot_conversations" && e.newValue) { const cached = JSON.parse(e.newValue); - if (cached.data) { - setConversations(cached.data); - } + if (cached.data) setConversations(cached.data); } }; - window.addEventListener('storage', handleStorageChange); - return () => window.removeEventListener('storage', handleStorageChange); + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); }, [setConversations]); - useEffect(() => { - const handleStartNewChat = () => { startNewChat(); }; - const handleLoadConversation = (data: { conversationId: string; messages: Message[]; userId?: string }) => { - loadConversation(data.conversationId, data.messages); - }; - const handleConversationRenamed = (data: { conversationId: string; newTitle: string }) => { - updateConversations((prev) => - prev.map((conv) => (conv.conversation_id === data.conversationId ? { ...conv, title: data.newTitle } : conv)) - ); - }; - const handleRequestConversations = () => { - console.log('ChatBot received request for conversations, sending:', conversations.length, 'conversations'); - chatEventEmitter.emit('conversationUpdate', conversations); - }; - - chatEventEmitter.on('startNewChat', handleStartNewChat); - chatEventEmitter.on('loadConversation', handleLoadConversation); - chatEventEmitter.on('conversationRenamed', handleConversationRenamed); - chatEventEmitter.on('requestConversations', handleRequestConversations); - - return () => { - chatEventEmitter.off('startNewChat', handleStartNewChat); - chatEventEmitter.off('loadConversation', handleLoadConversation); - chatEventEmitter.off('conversationRenamed', handleConversationRenamed); - chatEventEmitter.off('requestConversations', handleRequestConversations); - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { if (chatContainerRef.current) { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; @@ -528,467 +268,177 @@ const ChatBot: React.FC = () => { }, [query]); useEffect(() => { - updateScrollPosition(); + if (handleClickQueryFlag.current) { + handleSendQuery(); + handleClickQueryFlag.current = false; + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [moreOptionsOpenId]); + }, [query]); - function updateScrollPosition() { - if (!conversations?.length) return; - const idx = conversations.findIndex((c) => c.conversation_id === moreOptionsOpenId); - if (idx < 0) { - setShowContextMenu(false); + useEffect(() => { + if (!activeConversationId) { + setMessages([]); return; } - const activeContextRef = contextButtonRefs.current[idx]; - if (activeContextRef) { - const rect = activeContextRef.getBoundingClientRect(); - setContextMenuPosition({ top: rect.top, left: rect.left }); - setShowContextMenu(true); - } else { - setShowContextMenu(false); - } - } - - useEffect(() => { - const reloadChatHistory = async () => { - if (!conversation_id) return; - - try { - const cachedConversationsString = localStorage.getItem("chatbot_conversations"); - if (cachedConversationsString) { - const cachedConversations = JSON.parse(cachedConversationsString); - if ( - cachedConversations.timestamp && - cachedConversations.userId && - isCacheValid( - cachedConversations.timestamp, - cachedConversations.userId, - CONVERSATIONS_CACHE_EXPIRATION_TIME - ) - ) { - console.log("Using cached conversations for history"); - const selectedConversation: Conversation | undefined = cachedConversations.data.find( - (conv: Conversation) => conv.conversation_id === conversation_id - ); - - if (selectedConversation) { - // ---- Hydrate email messages from localStorage cache ---- - const hydratedMessages = hydrateMessages(selectedConversation.messages || []); - setMessages(hydratedMessages); - localStorage.setItem( - "chatbot_conversation", - JSON.stringify({ - messages: hydratedMessages, - conversation_id, - timestamp: Date.now(), - cacheUserId: user?.uid ?? null, - }) - ); - return; - } - } - } + const conv = conversations.find((c) => c.conversation_id === activeConversationId); + if (!conv) return; + const hydrated = hydrateMessages(conv.messages || []); + setMessages(hydrated); + localStorage.setItem( + "chatbot_conversation", + JSON.stringify({ + messages: hydrated, + conversation_id: activeConversationId, + timestamp: Date.now(), + cacheUserId: user?.uid ?? null, + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeConversationId, conversations]); + + const emptyStateAdvising = ( +
+

Hi, I'm Sage.

+

What can I help with?

+

Here are some example questions that I can help you with:

+
    + {advisingExampleQuestions.map((example) => ( +
  • + +
  • + ))} +
+
+ ); - const data = await fetchConversation(); - if (!Array.isArray(data)) return; + const emptyStateSchedule = ( +
+

Hi, I'm Sage.

+

Let's start building your schedule!

+

Here are some example queries for the schedule generator that I can help you with:

+
    + {scheduleExampleQuestions.map((example) => ( +
  • + +
  • + ))} +
+
+ ); - const selectedConversation = data.find((conv) => conv.conversation_id === conversation_id); - if (selectedConversation) { - // ---- Hydrate email messages from S3 ---- - const hydratedMessages = hydrateMessages(selectedConversation.messages || []); - setMessages(hydratedMessages); - localStorage.setItem( - "chatbot_conversation", - JSON.stringify({ - messages: hydratedMessages, - conversation_id, - timestamp: Date.now(), - cacheUserId: user?.uid ?? null, - }) - ); - } else { - setConversationId(null); - setMessages([]); - localStorage.removeItem("chatbot_conversation"); - } - } catch (err) { - console.error("Error loading chat history:", err); - } - }; + const chatArea = ( +
+
+ {messages.length === 0 && !chatLoad && !generateSchedule && emptyStateAdvising} + {messages.length === 0 && !chatLoad && generateSchedule && emptyStateSchedule} + {messages.length > 0 && messages.map((msg, index) => ( + + ))} + {chatLoad && !chatError && ( +
+ Thinking... +
+ )} + {chatError &&
{chatError}
} +
+
+ ); - reloadChatHistory(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [conversation_id]); + const queryInput = ( +
+