Repository: mfathonin/prodigi-dashboard
Pull request #21: Feature: Multi Answer
Author: login: mfathonin
Created: 2025-05-22T20:14:17Z | Merged: 2025-05-22T21:37:04Z



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added support for multiple correct answers (multi-answer) in quizzes, allowing both single and multiple selections per question.
  - Introduced a toggle in the quiz interface to switch between single and multi-answer modes for each question.
  - Added a new switch component for toggling answer modes.

- **Enhancements**
  - Renamed "quiz" content type to "answer_sheet" and introduced a new "exercise" content type for improved clarity.
  - Updated labels, badges, and UI elements throughout the app to reflect the new content types ("answer_sheet" and "exercise").
  - Improved answer validation to ensure all selections are within valid ranges and formats.
  - Enhanced layout and styling for quiz forms, content cards, and question configuration sections.

- **Bug Fixes**
  - Ensured fallback values for undefined content types to prevent display or logic errors.

- **Database & Backend**
  - Updated database schema to support storing answers as JSON, enabling multi-answer functionality.
  - Enhanced API endpoints and validation logic to handle both single and multiple answers per question.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/app/(protected)/(books)/[bookId]/components/content-card.tsx b/app/(protected)/(books)/[bookId]/components/content-card.tsx
index 886071e..b75d40e 100644
--- a/app/(protected)/(books)/[bookId]/components/content-card.tsx
+++ b/app/(protected)/(books)/[bookId]/components/content-card.tsx
@@ -67,7 +67,7 @@ export const ContentCard = ({
       linkId: link.id,
       id: content.id,
       uuid: content.uuid,
-      type: content.type,
+      type: content.type ?? "content",
     };
 
     dialog?.openDialog("alert", _content, async (result) => {
@@ -92,7 +92,7 @@ export const ContentCard = ({
       linkId: link.id,
       id: content.id,
       uuid: content.uuid,
-      type: content.type,
+      type: content.type ?? "content",
     };
 
     dialog?.openDialog<ContentUpdateForm>("form", _content, async (result) => {
@@ -113,7 +113,8 @@ export const ContentCard = ({
 
   const handleContentClick = () => {
     switch (content.type) {
-      case "quiz":
+      case "answer_sheet":
+      case "exercise":
         if (link.targetUrl) {
           const targetUrl = new URL(link.targetUrl);
           window.open(targetUrl.pathname, "_blank");
@@ -137,7 +138,7 @@ export const ContentCard = ({
         <div className="flex flex-col w-full md:flex-row gap-x-10 gap-y-3">
           <div className="flex flex-col flex-1 gap-y-2">
             <div className="flex items-center gap-x-2">
-              <Badge variant={content.type}>{LABEL[content.type]}</Badge>
+              <Badge variant={content.type}>{LABEL[content.type ?? 'content']}</Badge>
               <p className="text-sm">{content.title}</p>
             </div>
             <p className="text-xs text-zinc-400 dark:text-zinc-500 line-clamp-1">
diff --git a/app/(protected)/(books)/[bookId]/components/handler.ts b/app/(protected)/(books)/[bookId]/components/handler.ts
index 0930af4..6f1c851 100644
--- a/app/(protected)/(books)/[bookId]/components/handler.ts
+++ b/app/(protected)/(books)/[bookId]/components/handler.ts
@@ -1,9 +1,9 @@
 import {
   ContentUpdateForm,
   isExternalContent,
-  isQuizContent,
+  isAnswerSheetContent,
   validateExternalContent,
-  validateQuizContent,
+  validateAnswerSheetContent,
 } from "@/models";
 import { ContentsRepository } from "@/repositories/contents";
 import { SupabaseClient } from "@supabase/supabase-js";
@@ -21,16 +21,16 @@ const addContent = async (
   await new ContentsRepository(supabase).upsertContentLink(content);
 };
 
-const addQuiz = async (
+const addAnswerSheet = async (
   content: ContentUpdateForm,
   supabase: SupabaseClient
 ) => {
-  if (!isQuizContent(content)) {
-    throw new Error("Invalid quiz content");
+  if (!isAnswerSheetContent(content)) {
+    throw new Error("Invalid answer sheet content");
   }
-  validateQuizContent(content);
+  validateAnswerSheetContent(content);
 
-  await new ContentsRepository(supabase).upsertQuiz(content);
+  await new ContentsRepository(supabase).upsertAnswerSheet(content);
 };
 
 const supabaseClient = createClient();
@@ -43,8 +43,8 @@ export const handleContentForm = async (result: ContentUpdateForm) => {
       case "content":
         await addContent(result as ContentUpdateForm, supabaseClient);
         break;
-      case "quiz":
-        await addQuiz(result as ContentUpdateForm, supabaseClient);
+      case "answer_sheet":
+        await addAnswerSheet(result as ContentUpdateForm, supabaseClient);
         break;
       default:
         throw new Error(`Unsupported content type: ${result.type}`);
diff --git a/app/(protected)/(books)/[bookId]/dialog/content-form/content-form.tsx b/app/(protected)/(books)/[bookId]/dialog/content-form/content-form.tsx
index 60d2213..807c84f 100644
--- a/app/(protected)/(books)/[bookId]/dialog/content-form/content-form.tsx
+++ b/app/(protected)/(books)/[bookId]/dialog/content-form/content-form.tsx
@@ -107,8 +107,8 @@ export const ContentForm = ({
                     <TabsTrigger className="w-full" value="content">
                       Link
                     </TabsTrigger>
-                    <TabsTrigger className="w-full" value="quiz">
-                      Kuis
+                    <TabsTrigger className="w-full" value="answer_sheet">
+                      Lembar Jawab
                     </TabsTrigger>
                   </TabsList>
                 )}
@@ -150,7 +150,7 @@ export const ContentForm = ({
                   />
                   <div className="h-[68px] w-full"></div>
                 </TabsContent>
-                <TabsContent value="quiz" className="flex flex-col m-0 gap-y-3">
+                <TabsContent value="answer_sheet" className="flex flex-col m-0 gap-y-3">
                   <div className="flex gap-x-3 w-full">
                     <FormField
                       control={form.control}
@@ -211,7 +211,7 @@ export const ContentForm = ({
                 {!isEditing && (
                   <TabsList className="hidden md:block">
                     <TabsTrigger value="content">Link</TabsTrigger>
-                    <TabsTrigger value="quiz">Kuis</TabsTrigger>
+                    <TabsTrigger value="answer_sheet">Lembar Jawab</TabsTrigger>
                   </TabsList>
                 )}
                 <div className="w-full flex flex-col gap-4 items-center justify-start">
diff --git a/app/(public)/api/v2/quiz/[[...uuid]]/route.ts b/app/(public)/api/v2/quiz/[[...uuid]]/route.ts
index fff0edc..6b787d6 100644
--- a/app/(public)/api/v2/quiz/[[...uuid]]/route.ts
+++ b/app/(public)/api/v2/quiz/[[...uuid]]/route.ts
@@ -20,14 +20,23 @@ const {
   },
 } = constants;
 
-const inputSchema = z.object({
-  answers: z.array(z.number()).min(1),
+const submissionSchema = z.object({
+  answers: z.array(z.union([z.number(), z.array(z.number())])).min(1),
   name: z.string().min(1),
   className: z.string().min(1),
   numberId: z.string().min(1),
   schoolName: z.string().min(1),
 });
 
+/**
+ * API handler for quiz submission evaluation.
+ *
+ * This handler processes quiz submissions, evaluates them, and returns the result.
+ *
+ * @param request - submission data
+ * @param params - params to load answer sheet Id
+ * @returns
+ */
 export async function POST(
   request: Request,
   { params }: { params: { uuid: string[] } }
@@ -48,14 +57,20 @@ export async function POST(
     }
 
     // Validate required fields
-    const { data: body, success } = inputSchema.safeParse(rawBody);
+    const { data: body, success } = submissionSchema.safeParse(rawBody);
     if (!success) {
       return ApiResponseHandler.error(QUIZ_MISSING_FIELDS);
     }
 
     const { answers, ...profile } = body;
 
-    if (!answers.every((answer) => typeof answer === "number")) {
+    if (
+      !answers.every(
+        (answer) =>
+          typeof answer === "number" ||
+          (Array.isArray(answer) && answer.every((a) => typeof a === "number"))
+      )
+    ) {
       return ApiResponseHandler.error(QUIZ_ANSWERS_NOT_ARRAY);
     }
 
@@ -76,21 +91,41 @@ export async function POST(
 
     const { totalPoints, correctAnswers } = answers.reduce(
       (acc, submittedAnswer, index) => {
-        if (index < answerKey.length && submittedAnswer === answerKey[index]) {
-          acc.totalPoints += answerSheet.points[index];
-          acc.correctAnswers++;
+        const keys = answerKey as (number | number[])[];
+        if (index < keys.length) {
+          if (!Array.isArray(keys[index]) && submittedAnswer === keys[index]) {
+            acc.totalPoints += answerSheet.points[index];
+            acc.correctAnswers++;
+          } else if (
+            Array.isArray(keys[index]) &&
+            Array.isArray(submittedAnswer)
+          ) {
+            const keySet = new Set(keys[index] as number[]);
+            const submittedSet = new Set(submittedAnswer);
+            const isCorrect =
+              keySet.size === submittedSet.size &&
+              [...keySet].every((a) => submittedSet.has(a));
+
+            if (isCorrect) {
+              acc.totalPoints += answerSheet.points[index];
+              acc.correctAnswers++;
+            }
+          }
         }
         return acc;
       },
       { totalPoints: 0, correctAnswers: 0 }
     );
 
+    const fullPoints = answerSheet.points.reduce((p, sum) => sum + p, 0);
+
     // Calculate percentage
     const percentage = (correctAnswers / totalQuestions) * 100;
 
     const result = {
       profile,
       totalPoints,
+      fullPoints,
       percentage,
       totalQuestions,
       correctAnswers,
@@ -137,6 +172,13 @@ export async function GET(
         contentTitle: contentPromise.value?.title,
       });
 
+    // Adding answers type mode to answerSheets object
+    const modes = (answers as (number | number[])[]).map((answer) =>
+      // 0: single choice, 1: multiple choice
+      Array.isArray(answer) ? 1 : 0
+    );
+    Object.assign(answerSheets, { modes });
+
     return ApiResponseHandler.success(answerSheets);
   } catch (error: unknown) {
     console.error(1349, `get.quiz.${id}`, error);
diff --git a/app/(public)/quiz/[id]/components/forms.tsx b/app/(public)/quiz/[id]/components/forms.tsx
index 2e6aa3f..fdeb266 100644
--- a/app/(public)/quiz/[id]/components/forms.tsx
+++ b/app/(public)/quiz/[id]/components/forms.tsx
@@ -1,12 +1,14 @@
 "use client";
 
-import { useOptimistic, useState } from "react";
+import { useId, useOptimistic, useState } from "react";
 import { useFormStatus } from "react-dom";
 import { toast } from "sonner";
 
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
 import { cn } from "@/lib/utils";
 import { AnswerSheet } from "@/models";
 
@@ -146,8 +148,8 @@ export const OptionsForm = ({
     };
 
   return (
-    <div className="flex items-center gap-4">
-      <Label className="text-xs">Jumlah Opsi</Label>
+    <div className="flex flex-col items-start gap-y-2 w-32">
+      <Label className="text-xs font-light opacity-60 px-2">Jumlah Opsi</Label>
       <div className="flex items-center gap-2">
         <form action={handleFormAction("decrease")}>
           <input type="hidden" name="answerSheetId" value={answerSheetId} />
@@ -218,8 +220,8 @@ export const PointsForm = ({
       }
     };
   return (
-    <div className="flex items-center gap-4">
-      <Label className="text-xs">Points</Label>
+    <div className="flex flex-col items-start gap-y-2 w-32">
+      <Label className="text-xs font-light opacity-60 px-2">Points</Label>
       <div className="flex items-center gap-2">
         <form action={handleFormAction("decrease")}>
           <input type="hidden" name="answerSheetId" value={answerSheetId} />
@@ -255,6 +257,75 @@ export const PointsForm = ({
   );
 };
 
+export const MultiAnswerForm = ({
+  answerSheetId,
+  index,
+  answers: currentAnswers,
+}: {
+  answerSheetId: string;
+  index: number;
+  answers: (number | number[])[];
+}) => {
+  const [isCheckedOptimistic, setIsCheckedOptimistic] = useOptimistic<
+    boolean,
+    boolean
+  >(Array.isArray(currentAnswers[index]), (_, value: boolean) => value);
+
+  const handleToggleMultiAnswer = async (formData: FormData) => {
+    try {
+      setIsCheckedOptimistic(!isCheckedOptimistic);
+      const newAnswer = Array.isArray(currentAnswers[index])
+        ? 0
+        : [currentAnswers[index]];
+      const newAnswersArray = [
+        ...currentAnswers.slice(0, index),
+        newAnswer,
+        ...currentAnswers.slice(index + 1),
+      ];
+      formData.set("answers", JSON.stringify(newAnswersArray));
+
+      await updateAnswerSheet(formData);
+    } catch (error) {
+      toast.error("Terjadi kesalahan", {
+        description: `[multi-answer.${index}]: ${(error as Error).message}`,
+      });
+      setIsCheckedOptimistic(!isCheckedOptimistic);
+    }
+  };
+
+  return (
+    <form action={handleToggleMultiAnswer}>
+      <input type="hidden" name="answerSheetId" value={answerSheetId} />
+      <input type="hidden" name="counts" value={currentAnswers.length} />
+      <div className="flex flex-1 flex-col gap-y-2">
+        <p className="font-light opacity-60 text-xs">Multi Jawaban</p>
+        <ToggleMultiAnswer state={isCheckedOptimistic} />
+      </div>
+    </form>
+  );
+};
+
+const ToggleMultiAnswer = ({ state }: { state: boolean }) => {
+  const { pending } = useFormStatus();
+  const uid = useId();
+
+  return (
+    <div className="h-8 flex justify-start items-center gap-x-1">
+      <Switch
+        id={`multi-answer-${uid}`}
+        type="submit"
+        disabled={pending}
+        checked={state}
+      />
+      {pending && (
+        <div className="size-6">
+          <Spinner className="mx-2 !text-lg" />
+        </div>
+      )}
+    </div>
+  );
+};
+
 const ButtonPlusMinus = ({
   type,
   value,
@@ -306,7 +377,7 @@ export const AnswerConfigForm = ({
     id: answerSheetId,
     nOptions: data.n_options[index] ?? 0,
     points: data.points[index] ?? 0,
-    answer: data.answers[index] ?? 0,
+    answer: (data.answers as (number | number[])[])[index] ?? 0,
   });
 
   return (
diff --git a/app/(public)/quiz/[id]/components/handler.ts b/app/(public)/quiz/[id]/components/handler.ts
index fdf056d..f48b88f 100644
--- a/app/(public)/quiz/[id]/components/handler.ts
+++ b/app/(public)/quiz/[id]/components/handler.ts
@@ -25,7 +25,7 @@ const updateAnswerSheetSchema = z
   .object({
     answerSheetId: z.string(),
     counts: z.number(),
-    answers: z.array(z.number()),
+    answers: z.array(z.union([z.number(), z.array(z.number())])),
   })
   .refine((data) => data.answers.length === data.counts, {
     message: "Jumlah jawaban tidak sama dengan jumlah soal",
@@ -126,9 +126,44 @@ export const decreasePoints = async (formData: FormData) => {
 export const updateAnswerSheet = async (formData: FormData) => {
   const answerSheetId = formData.get("answerSheetId") as string;
   const counts = parseInt(formData.get("counts") as string);
-  const answers = formData
-    .getAll("answer")
-    .map((answer) => parseInt(answer as string));
+
+  let answers: (number | number[])[] = [];
+
+  try {
+    // First try to parse from the "answers" JSON field
+    const answersJson = formData.get("answers");
+    if (answersJson) {
+      const parsed = JSON.parse(answersJson as string);
+      if (Array.isArray(parsed)) {
+        answers = parsed;
+      }
+    }
+
+    // If no answers from JSON, try individual answer fields
+    if (answers.length === 0) {
+      answers = formData.getAll("answer").map((answerStr) => {
+        if (typeof answerStr !== "string") return 0;
+
+        if (answerStr.includes(",")) {
+          // Handle multi-answer case
+          return answerStr
+            .split(",")
+            .map((s) => {
+              const n = Number.parseInt(s.trim(), 10);
+              return Number.isNaN(n) ? null : n;
+            })
+            .filter((n): n is number => n !== null);
+        } else {
+          // Handle single answer case
+          const num = parseInt(answerStr);
+          return isNaN(num) ? 0 : num;
+        }
+      });
+    }
+  } catch (error) {
+    console.error("Error parsing answers:", error);
+    throw { message: "Failed to parse answers", error };
+  }
 
   const { error } = updateAnswerSheetSchema.safeParse({
     answerSheetId,
@@ -169,7 +204,7 @@ export const recreateAnswerSheet = async (formData: FormData) => {
   revalidatePath(`/quiz/${answerSheetId}`);
 
   // ensure content type is correct
-  await contentRepo.ensureQuizContentType(answerSheetId);
+  await contentRepo.ensureAnswerSheetContentType(answerSheetId);
 
   revalidatePath(`/${bookId}`);
 };
diff --git a/app/(public)/quiz/[id]/components/options-editor.tsx b/app/(public)/quiz/[id]/components/options-editor.tsx
index 9cab508..b35b215 100644
--- a/app/(public)/quiz/[id]/components/options-editor.tsx
+++ b/app/(public)/quiz/[id]/components/options-editor.tsx
@@ -16,7 +16,7 @@ type OptionsEditorProps = {
   data: {
     nOptions: number;
     points: number;
-    answer: number;
+    answer: number | number[];
   };
   index: number;
   onEdit: (value: string) => void;
@@ -25,7 +25,7 @@ type OptionsEditorProps = {
 export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
   const { nOptions, answer } = data;
 
-  const [answerState, setAnswerState] = useState(answer);
+  const [answerState, setAnswerState] = useState<number | number[]>(answer);
 
   const defaultOptions = (className?: string) =>
     Array.from({ length: nOptions }, (_, index) => (
@@ -58,28 +58,61 @@ export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
 
   return (
     <div className="flex-1 border-b last:border-b-0 border-gray-200 dark:border-gray-800 pb-3 mb-3">
-      <Label className="pt-4">No. {index + 1}</Label>
+      <div className="flex justify-between items-end">
+        <Label className="h-5 flex items-center">No. {index + 1}</Label>
+        {Array.isArray(answer) && (
+          <p className="px-1.5 h-5 flex items-center rounded-lg text-white text-xs bg-green-400/70 dark:bg-green-800/50 ">
+            <i className="bx bx-info-circle me-1" /> Pilih lebih dari satu
+          </p>
+        )}
+      </div>
       <Input
         type="hidden"
         name="answer"
-        value={answerState}
-        onChange={(e) => setAnswerState(parseInt(e.target.value))}
+        value={
+          Array.isArray(answerState)
+            ? answerState.join(",")
+            : answerState.toString()
+        }
       />
 
-      <ToggleGroup
-        className="flex gap-2 w-full mt-3"
-        type="single"
-        value={answerState.toString()}
-        onValueChange={(value) => {
-          setAnswerState(parseInt(value));
-          onEdit(value);
-        }}
-      >
-        {options(
-          nOptions,
-          "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
-        )}
-      </ToggleGroup>
+      {Array.isArray(answerState) ? (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="multiple"
+          value={answerState.map((e) => e.toString())}
+          onValueChange={(value) => {
+            if (value.length === 0) return;
+            setAnswerState(value.map((e) => parseInt(e)));
+            onEdit(value.map((e) => parseInt(e)).toString());
+          }}
+        >
+          {options(
+            nOptions,
+            "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
+          )}
+        </ToggleGroup>
+      ) : (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="single"
+          value={answerState.toString()}
+          onValueChange={(value) => {
+            if (value === "") return;
+
+            const numeric = parseInt(value, 10);
+            if (Number.isNaN(numeric)) return;
+
+            setAnswerState(parseInt(value));
+            onEdit(value);
+          }}
+        >
+          {options(
+            nOptions,
+            "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
+          )}
+        </ToggleGroup>
+      )}
     </div>
   );
 };
diff --git a/app/(public)/quiz/[id]/components/question-config.tsx b/app/(public)/quiz/[id]/components/question-config.tsx
index b715085..4727148 100644
--- a/app/(public)/quiz/[id]/components/question-config.tsx
+++ b/app/(public)/quiz/[id]/components/question-config.tsx
@@ -1,11 +1,12 @@
 import { Badge } from "@/components/ui/badge";
-import { OptionsForm, PointsForm } from "./forms";
+import { MultiAnswerForm, OptionsForm, PointsForm } from "./forms";
 
 type QuestionConfigProps = {
   data: {
     id: string;
     nOptions: number;
     points: number;
+    answers: (number | number[])[];
   };
   index: number;
 };
@@ -15,26 +16,32 @@ export default function QuestionConfig({ data, index }: QuestionConfigProps) {
     nOptions: dataNOptions,
     points: dataPoints,
     id: answerSheetId,
+    answers
   } = data;
 
   return (
-    <div className="hidden md:flex max-w-64 flex-1 flex-col justify-end items-end pb-3 mb-3 gap-1 border-b border-gray-200 dark:border-gray-800 pe-4 first:mt-4 last:border-b-0">
-      <div className="flex justify-between w-full items-center">
+    <div className="hidden md:flex max-w-xl flex-1 flex-row justify-start items-end pb-3 mb-3 gap-y-4 gap-x-2 border-b border-gray-200 dark:border-gray-800 first:mt-4 last:border-b-0">
+      <div className="h-14 px-2 flex flex-col gap-y-2.5 items-start">
+        <p className="font-light opacity-60 text-xs">No.</p>
         <Badge variant="content" className="w-fit h-fit text-xs">
           #{index + 1}
         </Badge>
-        <PointsForm
-          index={index}
-          answerSheetId={answerSheetId}
-          dataPoints={dataPoints}
-        />
       </div>
-
+      <PointsForm
+        index={index}
+        answerSheetId={answerSheetId}
+        dataPoints={dataPoints}
+      />
       <OptionsForm
         index={index}
         answerSheetId={answerSheetId}
         dataNOptions={dataNOptions}
       />
+      <MultiAnswerForm
+        index={index}
+        answerSheetId={answerSheetId}
+        answers={answers}
+      />
     </div>
   );
 }
diff --git a/app/(public)/quiz/[id]/handler.ts b/app/(public)/quiz/[id]/handler.ts
index 77ff100..bcda670 100644
--- a/app/(public)/quiz/[id]/handler.ts
+++ b/app/(public)/quiz/[id]/handler.ts
@@ -4,7 +4,7 @@ type QuestionConfig = {
   id: string;
   nOptions: number;
   points: number;
-  answer: number;
+  answers: (number | number[])[];
 }
 
 export const getConfigByIndex = (index: number, answerSheet: AnswerSheet): QuestionConfig => {
@@ -12,7 +12,7 @@ export const getConfigByIndex = (index: number, answerSheet: AnswerSheet): Quest
     id: answerSheet.uuid,
     nOptions: 0,
     points: 0,
-    answer: 0,
+    answers: [0],
   };
 
   const { n_options, points, answers, uuid } = answerSheet;
@@ -25,6 +25,6 @@ export const getConfigByIndex = (index: number, answerSheet: AnswerSheet): Quest
     id: uuid,
     nOptions: n_options[index] ?? 0,
     points: points[index] ?? 0,
-    answer: answers[index] ?? 0,
+    answers: answers as (number | number[])[],
   };
 };
diff --git a/app/(public)/quiz/[id]/page.tsx b/app/(public)/quiz/[id]/page.tsx
index a688aa5..34ea02f 100644
--- a/app/(public)/quiz/[id]/page.tsx
+++ b/app/(public)/quiz/[id]/page.tsx
@@ -56,8 +56,8 @@ export default async function QuizPage({ params }: { params: { id: string } }) {
         </div>
       </div>
 
-      <div className="flex w-full">
-        <div className="hidden md:flex max-w-64 flex-col flex-1 justify-between pb-1 border-transparent">
+      <div className="flex w-full justify-between">
+        <div className="hidden md:flex max-w-xl flex-col flex-grow justify-between pb-1 border-transparent">
           {questions.map((_, index) => (
             <QuestionConfig
               key={index}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
index 7d09c63..1914190 100644
--- a/components/ui/badge.tsx
+++ b/components/ui/badge.tsx
@@ -14,7 +14,8 @@ const badgeVariants = cva(
           "border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
         destructive:
           "border-transparent bg-red-500 text-slate-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/80",
-        quiz: "border-transparent bg-emerald-400 text-slate-50 shadow hover:bg-emerald-400/80 dark:bg-emerald-800 dark:text-slate-50 dark:hover:bg-emerald-800/80",
+        exercise: "border-transparent bg-amber-400 text-slate-50 shadow hover:bg-amber-400/80 dark:bg-amber-800 dark:text-slate-50 dark:hover:bg-amber-800/80",
+        answer_sheet: "border-transparent bg-emerald-400 text-slate-50 shadow hover:bg-emerald-400/80 dark:bg-emerald-800 dark:text-slate-50 dark:hover:bg-emerald-800/80",
         content:
           "border-transparent bg-sky-500 text-slate-50 shadow hover:bg-sky-500/80 dark:bg-sky-900 dark:text-slate-50 dark:hover:bg-sky-900/80",
         outline: "text-slate-950 dark:text-slate-50",
diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx
new file mode 100644
index 0000000..cbd9e23
--- /dev/null
+++ b/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import * as React from "react";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+
+import { cn } from "@/lib/utils";
+
+const Switch = React.forwardRef<
+  React.ElementRef<typeof SwitchPrimitives.Root>,
+  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
+>(({ className, ...props }, ref) => (
+  <SwitchPrimitives.Root
+    className={cn(
+      "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-green-400 data-[state=unchecked]:bg-slate-200 dark:focus-visible:ring-slate-300 dark:focus-visible:ring-offset-slate-950 dark:data-[state=checked]:bg-green-800 dark:data-[state=unchecked]:bg-slate-800",
+      className
+    )}
+    {...props}
+    ref={ref}
+  >
+    <SwitchPrimitives.Thumb
+      className={cn(
+        "pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 dark:bg-slate-950"
+      )}
+    />
+  </SwitchPrimitives.Root>
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
diff --git a/lib/constants.ts b/lib/constants.ts
index fa22175..c6e86d8 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -9,7 +9,8 @@ export const constants = {
   CONTENT: {
     LABEL: {
       content: "URL",
-      quiz: "Kuis",
+      exercise: "Kuis",
+      answer_sheet: "Lembar jawab",
     },
     OPTIONS_LABEL: ["A", "B", "C", "D", "E"],
   },
diff --git a/models/contents.ts b/models/contents.ts
index 3b9801e..be6e64d 100644
--- a/models/contents.ts
+++ b/models/contents.ts
@@ -6,7 +6,7 @@ import { ContentsRepository } from "@/repositories/contents";
 const urlValidation: { pattern: RegExp; message: string } =
   constants.validation.url;
 
-export type ContentType = "quiz" | "content";
+export type ContentType = "exercise" | "content" | "answer_sheet";
 
 export const contentSchema = z
   .object({
@@ -18,7 +18,7 @@ export const contentSchema = z
     uuid: z.string().optional(),
     linkId: z.number().optional(),
     id: z.number().optional(),
-    type: z.enum(["quiz", "content"]),
+    type: z.enum(["exercise", "content", "answer_sheet"]),
     title: z.string().min(1, "Judul tidak boleh kosong"),
 
     nQuestion: z.number().min(1, "Jumlah soal tidak boleh kosong").optional(),
@@ -30,11 +30,11 @@ export const contentSchema = z
 
     targetUrl: z.string().optional(),
   })
-  .refine((data) => (data.type === "quiz" ? data.nQuestion : true), {
+  .refine((data) => (data.type !== "content" ? data.nQuestion : true), {
     path: ["nQuestion"],
     message: "Jumlah soal tidak valid",
   })
-  .refine((data) => (data.type === "quiz" ? data.nOptions : true), {
+  .refine((data) => (data.type !== "content" ? data.nOptions : true), {
     path: ["nOptions"],
     message: "Jumlah opsi tidak valid",
   })
@@ -98,21 +98,21 @@ export function validateExternalContent(
   return true;
 }
 
-export function isQuizContent(
+export function isAnswerSheetContent(
   content: ContentUpdateForm
 ): content is QuizUpdateForm {
   return (
-    content.type === "quiz" &&
+    content.type === "answer_sheet" &&
     typeof content.nQuestion === "number" &&
     typeof content.nOptions === "number"
   );
 }
 
-export function validateQuizContent(
+export function validateAnswerSheetContent(
   content: ContentUpdateForm
 ): content is QuizUpdateForm {
   return (
-    isQuizContent(content) && content.nQuestion > 0 && content.nOptions > 0
+    isAnswerSheetContent(content) && content.nQuestion > 0 && content.nOptions > 0
   );
 }
 
diff --git a/models/supaservice.types.ts b/models/supaservice.types.ts
index 533f6ab..053d957 100644
--- a/models/supaservice.types.ts
+++ b/models/supaservice.types.ts
@@ -36,7 +36,7 @@ export type Database = {
     Tables: {
       answer_sheets: {
         Row: {
-          answers: number[]
+          answers: Json
           book_id: string
           counts: number
           created_at: string
@@ -47,7 +47,7 @@ export type Database = {
           uuid: string
         }
         Insert: {
-          answers: number[]
+          answers: Json
           book_id: string
           counts: number
           created_at?: string
@@ -58,7 +58,7 @@ export type Database = {
           uuid?: string
         }
         Update: {
-          answers?: number[]
+          answers?: Json
           book_id?: string
           counts?: number
           created_at?: string
@@ -282,14 +282,16 @@ export type Database = {
     }
     Functions: {
       is_admin: {
-        Args: {
-          user_id: string
-        }
+        Args: { user_id: string }
         Returns: boolean
       }
+      jsonb_to_int_array_replace_nested: {
+        Args: { data: Json }
+        Returns: number[]
+      }
     }
     Enums: {
-      content_type: "content" | "quiz"
+      content_type: "content" | "answer_sheet" | "exercise"
     }
     CompositeTypes: {
       [_ in never]: never
@@ -297,27 +299,29 @@ export type Database = {
   }
 }
 
-type PublicSchema = Database[Extract<keyof Database, "public">]
+type DefaultSchema = Database[Extract<keyof Database, "public">]
 
 export type Tables<
-  PublicTableNameOrOptions extends
-    | keyof (PublicSchema["Tables"] & PublicSchema["Views"])
+  DefaultSchemaTableNameOrOptions extends
+    | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
     | { schema: keyof Database },
-  TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
-    ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
-        Database[PublicTableNameOrOptions["schema"]]["Views"])
+  TableName extends DefaultSchemaTableNameOrOptions extends {
+    schema: keyof Database
+  }
+    ? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
+        Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
     : never = never,
-> = PublicTableNameOrOptions extends { schema: keyof Database }
-  ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
-      Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
+> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
+  ? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
+      Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
       Row: infer R
     }
     ? R
     : never
-  : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
-        PublicSchema["Views"])
-    ? (PublicSchema["Tables"] &
-        PublicSchema["Views"])[PublicTableNameOrOptions] extends {
+  : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
+        DefaultSchema["Views"])
+    ? (DefaultSchema["Tables"] &
+        DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
         Row: infer R
       }
       ? R
@@ -325,20 +329,22 @@ export type Tables<
     : never
 
 export type TablesInsert<
-  PublicTableNameOrOptions extends
-    | keyof PublicSchema["Tables"]
+  DefaultSchemaTableNameOrOptions extends
+    | keyof DefaultSchema["Tables"]
     | { schema: keyof Database },
-  TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
-    ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
+  TableName extends DefaultSchemaTableNameOrOptions extends {
+    schema: keyof Database
+  }
+    ? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
     : never = never,
-> = PublicTableNameOrOptions extends { schema: keyof Database }
-  ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
+> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
+  ? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
       Insert: infer I
     }
     ? I
     : never
-  : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
-    ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
+  : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
+    ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
         Insert: infer I
       }
       ? I
@@ -346,20 +352,22 @@ export type TablesInsert<
     : never
 
 export type TablesUpdate<
-  PublicTableNameOrOptions extends
-    | keyof PublicSchema["Tables"]
+  DefaultSchemaTableNameOrOptions extends
+    | keyof DefaultSchema["Tables"]
     | { schema: keyof Database },
-  TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
-    ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
+  TableName extends DefaultSchemaTableNameOrOptions extends {
+    schema: keyof Database
+  }
+    ? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
     : never = never,
-> = PublicTableNameOrOptions extends { schema: keyof Database }
-  ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
+> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
+  ? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
       Update: infer U
     }
     ? U
     : never
-  : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
-    ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
+  : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
+    ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
         Update: infer U
       }
       ? U
@@ -367,21 +375,23 @@ export type TablesUpdate<
     : never
 
 export type Enums<
-  PublicEnumNameOrOptions extends
-    | keyof PublicSchema["Enums"]
+  DefaultSchemaEnumNameOrOptions extends
+    | keyof DefaultSchema["Enums"]
     | { schema: keyof Database },
-  EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
-    ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
+  EnumName extends DefaultSchemaEnumNameOrOptions extends {
+    schema: keyof Database
+  }
+    ? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
     : never = never,
-> = PublicEnumNameOrOptions extends { schema: keyof Database }
-  ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
-  : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
-    ? PublicSchema["Enums"][PublicEnumNameOrOptions]
+> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
+  ? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
+  : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
+    ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
     : never
 
 export type CompositeTypes<
   PublicCompositeTypeNameOrOptions extends
-    | keyof PublicSchema["CompositeTypes"]
+    | keyof DefaultSchema["CompositeTypes"]
     | { schema: keyof Database },
   CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
     schema: keyof Database
@@ -390,7 +400,18 @@ export type CompositeTypes<
     : never = never,
 > = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
   ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
-  : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
-    ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
+  : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
+    ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
     : never
 
+export const Constants = {
+  graphql_public: {
+    Enums: {},
+  },
+  public: {
+    Enums: {
+      content_type: ["content", "answer_sheet", "exercise"],
+    },
+  },
+} as const
+
diff --git a/package.json b/package.json
index 14fe981..59241ff 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "start": "next start",
     "lint": "next lint",
     "env:clear": "bash ./scripts/clear_env.sh",
-    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start'",
+    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start && echo \"\n\n\u001b[1mApp Link:\u001b[0m \u001b[32m$NEXT_PUBLIC_SUPABASE_URL\u001b[0m\"'",
     "supa:stop": "bash -c 'pnpm dlx supabase stop --no-backup && source ./scripts/clear_env.sh'",
     "supa:typegen": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase gen types typescript --db-url \"$SUPABASE_DB_URL\" > models/supaservice.types.ts'",
     "supa:check": "bash -c 'source ./scripts/set_env.sh && echo \"App Link: $NEXT_PUBLIC_SUPABASE_URL\nDB URL: $SUPABASE_DB_URL\" && pnpm dlx supabase status'",
@@ -33,6 +33,7 @@
     "@radix-ui/react-select": "^2.1.1",
     "@radix-ui/react-separator": "^1.1.0",
     "@radix-ui/react-slot": "^1.1.0",
+    "@radix-ui/react-switch": "^1.2.2",
     "@radix-ui/react-tabs": "^1.1.1",
     "@radix-ui/react-toggle": "^1.1.0",
     "@radix-ui/react-toggle-group": "^1.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fca7ab9..e7d6236 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
       '@radix-ui/react-slot':
         specifier: ^1.1.0
         version: 1.1.0(@types/react@18.3.3)(react@18.3.1)
+      '@radix-ui/react-switch':
+        specifier: ^1.2.2
+        version: 1.2.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@radix-ui/react-tabs':
         specifier: ^1.1.1
         version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -437,6 +440,9 @@ packages:
   '@radix-ui/primitive@1.1.0':
     resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
 
+  '@radix-ui/primitive@1.1.2':
+    resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
+
   '@radix-ui/react-accordion@1.2.1':
     resolution: {integrity: sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==}
     peerDependencies:
@@ -550,6 +556,15 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-compose-refs@1.1.2':
+    resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   '@radix-ui/react-context@1.1.0':
     resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==}
     peerDependencies:
@@ -568,6 +583,15 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-context@1.1.2':
+    resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   '@radix-ui/react-dialog@1.1.1':
     resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==}
     peerDependencies:
@@ -743,6 +767,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-primitive@2.1.0':
+    resolution: {integrity: sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-roving-focus@1.1.0':
     resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
     peerDependencies:
@@ -791,6 +828,28 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-slot@1.2.0':
+    resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-switch@1.2.2':
+    resolution: {integrity: sha512-7Z8n6L+ifMIIYZ83f28qWSceUpkXuslI2FJ34+kDMTiyj91ENdpdQ7VCidrzj5JfwfZTeano/BnGBbu/jqa5rQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-tabs@1.1.1':
     resolution: {integrity: sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==}
     peerDependencies:
@@ -861,6 +920,24 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-use-controllable-state@1.2.2':
+    resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-effect-event@0.0.2':
+    resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   '@radix-ui/react-use-escape-keydown@1.1.0':
     resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==}
     peerDependencies:
@@ -879,6 +956,15 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-use-layout-effect@1.1.1':
+    resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   '@radix-ui/react-use-previous@1.1.0':
     resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
     peerDependencies:
@@ -888,6 +974,15 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-use-previous@1.1.1':
+    resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   '@radix-ui/react-use-rect@1.1.0':
     resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
     peerDependencies:
@@ -906,6 +1001,15 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-use-size@1.1.1':
+    resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   '@radix-ui/react-visually-hidden@1.1.0':
     resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==}
     peerDependencies:
@@ -2822,6 +2926,8 @@ snapshots:
 
   '@radix-ui/primitive@1.1.0': {}
 
+  '@radix-ui/primitive@1.1.2': {}
+
   '@radix-ui/react-accordion@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
       '@radix-ui/primitive': 1.1.0
@@ -2933,6 +3039,12 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.3.3
 
+  '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.3)(react@18.3.1)':
+    dependencies:
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.3
+
   '@radix-ui/react-context@1.1.0(@types/react@18.3.3)(react@18.3.1)':
     dependencies:
       react: 18.3.1
@@ -2945,6 +3057,12 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.3.3
 
+  '@radix-ui/react-context@1.1.2(@types/react@18.3.3)(react@18.3.1)':
+    dependencies:
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.3
+
   '@radix-ui/react-dialog@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
       '@radix-ui/primitive': 1.1.0
@@ -3121,6 +3239,15 @@ snapshots:
       '@types/react': 18.3.3
       '@types/react-dom': 18.3.0
 
+  '@radix-ui/react-primitive@2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+    dependencies:
+      '@radix-ui/react-slot': 1.2.0(@types/react@18.3.3)(react@18.3.1)
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+    optionalDependencies:
+      '@types/react': 18.3.3
+      '@types/react-dom': 18.3.0
+
   '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
       '@radix-ui/primitive': 1.1.0
@@ -3183,6 +3310,28 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.3.3
 
+  '@radix-ui/react-slot@1.2.0(@types/react@18.3.3)(react@18.3.1)':
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.3)(react@18.3.1)
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.3
+
+  '@radix-ui/react-switch@1.2.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.2
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.3)(react@18.3.1)
+      '@radix-ui/react-context': 1.1.2(@types/react@18.3.3)(react@18.3.1)
+      '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.3)(react@18.3.1)
+      '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.3)(react@18.3.1)
+      '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.3)(react@18.3.1)
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+    optionalDependencies:
+      '@types/react': 18.3.3
+      '@types/react-dom': 18.3.0
+
   '@radix-ui/react-tabs@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
       '@radix-ui/primitive': 1.1.0
@@ -3258,6 +3407,21 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.3.3
 
+  '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.3)(react@18.3.1)':
+    dependencies:
+      '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.3)(react@18.3.1)
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.3)(react@18.3.1)
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.3
+
+  '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.3)(react@18.3.1)':
+    dependencies:
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.3)(react@18.3.1)
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.3
+
   '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.3)(react@18.3.1)':
     dependencies:
       '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1)
@@ -3271,12 +3435,24 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.3.3
 
+  '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.3)(react@18.3.1)':
+    dependencies:
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.3
+
   '@radix-ui/react-use-previous@1.1.0(@types/react@18.3.3)(react@18.3.1)':
     dependencies:
       react: 18.3.1
     optionalDependencies:
       '@types/react': 18.3.3
 
+  '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.3)(react@18.3.1)':
+    dependencies:
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.3
+
   '@radix-ui/react-use-rect@1.1.0(@types/react@18.3.3)(react@18.3.1)':
     dependencies:
       '@radix-ui/rect': 1.1.0
@@ -3291,6 +3467,13 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.3.3
 
+  '@radix-ui/react-use-size@1.1.1(@types/react@18.3.3)(react@18.3.1)':
+    dependencies:
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.3)(react@18.3.1)
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.3
+
   '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
       '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3898,7 +4081,7 @@ snapshots:
       eslint: 8.57.0
       eslint-import-resolver-node: 0.3.9
       eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0)
-      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
+      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
       eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0)
       eslint-plugin-react: 7.34.3(eslint@8.57.0)
       eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0)
@@ -3922,7 +4105,7 @@ snapshots:
       enhanced-resolve: 5.17.0
       eslint: 8.57.0
       eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
-      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
+      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
       fast-glob: 3.3.2
       get-tsconfig: 4.7.5
       is-core-module: 2.14.0
@@ -3944,7 +4127,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
+  eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
     dependencies:
       array-includes: 3.1.8
       array.prototype.findlastindex: 1.2.5
diff --git a/repositories/answer-sheets.ts b/repositories/answer-sheets.ts
index 7dc2461..777cb0c 100644
--- a/repositories/answer-sheets.ts
+++ b/repositories/answer-sheets.ts
@@ -25,7 +25,10 @@ interface AnswerSheets {
     index: number,
     type: "increase" | "decrease"
   ) => Promise<void>;
-  updateAnswers: (answerSheetId: string, answers: number[]) => Promise<void>;
+  updateAnswers: (
+    answerSheetId: string,
+    answers: (number | number[])[]
+  ) => Promise<void>;
   recreateAnswerSheet: (
     answerSheetId: string,
     bookId: string,
@@ -42,14 +45,41 @@ export class AnswerSheetRepository implements AnswerSheets {
     this.db = supabase;
   }
 
-  private createConfigArray = (
-    newValue: number | undefined,
-    oldValue: number[],
+  /**
+   * Validates an answer against the number of options
+   * @param answer The answer to validate (single number or array of numbers)
+   * @param nOptions The number of options available for this question
+   * @returns The validated answer (corrected if needed)
+   */
+  private validateAnswer = (
+    answer: number | number[],
+    nOptions: number
+  ): number | number[] => {
+    // For single answer
+    if (!Array.isArray(answer)) {
+      return answer >= 0 && answer < nOptions ? answer : 0;
+    }
+
+    // For array answers
+    // Filter valid options and ensure uniqueness
+    const validOptions = [...new Set(answer)].filter(
+      (opt) => opt >= 0 && opt < nOptions
+    );
+
+    // Return [0] if no valid options or empty array
+    return validOptions.length > 0 ? validOptions : [0];
+  };
+
+  private createConfigArray = <T extends number | number[]>(
+    newValue: T | undefined,
+    oldValue: T[],
     newCount: number,
     oldCount: number
-  ): number[] => {
+  ): T[] => {
     const baseValue = newValue ?? oldValue[0];
-    const newArray = Array(newCount).fill(baseValue);
+    const newArray = Array.from({ length: newCount }, () =>
+      Array.isArray(baseValue) ? [...baseValue] : (baseValue as number)
+    ) as T[];
 
     // Copy existing values up to the new count
     if (!newValue) {
@@ -101,15 +131,29 @@ export class AnswerSheetRepository implements AnswerSheets {
       answers: oldAnswers,
     } = existingData;
 
-    const newNOptions = this.createConfigArray(nOptions, oldNOptions, counts, oldCounts);
-    const newPoints = this.createConfigArray(points, oldPoints, counts, oldCounts);
-    let newAnswers = this.createConfigArray(undefined, oldAnswers, counts, oldCounts);
+    const newNOptions = this.createConfigArray(
+      nOptions,
+      oldNOptions,
+      counts,
+      oldCounts
+    );
+    const newPoints = this.createConfigArray(
+      points,
+      oldPoints,
+      counts,
+      oldCounts
+    );
+    let newAnswers = this.createConfigArray(
+      0,
+      oldAnswers as (number | number[])[],
+      counts,
+      oldCounts
+    );
 
-    // validate each answer is in range
-    newAnswers = newAnswers.map((answer, index) => {
-      if (answer < 0 || answer >= newNOptions[index]) return 0;
-      return answer;
-    });
+    // Validate each answer using the validator function
+    newAnswers = newAnswers.map((answer, index) =>
+      this.validateAnswer(answer, newNOptions[index])
+    );
 
     // update answer_sheets
     const { error: updateError } = await this.db
@@ -147,10 +191,13 @@ export class AnswerSheetRepository implements AnswerSheets {
       Math.min(MAX_OPTIONS, newNOptions[index])
     );
 
-    // validate answer is in range, reset to 0 if out of range
-    const updatedAnswers = [...existingData.answers];
-    if (existingData.answers[index] >= newNOptions[index])
-      updatedAnswers[index] = 0;
+    // Validate answer is in range using the validator function
+    const existingAnswers = existingData.answers as (number | number[])[];
+    const updatedAnswers = [...existingAnswers];
+    updatedAnswers[index] = this.validateAnswer(
+      existingAnswers[index],
+      newNOptions[index]
+    );
 
     const { error: updateError } = await this.db
       .from("answer_sheets")
@@ -186,19 +233,46 @@ export class AnswerSheetRepository implements AnswerSheets {
       throw { message: "Gagal mengupdate data", error: updateError };
   };
 
-  updateAnswers = async (answerSheetId: string, answers: number[]) => {
+  updateAnswers = async (
+    answerSheetId: string,
+    answers: (number | number[])[]
+  ) => {
     const existingData = await this.getAnswerSheetById(answerSheetId);
     if (!existingData) throw { message: "AnswerSheet not found" };
 
     if (answers.length !== existingData.counts)
       throw { message: "Invalid answers array length" };
 
-    if (
-      !answers.every(
-        (answer, index) => answer >= 0 && answer < existingData.n_options[index]
-      )
-    )
+    // Validate all answers using the validator function
+    const validatedAnswers = answers.map((answer, index) =>
+      this.validateAnswer(answer, existingData.n_options[index])
+    );
+
+    // Check if any answer was invalid and needed correction
+    const hasInvalidAnswers = validatedAnswers.some((validAnswer, index) => {
+      const originalAnswer = answers[index];
+
+      // For single answers
+      if (!Array.isArray(originalAnswer) && !Array.isArray(validAnswer)) {
+        return originalAnswer !== validAnswer;
+      }
+
+      // For array answers
+      if (Array.isArray(originalAnswer) && Array.isArray(validAnswer)) {
+        // Check if arrays have different lengths or different content
+        return (
+          originalAnswer.length !== validAnswer.length ||
+          !originalAnswer.every((val) => validAnswer.includes(val))
+        );
+      }
+
+      // Type mismatch (shouldn't happen in normal operation)
+      return true;
+    });
+
+    if (hasInvalidAnswers) {
       throw { message: "Invalid answer values" };
+    }
 
     const { error } = await this.db
       .from("answer_sheets")
diff --git a/repositories/contents.ts b/repositories/contents.ts
index e63cd74..320ce07 100644
--- a/repositories/contents.ts
+++ b/repositories/contents.ts
@@ -18,8 +18,8 @@ type BookContentsLink = Tables<"contents"> & {
 interface Contents {
   getBookContents(bookId: string): Promise<BookContentsLink[]>;
   upsertContentLink(contents: ContentUpdateForm): Promise<BookContentsLink>;
-  upsertQuiz(content: QuizUpdateForm): Promise<BookContentsLink>;
-  ensureQuizContentType(answerSheetId: string): Promise<void>;
+  upsertAnswerSheet(content: QuizUpdateForm): Promise<BookContentsLink>;
+  ensureAnswerSheetContentType(answerSheetId: string): Promise<void>;
   deleteContentsLink(contentId: string): Promise<void>;
   getContentByLink(path: string): Promise<BookContentsLink>;
   getContentLinkByTargetPath(targetPath: string): Promise<BookContentsLink>;
@@ -93,7 +93,7 @@ export class ContentsRepository implements Contents {
     return savedContents;
   }
 
-  async upsertQuiz(content: QuizUpdateForm): Promise<BookContentsLink> {
+  async upsertAnswerSheet(content: QuizUpdateForm): Promise<BookContentsLink> {
     // 1. create new answerSheet -> title, nQuestion, nOptions, answers, book_id
     const { nQuestion, nOptions, ...contentData } = content;
 
@@ -121,25 +121,25 @@ export class ContentsRepository implements Contents {
     const contentWithLink = await this.upsertContentLink({
       ...contentData,
       targetUrl,
-      type: "quiz",
+      type: "answer_sheet",
     });
 
     return contentWithLink;
   }
 
-  async ensureQuizContentType(answerSheetId: string): Promise<void> {
+  async ensureAnswerSheetContentType(answerSheetId: string): Promise<void> {
     const targetPath = `/quiz/${answerSheetId}`;
 
     // Get content via link
     const content = await this.getContentLinkByTargetPath(targetPath);
     if (!content) throw new Error("Content link not found");
 
-    if (content.type !== "quiz")
+    if (content.type !== "answer_sheet")
       try {
         // Update content type if needed
         await this._db
           .from("contents")
-          .update({ type: "quiz" })
+          .update({ type: "answer_sheet" })
           .eq("link_id", content.link_id);
       } catch (error) {
         throw new Error("Update content type failed");
diff --git a/supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql b/supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql
new file mode 100644
index 0000000..7caf9d3
--- /dev/null
+++ b/supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql
@@ -0,0 +1,27 @@
+-- Revert to integer array type
+ALTER TABLE public.answer_sheets 
+  RENAME COLUMN answers TO answers_new;
+
+ALTER TABLE public.answer_sheets
+  ADD COLUMN answers integer[];
+
+UPDATE public.answer_sheets
+SET answers = jsonb_to_int_array_replace_nested(answers_new);
+
+ALTER TABLE public.answer_sheets
+  ALTER COLUMN answers SET NOT NULL;
+
+ALTER TABLE public.answer_sheets
+  DROP COLUMN answers_new,
+  DROP CONSTRAINT IF EXISTS answers_check,
+  DROP CONSTRAINT IF EXISTS points_check,
+  DROP CONSTRAINT IF EXISTS n_options_check;
+
+-- Revert constraints
+ALTER TABLE public.answer_sheets
+  ADD CONSTRAINT answers_sheets_check
+    CHECK (array_position(answers, NULL) IS NULL AND array_length(answers, 1) = counts),
+  ADD CONSTRAINT answer_sheets_check1
+    CHECK (array_position(points, NULL) IS NULL AND array_length(points, 1) = counts),
+  ADD CONSTRAINT answer_sheets__check2
+    CHECK (array_position(n_options, NULL) IS NULL AND array_length(n_options, 1) = counts);
\ No newline at end of file
diff --git a/supabase/down_migrations/20250420071140_new_content_type_enum_down.sql b/supabase/down_migrations/20250420071140_new_content_type_enum_down.sql
new file mode 100644
index 0000000..5cc4a46
--- /dev/null
+++ b/supabase/down_migrations/20250420071140_new_content_type_enum_down.sql
@@ -0,0 +1,28 @@
+ALTER TABLE "public"."contents"
+  ALTER COLUMN "type" drop NOT NULL,
+  ALTER COLUMN "type" drop default;
+
+ALTER TABLE "public"."contents"
+  ALTER COLUMN "type" TYPE TEXT;
+
+UPDATE public.contents
+SET type = NULL
+WHERE type = 'exercise';
+
+UPDATE public.contents
+SET type = 'quiz'
+WHERE type = 'answer_sheet';
+
+ALTER TYPE "public"."content_type"
+  RENAME TO "content_type__old_version_to_be_dropped";
+
+CREATE TYPE "public"."content_type" AS ENUM ('content', 'quiz');
+
+ALTER TABLE "public"."contents"
+  ALTER COLUMN TYPE type "public"."content_type" USING TYPE::TEXT::"public"."content_type";
+
+ALTER TABLE "public"."contents"
+  ALTER COLUMN "type"
+  SET DEFAULT 'content'::content_type;
+
+DROP TYPE "public"."content_type__old_version_to_be_dropped";
\ No newline at end of file
diff --git a/supabase/migrations/20250420053838_change_answers_to_jsonb.sql b/supabase/migrations/20250420053838_change_answers_to_jsonb.sql
new file mode 100644
index 0000000..47a505e
--- /dev/null
+++ b/supabase/migrations/20250420053838_change_answers_to_jsonb.sql
@@ -0,0 +1,52 @@
+-- Modify answers column type and constraints
+ALTER TABLE public.answer_sheets 
+  RENAME COLUMN answers TO answers_old;
+
+ALTER TABLE public.answer_sheets
+  ADD COLUMN answers jsonb;
+
+UPDATE public.answer_sheets 
+SET answers = to_jsonb(answers_old);
+
+ALTER TABLE public.answer_sheets
+  ALTER COLUMN answers SET NOT NULL;
+
+ALTER TABLE public.answer_sheets
+  DROP CONSTRAINT answer_sheets_check,
+  DROP CONSTRAINT answer_sheets_check1,
+  DROP CONSTRAINT answer_sheets_check2,
+  DROP COLUMN answers_old;
+
+ALTER TABLE public.answer_sheets
+  ADD CONSTRAINT answers_checks 
+    CHECK (jsonb_typeof(answers) = 'array'),
+  ADD CONSTRAINT points_check 
+    CHECK (
+      array_position(points, NULL) IS NULL AND 
+      array_length(points, 1) = counts
+    ),
+  ADD CONSTRAINT n_options_check 
+    CHECK (
+      array_position(n_options, NULL) IS NULL AND 
+      array_length(n_options, 1) = counts
+    );
+
+-- Prepare drop migration function to be used in down migration
+CREATE OR REPLACE FUNCTION jsonb_to_int_array_replace_nested(data jsonb) RETURNS int[] AS $$
+DECLARE
+  result int[] := '{}';
+  element jsonb;
+BEGIN
+  FOR element IN SELECT * FROM jsonb_array_elements(data) LOOP
+    IF jsonb_typeof(element) = 'array' THEN
+      -- Replace nested arrays with 0
+      result := array_append(result, 0);
+    ELSE
+      -- For regular elements, take the value directly
+      result := array_append(result, (element#>>'{}')::int);
+    END IF;
+  END LOOP;
+  
+  RETURN result;
+END;
+$$ LANGUAGE plpgsql;
\ No newline at end of file
diff --git a/supabase/migrations/20250420071140_new_content_type_enum.sql b/supabase/migrations/20250420071140_new_content_type_enum.sql
new file mode 100644
index 0000000..cd3e388
--- /dev/null
+++ b/supabase/migrations/20250420071140_new_content_type_enum.sql
@@ -0,0 +1,37 @@
+ALTER TABLE "public"."contents"
+  ALTER COLUMN "type" drop NOT NULL,
+  ALTER COLUMN "type" drop default;
+
+-- Change type column to text type to allow any string values
+ALTER TABLE "public"."contents"
+  ALTER COLUMN type type text;
+
+-- Change quiz type to answer_sheet
+UPDATE public.contents
+  SET type = 'answer_sheet'
+  WHERE type = 'quiz';
+
+ALTER TYPE "public"."content_type"
+  RENAME TO "content_type__old_version_to_be_dropped";
+
+CREATE TYPE "public"."content_type" AS ENUM ('content', 'answer_sheet', 'exercise');
+
+ALTER TABLE "public"."contents"
+  ALTER COLUMN type type "public"."content_type" USING TYPE::TEXT::"public"."content_type";
+
+ALTER TABLE "public"."contents"
+  ALTER COLUMN "type"
+  SET DEFAULT 'content'::content_type;
+
+DROP TYPE "public"."content_type__old_version_to_be_dropped";
+
+-- Fallback if there any NULL value we assume it is an `exercise`. 
+-- This assumtion come from down migration script
+UPDATE "public"."contents"
+  SET type = 'exercise'::content_type
+  WHERE type IS NULL;
+
+-- Set back 'type' column to not null
+ALTER TABLE "public"."contents"
+  ALTER COLUMN "type"
+  SET NOT NULL;
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index e7ff90f..9d570ef 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,10 @@
 {
   "compilerOptions": {
-    "lib": ["dom", "dom.iterable", "esnext"],
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "esnext"
+    ],
     "allowJs": true,
     "skipLibCheck": true,
     "strict": true,
@@ -12,15 +16,25 @@
     "isolatedModules": true,
     "jsx": "preserve",
     "incremental": true,
+    "downlevelIteration": true,
     "plugins": [
       {
         "name": "next"
       }
     ],
     "paths": {
-      "@/*": ["./*"]
+      "@/*": [
+        "./*"
+      ]
     }
   },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
-  "exclude": ["node_modules"]
-}
+  "include": [
+    "next-env.d.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    ".next/types/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}
\ No newline at end of file


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 8**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>repositories/answer-sheets.ts (1)</summary><blockquote>

`275-278`: _⚠️ Potential issue_

**Persist the *validated* answers, not the raw payload**

`validatedAnswers` is computed but the DB update still uses the un-sanitised `answers`, re-introducing duplicates or out-of-range values that passed the earlier equality check.

```diff
-  .update({ answers })
+  .update({ answers: validatedAnswers })
```

If preserving user order is required, perform the sanitisation before the equality check and use that result consistently.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (9)</summary><blockquote>

<details>
<summary>supabase/migrations/20250420071140_new_content_type_enum.sql (2)</summary><blockquote>

`5-8`: **Consider performance impact of `ALTER COLUMN` on large tables.**

Changing the `type` column to `text` (and later to a new enum) will acquire an exclusive lock on `public.contents`, which can block reads/writes on large datasets. In high-volume environments, consider breaking this migration into smaller steps or running during a maintenance window to minimize downtime.

---

`28-30`: **Fix typo in comment.**

The word “assumtion” should be corrected to “assumption” for clarity:

```diff
-- Fallback if there any NULL value we assume it is an `exercise`. 
-- This assumption comes from down migration script
```

</blockquote></details>
<details>
<summary>supabase/migrations/20250420053838_change_answers_to_jsonb.sql (1)</summary><blockquote>

`34-52`: **Well-implemented helper function for down migration.**

The PL/pgSQL function elegantly handles the conversion of nested arrays back to integer arrays by replacing nested arrays with zeros. This will be useful for the down migration.


Consider adding a comment explaining that nested arrays are replaced with 0 during down migration because the integer array type can't represent nested arrays, and what this means for data restoration.

</blockquote></details>
<details>
<summary>repositories/contents.ts (3)</summary><blockquote>

`18-26`: **Renamed interface methods to reflect content type change.**

Changed from `upsertQuiz` to `upsertAnswerSheet` and from `ensureQuizContentType` to `ensureAnswerSheetContentType` to maintain consistency with the content type renaming throughout the application.


While the method names have been updated, the `QuizUpdateForm` type referenced on line 21 is still using the old "Quiz" prefix. Consider renaming this type to `AnswerSheetUpdateForm` for consistency.

---

`96-128`: **Updated upsertAnswerSheet method implementation.**

The implementation correctly maintains the existing functionality while updating the content type from "quiz" to "answer_sheet".


On line 103, you're initializing `answers` as an array filled with 0. Since the column now supports multi-answer functionality, consider initializing it with single-number arrays to be more explicit about the data format.

---

`130-147`: **Updated ensureAnswerSheetContentType method implementation.**

The implementation correctly checks and updates the content type to "answer_sheet" instead of "quiz".


The target path still uses "/quiz/" (line 131) while the content type is now "answer_sheet". This might be confusing for future maintenance. Consider adding a comment explaining why the path remains "quiz" while the content type has changed, if keeping this URL structure is intentional.

</blockquote></details>
<details>
<summary>app/(public)/api/v2/quiz/[[...uuid]]/route.ts (2)</summary><blockquote>

`31-39`: **Documentation comment still references 'quiz'.**

While the functionality has been updated, the documentation still refers to "quiz" rather than "answer_sheet".

Update the documentation comment to refer to "answer sheet" instead of "quiz" for consistency with the terminology changes elsewhere in the codebase.

---

`175-181`: **Added modes array to identify answer types.**

Good addition of a "modes" array that indicates whether each answer is single-choice (0) or multi-choice (1), making it easier for clients to render the appropriate UI.


Consider using enum constants instead of magic numbers for better readability:

```diff
const SINGLE_CHOICE = 0;
const MULTIPLE_CHOICE = 1;

const modes = (answers as (number | number[])[]).map((answer) =>
-  Array.isArray(answer) ? 1 : 0
+  Array.isArray(answer) ? MULTIPLE_CHOICE : SINGLE_CHOICE
);
```

</blockquote></details>
<details>
<summary>app/(public)/quiz/[id]/components/options-editor.tsx (1)</summary><blockquote>

`81-84`: **Allow clearing all selections in multi-answer mode**

`if (value.length === 0) return;` prevents users from un-checking every option once at least one answer is selected.  
Consider removing the early return (or converting it to `setAnswerState([])`) so that users can explicitly reset a question.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between ba2561bee1fcb2d1a09958a5ff724427002ae112 and 9a822cae0642eb7b11d34c8d0a6c5a2327e16ab6.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `pnpm-lock.yaml` is excluded by `!**/pnpm-lock.yaml`

</details>

<details>
<summary>📒 Files selected for processing (23)</summary>

* `app/(protected)/(books)/[bookId]/components/content-card.tsx` (4 hunks)
* `app/(protected)/(books)/[bookId]/components/handler.ts` (3 hunks)
* `app/(protected)/(books)/[bookId]/dialog/content-form/content-form.tsx` (3 hunks)
* `app/(public)/api/v2/quiz/[[...uuid]]/route.ts` (4 hunks)
* `app/(public)/quiz/[id]/components/forms.tsx` (5 hunks)
* `app/(public)/quiz/[id]/components/handler.ts` (3 hunks)
* `app/(public)/quiz/[id]/components/options-editor.tsx` (3 hunks)
* `app/(public)/quiz/[id]/components/question-config.tsx` (2 hunks)
* `app/(public)/quiz/[id]/handler.ts` (2 hunks)
* `app/(public)/quiz/[id]/page.tsx` (1 hunks)
* `components/ui/badge.tsx` (1 hunks)
* `components/ui/switch.tsx` (1 hunks)
* `lib/constants.ts` (1 hunks)
* `models/contents.ts` (4 hunks)
* `models/supaservice.types.ts` (5 hunks)
* `package.json` (2 hunks)
* `repositories/answer-sheets.ts` (5 hunks)
* `repositories/contents.ts` (3 hunks)
* `supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql` (1 hunks)
* `supabase/down_migrations/20250420071140_new_content_type_enum_down.sql` (1 hunks)
* `supabase/migrations/20250420053838_change_answers_to_jsonb.sql` (1 hunks)
* `supabase/migrations/20250420071140_new_content_type_enum.sql` (1 hunks)
* `tsconfig.json` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧠 Learnings (5)</summary>

<details>
<summary>app/(public)/quiz/[id]/handler.ts (1)</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#19
File: app/(public)/quiz/[id]/handler.ts:28-28
Timestamp: 2025-04-27T10:05:11.863Z
Learning: In app/(public)/quiz/[id]/handler.ts, the getConfigByIndex function is designed to return the entire answers array in the QuestionConfig object, not just the specific answer at the given index. This is intentional behavior to support recreating the entire answers data when there is a change at a specific index in multi-answer functionality.
```

</details>
<details>
<summary>app/(public)/api/v2/quiz/[[...uuid]]/route.ts (1)</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#8
File: app/(public)/api/v2/quiz/[[...uuid]]/route.ts:23-29
Timestamp: 2025-01-12T02:05:14.632Z
Learning: The quiz input schema in `app/(public)/api/v2/quiz/[[...uuid]]/route.ts` cannot have maximum length constraints due to specific requirements that need flexibility in the input fields.
```

</details>
<details>
<summary>app/(public)/quiz/[id]/components/question-config.tsx (1)</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#19
File: app/(public)/quiz/[id]/handler.ts:28-28
Timestamp: 2025-04-27T10:05:11.863Z
Learning: In app/(public)/quiz/[id]/handler.ts, the getConfigByIndex function is designed to return the entire answers array in the QuestionConfig object, not just the specific answer at the given index. This is intentional behavior to support recreating the entire answers data when there is a change at a specific index in multi-answer functionality.
```

</details>
<details>
<summary>app/(public)/quiz/[id]/components/handler.ts (1)</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#19
File: app/(public)/quiz/[id]/handler.ts:28-28
Timestamp: 2025-04-27T10:05:11.863Z
Learning: In app/(public)/quiz/[id]/handler.ts, the getConfigByIndex function is designed to return the entire answers array in the QuestionConfig object, not just the specific answer at the given index. This is intentional behavior to support recreating the entire answers data when there is a change at a specific index in multi-answer functionality.
```

</details>
<details>
<summary>models/contents.ts (1)</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#19
File: models/contents.ts:33-40
Timestamp: 2025-04-27T10:21:12.539Z
Learning: The validation rules in models/contents.ts for nQuestion and nOptions currently apply to all types except "content" (including "exercise" type). This will be revisited and likely changed to only apply to "answer_sheet" type when the "exercise" feature is developed.
```

</details>

</details><details>
<summary>🧬 Code Graph Analysis (7)</summary>

<details>
<summary>app/(public)/quiz/[id]/handler.ts (2)</summary><blockquote>

<details>
<summary>models/index.ts (1)</summary>

* `AnswerSheet` (18-18)

</details>
<details>
<summary>app/(public)/quiz/[id]/components/question-config.tsx (1)</summary>

* `QuestionConfig` (14-47)

</details>

</blockquote></details>
<details>
<summary>components/ui/switch.tsx (1)</summary><blockquote>

<details>
<summary>lib/utils.ts (1)</summary>

* `cn` (6-8)

</details>

</blockquote></details>
<details>
<summary>repositories/contents.ts (2)</summary><blockquote>

<details>
<summary>models/contents.ts (2)</summary>

* `QuizUpdateForm` (64-67)
* `BookContentsLink` (69-71)

</details>
<details>
<summary>app/(protected)/banner/error.tsx (1)</summary>

* `Error` (7-34)

</details>

</blockquote></details>
<details>
<summary>app/(public)/api/v2/quiz/[[...uuid]]/route.ts (1)</summary><blockquote>

<details>
<summary>lib/api-response.ts (1)</summary>

* `success` (5-13)

</details>

</blockquote></details>
<details>
<summary>app/(protected)/(books)/[bookId]/components/handler.ts (1)</summary><blockquote>

<details>
<summary>models/contents.ts (3)</summary>

* `ContentUpdateForm` (53-53)
* `isAnswerSheetContent` (101-109)
* `validateAnswerSheetContent` (111-117)

</details>

</blockquote></details>
<details>
<summary>app/(public)/quiz/[id]/components/options-editor.tsx (3)</summary><blockquote>

<details>
<summary>components/ui/label.tsx (1)</summary>

* `Label` (26-26)

</details>
<details>
<summary>components/ui/input.tsx (1)</summary>

* `Input` (25-25)

</details>
<details>
<summary>components/ui/toggle-group.tsx (1)</summary>

* `ToggleGroup` (61-61)

</details>

</blockquote></details>
<details>
<summary>app/(public)/quiz/[id]/components/forms.tsx (4)</summary><blockquote>

<details>
<summary>components/ui/label.tsx (1)</summary>

* `Label` (26-26)

</details>
<details>
<summary>app/(public)/quiz/[id]/components/handler.ts (1)</summary>

* `updateAnswerSheet` (126-175)

</details>
<details>
<summary>components/ui/switch.tsx (1)</summary>

* `Switch` (29-29)

</details>
<details>
<summary>components/ui/spinner.tsx (1)</summary>

* `Spinner` (3-21)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (54)</summary><blockquote>

<details>
<summary>package.json (1)</summary>

`36-36`: **Approve addition of `@radix-ui/react-switch`.**

Adding `@radix-ui/react-switch` at `^1.2.2` aligns with the new UI toggle component. Ensure that it’s imported and styled correctly in `components/ui/switch.tsx`.

</details>
<details>
<summary>supabase/migrations/20250420071140_new_content_type_enum.sql (1)</summary>

`20-21`: **Verify the `USING` cast syntax.**

The clause `USING TYPE::TEXT::"public"."content_type"` relies on the unquoted column name resolving correctly. It’s safer and clearer to quote the column, e.g.:

```sql
ALTER TABLE public.contents
  ALTER COLUMN type TYPE public.content_type
  USING "type"::text::public.content_type;
```
Please confirm this works as expected in your target Postgres version.

</details>
<details>
<summary>tsconfig.json (5)</summary>

`3-7`: **Approve: Expand `lib` into multi-line array.**

Breaking `"lib"` into separate lines improves readability without impacting behavior.

---

`19-19`: **Approve: Added `downlevelIteration`.**

Enabling `downlevelIteration` supports complex iteration patterns in older JS targets, which aligns with the new multi-answer logic.

---

`26-29`: **Approve: Multi-line `paths` config.**

Formatting the `paths` mapping as multiple lines enhances maintainability.

---

`31-36`: **Approve: Multi-line `include` configuration.**

Splitting `include` into separate entries is a readability improvement.

---

`37-39`: **Approve: Multi-line `exclude` configuration.**

Expanding `exclude` enhances clarity on ignored paths.

</details>
<details>
<summary>app/(public)/quiz/[id]/page.tsx (2)</summary>

`59-59`: **Approve: Add `justify-between` to outer flex container.**

Including `justify-between` evenly spaces the question list and answer form, matching the updated design requirements.

---

`60-60`: **Approve: Increase max-width and switch to `flex-grow`.**

Changing `max-w-64 flex-1` to `max-w-xl flex-grow` provides more horizontal room for multi-answer UI elements. Please verify the layout at various breakpoints to ensure it does not overflow.

</details>
<details>
<summary>app/(protected)/(books)/[bookId]/dialog/content-form/content-form.tsx (3)</summary>

`110-112`: **LGTM: Content type renamed from "quiz" to "answer_sheet"**

The TabsTrigger value and label have been consistently updated from "quiz"/"Kuis" to "answer_sheet"/"Lembar Jawab" in the mobile view.

---

`153-153`: **LGTM: TabsContent value updated to match TabsTrigger**

The TabsContent value has been properly updated to "answer_sheet" to match the corresponding TabsTrigger.

---

`214-214`: **LGTM: Desktop view TabsTrigger updated consistently**

The desktop view TabsTrigger has been updated to maintain consistency with the mobile view changes.

</details>
<details>
<summary>components/ui/badge.tsx (1)</summary>

`17-18`: **Badge variants properly updated to support new content types**

The "quiz" badge variant has been replaced with two new variants:
1. "exercise" - using amber color scheme
2. "answer_sheet" - using the emerald color scheme (previously used by "quiz")

Both variants maintain consistent styling patterns with other badge variants.

</details>
<details>
<summary>supabase/down_migrations/20250420071140_new_content_type_enum_down.sql (2)</summary>

`8-14`: **LGTM: Data migration for content type reversion**

The UPDATE statements correctly map the new content types back to their original values:
- 'exercise' → NULL
- 'answer_sheet' → 'quiz'

This ensures data integrity during the rollback.

---

`16-28`: **LGTM: Enum type recreation and column conversion**

The migration properly:
1. Renames the current enum type
2. Creates a new enum with the original values
3. Converts the column to use the new enum
4. Sets the default value
5. Cleans up by dropping the old enum

This follows best practices for safely modifying enum types in PostgreSQL.

</details>
<details>
<summary>supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql (1)</summary>

`21-27`: **LGTM: Constraints properly restored**

The constraints ensuring array integrity are correctly restored:
1. No NULL elements in arrays
2. Array lengths match the 'counts' column

This maintains data consistency rules from the original schema.

</details>
<details>
<summary>app/(protected)/(books)/[bookId]/components/content-card.tsx (4)</summary>

`70-70`: **Good defensive programming approach**

Adding a fallback default value for the content type is a good practice to prevent potential issues with undefined values. This makes the code more robust against unexpected states.

---

`95-95`: **Good consistent defensive approach**

Consistent with the earlier change, adding a fallback default value here ensures code robustness. This pattern helps maintain a reliable state throughout the component.

---

`116-118`: **Well-implemented content type transition**

The replacement of "quiz" with "answer_sheet" and "exercise" aligns with the broader refactoring in the PR. Both new content types correctly reuse the same behavior, maintaining functionality while improving naming.

---

`141-141`: **Appropriate fallback for Badge component**

Using the fallback for content type in the Badge component prevents potential rendering issues with undefined keys. This is consistent with the defensive approach used elsewhere in the file.

</details>
<details>
<summary>app/(public)/quiz/[id]/handler.ts (3)</summary>

`7-7`: **Good type definition for multi-answer support**

The updated type `(number | number[])[]` effectively supports both single and multiple answers per question, which aligns with the PR objective of enabling multi-answer functionality.

---

`15-15`: **Appropriate default value for answers array**

The default configuration correctly uses an array with a single default value, which aligns with the new answers structure and maintains backward compatibility with existing code.

---

`28-28`: **Correctly typed return value**

The cast to `(number | number[])[]` ensures type safety when handling the answers array. This change aligns with the retrieved learning that this function is designed to return the entire answers array to support recreating answer data when there's a change at a specific index.

</details>
<details>
<summary>components/ui/switch.tsx (2)</summary>

`8-26`: **Well-structured Switch component**

The Switch component is well-implemented using Radix UI primitives and follows best practices:
- Properly uses React.forwardRef
- Uses the cn utility for className composition
- Includes appropriate styling for different states (checked/unchecked, focus, disabled)
- Supports dark mode with appropriate color variants
- Maintains accessibility with focus states

This component provides a consistent UI element for toggle functionality needed for the multi-answer feature.

---

`27-29`: **Good component naming practice**

Setting the displayName to match the underlying Radix UI component's displayName is a good practice for debugging purposes. This ensures clear component identification in React DevTools.

</details>
<details>
<summary>app/(protected)/(books)/[bookId]/components/handler.ts (3)</summary>

`4-6`: **Consistent import renaming**

The import changes from quiz-related functions to answer sheet-related functions are consistent with the PR's objective of renaming the "quiz" content type to "answer_sheet".

---

`24-34`: **Well-refactored answer sheet handling**

The function rename from `addQuiz` to `addAnswerSheet` and its implementation updates are well-aligned with the content type changes. The function correctly:
- Validates input using the new answer sheet validators
- Throws appropriate error messages
- Uses the updated repository method `upsertAnswerSheet`

This change maintains the same functionality while using the new terminology.

---

`46-48`: **Consistent switch case update**

The switch case update from "quiz" to "answer_sheet" completes the refactoring of this file. This change ensures that the content handler correctly routes answer sheet content to the appropriate function.

</details>
<details>
<summary>supabase/migrations/20250420053838_change_answers_to_jsonb.sql (4)</summary>

`1-4`: **Well-structured migration for changing the answers column type.**

The migration properly renames the existing column before adding the new one, ensuring a clean transition.

---

`5-12`: **Appropriate column addition and data migration.**

The migration correctly adds the new JSONB column and converts existing data using to_jsonb. Setting NOT NULL constraint after data migration is a good practice.

---

`14-18`: **Clean constraint removal.**

Good approach to drop old constraints before removing the old column.

---

`20-32`: **Comprehensive constraint implementation.**

The new constraints properly enforce:
1. The JSONB column must be an array
2. Points and n_options arrays must not contain NULL values
3. Points and n_options arrays must match the counts column length

This ensures data integrity for the new multi-answer format.

</details>
<details>
<summary>app/(public)/quiz/[id]/components/question-config.tsx (5)</summary>

`2-2`: **Added MultiAnswerForm import to support new multi-answer functionality.**

Properly importing the new component needed for multi-answer support.

---

`4-12`: **Updated QuestionConfigProps to support multi-answer data structure.**

The type definition now correctly includes `answers` as an array of numbers or number arrays, which properly supports both single and multi-answer questions.

---

`14-20`: **Updated data destructuring to include answers array.**

Properly extracting the answers property from the data object.

---

`23-29`: **Improved layout for better component organization.**

The component layout has been changed from a vertical column to a horizontal row with improved spacing and responsive behavior. The "hidden md:flex" ensures the component is only visible on medium screens and larger, improving mobile usability.

---

`40-44`: <details>
<summary>✅ Verification successful</summary>

**Added MultiAnswerForm component to support multi-answer functionality.**

The MultiAnswerForm component is properly integrated with the necessary props (index, answerSheetId, and answers) to manage multi-answer functionality.


Ensure that the MultiAnswerForm component can handle both single answers (numbers) and multiple answers (number arrays) correctly, as that functionality isn't visible in this file.

---

🏁 Script executed:

```shell
#!/bin/bash
# Description: Check the implementation of MultiAnswerForm

# Looking for the MultiAnswerForm component implementation
rg -A 10 "export function MultiAnswerForm" --type=tsx
```

Length of output: 83

---

Let’s broaden the search to locate the actual `MultiAnswerForm` implementation across all file types:

---

🏁 Script executed:

```shell
#!/bin/bash
# Broadly search for MultiAnswerForm definition or export
rg -A10 "export function MultiAnswerForm" .
rg -A10 "function MultiAnswerForm" .
```

Length of output: 84

---

Let’s locate all references to `MultiAnswerForm` to find its definition or import:

---

🏁 Script executed:

```shell
#!/bin/bash
# Search for any occurrences of MultiAnswerForm across the codebase
rg -n "MultiAnswerForm" .
rg -n "multianswerform" .
```

Length of output: 343

---

Let’s inspect the `MultiAnswerForm` implementation to verify how `answers` is handled:

---

🏁 Script executed:

```shell
#!/bin/bash
# Show the MultiAnswerForm definition and surrounding logic
sed -n '250,340p' './app/(public)/quiz/[id]/components/forms.tsx'
```

Length of output: 2387

---



</details>

**Verified MultiAnswerForm type handling**

The `MultiAnswerForm` correctly detects and toggles between single (`number`) and multiple (`number[]`) answers:

- It initializes state with `Array.isArray(currentAnswers[index])`.
- On toggle → multi, it wraps the existing number in an array.
- On toggle → single, it resets to a numeric value (`0`).

No further changes are needed.

</details>
<details>
<summary>app/(public)/api/v2/quiz/[[...uuid]]/route.ts (4)</summary>

`23-29`: **Updated submission schema to support multi-answer functionality.**

The schema now correctly validates that answers can be either numbers (single-choice) or arrays of numbers (multi-choice).

---

`60-75`: **Improved validation for multi-answer support.**

The validation now correctly checks that each answer is either a number (single-choice) or an array of numbers (multi-choice).

---

`94-114`: **Enhanced scoring logic for multi-answer questions.**

The scoring logic now properly handles both single-choice and multi-choice answers:
- For single-choice, it compares values directly
- For multi-choice, it converts arrays to sets and checks for exact matches in size and content

This is a robust implementation that ensures correct scoring regardless of answer format.

---

`120-129`: **Added fullPoints calculation to the response.**

The code now calculates the total possible points from the points array and includes it in the response, which is useful for displaying scores as percentages or fractions of the total.

</details>
<details>
<summary>models/supaservice.types.ts (5)</summary>

`39-40`: **Type change supports multi-answer functionality**

The change from `number[]` to `Json` for the answers field in the `answer_sheets` table is a good approach to support the new multi-answer feature. This allows for more flexible answer formats including arrays of numbers or nested arrays.



Also applies to: 50-51, 61-62

---

`288-291`: **Good addition of helper database function**

Adding the `jsonb_to_int_array_replace_nested` function provides a clean way to convert JSONB arrays to integer arrays, which will be useful for migration scripts and data handling.

---

`294-294`: **Updated content type enum supports new features**

The content_type enum now includes "answer_sheet" and "exercise" values, reflecting the renaming and repurposing of quiz-related content types. This aligns well with the multi-answer feature objective.

---

`302-302`: **Schema type alias renamed for clarity**

Renaming from `PublicSchema` to `DefaultSchema` improves code clarity by using a more generic name that better represents its purpose.

---

`407-416`: **Constants object provides consistent enum references**

Adding the `Constants` object is a good practice as it provides a centralized way to reference the enum values across the application, ensuring consistency when using content types.

</details>
<details>
<summary>app/(public)/quiz/[id]/components/forms.tsx (4)</summary>

`151-152`: **Improved UI layout for form components**

The layout changes from horizontal to vertical flex with consistent spacing and width improve the visual organization of the form elements. The updated label styling with lighter font weight and opacity also enhances readability.



Also applies to: 223-224

---

`260-306`: **Well-implemented MultiAnswerForm component**

The `MultiAnswerForm` component effectively implements the multi-answer functionality with:
- Optimistic UI updates for immediate feedback
- Proper error handling with toast notifications
- Clean conversion between single and multi-answer formats
- Efficient array manipulation to update only the affected answer

This aligns perfectly with the PR objective of supporting multiple answers per question.

---

`308-327`: **Good UI component for toggling multi-answer mode**

The `ToggleMultiAnswer` component provides a clean UI for switching between single and multi-answer modes with:
- Loading state indication during form submission
- Unique ID generation to prevent conflicts
- Proper disabled state handling

The integration with the Switch component from the UI library maintains design consistency.

---

`380-380`: **Type assertion ensures proper handling of multi-answer data**

The type assertion for `data.answers` correctly handles the new data structure that can contain either single numbers or arrays of numbers for multi-answers.

</details>
<details>
<summary>models/contents.ts (4)</summary>

`9-9`: **Updated ContentType to support new content types**

The `ContentType` has been properly updated to include "exercise" and "answer_sheet" instead of "quiz", aligning with the overall refactoring objective.

---

`21-21`: **Schema enum updated to match ContentType**

The content schema's type enum has been correctly updated to match the new ContentType, ensuring type safety throughout the application.

---

`33-40`: **Schema refinement rules may need further adjustment**

Based on the retrieved learning from the user, the refinement rules for `nQuestion` and `nOptions` currently apply to all types except "content" (including the new "exercise" type). The user indicated this will be revisited when the "exercise" feature is developed further.

The current implementation is consistent with the user's understanding, but you may want to consider if the validation should be more specific to just the "answer_sheet" type as mentioned in the retrieved learning.

---

`101-109`: **Function names and implementations properly updated**

The functions have been renamed from `isQuizContent` to `isAnswerSheetContent` and `validateQuizContent` to `validateAnswerSheetContent`, with their implementations updated to check for the "answer_sheet" type. This ensures consistent naming and behavior throughout the application.



Also applies to: 111-117

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:19:14Z
user: login: mfathonin
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-22T20:56:26Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:56:47Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:56:49Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:57:18Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>app/(public)/quiz/[id]/components/options-editor.tsx (1)</summary><blockquote>

`84-88`: **Add NaN validation and simplify the onEdit call**

The multiple-selection handler lacks NaN validation that was added to the single-selection handler. Also, the parsed values from line 86 could be reused in line 87 to avoid redundant parsing.

```diff
onValueChange={(value) => {
  if (value.length === 0) return;
- setAnswerState(value.map((e) => parseInt(e)));
- onEdit(value.map((e) => parseInt(e)).toString());
+ const parsedValues = value.map((e) => parseInt(e, 10));
+ if (parsedValues.some(Number.isNaN)) return;
+ setAnswerState(parsedValues);
+ onEdit(parsedValues.toString());
}}
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 9a822cae0642eb7b11d34c8d0a6c5a2327e16ab6 and 0beeb8efdd94cf78ccecf55ce8fd1dc86a7d9fe7.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `app/(public)/quiz/[id]/components/handler.ts` (3 hunks)
* `app/(public)/quiz/[id]/components/options-editor.tsx` (3 hunks)
* `repositories/answer-sheets.ts` (5 hunks)
* `supabase/down_migrations/20250420071140_new_content_type_enum_down.sql` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (3)</summary>

* supabase/down_migrations/20250420071140_new_content_type_enum_down.sql
* repositories/answer-sheets.ts
* app/(public)/quiz/[id]/components/handler.ts

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>app/(public)/quiz/[id]/components/options-editor.tsx (3)</summary><blockquote>

<details>
<summary>components/ui/label.tsx (1)</summary>

* `Label` (26-26)

</details>
<details>
<summary>components/ui/input.tsx (1)</summary>

* `Input` (25-25)

</details>
<details>
<summary>components/ui/toggle-group.tsx (1)</summary>

* `ToggleGroup` (61-61)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: Analyze (javascript-typescript)

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>app/(public)/quiz/[id]/components/options-editor.tsx (5)</summary>

`19-19`: **LGTM: Type signature updated to support multi-answer mode**

The `answer` prop type has been correctly updated to support both single and multiple answers using a union type.

---

`61-67`: **Good UX addition with the informational label**

The layout changes with the added informational label "Pilih lebih dari satu" provides clear guidance to users when multiple answers are allowed. The flex layout ensures proper positioning of both elements.

---

`72-76`: **Well-implemented serialization fix**

This implementation correctly addresses the previous trailing comma issue by using a conditional approach based on the current state type rather than the initial prop value.

---

`96-115`: **Good implementation of conditional rendering based on answer type**

The conditional rendering approach is clean and effectively handles both single and multiple selection modes with appropriate ToggleGroup configurations.

---

`100-105`: **Properly implemented empty selection and NaN guards**

The guards against empty selection and NaN values address the previous review comment and prevent potential issues.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:09:49Z
user: login: mfathonin
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-22T21:12:58Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:13:31Z
user: login: mfathonin
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-22T21:13:43Z
user: login: mfathonin
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-22T21:13:48Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:14:07Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:14:52Z
user: login: coderabbitai[bot]
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:15:34Z

Review comments:
diff_hunk: @@ -8,7 +8,7 @@
     "start": "next start",
     "lint": "next lint",
     "env:clear": "bash ./scripts/clear_env.sh",
-    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start'",
+    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start && echo \"\n\n\u001b[1mApp Link:\u001b[0m \u001b[32m$NEXT_PUBLIC_SUPABASE_URL\u001b[0m\"'",
path: package.json
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Fix `supa:start` script behavior to detach Supabase process.**

As written, `pnpm dlx supabase start` runs in the foreground, so the following `echo` will only execute after stopping Supabase, negating the intent to display the App URL at startup. Consider using the `--detached` flag or appending `&` to run Supabase in the background. For example:

```diff
- "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start && echo \"\n\n\u001b[1mApp Link:\u001b[0m \u001b[32m$NEXT_PUBLIC_SUPABASE_URL\u001b[0m\"'",
+ "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start --detached && echo \"\n\n\u001b[1mApp Link:\u001b[0m \u001b[32m$NEXT_PUBLIC_SUPABASE_URL\u001b[0m\"'"
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start --detached && echo \"\n\n\u001b[1mApp Link:\u001b[0m \u001b[32m$NEXT_PUBLIC_SUPABASE_URL\u001b[0m\"'",
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In package.json at line 11, the "supa:start" script runs "pnpm dlx supabase
start" in the foreground, causing the subsequent echo command to wait until
Supabase stops. To fix this, modify the script to run Supabase in the background
by adding the --detached flag to the supabase start command or appending & at
the end of the command. This change will allow the echo statement to execute
immediately after starting Supabase, displaying the App URL as intended.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T20:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,7 +9,8 @@ export const constants = {
   CONTENT: {
     LABEL: {
       content: "URL",
-      quiz: "Kuis",
+      exercise: "Kuis",
+      answer_sheet: "Lembar jawab",
path: lib/constants.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Align error code grouping with renamed content types.**

`CONTENT.LABEL` has been updated to `exercise` and `answer_sheet`, but the `errors.quiz` section still uses the old “quiz” grouping and error code names. This can lead to confusion when handling errors for answer sheets. Consider renaming `errors.quiz` to `errors.answer_sheet` (or splitting into `errors.answer_sheet` and `errors.exercise`) and updating code references accordingly.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In lib/constants.ts around lines 12 to 13, the error code grouping under
errors.quiz uses outdated names that do not align with the renamed content types
exercise and answer_sheet. Rename errors.quiz to errors.answer_sheet or split it
into errors.answer_sheet and errors.exercise to match the new content type
labels. Update all related code references to use the new error group names to
maintain consistency and avoid confusion.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T20:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,28 @@
+ALTER TABLE "public"."contents"
+  ALTER COLUMN "type" drop NOT NULL,
+  ALTER COLUMN "type" drop default;
+
+ALTER TABLE "public"."contents"
+  ALTER COLUMN TYPE type TEXT;
path: supabase/down_migrations/20250420071140_new_content_type_enum_down.sql
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Fix syntax error in ALTER COLUMN statement**

There's a syntax error in the ALTER COLUMN statement that would cause the migration to fail.


```diff
-ALTER TABLE "public"."contents"
-  ALTER COLUMN TYPE type TEXT;
+ALTER TABLE "public"."contents"
+  ALTER COLUMN "type" TYPE TEXT;
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
ALTER TABLE "public"."contents"
  ALTER COLUMN "type" TYPE TEXT;
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In supabase/down_migrations/20250420071140_new_content_type_enum_down.sql around
lines 5 to 6, the ALTER COLUMN statement has a syntax error. Correct the syntax
by specifying the column name after ALTER COLUMN and then using TYPE to change
its data type, for example: ALTER COLUMN "column_name" TYPE TEXT;. Replace TYPE
with the actual column name being altered.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 0beeb8e
created_at: 2025-05-22T20:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+-- Revert to integer array type
+ALTER TABLE public.answer_sheets 
+  RENAME COLUMN answers TO answers_new;
+
+ALTER TABLE public.answer_sheets
+  ADD COLUMN answers integer[];
+
+UPDATE public.answer_sheets
+SET answers = jsonb_to_int_array_replace_nested(answers_new);
path: supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Verify data conversion for multi-answer compatibility**

The migration relies on a custom function `jsonb_to_int_array_replace_nested` to convert JSONB data back to integer arrays. Ensure this function properly handles nested arrays that might have been used for multiple answers.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if the function exists and how it's implemented
echo "Checking the jsonb_to_int_array_replace_nested function definition:"
cat supabase/migrations/20250420053838_change_answers_to_jsonb.sql | grep -A 20 "CREATE OR REPLACE FUNCTION"
```

Length of output: 784

---



</details>

**Enhance nested array handling in jsonb_to_int_array_replace_nested**

The current implementation in supabase/migrations/20250420053838_change_answers_to_jsonb.sql  
replaces any nested JSONB array with a single `0`, which will drop all multi-answer data. You should instead unpack nested arrays and append each integer element to preserve every answer.

- File: supabase/migrations/20250420053838_change_answers_to_jsonb.sql  
  Function: `jsonb_to_int_array_replace_nested` (around the `IF jsonb_typeof(element)` block)

Suggested change:
```diff
- IF jsonb_typeof(element) = 'array' THEN
-   -- Replace nested arrays with 0
-   result := array_append(result, 0);
- ELSE
+ IF jsonb_typeof(element) = 'array' THEN
+   -- Unpack nested arrays and preserve all values
+   FOR nested_elem IN SELECT * FROM jsonb_array_elements(element) LOOP
+     result := array_append(result, (nested_elem #>> '{}')::int);
+   END LOOP;
+ ELSE
```

This ensures that any nested arrays (multi-answer entries) are flattened into the resulting `int[]` rather than lost. 

> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql
around lines 8 to 9, the function jsonb_to_int_array_replace_nested currently
replaces nested JSONB arrays with a single 0, causing loss of multi-answer data.
To fix this, update the function implementation in
supabase/migrations/20250420053838_change_answers_to_jsonb.sql to detect nested
arrays and flatten them by unpacking each integer element and appending them to
the resulting int[] array, preserving all answers instead of dropping them.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T20:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,28 +58,52 @@ export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
 
   return (
     <div className="flex-1 border-b last:border-b-0 border-gray-200 dark:border-gray-800 pb-3 mb-3">
-      <Label className="pt-4">No. {index + 1}</Label>
+      <div className="flex justify-between items-end">
+        <Label className="h-5 flex items-center">No. {index + 1}</Label>
+        {Array.isArray(answer) && (
+          <p className="px-1.5 h-5 flex items-center rounded-lg text-white text-xs bg-green-400/70 dark:bg-green-800/50 ">
+            <i className="bx bx-info-circle me-1" /> Pilih lebih dari satu
+          </p>
+        )}
+      </div>
       <Input
         type="hidden"
         name="answer"
-        value={answerState}
-        onChange={(e) => setAnswerState(parseInt(e.target.value))}
+        value={answerState.toString() + (Array.isArray(answer) ? "," : "")}
       />
path: app/(public)/quiz/[id]/components/options-editor.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Hidden input serialisation adds a superfluous trailing comma**

`value={answerState.toString() + (Array.isArray(answer) ? "," : "")}` always appends a `,` for multi-answer mode.  
Side effects:

1. Produces strings like `"1,2,"`, which requires extra post-processing and is easy to mishandle.
2. Relies on the *initial* `answer` type, not the current `answerState`, so the flag is never updated if the mode changes.

Replace with a straightforward join that accurately reflects the *current* state:

```diff
- value={answerState.toString() + (Array.isArray(answer) ? "," : "")}
+ value={
+   Array.isArray(answerState)
+     ? answerState.join(",")          // e.g. "1,2"
+     : answerState.toString()         // e.g. "3"
+ }
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
        type="hidden"
        name="answer"
        value={
          Array.isArray(answerState)
            ? answerState.join(",")          // e.g. "1,2"
            : answerState.toString()         // e.g. "3"
        }
      />
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In app/(public)/quiz/[id]/components/options-editor.tsx around lines 70 to 73,
the hidden input's value appends a trailing comma when answer is an array,
causing incorrect serialization and relying on the initial answer type instead
of the current answerState. Fix this by replacing the value assignment with a
join operation on answerState if it is an array, ensuring no trailing comma and
reflecting the current state accurately.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 0beeb8e
created_at: 2025-05-22T20:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,28 +58,52 @@ export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
 
   return (
     <div className="flex-1 border-b last:border-b-0 border-gray-200 dark:border-gray-800 pb-3 mb-3">
-      <Label className="pt-4">No. {index + 1}</Label>
+      <div className="flex justify-between items-end">
+        <Label className="h-5 flex items-center">No. {index + 1}</Label>
+        {Array.isArray(answer) && (
+          <p className="px-1.5 h-5 flex items-center rounded-lg text-white text-xs bg-green-400/70 dark:bg-green-800/50 ">
+            <i className="bx bx-info-circle me-1" /> Pilih lebih dari satu
+          </p>
+        )}
+      </div>
       <Input
         type="hidden"
         name="answer"
-        value={answerState}
-        onChange={(e) => setAnswerState(parseInt(e.target.value))}
+        value={answerState.toString() + (Array.isArray(answer) ? "," : "")}
       />
 
-      <ToggleGroup
-        className="flex gap-2 w-full mt-3"
-        type="single"
-        value={answerState.toString()}
-        onValueChange={(value) => {
-          setAnswerState(parseInt(value));
-          onEdit(value);
-        }}
-      >
-        {options(
-          nOptions,
-          "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
-        )}
-      </ToggleGroup>
+      {Array.isArray(answerState) ? (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="multiple"
+          value={answerState.map((e) => e.toString())}
+          onValueChange={(value) => {
+            if (value.length === 0) return;
+            setAnswerState(value.map((e) => parseInt(e)));
+            onEdit(value.map((e) => parseInt(e)).toString());
+          }}
+        >
+          {options(
+            nOptions,
+            "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
+          )}
+        </ToggleGroup>
+      ) : (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="single"
+          value={answerState.toString()}
+          onValueChange={(value) => {
+            setAnswerState(parseInt(value));
+            onEdit(value);
+          }}
path: app/(public)/quiz/[id]/components/options-editor.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Guard against empty deselection & specify radix in `parseInt`**

When a user deselects the only active item in `type="single"` mode, `value` becomes an empty string.  
`parseInt("")` evaluates to `NaN`, causing `answerState` to hold `NaN` and the hidden input to serialise to `"NaN"`.

```diff
-onValueChange={(value) => {
-  setAnswerState(parseInt(value));
-  onEdit(value);
-}}
+onValueChange={(value) => {
+  if (value === "") return;        // ignore full deselection
+  const numeric = parseInt(value, 10);
+  if (Number.isNaN(numeric)) return;
+  setAnswerState(numeric);
+  onEdit(value);
+}}
```
This also adds the missing radix argument for `parseInt`, eliminating the implicit-octal edge case.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
          value={answerState.toString()}
          onValueChange={(value) => {
            if (value === "") return;        // ignore full deselection
            const numeric = parseInt(value, 10);
            if (Number.isNaN(numeric)) return;
            setAnswerState(numeric);
            onEdit(value);
          }}
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In app/(public)/quiz/[id]/components/options-editor.tsx lines 95 to 99, the
onValueChange handler uses parseInt without a radix and does not guard against
empty strings, causing answerState to become NaN when the value is empty. Fix
this by checking if the value is an empty string before parsing; if empty, set
answerState to a default (e.g., null or a safe fallback). Also, add the radix
parameter 10 to parseInt to avoid implicit octal parsing issues.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T20:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -42,14 +45,39 @@ export class AnswerSheetRepository implements AnswerSheets {
     this.db = supabase;
   }
 
-  private createConfigArray = (
-    newValue: number | undefined,
-    oldValue: number[],
+  /**
+   * Validates an answer against the number of options
+   * @param answer The answer to validate (single number or array of numbers)
+   * @param nOptions The number of options available for this question
+   * @returns The validated answer (corrected if needed)
+   */
+  private validateAnswer = (
+    answer: number | number[],
+    nOptions: number
+  ): number | number[] => {
+    // For single answer
+    if (!Array.isArray(answer)) {
+      return answer >= 0 && answer < nOptions ? answer : 0;
+    }
+
+    // For array answers
+    // Filter valid options and ensure uniqueness
+    const validOptions = [...new Set(answer)].filter(
+      (opt) => opt >= 0 && opt < nOptions
+    );
+
+    // Return [0] if no valid options or empty array
+    return validOptions.length > 0 ? validOptions : [0];
+  };
+
+  private createConfigArray = <T extends number | number[]>(
+    newValue: T | undefined,
+    oldValue: T[],
     newCount: number,
     oldCount: number
-  ): number[] => {
+  ): T[] => {
     const baseValue = newValue ?? oldValue[0];
-    const newArray = Array(newCount).fill(baseValue);
+    const newArray = Array(newCount).fill(baseValue) as T[];
 
path: repositories/answer-sheets.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**`Array.fill` shares the same reference for array values**

`Array(newCount).fill(baseValue)` duplicates the *reference* when `baseValue` is an array, so mutating one answer mutates every copied slot.

```diff
-const newArray = Array(newCount).fill(baseValue) as T[];
+const newArray = Array.from({ length: newCount }, () =>
+  Array.isArray(baseValue) ? [...baseValue] : (baseValue as number)
+) as T[];
```
This keeps primitives cheap while giving each array its own copy.  



> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In repositories/answer-sheets.ts around lines 79 to 81, the use of
Array(newCount).fill(baseValue) causes all elements to share the same reference
if baseValue is an array, leading to unintended mutations across elements. To
fix this, check if baseValue is an array and, if so, create a new copy of it for
each element instead of filling with the same reference. For primitives,
continue using fill as is. This ensures each array element is independent and
mutations do not affect others.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 0beeb8e
created_at: 2025-05-22T20:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,9 +126,41 @@ export const decreasePoints = async (formData: FormData) => {
 export const updateAnswerSheet = async (formData: FormData) => {
   const answerSheetId = formData.get("answerSheetId") as string;
   const counts = parseInt(formData.get("counts") as string);
-  const answers = formData
-    .getAll("answer")
-    .map((answer) => parseInt(answer as string));
+
+  let answers: (number | number[])[] = [];
+
+  try {
+    // First try to parse from the "answers" JSON field
+    const answersJson = formData.get("answers");
+    if (answersJson) {
+      const parsed = JSON.parse(answersJson as string);
+      if (Array.isArray(parsed)) {
+        answers = parsed;
+      }
+    }
+
+    // If no answers from JSON, try individual answer fields
+    if (answers.length === 0) {
+      answers = formData.getAll("answer").map((answerStr) => {
+        if (typeof answerStr !== "string") return 0;
+
+        if (answerStr.includes(",")) {
+          // Handle multi-answer case
+          return answerStr
+            .split(",")
+            .map((num) => parseInt(num.trim()))
+            .filter((num) => !isNaN(num));
+        } else {
+          // Handle single answer case
+          const num = parseInt(answerStr);
+          return isNaN(num) ? 0 : num;
path: app/(public)/quiz/[id]/components/handler.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Explicit radix & stricter filtering when parsing multi-answers**

`parseInt(num.trim())`:

1. Lacks radix – `"08"` can be parsed as octal in some contexts.
2. Accepts partial strings (`"3foo"` → `3`) which silently introduces wrong answers.

```diff
-  return answerStr
-    .split(",")
-    .map((num) => parseInt(num.trim()))
-    .filter((num) => !isNaN(num));
+  return answerStr
+    .split(",")
+    .map((s) => {
+      const n = Number.parseInt(s.trim(), 10);
+      return Number.isNaN(n) ? null : n;
+    })
+    .filter((n): n is number => n !== null);
```
This preserves only fully valid integers.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
          return answerStr
            .split(",")
            .map((s) => {
              const n = Number.parseInt(s.trim(), 10);
              return Number.isNaN(n) ? null : n;
            })
            .filter((n): n is number => n !== null);
        } else {
          // Handle single answer case
          const num = parseInt(answerStr);
          return isNaN(num) ? 0 : num;
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In app/(public)/quiz/[id]/components/handler.ts around lines 149 to 156, the
parseInt calls lack an explicit radix and allow partial string parsing, which
can cause incorrect results. Fix this by adding a radix of 10 to all parseInt
calls and replace the filter condition to only keep strings that fully match a
valid integer pattern before parsing, ensuring only fully valid integers are
accepted.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T20:19:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,7 +9,8 @@ export const constants = {
   CONTENT: {
     LABEL: {
       content: "URL",
-      quiz: "Kuis",
+      exercise: "Kuis",
+      answer_sheet: "Lembar jawab",
path: lib/constants.ts
user: login: mfathonin
body: It used differently in `api/v2/quiz/[[..uuid]]/route.ts`. We not relaying on the key `quiz`, we just destructuring it and directly use the contants under `quiz` key
created_at: 2025-05-22T20:44:42Z
author_association: OWNER
diff_hunk: @@ -8,7 +8,7 @@
     "start": "next start",
     "lint": "next lint",
     "env:clear": "bash ./scripts/clear_env.sh",
-    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start'",
+    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start && echo \"\n\n\u001b[1mApp Link:\u001b[0m \u001b[32m$NEXT_PUBLIC_SUPABASE_URL\u001b[0m\"'",
path: package.json
user: login: mfathonin
body: Lets skip this for now, it's more of DX concern
created_at: 2025-05-22T20:47:36Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,27 @@
+-- Revert to integer array type
+ALTER TABLE public.answer_sheets 
+  RENAME COLUMN answers TO answers_new;
+
+ALTER TABLE public.answer_sheets
+  ADD COLUMN answers integer[];
+
+UPDATE public.answer_sheets
+SET answers = jsonb_to_int_array_replace_nested(answers_new);
path: supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql
user: login: mfathonin
body: I've tested this down migration and it still works fine. Since this is just an enhancment, I'll skip it for now
created_at: 2025-05-22T20:55:37Z
author_association: OWNER
diff_hunk: @@ -8,7 +8,7 @@
     "start": "next start",
     "lint": "next lint",
     "env:clear": "bash ./scripts/clear_env.sh",
-    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start'",
+    "supa:start": "bash -c 'source ./scripts/set_env.sh && pnpm dlx supabase start && echo \"\n\n\u001b[1mApp Link:\u001b[0m \u001b[32m$NEXT_PUBLIC_SUPABASE_URL\u001b[0m\"'",
path: package.json
user: login: coderabbitai[bot]
body: @mfathonin Understood! I'll skip this suggestion since it's primarily a developer experience concern rather than a functional issue.

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#21
File: package.json:11-11
Timestamp: 2025-05-22T20:56:45.031Z
Learning: The user has decided to skip optimizing the `supa:start` script (running Supabase in detached mode) as it's primarily a developer experience concern rather than a critical functional issue.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@mfathonin Understood! I'll skip this suggestion since it's primarily a developer experience concern rather than a functional issue.

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#21
File: package.json:11-11
Timestamp: 2025-05-22T20:56:45.031Z
Learning: The user has decided to skip optimizing the `supa:start` script (running Supabase in detached mode) as it's primarily a developer experience concern rather than a critical functional issue.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T20:56:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+-- Revert to integer array type
+ALTER TABLE public.answer_sheets 
+  RENAME COLUMN answers TO answers_new;
+
+ALTER TABLE public.answer_sheets
+  ADD COLUMN answers integer[];
+
+UPDATE public.answer_sheets
+SET answers = jsonb_to_int_array_replace_nested(answers_new);
path: supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql
user: login: coderabbitai[bot]
body: @mfathonin I understand. Since you've tested the down migration and it works for your needs, skipping this enhancement is a reasonable choice. The current implementation is functional for your use case, which is what matters most.


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@mfathonin I understand. Since you've tested the down migration and it works for your needs, skipping this enhancement is a reasonable choice. The current implementation is functional for your use case, which is what matters most.</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T20:56:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,7 +9,8 @@ export const constants = {
   CONTENT: {
     LABEL: {
       content: "URL",
-      quiz: "Kuis",
+      exercise: "Kuis",
+      answer_sheet: "Lembar jawab",
path: lib/constants.ts
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

🏁 Script executed:
```
#!/bin/bash
# Check how the error constants are used in the file
cat app/\(public\)/api/v2/quiz/\[\[...uuid\]\]/route.ts
```

Length of output: 5680

---



</details>

@mfathonin Thanks for the clarification. You're right - after examining the code, I can see that the error constants are destructured at the beginning and then used directly throughout the file. The namespace/key 'quiz' isn't referenced again after destructuring, so functionality isn't dependent on the key name itself.

While aligning the error structure with the renamed content types would be conceptually cleaner, your current approach is pragmatic and works as intended. The code simply uses the error constants directly after they're extracted.

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Possibly relevant snippets from `models/books.ts` (line 17)
```typescript
export type BookUpdateForm = z.infer<typeof bookSchema>;
```
- This defines the `BookUpdateForm` type by inferring it from a Zod schema named `bookSchema`. It is used in `lib/constants.ts` to type the `EMPTY_BOOK_TEMPLATE` constant.

### Possibly relevant snippets from `models/contents.ts` (line 53)
```typescript
export type ContentUpdateForm = z.infer<typeof contentSchema>;
```
- This defines the `ContentUpdateForm` type by inferring it from a Zod schema named `contentSchema`. It is used in `lib/constants.ts` to type the `EMPTY_CONTENT_TEMPLATE` constant.

</details>



---

<details>
<summary>🧠 Learnings used</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#8
File: app/(public)/api/v2/quiz/[[...uuid]]/route.ts:23-29
Timestamp: 2025-01-12T02:05:14.632Z
Learning: The quiz input schema in `app/(public)/api/v2/quiz/[[...uuid]]/route.ts` cannot have maximum length constraints due to specific requirements that need flexibility in the input fields.
```

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

🏁 Script executed:
```
#!/bin/bash
# Check how the error constants are used in the file
cat app/\(public\)/api/v2/quiz/\[\[...uuid\]\]/route.ts
```

Length of output: 5680

---



</details>

@mfathonin Thanks for the clarification. You're right - after examining the code, I can see that the error constants are destructured at the beginning and then used directly throughout the file. The namespace/key 'quiz' isn't referenced again after destructuring, so functionality isn't dependent on the key name itself.

While aligning the error structure with the renamed content types would be conceptually cleaner, your current approach is pragmatic and works as intended. The code simply uses the error constants directly after they're extracted.

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Possibly relevant snippets from `models/books.ts` (line 17)
```typescript
export type BookUpdateForm = z.infer<typeof bookSchema>;
```
- This defines the `BookUpdateForm` type by inferring it from a Zod schema named `bookSchema`. It is used in `lib/constants.ts` to type the `EMPTY_BOOK_TEMPLATE` constant.

### Possibly relevant snippets from `models/contents.ts` (line 53)
```typescript
export type ContentUpdateForm = z.infer<typeof contentSchema>;
```
- This defines the `ContentUpdateForm` type by inferring it from a Zod schema named `contentSchema`. It is used in `lib/constants.ts` to type the `EMPTY_CONTENT_TEMPLATE` constant.

</details>



---

<details>
<summary>🧠 Learnings used</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#8
File: app/(public)/api/v2/quiz/[[...uuid]]/route.ts:23-29
Timestamp: 2025-01-12T02:05:14.632Z
Learning: The quiz input schema in `app/(public)/api/v2/quiz/[[...uuid]]/route.ts` cannot have maximum length constraints due to specific requirements that need flexibility in the input fields.
```

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T20:57:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,28 +58,61 @@ export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
 
   return (
     <div className="flex-1 border-b last:border-b-0 border-gray-200 dark:border-gray-800 pb-3 mb-3">
-      <Label className="pt-4">No. {index + 1}</Label>
+      <div className="flex justify-between items-end">
+        <Label className="h-5 flex items-center">No. {index + 1}</Label>
+        {Array.isArray(answer) && (
+          <p className="px-1.5 h-5 flex items-center rounded-lg text-white text-xs bg-green-400/70 dark:bg-green-800/50 ">
+            <i className="bx bx-info-circle me-1" /> Pilih lebih dari satu
+          </p>
+        )}
+      </div>
       <Input
         type="hidden"
         name="answer"
-        value={answerState}
-        onChange={(e) => setAnswerState(parseInt(e.target.value))}
+        value={
+          Array.isArray(answerState)
+            ? answerState.join(",")
+            : answerState.toString()
+        }
       />
 
-      <ToggleGroup
-        className="flex gap-2 w-full mt-3"
-        type="single"
-        value={answerState.toString()}
-        onValueChange={(value) => {
-          setAnswerState(parseInt(value));
-          onEdit(value);
-        }}
-      >
-        {options(
-          nOptions,
-          "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
-        )}
-      </ToggleGroup>
+      {Array.isArray(answerState) ? (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="multiple"
+          value={answerState.map((e) => e.toString())}
+          onValueChange={(value) => {
+            if (value.length === 0) return;
+            setAnswerState(value.map((e) => parseInt(e)));
+            onEdit(value.map((e) => parseInt(e)).toString());
+          }}
+        >
+          {options(
+            nOptions,
+            "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
+          )}
+        </ToggleGroup>
+      ) : (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="single"
+          value={answerState.toString()}
+          onValueChange={(value) => {
+            if (value === "") return;
+
+            const numeric = parseInt(value, 10);
+            if (Number.isNaN(numeric)) return;
+
+            setAnswerState(parseInt(value));
path: app/(public)/quiz/[id]/components/options-editor.tsx
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Missing radix parameter in parseInt**

You've correctly added the radix parameter in line 103, but it's missing here. Always specify the radix to prevent unexpected parsing behavior with leading zeros.

```diff
- setAnswerState(parseInt(value));
+ setAnswerState(parseInt(value, 10));
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
            setAnswerState(parseInt(value, 10));
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In app/(public)/quiz/[id]/components/options-editor.tsx at line 106, the call to
parseInt is missing the radix parameter. Update the parseInt call to include the
radix 10 as the second argument to ensure consistent decimal parsing and avoid
issues with leading zeros.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T21:09:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,28 +58,61 @@ export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
 
   return (
     <div className="flex-1 border-b last:border-b-0 border-gray-200 dark:border-gray-800 pb-3 mb-3">
-      <Label className="pt-4">No. {index + 1}</Label>
+      <div className="flex justify-between items-end">
+        <Label className="h-5 flex items-center">No. {index + 1}</Label>
+        {Array.isArray(answer) && (
+          <p className="px-1.5 h-5 flex items-center rounded-lg text-white text-xs bg-green-400/70 dark:bg-green-800/50 ">
+            <i className="bx bx-info-circle me-1" /> Pilih lebih dari satu
+          </p>
+        )}
+      </div>
       <Input
         type="hidden"
         name="answer"
-        value={answerState}
-        onChange={(e) => setAnswerState(parseInt(e.target.value))}
+        value={
+          Array.isArray(answerState)
+            ? answerState.join(",")
+            : answerState.toString()
+        }
       />
 
-      <ToggleGroup
-        className="flex gap-2 w-full mt-3"
-        type="single"
-        value={answerState.toString()}
-        onValueChange={(value) => {
-          setAnswerState(parseInt(value));
-          onEdit(value);
-        }}
-      >
-        {options(
-          nOptions,
-          "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
-        )}
-      </ToggleGroup>
+      {Array.isArray(answerState) ? (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="multiple"
+          value={answerState.map((e) => e.toString())}
+          onValueChange={(value) => {
+            if (value.length === 0) return;
+            setAnswerState(value.map((e) => parseInt(e)));
+            onEdit(value.map((e) => parseInt(e)).toString());
+          }}
+        >
+          {options(
+            nOptions,
+            "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
+          )}
+        </ToggleGroup>
+      ) : (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="single"
+          value={answerState.toString()}
+          onValueChange={(value) => {
+            if (value === "") return;
+
+            const numeric = parseInt(value, 10);
+            if (Number.isNaN(numeric)) return;
+
+            setAnswerState(parseInt(value));
path: app/(public)/quiz/[id]/components/options-editor.tsx
user: login: mfathonin
body: 10 is the default radix used in `parseInt()`, isn’t it? Let’s skip this then
created_at: 2025-05-22T21:12:58Z
author_association: OWNER
diff_hunk: @@ -58,28 +58,61 @@ export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
 
   return (
     <div className="flex-1 border-b last:border-b-0 border-gray-200 dark:border-gray-800 pb-3 mb-3">
-      <Label className="pt-4">No. {index + 1}</Label>
+      <div className="flex justify-between items-end">
+        <Label className="h-5 flex items-center">No. {index + 1}</Label>
+        {Array.isArray(answer) && (
+          <p className="px-1.5 h-5 flex items-center rounded-lg text-white text-xs bg-green-400/70 dark:bg-green-800/50 ">
+            <i className="bx bx-info-circle me-1" /> Pilih lebih dari satu
+          </p>
+        )}
+      </div>
       <Input
         type="hidden"
         name="answer"
-        value={answerState}
-        onChange={(e) => setAnswerState(parseInt(e.target.value))}
+        value={
+          Array.isArray(answerState)
+            ? answerState.join(",")
+            : answerState.toString()
+        }
       />
 
-      <ToggleGroup
-        className="flex gap-2 w-full mt-3"
-        type="single"
-        value={answerState.toString()}
-        onValueChange={(value) => {
-          setAnswerState(parseInt(value));
-          onEdit(value);
-        }}
-      >
-        {options(
-          nOptions,
-          "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
-        )}
-      </ToggleGroup>
+      {Array.isArray(answerState) ? (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="multiple"
+          value={answerState.map((e) => e.toString())}
+          onValueChange={(value) => {
+            if (value.length === 0) return;
+            setAnswerState(value.map((e) => parseInt(e)));
+            onEdit(value.map((e) => parseInt(e)).toString());
+          }}
+        >
+          {options(
+            nOptions,
+            "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
+          )}
+        </ToggleGroup>
+      ) : (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="single"
+          value={answerState.toString()}
+          onValueChange={(value) => {
+            if (value === "") return;
+
+            const numeric = parseInt(value, 10);
+            if (Number.isNaN(numeric)) return;
+
+            setAnswerState(parseInt(value));
path: app/(public)/quiz/[id]/components/options-editor.tsx
user: login: coderabbitai[bot]
body: @mfathonin While 10 is the default radix when the input doesn't have special prefixes like "0x", the behavior can vary depending on the string format. For example, in some older browsers, strings with leading zeros could be interpreted as octal.

That said, in your specific context where the values are coming from a toggle group and likely won't have prefixes, skipping the explicit radix is reasonable. It's just a general best practice but not critical here.

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#21
File: app/(public)/quiz/[id]/components/options-editor.tsx:106-106
Timestamp: 2025-05-22T21:13:30.040Z
Learning: When parsing toggle group values in the quiz application, parseInt() without an explicit radix parameter is acceptable as the values are simple numbers without prefixes.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@mfathonin While 10 is the default radix when the input doesn't have special prefixes like "0x", the behavior can vary depending on the string format. For example, in some older browsers, strings with leading zeros could be interpreted as octal.

That said, in your specific context where the values are coming from a toggle group and likely won't have prefixes, skipping the explicit radix is reasonable. It's just a general best practice but not critical here.

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: mfathonin
PR: mfathonin/prodigi-dashboard#21
File: app/(public)/quiz/[id]/components/options-editor.tsx:106-106
Timestamp: 2025-05-22T21:13:30.040Z
Learning: When parsing toggle group values in the quiz application, parseInt() without an explicit radix parameter is acceptable as the values are simple numbers without prefixes.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T21:13:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,9 +126,41 @@ export const decreasePoints = async (formData: FormData) => {
 export const updateAnswerSheet = async (formData: FormData) => {
   const answerSheetId = formData.get("answerSheetId") as string;
   const counts = parseInt(formData.get("counts") as string);
-  const answers = formData
-    .getAll("answer")
-    .map((answer) => parseInt(answer as string));
+
+  let answers: (number | number[])[] = [];
+
+  try {
+    // First try to parse from the "answers" JSON field
+    const answersJson = formData.get("answers");
+    if (answersJson) {
+      const parsed = JSON.parse(answersJson as string);
+      if (Array.isArray(parsed)) {
+        answers = parsed;
+      }
+    }
+
+    // If no answers from JSON, try individual answer fields
+    if (answers.length === 0) {
+      answers = formData.getAll("answer").map((answerStr) => {
+        if (typeof answerStr !== "string") return 0;
+
+        if (answerStr.includes(",")) {
+          // Handle multi-answer case
+          return answerStr
+            .split(",")
+            .map((num) => parseInt(num.trim()))
+            .filter((num) => !isNaN(num));
+        } else {
+          // Handle single answer case
+          const num = parseInt(answerStr);
+          return isNaN(num) ? 0 : num;
path: app/(public)/quiz/[id]/components/handler.ts
user: login: mfathonin
body: updated in 0beeb8e
created_at: 2025-05-22T21:13:43Z
author_association: OWNER
diff_hunk: @@ -58,28 +58,52 @@ export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
 
   return (
     <div className="flex-1 border-b last:border-b-0 border-gray-200 dark:border-gray-800 pb-3 mb-3">
-      <Label className="pt-4">No. {index + 1}</Label>
+      <div className="flex justify-between items-end">
+        <Label className="h-5 flex items-center">No. {index + 1}</Label>
+        {Array.isArray(answer) && (
+          <p className="px-1.5 h-5 flex items-center rounded-lg text-white text-xs bg-green-400/70 dark:bg-green-800/50 ">
+            <i className="bx bx-info-circle me-1" /> Pilih lebih dari satu
+          </p>
+        )}
+      </div>
       <Input
         type="hidden"
         name="answer"
-        value={answerState}
-        onChange={(e) => setAnswerState(parseInt(e.target.value))}
+        value={answerState.toString() + (Array.isArray(answer) ? "," : "")}
       />
 
-      <ToggleGroup
-        className="flex gap-2 w-full mt-3"
-        type="single"
-        value={answerState.toString()}
-        onValueChange={(value) => {
-          setAnswerState(parseInt(value));
-          onEdit(value);
-        }}
-      >
-        {options(
-          nOptions,
-          "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
-        )}
-      </ToggleGroup>
+      {Array.isArray(answerState) ? (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="multiple"
+          value={answerState.map((e) => e.toString())}
+          onValueChange={(value) => {
+            if (value.length === 0) return;
+            setAnswerState(value.map((e) => parseInt(e)));
+            onEdit(value.map((e) => parseInt(e)).toString());
+          }}
+        >
+          {options(
+            nOptions,
+            "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
+          )}
+        </ToggleGroup>
+      ) : (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="single"
+          value={answerState.toString()}
+          onValueChange={(value) => {
+            setAnswerState(parseInt(value));
+            onEdit(value);
+          }}
path: app/(public)/quiz/[id]/components/options-editor.tsx
user: login: mfathonin
body: updated in 0beeb8e
created_at: 2025-05-22T21:13:48Z
author_association: OWNER
diff_hunk: @@ -126,9 +126,41 @@ export const decreasePoints = async (formData: FormData) => {
 export const updateAnswerSheet = async (formData: FormData) => {
   const answerSheetId = formData.get("answerSheetId") as string;
   const counts = parseInt(formData.get("counts") as string);
-  const answers = formData
-    .getAll("answer")
-    .map((answer) => parseInt(answer as string));
+
+  let answers: (number | number[])[] = [];
+
+  try {
+    // First try to parse from the "answers" JSON field
+    const answersJson = formData.get("answers");
+    if (answersJson) {
+      const parsed = JSON.parse(answersJson as string);
+      if (Array.isArray(parsed)) {
+        answers = parsed;
+      }
+    }
+
+    // If no answers from JSON, try individual answer fields
+    if (answers.length === 0) {
+      answers = formData.getAll("answer").map((answerStr) => {
+        if (typeof answerStr !== "string") return 0;
+
+        if (answerStr.includes(",")) {
+          // Handle multi-answer case
+          return answerStr
+            .split(",")
+            .map((num) => parseInt(num.trim()))
+            .filter((num) => !isNaN(num));
+        } else {
+          // Handle single answer case
+          const num = parseInt(answerStr);
+          return isNaN(num) ? 0 : num;
path: app/(public)/quiz/[id]/components/handler.ts
user: login: coderabbitai[bot]
body: @mfathonin Thanks for implementing the suggested changes! The improved parsing with explicit radix and stricter validation will help prevent potential bugs with numeric inputs. 



🐰 ✅

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=Avoid%20silent%20bugs%20in%20parsing%20multi-answers!%20Always%20use%20radix%2010%20with%20parseInt%20and%20filter%20out%20invalid%20partial%20parses%20for%20clean%2C%20reliable%20integer%20arrays.%20Try%20%40coderabbitai%20%F0%9F%9B%A0%EF%B8%8F%F0%9F%94%8D&url=https%3A%2F%2Fgithub.com%2Fmfathonin%2Fprodigi-dashboard%2Fpull%2F21%23discussion_r2103335387)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@mfathonin Thanks for implementing the suggested changes! The improved parsing with explicit radix and stricter validation will help prevent potential bugs with numeric inputs. 



🐰 ✅

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=Avoid%20silent%20bugs%20in%20parsing%20multi-answers!%20Always%20use%20radix%2010%20with%20parseInt%20and%20filter%20out%20invalid%20partial%20parses%20for%20clean%2C%20reliable%20integer%20arrays.%20Try%20%40coderabbitai%20%F0%9F%9B%A0%EF%B8%8F%F0%9F%94%8D&url=https%3A%2F%2Fgithub.com%2Fmfathonin%2Fprodigi-dashboard%2Fpull%2F21%23discussion_r2103335387)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T21:14:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,28 +58,52 @@ export const OptionsEditor = ({ data, index, onEdit }: OptionsEditorProps) => {
 
   return (
     <div className="flex-1 border-b last:border-b-0 border-gray-200 dark:border-gray-800 pb-3 mb-3">
-      <Label className="pt-4">No. {index + 1}</Label>
+      <div className="flex justify-between items-end">
+        <Label className="h-5 flex items-center">No. {index + 1}</Label>
+        {Array.isArray(answer) && (
+          <p className="px-1.5 h-5 flex items-center rounded-lg text-white text-xs bg-green-400/70 dark:bg-green-800/50 ">
+            <i className="bx bx-info-circle me-1" /> Pilih lebih dari satu
+          </p>
+        )}
+      </div>
       <Input
         type="hidden"
         name="answer"
-        value={answerState}
-        onChange={(e) => setAnswerState(parseInt(e.target.value))}
+        value={answerState.toString() + (Array.isArray(answer) ? "," : "")}
       />
 
-      <ToggleGroup
-        className="flex gap-2 w-full mt-3"
-        type="single"
-        value={answerState.toString()}
-        onValueChange={(value) => {
-          setAnswerState(parseInt(value));
-          onEdit(value);
-        }}
-      >
-        {options(
-          nOptions,
-          "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
-        )}
-      </ToggleGroup>
+      {Array.isArray(answerState) ? (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="multiple"
+          value={answerState.map((e) => e.toString())}
+          onValueChange={(value) => {
+            if (value.length === 0) return;
+            setAnswerState(value.map((e) => parseInt(e)));
+            onEdit(value.map((e) => parseInt(e)).toString());
+          }}
+        >
+          {options(
+            nOptions,
+            "flex-1 data-[state=on]:bg-green-400 data-[state=on]:text-white dark:data-[state=on]:bg-green-800"
+          )}
+        </ToggleGroup>
+      ) : (
+        <ToggleGroup
+          className="flex gap-2 w-full mt-3"
+          type="single"
+          value={answerState.toString()}
+          onValueChange={(value) => {
+            setAnswerState(parseInt(value));
+            onEdit(value);
+          }}
path: app/(public)/quiz/[id]/components/options-editor.tsx
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

@mfathonin Thanks for implementing the suggested fix! I've verified that you've added the proper empty string check and parseInt radix parameter in your code. This will prevent potential NaN values and implicit octal parsing issues.



🐰✨ *Hoppy to see the code improvement!*

---

🏁 Script executed:
```
#!/bin/bash
# Check the commit mentioned by the user
git show 0beeb8e
```

Length of output: 3315

---



</details>

@mfathonin Thanks for implementing the suggested fixes! I can see you've added the empty string check and proper parseInt radix parameter in the options editor, along with some additional improvements in other files. These changes will help prevent NaN values and ensure more robust number parsing throughout your application.



🐰 *Happy coding!*

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippets for `app/(public)/quiz/[id]/components/options-editor.tsx`

---

#### From `lib/constants.ts` (lines 8-127)
```ts
export const constants = {
  CONTENT: {
    LABEL: {
      content: "URL",
      exercise: "Kuis",
      answer_sheet: "Lembar jawab",
    },
    OPTIONS_LABEL: ["A", "B", "C", "D", "E"],
  },
  // ... other constants omitted for brevity
};
```
- Provides the `OPTIONS_LABEL` array used in `OptionsEditor` to label options as "A", "B", "C", "D", "E".
- Contains other content labels and error messages, but the relevant part here is `OPTIONS_LABEL`.

---

#### From `components/ui/label.tsx` (line 26)
```tsx
Label
```
- The `Label` component is imported and used in `OptionsEditor` to display the question number label.
- No further details provided, but it is a UI component for labeling form elements.

---

#### From `components/ui/input.tsx` (line 25)
```tsx
Input
```
- The `Input` component is used in `OptionsEditor` as a hidden input to store the selected answer(s).
- No further details provided, but it is a UI input component.

---

#### From `components/ui/button.tsx` (line 60)
```tsx
Button
```
- The `Button` component is used in the `SaveButton` component to render the submit button.
- No further details provided, but it is a UI button component.

---

#### From `components/ui/toggle-group.tsx` (lines 61)
```tsx
ToggleGroupItem
ToggleGroup
```
- `ToggleGroup` and `ToggleGroupItem` are used extensively in `OptionsEditor` to render selectable options.
- `ToggleGroup` supports `type="single"` or `type="multiple"` for single or multiple selections.
- `ToggleGroupItem` represents each selectable option.
- No further implementation details provided here.

---

### Summary
The `OptionsEditor` component relies on UI components `Label`, `Input`, `Button`, `ToggleGroup`, and `ToggleGroupItem` for rendering form controls and toggles. It also uses the `OPTIONS_LABEL` constant from `lib/constants.ts` to label options "A" through "E". The toggle group supports both single and multiple selections depending on whether the answer is a number or an array of numbers. The `SaveButton` component uses the `Button` UI component and the React `useFormStatus` hook to disable the button while a form submission is pending.

</details>

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=Avoid%20NaN%20bugs%20by%20guarding%20empty%20deselection%20and%20always%20specifying%20radix%20in%20parseInt!%20Cleaner%2C%20safer%20state%20updates%20in%20your%20React%20handlers.%20Try%20%40coderabbitai%20for%20smart%20code%20fixes!%20%F0%9F%9B%A0%EF%B8%8F%E2%9C%A8&url=https%3A%2F%2Fgithub.com%2Fmfathonin%2Fprodigi-dashboard%2Fpull%2F21%23discussion_r2103335378)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

@mfathonin Thanks for implementing the suggested fix! I've verified that you've added the proper empty string check and parseInt radix parameter in your code. This will prevent potential NaN values and implicit octal parsing issues.



🐰✨ *Hoppy to see the code improvement!*

---

🏁 Script executed:
```
#!/bin/bash
# Check the commit mentioned by the user
git show 0beeb8e
```

Length of output: 3315

---



</details>

@mfathonin Thanks for implementing the suggested fixes! I can see you've added the empty string check and proper parseInt radix parameter in the options editor, along with some additional improvements in other files. These changes will help prevent NaN values and ensure more robust number parsing throughout your application.



🐰 *Happy coding!*

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippets for `app/(public)/quiz/[id]/components/options-editor.tsx`

---

#### From `lib/constants.ts` (lines 8-127)
```ts
export const constants = {
  CONTENT: {
    LABEL: {
      content: "URL",
      exercise: "Kuis",
      answer_sheet: "Lembar jawab",
    },
    OPTIONS_LABEL: ["A", "B", "C", "D", "E"],
  },
  // ... other constants omitted for brevity
};
```
- Provides the `OPTIONS_LABEL` array used in `OptionsEditor` to label options as "A", "B", "C", "D", "E".
- Contains other content labels and error messages, but the relevant part here is `OPTIONS_LABEL`.

---

#### From `components/ui/label.tsx` (line 26)
```tsx
Label
```
- The `Label` component is imported and used in `OptionsEditor` to display the question number label.
- No further details provided, but it is a UI component for labeling form elements.

---

#### From `components/ui/input.tsx` (line 25)
```tsx
Input
```
- The `Input` component is used in `OptionsEditor` as a hidden input to store the selected answer(s).
- No further details provided, but it is a UI input component.

---

#### From `components/ui/button.tsx` (line 60)
```tsx
Button
```
- The `Button` component is used in the `SaveButton` component to render the submit button.
- No further details provided, but it is a UI button component.

---

#### From `components/ui/toggle-group.tsx` (lines 61)
```tsx
ToggleGroupItem
ToggleGroup
```
- `ToggleGroup` and `ToggleGroupItem` are used extensively in `OptionsEditor` to render selectable options.
- `ToggleGroup` supports `type="single"` or `type="multiple"` for single or multiple selections.
- `ToggleGroupItem` represents each selectable option.
- No further implementation details provided here.

---

### Summary
The `OptionsEditor` component relies on UI components `Label`, `Input`, `Button`, `ToggleGroup`, and `ToggleGroupItem` for rendering form controls and toggles. It also uses the `OPTIONS_LABEL` constant from `lib/constants.ts` to label options "A" through "E". The toggle group supports both single and multiple selections depending on whether the answer is a number or an array of numbers. The `SaveButton` component uses the `Button` UI component and the React `useFormStatus` hook to disable the button while a form submission is pending.

</details>

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=Avoid%20NaN%20bugs%20by%20guarding%20empty%20deselection%20and%20always%20specifying%20radix%20in%20parseInt!%20Cleaner%2C%20safer%20state%20updates%20in%20your%20React%20handlers.%20Try%20%40coderabbitai%20for%20smart%20code%20fixes!%20%F0%9F%9B%A0%EF%B8%8F%E2%9C%A8&url=https%3A%2F%2Fgithub.com%2Fmfathonin%2Fprodigi-dashboard%2Fpull%2F21%23discussion_r2103335378)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-22T21:14:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: netlify[bot]
created_at: 2025-05-22T20:14:21Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *mpp-hub* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 0beeb8efdd94cf78ccecf55ce8fd1dc86a7d9fe7 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/mpp-hub/deploys/682f920175c38a0009d18edc |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-21--mpp-hub.netlify.app](https://deploy-preview-21--mpp-hub.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTIxLS1tcHAtaHViLm5ldGxpZnkuYXBwIn0.M8nUeeJw1ke7hA_iadjnbBaoGxz4lI581lEbmU9J_BI)<br /><br />_Use your smartphone camera to open QR code link._</details> |
|<span aria-hidden="true">![Lighthouse](https://app.netlify.com/public/external-usage/git-comments/lighthouse-sm.png)</span><br />Lighthouse | 1 paths audited<br />**Performance**: 90<br />**Accessibility**: 92<br />**Best Practices**: 100<br />**SEO**: 92<br />**PWA**: -<br />[View the detailed breakdown and full score reports](https://app.netlify.com/projects/mpp-hub/deploys/682f920175c38a0009d18edc) |
---
<!-- [mpp-hub Preview](https://deploy-preview-21--mpp-hub.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/mpp-hub/configuration/notifications#deploy-notifications)._
author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-05-22T20:14:23Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

This set of changes refactors the quiz feature to support both single and multiple answers per question, renaming the "quiz" content type to "answer_sheet" and introducing "exercise" as an additional type. The changes span UI components, repositories, models, API routes, database migrations, and constants to accommodate the new answer formats and content types.

## Changes

| File(s) | Change Summary |
|---------|----------------|
| `app/(protected)/(books)/[bookId]/components/content-card.tsx`, `lib/constants.ts`, `components/ui/badge.tsx` | Replaced "quiz" content type/label with "answer_sheet" and added "exercise"; defaulted undefined types to "content"; updated badge variants and label constants accordingly. |
| `app/(protected)/(books)/[bookId]/components/handler.ts`, `repositories/contents.ts`, `models/contents.ts` | Renamed and refactored all quiz-related logic to "answer_sheet"; updated validation, content type checks, and repository methods to use "answer_sheet" and "exercise". |
| `app/(protected)/(books)/[bookId]/dialog/content-form/content-form.tsx` | Updated tab labels and values from "quiz" ("Kuis") to "answer_sheet" ("Lembar Jawab") in the content form UI. |
| `app/(public)/api/v2/quiz/[[...uuid]]/route.ts` | Expanded quiz submission schema to support single and multiple answers; updated validation, scoring logic, and GET response to reflect answer modes. |
| `app/(public)/quiz/[id]/components/forms.tsx`, `app/(public)/quiz/[id]/components/options-editor.tsx`, `app/(public)/quiz/[id]/components/question-config.tsx` | Introduced support for multi-answer questions: added `MultiAnswerForm`, updated answer state/types, and modified UI to allow toggling between single/multiple answers per question. |
| `app/(public)/quiz/[id]/components/handler.ts`, `repositories/answer-sheets.ts` | Updated answer parsing and validation logic to handle arrays of numbers or arrays of number arrays; enhanced validation to ensure answer integrity for both formats. |
| `app/(public)/quiz/[id]/handler.ts` | Changed question config to use an `answers` array (supporting single/multi-answer) instead of a single `answer` value; updated default and retrieval logic. |
| `app/(public)/quiz/[id]/page.tsx` | Adjusted layout for question configuration section (spacing, sizing). |
| `components/ui/switch.tsx` | Added a new `Switch` UI component based on Radix UI for toggling multi-answer state. |
| `models/supaservice.types.ts` | Changed `answers` field in `answer_sheets` table to `Json` type; updated enums and function typings to include new content types; introduced a helper function for down migrations. |
| `package.json` | Added `@radix-ui/react-switch` dependency; updated `supa:start` script to echo Supabase URL. |
| `supabase/migrations/20250420053838_change_answers_to_jsonb.sql`, `supabase/down_migrations/20250420053838_change_answers_to_jsonb_down.sql` | Migrated `answers` column in `answer_sheets` from integer array to JSONB (and vice versa for down migration); added helper function for conversion. |
| `supabase/migrations/20250420071140_new_content_type_enum.sql`, `supabase/down_migrations/20250420071140_new_content_type_enum_down.sql` | Migrated `contents.type` column to support new enum values ("answer_sheet", "exercise"); handled data and enum transitions for up/down migrations. |
| `tsconfig.json` | Reformatted arrays for readability; added `"downlevelIteration": true` compiler option. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
  participant User
  participant UI (Quiz/AnswerSheet)
  participant API
  participant Repo
  participant DB

  User->>UI (Quiz/AnswerSheet): Toggle multi-answer, edit answers
  UI (Quiz/AnswerSheet)->>API: Submit answers (number or array)
  API->>Repo: Validate & update answers
  Repo->>DB: Store answers as JSONB
  DB-->>Repo: Confirmation
  Repo-->>API: Success/Failure
  API-->>UI (Quiz/AnswerSheet): Response (score, modes, etc.)
  UI (Quiz/AnswerSheet)-->>User: Display result/feedback
```

## Possibly related PRs

- [mfathonin/prodigi-dashboard#19](https://github.com/mfathonin/prodigi-dashboard/pull/19): Refactors "quiz" to "answer_sheet" and "exercise" across content types and repositories, mirroring the main refactor in this PR.
- [mfathonin/prodigi-dashboard#3](https://github.com/mfathonin/prodigi-dashboard/pull/3): Updates content card handling for new content types and click logic, closely connected to similar changes in this PR.
- [mfathonin/prodigi-dashboard#6](https://github.com/mfathonin/prodigi-dashboard/pull/6): Introduces quiz answer submission endpoint and related repository methods, related to answer sheet handling but focusing on API functionality rather than content type refactoring.

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!NOTE]
> <details>
> <summary>⚡️ AI Code Reviews for VS Code, Cursor, Windsurf</summary>
> 
> CodeRabbit now has a plugin for VS Code, Cursor and Windsurf. This brings AI code reviews directly in the code editor. Each commit is reviewed immediately, finding bugs before the PR is raised. Seamless context handoff to your AI code agent ensures that you can easily incorporate review feedback.
> Learn more [here](http://coderabbit.ai/ide).
> 
> </details>

---

> [!NOTE]
> <details>
> <summary>⚡️ Faster reviews with caching</summary>
> 
> CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 30th. To opt out, configure `Review - Disable Cache` at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the `Data Retention` setting under your Organization Settings.
> Enjoy the performance boost—your workflow just got faster.
> 
> </details>

<!-- announcements_end -->
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=mfathonin/prodigi-dashboard&utm_content=21):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR