From 79c6e2be53a9acf5aea6b85faff214fbb8602c53 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Fri, 14 Mar 2025 03:05:07 +0000 Subject: [PATCH] refactor: ai-courses (#8327) * Refactor ai courses * Refactor * Regenerate roadmap functionality * Title and difficulty to refresh also * Add course regeneration * Improve the non paid user headings * Update * Improve back button logic * Is paid user checks --- src/components/Activity/ProjectProgress.tsx | 1 - src/components/Activity/ResourceProgress.tsx | 2 +- src/components/Billing/BillingPage.tsx | 55 +++--- src/components/Billing/BillingWarning.tsx | 39 +++++ .../Dashboard/DashboardCustomProgressCard.tsx | 2 +- .../Dashboard/DashboardProgressCard.tsx | 3 +- .../GenerateCourse/AICourseCard.tsx | 2 +- .../GenerateCourse/AICourseContent.tsx | 111 +++++++----- .../GenerateCourse/AICourseFollowUp.tsx | 5 +- .../AICourseFollowUpPopover.tsx | 10 +- .../GenerateCourse/AICourseLimit.tsx | 29 ++-- .../GenerateCourse/AICourseModuleView.tsx | 81 +++++---- .../GenerateCourse/AILimitsPopup.tsx | 2 +- .../GenerateCourse/GenerateAICourse.tsx | 133 +++----------- src/components/GenerateCourse/GetAICourse.tsx | 57 +++++- .../GenerateCourse/ModifyCoursePrompt.tsx | 69 ++++++++ .../GenerateCourse/RegenerateOutline.tsx | 98 +++++++++++ .../GenerateCourse/UserCoursesList.tsx | 73 ++++---- .../GenerateRoadmap/GenerateRoadmap.tsx | 3 +- .../GenerateRoadmap/RoadmapTopicDetail.tsx | 4 +- src/components/Navigation/Navigation.astro | 5 +- .../UserPublicProfile/UserProfileRoadmap.tsx | 2 +- .../UserPublicProgressStats.tsx | 2 +- .../UserPublicProgresses.tsx | 9 +- src/helper/generate-ai-course.ts | 162 ++++++++++++++++++ src/helper/number.ts | 12 -- src/helper/read-stream.ts | 141 --------------- src/lib/ai.ts | 114 ++++++++++++ src/lib/number.ts | 13 ++ src/queries/ai-course.ts | 2 +- src/queries/billing.ts | 22 ++- 31 files changed, 813 insertions(+), 450 deletions(-) create mode 100644 src/components/Billing/BillingWarning.tsx create mode 100644 src/components/GenerateCourse/ModifyCoursePrompt.tsx create mode 100644 src/components/GenerateCourse/RegenerateOutline.tsx create mode 100644 src/helper/generate-ai-course.ts delete mode 100644 src/helper/number.ts delete mode 100644 src/helper/read-stream.ts diff --git a/src/components/Activity/ProjectProgress.tsx b/src/components/Activity/ProjectProgress.tsx index 6ac20de28..3e91c25f3 100644 --- a/src/components/Activity/ProjectProgress.tsx +++ b/src/components/Activity/ProjectProgress.tsx @@ -1,5 +1,4 @@ import { getUser } from '../../lib/jwt'; -import { getPercentage } from '../../helper/number'; import { ProjectProgressActions } from './ProjectProgressActions'; import { cn } from '../../lib/classname'; import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; diff --git a/src/components/Activity/ResourceProgress.tsx b/src/components/Activity/ResourceProgress.tsx index 28c88ec9f..00b38aba3 100644 --- a/src/components/Activity/ResourceProgress.tsx +++ b/src/components/Activity/ResourceProgress.tsx @@ -1,7 +1,7 @@ import { getUser } from '../../lib/jwt'; -import { getPercentage } from '../../helper/number'; import { ResourceProgressActions } from './ResourceProgressActions'; import { cn } from '../../lib/classname'; +import { getPercentage } from '../../lib/number'; type ResourceProgressType = { resourceType: 'roadmap' | 'best-practice'; diff --git a/src/components/Billing/BillingPage.tsx b/src/components/Billing/BillingPage.tsx index fdf8287b0..92748a844 100644 --- a/src/components/Billing/BillingPage.tsx +++ b/src/components/Billing/BillingPage.tsx @@ -16,10 +16,11 @@ import { Calendar, RefreshCw, Loader2, - AlertTriangle, CreditCard, ArrowRightLeft, + CircleX, } from 'lucide-react'; +import { BillingWarning } from './BillingWarning'; export type CreateCustomerPortalBody = {}; @@ -38,6 +39,10 @@ export function BillingPage() { queryClient, ); + const isCanceled = + billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd; + const isPastDue = billingDetails?.status === 'past_due'; + const { mutate: createCustomerPortal, isSuccess: isCreatingCustomerPortalSuccess, @@ -80,9 +85,6 @@ export function BillingPage() { const selectedPlanDetails = USER_SUBSCRIPTION_PLAN_PRICES.find( (plan) => plan.priceId === billingDetails?.priceId, ); - - const shouldHideDeleteButton = - billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd; const priceDetails = selectedPlanDetails; const formattedNextBillDate = new Date( @@ -115,25 +117,30 @@ export function BillingPage() { !isLoadingBillingDetails && priceDetails && (
- {billingDetails?.status === 'past_due' && ( -
- - - We were not able to charge your card.{' '} - - -
+ {isCanceled && ( + { + createCustomerPortal({}); + }} + isLoading={ + isCreatingCustomerPortal || isCreatingCustomerPortalSuccess + } + /> + )} + {isPastDue && ( + { + createCustomerPortal({}); + }} + isLoading={ + isCreatingCustomerPortal || isCreatingCustomerPortalSuccess + } + /> )}

@@ -181,7 +188,7 @@ export function BillingPage() {

- {!shouldHideDeleteButton && ( + {!isCanceled && ( + )} + +
+ ); +} diff --git a/src/components/Dashboard/DashboardCustomProgressCard.tsx b/src/components/Dashboard/DashboardCustomProgressCard.tsx index 9464d5a73..9262614bc 100644 --- a/src/components/Dashboard/DashboardCustomProgressCard.tsx +++ b/src/components/Dashboard/DashboardCustomProgressCard.tsx @@ -1,5 +1,5 @@ -import { getPercentage } from '../../helper/number'; import { getRelativeTimeString } from '../../lib/date'; +import { getPercentage } from '../../lib/number'; import type { UserProgress } from '../TeamProgress/TeamProgressPage'; type DashboardCustomProgressCardProps = { diff --git a/src/components/Dashboard/DashboardProgressCard.tsx b/src/components/Dashboard/DashboardProgressCard.tsx index d243051b0..467cffb6c 100644 --- a/src/components/Dashboard/DashboardProgressCard.tsx +++ b/src/components/Dashboard/DashboardProgressCard.tsx @@ -1,6 +1,5 @@ -import { getPercentage } from '../../helper/number'; +import { getPercentage } from '../../lib/number'; import type { UserProgress } from '../TeamProgress/TeamProgressPage'; -import { ArrowUpRight, ExternalLink } from 'lucide-react'; type DashboardProgressCardProps = { progress: UserProgress; diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx index 3a4a6bd6c..4048dd73f 100644 --- a/src/components/GenerateCourse/AICourseCard.tsx +++ b/src/components/GenerateCourse/AICourseCard.tsx @@ -27,7 +27,7 @@ export function AICourseCard(props: AICourseCardProps) { // Calculate progress percentage const totalTopics = course.lessonCount || 0; - const completedTopics = course.progress?.done?.length || 0; + const completedTopics = course.done?.length || 0; const progressPercentage = totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0; diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index ce3ad71fb..78b6d304e 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -20,16 +20,19 @@ import { AICourseModuleList } from './AICourseModuleList'; import { AICourseModuleView } from './AICourseModuleView'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { AILimitsPopup } from './AILimitsPopup'; +import { RegenerateOutline } from './RegenerateOutline'; +import { useIsPaidUser } from '../../queries/billing'; type AICourseContentProps = { courseSlug?: string; course: AiCourse; isLoading: boolean; error?: string; + onRegenerateOutline: (prompt?: string) => void; }; export function AICourseContent(props: AICourseContentProps) { - const { course, courseSlug, isLoading, error } = props; + const { course, courseSlug, isLoading, error, onRegenerateOutline } = props; const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); @@ -39,6 +42,8 @@ export function AICourseContent(props: AICourseContentProps) { const [sidebarOpen, setSidebarOpen] = useState(false); const [viewMode, setViewMode] = useState<'module' | 'full'>('full'); + const { isPaidUser } = useIsPaidUser(); + const { data: aiCourseProgress } = useQuery( getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), queryClient, @@ -49,21 +54,23 @@ export function AICourseContent(props: AICourseContentProps) { >({}); const goToNextModule = () => { - if (activeModuleIndex < course.modules.length - 1) { - const nextModuleIndex = activeModuleIndex + 1; - setActiveModuleIndex(nextModuleIndex); - setActiveLessonIndex(0); - - setExpandedModules((prev) => { - const newState: Record = {}; - course.modules.forEach((_, idx) => { - newState[idx] = false; - }); - - newState[nextModuleIndex] = true; - return newState; - }); + if (activeModuleIndex >= course.modules.length) { + return; } + + const nextModuleIndex = activeModuleIndex + 1; + setActiveModuleIndex(nextModuleIndex); + setActiveLessonIndex(0); + + setExpandedModules((prev) => { + const newState: Record = {}; + course.modules.forEach((_, idx) => { + newState[idx] = false; + }); + + newState[nextModuleIndex] = true; + return newState; + }); }; const goToNextLesson = () => { @@ -78,26 +85,29 @@ export function AICourseContent(props: AICourseContentProps) { const goToPrevLesson = () => { if (activeLessonIndex > 0) { setActiveLessonIndex(activeLessonIndex - 1); - } else { - const prevModule = course.modules[activeModuleIndex - 1]; - if (prevModule) { - const prevModuleIndex = activeModuleIndex - 1; - setActiveModuleIndex(prevModuleIndex); - setActiveLessonIndex(prevModule.lessons.length - 1); - - // Expand the previous module in the sidebar - setExpandedModules((prev) => { - const newState: Record = {}; - // Set all modules to collapsed - course.modules.forEach((_, idx) => { - newState[idx] = false; - }); - // Expand only the previous module - newState[prevModuleIndex] = true; - return newState; - }); - } + return; } + + const prevModule = course.modules[activeModuleIndex - 1]; + if (!prevModule) { + return; + } + + const prevModuleIndex = activeModuleIndex - 1; + setActiveModuleIndex(prevModuleIndex); + setActiveLessonIndex(prevModule.lessons.length - 1); + + // Expand the previous module in the sidebar + setExpandedModules((prev) => { + const newState: Record = {}; + // Set all modules to collapsed + course.modules.forEach((_, idx) => { + newState[idx] = false; + }); + // Expand only the previous module + newState[prevModuleIndex] = true; + return newState; + }); }; const currentModule = course.modules[activeModuleIndex]; @@ -109,6 +119,7 @@ export function AICourseContent(props: AICourseContentProps) { (total, module) => total + module.lessons.length, 0, ); + const totalDoneLessons = aiCourseProgress?.done?.length || 0; const finishedPercentage = Math.round( (totalDoneLessons / totalCourseLessons) * 100, @@ -154,12 +165,14 @@ export function AICourseContent(props: AICourseContentProps) { {isLimitReached && (
- + {!isPaidUser && ( + + )}

@@ -173,6 +186,8 @@ export function AICourseContent(props: AICourseContentProps) { ); } + const isViewingLesson = viewMode === 'module'; + return (

{modals} @@ -181,11 +196,17 @@ export function AICourseContent(props: AICourseContentProps) {
{ + if (isViewingLesson) { + e.preventDefault(); + setViewMode('full'); + } + }} className="flex flex-row items-center gap-1.5 text-sm font-medium text-gray-700 hover:text-gray-900" aria-label="Back to generator" > - Back to AI Tutor + Back {isViewingLesson ? 'to Outline' : 'to AI Tutor'}
@@ -351,7 +372,7 @@ export function AICourseContent(props: AICourseContentProps) {
@@ -363,6 +384,12 @@ export function AICourseContent(props: AICourseContentProps) { {course.title ? course.difficulty : 'Please wait ..'}

+ + {!isLoading && ( + + )}
{course.title ? (
diff --git a/src/components/GenerateCourse/AICourseFollowUp.tsx b/src/components/GenerateCourse/AICourseFollowUp.tsx index 9241ccdb2..917d1d6f9 100644 --- a/src/components/GenerateCourse/AICourseFollowUp.tsx +++ b/src/components/GenerateCourse/AICourseFollowUp.tsx @@ -36,10 +36,7 @@ export function AICourseFollowUp(props: AICourseFollowUpProps) { onClick={() => setIsOpen(true)} > - - Still confused?  - Ask AI some follow up questions - + Ask AI some follow up questions diff --git a/src/components/GenerateCourse/AICourseFollowUpPopover.tsx b/src/components/GenerateCourse/AICourseFollowUpPopover.tsx index d1e84976f..4a03fdf1c 100644 --- a/src/components/GenerateCourse/AICourseFollowUpPopover.tsx +++ b/src/components/GenerateCourse/AICourseFollowUpPopover.tsx @@ -2,18 +2,18 @@ import { useQuery } from '@tanstack/react-query'; import { BookOpen, Bot, Code, HelpCircle, LockIcon, Send } from 'lucide-react'; import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { flushSync } from 'react-dom'; +import TextareaAutosize from 'react-textarea-autosize'; import { useOutsideClick } from '../../hooks/use-outside-click'; -import { readAICourseLessonStream } from '../../helper/read-stream'; -import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; import { useToast } from '../../hooks/use-toast'; +import { readStream } from '../../lib/ai'; +import { cn } from '../../lib/classname'; +import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; import { markdownToHtml, markdownToHtmlWithHighlighting, } from '../../lib/markdown'; -import { cn } from '../../lib/classname'; import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; -import TextareaAutosize from 'react-textarea-autosize'; export type AllowedAIChatRole = 'user' | 'assistant'; export type AIChatHistoryType = { @@ -142,7 +142,7 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) { return; } - await readAICourseLessonStream(reader, { + await readStream(reader, { onStream: async (content) => { flushSync(() => { setStreamedMessage(content); diff --git a/src/components/GenerateCourse/AICourseLimit.tsx b/src/components/GenerateCourse/AICourseLimit.tsx index db57beda3..efba35c2b 100644 --- a/src/components/GenerateCourse/AICourseLimit.tsx +++ b/src/components/GenerateCourse/AICourseLimit.tsx @@ -1,9 +1,9 @@ import { useQuery } from '@tanstack/react-query'; +import { Gift, Info } from 'lucide-react'; +import { getPercentage } from '../../lib/number'; import { getAiCourseLimitOptions } from '../../queries/ai-course'; -import { queryClient } from '../../stores/query-client'; import { billingDetailsOptions } from '../../queries/billing'; -import { getPercentage } from '../../helper/number'; -import { Gift, Info } from 'lucide-react'; +import { queryClient } from '../../stores/query-client'; type AICourseLimitProps = { onUpgrade: () => void; @@ -31,19 +31,22 @@ export function AICourseLimit(props: AICourseLimitProps) { const totalPercentage = getPercentage(used, limit); - // has consumed 80% of the limit - const isNearLimit = used >= limit * 0.8; - const isPaidUser = userBillingDetails.status !== 'none'; + // has consumed 85% of the limit + const isNearLimit = used >= limit * 0.85; + const isPaidUser = userBillingDetails.status === 'active'; return ( <> - + {!isPaidUser || + (isNearLimit && ( + + ))} {(!isPaidUser || isNearLimit) && ( + {!isPaidUser && ( + + )}
) : (

{error}

diff --git a/src/components/GenerateCourse/AILimitsPopup.tsx b/src/components/GenerateCourse/AILimitsPopup.tsx index 7c87c6009..79244940c 100644 --- a/src/components/GenerateCourse/AILimitsPopup.tsx +++ b/src/components/GenerateCourse/AILimitsPopup.tsx @@ -24,7 +24,7 @@ export function AILimitsPopup(props: AILimitsPopupProps) { const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = useQuery(billingDetailsOptions(), queryClient); - const isPaidUser = userBillingDetails?.status !== 'none'; + const isPaidUser = userBillingDetails?.status === 'active'; return ( { - const { term, difficulty } = options; + const { term, difficulty, isForce, prompt } = options; if (!isLoggedIn()) { window.location.href = '/ai-tutor'; return; } - setIsLoading(true); - setCourse({ - title: '', - modules: [], - difficulty: '', + await generateCourse({ + term, + difficulty, + slug: courseSlug, + onCourseIdChange: setCourseId, + onCourseSlugChange: setCourseSlug, + onCourseChange: setCourse, + onLoadingChange: setIsLoading, + onError: setError, + isForce, + prompt, }); - setError(''); - - try { - const response = await fetch( - `${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - keyword: term, - difficulty, - }), - credentials: 'include', - }, - ); - - if (!response.ok) { - const data = await response.json(); - console.error( - 'Error generating course:', - data?.message || 'Something went wrong', - ); - setIsLoading(false); - setError(data?.message || 'Something went wrong'); - return; - } - - const reader = response.body?.getReader(); - - if (!reader) { - console.error('Failed to get reader from response'); - setError('Something went wrong'); - setIsLoading(false); - return; - } - - const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); - const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/); - - await readAICourseStream(reader, { - onStream: (result) => { - if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) { - const courseIdMatch = result.match(COURSE_ID_REGEX); - const courseSlugMatch = result.match(COURSE_SLUG_REGEX); - const extractedCourseId = courseIdMatch?.[1] || ''; - const extractedCourseSlug = courseSlugMatch?.[1] || ''; - - if (extractedCourseSlug) { - window.history.replaceState( - { - courseId, - courseSlug: extractedCourseSlug, - term, - difficulty, - }, - '', - `${origin}/ai-tutor/${extractedCourseSlug}`, - ); - } - - result = result - .replace(COURSE_ID_REGEX, '') - .replace(COURSE_SLUG_REGEX, ''); - - setCourseId(extractedCourseId); - setCourseSlug(extractedCourseSlug); - } - - try { - const aiCourse = generateAiCourseStructure(result); - setCourse({ - ...aiCourse, - difficulty: difficulty || '', - }); - } catch (e) { - console.error('Error parsing streamed course content:', e); - } - }, - onStreamEnd: (result) => { - result = result - .replace(COURSE_ID_REGEX, '') - .replace(COURSE_SLUG_REGEX, ''); - setIsLoading(false); - queryClient.invalidateQueries(getAiCourseLimitOptions()); - }, - }); - } catch (error: any) { - setError(error?.message || 'Something went wrong'); - console.error('Error in course generation:', error); - setIsLoading(false); - } }; useEffect(() => { @@ -167,7 +80,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) { setDifficulty(difficulty); setIsLoading(true); - generateCourse({ term, difficulty }).finally(() => { + handleGenerateCourse({ term, difficulty }).finally(() => { setIsLoading(false); }); }; @@ -184,6 +97,14 @@ export function GenerateAICourse(props: GenerateAICourseProps) { course={course} isLoading={isLoading} error={error} + onRegenerateOutline={(prompt) => { + handleGenerateCourse({ + term, + difficulty, + isForce: true, + prompt, + }); + }} /> ); } diff --git a/src/components/GenerateCourse/GetAICourse.tsx b/src/components/GenerateCourse/GetAICourse.tsx index d26f613c8..b3970d3fd 100644 --- a/src/components/GenerateCourse/GetAICourse.tsx +++ b/src/components/GenerateCourse/GetAICourse.tsx @@ -1,10 +1,14 @@ import { useQuery } from '@tanstack/react-query'; -import { getAiCourseOptions } from '../../queries/ai-course'; +import { + getAiCourseOptions, + getAiCourseProgressOptions, +} from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; import { useEffect, useState } from 'react'; import { AICourseContent } from './AICourseContent'; import { generateAiCourseStructure } from '../../lib/ai'; import { isLoggedIn } from '../../lib/jwt'; +import { generateCourse } from '../../helper/generate-ai-course'; type GetAICourseProps = { courseSlug: string; @@ -14,7 +18,10 @@ export function GetAICourse(props: GetAICourseProps) { const { courseSlug } = props; const [isLoading, setIsLoading] = useState(true); - const { data: aiCourse, error } = useQuery( + const [isRegenerating, setIsRegenerating] = useState(false); + + const [error, setError] = useState(''); + const { data: aiCourse, error: queryError } = useQuery( { ...getAiCourseOptions({ aiCourseSlug: courseSlug }), select: (data) => { @@ -43,12 +50,49 @@ export function GetAICourse(props: GetAICourseProps) { }, [aiCourse]); useEffect(() => { - if (!error) { + if (!queryError) { return; } setIsLoading(false); - }, [error]); + setError(queryError.message); + }, [queryError]); + + const handleRegenerateCourse = async (prompt?: string) => { + if (!aiCourse) { + return; + } + + await generateCourse({ + term: aiCourse.keyword, + difficulty: aiCourse.difficulty, + slug: courseSlug, + prompt, + onCourseChange: (course, rawData) => { + queryClient.setQueryData( + getAiCourseOptions({ aiCourseSlug: courseSlug }).queryKey, + { + ...aiCourse, + title: course.title, + difficulty: course.difficulty, + data: rawData, + }, + ); + }, + onLoadingChange: (isNewLoading) => { + setIsRegenerating(isNewLoading); + if (!isNewLoading) { + queryClient.invalidateQueries({ + queryKey: getAiCourseProgressOptions({ + aiCourseSlug: courseSlug, + }).queryKey, + }); + } + }, + onError: setError, + isForce: true, + }); + }; return ( ); } diff --git a/src/components/GenerateCourse/ModifyCoursePrompt.tsx b/src/components/GenerateCourse/ModifyCoursePrompt.tsx new file mode 100644 index 000000000..35583635d --- /dev/null +++ b/src/components/GenerateCourse/ModifyCoursePrompt.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Modal } from '../Modal'; + +export type ModifyCoursePromptProps = { + onClose: () => void; + onSubmit: (prompt: string) => void; +}; + +export function ModifyCoursePrompt(props: ModifyCoursePromptProps) { + const { onClose, onSubmit } = props; + + const [prompt, setPrompt] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(prompt); + }; + + return ( + +
+
+

+ Give AI more context +

+

+ Pass additional information to the AI to generate a course outline. +

+
+
+