From 38c9a67a2a7499eb4c32c665b5af2ab2ead422cf Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Tue, 1 Jul 2025 21:18:46 +0600 Subject: [PATCH] wip --- src/components/AIQuiz/AIMCQQuestion.tsx | 21 ++++ src/components/AIQuiz/AIQuiz.tsx | 125 ++++++++++++++++++++++- src/components/AIQuiz/AIQuizContent.tsx | 66 ++++++++++++ src/components/AIQuiz/GenerateAIQuiz.tsx | 57 ++++++----- src/pages/ai/quiz/[slug].astro | 22 ++++ src/queries/ai-quiz.ts | 46 +++++++++ 6 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 src/components/AIQuiz/AIMCQQuestion.tsx create mode 100644 src/components/AIQuiz/AIQuizContent.tsx create mode 100644 src/pages/ai/quiz/[slug].astro diff --git a/src/components/AIQuiz/AIMCQQuestion.tsx b/src/components/AIQuiz/AIMCQQuestion.tsx new file mode 100644 index 000000000..a7c698348 --- /dev/null +++ b/src/components/AIQuiz/AIMCQQuestion.tsx @@ -0,0 +1,21 @@ +import type { QuizQuestion } from '../../queries/ai-quiz'; + +type AIMCQQuestionProps = { + question: QuizQuestion; +}; + +export function AIMCQQuestion(props: AIMCQQuestionProps) { + const { question } = props; + const { title: questionText, options } = question; + + return ( +
+

{questionText}

+
+ {options.map((option) => ( +
{option.title}
+ ))} +
+
+ ); +} diff --git a/src/components/AIQuiz/AIQuiz.tsx b/src/components/AIQuiz/AIQuiz.tsx index b19f133ac..a663e03e9 100644 --- a/src/components/AIQuiz/AIQuiz.tsx +++ b/src/components/AIQuiz/AIQuiz.tsx @@ -1,10 +1,131 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { flushSync } from 'react-dom'; +import { useToast } from '../../hooks/use-toast'; +import { queryClient } from '../../stores/query-client'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { AlertCircleIcon } from 'lucide-react'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { billingDetailsOptions } from '../../queries/billing'; import { AIQuizLayout } from './AIQuizLayout'; import { GenerateAIQuiz } from './GenerateAIQuiz'; +import { aiQuizOptions, generateAIQuiz } from '../../queries/ai-quiz'; +import { AIQuizContent } from './AIQuizContent'; + +type AIQuizProps = { + quizSlug?: string; +}; + +export function AIQuiz(props: AIQuizProps) { + const { quizSlug: defaultQuizSlug } = props; + const [quizSlug, setQuizSlug] = useState(defaultQuizSlug); + + const toast = useToast(); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + + // only fetch the guide if the guideSlug is provided + // otherwise we are still generating the guide + const { + data: aiQuiz, + isLoading: isLoadingBySlug, + error: aiQuizError, + } = useQuery(aiQuizOptions(quizSlug), queryClient); + + const { + data: tokenUsage, + isLoading: isTokenUsageLoading, + refetch: refetchTokenUsage, + } = useQuery(getAiCourseLimitOptions(), queryClient); + + const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = + useQuery(billingDetailsOptions(), queryClient); + + const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); + const isPaidUser = userBillingDetails?.status === 'active'; + + const handleRegenerate = async (prompt?: string) => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + if (!isPaidUser && isLimitExceeded) { + setShowUpgradeModal(true); + return; + } + + flushSync(() => { + setIsRegenerating(true); + }); + + queryClient.cancelQueries(aiQuizOptions(quizSlug)); + queryClient.setQueryData(aiQuizOptions(quizSlug).queryKey, (old) => { + if (!old) { + return old; + } + + return { + ...old, + data: '', + svgHtml: '', + }; + }); + + await generateAIQuiz({ + quizSlug: aiQuiz?.slug || '', + term: aiQuiz?.keyword || '', + format: aiQuiz?.format || '', + prompt, + isForce: true, + onStreamingChange: setIsRegenerating, + onError: (error) => { + toast.error(error); + }, + onFinish: () => { + setIsRegenerating(false); + refetchTokenUsage(); + queryClient.invalidateQueries(aiQuizOptions(quizSlug)); + }, + }); + }; + + const isLoading = + isLoadingBySlug || + isRegenerating || + isTokenUsageLoading || + isBillingDetailsLoading; -export function AIQuiz() { return ( - + {showUpgradeModal && ( + setShowUpgradeModal(false)} /> + )} + + {!isLoading && aiQuizError && ( +
+
+ +

+ {aiQuizError?.message || 'Something went wrong'} +

+
+
+ )} + +
+ {quizSlug && !aiQuizError && ( + + )} + {!quizSlug && !aiQuizError && ( + + )} +
); } diff --git a/src/components/AIQuiz/AIQuizContent.tsx b/src/components/AIQuiz/AIQuizContent.tsx new file mode 100644 index 000000000..49f6dc406 --- /dev/null +++ b/src/components/AIQuiz/AIQuizContent.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import type { QuizQuestion } from '../../queries/ai-quiz'; +import { + ChevronLeftIcon, + ChevronRightIcon, + type LucideIcon, +} from 'lucide-react'; +import { AIMCQQuestion } from './AIMCQQuestion'; + +type AIQuizContentProps = { + questions: QuizQuestion[]; + isLoading?: boolean; +}; + +export function AIQuizContent(props: AIQuizContentProps) { + const { questions, isLoading } = props; + + const [activeQuestionIndex, setActiveQuestionIndex] = useState(0); + const activeQuestion = questions[activeQuestionIndex]; + + const hasMoreQuestions = activeQuestionIndex < questions.length - 1; + const hasPreviousQuestions = activeQuestionIndex > 0; + + return ( +
+
+ setActiveQuestionIndex(activeQuestionIndex - 1)} + icon={ChevronLeftIcon} + /> + + Question {activeQuestionIndex + 1} of {questions.length} + + setActiveQuestionIndex(activeQuestionIndex + 1)} + icon={ChevronRightIcon} + /> +
+ + {activeQuestion && activeQuestion.type === 'mcq' && ( + + )} +
+ ); +} + +type NavigationButtonProps = { + disabled: boolean; + onClick: () => void; + icon: LucideIcon; +}; + +function NavigationButton(props: NavigationButtonProps) { + const { disabled, onClick, icon: Icon } = props; + return ( + + ); +} diff --git a/src/components/AIQuiz/GenerateAIQuiz.tsx b/src/components/AIQuiz/GenerateAIQuiz.tsx index 1df604026..f955880dc 100644 --- a/src/components/AIQuiz/GenerateAIQuiz.tsx +++ b/src/components/AIQuiz/GenerateAIQuiz.tsx @@ -1,12 +1,16 @@ import { useEffect, useRef, useState } from 'react'; import { getUrlParams } from '../../lib/browser'; import { isLoggedIn } from '../../lib/jwt'; -import { queryClient } from '../../stores/query-client'; import { LoadingChip } from '../LoadingChip'; import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat'; import { getQuestionAnswerChatMessages } from '../../lib/ai-questions'; -import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap'; -import { generateAIQuiz, type QuizQuestion } from '../../queries/ai-quiz'; +import { + aiQuizOptions, + generateAIQuiz, + type QuizQuestion, +} from '../../queries/ai-quiz'; +import { queryClient } from '../../stores/query-client'; +import { AIQuizContent } from './AIQuizContent'; type GenerateAIQuizProps = { onQuizSlugChange?: (quizSlug: string) => void; @@ -20,6 +24,7 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) { const [error, setError] = useState(''); const [questions, setQuestions] = useState([]); + const questionsRef = useRef([]); useEffect(() => { const params = getUrlParams(); @@ -66,31 +71,33 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) { prompt, questionAndAnswers, onDetailsChange: (details) => { - // const { quizId, quizSlug, title, userId } = details; - // const aiRoadmapData = { - // _id: quizId, - // userId, - // title, - // term, - // data: content, - // questionAndAnswers, - // viewCount: 0, - // svgHtml: svgRef.current || '', - // lastVisitedAt: new Date(), - // createdAt: new Date(), - // updatedAt: new Date(), - // }; - // queryClient.setQueryData( - // aiRoadmapOptions(roadmapSlug).queryKey, - // aiRoadmapData, - // ); - // onQuizSlugChange?.(roadmapSlug); - // window.history.replaceState(null, '', `/ai-roadmaps/${roadmapSlug}`); + const { quizId, quizSlug, title, userId } = details; + const aiQuizData = { + _id: quizId, + userId, + title, + slug: quizSlug, + keyword: term, + format, + content: '', + questionAndAnswers: questionAndAnswers || [], + questions: questionsRef.current || [], + viewCount: 0, + lastVisitedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + queryClient.setQueryData(aiQuizOptions(quizSlug).queryKey, aiQuizData); + onQuizSlugChange?.(quizSlug); + window.history.replaceState(null, '', `/ai/quiz/${quizSlug}`); }, onLoadingChange: setIsLoading, onError: setError, onStreamingChange: setIsStreaming, - onQuestionsChange: setQuestions, + onQuestionsChange: (questions) => { + setQuestions(questions); + questionsRef.current = questions; + }, }); }; @@ -106,5 +113,5 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) { ); } - return
GenerateAIQuiz
; + return ; } diff --git a/src/pages/ai/quiz/[slug].astro b/src/pages/ai/quiz/[slug].astro new file mode 100644 index 000000000..0825f508b --- /dev/null +++ b/src/pages/ai/quiz/[slug].astro @@ -0,0 +1,22 @@ +--- +import { AIQuiz } from '../../../components/AIQuiz/AIQuiz'; +import SkeletonLayout from '../../../layouts/SkeletonLayout.astro'; + +export const prerender = false; + +interface Params extends Record { + slug: string; +} + +const { slug } = Astro.params as Params; +--- + + + + diff --git a/src/queries/ai-quiz.ts b/src/queries/ai-quiz.ts index 71c97bee0..a8f67c680 100644 --- a/src/queries/ai-quiz.ts +++ b/src/queries/ai-quiz.ts @@ -3,6 +3,8 @@ import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/Q import { readChatStream } from '../lib/chat'; import { queryClient } from '../stores/query-client'; import { getAiCourseLimitOptions } from './ai-course'; +import { queryOptions } from '@tanstack/react-query'; +import { httpGet } from '../lib/query-http'; type QuizDetails = { quizId: string; @@ -229,3 +231,47 @@ export function generateAiQuizQuestions(questionData: string): QuizQuestion[] { addCurrentQuestion(); return questions; } + +export interface AIQuizDocument { + _id: string; + userId: string; + title: string; + slug: string; + keyword: string; + format: string; + content: string; + + tokens?: { + prompt: number; + completion: number; + total: number; + }; + + questionAndAnswers: QuestionAnswerChatMessage[]; + + viewCount: number; + lastVisitedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +type GetAIQuizResponse = AIQuizDocument & { + questions: QuizQuestion[]; +}; + +export function aiQuizOptions(quizSlug?: string) { + return queryOptions({ + queryKey: ['ai-quiz', quizSlug], + queryFn: async () => { + const res = await httpGet( + `/v1-get-ai-quiz/${quizSlug}`, + ); + + return { + ...res, + questions: generateAiQuizQuestions(res.content), + }; + }, + enabled: !!quizSlug, + }); +}