mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 10:11:55 +08:00
Compare commits
8 Commits
fix/empty-
...
feat/limit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7766ab2351 | ||
|
|
59ed1536b7 | ||
|
|
c4104eb56b | ||
|
|
93ff7e5636 | ||
|
|
b0923864d5 | ||
|
|
c661c2f30d | ||
|
|
402a00775b | ||
|
|
c2a6ea8e47 |
@@ -12,7 +12,7 @@ import { flushSync } from 'react-dom';
|
||||
import AutogrowTextarea from 'react-textarea-autosize';
|
||||
import { QuickHelpPrompts } from './QuickHelpPrompts';
|
||||
import { QuickActionButton } from './QuickActionButton';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
@@ -83,7 +83,7 @@ export function AIChat(props: AIChatProps) {
|
||||
const textareaMessageRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { data: tokenUsage, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
@@ -275,7 +275,7 @@ export function AIChat(props: AIChatProps) {
|
||||
setAiChatHistory(newMessages);
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return query.queryKey[0] === 'list-chat-history';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { AIQuizLayout } from './AIQuizLayout';
|
||||
import { GenerateAIQuiz } from './GenerateAIQuiz';
|
||||
@@ -34,7 +34,7 @@ export function AIQuiz(props: AIQuizProps) {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
} = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
@@ -24,7 +24,8 @@ import { getUrlParams } from '../../lib/browser';
|
||||
import { FormatItem } from '../ContentGenerator/FormatItem';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { showUpgradeModal } from '../../stores/subscription';
|
||||
|
||||
const allowedFormats = ['mcq', 'open-ended', 'mixed'] as const;
|
||||
export type AllowedFormat = (typeof allowedFormats)[number];
|
||||
@@ -45,7 +46,7 @@ export function AIQuizGenerator() {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
} = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
@@ -53,6 +54,13 @@ export function AIQuizGenerator() {
|
||||
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||
const isPaidUser = userBillingDetails?.status === 'active';
|
||||
|
||||
const selectedLimit = tokenUsage?.quiz;
|
||||
const showLimitWarning =
|
||||
!isPaidUser &&
|
||||
!isBillingDetailsLoading &&
|
||||
!isTokenUsageLoading &&
|
||||
isLoggedIn();
|
||||
|
||||
const titleFieldId = useId();
|
||||
const fineTuneOptionsId = useId();
|
||||
|
||||
@@ -101,6 +109,15 @@ export function AIQuizGenerator() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isPaidUser &&
|
||||
selectedLimit &&
|
||||
selectedLimit?.used >= selectedLimit?.limit
|
||||
) {
|
||||
showUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = '';
|
||||
if (showFineTuneOptions) {
|
||||
clearQuestionAnswerChatMessages();
|
||||
@@ -131,14 +148,14 @@ export function AIQuizGenerator() {
|
||||
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{!isPaidUser && !isBillingDetailsLoading && isLoggedIn() && (
|
||||
{showLimitWarning && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 -translate-y-8 text-gray-500 max-md:hidden">
|
||||
You are on the free plan
|
||||
{selectedLimit?.used} of {selectedLimit?.limit} quizzes
|
||||
<button
|
||||
onClick={() => setIsUpgradeModalOpen(true)}
|
||||
className="ml-2 rounded-xl bg-yellow-600 px-2 py-1 text-sm text-white hover:opacity-80"
|
||||
>
|
||||
Upgrade to Pro
|
||||
Need more? Upgrade
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -249,7 +266,9 @@ export function AIQuizGenerator() {
|
||||
<button
|
||||
type="submit"
|
||||
className="flex h-[56px] w-full items-center justify-center gap-2 rounded-xl bg-black p-4 text-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-80"
|
||||
disabled={!canGenerate}
|
||||
disabled={
|
||||
!canGenerate || isTokenUsageLoading || isBillingDetailsLoading
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="size-4" />
|
||||
Generate Quiz
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AIQuizContent } from './AIQuizContent';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
|
||||
type GenerateAIQuizProps = {
|
||||
onQuizSlugChange?: (quizSlug: string) => void;
|
||||
@@ -27,7 +31,27 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
|
||||
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
|
||||
const questionsRef = useRef<QuizQuestion[]>([]);
|
||||
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const isLimitDataLoading = isPaidUserLoading || isLimitLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLimitDataLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPaidUser && limits && limits?.quiz?.used >= limits?.quiz?.limit) {
|
||||
setError('You have reached the limit for this format');
|
||||
setIsLoading(false);
|
||||
setShowUpgradeModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsFormat = params?.format;
|
||||
@@ -48,7 +72,7 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
|
||||
src: paramsSrc,
|
||||
questionAndAnswers,
|
||||
});
|
||||
}, []);
|
||||
}, [isLimitDataLoading, isPaidUser]);
|
||||
|
||||
const handleGenerateQuiz = async (options: {
|
||||
term: string;
|
||||
@@ -102,24 +126,43 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const upgradeModal = showUpgradeModal ? (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
window.location.href = '/ai/quiz';
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex h-full flex-col items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<AlertCircleIcon className="size-10 text-gray-500" />
|
||||
<p className="text-center">{error}</p>
|
||||
<>
|
||||
{upgradeModal}
|
||||
<div className="absolute inset-0 z-20 flex h-full flex-col items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<AlertCircleIcon className="size-10 text-gray-500" />
|
||||
<p className="text-center">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
<>
|
||||
{upgradeModal}
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <AIQuizContent isStreaming={isStreaming} questions={questions} />;
|
||||
return (
|
||||
<>
|
||||
{upgradeModal}
|
||||
<AIQuizContent isStreaming={isStreaming} questions={questions} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
type ListUserAiQuizzesQuery,
|
||||
} from '../../queries/ai-quiz';
|
||||
import { AIQuizCard } from './AIQuizCard';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { AIUsageWarning } from '../AIUsageWarning/AIUsageWarning';
|
||||
|
||||
export function UserQuizzesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -25,6 +28,14 @@ export function UserQuizzesList() {
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const selectedLimit = limits?.quiz;
|
||||
|
||||
const { data: userAiQuizzes, isFetching: isUserAiQuizzesLoading } = useQuery(
|
||||
listUserAiQuizzesOptions(pageState),
|
||||
queryClient,
|
||||
@@ -59,7 +70,11 @@ export function UserQuizzesList() {
|
||||
}, [pageState]);
|
||||
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiQuizzesLoading || isInitialLoading;
|
||||
const isAnyLoading =
|
||||
isUserAiQuizzesLoading ||
|
||||
isInitialLoading ||
|
||||
isPaidUserLoading ||
|
||||
isLimitLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,11 +104,14 @@ export function UserQuizzesList() {
|
||||
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiQuizzes?.totalCount} quizzes so far.`
|
||||
: 'Sign up or login to generate your first quiz. Takes 2s to do so.'}
|
||||
</p>
|
||||
<AIUsageWarning
|
||||
type="quiz"
|
||||
totalCount={userAiQuizzes?.totalCount}
|
||||
isPaidUser={isPaidUser}
|
||||
usedCount={selectedLimit?.used}
|
||||
limitCount={selectedLimit?.limit}
|
||||
onUpgrade={() => setShowUpgradePopup(true)}
|
||||
/>
|
||||
|
||||
{isUserAuthenticated && !isAnyLoading && quizzes.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { AIRoadmapChat } from './AIRoadmapChat';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
|
||||
export type AIRoadmapChatActions = {
|
||||
@@ -50,7 +50,7 @@ export function AIRoadmap(props: AIRoadmapProps) {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
} = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
@@ -57,7 +57,7 @@ export function AIRoadmapChat(props: AIRoadmapChatProps) {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
} = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
@@ -7,6 +7,10 @@ import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnsw
|
||||
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
|
||||
import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap';
|
||||
import { AIRoadmapContent } from './AIRoadmapContent';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
|
||||
type GenerateAIRoadmapProps = {
|
||||
onRoadmapSlugChange?: (roadmapSlug: string) => void;
|
||||
@@ -18,12 +22,36 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
|
||||
const [svgHtml, setSvgHtml] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const svgRef = useRef<string | null>(null);
|
||||
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const isLimitDataLoading = isPaidUserLoading || isLimitLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLimitDataLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isPaidUser &&
|
||||
limits &&
|
||||
limits?.roadmap?.used >= limits?.roadmap?.limit
|
||||
) {
|
||||
setError('You have reached the limit for this format');
|
||||
setIsLoading(false);
|
||||
setShowUpgradeModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsSrc = params?.src || 'search';
|
||||
@@ -42,7 +70,7 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
|
||||
src: paramsSrc,
|
||||
questionAndAnswers,
|
||||
});
|
||||
}, []);
|
||||
}, [isLimitDataLoading, isPaidUser]);
|
||||
|
||||
const handleGenerateDocument = async (options: {
|
||||
term: string;
|
||||
@@ -99,17 +127,39 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const upgradeModal = showUpgradeModal ? (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
window.location.href = '/ai';
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">{error}</div>;
|
||||
return (
|
||||
<>
|
||||
{upgradeModal}
|
||||
<div className="text-red-500">{error}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
<>
|
||||
{upgradeModal}
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <AIRoadmapContent isLoading={isLoading} svgHtml={svgHtml} />;
|
||||
return (
|
||||
<>
|
||||
{upgradeModal}
|
||||
|
||||
<AIRoadmapContent isLoading={isLoading} svgHtml={svgHtml} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
type ListUserAiRoadmapsQuery,
|
||||
} from '../../queries/ai-roadmap';
|
||||
import { AIRoadmapCard } from './AIRoadmapCard';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { AIUsageWarning } from '../AIUsageWarning/AIUsageWarning';
|
||||
|
||||
export function UserRoadmapsList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -25,6 +28,14 @@ export function UserRoadmapsList() {
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const selectedLimit = limits?.roadmap;
|
||||
|
||||
const { data: userAiRoadmaps, isFetching: isUserAiRoadmapsLoading } =
|
||||
useQuery(listUserAiRoadmapsOptions(pageState), queryClient);
|
||||
|
||||
@@ -57,7 +68,11 @@ export function UserRoadmapsList() {
|
||||
}, [pageState]);
|
||||
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiRoadmapsLoading || isInitialLoading;
|
||||
const isAnyLoading =
|
||||
isUserAiRoadmapsLoading ||
|
||||
isInitialLoading ||
|
||||
isPaidUserLoading ||
|
||||
isLimitLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -81,23 +96,30 @@ export function UserRoadmapsList() {
|
||||
{isAnyLoading && (
|
||||
<p className="mb-4 flex flex-row items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading your courses...
|
||||
Loading your roadmaps...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiRoadmaps?.totalCount} roadmaps so far.`
|
||||
: 'Sign up or login to generate your first roadmap. Takes 2s to do so.'}
|
||||
</p>
|
||||
<AIUsageWarning
|
||||
type="roadmap"
|
||||
totalCount={userAiRoadmaps?.totalCount}
|
||||
isPaidUser={isPaidUser}
|
||||
usedCount={selectedLimit?.used}
|
||||
limitCount={selectedLimit?.limit}
|
||||
onUpgrade={() => setShowUpgradePopup(true)}
|
||||
/>
|
||||
|
||||
{isUserAuthenticated && !isAnyLoading && roadmaps.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{roadmaps.map((roadmap) => (
|
||||
<AIRoadmapCard variant="column" key={roadmap._id} roadmap={roadmap} />
|
||||
<AIRoadmapCard
|
||||
variant="column"
|
||||
key={roadmap._id}
|
||||
roadmap={roadmap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
@@ -14,7 +14,7 @@ type AITutorHeaderProps = {
|
||||
export function AITutorHeader(props: AITutorHeaderProps) {
|
||||
const { title, subtitle, onUpgradeClick, children } = props;
|
||||
|
||||
const { data: limits } = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
const { data: limits } = useQuery(aiLimitOptions(), queryClient);
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
|
||||
const { used, limit } = limits ?? { used: 0, limit: 0 };
|
||||
@@ -23,7 +23,7 @@ export function AITutorHeader(props: AITutorHeaderProps) {
|
||||
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2">
|
||||
<div className="gap-2">
|
||||
<h2 className="relative top-0 mb-1 sm:mb-3 flex-shrink-0 text-2xl sm:text-3xl font-semibold lg:top-1">
|
||||
<h2 className="relative top-0 mb-1 flex-shrink-0 text-2xl font-semibold sm:mb-3 sm:text-3xl lg:top-1">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && <p className="mb-4 text-sm text-gray-500">{subtitle}</p>}
|
||||
@@ -31,7 +31,7 @@ export function AITutorHeader(props: AITutorHeaderProps) {
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a
|
||||
href="/ai"
|
||||
className="flex max-sm:hidden flex-row items-center gap-2 rounded-lg bg-black px-4 py-1.5 text-sm font-medium text-white"
|
||||
className="flex flex-row items-center gap-2 rounded-lg bg-black px-4 py-1.5 text-sm font-medium text-white max-sm:hidden"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
New
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useIsPaidUser } from '../../queries/billing';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { AITutorLogo } from '../ReactIcons/AITutorLogo';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
|
||||
@@ -85,7 +85,7 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
|
||||
const { data: limits, isLoading: isLimitsLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
@@ -27,7 +27,7 @@ export function UpgradeSidebarCard(props: UpgradeSidebarCardProps) {
|
||||
} = props;
|
||||
|
||||
const { data: limits, isLoading: isLimitsLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
|
||||
51
src/components/AIUsageWarning/AIUsageWarning.tsx
Normal file
51
src/components/AIUsageWarning/AIUsageWarning.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
type AIUsageWarningProps = {
|
||||
type: 'course' | 'guide' | 'roadmap' | 'quiz';
|
||||
totalCount?: number;
|
||||
isPaidUser?: boolean;
|
||||
usedCount?: number;
|
||||
limitCount?: number;
|
||||
onUpgrade: () => void;
|
||||
};
|
||||
|
||||
export function AIUsageWarning(props: AIUsageWarningProps) {
|
||||
const { type, totalCount, isPaidUser, usedCount, limitCount, onUpgrade } =
|
||||
props;
|
||||
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
|
||||
const typeLabels = {
|
||||
course: 'courses',
|
||||
guide: 'guides',
|
||||
roadmap: 'roadmaps',
|
||||
quiz: 'quizzes',
|
||||
};
|
||||
|
||||
const typeLabel = typeLabels[type];
|
||||
|
||||
return (
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated ? (
|
||||
isPaidUser ? (
|
||||
`You have generated ${totalCount} ${typeLabel} so far.`
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-500">You have used</span>{' '}
|
||||
<span className="text-gray-500">
|
||||
{usedCount} of {limitCount} {typeLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
className="ml-2 text-blue-600 underline underline-offset-2 hover:text-blue-700"
|
||||
>
|
||||
Need more? Upgrade
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
`Sign up or login to generate your first ${type}. Takes 2s to do so.`
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
16
src/components/Billing/GlobalUpgradeModal.tsx
Normal file
16
src/components/Billing/GlobalUpgradeModal.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import {
|
||||
hideUpgradeModal,
|
||||
isUpgradeModalOpen,
|
||||
} from '../../stores/subscription';
|
||||
import { UpgradeAccountModal } from './UpgradeAccountModal';
|
||||
|
||||
export function GlobalUpgradeModal() {
|
||||
const isOpen = useStore(isUpgradeModalOpen);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UpgradeAccountModal onClose={hideUpgradeModal} />;
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { useToast } from '../../hooks/use-toast';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { useParams } from '../../hooks/use-params';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { showUpgradeModal } from '../../stores/subscription';
|
||||
|
||||
const allowedFormats = ['course', 'guide', 'roadmap'] as const;
|
||||
export type AllowedFormat = (typeof allowedFormats)[number];
|
||||
@@ -36,6 +40,11 @@ export function ContentGenerator() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [selectedFormat, setSelectedFormat] = useState<AllowedFormat>('course');
|
||||
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const isValidFormat = allowedFormats.find(
|
||||
(format) => format.value === params.format,
|
||||
@@ -48,7 +57,6 @@ export function ContentGenerator() {
|
||||
}
|
||||
}, [params.format]);
|
||||
|
||||
// question answer chat options
|
||||
const [showFineTuneOptions, setShowFineTuneOptions] = useState(false);
|
||||
const [questionAnswerChatMessages, setQuestionAnswerChatMessages] = useState<
|
||||
QuestionAnswerChatMessage[]
|
||||
@@ -87,12 +95,25 @@ export function ContentGenerator() {
|
||||
},
|
||||
];
|
||||
|
||||
const selectedLimit = limits?.[selectedFormat];
|
||||
const showLimitWarning =
|
||||
!isPaidUser && !isPaidUserLoading && !isLimitLoading && isLoggedIn();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isPaidUser &&
|
||||
selectedLimit &&
|
||||
selectedLimit?.used >= selectedLimit?.limit
|
||||
) {
|
||||
showUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = '';
|
||||
if (showFineTuneOptions) {
|
||||
clearQuestionAnswerChatMessages();
|
||||
@@ -126,17 +147,18 @@ export function ContentGenerator() {
|
||||
{isUpgradeModalOpen && (
|
||||
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
|
||||
)}
|
||||
{!isPaidUser && !isPaidUserLoading && isLoggedIn() && (
|
||||
{showLimitWarning && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 -translate-y-8 text-gray-500 max-md:hidden">
|
||||
You are on the free plan
|
||||
{selectedLimit?.used} of {selectedLimit?.limit} {selectedFormat}s
|
||||
<button
|
||||
onClick={() => setIsUpgradeModalOpen(true)}
|
||||
className="ml-2 rounded-xl bg-yellow-600 px-2 py-1 text-sm text-white hover:opacity-80"
|
||||
>
|
||||
Upgrade to Pro
|
||||
Need more? Upgrade
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="mb-0.5 text-center text-4xl font-semibold max-md:text-left max-md:text-xl lg:mb-3">
|
||||
What can I help you learn?
|
||||
</h1>
|
||||
@@ -233,7 +255,7 @@ export function ContentGenerator() {
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-black p-4 text-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-80"
|
||||
disabled={!canGenerate}
|
||||
disabled={!canGenerate || isLimitLoading}
|
||||
>
|
||||
<SparklesIcon className="size-4" />
|
||||
Generate
|
||||
|
||||
@@ -26,7 +26,7 @@ import { lockBodyScroll } from '../../lib/dom';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||
@@ -190,7 +190,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
const isAuthenticatedUser = isLoggedIn();
|
||||
|
||||
const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
const isLimitExceeded =
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
} from '../../lib/markdown';
|
||||
import { httpPatch } from '../../lib/query-http';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import {
|
||||
getAiCourseLimitOptions,
|
||||
getAiCourseOptions,
|
||||
} from '../../queries/ai-course';
|
||||
import { aiLimitOptions, getAiCourseOptions } from '../../queries/ai-course';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import './AICourseLessonChat.css';
|
||||
@@ -229,7 +226,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
||||
});
|
||||
|
||||
setLessonHtml(markdownHtml);
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
setIsGenerating(false);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
markdownToHtml,
|
||||
markdownToHtmlWithHighlighting,
|
||||
} from '../../lib/markdown';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { ResizablePanel } from './Resizeable';
|
||||
@@ -93,7 +93,7 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
|
||||
const [streamedMessage, setStreamedMessage] = useState('');
|
||||
|
||||
const { data: tokenUsage, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
@@ -205,7 +205,7 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
|
||||
setCourseAIChatHistory(newMessages);
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
scrollToBottom();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Gift, Info } from 'lucide-react';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
@@ -14,10 +14,7 @@ type AICourseLimitProps = {
|
||||
export function AICourseLimit(props: AICourseLimitProps) {
|
||||
const { onUpgrade, onShowLimits } = props;
|
||||
|
||||
const { data: limits, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
const { data: limits, isLoading } = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { formatCommaNumber } from '../../lib/number';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
|
||||
type AILimitsPopupProps = {
|
||||
onClose: () => void;
|
||||
@@ -14,10 +14,7 @@ type AILimitsPopupProps = {
|
||||
export function AILimitsPopup(props: AILimitsPopupProps) {
|
||||
const { onClose, onUpgrade } = props;
|
||||
|
||||
const { data: limits, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
const { data: limits, isLoading } = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { used, limit } = limits ?? { used: 0, limit: 0 };
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ import type { AiCourse } from '../../lib/ai';
|
||||
import { AICourseContent } from './AICourseContent';
|
||||
import { generateCourse } from '../../helper/generate-ai-course';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions, getAiCourseOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
|
||||
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
|
||||
type GenerateAICourseProps = {};
|
||||
|
||||
@@ -18,8 +20,8 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [creatorId, setCreatorId] = useState('');
|
||||
const [courseId, setCourseId] = useState('');
|
||||
const [courseSlug, setCourseSlug] = useState('');
|
||||
const [course, setCourse] = useState<AiCourse>({
|
||||
title: '',
|
||||
@@ -35,14 +37,34 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (aiCourse) {
|
||||
setCourse(aiCourse);
|
||||
}
|
||||
}, [aiCourse]);
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (term) {
|
||||
if (!aiCourse) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCourse(aiCourse);
|
||||
}, [aiCourse]);
|
||||
|
||||
const isLimitDataLoading = isPaidUserLoading || isLimitLoading;
|
||||
useEffect(() => {
|
||||
if (term || isLimitDataLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isPaidUser &&
|
||||
limits &&
|
||||
limits?.course?.used >= limits?.course?.limit
|
||||
) {
|
||||
setError('You have reached the limit for this format');
|
||||
setIsLoading(false);
|
||||
setShowUpgradeModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,7 +87,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
src: paramsSrc,
|
||||
questionAndAnswers,
|
||||
});
|
||||
}, [term]);
|
||||
}, [term, isLimitDataLoading]);
|
||||
|
||||
const handleGenerateCourse = async (options: {
|
||||
term: string;
|
||||
@@ -84,7 +106,6 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
await generateCourse({
|
||||
term,
|
||||
slug: courseSlug,
|
||||
onCourseIdChange: setCourseId,
|
||||
onCourseSlugChange: setCourseSlug,
|
||||
onCreatorIdChange: setCreatorId,
|
||||
onCourseChange: setCourse,
|
||||
@@ -98,19 +119,29 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AICourseContent
|
||||
courseSlug={courseSlug}
|
||||
creatorId={creatorId}
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRegenerateOutline={(prompt) => {
|
||||
handleGenerateCourse({
|
||||
term,
|
||||
isForce: true,
|
||||
prompt,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
window.location.href = '/ai';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AICourseContent
|
||||
courseSlug={courseSlug}
|
||||
creatorId={creatorId}
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRegenerateOutline={(prompt) => {
|
||||
handleGenerateCourse({
|
||||
term,
|
||||
isForce: true,
|
||||
prompt,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BookOpen, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import {
|
||||
aiLimitOptions,
|
||||
listUserAiCoursesOptions,
|
||||
type ListUserAiCoursesQuery,
|
||||
} from '../../queries/ai-course';
|
||||
@@ -14,6 +15,8 @@ import { AICourseCard } from './AICourseCard';
|
||||
import { AICourseSearch } from './AICourseSearch';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { AIUsageWarning } from '../AIUsageWarning/AIUsageWarning';
|
||||
|
||||
export function UserCoursesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -25,6 +28,14 @@ export function UserCoursesList() {
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const selectedLimit = limits?.course;
|
||||
|
||||
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
|
||||
listUserAiCoursesOptions(pageState),
|
||||
queryClient,
|
||||
@@ -59,7 +70,11 @@ export function UserCoursesList() {
|
||||
}, [pageState]);
|
||||
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiCoursesLoading || isInitialLoading;
|
||||
const isAnyLoading =
|
||||
isUserAiCoursesLoading ||
|
||||
isInitialLoading ||
|
||||
isPaidUserLoading ||
|
||||
isLimitLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -88,16 +103,23 @@ export function UserCoursesList() {
|
||||
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiCourses?.totalCount} courses so far.`
|
||||
: 'Sign up or login to generate your first course. Takes 2s to do so.'}
|
||||
</p>
|
||||
<AIUsageWarning
|
||||
type="course"
|
||||
totalCount={userAiCourses?.totalCount}
|
||||
isPaidUser={isPaidUser}
|
||||
usedCount={selectedLimit?.used}
|
||||
limitCount={selectedLimit?.limit}
|
||||
onUpgrade={() => setShowUpgradePopup(true)}
|
||||
/>
|
||||
|
||||
{isUserAuthenticated && !isAnyLoading && courses.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
{courses.map((course) => (
|
||||
<AICourseCard variant="column" key={course._id} course={course} />
|
||||
<AICourseCard
|
||||
variant="column"
|
||||
key={course._id}
|
||||
course={course}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Pagination
|
||||
|
||||
@@ -16,7 +16,7 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { AIGuideChat } from './AIGuideChat';
|
||||
import { AIGuideContent } from './AIGuideContent';
|
||||
import { GenerateAIGuide } from './GenerateAIGuide';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
@@ -45,7 +45,7 @@ export function AIGuide(props: AIGuideProps) {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
} = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
@@ -58,7 +58,7 @@ export function AIGuideChat(props: AIGuideChatProps) {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
} = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
@@ -9,6 +9,10 @@ import { getAiGuideOptions } from '../../queries/ai-guide';
|
||||
import { LoadingChip } from '../LoadingChip';
|
||||
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
|
||||
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
|
||||
type GenerateAIGuideProps = {
|
||||
onGuideSlugChange?: (guideSlug: string) => void;
|
||||
@@ -20,12 +24,32 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [html, setHtml] = useState('');
|
||||
const htmlRef = useRef<string>('');
|
||||
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const isLimitDataLoading = isPaidUserLoading || isLimitLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLimitDataLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPaidUser && limits && limits?.guide?.used >= limits?.guide?.limit) {
|
||||
setError('You have reached the limit for this format');
|
||||
setIsLoading(false);
|
||||
setShowUpgradeModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsSrc = params?.src || 'search';
|
||||
@@ -44,7 +68,7 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
|
||||
src: paramsSrc,
|
||||
questionAndAnswers,
|
||||
});
|
||||
}, []);
|
||||
}, [isLimitDataLoading, isPaidUser]);
|
||||
|
||||
const handleGenerateDocument = async (options: {
|
||||
term: string;
|
||||
@@ -109,17 +133,38 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const upgradeModal = showUpgradeModal ? (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
window.location.href = '/ai';
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">{error}</div>;
|
||||
return (
|
||||
<>
|
||||
{upgradeModal}
|
||||
<div className="text-red-500">{error}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
<>
|
||||
{upgradeModal}
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <AIGuideContent html={html} />;
|
||||
return (
|
||||
<>
|
||||
{upgradeModal}
|
||||
<AIGuideContent html={html} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
|
||||
import { AIGuideCard } from '../AIGuide/AIGuideCard';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { AIUsageWarning } from '../AIUsageWarning/AIUsageWarning';
|
||||
|
||||
export function UserGuidesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -25,6 +28,14 @@ export function UserGuidesList() {
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
const { data: limits, isLoading: isLimitLoading } = useQuery(
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const selectedLimit = limits?.guide;
|
||||
|
||||
const { data: userAiGuides, isFetching: isUserAiGuidesLoading } = useQuery(
|
||||
listUserAIGuidesOptions(pageState),
|
||||
queryClient,
|
||||
@@ -59,7 +70,11 @@ export function UserGuidesList() {
|
||||
}, [pageState]);
|
||||
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiGuidesLoading || isInitialLoading;
|
||||
const isAnyLoading =
|
||||
isUserAiGuidesLoading ||
|
||||
isInitialLoading ||
|
||||
isPaidUserLoading ||
|
||||
isLimitLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,11 +104,14 @@ export function UserGuidesList() {
|
||||
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiGuides?.totalCount} guides so far.`
|
||||
: 'Sign up or login to generate your first guide. Takes 2s to do so.'}
|
||||
</p>
|
||||
<AIUsageWarning
|
||||
type="guide"
|
||||
totalCount={userAiGuides?.totalCount}
|
||||
isPaidUser={isPaidUser}
|
||||
usedCount={selectedLimit?.used}
|
||||
limitCount={selectedLimit?.limit}
|
||||
onUpgrade={() => setShowUpgradePopup(true)}
|
||||
/>
|
||||
|
||||
{isUserAuthenticated && !isAnyLoading && guides.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
@@ -105,7 +105,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
);
|
||||
|
||||
const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
@@ -91,7 +91,7 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
} = props;
|
||||
|
||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||
const { data: tokenUsage } = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
const { data: tokenUsage } = useQuery(aiLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails } = useQuery(
|
||||
billingDetailsOptions(),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { aiLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
@@ -76,7 +76,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
const [streamedMessage, setStreamedMessage] = useState('');
|
||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||
const { data: tokenUsage, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
aiLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
@@ -170,7 +170,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
setAiChatHistory(newMessages);
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
scrollToBottom();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type AiCourse,
|
||||
} from '../lib/ai';
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||
import { aiLimitOptions } from '../queries/ai-course';
|
||||
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
||||
|
||||
type GenerateCourseOptions = {
|
||||
@@ -157,7 +157,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
|
||||
.replace(CREATOR_ID_REGEX, '');
|
||||
|
||||
onLoadingChange?.(false);
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||
import { aiLimitOptions } from '../queries/ai-course';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
|
||||
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
||||
@@ -114,7 +114,7 @@ export async function generateGuide(options: GenerateGuideOptions) {
|
||||
onMessageEnd: async (message) => {
|
||||
onGuideChange?.(message);
|
||||
onHtmlChange?.(await markdownToHtmlWithHighlighting(message));
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
onStreamingChange?.(false);
|
||||
},
|
||||
onDetails: async (details) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { flushSync } from 'react-dom';
|
||||
import { removeAuthToken } from '../lib/jwt';
|
||||
import { readStream } from '../lib/ai';
|
||||
import { useToast } from './use-toast';
|
||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||
import { aiLimitOptions } from '../queries/ai-course';
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import {
|
||||
renderMessage,
|
||||
@@ -184,7 +184,7 @@ export function useRoadmapAIChat(options: Options) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ export function useRoadmapAIChat(options: Options) {
|
||||
setIsStreamingMessage(false);
|
||||
setAiChatHistory(newMessages);
|
||||
});
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { ResourceType } from '../lib/resource-progress';
|
||||
import Bluconic from '../components/Analytics/Bluconic.astro';
|
||||
import OneTrust from '../components/Analytics/OneTrust.astro';
|
||||
import { cn } from '../lib/classname';
|
||||
import { GlobalUpgradeModal } from '../components/Billing/GlobalUpgradeModal';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -250,6 +251,7 @@ const gaPageIdentifier = Astro.url.pathname
|
||||
<Toaster client:only='react' />
|
||||
<CommandMenu client:idle />
|
||||
<PageProgress initialMessage={initialLoadingMessage} client:idle />
|
||||
<GlobalUpgradeModal client:only='react' />
|
||||
|
||||
<GoogleAdSlot />
|
||||
<slot name='after-footer' />
|
||||
|
||||
@@ -54,15 +54,33 @@ export function getAiCourseOptions(params: GetAICourseParams) {
|
||||
export type GetAICourseLimitResponse = {
|
||||
used: number;
|
||||
limit: number;
|
||||
course: {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
guide: {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
roadmap: {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
quiz: {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function getAiCourseLimitOptions() {
|
||||
export function aiLimitOptions() {
|
||||
return queryOptions({
|
||||
queryKey: ['ai-course-limit'],
|
||||
queryFn: () => {
|
||||
return httpGet<GetAICourseLimitResponse>(`/v1-get-ai-course-limit`);
|
||||
},
|
||||
enabled: !!isLoggedIn(),
|
||||
retryOnMount: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { nanoid } from 'nanoid';
|
||||
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from './ai-course';
|
||||
import { aiLimitOptions } from './ai-course';
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { httpGet } from '../lib/query-http';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
@@ -114,7 +114,7 @@ export async function generateAIQuiz(options: GenerateAIQuizOptions) {
|
||||
onQuestionsChange?.(questions);
|
||||
},
|
||||
onMessageEnd: async (result) => {
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
onStreamingChange?.(false);
|
||||
},
|
||||
onDetails: async (details) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { httpGet } from '../lib/query-http';
|
||||
import { generateAICourseRoadmapStructure } from '../lib/ai';
|
||||
import { generateAIRoadmapFromText, renderFlowJSON } from '@roadmapsh/editor';
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||
import { aiLimitOptions } from '../queries/ai-course';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
@@ -157,7 +157,7 @@ export async function generateAIRoadmap(options: GenerateAIRoadmapOptions) {
|
||||
onRoadmapSvgChange?.(svg);
|
||||
},
|
||||
onMessageEnd: async () => {
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries(aiLimitOptions());
|
||||
onStreamingChange?.(false);
|
||||
},
|
||||
onDetails: async (details) => {
|
||||
|
||||
11
src/stores/subscription.ts
Normal file
11
src/stores/subscription.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const isUpgradeModalOpen = atom(false);
|
||||
|
||||
export function showUpgradeModal() {
|
||||
isUpgradeModalOpen.set(true);
|
||||
}
|
||||
|
||||
export function hideUpgradeModal() {
|
||||
isUpgradeModalOpen.set(false);
|
||||
}
|
||||
Reference in New Issue
Block a user