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,
+ });
+}