mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-10-01 19:36:43 +02:00
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
This commit is contained in:
@@ -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';
|
||||
|
@@ -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';
|
||||
|
@@ -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={() => {
|
||||
{isCanceled && (
|
||||
<BillingWarning
|
||||
icon={CircleX}
|
||||
message="Your subscription has been canceled."
|
||||
buttonText="Reactivate?"
|
||||
onButtonClick={() => {
|
||||
createCustomerPortal({});
|
||||
}}
|
||||
className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Update payment information.
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
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={() => {
|
||||
|
39
src/components/Billing/BillingWarning.tsx
Normal file
39
src/components/Billing/BillingWarning.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
@@ -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 = {
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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,7 +54,10 @@ export function AICourseContent(props: AICourseContentProps) {
|
||||
>({});
|
||||
|
||||
const goToNextModule = () => {
|
||||
if (activeModuleIndex < course.modules.length - 1) {
|
||||
if (activeModuleIndex >= course.modules.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextModuleIndex = activeModuleIndex + 1;
|
||||
setActiveModuleIndex(nextModuleIndex);
|
||||
setActiveLessonIndex(0);
|
||||
@@ -63,7 +71,6 @@ export function AICourseContent(props: AICourseContentProps) {
|
||||
newState[nextModuleIndex] = true;
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToNextLesson = () => {
|
||||
@@ -78,9 +85,14 @@ export function AICourseContent(props: AICourseContentProps) {
|
||||
const goToPrevLesson = () => {
|
||||
if (activeLessonIndex > 0) {
|
||||
setActiveLessonIndex(activeLessonIndex - 1);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevModule = course.modules[activeModuleIndex - 1];
|
||||
if (prevModule) {
|
||||
if (!prevModule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevModuleIndex = activeModuleIndex - 1;
|
||||
setActiveModuleIndex(prevModuleIndex);
|
||||
setActiveLessonIndex(prevModule.lessons.length - 1);
|
||||
@@ -96,8 +108,6 @@ export function AICourseContent(props: AICourseContentProps) {
|
||||
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 && (
|
||||
<div className="mt-4">
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
onClick={() => setShowUpgradeModal(true)}
|
||||
className="rounded-md bg-yellow-400 px-6 py-2 text-sm font-medium text-black hover:bg-yellow-500"
|
||||
>
|
||||
Upgrade to remove Limits
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-black">
|
||||
<a href="/ai-tutor" className="underline underline-offset-2">
|
||||
@@ -173,6 +186,8 @@ export function AICourseContent(props: AICourseContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const isViewingLesson = viewMode === 'module';
|
||||
|
||||
return (
|
||||
<section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50">
|
||||
{modals}
|
||||
@@ -181,11 +196,17 @@ export function AICourseContent(props: AICourseContentProps) {
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<a
|
||||
href="/ai-tutor"
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="size-4" strokeWidth={2.5} />
|
||||
Back<span className="hidden lg:inline"> to AI Tutor</span>
|
||||
Back {isViewingLesson ? 'to Outline' : 'to AI Tutor'}
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-row lg:hidden">
|
||||
@@ -351,7 +372,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 +384,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">
|
||||
|
@@ -36,10 +36,7 @@ export function AICourseFollowUp(props: AICourseFollowUpProps) {
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<BotIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<span className="max-sm:hidden">Still confused? </span>
|
||||
Ask AI some follow up questions
|
||||
</span>
|
||||
<span>Ask AI some follow up questions</span>
|
||||
|
||||
<ArrowRightIcon className="ml-auto h-4 w-4 max-sm:hidden" />
|
||||
</button>
|
||||
|
@@ -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);
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 { getPercentage } from '../../lib/number';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
|
||||
type AICourseLimitProps = {
|
||||
onUpgrade: () => void;
|
||||
@@ -31,12 +31,14 @@ 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 && (
|
||||
<button
|
||||
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden"
|
||||
onClick={() => onShowLimits()}
|
||||
@@ -44,6 +46,7 @@ export function AICourseLimit(props: AICourseLimitProps) {
|
||||
<Info className="size-4" />
|
||||
{totalPercentage}% limit used
|
||||
</button>
|
||||
))}
|
||||
|
||||
{(!isPaidUser || isNearLimit) && (
|
||||
<button
|
||||
|
@@ -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 {
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AICourseFollowUp } from './AICourseFollowUp';
|
||||
import './AICourseFollowUp.css';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
|
||||
type AICourseModuleViewProps = {
|
||||
courseSlug: string;
|
||||
@@ -72,6 +73,8 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
|
||||
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`;
|
||||
const isLessonDone = aiCourseProgress?.done.includes(lessonId);
|
||||
|
||||
const { isPaidUser } = useIsPaidUser();
|
||||
|
||||
const abortController = useMemo(
|
||||
() => new AbortController(),
|
||||
[activeModuleIndex, activeLessonIndex],
|
||||
@@ -124,19 +127,20 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
setIsLoading(false);
|
||||
setError('No response body received');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reader = response.body.getReader();
|
||||
setIsLoading(false);
|
||||
setIsGenerating(true);
|
||||
await readAICourseLessonStream(reader, {
|
||||
await readStream(reader, {
|
||||
onStream: async (result) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
@@ -154,6 +158,10 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
|
||||
setIsGenerating(false);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: toggleDone, isPending: isTogglingDone } = useMutation(
|
||||
@@ -273,10 +281,12 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
|
||||
Limit reached
|
||||
</h2>
|
||||
<p className="my-3 text-red-600">
|
||||
You have reached the AI usage limit for today. Please upgrade
|
||||
your account to continue.
|
||||
You have reached the AI usage limit for today.
|
||||
{!isPaidUser && <>Please upgrade your account to continue.</>}
|
||||
{isPaidUser && <>Please wait until tomorrow to continue.</>}
|
||||
</p>
|
||||
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpgrade();
|
||||
@@ -285,6 +295,7 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
|
||||
>
|
||||
Upgrade Account
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-red-600">{error}</p>
|
||||
|
@@ -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
|
||||
|
@@ -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,34 @@ 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;
|
||||
prompt?: string;
|
||||
}) => {
|
||||
const { term, difficulty } = options;
|
||||
const { term, difficulty, isForce, prompt } = options;
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
window.location.href = '/ai-tutor';
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setCourse({
|
||||
title: '',
|
||||
modules: [],
|
||||
difficulty: '',
|
||||
});
|
||||
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,
|
||||
await generateCourse({
|
||||
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 || '',
|
||||
slug: courseSlug,
|
||||
onCourseIdChange: setCourseId,
|
||||
onCourseSlugChange: setCourseSlug,
|
||||
onCourseChange: setCourse,
|
||||
onLoadingChange: setIsLoading,
|
||||
onError: setError,
|
||||
isForce,
|
||||
prompt,
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -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 (
|
||||
<AICourseContent
|
||||
@@ -57,9 +101,10 @@ export function GetAICourse(props: GetAICourseProps) {
|
||||
modules: aiCourse?.course.modules || [],
|
||||
difficulty: aiCourse?.difficulty || 'Easy',
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isRegenerating}
|
||||
courseSlug={courseSlug}
|
||||
error={error?.message}
|
||||
error={error}
|
||||
onRegenerateOutline={handleRegenerateCourse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
69
src/components/GenerateCourse/ModifyCoursePrompt.tsx
Normal file
69
src/components/GenerateCourse/ModifyCoursePrompt.tsx
Normal file
@@ -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<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
onSubmit(prompt);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
wrapperClassName="rounded-xl max-w-xl w-full h-auto"
|
||||
bodyClassName="p-6"
|
||||
overlayClassName="items-start md:items-center"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="mb-2 text-left text-xl font-semibold">
|
||||
Give AI more context
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Pass additional information to the AI to generate a course outline.
|
||||
</p>
|
||||
</div>
|
||||
<form className="flex flex-col gap-2" onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
id="prompt"
|
||||
autoFocus
|
||||
rows={3}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-200 p-2 placeholder:text-sm focus:outline-black"
|
||||
placeholder="e.g. make sure to add a section on React hooks"
|
||||
/>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Complete the sentence: "I want AI to..."
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="rounded-md bg-gray-200 px-4 py-2.5 text-sm text-black hover:opacity-80"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!prompt.trim()}
|
||||
className="rounded-md bg-black px-4 py-2.5 text-sm text-white hover:opacity-80 disabled:opacity-50"
|
||||
>
|
||||
Modify Prompt
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
98
src/components/GenerateCourse/RegenerateOutline.tsx
Normal file
98
src/components/GenerateCourse/RegenerateOutline.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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';
|
||||
import { ModifyCoursePrompt } from './ModifyCoursePrompt';
|
||||
|
||||
type RegenerateOutlineProps = {
|
||||
onRegenerateOutline: (prompt?: string) => void;
|
||||
};
|
||||
|
||||
export function RegenerateOutline(props: RegenerateOutlineProps) {
|
||||
const { onRegenerateOutline } = props;
|
||||
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { isPaidUser } = useIsPaidUser();
|
||||
|
||||
useOutsideClick(ref, () => setIsDropdownVisible(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
setShowUpgradeModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPromptModal && (
|
||||
<ModifyCoursePrompt
|
||||
onClose={() => setShowPromptModal(false)}
|
||||
onSubmit={(prompt) => {
|
||||
setShowPromptModal(false);
|
||||
onRegenerateOutline(prompt);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(false);
|
||||
if (!isPaidUser) {
|
||||
setShowUpgradeModal(true);
|
||||
} else {
|
||||
setShowPromptModal(true);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -10,7 +10,7 @@ import { Gift, Loader2, Search, User2 } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
|
||||
type UserCoursesListProps = {};
|
||||
@@ -20,17 +20,13 @@ export function UserCoursesList(props: UserCoursesListProps) {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const { data: limits, isLoading } = useQuery(
|
||||
const { data: limits, isLoading: isLimitsLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const { used, limit } = limits ?? { used: 0, limit: 0 };
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const isPaidUser = userBillingDetails?.status !== 'none';
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
|
||||
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
|
||||
listUserAiCoursesOptions(),
|
||||
@@ -55,13 +51,6 @@ export function UserCoursesList(props: UserCoursesListProps) {
|
||||
});
|
||||
|
||||
const isAuthenticated = isLoggedIn();
|
||||
|
||||
const canSearch =
|
||||
!isInitialLoading &&
|
||||
!isUserAiCoursesLoading &&
|
||||
isAuthenticated &&
|
||||
userAiCourses?.length !== 0;
|
||||
|
||||
const limitUsedPercentage = Math.round((used / limit) * 100);
|
||||
|
||||
return (
|
||||
@@ -72,11 +61,12 @@ export function UserCoursesList(props: UserCoursesListProps) {
|
||||
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<span className='max-md:hidden'>Your </span>Courses
|
||||
<span className="max-md:hidden">Your </span>Courses
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{used > 0 && limit > 0 && !isPaidUserLoading && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 opacity-0 transition-opacity',
|
||||
@@ -103,6 +93,7 @@ export function UserCoursesList(props: UserCoursesListProps) {
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('relative w-64 max-sm:hidden', {})}>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
|
@@ -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';
|
||||
|
@@ -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;
|
||||
|
@@ -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>
|
||||
|
@@ -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';
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
|
||||
type UserPublicProgressStats = {
|
||||
resourceType: 'roadmap';
|
||||
|
@@ -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}%`,
|
||||
}}
|
||||
|
162
src/helper/generate-ai-course.ts
Normal file
162
src/helper/generate-ai-course.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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;
|
||||
slug?: string;
|
||||
isForce?: boolean;
|
||||
prompt?: string;
|
||||
onCourseIdChange?: (courseId: string) => void;
|
||||
onCourseSlugChange?: (courseSlug: string) => void;
|
||||
onCourseChange?: (course: AiCourse, rawData: string) => void;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onError?: (error: string) => void;
|
||||
};
|
||||
|
||||
export async function generateCourse(options: GenerateCourseOptions) {
|
||||
const {
|
||||
term,
|
||||
slug,
|
||||
difficulty,
|
||||
onCourseIdChange,
|
||||
onCourseSlugChange,
|
||||
onCourseChange,
|
||||
onLoadingChange,
|
||||
onError,
|
||||
isForce = false,
|
||||
prompt,
|
||||
} = options;
|
||||
|
||||
onLoadingChange?.(true);
|
||||
onCourseChange?.(
|
||||
{
|
||||
title: '',
|
||||
modules: [],
|
||||
difficulty: '',
|
||||
},
|
||||
'',
|
||||
);
|
||||
onError?.('');
|
||||
|
||||
try {
|
||||
let response = null;
|
||||
|
||||
if (slug && isForce) {
|
||||
response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-course/${slug}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
isForce,
|
||||
customPrompt: prompt,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
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,
|
||||
customPrompt: prompt,
|
||||
}),
|
||||
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 || '',
|
||||
},
|
||||
result,
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
}
|
@@ -1,12 +0,0 @@
|
||||
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);
|
||||
}
|
@@ -1,141 +0,0 @@
|
||||
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>,
|
||||
{
|
||||
onStream,
|
||||
onStreamEnd,
|
||||
}: {
|
||||
onStream?: (lesson: string) => void;
|
||||
onStreamEnd?: (lesson: string) => void;
|
||||
},
|
||||
) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let result = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
result += decoder.decode(value);
|
||||
onStream?.(result);
|
||||
}
|
||||
|
||||
onStream?.(result);
|
||||
onStreamEnd?.(result);
|
||||
reader.releaseLock();
|
||||
}
|
114
src/lib/ai.ts
114
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();
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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;
|
||||
};
|
||||
|
||||
|
@@ -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,25 @@ export function billingDetailsOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useIsPaidUser() {
|
||||
const { data, isLoading } = useQuery(
|
||||
{
|
||||
queryKey: ['billing-details'],
|
||||
queryFn: async () => {
|
||||
return httpGet<BillingDetailsResponse>('/v1-billing-details');
|
||||
},
|
||||
enabled: !!isLoggedIn(),
|
||||
select: (data) => data.status === 'active',
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
return {
|
||||
isPaidUser: data ?? false,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
type CoursePriceParams = {
|
||||
courseSlug: string;
|
||||
};
|
||||
|
Reference in New Issue
Block a user