diff --git a/src/components/AIGuide/AIGuideCard.tsx b/src/components/AIGuide/AIGuideCard.tsx index c5f96c1d8..273da8e0a 100644 --- a/src/components/AIGuide/AIGuideCard.tsx +++ b/src/components/AIGuide/AIGuideCard.tsx @@ -11,30 +11,15 @@ type AIGuideCardProps = { export function AIGuideCard(props: AIGuideCardProps) { const { guide, showActions = true } = props; - const guideDepthColor = - { - essentials: 'text-green-700', - detailed: 'text-blue-700', - complete: 'text-purple-700', - }[guide.depth] || 'text-gray-700'; - return (
-
- - {guide.depth} - -
-
diff --git a/src/components/AIGuide/AILibraryLayout.tsx b/src/components/AIGuide/AILibraryLayout.tsx index fb2173aac..8e8e50ed1 100644 --- a/src/components/AIGuide/AILibraryLayout.tsx +++ b/src/components/AIGuide/AILibraryLayout.tsx @@ -5,7 +5,7 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { LibraryTabs } from '../Library/LibraryTab'; type AILibraryLayoutProps = { - activeTab: 'courses' | 'guides'; + activeTab: 'courses' | 'guides' | 'roadmaps'; children: React.ReactNode; }; @@ -22,7 +22,7 @@ export function AILibraryLayout(props: AILibraryLayoutProps) {
setShowUpgradePopup(true)} /> diff --git a/src/components/AIRoadmap/AIRoadmap.css b/src/components/AIRoadmap/AIRoadmap.css new file mode 100644 index 000000000..d30ca465a --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmap.css @@ -0,0 +1,58 @@ +@font-face { + font-family: 'balsamiq'; + src: url('/fonts/balsamiq.woff2'); +} + +svg text tspan { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeSpeed; +} + +svg > g[data-type='topic'], +svg > g[data-type='subtopic'], +svg > g > g[data-type='link-item'], +svg > g[data-type='button'] { + cursor: pointer; +} + +svg > g[data-type='topic']:hover > rect { + fill: #d6d700; +} + +svg > g[data-type='subtopic']:hover > rect { + fill: #f3c950; +} +svg > g[data-type='button']:hover { + opacity: 0.8; +} + +svg .done rect { + fill: #cbcbcb !important; +} + +svg .done text, +svg .skipped text { + text-decoration: line-through; +} + +svg > g[data-type='topic'].learning > rect + text, +svg > g[data-type='topic'].done > rect + text { + fill: black; +} + +svg > g[data-type='subtipic'].done > rect + text, +svg > g[data-type='subtipic'].learning > rect + text { + fill: #cbcbcb; +} + +svg .learning rect { + fill: #dad1fd !important; +} +svg .learning text { + text-decoration: underline; +} + +svg .skipped rect { + fill: #496b69 !important; +} diff --git a/src/components/AIRoadmap/AIRoadmap.tsx b/src/components/AIRoadmap/AIRoadmap.tsx new file mode 100644 index 000000000..5c11b3eba --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmap.tsx @@ -0,0 +1,167 @@ +import './AIRoadmap.css'; + +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { useToast } from '../../hooks/use-toast'; +import { queryClient } from '../../stores/query-client'; +import { AITutorLayout } from '../AITutor/AITutorLayout'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap'; +import { GenerateAIRoadmap } from './GenerateAIRoadmap'; +import { AIRoadmapContent, type RoadmapNodeDetails } from './AIRoadmapContent'; +import { AIRoadmapChat } from './AIRoadmapChat'; +import { AlertCircleIcon } from 'lucide-react'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { billingDetailsOptions } from '../../queries/billing'; + +export type AIRoadmapChatActions = { + handleNodeClick: (node: RoadmapNodeDetails) => void; +}; + +type AIRoadmapProps = { + roadmapSlug?: string; +}; + +export function AIRoadmap(props: AIRoadmapProps) { + const { roadmapSlug: defaultRoadmapSlug } = props; + const [roadmapSlug, setRoadmapSlug] = useState(defaultRoadmapSlug); + + const toast = useToast(); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + const [regeneratedSvgHtml, setRegeneratedSvgHtml] = useState( + null, + ); + + const aiChatActionsRef = useRef(null); + + // only fetch the guide if the guideSlug is provided + // otherwise we are still generating the guide + const { + data: aiRoadmap, + isLoading: isLoadingBySlug, + error: aiRoadmapError, + } = useQuery(aiRoadmapOptions(roadmapSlug), queryClient); + + const { + data: tokenUsage, + isLoading: isTokenUsageLoading, + refetch: refetchTokenUsage, + } = useQuery(getAiCourseLimitOptions(), queryClient); + + const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = + useQuery(billingDetailsOptions(), queryClient); + + const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); + const isPaidUser = userBillingDetails?.status === 'active'; + + const handleRegenerate = async (prompt?: string) => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + if (!isPaidUser && isLimitExceeded) { + setShowUpgradeModal(true); + return; + } + + flushSync(() => { + setIsRegenerating(true); + setRegeneratedSvgHtml(null); + }); + + queryClient.cancelQueries(aiRoadmapOptions(roadmapSlug)); + queryClient.setQueryData(aiRoadmapOptions(roadmapSlug).queryKey, (old) => { + if (!old) { + return old; + } + + return { + ...old, + data: '', + svgHtml: '', + }; + }); + + setRegeneratedSvgHtml(''); + await generateAIRoadmap({ + roadmapSlug: aiRoadmap?.slug || '', + term: aiRoadmap?.term || '', + prompt, + isForce: true, + onStreamingChange: setIsRegenerating, + onRoadmapSvgChange: (svg) => { + setRegeneratedSvgHtml(svg.outerHTML); + }, + onError: (error) => { + toast.error(error); + }, + onFinish: () => { + setIsRegenerating(false); + refetchTokenUsage(); + queryClient.invalidateQueries(aiRoadmapOptions(roadmapSlug)); + }, + }); + }; + + const isLoading = + isLoadingBySlug || + isRegenerating || + isTokenUsageLoading || + isBillingDetailsLoading; + + const handleNodeClick = useCallback( + (node: RoadmapNodeDetails) => { + aiChatActionsRef.current?.handleNodeClick(node); + }, + [aiChatActionsRef], + ); + + return ( + + {showUpgradeModal && ( + setShowUpgradeModal(false)} /> + )} + + {!isLoading && aiRoadmapError && ( +
+
+ +

+ {aiRoadmapError?.message || 'Something went wrong'} +

+
+
+ )} + +
+ {roadmapSlug && !aiRoadmapError && ( + + )} + {!roadmapSlug && !aiRoadmapError && ( + + )} +
+ + setShowUpgradeModal(true)} + aiChatActionsRef={aiChatActionsRef} + /> +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapActions.tsx b/src/components/AIRoadmap/AIRoadmapActions.tsx new file mode 100644 index 000000000..211f81346 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapActions.tsx @@ -0,0 +1,116 @@ +import { MoreVertical, Play, Trash2 } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { useKeydown } from '../../hooks/use-keydown'; +import { useToast } from '../../hooks/use-toast'; +import { useMutation } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { httpDelete } from '../../lib/query-http'; + +type AIRoadmapActionsType = { + roadmapSlug: string; + onDeleted?: () => void; +}; + +export function AIRoadmapActions(props: AIRoadmapActionsType) { + const { roadmapSlug, onDeleted } = props; + + const toast = useToast(); + const dropdownRef = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + + const { mutate: deleteRoadmap, isPending: isDeleting } = useMutation( + { + mutationFn: async () => { + return httpDelete(`/v1-delete-ai-roadmap/${roadmapSlug}`); + }, + onSuccess: () => { + toast.success('Roadmap deleted'); + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey?.[0] === 'user-ai-roadmaps', + }); + onDeleted?.(); + }, + onError: (error) => { + toast.error(error?.message || 'Failed to delete roadmap'); + }, + }, + queryClient, + ); + + useOutsideClick(dropdownRef, () => { + setIsOpen(false); + }); + + useKeydown('Escape', () => { + setIsOpen(false); + }); + + return ( +
+ + + {isOpen && ( +
+ + + Visit Roadmap + + {!isConfirming && ( + + )} + + {isConfirming && ( + + Are you sure? +
+ + +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapCard.tsx b/src/components/AIRoadmap/AIRoadmapCard.tsx new file mode 100644 index 000000000..cb97cc4cf --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapCard.tsx @@ -0,0 +1,49 @@ +import { CalendarIcon } from 'lucide-react'; +import { getRelativeTimeString } from '../../lib/date'; +import { cn } from '../../lib/classname'; +import type { AIRoadmapDocument } from '../../queries/ai-roadmap'; +import { AIRoadmapActions } from './AIRoadmapActions'; + +type AIRoadmapCardProps = { + roadmap: Omit; + variant?: 'row' | 'column'; + showActions?: boolean; +}; + +export function AIRoadmapCard(props: AIRoadmapCardProps) { + const { roadmap, variant = 'row', showActions = true } = props; + + const updatedAgo = getRelativeTimeString(roadmap?.updatedAt); + + return ( +
+ +
+

+ {roadmap.title} +

+
+ +
+
+ + {updatedAgo} +
+
+
+ + {showActions && roadmap.slug && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapChat.tsx b/src/components/AIRoadmap/AIRoadmapChat.tsx new file mode 100644 index 000000000..b865471b0 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapChat.tsx @@ -0,0 +1,371 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from 'react'; +import { useChat, type ChatMessage } from '../../hooks/use-chat'; +import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; +import { + ArrowDownIcon, + BotIcon, + LockIcon, + MessageCircleIcon, + PauseCircleIcon, + SendIcon, + Trash2Icon, + XIcon, +} from 'lucide-react'; +import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { flushSync } from 'react-dom'; +import { markdownToHtml } from '../../lib/markdown'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { billingDetailsOptions } from '../../queries/billing'; +import { LoadingChip } from '../LoadingChip'; +import { getTailwindScreenDimension } from '../../lib/is-mobile'; +import { useToast } from '../../hooks/use-toast'; +import type { AIRoadmapChatActions } from './AIRoadmap'; +import type { RoadmapNodeDetails } from './AIRoadmapContent'; + +type AIRoadmapChatProps = { + roadmapSlug?: string; + isRoadmapLoading?: boolean; + onUpgrade?: () => void; + aiChatActionsRef?: RefObject; +}; + +export function AIRoadmapChat(props: AIRoadmapChatProps) { + const { roadmapSlug, isRoadmapLoading, onUpgrade, aiChatActionsRef } = props; + + const toast = useToast(); + const scrollareaRef = useRef(null); + const inputRef = useRef(null); + + const [inputValue, setInputValue] = useState(''); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [isChatOpen, setIsChatOpen] = useState(true); + const [isMobile, setIsMobile] = useState(false); + + const { + data: tokenUsage, + isLoading: isTokenUsageLoading, + refetch: refetchTokenUsage, + } = useQuery(getAiCourseLimitOptions(), queryClient); + + const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = + useQuery(billingDetailsOptions(), queryClient); + + const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); + const isPaidUser = userBillingDetails?.status === 'active'; + + const { + messages, + status, + streamedMessageHtml, + sendMessages, + setMessages, + stop, + } = useChat({ + endpoint: `${import.meta.env.PUBLIC_API_URL}/v1-ai-roadmap-chat`, + onError: (error) => { + console.error(error); + toast.error(error?.message || 'Something went wrong'); + }, + data: { + aiRoadmapSlug: roadmapSlug, + }, + onFinish: () => { + refetchTokenUsage(); + }, + }); + + const scrollToBottom = useCallback( + (behavior: 'smooth' | 'instant' = 'smooth') => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior, + }); + }, + [scrollareaRef], + ); + + const isStreamingMessage = status === 'streaming'; + const hasMessages = messages.length > 0; + + const handleSubmitInput = useCallback( + (defaultInputValue?: string) => { + const message = defaultInputValue || inputValue; + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + if (isStreamingMessage) { + return; + } + + const newMessages: ChatMessage[] = [ + ...messages, + { + role: 'user', + content: message, + html: markdownToHtml(message), + }, + ]; + flushSync(() => { + setMessages(newMessages); + }); + sendMessages(newMessages); + setInputValue(''); + + setTimeout(() => { + scrollToBottom('smooth'); + }, 0); + }, + [inputValue, isStreamingMessage, messages, sendMessages, setMessages], + ); + + const checkScrollPosition = useCallback(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = scrollArea; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold + setShowScrollToBottom(!isAtBottom && messages.length > 0); + }, [messages.length]); + + useEffect(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + scrollArea.addEventListener('scroll', checkScrollPosition); + return () => scrollArea.removeEventListener('scroll', checkScrollPosition); + }, [checkScrollPosition]); + + const isLoading = + isRoadmapLoading || isTokenUsageLoading || isBillingDetailsLoading; + + useLayoutEffect(() => { + const deviceType = getTailwindScreenDimension(); + const isMediumSize = ['sm', 'md'].includes(deviceType); + + if (!isMediumSize) { + const storedState = localStorage.getItem('chat-history-sidebar-open'); + setIsChatOpen(storedState === null ? true : storedState === 'true'); + } else { + setIsChatOpen(!isMediumSize); + } + + setIsMobile(isMediumSize); + }, []); + + useEffect(() => { + if (!isMobile) { + localStorage.setItem('chat-history-sidebar-open', isChatOpen.toString()); + } + }, [isChatOpen, isMobile]); + + useImperativeHandle(aiChatActionsRef, () => ({ + handleNodeClick: (node: RoadmapNodeDetails) => { + handleSubmitInput(`Explain what is "${node.nodeTitle}" topic in detail.`); + }, + })); + + if (!isChatOpen) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ + AI Roadmap +

+ + +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && ( + <> +
+
+
+
+ + + {messages.map((chat, index) => { + return ( + + ); + })} + + {status === 'streaming' && !streamedMessageHtml && ( + + )} + + {status === 'streaming' && streamedMessageHtml && ( + + )} +
+
+
+
+ + {(hasMessages || showScrollToBottom) && ( +
+ } + className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300" + onClick={() => { + setMessages([]); + }} + > + Clear + + {showScrollToBottom && ( + } + className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300" + onClick={() => { + scrollToBottom('smooth'); + }} + > + Scroll to bottom + + )} +
+ )} + +
+ {isLimitExceeded && isLoggedIn() && ( +
+ +

+ Limit reached for today + {isPaidUser ? '. Please wait until tomorrow.' : ''} +

+ {!isPaidUser && ( + + )} +
+ )} + + {!isLoggedIn() && ( +
+ +

Please login to continue

+ +
+ )} + + setInputValue(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (isStreamingMessage) { + return; + } + handleSubmitInput(); + } + }} + placeholder="Ask me anything about this roadmap..." + className="w-full resize-none px-3 py-4 outline-none" + /> + + +
+ + )} +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapContent.tsx b/src/components/AIRoadmap/AIRoadmapContent.tsx new file mode 100644 index 000000000..59695bf53 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapContent.tsx @@ -0,0 +1,117 @@ +import { cn } from '../../lib/classname'; +import { AIRoadmapRegenerate } from './AIRoadmapRegenerate'; +import { LoadingChip } from '../LoadingChip'; +import { type MouseEvent, useCallback } from 'react'; + +export type RoadmapNodeDetails = { + nodeId: string; + nodeType: string; + targetGroup?: SVGElement; + nodeTitle?: string; + parentTitle?: string; + parentId?: string; +}; + +export function getNodeDetails( + svgElement: SVGElement, +): RoadmapNodeDetails | null { + const targetGroup = (svgElement?.closest('g') as SVGElement) || {}; + + const nodeId = targetGroup?.dataset?.nodeId; + const nodeType = targetGroup?.dataset?.type; + const nodeTitle = targetGroup?.dataset?.title; + const parentTitle = targetGroup?.dataset?.parentTitle; + const parentId = targetGroup?.dataset?.parentId; + if (!nodeId || !nodeType) return null; + + return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle, parentId }; +} + +export const allowedClickableNodeTypes = [ + 'topic', + 'subtopic', + 'button', + 'link-item', +]; + +type AIRoadmapContentProps = { + isLoading?: boolean; + svgHtml: string; + onRegenerate?: (prompt?: string) => void; + roadmapSlug?: string; + + onNodeClick?: (node: RoadmapNodeDetails) => void; +}; + +export function AIRoadmapContent(props: AIRoadmapContentProps) { + const { isLoading, svgHtml, onRegenerate, roadmapSlug, onNodeClick } = props; + + const handleNodeClick = useCallback( + (e: MouseEvent) => { + if (isLoading) { + return; + } + + const target = e.target as SVGElement; + const { nodeId, nodeType, targetGroup, nodeTitle, parentTitle } = + getNodeDetails(target) || {}; + if ( + !nodeId || + !nodeType || + !allowedClickableNodeTypes.includes(nodeType) || + !nodeTitle + ) + return; + + if (nodeType === 'button' || nodeType === 'link-item') { + const link = targetGroup?.dataset?.link || ''; + const isExternalLink = link.startsWith('http'); + if (isExternalLink) { + window.open(link, '_blank'); + } else { + window.location.href = link; + } + return; + } + + onNodeClick?.({ + nodeId, + nodeType, + nodeTitle, + ...(nodeType === 'subtopic' && { parentTitle }), + }); + }, + [isLoading, onNodeClick], + ); + + return ( +
+
+ + {isLoading && !svgHtml && ( +
+ +
+ )} + + {onRegenerate && !isLoading && roadmapSlug && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapRegenerate.tsx b/src/components/AIRoadmap/AIRoadmapRegenerate.tsx new file mode 100644 index 000000000..6057b7658 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapRegenerate.tsx @@ -0,0 +1,298 @@ +import { + Loader2Icon, + PenSquare, + RefreshCcw, + SaveIcon, + SettingsIcon, + type LucideIcon, +} from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { cn } from '../../lib/classname'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { ModifyCoursePrompt } from '../GenerateCourse/ModifyCoursePrompt'; +import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { httpPost } from '../../lib/query-http'; +import { aiRoadmapOptions } from '../../queries/ai-roadmap'; +import { UpdatePreferences } from '../GenerateGuide/UpdatePreferences'; +import { generateAIRoadmapFromText } from '@roadmapsh/editor'; +import { useToast } from '../../hooks/use-toast'; +import { showLoginPopup } from '../../lib/popup'; +import { isLoggedIn } from '../../lib/jwt'; +import { useAuth } from '../../hooks/use-auth'; + +type AIRoadmapRegenerateProps = { + onRegenerate: (prompt?: string) => void; + roadmapSlug: string; +}; + +export function AIRoadmapRegenerate(props: AIRoadmapRegenerateProps) { + const { onRegenerate, roadmapSlug } = props; + + const toast = useToast(); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const [showPromptModal, setShowPromptModal] = useState(false); + const [showUpdatePreferencesModal, setShowUpdatePreferencesModal] = + useState(false); + const currentUser = useAuth(); + + const ref = useRef(null); + + useOutsideClick(ref, () => setIsDropdownVisible(false)); + + const { data: aiRoadmap } = useQuery( + aiRoadmapOptions(roadmapSlug), + queryClient, + ); + const { mutate: updatePreferences, isPending: isUpdating } = useMutation( + { + mutationFn: (questionAndAnswers: QuestionAnswerChatMessage[]) => { + return httpPost(`/v1-update-ai-roadmap-preferences/${roadmapSlug}`, { + questionAndAnswers, + }); + }, + onSuccess: (_, vars) => { + queryClient.setQueryData( + aiRoadmapOptions(roadmapSlug).queryKey, + (old) => { + if (!old) { + return old; + } + + return { + ...old, + questionAndAnswers: vars, + }; + }, + ); + + setShowUpdatePreferencesModal(false); + setIsDropdownVisible(false); + onRegenerate(); + }, + }, + queryClient, + ); + + const handleSaveAIRoadmap = async () => { + const { nodes, edges } = generateAIRoadmapFromText(aiRoadmap?.data || ''); + return httpPost<{ + roadmapId: string; + roadmapSlug: string; + }>(`/v1-save-ai-roadmap/${aiRoadmap?._id}`, { + title: aiRoadmap?.term, + nodes: nodes.map((node) => ({ + ...node, + + // To reset the width and height of the node + // so that it can be calculated based on the content in the editor + width: undefined, + height: undefined, + style: { + ...node.style, + width: undefined, + height: undefined, + }, + measured: { + width: undefined, + height: undefined, + }, + })), + edges, + }); + }; + + const { mutate: saveAIRoadmap, isPending: isSavingAIRoadmap } = useMutation( + { + mutationFn: handleSaveAIRoadmap, + onSuccess: (data) => { + if (!data?.roadmapId) { + toast.error('Something went wrong'); + return; + } + window.location.href = `/r/${data?.roadmapSlug}`; + }, + }, + queryClient, + ); + + const { mutate: editAIRoadmap, isPending: isEditingAIRoadmap } = useMutation( + { + mutationFn: handleSaveAIRoadmap, + onSuccess: (data) => { + if (!data?.roadmapId) { + toast.error('Something went wrong'); + return; + } + window.open( + `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${data?.roadmapId}`, + '_blank', + ); + }, + }, + queryClient, + ); + + const isCurrentUserCreator = currentUser?.id === aiRoadmap?.userId; + const showUpdatePreferences = + aiRoadmap?.questionAndAnswers && + aiRoadmap.questionAndAnswers.length > 0 && + isCurrentUserCreator; + + return ( + <> + {showUpgradeModal && ( + { + setShowUpgradeModal(false); + }} + /> + )} + + {showPromptModal && ( + setShowPromptModal(false)} + onSubmit={(prompt) => { + setShowPromptModal(false); + onRegenerate(prompt); + }} + /> + )} + + {showUpdatePreferencesModal && ( + setShowUpdatePreferencesModal(false)} + questionAndAnswers={aiRoadmap?.questionAndAnswers} + term={aiRoadmap?.term || ''} + format="roadmap" + onUpdatePreferences={(questionAndAnswers) => { + updatePreferences(questionAndAnswers); + }} + isUpdating={isUpdating} + /> + )} + +
+ + {isDropdownVisible && ( +
+ {isCurrentUserCreator && ( + <> + {showUpdatePreferences && ( + { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + setIsDropdownVisible(false); + setShowUpdatePreferencesModal(true); + }} + icon={SettingsIcon} + label="Update Preferences" + /> + )} + + { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + setIsDropdownVisible(false); + onRegenerate(); + }} + icon={RefreshCcw} + label="Regenerate" + /> + { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + setIsDropdownVisible(false); + setShowPromptModal(true); + }} + icon={PenSquare} + label="Modify Prompt" + /> + +
+ + )} + + { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + saveAIRoadmap(); + }} + icon={SaveIcon} + label="Start Learning" + isLoading={isSavingAIRoadmap} + /> + + { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + editAIRoadmap(); + }} + icon={PenSquare} + label="Edit in Editor" + isLoading={isEditingAIRoadmap} + /> +
+ )} +
+ + ); +} + +type ActionButtonProps = { + onClick: () => void; + isLoading?: boolean; + icon: LucideIcon; + label: string; +}; + +function ActionButton(props: ActionButtonProps) { + const { onClick, isLoading, icon: Icon, label } = props; + + return ( + + ); +} diff --git a/src/components/AIRoadmap/GenerateAIRoadmap.tsx b/src/components/AIRoadmap/GenerateAIRoadmap.tsx new file mode 100644 index 000000000..fe6eb2957 --- /dev/null +++ b/src/components/AIRoadmap/GenerateAIRoadmap.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from 'react'; +import { getUrlParams } from '../../lib/browser'; +import { isLoggedIn } from '../../lib/jwt'; +import { queryClient } from '../../stores/query-client'; +import { LoadingChip } from '../LoadingChip'; +import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat'; +import { getQuestionAnswerChatMessages } from '../../lib/ai-questions'; +import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap'; +import { AIRoadmapContent } from './AIRoadmapContent'; + +type GenerateAIRoadmapProps = { + onRoadmapSlugChange?: (roadmapSlug: string) => void; +}; + +export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) { + const { onRoadmapSlugChange } = props; + + const [isLoading, setIsLoading] = useState(true); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(''); + + const [svgHtml, setSvgHtml] = useState(''); + const [content, setContent] = useState(''); + const svgRef = useRef(null); + + useEffect(() => { + const params = getUrlParams(); + const paramsTerm = params?.term; + const paramsSrc = params?.src || 'search'; + if (!paramsTerm) { + return; + } + + let questionAndAnswers: QuestionAnswerChatMessage[] = []; + const sessionId = params?.id; + if (sessionId) { + questionAndAnswers = getQuestionAnswerChatMessages(sessionId); + } + + handleGenerateDocument({ + term: paramsTerm, + src: paramsSrc, + questionAndAnswers, + }); + }, []); + + const handleGenerateDocument = async (options: { + term: string; + isForce?: boolean; + prompt?: string; + src?: string; + questionAndAnswers?: QuestionAnswerChatMessage[]; + }) => { + const { term, isForce, prompt, src, questionAndAnswers } = options; + + if (!isLoggedIn()) { + window.location.href = '/ai'; + return; + } + + await generateAIRoadmap({ + term, + isForce, + prompt, + questionAndAnswers, + onDetailsChange: (details) => { + const { roadmapId, roadmapSlug, title, userId } = details; + + const aiRoadmapData = { + _id: roadmapId, + userId, + title, + term, + data: content, + questionAndAnswers, + viewCount: 0, + svgHtml: svgRef.current || '', + lastVisitedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + queryClient.setQueryData( + aiRoadmapOptions(roadmapSlug).queryKey, + aiRoadmapData, + ); + + onRoadmapSlugChange?.(roadmapSlug); + window.history.replaceState(null, '', `/ai-roadmaps/${roadmapSlug}`); + }, + onLoadingChange: setIsLoading, + onError: setError, + onStreamingChange: setIsStreaming, + onRoadmapSvgChange: (svg) => { + const svgHtml = svg.outerHTML; + svgRef.current = svgHtml; + setSvgHtml(svgHtml); + }, + }); + }; + + if (error) { + return
{error}
; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ; +} diff --git a/src/components/AIRoadmap/UserRoadmapsList.tsx b/src/components/AIRoadmap/UserRoadmapsList.tsx new file mode 100644 index 000000000..244f815fd --- /dev/null +++ b/src/components/AIRoadmap/UserRoadmapsList.tsx @@ -0,0 +1,146 @@ +import { useQuery } from '@tanstack/react-query'; +import { BookOpen, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; +import { queryClient } from '../../stores/query-client'; +import { AITutorTallMessage } from '../AITutor/AITutorTallMessage'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { Pagination } from '../Pagination/Pagination'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { AICourseSearch } from '../GenerateCourse/AICourseSearch'; +import { + listUserAiRoadmapsOptions, + type ListUserAiRoadmapsQuery, +} from '../../queries/ai-roadmap'; +import { AIRoadmapCard } from './AIRoadmapCard'; + +export function UserRoadmapsList() { + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [showUpgradePopup, setShowUpgradePopup] = useState(false); + + const [pageState, setPageState] = useState({ + perPage: '21', + currPage: '1', + query: '', + }); + + const { data: userAiRoadmaps, isFetching: isUserAiRoadmapsLoading } = + useQuery(listUserAiRoadmapsOptions(pageState), queryClient); + + useEffect(() => { + setIsInitialLoading(false); + }, [userAiRoadmaps]); + + const roadmaps = userAiRoadmaps?.data ?? []; + + useEffect(() => { + const queryParams = getUrlParams(); + + setPageState({ + ...pageState, + currPage: queryParams?.p || '1', + query: queryParams?.q || '', + }); + }, []); + + useEffect(() => { + if (pageState?.currPage !== '1' || pageState?.query !== '') { + setUrlParams({ + p: pageState?.currPage || '1', + q: pageState?.query || '', + }); + } else { + deleteUrlParam('p'); + deleteUrlParam('q'); + } + }, [pageState]); + + const isUserAuthenticated = isLoggedIn(); + const isAnyLoading = isUserAiRoadmapsLoading || isInitialLoading; + + return ( + <> + {showUpgradePopup && ( + setShowUpgradePopup(false)} /> + )} + + { + setPageState({ + ...pageState, + query: value, + currPage: '1', + }); + }} + placeholder="Search Roadmaps..." + disabled={isAnyLoading} + /> + + {isAnyLoading && ( +

+ + Loading your courses... +

+ )} + + {!isAnyLoading && ( + <> +

+ {isUserAuthenticated + ? `You have generated ${userAiRoadmaps?.totalCount} roadmaps so far.` + : 'Sign up or login to generate your first roadmap. Takes 2s to do so.'} +

+ + {isUserAuthenticated && !isAnyLoading && roadmaps.length > 0 && ( +
+
+ {roadmaps.map((roadmap) => ( + + ))} +
+ + { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> +
+ )} + + {!isAnyLoading && roadmaps.length === 0 && ( + { + if (isUserAuthenticated) { + window.location.href = '/ai'; + } else { + showLoginPopup(); + } + }} + /> + )} + + )} + + ); +} diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index a06187d95..0bb5b801a 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -1,4 +1,12 @@ -import { BookOpen, Compass, Plus, Star, X, Zap } from 'lucide-react'; +import { + BookOpen, + Compass, + MessageCircle, + Plus, + Star, + X, + Zap, +} from 'lucide-react'; import { useEffect, useState } from 'react'; import { isLoggedIn } from '../../lib/jwt'; import { useIsPaidUser } from '../../queries/billing'; @@ -21,22 +29,22 @@ type AITutorSidebarProps = { const sidebarItems = [ { key: 'new', - label: 'New', + label: 'Create with AI', href: '/ai', icon: Plus, }, + { + key: 'chat', + label: 'Ask AI Tutor', + href: '/ai/chat', + icon: MessageCircle, + }, { key: 'library', - label: 'Library', + label: 'My Learning', href: '/ai/courses', icon: BookOpen, }, - // { - // key: 'chat', - // label: 'AI Chat', - // href: '/ai/chat', - // icon: Bot, - // }, { key: 'staff-picks', label: 'Staff Picks', diff --git a/src/components/ContentGenerator/ContentGenerator.tsx b/src/components/ContentGenerator/ContentGenerator.tsx index bc255b931..85da2d70d 100644 --- a/src/components/ContentGenerator/ContentGenerator.tsx +++ b/src/components/ContentGenerator/ContentGenerator.tsx @@ -1,50 +1,70 @@ import { BookOpenIcon, FileTextIcon, + MapIcon, SparklesIcon, type LucideIcon, } from 'lucide-react'; -import { useEffect, useId, useState, type FormEvent } from 'react'; +import { useEffect, useId, useState } from 'react'; import { FormatItem } from './FormatItem'; -import { GuideOptions } from './GuideOptions'; -import { FineTuneCourse } from '../GenerateCourse/FineTuneCourse'; -import { CourseOptions } from './CourseOptions'; -import { - clearFineTuneData, - getCourseFineTuneData, - getLastSessionId, - storeFineTuneData, -} from '../../lib/ai'; import { isLoggedIn } from '../../lib/jwt'; import { showLoginPopup } from '../../lib/popup'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { useIsPaidUser } from '../../queries/billing'; +import { + clearQuestionAnswerChatMessages, + storeQuestionAnswerChatMessages, +} from '../../lib/ai-questions'; +import { + QuestionAnswerChat, + type QuestionAnswerChatMessage, +} from './QuestionAnswerChat'; +import { useToast } from '../../hooks/use-toast'; import { cn } from '../../lib/classname'; +import { getUrlParams } from '../../lib/browser'; +import { useParams } from '../../hooks/use-params'; const allowedFormats = ['course', 'guide', 'roadmap'] as const; -type AllowedFormat = (typeof allowedFormats)[number]; +export type AllowedFormat = (typeof allowedFormats)[number]; export function ContentGenerator() { const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser(); + const params = useParams<{ format: AllowedFormat }>(); + const toast = useToast(); const [title, setTitle] = useState(''); const [selectedFormat, setSelectedFormat] = useState('course'); - // guide options - const [depth, setDepth] = useState('essentials'); - // course options - const [difficulty, setDifficulty] = useState('beginner'); + useEffect(() => { + const isValidFormat = allowedFormats.find( + (format) => format.value === params.format, + ); - // fine-tune options + if (isValidFormat) { + setSelectedFormat(isValidFormat.value); + } else { + setSelectedFormat('course'); + } + }, [params.format]); + + // question answer chat options const [showFineTuneOptions, setShowFineTuneOptions] = useState(false); - const [about, setAbout] = useState(''); - const [goal, setGoal] = useState(''); - const [customInstructions, setCustomInstructions] = useState(''); + const [questionAnswerChatMessages, setQuestionAnswerChatMessages] = useState< + QuestionAnswerChatMessage[] + >([]); const titleFieldId = useId(); const fineTuneOptionsId = useId(); + useEffect(() => { + const params = getUrlParams(); + const format = params.format as AllowedFormat; + if (format && allowedFormats.includes(format)) { + setSelectedFormat(format); + } + }, []); + const allowedFormats: { label: string; icon: LucideIcon; @@ -60,10 +80,14 @@ export function ContentGenerator() { icon: FileTextIcon, value: 'guide', }, + { + label: 'Roadmap', + icon: MapIcon, + value: 'roadmap', + }, ]; - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); + const handleSubmit = () => { if (!isLoggedIn()) { showLoginPopup(); return; @@ -71,18 +95,17 @@ export function ContentGenerator() { let sessionId = ''; if (showFineTuneOptions) { - clearFineTuneData(); - sessionId = storeFineTuneData({ - about, - goal, - customInstructions, - }); + clearQuestionAnswerChatMessages(); + sessionId = storeQuestionAnswerChatMessages(questionAnswerChatMessages); } + const trimmedTitle = title.trim(); if (selectedFormat === 'course') { - window.location.href = `/ai/course?term=${encodeURIComponent(title)}&difficulty=${difficulty}&id=${sessionId}&format=${selectedFormat}`; + window.location.href = `/ai/course?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`; } else if (selectedFormat === 'guide') { - window.location.href = `/ai/guide?term=${encodeURIComponent(title)}&depth=${depth}&id=${sessionId}&format=${selectedFormat}`; + window.location.href = `/ai/guide?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`; + } else if (selectedFormat === 'roadmap') { + window.location.href = `/ai/roadmap?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`; } }; @@ -94,24 +117,11 @@ export function ContentGenerator() { }); }, []); - useEffect(() => { - const lastSessionId = getLastSessionId(); - if (!lastSessionId) { - return; - } - - const fineTuneData = getCourseFineTuneData(lastSessionId); - if (!fineTuneData) { - return; - } - - setAbout(fineTuneData.about); - setGoal(fineTuneData.goal); - setCustomInstructions(fineTuneData.customInstructions); - }, []); + const trimmedTitle = title.trim(); + const canGenerate = trimmedTitle && trimmedTitle.length >= 3; return ( -
+
{isUpgradeModalOpen && ( setIsUpgradeModalOpen(false)} /> @@ -135,7 +145,13 @@ export function ContentGenerator() {

-
+ { + e.preventDefault(); + handleSubmit(); + }} + >
+
+ )} + + {!isLoadingAiQuestionSuggestions && status === 'answering' && ( + <> +
+
+
+
+ {questionAnswerChatMessages.map((message, index) => ( + handleEditMessage(index) + : undefined + } + /> + ))} + + {activeMessage && ( + + )} +
+
+
+ + {!activeMessage && type === 'update' && ( +
+ +
+ )} + + {activeMessage && ( +
+
+
+ setMessage(e.target.value)} + className="w-full bg-transparent text-sm focus:outline-none" + placeholder={activeMessage.possibleAnswers ? "Type your answer..." : "Or type your own answer..."} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleAnswerSelect(message); + setMessage(''); + } + }} + /> + + +
+
+
+ )} +
+ + )} +
+ + ); +} + +type QuestionAnswerChatMessageProps = { + role: 'user' | 'assistant'; + question?: string; + answer?: string; + possibleAnswers?: string[]; + onAnswerSelect?: (answer: string) => void; + onEdit?: () => void; +}; + +function QuestionAnswerChatMessage(props: QuestionAnswerChatMessageProps) { + const { role, question, answer, possibleAnswers, onAnswerSelect, onEdit } = + props; + + const hasAnswers = possibleAnswers && possibleAnswers.length > 0; + + return ( +
+ {role === 'assistant' && ( +
+
{question}
+ {hasAnswers && onAnswerSelect && ( +
+
+ {possibleAnswers.map((answer) => ( + + ))} +
+
+ )} +
+ )} + {role === 'user' && ( +
+
{answer}
+ {onEdit && ( +
+ + + Reanswer after this point + +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx index d345d4633..8733ca0ef 100644 --- a/src/components/GenerateCourse/AICourseCard.tsx +++ b/src/components/GenerateCourse/AICourseCard.tsx @@ -46,15 +46,24 @@ export function AICourseCard(props: AICourseCardProps) { > {/* Title and difficulty section */}
-
- - {course.difficulty} - -
+ {course.difficulty && ( +
+ + {course.difficulty} + +
+ )} -

+

{course.title ?.replace(": A Beginner's Guide", '') ?.replace(' for beginners', '') diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 1e253eb54..9a71d89cc 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -256,12 +256,14 @@ export function AICourseContent(props: AICourseContentProps) {
-
- setShowUpgradeModal(true)} - onShowLimits={() => setShowAILimitsPopup(true)} - /> -
+ {!isLoading && ( +
+ setShowUpgradeModal(true)} + onShowLimits={() => setShowAILimitsPopup(true)} + /> +
+ )} {viewMode === 'module' && (

-
-
- setShowUpgradeModal(true)} - onShowLimits={() => setShowAILimitsPopup(true)} - /> + {!isLoading && ( +
+
+ setShowUpgradeModal(true)} + onShowLimits={() => setShowAILimitsPopup(true)} + /> +
-
+ )}
@@ -499,6 +503,7 @@ export function AICourseContent(props: AICourseContentProps) { onForkCourse={() => { setIsForkingCourse(true); }} + courseSlug={courseSlug!} /> )} diff --git a/src/components/GenerateCourse/AICourseOutlineHeader.tsx b/src/components/GenerateCourse/AICourseOutlineHeader.tsx index d099bfa83..92e6f98f2 100644 --- a/src/components/GenerateCourse/AICourseOutlineHeader.tsx +++ b/src/components/GenerateCourse/AICourseOutlineHeader.tsx @@ -12,6 +12,7 @@ type AICourseOutlineHeaderProps = { setViewMode: (mode: AICourseViewMode) => void; isForkable: boolean; onForkCourse: () => void; + courseSlug: string; }; export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) { @@ -23,6 +24,7 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) { setViewMode, isForkable, onForkCourse, + courseSlug, } = props; return ( @@ -36,9 +38,6 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {

{course.title || 'Loading course ..'}

-

- {course.title ? course.difficulty : 'Please wait ..'} -

@@ -48,6 +47,7 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) { onRegenerateOutline={onRegenerateOutline} isForkable={isForkable} onForkCourse={onForkCourse} + courseSlug={courseSlug} />
{isDropdownVisible && ( -
+
+ {showUpdatePreferences && ( + + )} +
+ )} + void; isLoading?: boolean; + guideSlug?: string; }; export function AIGuideContent(props: AIGuideContentProps) { - const { html, onRegenerate, isLoading } = props; + const { html, onRegenerate, isLoading, guideSlug } = props; return (
)} - {onRegenerate && !isLoading && ( + {onRegenerate && !isLoading && guideSlug && (
- +
)}
diff --git a/src/components/GenerateGuide/AIGuideRegenerate.tsx b/src/components/GenerateGuide/AIGuideRegenerate.tsx index 3be0df9c0..ae9c11947 100644 --- a/src/components/GenerateGuide/AIGuideRegenerate.tsx +++ b/src/components/GenerateGuide/AIGuideRegenerate.tsx @@ -1,25 +1,74 @@ -import { PenSquare, RefreshCcw } from 'lucide-react'; +import { PenSquare, RefreshCcw, SettingsIcon } from 'lucide-react'; import { useRef, useState } from 'react'; import { useOutsideClick } from '../../hooks/use-outside-click'; import { cn } from '../../lib/classname'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { ModifyCoursePrompt } from '../GenerateCourse/ModifyCoursePrompt'; +import { UpdatePreferences } from './UpdatePreferences'; +import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { getAiGuideOptions } from '../../queries/ai-guide'; +import { queryClient } from '../../stores/query-client'; +import { httpPost } from '../../lib/query-http'; +import { useAuth } from '../../hooks/use-auth'; +import { showLoginPopup } from '../../lib/popup'; +import { isLoggedIn } from '../../lib/jwt'; type AIGuideRegenerateProps = { onRegenerate: (prompt?: string) => void; + guideSlug: string; }; export function AIGuideRegenerate(props: AIGuideRegenerateProps) { - const { onRegenerate } = props; + const { onRegenerate, guideSlug } = props; const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [showPromptModal, setShowPromptModal] = useState(false); + const [showUpdatePreferencesModal, setShowUpdatePreferencesModal] = + useState(false); + const currentUser = useAuth(); const ref = useRef(null); useOutsideClick(ref, () => setIsDropdownVisible(false)); + const { data: aiGuide } = useQuery(getAiGuideOptions(guideSlug), queryClient); + const { mutate: updatePreferences, isPending: isUpdating } = useMutation( + { + mutationFn: (questionAndAnswers: QuestionAnswerChatMessage[]) => { + return httpPost(`/v1-update-guide-preferences/${guideSlug}`, { + questionAndAnswers, + }); + }, + onSuccess: (_, vars) => { + queryClient.setQueryData( + getAiGuideOptions(guideSlug).queryKey, + (old) => { + if (!old) { + return old; + } + + return { + ...old, + questionAndAnswers: vars, + }; + }, + ); + + setShowUpdatePreferencesModal(false); + setIsDropdownVisible(false); + onRegenerate(); + }, + }, + queryClient, + ); + + const showUpdatePreferences = + aiGuide?.questionAndAnswers && + aiGuide.questionAndAnswers.length > 0 && + currentUser?.id === aiGuide.userId; + return ( <> {showUpgradeModal && ( @@ -41,6 +90,19 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) { /> )} + {showUpdatePreferencesModal && ( + setShowUpdatePreferencesModal(false)} + questionAndAnswers={aiGuide?.questionAndAnswers} + term={aiGuide?.keyword || ''} + format="guide" + onUpdatePreferences={(questionAndAnswers) => { + updatePreferences(questionAndAnswers); + }} + isUpdating={isUpdating} + /> + )} +
{isDropdownVisible && ( -
+
+ {showUpdatePreferences && ( + + )} + + )} + + ); +} diff --git a/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx b/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx deleted file mode 100644 index 1bd6c3565..000000000 --- a/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Check, Clipboard } from 'lucide-react'; -import { useRef } from 'react'; -import { useAuth } from '../../hooks/use-auth'; -import { useCopyText } from '../../hooks/use-copy-text'; -import { useToast } from '../../hooks/use-toast'; -import { cn } from '../../lib/classname'; -import { Modal } from '../Modal'; - -type IncreaseRoadmapLimitProps = { - onClose: () => void; -}; - -export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) { - const { onClose } = props; - - const user = useAuth(); - const toast = useToast(); - const inputRef = useRef(null); - - const { copyText, isCopied } = useCopyText(); - const referralLink = new URL( - `/ai?rc=${user?.id}`, - import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh', - ).toString(); - - const handleCopy = () => { - inputRef.current?.select(); - copyText(referralLink); - toast.success('Copied to clipboard'); - }; - - return ( - -
-

- Refer your Friends -

-

- Share the URL below with your friends. When they sign up with your - link, you will get extra roadmap generation credits. -

- - -
-
- ); -} diff --git a/src/components/GenerateRoadmap/PayToBypass.tsx b/src/components/GenerateRoadmap/PayToBypass.tsx deleted file mode 100644 index 7b40c9fff..000000000 --- a/src/components/GenerateRoadmap/PayToBypass.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { ChevronLeft } from 'lucide-react'; -import { useAuth } from '../../hooks/use-auth'; - -type PayToBypassProps = { - onBack: () => void; - onClose: () => void; -}; - -export function PayToBypass(props: PayToBypassProps) { - const { onBack, onClose } = props; - const user = useAuth(); - - const userId = 'entry.1665642993'; - const nameId = 'entry.527005328'; - const emailId = 'entry.982906376'; - const amountId = 'entry.1826002937'; - const roadmapCountId = 'entry.1161404075'; - const usageId = 'entry.535914744'; - const feedbackId = 'entry.1024388959'; - - return ( -
- - -

Pay to Bypass

-

- Tell us more about how you will be using this. -

- - - - - - -
- - -
-
- -