mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-10-01 11:26:42 +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 { getUser } from '../../lib/jwt';
|
||||||
import { getPercentage } from '../../helper/number';
|
|
||||||
import { ProjectProgressActions } from './ProjectProgressActions';
|
import { ProjectProgressActions } from './ProjectProgressActions';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { getUser } from '../../lib/jwt';
|
import { getUser } from '../../lib/jwt';
|
||||||
import { getPercentage } from '../../helper/number';
|
|
||||||
import { ResourceProgressActions } from './ResourceProgressActions';
|
import { ResourceProgressActions } from './ResourceProgressActions';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
|
import { getPercentage } from '../../lib/number';
|
||||||
|
|
||||||
type ResourceProgressType = {
|
type ResourceProgressType = {
|
||||||
resourceType: 'roadmap' | 'best-practice';
|
resourceType: 'roadmap' | 'best-practice';
|
||||||
|
@@ -16,10 +16,11 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertTriangle,
|
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
|
CircleX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { BillingWarning } from './BillingWarning';
|
||||||
|
|
||||||
export type CreateCustomerPortalBody = {};
|
export type CreateCustomerPortalBody = {};
|
||||||
|
|
||||||
@@ -38,6 +39,10 @@ export function BillingPage() {
|
|||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isCanceled =
|
||||||
|
billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd;
|
||||||
|
const isPastDue = billingDetails?.status === 'past_due';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutate: createCustomerPortal,
|
mutate: createCustomerPortal,
|
||||||
isSuccess: isCreatingCustomerPortalSuccess,
|
isSuccess: isCreatingCustomerPortalSuccess,
|
||||||
@@ -80,9 +85,6 @@ export function BillingPage() {
|
|||||||
const selectedPlanDetails = USER_SUBSCRIPTION_PLAN_PRICES.find(
|
const selectedPlanDetails = USER_SUBSCRIPTION_PLAN_PRICES.find(
|
||||||
(plan) => plan.priceId === billingDetails?.priceId,
|
(plan) => plan.priceId === billingDetails?.priceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldHideDeleteButton =
|
|
||||||
billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd;
|
|
||||||
const priceDetails = selectedPlanDetails;
|
const priceDetails = selectedPlanDetails;
|
||||||
|
|
||||||
const formattedNextBillDate = new Date(
|
const formattedNextBillDate = new Date(
|
||||||
@@ -115,25 +117,30 @@ export function BillingPage() {
|
|||||||
!isLoadingBillingDetails &&
|
!isLoadingBillingDetails &&
|
||||||
priceDetails && (
|
priceDetails && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{billingDetails?.status === 'past_due' && (
|
{isCanceled && (
|
||||||
<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">
|
<BillingWarning
|
||||||
<AlertTriangle className="h-5 w-5" />
|
icon={CircleX}
|
||||||
<span>
|
message="Your subscription has been canceled."
|
||||||
We were not able to charge your card.{' '}
|
buttonText="Reactivate?"
|
||||||
<button
|
onButtonClick={() => {
|
||||||
disabled={
|
createCustomerPortal({});
|
||||||
isCreatingCustomerPortal ||
|
}}
|
||||||
isCreatingCustomerPortalSuccess
|
isLoading={
|
||||||
}
|
isCreatingCustomerPortal || isCreatingCustomerPortalSuccess
|
||||||
onClick={() => {
|
}
|
||||||
createCustomerPortal({});
|
/>
|
||||||
}}
|
)}
|
||||||
className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50"
|
{isPastDue && (
|
||||||
>
|
<BillingWarning
|
||||||
Update payment information.
|
message="We were not able to charge your card."
|
||||||
</button>
|
buttonText="Update payment information."
|
||||||
</span>
|
onButtonClick={() => {
|
||||||
</div>
|
createCustomerPortal({});
|
||||||
|
}}
|
||||||
|
isLoading={
|
||||||
|
isCreatingCustomerPortal || isCreatingCustomerPortalSuccess
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2 className="mb-2 text-xl font-semibold text-black">
|
<h2 className="mb-2 text-xl font-semibold text-black">
|
||||||
@@ -181,7 +188,7 @@ export function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex gap-3 max-sm:flex-col">
|
<div className="mt-8 flex gap-3 max-sm:flex-col">
|
||||||
{!shouldHideDeleteButton && (
|
{!isCanceled && (
|
||||||
<button
|
<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"
|
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={() => {
|
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 { getRelativeTimeString } from '../../lib/date';
|
||||||
|
import { getPercentage } from '../../lib/number';
|
||||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||||
|
|
||||||
type DashboardCustomProgressCardProps = {
|
type DashboardCustomProgressCardProps = {
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import { getPercentage } from '../../helper/number';
|
import { getPercentage } from '../../lib/number';
|
||||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||||
import { ArrowUpRight, ExternalLink } from 'lucide-react';
|
|
||||||
|
|
||||||
type DashboardProgressCardProps = {
|
type DashboardProgressCardProps = {
|
||||||
progress: UserProgress;
|
progress: UserProgress;
|
||||||
|
@@ -27,7 +27,7 @@ export function AICourseCard(props: AICourseCardProps) {
|
|||||||
|
|
||||||
// Calculate progress percentage
|
// Calculate progress percentage
|
||||||
const totalTopics = course.lessonCount || 0;
|
const totalTopics = course.lessonCount || 0;
|
||||||
const completedTopics = course.progress?.done?.length || 0;
|
const completedTopics = course.done?.length || 0;
|
||||||
const progressPercentage =
|
const progressPercentage =
|
||||||
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;
|
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;
|
||||||
|
|
||||||
|
@@ -20,16 +20,19 @@ import { AICourseModuleList } from './AICourseModuleList';
|
|||||||
import { AICourseModuleView } from './AICourseModuleView';
|
import { AICourseModuleView } from './AICourseModuleView';
|
||||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||||
import { AILimitsPopup } from './AILimitsPopup';
|
import { AILimitsPopup } from './AILimitsPopup';
|
||||||
|
import { RegenerateOutline } from './RegenerateOutline';
|
||||||
|
import { useIsPaidUser } from '../../queries/billing';
|
||||||
|
|
||||||
type AICourseContentProps = {
|
type AICourseContentProps = {
|
||||||
courseSlug?: string;
|
courseSlug?: string;
|
||||||
course: AiCourse;
|
course: AiCourse;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
onRegenerateOutline: (prompt?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AICourseContent(props: AICourseContentProps) {
|
export function AICourseContent(props: AICourseContentProps) {
|
||||||
const { course, courseSlug, isLoading, error } = props;
|
const { course, courseSlug, isLoading, error, onRegenerateOutline } = props;
|
||||||
|
|
||||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||||
@@ -39,6 +42,8 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<'module' | 'full'>('full');
|
const [viewMode, setViewMode] = useState<'module' | 'full'>('full');
|
||||||
|
|
||||||
|
const { isPaidUser } = useIsPaidUser();
|
||||||
|
|
||||||
const { data: aiCourseProgress } = useQuery(
|
const { data: aiCourseProgress } = useQuery(
|
||||||
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
|
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -49,21 +54,23 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
>({});
|
>({});
|
||||||
|
|
||||||
const goToNextModule = () => {
|
const goToNextModule = () => {
|
||||||
if (activeModuleIndex < course.modules.length - 1) {
|
if (activeModuleIndex >= course.modules.length) {
|
||||||
const nextModuleIndex = activeModuleIndex + 1;
|
return;
|
||||||
setActiveModuleIndex(nextModuleIndex);
|
|
||||||
setActiveLessonIndex(0);
|
|
||||||
|
|
||||||
setExpandedModules((prev) => {
|
|
||||||
const newState: Record<number, boolean> = {};
|
|
||||||
course.modules.forEach((_, idx) => {
|
|
||||||
newState[idx] = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
newState[nextModuleIndex] = true;
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
const goToNextLesson = () => {
|
||||||
@@ -78,26 +85,29 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
const goToPrevLesson = () => {
|
const goToPrevLesson = () => {
|
||||||
if (activeLessonIndex > 0) {
|
if (activeLessonIndex > 0) {
|
||||||
setActiveLessonIndex(activeLessonIndex - 1);
|
setActiveLessonIndex(activeLessonIndex - 1);
|
||||||
} else {
|
return;
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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];
|
const currentModule = course.modules[activeModuleIndex];
|
||||||
@@ -109,6 +119,7 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
(total, module) => total + module.lessons.length,
|
(total, module) => total + module.lessons.length,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalDoneLessons = aiCourseProgress?.done?.length || 0;
|
const totalDoneLessons = aiCourseProgress?.done?.length || 0;
|
||||||
const finishedPercentage = Math.round(
|
const finishedPercentage = Math.round(
|
||||||
(totalDoneLessons / totalCourseLessons) * 100,
|
(totalDoneLessons / totalCourseLessons) * 100,
|
||||||
@@ -154,12 +165,14 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
|
|
||||||
{isLimitReached && (
|
{isLimitReached && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button
|
{!isPaidUser && (
|
||||||
onClick={() => setShowUpgradeModal(true)}
|
<button
|
||||||
className="rounded-md bg-yellow-400 px-6 py-2 text-sm font-medium text-black hover:bg-yellow-500"
|
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>
|
Upgrade to remove Limits
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="mt-4 text-sm text-black">
|
<p className="mt-4 text-sm text-black">
|
||||||
<a href="/ai-tutor" className="underline underline-offset-2">
|
<a href="/ai-tutor" className="underline underline-offset-2">
|
||||||
@@ -173,6 +186,8 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isViewingLesson = viewMode === 'module';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50">
|
<section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50">
|
||||||
{modals}
|
{modals}
|
||||||
@@ -181,11 +196,17 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
<div className="flex items-center justify-between px-4 py-2">
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
<a
|
<a
|
||||||
href="/ai-tutor"
|
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"
|
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"
|
aria-label="Back to generator"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" strokeWidth={2.5} />
|
<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>
|
</a>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-row lg:hidden">
|
<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="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
isLoading && 'striped-loader',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -363,6 +384,12 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
{course.title ? course.difficulty : 'Please wait ..'}
|
{course.title ? course.difficulty : 'Please wait ..'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
|
<RegenerateOutline
|
||||||
|
onRegenerateOutline={onRegenerateOutline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{course.title ? (
|
{course.title ? (
|
||||||
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">
|
<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)}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
<BotIcon className="h-4 w-4" />
|
<BotIcon className="h-4 w-4" />
|
||||||
<span>
|
<span>Ask AI some follow up questions</span>
|
||||||
<span className="max-sm:hidden">Still confused? </span>
|
|
||||||
Ask AI some follow up questions
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<ArrowRightIcon className="ml-auto h-4 w-4 max-sm:hidden" />
|
<ArrowRightIcon className="ml-auto h-4 w-4 max-sm:hidden" />
|
||||||
</button>
|
</button>
|
||||||
|
@@ -2,18 +2,18 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { BookOpen, Bot, Code, HelpCircle, LockIcon, Send } from 'lucide-react';
|
import { BookOpen, Bot, Code, HelpCircle, LockIcon, Send } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
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 { useToast } from '../../hooks/use-toast';
|
||||||
|
import { readStream } from '../../lib/ai';
|
||||||
|
import { cn } from '../../lib/classname';
|
||||||
|
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||||
import {
|
import {
|
||||||
markdownToHtml,
|
markdownToHtml,
|
||||||
markdownToHtmlWithHighlighting,
|
markdownToHtmlWithHighlighting,
|
||||||
} from '../../lib/markdown';
|
} from '../../lib/markdown';
|
||||||
import { cn } from '../../lib/classname';
|
|
||||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
|
||||||
|
|
||||||
export type AllowedAIChatRole = 'user' | 'assistant';
|
export type AllowedAIChatRole = 'user' | 'assistant';
|
||||||
export type AIChatHistoryType = {
|
export type AIChatHistoryType = {
|
||||||
@@ -142,7 +142,7 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await readAICourseLessonStream(reader, {
|
await readStream(reader, {
|
||||||
onStream: async (content) => {
|
onStream: async (content) => {
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setStreamedMessage(content);
|
setStreamedMessage(content);
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
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 { 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 = {
|
type AICourseLimitProps = {
|
||||||
onUpgrade: () => void;
|
onUpgrade: () => void;
|
||||||
@@ -31,19 +31,22 @@ export function AICourseLimit(props: AICourseLimitProps) {
|
|||||||
|
|
||||||
const totalPercentage = getPercentage(used, limit);
|
const totalPercentage = getPercentage(used, limit);
|
||||||
|
|
||||||
// has consumed 80% of the limit
|
// has consumed 85% of the limit
|
||||||
const isNearLimit = used >= limit * 0.8;
|
const isNearLimit = used >= limit * 0.85;
|
||||||
const isPaidUser = userBillingDetails.status !== 'none';
|
const isPaidUser = userBillingDetails.status === 'active';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
{!isPaidUser ||
|
||||||
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden"
|
(isNearLimit && (
|
||||||
onClick={() => onShowLimits()}
|
<button
|
||||||
>
|
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden"
|
||||||
<Info className="size-4" />
|
onClick={() => onShowLimits()}
|
||||||
{totalPercentage}% limit used
|
>
|
||||||
</button>
|
<Info className="size-4" />
|
||||||
|
{totalPercentage}% limit used
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
{(!isPaidUser || isNearLimit) && (
|
{(!isPaidUser || isNearLimit) && (
|
||||||
<button
|
<button
|
||||||
|
@@ -8,7 +8,7 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { readAICourseLessonStream } from '../../helper/read-stream';
|
import { readStream } from '../../lib/ai';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { AICourseFollowUp } from './AICourseFollowUp';
|
import { AICourseFollowUp } from './AICourseFollowUp';
|
||||||
import './AICourseFollowUp.css';
|
import './AICourseFollowUp.css';
|
||||||
|
import { useIsPaidUser } from '../../queries/billing';
|
||||||
|
|
||||||
type AICourseModuleViewProps = {
|
type AICourseModuleViewProps = {
|
||||||
courseSlug: string;
|
courseSlug: string;
|
||||||
@@ -72,6 +73,8 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
|
|||||||
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`;
|
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`;
|
||||||
const isLessonDone = aiCourseProgress?.done.includes(lessonId);
|
const isLessonDone = aiCourseProgress?.done.includes(lessonId);
|
||||||
|
|
||||||
|
const { isPaidUser } = useIsPaidUser();
|
||||||
|
|
||||||
const abortController = useMemo(
|
const abortController = useMemo(
|
||||||
() => new AbortController(),
|
() => new AbortController(),
|
||||||
[activeModuleIndex, activeLessonIndex],
|
[activeModuleIndex, activeLessonIndex],
|
||||||
@@ -124,36 +127,41 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
|
|||||||
removeAuthToken();
|
removeAuthToken();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
|
|
||||||
if (!reader) {
|
|
||||||
setIsLoading(false);
|
|
||||||
setError('Something went wrong');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
if (!response.body) {
|
||||||
setIsGenerating(true);
|
setIsLoading(false);
|
||||||
await readAICourseLessonStream(reader, {
|
setError('No response body received');
|
||||||
onStream: async (result) => {
|
return;
|
||||||
if (abortController.signal.aborted) {
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLessonHtml(markdownToHtml(result, false));
|
try {
|
||||||
},
|
const reader = response.body.getReader();
|
||||||
onStreamEnd: async (result) => {
|
setIsLoading(false);
|
||||||
if (abortController.signal.aborted) {
|
setIsGenerating(true);
|
||||||
return;
|
await readStream(reader, {
|
||||||
}
|
onStream: async (result) => {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLessonHtml(await markdownToHtmlWithHighlighting(result));
|
setLessonHtml(markdownToHtml(result, false));
|
||||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
},
|
||||||
setIsGenerating(false);
|
onStreamEnd: async (result) => {
|
||||||
},
|
if (abortController.signal.aborted) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLessonHtml(await markdownToHtmlWithHighlighting(result));
|
||||||
|
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||||
|
setIsGenerating(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Something went wrong');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutate: toggleDone, isPending: isTogglingDone } = useMutation(
|
const { mutate: toggleDone, isPending: isTogglingDone } = useMutation(
|
||||||
@@ -273,18 +281,21 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
|
|||||||
Limit reached
|
Limit reached
|
||||||
</h2>
|
</h2>
|
||||||
<p className="my-3 text-red-600">
|
<p className="my-3 text-red-600">
|
||||||
You have reached the AI usage limit for today. Please upgrade
|
You have reached the AI usage limit for today.
|
||||||
your account to continue.
|
{!isPaidUser && <>Please upgrade your account to continue.</>}
|
||||||
|
{isPaidUser && <>Please wait until tomorrow to continue.</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
{!isPaidUser && (
|
||||||
onClick={() => {
|
<button
|
||||||
onUpgrade();
|
onClick={() => {
|
||||||
}}
|
onUpgrade();
|
||||||
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
|
}}
|
||||||
>
|
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
|
||||||
Upgrade Account
|
>
|
||||||
</button>
|
Upgrade Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-red-600">{error}</p>
|
<p className="text-red-600">{error}</p>
|
||||||
|
@@ -24,7 +24,7 @@ export function AILimitsPopup(props: AILimitsPopupProps) {
|
|||||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||||
useQuery(billingDetailsOptions(), queryClient);
|
useQuery(billingDetailsOptions(), queryClient);
|
||||||
|
|
||||||
const isPaidUser = userBillingDetails?.status !== 'none';
|
const isPaidUser = userBillingDetails?.status === 'active';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
@@ -1,11 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getUrlParams } from '../../lib/browser';
|
import { getUrlParams } from '../../lib/browser';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
import { generateAiCourseStructure, type AiCourse } from '../../lib/ai';
|
import { type AiCourse } from '../../lib/ai';
|
||||||
import { readAICourseStream } from '../../helper/read-stream';
|
|
||||||
import { AICourseContent } from './AICourseContent';
|
import { AICourseContent } from './AICourseContent';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { generateCourse } from '../../helper/generate-ai-course';
|
||||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
|
||||||
|
|
||||||
type GenerateAICourseProps = {};
|
type GenerateAICourseProps = {};
|
||||||
|
|
||||||
@@ -38,119 +36,34 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
|||||||
|
|
||||||
setTerm(paramsTerm);
|
setTerm(paramsTerm);
|
||||||
setDifficulty(paramsDifficulty);
|
setDifficulty(paramsDifficulty);
|
||||||
generateCourse({ term: paramsTerm, difficulty: paramsDifficulty });
|
handleGenerateCourse({ term: paramsTerm, difficulty: paramsDifficulty });
|
||||||
}, [term, difficulty]);
|
}, [term, difficulty]);
|
||||||
|
|
||||||
const generateCourse = async (options: {
|
const handleGenerateCourse = async (options: {
|
||||||
term: string;
|
term: string;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
|
isForce?: boolean;
|
||||||
|
prompt?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { term, difficulty } = options;
|
const { term, difficulty, isForce, prompt } = options;
|
||||||
|
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
window.location.href = '/ai-tutor';
|
window.location.href = '/ai-tutor';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
await generateCourse({
|
||||||
setCourse({
|
term,
|
||||||
title: '',
|
difficulty,
|
||||||
modules: [],
|
slug: courseSlug,
|
||||||
difficulty: '',
|
onCourseIdChange: setCourseId,
|
||||||
|
onCourseSlugChange: setCourseSlug,
|
||||||
|
onCourseChange: setCourse,
|
||||||
|
onLoadingChange: setIsLoading,
|
||||||
|
onError: setError,
|
||||||
|
isForce,
|
||||||
|
prompt,
|
||||||
});
|
});
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
keyword: term,
|
|
||||||
difficulty,
|
|
||||||
}),
|
|
||||||
credentials: 'include',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
console.error(
|
|
||||||
'Error generating course:',
|
|
||||||
data?.message || 'Something went wrong',
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
|
||||||
setError(data?.message || 'Something went wrong');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
|
|
||||||
if (!reader) {
|
|
||||||
console.error('Failed to get reader from response');
|
|
||||||
setError('Something went wrong');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@');
|
|
||||||
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/);
|
|
||||||
|
|
||||||
await readAICourseStream(reader, {
|
|
||||||
onStream: (result) => {
|
|
||||||
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) {
|
|
||||||
const courseIdMatch = result.match(COURSE_ID_REGEX);
|
|
||||||
const courseSlugMatch = result.match(COURSE_SLUG_REGEX);
|
|
||||||
const extractedCourseId = courseIdMatch?.[1] || '';
|
|
||||||
const extractedCourseSlug = courseSlugMatch?.[1] || '';
|
|
||||||
|
|
||||||
if (extractedCourseSlug) {
|
|
||||||
window.history.replaceState(
|
|
||||||
{
|
|
||||||
courseId,
|
|
||||||
courseSlug: extractedCourseSlug,
|
|
||||||
term,
|
|
||||||
difficulty,
|
|
||||||
},
|
|
||||||
'',
|
|
||||||
`${origin}/ai-tutor/${extractedCourseSlug}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result = result
|
|
||||||
.replace(COURSE_ID_REGEX, '')
|
|
||||||
.replace(COURSE_SLUG_REGEX, '');
|
|
||||||
|
|
||||||
setCourseId(extractedCourseId);
|
|
||||||
setCourseSlug(extractedCourseSlug);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const aiCourse = generateAiCourseStructure(result);
|
|
||||||
setCourse({
|
|
||||||
...aiCourse,
|
|
||||||
difficulty: difficulty || '',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing streamed course content:', e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onStreamEnd: (result) => {
|
|
||||||
result = result
|
|
||||||
.replace(COURSE_ID_REGEX, '')
|
|
||||||
.replace(COURSE_SLUG_REGEX, '');
|
|
||||||
setIsLoading(false);
|
|
||||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
setError(error?.message || 'Something went wrong');
|
|
||||||
console.error('Error in course generation:', error);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,7 +80,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
|||||||
setDifficulty(difficulty);
|
setDifficulty(difficulty);
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
generateCourse({ term, difficulty }).finally(() => {
|
handleGenerateCourse({ term, difficulty }).finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -184,6 +97,14 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
|||||||
course={course}
|
course={course}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
|
onRegenerateOutline={(prompt) => {
|
||||||
|
handleGenerateCourse({
|
||||||
|
term,
|
||||||
|
difficulty,
|
||||||
|
isForce: true,
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
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 { queryClient } from '../../stores/query-client';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AICourseContent } from './AICourseContent';
|
import { AICourseContent } from './AICourseContent';
|
||||||
import { generateAiCourseStructure } from '../../lib/ai';
|
import { generateAiCourseStructure } from '../../lib/ai';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
|
import { generateCourse } from '../../helper/generate-ai-course';
|
||||||
|
|
||||||
type GetAICourseProps = {
|
type GetAICourseProps = {
|
||||||
courseSlug: string;
|
courseSlug: string;
|
||||||
@@ -14,7 +18,10 @@ export function GetAICourse(props: GetAICourseProps) {
|
|||||||
const { courseSlug } = props;
|
const { courseSlug } = props;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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 }),
|
...getAiCourseOptions({ aiCourseSlug: courseSlug }),
|
||||||
select: (data) => {
|
select: (data) => {
|
||||||
@@ -43,12 +50,49 @@ export function GetAICourse(props: GetAICourseProps) {
|
|||||||
}, [aiCourse]);
|
}, [aiCourse]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!error) {
|
if (!queryError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
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 (
|
return (
|
||||||
<AICourseContent
|
<AICourseContent
|
||||||
@@ -57,9 +101,10 @@ export function GetAICourse(props: GetAICourseProps) {
|
|||||||
modules: aiCourse?.course.modules || [],
|
modules: aiCourse?.course.modules || [],
|
||||||
difficulty: aiCourse?.difficulty || 'Easy',
|
difficulty: aiCourse?.difficulty || 'Easy',
|
||||||
}}
|
}}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading || isRegenerating}
|
||||||
courseSlug={courseSlug}
|
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 { isLoggedIn } from '../../lib/jwt';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { billingDetailsOptions } from '../../queries/billing';
|
import { useIsPaidUser } from '../../queries/billing';
|
||||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||||
|
|
||||||
type UserCoursesListProps = {};
|
type UserCoursesListProps = {};
|
||||||
@@ -20,17 +20,13 @@ export function UserCoursesList(props: UserCoursesListProps) {
|
|||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||||
|
|
||||||
const { data: limits, isLoading } = useQuery(
|
const { data: limits, isLoading: isLimitsLoading } = useQuery(
|
||||||
getAiCourseLimitOptions(),
|
getAiCourseLimitOptions(),
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { used, limit } = limits ?? { used: 0, limit: 0 };
|
const { used, limit } = limits ?? { used: 0, limit: 0 };
|
||||||
|
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
|
||||||
useQuery(billingDetailsOptions(), queryClient);
|
|
||||||
|
|
||||||
const isPaidUser = userBillingDetails?.status !== 'none';
|
|
||||||
|
|
||||||
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
|
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
|
||||||
listUserAiCoursesOptions(),
|
listUserAiCoursesOptions(),
|
||||||
@@ -55,13 +51,6 @@ export function UserCoursesList(props: UserCoursesListProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isAuthenticated = isLoggedIn();
|
const isAuthenticated = isLoggedIn();
|
||||||
|
|
||||||
const canSearch =
|
|
||||||
!isInitialLoading &&
|
|
||||||
!isUserAiCoursesLoading &&
|
|
||||||
isAuthenticated &&
|
|
||||||
userAiCourses?.length !== 0;
|
|
||||||
|
|
||||||
const limitUsedPercentage = Math.round((used / limit) * 100);
|
const limitUsedPercentage = Math.round((used / limit) * 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,37 +61,39 @@ export function UserCoursesList(props: UserCoursesListProps) {
|
|||||||
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
|
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
<span className='max-md:hidden'>Your </span>Courses
|
<span className="max-md:hidden">Your </span>Courses
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
{used > 0 && limit > 0 && !isPaidUserLoading && (
|
||||||
className={cn(
|
<div
|
||||||
'flex items-center gap-2 opacity-0 transition-opacity',
|
className={cn(
|
||||||
{
|
'flex items-center gap-2 opacity-0 transition-opacity',
|
||||||
'opacity-100': !isPaidUser,
|
{
|
||||||
},
|
'opacity-100': !isPaidUser,
|
||||||
)}
|
},
|
||||||
>
|
)}
|
||||||
<p className="flex items-center text-sm text-yellow-600">
|
>
|
||||||
<span className="max-md:hidden">
|
<p className="flex items-center text-sm text-yellow-600">
|
||||||
{limitUsedPercentage}% of daily limit used{' '}
|
<span className="max-md:hidden">
|
||||||
</span>
|
{limitUsedPercentage}% of daily limit used{' '}
|
||||||
<span className="inline md:hidden">
|
</span>
|
||||||
{limitUsedPercentage}% used
|
<span className="inline md:hidden">
|
||||||
</span>
|
{limitUsedPercentage}% used
|
||||||
<button
|
</span>
|
||||||
onClick={() => {
|
<button
|
||||||
setShowUpgradePopup(true);
|
onClick={() => {
|
||||||
}}
|
setShowUpgradePopup(true);
|
||||||
className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pl-1.5 pr-2 text-xs text-white"
|
}}
|
||||||
>
|
className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pl-1.5 pr-2 text-xs text-white"
|
||||||
<Gift className="size-4" />
|
>
|
||||||
Upgrade
|
<Gift className="size-4" />
|
||||||
</button>
|
Upgrade
|
||||||
</p>
|
</button>
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={cn('relative w-64 max-sm:hidden', {})}>
|
<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">
|
<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 { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator';
|
||||||
import { renderFlowJSON } from '../../../editor/renderer/renderer';
|
import { renderFlowJSON } from '../../../editor/renderer/renderer';
|
||||||
import { replaceChildren } from '../../lib/dom';
|
import { replaceChildren } from '../../lib/dom';
|
||||||
import { readAIRoadmapStream } from '../../helper/read-stream';
|
|
||||||
import {
|
import {
|
||||||
getOpenAIKey,
|
getOpenAIKey,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
@@ -31,7 +30,7 @@ import { showLoginPopup } from '../../lib/popup.ts';
|
|||||||
import { cn } from '../../lib/classname.ts';
|
import { cn } from '../../lib/classname.ts';
|
||||||
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
||||||
import { AIRoadmapAlert } from './AIRoadmapAlert.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 { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
||||||
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
||||||
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.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 { useKeydown } from '../../hooks/use-keydown';
|
||||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||||
import { markdownToHtml } from '../../lib/markdown';
|
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 { Spinner } from '../ReactIcons/Spinner';
|
||||||
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
||||||
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||||
import { readAIRoadmapContentStream } from '../../helper/read-stream';
|
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
|
import { readAIRoadmapContentStream } from '../../lib/ai';
|
||||||
|
|
||||||
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
|
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
@@ -49,7 +49,10 @@ import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</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
|
Teams
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -2,7 +2,7 @@ import type {
|
|||||||
GetUserProfileRoadmapResponse,
|
GetUserProfileRoadmapResponse,
|
||||||
GetPublicProfileResponse,
|
GetPublicProfileResponse,
|
||||||
} from '../../api/user';
|
} from '../../api/user';
|
||||||
import { getPercentage } from '../../helper/number';
|
import { getPercentage } from '../../lib/number';
|
||||||
import { PrivateProfileBanner } from './PrivateProfileBanner';
|
import { PrivateProfileBanner } from './PrivateProfileBanner';
|
||||||
import { UserProfileRoadmapRenderer } from './UserProfileRoadmapRenderer';
|
import { UserProfileRoadmapRenderer } from './UserProfileRoadmapRenderer';
|
||||||
|
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { getPercentage } from '../../helper/number';
|
|
||||||
import { getRelativeTimeString } from '../../lib/date';
|
import { getRelativeTimeString } from '../../lib/date';
|
||||||
|
import { getPercentage } from '../../lib/number';
|
||||||
|
|
||||||
type UserPublicProgressStats = {
|
type UserPublicProgressStats = {
|
||||||
resourceType: 'roadmap';
|
resourceType: 'roadmap';
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import type { GetPublicProfileResponse } from '../../api/user';
|
import type { GetPublicProfileResponse } from '../../api/user';
|
||||||
import { UserPublicProgressStats } from './UserPublicProgressStats';
|
import { getPercentage } from '../../lib/number';
|
||||||
import { getPercentage } from '../../helper/number.ts';
|
|
||||||
|
|
||||||
type UserPublicProgressesProps = {
|
type UserPublicProgressesProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -73,15 +72,15 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
key={roadmap.id + counter}
|
key={roadmap.id + counter}
|
||||||
href={`/${roadmap.id}?s=${userId}`}
|
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="flex-grow truncate">{roadmap.title}</span>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{parseInt(percentageDone, 10)}%
|
{percentageDone}%
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<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={{
|
style={{
|
||||||
width: `${percentageDone}%`,
|
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,
|
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`;
|
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;
|
title: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
|
done: string[];
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
data: string;
|
data: string;
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
@@ -75,7 +76,6 @@ export function getAiCourseLimitOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AICourseListItem = AICourseDocument & {
|
export type AICourseListItem = AICourseDocument & {
|
||||||
progress: AICourseProgressDocument;
|
|
||||||
lessonCount: number;
|
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 { httpGet } from '../lib/query-http';
|
||||||
import { isLoggedIn } from '../lib/jwt';
|
import { isLoggedIn } from '../lib/jwt';
|
||||||
|
import { queryClient } from '../stores/query-client';
|
||||||
|
|
||||||
export const allowedSubscriptionStatus = [
|
export const allowedSubscriptionStatus = [
|
||||||
'active',
|
'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 = {
|
type CoursePriceParams = {
|
||||||
courseSlug: string;
|
courseSlug: string;
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user