diff --git a/.astro/settings.json b/.astro/settings.json index ac716c5b2..cde26c11e 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1753099755914 + "lastUpdateCheck": 1753810743067 } } \ No newline at end of file diff --git a/src/components/PersonalizedRoadmap/PersonalizedRoadmap.tsx b/src/components/PersonalizedRoadmap/PersonalizedRoadmap.tsx new file mode 100644 index 000000000..4f05ee946 --- /dev/null +++ b/src/components/PersonalizedRoadmap/PersonalizedRoadmap.tsx @@ -0,0 +1,247 @@ +import { Loader2Icon, PersonStandingIcon } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { usePersonalizedRoadmap } from '../../hooks/use-personalized-roadmap'; +import { + refreshProgressCounters, + renderTopicProgress, +} from '../../lib/resource-progress'; +import { PersonalizedRoadmapModal } from './PersonalizedRoadmapModal'; +import { PersonalizedRoadmapSwitcher } from './PersonalizedRoadmapSwitcher'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { httpPost } from '../../lib/query-http'; +import { useToast } from '../../hooks/use-toast'; +import { queryClient } from '../../stores/query-client'; +import { userResourceProgressOptions } from '../../queries/resource-progress'; +import { useAuth } from '../../hooks/use-auth'; +import { roadmapJSONOptions } from '../../queries/roadmap'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { cn } from '../../lib/classname'; + +type PersonalizedRoadmapProps = { + roadmapId: string; +}; + +export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) { + const { roadmapId } = props; + + const toast = useToast(); + const currentUser = useAuth(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isPersonalized, setIsPersonalized] = useState(false); + + const { data: roadmap } = useQuery( + roadmapJSONOptions(roadmapId), + queryClient, + ); + + const { + data: userProgress, + isLoading: isUserProgressLoading, + refetch: refetchUserProgress, + } = useQuery(userResourceProgressOptions('roadmap', roadmapId), queryClient); + + useEffect(() => { + if (userProgress?.personalized) { + setIsPersonalized(true); + } + }, [userProgress]); + + const alreadyInProgressNodeIds = useMemo(() => { + return new Set([ + ...(userProgress?.learning ?? []), + ...(userProgress?.done ?? []), + ]); + }, [userProgress]); + + const allPendingNodeIds = useMemo(() => { + const nodes = + roadmap?.json?.nodes?.filter((node) => + ['topic', 'subtopic'].includes(node?.type ?? ''), + ) ?? []; + + return nodes + .filter((node) => { + const topicId = node?.id; + return !alreadyInProgressNodeIds.has(topicId); + }) + .map((node) => node?.id); + }, [roadmap, alreadyInProgressNodeIds]); + + const clearResourceProgressLocalStorage = useCallback(() => { + localStorage.removeItem(`roadmap-${roadmapId}-${currentUser?.id}-progress`); + localStorage.removeItem(`roadmap-${roadmapId}-${currentUser?.id}-favorite`); + }, [roadmapId, currentUser]); + + const { mutate: savePersonalization, isPending: isSavingPersonalization } = + useMutation( + { + mutationFn: (data: { topicIds: string[]; information: string }) => { + const remainingTopicIds = allPendingNodeIds.filter( + (nodeId) => !data.topicIds.includes(nodeId), + ); + + return httpPost(`/v1-save-personalization/${roadmapId}`, { + personalized: { + ...data, + topicIds: remainingTopicIds, + }, + }); + }, + onError: (error) => { + toast.error(error?.message ?? 'Failed to save personalization'); + }, + onSuccess: () => { + clearResourceProgressLocalStorage(); + refetchUserProgress(); + refreshProgressCounters(); + toast.success('Personalization saved successfully'); + }, + }, + queryClient, + ); + + const { generatePersonalizedRoadmap, status } = usePersonalizedRoadmap({ + roadmapId, + onStart: () => { + setIsModalOpen(false); + }, + onError: (error) => { + for (const nodeId of allPendingNodeIds) { + renderTopicProgress(nodeId, 'pending'); + } + }, + onData: (data) => { + const { topicIds } = data; + topicIds.forEach((topicId) => { + if (alreadyInProgressNodeIds.has(topicId)) { + return; + } + + renderTopicProgress(topicId, 'pending'); + }); + }, + onFinish: (data) => { + const { topicIds, information } = data; + savePersonalization({ topicIds, information }); + }, + }); + + const { mutate: clearPersonalization, isPending: isClearing } = useMutation( + { + mutationFn: () => { + return httpPost(`/v1-clear-roadmap-personalization/${roadmapId}`, {}); + }, + onError: (error) => { + toast.error(error?.message ?? 'Failed to clear personalization'); + }, + onSuccess: () => { + // Reset all topics to pending state + allPendingNodeIds.forEach((topicId) => { + renderTopicProgress(topicId, 'pending'); + }); + + setIsPersonalized(false); + toast.success('Personalization cleared successfully.'); + refetchUserProgress(); + }, + }, + queryClient, + ); + + const isGenerating = + status !== 'idle' || isClearing || isSavingPersonalization; + + const handleTogglePersonalization = (showPersonalized: boolean) => { + setIsPersonalized(showPersonalized); + + if (!showPersonalized) { + const allTopicIds = allPendingNodeIds; + allTopicIds.forEach((topicId) => { + renderTopicProgress(topicId, 'pending'); + }); + } else if (userProgress?.personalized) { + const { topicIds } = userProgress.personalized; + // user is asking for personalized roadmap, we need to + // mark all the pending ids which are in the personalized roadmap + // as skipped and mark the rest as pending + allPendingNodeIds.forEach((topicId) => { + if (topicIds.includes(topicId)) { + renderTopicProgress(topicId, 'skipped'); + } else { + renderTopicProgress(topicId, 'pending'); + } + }); + } + }; + + return ( + <> + {isModalOpen && ( + setIsModalOpen(false)} + onSubmit={(information) => { + for (const nodeId of allPendingNodeIds) { + renderTopicProgress(nodeId, 'skipped'); + } + + generatePersonalizedRoadmap(information); + }} + onClearProgress={() => { + setIsModalOpen(false); + clearPersonalization(); + }} + /> + )} + + {userProgress?.personalized?.information ? ( + setIsModalOpen(true)} + onRemove={() => { + if (confirm('Are you sure you want to remove personalization?')) { + clearPersonalization(); + } + }} + /> + ) : ( + + )} + + ); +} diff --git a/src/components/PersonalizedRoadmap/PersonalizedRoadmapModal.tsx b/src/components/PersonalizedRoadmap/PersonalizedRoadmapModal.tsx new file mode 100644 index 000000000..9a9c1c50e --- /dev/null +++ b/src/components/PersonalizedRoadmap/PersonalizedRoadmapModal.tsx @@ -0,0 +1,90 @@ +import { PersonStandingIcon, Trash2 } from 'lucide-react'; +import { useId, useState, type FormEvent } from 'react'; +import { Modal } from '../Modal'; +import { queryClient } from '../../stores/query-client'; +import { aiLimitOptions } from '../../queries/ai-course'; +import { useQuery } from '@tanstack/react-query'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; + +type PersonalizedRoadmapModalProps = { + onClose: () => void; + info: string; + onSubmit: (information: string) => void; + onClearProgress: () => void; +}; + +export function PersonalizedRoadmapModal(props: PersonalizedRoadmapModalProps) { + const { + onClose, + info: infoProp, + onSubmit: onSubmitProp, + onClearProgress, + } = props; + + const [info, setInfo] = useState(infoProp); + const infoFieldId = useId(); + + const { data: limits, isLoading: isLimitLoading } = useQuery( + aiLimitOptions(), + queryClient, + ); + + const hasReachedLimit = + limits?.used && limits?.limit ? limits.used >= limits.limit : false; + console.log(limits); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSubmitProp(info); + }; + + if (hasReachedLimit) { + return ; + } + + return ( + +
+

Personalize Roadmap

+
+ +