From cfbb4f32abd4cf46cf9487234ab0e76d3b430735 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Thu, 13 Mar 2025 14:42:32 +0000 Subject: [PATCH] Refactor ai courses --- 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 | 84 ++++++----- .../AICourseFollowUpPopover.tsx | 10 +- .../GenerateCourse/AICourseLimit.tsx | 8 +- .../GenerateCourse/AICourseModuleView.tsx | 4 +- .../GenerateCourse/AILimitsPopup.tsx | 2 +- .../GenerateCourse/GenerateAICourse.tsx | 125 +++-------------- src/components/GenerateCourse/GetAICourse.tsx | 26 +++- .../GenerateCourse/RegenerateOutline.tsx | 75 ++++++++++ .../GenerateCourse/UserCoursesList.tsx | 2 +- src/components/GenerateCourse/re-generate | 0 .../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 | 132 ++++++++++++++++++ src/helper/read-stream.ts | 112 --------------- src/lib/ai.ts | 114 +++++++++++++++ src/lib/number.ts | 13 ++ src/queries/ai-course.ts | 2 +- src/queries/billing.ts | 19 ++- 29 files changed, 543 insertions(+), 314 deletions(-) create mode 100644 src/components/Billing/BillingWarning.tsx create mode 100644 src/components/GenerateCourse/RegenerateOutline.tsx create mode 100644 src/components/GenerateCourse/re-generate create mode 100644 src/helper/generate-ai-course.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 && ( <div className="mt-1"> - {billingDetails?.status === 'past_due' && ( - <div className="mb-6 flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-600"> - <AlertTriangle className="h-5 w-5" /> - <span> - We were not able to charge your card.{' '} - <button - disabled={ - isCreatingCustomerPortal || - isCreatingCustomerPortalSuccess - } - onClick={() => { - createCustomerPortal({}); - }} - className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50" - > - Update payment information. - </button> - </span> - </div> + {isCanceled && ( + <BillingWarning + icon={CircleX} + message="Your subscription has been canceled." + buttonText="Reactivate?" + onButtonClick={() => { + createCustomerPortal({}); + }} + isLoading={ + isCreatingCustomerPortal || isCreatingCustomerPortalSuccess + } + /> + )} + {isPastDue && ( + <BillingWarning + message="We were not able to charge your card." + buttonText="Update payment information." + onButtonClick={() => { + createCustomerPortal({}); + }} + isLoading={ + isCreatingCustomerPortal || isCreatingCustomerPortalSuccess + } + /> )} <h2 className="mb-2 text-xl font-semibold text-black"> @@ -181,7 +188,7 @@ export function BillingPage() { </div> <div className="mt-8 flex gap-3 max-sm:flex-col"> - {!shouldHideDeleteButton && ( + {!isCanceled && ( <button className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 max-sm:flex-grow" onClick={() => { diff --git a/src/components/Billing/BillingWarning.tsx b/src/components/Billing/BillingWarning.tsx new file mode 100644 index 000000000..f71cce994 --- /dev/null +++ b/src/components/Billing/BillingWarning.tsx @@ -0,0 +1,39 @@ +import { AlertTriangle, type LucideIcon } from 'lucide-react'; + +export type BillingWarningProps = { + icon?: LucideIcon; + message: string; + onButtonClick?: () => void; + buttonText?: string; + isLoading?: boolean; +}; + +export function BillingWarning(props: BillingWarningProps) { + const { + message, + onButtonClick, + buttonText, + isLoading, + icon: Icon = AlertTriangle, + } = props; + + return ( + <div className="mb-6 flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-600"> + <Icon className="h-5 w-5" /> + <span> + {message} + {buttonText && ( + <button + disabled={isLoading} + onClick={() => { + onButtonClick?.(); + }} + className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50 ml-0.5" + > + {buttonText} + </button> + )} + </span> + </div> + ); +} 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..7e30708e7 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -20,16 +20,18 @@ import { AICourseModuleList } from './AICourseModuleList'; import { AICourseModuleView } from './AICourseModuleView'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { AILimitsPopup } from './AILimitsPopup'; +import { RegenerateOutline } from './RegenerateOutline'; type AICourseContentProps = { courseSlug?: string; course: AiCourse; isLoading: boolean; error?: string; + onRegenerateOutline: () => 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); @@ -49,21 +51,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<number, boolean> = {}; - 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<number, boolean> = {}; + course.modules.forEach((_, idx) => { + newState[idx] = false; + }); + + newState[nextModuleIndex] = true; + return newState; + }); }; const goToNextLesson = () => { @@ -78,26 +82,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<number, boolean> = {}; - // 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<number, boolean> = {}; + // 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 +116,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, @@ -351,7 +359,7 @@ export function AICourseContent(props: AICourseContentProps) { <div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl"> <div className={cn( - 'mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden', + 'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden', isLoading && 'striped-loader', )} > @@ -363,6 +371,12 @@ export function AICourseContent(props: AICourseContentProps) { {course.title ? course.difficulty : 'Please wait ..'} </p> </div> + + {!isLoading && ( + <RegenerateOutline + onRegenerateOutline={onRegenerateOutline} + /> + )} </div> {course.title ? ( <div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4"> 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..5dac1a284 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; @@ -33,7 +33,7 @@ export function AICourseLimit(props: AICourseLimitProps) { // has consumed 80% of the limit const isNearLimit = used >= limit * 0.8; - const isPaidUser = userBillingDetails.status !== 'none'; + const isPaidUser = userBillingDetails.status === 'active'; return ( <> diff --git a/src/components/GenerateCourse/AICourseModuleView.tsx b/src/components/GenerateCourse/AICourseModuleView.tsx index 34e07a91b..735d8d3c8 100644 --- a/src/components/GenerateCourse/AICourseModuleView.tsx +++ b/src/components/GenerateCourse/AICourseModuleView.tsx @@ -8,7 +8,7 @@ import { XIcon, } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; -import { readAICourseLessonStream } from '../../helper/read-stream'; +import { readStream } from '../../lib/ai'; import { cn } from '../../lib/classname'; import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; import { @@ -136,7 +136,7 @@ export function AICourseModuleView(props: AICourseModuleViewProps) { setIsLoading(false); setIsGenerating(true); - await readAICourseLessonStream(reader, { + await readStream(reader, { onStream: async (result) => { if (abortController.signal.aborted) { return; 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 ( <Modal diff --git a/src/components/GenerateCourse/GenerateAICourse.tsx b/src/components/GenerateCourse/GenerateAICourse.tsx index 5822a7855..a4af327bf 100644 --- a/src/components/GenerateCourse/GenerateAICourse.tsx +++ b/src/components/GenerateCourse/GenerateAICourse.tsx @@ -1,11 +1,9 @@ import { useEffect, useState } from 'react'; import { getUrlParams } from '../../lib/browser'; import { isLoggedIn } from '../../lib/jwt'; -import { generateAiCourseStructure, type AiCourse } from '../../lib/ai'; -import { readAICourseStream } from '../../helper/read-stream'; +import { type AiCourse } from '../../lib/ai'; import { AICourseContent } from './AICourseContent'; -import { queryClient } from '../../stores/query-client'; -import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { generateCourse } from '../../helper/generate-ai-course'; type GenerateAICourseProps = {}; @@ -38,119 +36,31 @@ export function GenerateAICourse(props: GenerateAICourseProps) { setTerm(paramsTerm); setDifficulty(paramsDifficulty); - generateCourse({ term: paramsTerm, difficulty: paramsDifficulty }); + handleGenerateCourse({ term: paramsTerm, difficulty: paramsDifficulty }); }, [term, difficulty]); - const generateCourse = async (options: { + const handleGenerateCourse = async (options: { term: string; difficulty: string; + isForce?: boolean; }) => { - const { term, difficulty } = options; + const { term, difficulty, isForce } = options; if (!isLoggedIn()) { window.location.href = '/ai-tutor'; return; } - setIsLoading(true); - setCourse({ - title: '', - modules: [], - difficulty: '', + await generateCourse({ + term, + difficulty, + onCourseIdChange: setCourseId, + onCourseSlugChange: setCourseSlug, + onCourseChange: setCourse, + onLoadingChange: setIsLoading, + onError: setError, + isForce, }); - 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 +77,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) { setDifficulty(difficulty); setIsLoading(true); - generateCourse({ term, difficulty }).finally(() => { + handleGenerateCourse({ term, difficulty }).finally(() => { setIsLoading(false); }); }; @@ -184,6 +94,9 @@ export function GenerateAICourse(props: GenerateAICourseProps) { course={course} isLoading={isLoading} error={error} + onRegenerateOutline={() => { + handleGenerateCourse({ term, difficulty, isForce: true }); + }} /> ); } diff --git a/src/components/GenerateCourse/GetAICourse.tsx b/src/components/GenerateCourse/GetAICourse.tsx index d26f613c8..5eb38999f 100644 --- a/src/components/GenerateCourse/GetAICourse.tsx +++ b/src/components/GenerateCourse/GetAICourse.tsx @@ -5,6 +5,7 @@ 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 +15,8 @@ export function GetAICourse(props: GetAICourseProps) { const { courseSlug } = props; const [isLoading, setIsLoading] = useState(true); - const { data: aiCourse, error } = useQuery( + const [error, setError] = useState(''); + const { data: aiCourse, error: queryError } = useQuery( { ...getAiCourseOptions({ aiCourseSlug: courseSlug }), select: (data) => { @@ -43,12 +45,27 @@ export function GetAICourse(props: GetAICourseProps) { }, [aiCourse]); useEffect(() => { - if (!error) { + if (!queryError) { return; } setIsLoading(false); - }, [error]); + setError(queryError.message); + }, [queryError]); + + const handleRegenerateCourse = async () => { + if (!aiCourse) { + return; + } + + await generateCourse({ + term: aiCourse.keyword, + difficulty: aiCourse.difficulty, + onLoadingChange: setIsLoading, + onError: setError, + isForce: true, + }); + }; return ( <AICourseContent @@ -59,7 +76,8 @@ export function GetAICourse(props: GetAICourseProps) { }} isLoading={isLoading} courseSlug={courseSlug} - error={error?.message} + error={error} + onRegenerateOutline={handleRegenerateCourse} /> ); } diff --git a/src/components/GenerateCourse/RegenerateOutline.tsx b/src/components/GenerateCourse/RegenerateOutline.tsx new file mode 100644 index 000000000..0bce70b0e --- /dev/null +++ b/src/components/GenerateCourse/RegenerateOutline.tsx @@ -0,0 +1,75 @@ +import { PenSquare, RefreshCcw } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { cn } from '../../lib/classname'; +import { useIsPaidUser } from '../../queries/billing'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; + +type RegenerateOutlineProps = { + onRegenerateOutline: () => void; +}; + +export function RegenerateOutline(props: RegenerateOutlineProps) { + const { onRegenerateOutline } = props; + + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const ref = useRef<HTMLDivElement>(null); + + const isPaidUser = useIsPaidUser(); + + useOutsideClick(ref, () => setIsDropdownVisible(false)); + + return ( + <> + {showUpgradeModal && ( + <UpgradeAccountModal + onClose={() => { + setShowUpgradeModal(false); + }} + /> + )} + + <div className="absolute right-3 top-3" ref={ref}> + <button + className={cn('text-gray-400 hover:text-black', { + 'text-black': isDropdownVisible, + })} + onClick={() => setIsDropdownVisible(!isDropdownVisible)} + > + <PenSquare className="text-current" size={16} strokeWidth={2.5} /> + </button> + {isDropdownVisible && ( + <div className="absolute right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white"> + <button + onClick={() => { + if (!isPaidUser) { + setIsDropdownVisible(false); + setShowUpgradeModal(true); + } else { + onRegenerateOutline(); + } + }} + className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100" + > + <RefreshCcw + size={16} + className="text-gray-400" + strokeWidth={2.5} + /> + Regenerate + </button> + <button className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"> + <PenSquare + size={16} + className="text-gray-400" + strokeWidth={2.5} + /> + Modify Prompt + </button> + </div> + )} + </div> + </> + ); +} diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx index e6e10a50d..925c882f5 100644 --- a/src/components/GenerateCourse/UserCoursesList.tsx +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -30,7 +30,7 @@ export function UserCoursesList(props: UserCoursesListProps) { const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = useQuery(billingDetailsOptions(), queryClient); - const isPaidUser = userBillingDetails?.status !== 'none'; + const isPaidUser = userBillingDetails?.status !== 'active'; const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery( listUserAiCoursesOptions(), diff --git a/src/components/GenerateCourse/re-generate b/src/components/GenerateCourse/re-generate new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx index ccb2cf16f..945fc2a35 100644 --- a/src/components/GenerateRoadmap/GenerateRoadmap.tsx +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -11,7 +11,6 @@ import { useToast } from '../../hooks/use-toast'; import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator'; import { renderFlowJSON } from '../../../editor/renderer/renderer'; import { replaceChildren } from '../../lib/dom'; -import { readAIRoadmapStream } from '../../helper/read-stream'; import { getOpenAIKey, isLoggedIn, @@ -31,7 +30,7 @@ import { showLoginPopup } from '../../lib/popup.ts'; import { cn } from '../../lib/classname.ts'; import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx'; import { AIRoadmapAlert } from './AIRoadmapAlert.tsx'; -import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts'; +import { IS_KEY_ONLY_ROADMAP_GENERATION, readAIRoadmapStream } from '../../lib/ai.ts'; import { AITermSuggestionInput } from './AITermSuggestionInput.tsx'; import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx'; import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx'; diff --git a/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx index 560d9e940..33938800a 100644 --- a/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx +++ b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx @@ -3,13 +3,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useKeydown } from '../../hooks/use-keydown'; import { useOutsideClick } from '../../hooks/use-outside-click'; import { markdownToHtml } from '../../lib/markdown'; -import { Ban, Cog, Contact, FileText, User, UserRound, X } from 'lucide-react'; +import { Ban, Cog, Contact, FileText, X } from 'lucide-react'; import { Spinner } from '../ReactIcons/Spinner'; import type { RoadmapNodeDetails } from './GenerateRoadmap'; import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt'; -import { readAIRoadmapContentStream } from '../../helper/read-stream'; import { cn } from '../../lib/classname'; import { showLoginPopup } from '../../lib/popup'; +import { readAIRoadmapContentStream } from '../../lib/ai'; type RoadmapTopicDetailProps = RoadmapNodeDetails & { onClose?: () => void; diff --git a/src/components/Navigation/Navigation.astro b/src/components/Navigation/Navigation.astro index 1b87eefd4..bebe897f9 100644 --- a/src/components/Navigation/Navigation.astro +++ b/src/components/Navigation/Navigation.astro @@ -49,7 +49,10 @@ import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement'; </span> </span> </a> - <a href='/teams' class='group hidden xl:block relative text-gray-400 hover:text-white'> + <a + href='/teams' + class='group relative hidden text-gray-400 hover:text-white xl:block' + > Teams </a> </div> diff --git a/src/components/UserPublicProfile/UserProfileRoadmap.tsx b/src/components/UserPublicProfile/UserProfileRoadmap.tsx index 0d42ba79d..5dfb69ab4 100644 --- a/src/components/UserPublicProfile/UserProfileRoadmap.tsx +++ b/src/components/UserPublicProfile/UserProfileRoadmap.tsx @@ -2,7 +2,7 @@ import type { GetUserProfileRoadmapResponse, GetPublicProfileResponse, } from '../../api/user'; -import { getPercentage } from '../../helper/number'; +import { getPercentage } from '../../lib/number'; import { PrivateProfileBanner } from './PrivateProfileBanner'; import { UserProfileRoadmapRenderer } from './UserProfileRoadmapRenderer'; diff --git a/src/components/UserPublicProfile/UserPublicProgressStats.tsx b/src/components/UserPublicProfile/UserPublicProgressStats.tsx index 9b8fc85f5..c9eef634c 100644 --- a/src/components/UserPublicProfile/UserPublicProgressStats.tsx +++ b/src/components/UserPublicProfile/UserPublicProgressStats.tsx @@ -1,5 +1,5 @@ -import { getPercentage } from '../../helper/number'; import { getRelativeTimeString } from '../../lib/date'; +import { getPercentage } from '../../lib/number'; type UserPublicProgressStats = { resourceType: 'roadmap'; diff --git a/src/components/UserPublicProfile/UserPublicProgresses.tsx b/src/components/UserPublicProfile/UserPublicProgresses.tsx index 1eac8e296..cb5fb2fb1 100644 --- a/src/components/UserPublicProfile/UserPublicProgresses.tsx +++ b/src/components/UserPublicProfile/UserPublicProgresses.tsx @@ -1,6 +1,5 @@ import type { GetPublicProfileResponse } from '../../api/user'; -import { UserPublicProgressStats } from './UserPublicProgressStats'; -import { getPercentage } from '../../helper/number.ts'; +import { getPercentage } from '../../lib/number'; type UserPublicProgressesProps = { userId: string; @@ -73,15 +72,15 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) { target="_blank" key={roadmap.id + counter} href={`/${roadmap.id}?s=${userId}`} - className="relative group border-gray-300 flex items-center justify-between rounded-md border bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400 overflow-hidden" + className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400" > <span className="flex-grow truncate">{roadmap.title}</span> <span className="text-xs text-gray-400"> - {parseInt(percentageDone, 10)}% + {percentageDone}% </span> <span - className="absolute transition-colors left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10" + className="absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 transition-colors group-hover:bg-black/10" style={{ width: `${percentageDone}%`, }} diff --git a/src/helper/generate-ai-course.ts b/src/helper/generate-ai-course.ts new file mode 100644 index 000000000..34092fbb1 --- /dev/null +++ b/src/helper/generate-ai-course.ts @@ -0,0 +1,132 @@ +import { + generateAiCourseStructure, + readStream, + type AiCourse, +} from '../lib/ai'; +import { queryClient } from '../stores/query-client'; +import { getAiCourseLimitOptions } from '../queries/ai-course'; + +type GenerateCourseOptions = { + term: string; + difficulty: string; + isForce?: boolean; + onCourseIdChange?: (courseId: string) => void; + onCourseSlugChange?: (courseSlug: string) => void; + onCourseChange?: (course: AiCourse) => void; + onLoadingChange?: (isLoading: boolean) => void; + onError?: (error: string) => void; +}; + +export async function generateCourse(options: GenerateCourseOptions) { + const { + term, + difficulty, + onCourseIdChange, + onCourseSlugChange, + onCourseChange, + onLoadingChange, + onError, + isForce = false, + } = options; + + onLoadingChange?.(true); + onCourseChange?.({ + title: '', + modules: [], + difficulty: '', + }); + onError?.(''); + + 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, + isForce, + }), + credentials: 'include', + }, + ); + + if (!response.ok) { + const data = await response.json(); + console.error( + 'Error generating course:', + data?.message || 'Something went wrong', + ); + onLoadingChange?.(false); + onError?.(data?.message || 'Something went wrong'); + return; + } + + const reader = response.body?.getReader(); + + if (!reader) { + console.error('Failed to get reader from response'); + onError?.('Something went wrong'); + onLoadingChange?.(false); + return; + } + + const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); + const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/); + + await readStream(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: extractedCourseId, + courseSlug: extractedCourseSlug, + term, + difficulty, + }, + '', + `${origin}/ai-tutor/${extractedCourseSlug}`, + ); + } + + result = result + .replace(COURSE_ID_REGEX, '') + .replace(COURSE_SLUG_REGEX, ''); + + onCourseIdChange?.(extractedCourseId); + onCourseSlugChange?.(extractedCourseSlug); + } + + try { + const aiCourse = generateAiCourseStructure(result); + onCourseChange?.({ + ...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, ''); + onLoadingChange?.(false); + queryClient.invalidateQueries(getAiCourseLimitOptions()); + }, + }); + } catch (error: any) { + onError?.(error?.message || 'Something went wrong'); + console.error('Error in course generation:', error); + onLoadingChange?.(false); + } +} diff --git a/src/helper/read-stream.ts b/src/helper/read-stream.ts index 2d65de068..8ae446cae 100644 --- a/src/helper/read-stream.ts +++ b/src/helper/read-stream.ts @@ -1,117 +1,5 @@ const NEW_LINE = '\n'.charCodeAt(0); -export async function readAIRoadmapStream( - reader: ReadableStreamDefaultReader<Uint8Array>, - { - onStream, - onStreamEnd, - }: { - onStream?: (roadmap: string) => void; - onStreamEnd?: (roadmap: string) => void; - }, -) { - const decoder = new TextDecoder('utf-8'); - let result = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) { - break; - } - - // We will call the renderRoadmap callback whenever we encounter - // a new line with the result until the new line - // otherwise, we will keep appending the result to the previous result - if (value) { - let start = 0; - for (let i = 0; i < value.length; i++) { - if (value[i] === NEW_LINE) { - result += decoder.decode(value.slice(start, i + 1)); - onStream?.(result); - start = i + 1; - } - } - if (start < value.length) { - result += decoder.decode(value.slice(start)); - } - } - } - - onStream?.(result); - onStreamEnd?.(result); - reader.releaseLock(); -} - -export async function readAIRoadmapContentStream( - reader: ReadableStreamDefaultReader<Uint8Array>, - { - onStream, - onStreamEnd, - }: { - onStream?: (roadmap: string) => void; - onStreamEnd?: (roadmap: string) => void; - }, -) { - const decoder = new TextDecoder('utf-8'); - let result = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) { - break; - } - - if (value) { - result += decoder.decode(value); - onStream?.(result); - } - } - - onStream?.(result); - onStreamEnd?.(result); - reader.releaseLock(); -} - -export async function readAICourseStream( - reader: ReadableStreamDefaultReader<Uint8Array>, - { - onStream, - onStreamEnd, - }: { - onStream?: (course: string) => void; - onStreamEnd?: (course: string) => void; - }, -) { - const decoder = new TextDecoder('utf-8'); - let result = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) { - break; - } - - // Process the stream data as it comes in - if (value) { - let start = 0; - for (let i = 0; i < value.length; i++) { - if (value[i] === NEW_LINE) { - result += decoder.decode(value.slice(start, i + 1)); - onStream?.(result); - start = i + 1; - } - } - if (start < value.length) { - result += decoder.decode(value.slice(start)); - } - } - } - - onStream?.(result); - onStreamEnd?.(result); - reader.releaseLock(); -} - export async function readAICourseLessonStream( reader: ReadableStreamDefaultReader<Uint8Array>, { diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 75307d36f..b7cec3f57 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -53,3 +53,117 @@ export function generateAiCourseStructure( modules, }; } + +const NEW_LINE = '\n'.charCodeAt(0); + +export async function readAIRoadmapStream( + reader: ReadableStreamDefaultReader<Uint8Array>, + { + onStream, + onStreamEnd, + }: { + onStream?: (roadmap: string) => void; + onStreamEnd?: (roadmap: string) => void; + }, +) { + const decoder = new TextDecoder('utf-8'); + let result = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + // We will call the renderRoadmap callback whenever we encounter + // a new line with the result until the new line + // otherwise, we will keep appending the result to the previous result + if (value) { + let start = 0; + for (let i = 0; i < value.length; i++) { + if (value[i] === NEW_LINE) { + result += decoder.decode(value.slice(start, i + 1)); + onStream?.(result); + start = i + 1; + } + } + if (start < value.length) { + result += decoder.decode(value.slice(start)); + } + } + } + + onStream?.(result); + onStreamEnd?.(result); + reader.releaseLock(); +} + +export async function readAIRoadmapContentStream( + reader: ReadableStreamDefaultReader<Uint8Array>, + { + onStream, + onStreamEnd, + }: { + onStream?: (roadmap: string) => void; + onStreamEnd?: (roadmap: string) => void; + }, +) { + const decoder = new TextDecoder('utf-8'); + let result = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + if (value) { + result += decoder.decode(value); + onStream?.(result); + } + } + + onStream?.(result); + onStreamEnd?.(result); + reader.releaseLock(); +} + +export async function readStream( + reader: ReadableStreamDefaultReader<Uint8Array>, + { + onStream, + onStreamEnd, + }: { + onStream?: (course: string) => void; + onStreamEnd?: (course: string) => void; + }, +) { + const decoder = new TextDecoder('utf-8'); + let result = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + // Process the stream data as it comes in + if (value) { + let start = 0; + for (let i = 0; i < value.length; i++) { + if (value[i] === NEW_LINE) { + result += decoder.decode(value.slice(start, i + 1)); + onStream?.(result); + start = i + 1; + } + } + if (start < value.length) { + result += decoder.decode(value.slice(start)); + } + } + } + + onStream?.(result); + onStreamEnd?.(result); + reader.releaseLock(); +} diff --git a/src/lib/number.ts b/src/lib/number.ts index d686a5934..feadfbaf2 100644 --- a/src/lib/number.ts +++ b/src/lib/number.ts @@ -21,3 +21,16 @@ export function humanizeNumber(number: number): string { return `${decimalIfNeeded(number / 1000000)}m`; } + +export function getPercentage(portion: number, total: number): number { + if (portion <= 0 || total <= 0) { + return 0; + } + + if (portion >= total) { + return 100; + } + + const percentage = (portion / total) * 100; + return Math.round(percentage); +} diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts index f7a947579..c95c77154 100644 --- a/src/queries/ai-course.ts +++ b/src/queries/ai-course.ts @@ -39,6 +39,7 @@ export interface AICourseDocument { title: string; slug?: string; keyword: string; + done: string[]; difficulty: string; data: string; viewCount: number; @@ -75,7 +76,6 @@ export function getAiCourseLimitOptions() { } export type AICourseListItem = AICourseDocument & { - progress: AICourseProgressDocument; lessonCount: number; }; diff --git a/src/queries/billing.ts b/src/queries/billing.ts index f7250e62a..717b8a36c 100644 --- a/src/queries/billing.ts +++ b/src/queries/billing.ts @@ -1,6 +1,7 @@ -import { queryOptions } from '@tanstack/react-query'; +import { queryOptions, useQuery } from '@tanstack/react-query'; import { httpGet } from '../lib/query-http'; import { isLoggedIn } from '../lib/jwt'; +import { queryClient } from '../stores/query-client'; export const allowedSubscriptionStatus = [ 'active', @@ -53,6 +54,22 @@ export function billingDetailsOptions() { }); } +export function useIsPaidUser() { + const { data } = useQuery( + { + queryKey: ['billing-details'], + queryFn: async () => { + return httpGet<BillingDetailsResponse>('/v1-billing-details'); + }, + enabled: !!isLoggedIn(), + select: (data) => data.status === 'active', + }, + queryClient, + ); + + return data ?? false; +} + type CoursePriceParams = { courseSlug: string; };