mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-25 16:39:02 +02:00
feat: ai chat limit
This commit is contained in:
@@ -15,8 +15,10 @@ import {
|
|||||||
BotIcon,
|
BotIcon,
|
||||||
Frown,
|
Frown,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
|
LockIcon,
|
||||||
PauseCircleIcon,
|
PauseCircleIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
|
Trash2Icon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||||
@@ -39,6 +41,10 @@ import { UserProgressActionList } from './UserProgressActionList';
|
|||||||
import { RoadmapTopicList } from './RoadmapTopicList';
|
import { RoadmapTopicList } from './RoadmapTopicList';
|
||||||
import { ShareResourceLink } from './ShareResourceLink';
|
import { ShareResourceLink } from './ShareResourceLink';
|
||||||
import { RoadmapRecommendations } from './RoadmapRecommendations';
|
import { RoadmapRecommendations } from './RoadmapRecommendations';
|
||||||
|
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
|
||||||
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||||
|
import { billingDetailsOptions } from '../../queries/billing';
|
||||||
|
|
||||||
export type RoamdapAIChatHistoryType = {
|
export type RoamdapAIChatHistoryType = {
|
||||||
role: AllowedAIChatRole;
|
role: AllowedAIChatRole;
|
||||||
@@ -67,6 +73,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
const scrollareaRef = useRef<HTMLDivElement>(null);
|
const scrollareaRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||||
|
|
||||||
const [aiChatHistory, setAiChatHistory] = useState<
|
const [aiChatHistory, setAiChatHistory] = useState<
|
||||||
RoamdapAIChatHistoryType[]
|
RoamdapAIChatHistoryType[]
|
||||||
@@ -79,16 +86,27 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
roadmapJSONOptions(roadmapId),
|
roadmapJSONOptions(roadmapId),
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
const { data: roadmapTreeData } = useQuery(
|
const { data: roadmapTreeData, isLoading: roadmapTreeLoading } = useQuery(
|
||||||
roadmapTreeMappingOptions(roadmapId),
|
roadmapTreeMappingOptions(roadmapId),
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: userResourceProgressData } = useQuery(
|
const {
|
||||||
userResourceProgressOptions('roadmap', roadmapId),
|
data: userResourceProgressData,
|
||||||
|
isLoading: userResourceProgressLoading,
|
||||||
|
} = useQuery(userResourceProgressOptions('roadmap', roadmapId), queryClient);
|
||||||
|
|
||||||
|
const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery(
|
||||||
|
getAiCourseLimitOptions(),
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||||
|
useQuery(billingDetailsOptions(), queryClient);
|
||||||
|
|
||||||
|
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||||
|
const isPaidUser = userBillingDetails?.status === 'active';
|
||||||
|
|
||||||
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -296,6 +314,14 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDataLoading =
|
||||||
|
isLoading ||
|
||||||
|
roadmapTreeLoading ||
|
||||||
|
userResourceProgressLoading ||
|
||||||
|
isTokenUsageLoading ||
|
||||||
|
isBillingDetailsLoading;
|
||||||
|
const hasChatHistory = aiChatHistory.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow flex-row">
|
<div className="flex flex-grow flex-row">
|
||||||
<div className="relative h-full flex-grow overflow-y-scroll">
|
<div className="relative h-full flex-grow overflow-y-scroll">
|
||||||
@@ -304,6 +330,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
|
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{roadmapDetail?.json && !isLoading && (
|
{roadmapDetail?.json && !isLoading && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto max-w-[968px] px-4">
|
<div className="mx-auto max-w-[968px] px-4">
|
||||||
@@ -318,12 +345,21 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full max-w-[40%] flex-grow flex-col border-l border-gray-200 bg-white">
|
<div className="flex h-full max-w-[40%] flex-grow flex-col border-l border-gray-200 bg-white">
|
||||||
<div className="flex min-h-[46px] items-center justify-between gap-2 border-b border-gray-200 px-3 py-2 text-sm">
|
{showUpgradeModal && (
|
||||||
<span className="flex items-center gap-2 text-sm">
|
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||||
<BotIcon className="size-4 shrink-0 text-black" />
|
)}
|
||||||
<span>AI Chat</span>
|
|
||||||
</span>
|
<RoadmapAIChatHeader
|
||||||
</div>
|
isLoading={isDataLoading}
|
||||||
|
hasChatHistory={hasChatHistory}
|
||||||
|
setAiChatHistory={setAiChatHistory}
|
||||||
|
onLogin={() => {
|
||||||
|
showLoginPopup();
|
||||||
|
}}
|
||||||
|
onUpgrade={() => {
|
||||||
|
setShowUpgradeModal(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
|
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -365,12 +401,13 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
roadmapId={roadmapId}
|
roadmapId={roadmapId}
|
||||||
onSubmit={(content) => {
|
onSubmit={(content) => {
|
||||||
if (isStreamingMessage || abortControllerRef.current) {
|
if (
|
||||||
return;
|
isStreamingMessage ||
|
||||||
}
|
abortControllerRef.current ||
|
||||||
|
!isLoggedIn() ||
|
||||||
if (isEmptyContent(content)) {
|
isDataLoading ||
|
||||||
toast.error('Please enter a message');
|
isEmptyContent(content)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +415,54 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isLimitExceeded && isLoggedIn() && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||||
|
<LockIcon
|
||||||
|
className="size-4 cursor-not-allowed"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
<p className="cursor-not-allowed">
|
||||||
|
Limit reached for today
|
||||||
|
{isPaidUser ? '. Please wait until tomorrow.' : ''}
|
||||||
|
</p>
|
||||||
|
{!isPaidUser && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowUpgradeModal(true);
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Upgrade for more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoggedIn() && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||||
|
<LockIcon
|
||||||
|
className="size-4 cursor-not-allowed"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
<p className="cursor-not-allowed">Please login to continue</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
showLoginPopup();
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Login / Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDataLoading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="flex aspect-square size-[36px] items-center justify-center p-2 text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex aspect-square size-[36px] items-center justify-center p-2 text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
117
src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx
Normal file
117
src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||||
|
import { queryClient } from '../../stores/query-client';
|
||||||
|
import { billingDetailsOptions } from '../../queries/billing';
|
||||||
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
|
import { BotIcon, GiftIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import type { RoamdapAIChatHistoryType } from './RoadmapAIChat';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
import { getPercentage } from '../../lib/number';
|
||||||
|
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
|
||||||
|
|
||||||
|
type RoadmapAIChatHeaderProps = {
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
hasChatHistory: boolean;
|
||||||
|
setAiChatHistory: (history: RoamdapAIChatHistoryType[]) => void;
|
||||||
|
|
||||||
|
onLogin: () => void;
|
||||||
|
onUpgrade: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||||
|
const {
|
||||||
|
hasChatHistory,
|
||||||
|
setAiChatHistory,
|
||||||
|
onLogin,
|
||||||
|
onUpgrade,
|
||||||
|
isLoading: isDataLoading,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||||
|
const { data: tokenUsage } = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||||
|
|
||||||
|
const { data: userBillingDetails } = useQuery(
|
||||||
|
billingDetailsOptions(),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||||
|
const isPaidUser = userBillingDetails?.status === 'active';
|
||||||
|
|
||||||
|
const usagePercentage = getPercentage(
|
||||||
|
tokenUsage?.used || 0,
|
||||||
|
tokenUsage?.limit || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showAILimitsPopup && (
|
||||||
|
<AILimitsPopup
|
||||||
|
onClose={() => setShowAILimitsPopup(false)}
|
||||||
|
onUpgrade={() => {
|
||||||
|
setShowAILimitsPopup(false);
|
||||||
|
onUpgrade();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-h-[46px] items-center justify-between gap-2 border-b border-gray-200 px-3 py-2 text-sm">
|
||||||
|
<span className="flex items-center gap-2 text-sm">
|
||||||
|
<BotIcon className="size-4 shrink-0 text-black" />
|
||||||
|
<span>AI Chat</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{!isDataLoading && (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{hasChatHistory && (
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-white px-2 py-2 text-xs font-medium text-black hover:bg-gray-200"
|
||||||
|
onClick={() => {
|
||||||
|
setAiChatHistory([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPaidUser && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="hidden rounded-md bg-gray-200 px-2 py-1 text-sm hover:bg-gray-300 sm:block"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
onLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAILimitsPopup(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{usagePercentage}%</span>{' '}
|
||||||
|
credits used
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
onLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpgrade();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GiftIcon className="size-4" />
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -471,6 +471,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoggedIn() && (
|
{!isLoggedIn() && (
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||||
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
|
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
|
||||||
|
Reference in New Issue
Block a user