UNPKG

analytica-frontend-lib

Version:

Repositório público dos componentes utilizados nas plataformas da Analytica Ensino

1 lines 81.5 kB
{"version":3,"sources":["../src/components/Quiz/QuizContent.tsx","../src/utils/stringUtils.ts"],"sourcesContent":["import {\n Fragment,\n forwardRef,\n MouseEvent,\n ReactNode,\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { ANSWER_STATUS, useQuizStore } from './useQuizStore';\nimport { QuizVariant } from './Quiz.types';\nimport { TrueFalseEnum } from '../../enums/Quiz';\nimport {\n prependLetterToHtml,\n getTrueOrFalseOptionState,\n shuffleWithSeed,\n} from './Quiz.utils';\nimport { cn } from '../../utils/utils';\nimport { stripHtmlTags } from '../../utils/stringUtils';\nimport Select, {\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '../Select/Select';\nimport TextArea from '../TextArea/TextArea';\nimport { AlternativesList } from '../Alternative/Alternative';\nimport { OptionStatus } from '../../enums/Options';\nimport { MultipleChoiceList } from '../MultipleChoice/MultipleChoice';\nimport Badge from '../Badge/Badge';\nimport { CheckCircleIcon } from '@phosphor-icons/react/dist/csr/CheckCircle';\nimport { XCircleIcon } from '@phosphor-icons/react/dist/csr/XCircle';\nimport ImageQuestion from '../../assets/img/mock-image-question.png';\nimport Text from '../Text/Text';\nimport { HtmlMathRenderer } from '../HtmlMathRenderer';\nexport const getStatusBadge = (status?: OptionStatus) => {\n switch (status) {\n case OptionStatus.CORRECT:\n return (\n <Badge variant=\"solid\" action=\"success\" iconLeft={<CheckCircleIcon />}>\n Resposta correta\n </Badge>\n );\n case OptionStatus.INCORRECT:\n return (\n <Badge variant=\"solid\" action=\"error\" iconLeft={<XCircleIcon />}>\n Resposta incorreta\n </Badge>\n );\n default:\n return null;\n }\n};\n\nexport const getStatusStyles = (variantCorrect?: OptionStatus) => {\n switch (variantCorrect) {\n case OptionStatus.CORRECT:\n return 'bg-success-background border-success-300';\n case OptionStatus.INCORRECT:\n return 'bg-error-background border-error-300';\n default:\n return '';\n }\n};\n\n/**\n * Normalizes parsed answer data to Record<string, string> format.\n * Handles legacy array format [{ optionId, selectedValue }] by converting to { [optionId]: selectedValue }.\n */\nconst normalizeAnswerData = (parsed: unknown): Record<string, string> => {\n // Handle legacy array format: [{ optionId: string, selectedValue: string }]\n if (Array.isArray(parsed)) {\n const result: Record<string, string> = {};\n for (const item of parsed) {\n if (\n item &&\n typeof item === 'object' &&\n 'optionId' in item &&\n 'selectedValue' in item\n ) {\n result[item.optionId as string] = item.selectedValue as string;\n }\n }\n return result;\n }\n\n // Handle expected object format: { [optionId]: selectedValue }\n if (parsed && typeof parsed === 'object') {\n return parsed as Record<string, string>;\n }\n\n return {};\n};\n\n/**\n * Parses JSON answers from stored answer string.\n * In result mode, uses persisted results. Otherwise, uses current draft answers.\n * Normalizes legacy array format to expected Record<string, string> map.\n */\nconst parseStoredAnswers = (\n variant: QuizVariant,\n resultAnswer: string | null | undefined,\n currentAnswer: string | null | undefined\n): Record<string, string> => {\n if (variant === QuizVariant.RESULT) {\n if (!resultAnswer) return {};\n try {\n const parsed = JSON.parse(resultAnswer);\n return normalizeAnswerData(parsed);\n } catch {\n return {};\n }\n }\n if (currentAnswer) {\n try {\n const parsed = JSON.parse(currentAnswer);\n return normalizeAnswerData(parsed);\n } catch {\n return {};\n }\n }\n return {};\n};\n\n/**\n * Converts isCorrect boolean to status variant string.\n * Returns undefined for null/unanswered to keep neutral styling.\n */\nconst getAnswerStatus = (\n isCorrect: boolean | null\n): OptionStatus | undefined => {\n if (isCorrect === true) return OptionStatus.CORRECT;\n if (isCorrect === false) return OptionStatus.INCORRECT;\n return undefined;\n};\n\ninterface QuizVariantInterface {\n paddingBottom?: string;\n}\n\nconst QuizSubTitle = forwardRef<HTMLDivElement, { subTitle: string }>(\n ({ subTitle, ...props }, ref) => {\n return (\n <div className=\"px-4 pb-2 pt-6\" {...props} ref={ref}>\n <p className=\"font-bold text-lg text-text-950\">{subTitle}</p>\n </div>\n );\n }\n);\n\nconst QuizContainer = forwardRef<\n HTMLDivElement,\n { children: ReactNode; className?: string }\n>(({ children, className, ...props }, ref) => {\n return (\n <div\n ref={ref}\n className={cn(\n 'bg-background rounded-t-xl px-4 pt-4 pb-[80px] h-auto flex flex-col gap-4 mb-auto',\n className\n )}\n {...props}\n >\n {children}\n </div>\n );\n});\n\nconst QuizAlternative = ({ paddingBottom }: QuizVariantInterface) => {\n const {\n getCurrentQuestion,\n selectAnswer,\n getQuestionResultByQuestionId,\n getCurrentAnswer,\n variant,\n } = useQuizStore();\n const currentQuestion = getCurrentQuestion();\n const currentQuestionResult = getQuestionResultByQuestionId(\n currentQuestion?.id || ''\n );\n\n const currentAnswer = getCurrentAnswer();\n const alternatives = currentQuestion?.options?.map((option) => {\n let status: OptionStatus = OptionStatus.NEUTRAL;\n if (variant === QuizVariant.RESULT) {\n const isCorrectOption =\n currentQuestionResult?.options?.find((op) => op.id === option.id)\n ?.isCorrect || false;\n\n const isSelected = currentQuestionResult?.selectedOptions.some(\n (selectedOption) => selectedOption.optionId === option.id\n );\n\n // Only show correct/incorrect status if answer is not pending evaluation\n const shouldShowCorrectAnswers =\n currentQuestionResult?.answerStatus !==\n ANSWER_STATUS.PENDENTE_AVALIACAO &&\n currentQuestionResult?.answerStatus !== ANSWER_STATUS.NAO_RESPONDIDO;\n\n if (shouldShowCorrectAnswers) {\n if (isCorrectOption) {\n status = OptionStatus.CORRECT;\n } else if (isSelected && !isCorrectOption) {\n status = OptionStatus.INCORRECT;\n } else {\n status = OptionStatus.NEUTRAL;\n }\n } else {\n // When pending evaluation, show all options as neutral\n status = OptionStatus.NEUTRAL;\n }\n }\n\n return {\n label: option.option,\n value: option.id,\n status: status,\n };\n });\n\n if (!alternatives)\n return (\n <div>\n <p>Não há Alternativas</p>\n </div>\n );\n\n return (\n <>\n <QuizSubTitle subTitle=\"Alternativas\" />\n\n <QuizContainer className={cn('', paddingBottom)}>\n <div className=\"space-y-4\">\n <AlternativesList\n mode={variant === QuizVariant.DEFAULT ? 'interactive' : 'readonly'}\n key={`question-${currentQuestion?.id || '1'}`}\n name={`question-${currentQuestion?.id || '1'}`}\n layout=\"compact\"\n alternatives={alternatives}\n value={\n variant === QuizVariant.RESULT\n ? currentQuestionResult?.selectedOptions[0]?.optionId || ''\n : currentAnswer?.optionId || ''\n }\n selectedValue={\n variant === QuizVariant.RESULT\n ? currentQuestionResult?.selectedOptions[0]?.optionId || ''\n : currentAnswer?.optionId || ''\n }\n onValueChange={(value) => {\n if (currentQuestion) {\n selectAnswer(currentQuestion.id, value);\n }\n }}\n />\n </div>\n </QuizContainer>\n </>\n );\n};\n\nconst QuizMultipleChoice = ({ paddingBottom }: QuizVariantInterface) => {\n const {\n getCurrentQuestion,\n selectMultipleAnswer,\n getQuestionResultByQuestionId,\n variant,\n } = useQuizStore();\n const currentQuestion = getCurrentQuestion();\n // Derive the current question's answers from the stable `userAnswers` slice\n // instead of calling getAllCurrentAnswer(), which allocates a fresh array on\n // every render. That fresh identity propagated through the memos below and,\n // combined with the store→prop→local-state mirror in MultipleChoiceList,\n // caused a \"Maximum update depth exceeded\" loop on /questionario.\n const userAnswers = useQuizStore((state) => state.userAnswers);\n const allCurrentAnswers = useMemo(\n () =>\n currentQuestion\n ? userAnswers.filter(\n (answer) => answer.questionId === currentQuestion.id\n )\n : [],\n [userAnswers, currentQuestion]\n );\n const currentQuestionResult = getQuestionResultByQuestionId(\n currentQuestion?.id || ''\n );\n // Use ref to track previous values and prevent unnecessary updates\n const prevSelectedValuesRef = useRef<string[]>([]);\n const prevQuestionIdRef = useRef<string>('');\n\n // Memoize the answer IDs to prevent unnecessary re-renders\n // For MULTIPLA_ESCOLHA, we now have a single answer entry with selectedOptionIds array\n const allCurrentAnswerIds = useMemo(() => {\n if (allCurrentAnswers && allCurrentAnswers.length > 0) {\n // Prefer any row already using the new format\n const answerWithSelectedOptionIds = allCurrentAnswers.find((answer) =>\n Array.isArray(answer.selectedOptionIds)\n );\n if (answerWithSelectedOptionIds) {\n return answerWithSelectedOptionIds.selectedOptionIds ?? [];\n }\n // Fallback to old format (multiple entries with optionId)\n return allCurrentAnswers.map((answer) => answer.optionId);\n }\n return [];\n }, [allCurrentAnswers]);\n\n // Memoize the selected values to prevent infinite loops\n const selectedValues = useMemo(() => {\n return allCurrentAnswerIds?.filter((id): id is string => id !== null) || [];\n }, [allCurrentAnswerIds]);\n\n // Only update selectedValues if they actually changed or question changed\n const stableSelectedValues = useMemo(() => {\n // In result mode, always use selectedOptions from the question result\n if (variant === QuizVariant.RESULT) {\n return (\n currentQuestionResult?.selectedOptions?.map((op) => op.optionId) || []\n );\n }\n\n const currentQuestionId = currentQuestion?.id || '';\n const hasQuestionChanged = prevQuestionIdRef.current !== currentQuestionId;\n\n if (hasQuestionChanged) {\n prevQuestionIdRef.current = currentQuestionId;\n prevSelectedValuesRef.current = selectedValues;\n return selectedValues;\n }\n\n // Only update if values actually changed\n const hasValuesChanged =\n JSON.stringify(prevSelectedValuesRef.current) !==\n JSON.stringify(selectedValues);\n if (hasValuesChanged) {\n prevSelectedValuesRef.current = selectedValues;\n return selectedValues;\n }\n\n return prevSelectedValuesRef.current;\n }, [\n selectedValues,\n currentQuestion?.id,\n variant,\n currentQuestionResult?.selectedOptions,\n ]);\n\n // Memoize the callback to prevent unnecessary re-renders\n const handleSelectedValues = useCallback(\n (values: string[]) => {\n if (currentQuestion) {\n selectMultipleAnswer(currentQuestion.id, values);\n }\n },\n [currentQuestion, selectMultipleAnswer]\n );\n\n // Create a stable key to force re-mount when question changes\n const questionKey = useMemo(\n () => `question-${currentQuestion?.id || '1'}`,\n [currentQuestion?.id]\n );\n const choices = currentQuestion?.options?.map((option) => {\n let status: OptionStatus = OptionStatus.NEUTRAL;\n\n if (variant === QuizVariant.RESULT) {\n const isCorrectOption =\n currentQuestionResult?.options?.find((op) => op.id === option.id)\n ?.isCorrect || false;\n\n const isSelected = currentQuestionResult?.selectedOptions?.some(\n (op) => op.optionId === option.id\n );\n\n // Only show correct/incorrect status if answer is not pending evaluation\n const shouldShowCorrectAnswers =\n currentQuestionResult?.answerStatus !==\n ANSWER_STATUS.PENDENTE_AVALIACAO &&\n currentQuestionResult?.answerStatus !== ANSWER_STATUS.NAO_RESPONDIDO;\n\n if (shouldShowCorrectAnswers) {\n if (isCorrectOption) {\n status = OptionStatus.CORRECT;\n } else if (isSelected && !isCorrectOption) {\n status = OptionStatus.INCORRECT;\n } else {\n status = OptionStatus.NEUTRAL;\n }\n } else {\n // When pending evaluation, show all options as neutral\n status = OptionStatus.NEUTRAL;\n }\n }\n\n return {\n label: option.option,\n value: option.id,\n status: status,\n };\n });\n\n if (!choices)\n return (\n <div>\n <p>Não há Escolhas Multiplas</p>\n </div>\n );\n return (\n <>\n <QuizSubTitle subTitle=\"Alternativas\" />\n\n <QuizContainer className={cn('', paddingBottom)}>\n <div className=\"space-y-4\">\n <MultipleChoiceList\n choices={choices}\n key={questionKey}\n name={questionKey}\n selectedValues={stableSelectedValues}\n onHandleSelectedValues={handleSelectedValues}\n mode={variant === QuizVariant.DEFAULT ? 'interactive' : 'readonly'}\n />\n </div>\n </QuizContainer>\n </>\n );\n};\n\nconst QuizDissertative = ({ paddingBottom }: QuizVariantInterface) => {\n const {\n getCurrentQuestion,\n getCurrentAnswer,\n selectDissertativeAnswer,\n getQuestionResultByQuestionId,\n variant,\n getDissertativeCharLimit,\n } = useQuizStore();\n\n const currentQuestion = getCurrentQuestion();\n const currentQuestionResult = getQuestionResultByQuestionId(\n currentQuestion?.id || ''\n );\n\n const currentAnswer = getCurrentAnswer();\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const charLimit = getDissertativeCharLimit();\n\n const handleAnswerChange = (value: string) => {\n if (currentQuestion) {\n selectDissertativeAnswer(currentQuestion.id, value);\n }\n };\n\n // Auto-resize function\n const adjustTextareaHeight = useCallback(() => {\n if (textareaRef.current) {\n textareaRef.current.style.height = 'auto';\n const scrollHeight = textareaRef.current.scrollHeight;\n const minHeight = 120; // 120px minimum height\n const maxHeight = 400; // 400px maximum height\n const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);\n textareaRef.current.style.height = `${newHeight}px`;\n }\n }, []);\n\n // Adjust height when currentAnswer changes\n useEffect(() => {\n adjustTextareaHeight();\n }, [currentAnswer, adjustTextareaHeight]);\n\n if (!currentQuestion) {\n return (\n <div className=\"space-y-4\">\n <p className=\"text-text-600 text-md\">Nenhuma questão disponível</p>\n </div>\n );\n }\n\n const localAnswer =\n (variant == 'result'\n ? currentQuestionResult?.answer\n : currentAnswer?.answer) || '';\n return (\n <>\n <QuizSubTitle subTitle=\"Resposta\" />\n\n <QuizContainer className={cn(variant != 'result' && paddingBottom)}>\n <div className=\"space-y-4 max-h-[600px] overflow-y-auto\">\n {variant === QuizVariant.DEFAULT ? (\n <div className=\"space-y-4\">\n <TextArea\n ref={textareaRef}\n placeholder=\"Escreva sua resposta\"\n value={localAnswer}\n onChange={(e) => handleAnswerChange(e.target.value)}\n rows={4}\n className=\"min-h-[120px] max-h-[400px] resize-none overflow-y-auto\"\n maxLength={charLimit}\n showCharacterCount={!!charLimit}\n />\n </div>\n ) : (\n <div className=\"space-y-4\">\n <p className=\"text-text-600 text-md whitespace-pre-wrap\">\n {localAnswer || 'Nenhuma resposta fornecida'}\n </p>\n </div>\n )}\n </div>\n </QuizContainer>\n\n {variant === QuizVariant.RESULT &&\n currentQuestionResult?.teacherFeedback && (\n <>\n <QuizSubTitle subTitle=\"Observação do professor\" />\n\n <QuizContainer className={cn('', paddingBottom)}>\n <p className=\"text-text-600 text-md whitespace-pre-wrap\">\n {currentQuestionResult?.teacherFeedback}\n </p>\n </QuizContainer>\n </>\n )}\n </>\n );\n};\n\nconst QuizTrueOrFalse = ({ paddingBottom }: QuizVariantInterface) => {\n const {\n variant,\n getCurrentQuestion,\n getCurrentAnswer,\n selectDissertativeAnswer,\n getQuestionResultByQuestionId,\n getUserAnswerByQuestionId,\n } = useQuizStore();\n\n const currentQuestion = getCurrentQuestion();\n const currentAnswer = getCurrentAnswer();\n const currentQuestionResult = getQuestionResultByQuestionId(\n currentQuestion?.id || ''\n );\n\n // Get the options - prefer persisted result options in result mode, fallback to live question\n const options = useMemo(() => {\n if (variant === QuizVariant.RESULT && currentQuestionResult?.options) {\n return currentQuestionResult.options;\n }\n return currentQuestion?.options || [];\n }, [variant, currentQuestionResult?.options, currentQuestion?.options]);\n\n // Parse stored answers from JSON string, handling both object and legacy array formats\n const parsedAnswers: Record<string, string> = useMemo(() => {\n return parseStoredAnswers(\n variant,\n currentQuestionResult?.answer,\n currentAnswer?.answer\n );\n }, [variant, currentQuestionResult?.answer, currentAnswer?.answer]);\n\n // Local state to track answers: { \"optionId\": \"V\" | \"F\" }\n const [localAnswers, setLocalAnswers] =\n useState<Record<string, string>>(parsedAnswers);\n\n // Sync local answers when question changes or parsed answers update\n useEffect(() => {\n setLocalAnswers(parsedAnswers);\n }, [parsedAnswers, currentQuestion?.id]);\n\n // Handle select change for an option\n const handleSelectChange = (optionId: string, value: string) => {\n if (!currentQuestion) return;\n\n // Get the LATEST answers directly from the store (not from closure)\n // This is critical to avoid stale closure issues when user selects options quickly\n const latestStoreAnswer = getUserAnswerByQuestionId(currentQuestion.id);\n let baseAnswers: Record<string, string> = {};\n if (latestStoreAnswer?.answer) {\n try {\n const parsed = JSON.parse(latestStoreAnswer.answer);\n baseAnswers = normalizeAnswerData(parsed);\n } catch {\n // Invalid JSON, start fresh\n }\n }\n\n // Merge: store answers + local state + new selection\n const mergedAnswers = {\n ...baseAnswers,\n ...localAnswers,\n [optionId]: value,\n };\n setLocalAnswers(mergedAnswers);\n\n // Save to store as JSON string\n selectDissertativeAnswer(currentQuestion.id, JSON.stringify(mergedAnswers));\n };\n\n // Get the current selection for an option\n const getOptionSelection = (optionId: string): string | undefined => {\n return localAnswers[optionId];\n };\n\n const getLetterByIndex = (index: number) => String.fromCodePoint(97 + index); // 97 = 'a' in ASCII\n\n const isDefaultVariant = variant === QuizVariant.DEFAULT;\n\n // Check if we should show correct/incorrect status\n const shouldShowStatus =\n currentQuestionResult?.answerStatus !== ANSWER_STATUS.PENDENTE_AVALIACAO &&\n currentQuestionResult?.answerStatus !== ANSWER_STATUS.NAO_RESPONDIDO;\n\n if (options.length === 0) {\n return (\n <QuizContainer className={cn('', paddingBottom)}>\n <Text size=\"sm\" className=\"text-text-500 italic\">\n Nenhuma opção disponível para esta questão\n </Text>\n </QuizContainer>\n );\n }\n\n return (\n <>\n <QuizSubTitle subTitle=\"Alternativas\" />\n\n <QuizContainer className={cn('', paddingBottom)}>\n <div className=\"flex flex-col gap-3.5\">\n {options.map((option, index) => {\n // Use helper to compute all option state\n const {\n hasAnswered,\n studentAnswer,\n correctAnswer,\n isStudentCorrect,\n variantCorrect,\n } = getTrueOrFalseOptionState(\n option.id,\n variant,\n currentQuestionResult,\n localAnswers\n );\n\n const contentWithLetter = prependLetterToHtml(\n getLetterByIndex(index),\n option.option\n );\n\n return (\n <section\n key={option.id || `option-${index}`}\n className=\"flex flex-col gap-2\"\n >\n <div\n className={cn(\n 'w-full grid grid-cols-[1fr_auto] items-start gap-4 p-2 rounded-md border',\n !isDefaultVariant && shouldShowStatus && hasAnswered\n ? getStatusStyles(variantCorrect)\n : 'border-transparent'\n )}\n >\n <HtmlMathRenderer\n content={contentWithLetter}\n className=\"text-text-900 text-sm\"\n />\n\n {isDefaultVariant ? (\n <Select\n size=\"medium\"\n value={getOptionSelection(option.id)}\n onValueChange={(value) =>\n handleSelectChange(option.id, value)\n }\n >\n <SelectTrigger className=\"w-[120px]\">\n <SelectValue placeholder=\"Selecione\" />\n </SelectTrigger>\n\n <SelectContent>\n <SelectItem value={TrueFalseEnum.VERDADEIRO}>\n Verdadeiro\n </SelectItem>\n <SelectItem value={TrueFalseEnum.FALSO}>\n Falso\n </SelectItem>\n </SelectContent>\n </Select>\n ) : (\n shouldShowStatus &&\n hasAnswered && <div>{getStatusBadge(variantCorrect)}</div>\n )}\n </div>\n\n {!isDefaultVariant && shouldShowStatus && (\n <span className=\"flex flex-row gap-2 items-center flex-wrap\">\n {hasAnswered ? (\n <>\n <Text size=\"2xs\" className=\"text-text-800\">\n Resposta selecionada: {studentAnswer}\n </Text>\n {!isStudentCorrect && (\n <Text size=\"2xs\" className=\"text-text-800\">\n | Resposta correta: {correctAnswer}\n </Text>\n )}\n </>\n ) : (\n <Text size=\"2xs\" className=\"text-text-800\">\n Não respondida | Resposta correta: {correctAnswer}\n </Text>\n )}\n </span>\n )}\n </section>\n );\n })}\n </div>\n </QuizContainer>\n </>\n );\n};\n\ninterface UserAnswer {\n option: string;\n dotOption: string | null;\n correctOption: string;\n isCorrect: boolean | null;\n}\n\nconst QuizConnectDots = ({ paddingBottom }: QuizVariantInterface) => {\n const {\n variant,\n getCurrentQuestion,\n getCurrentAnswer,\n selectDissertativeAnswer,\n getQuestionResultByQuestionId,\n getUserAnswerByQuestionId,\n } = useQuizStore();\n\n const currentQuestion = getCurrentQuestion();\n const currentQuestionResult = getQuestionResultByQuestionId(\n currentQuestion?.id || ''\n );\n const currentAnswer = getCurrentAnswer();\n\n // Extract options from the current question\n // Each option has: id, option (left column), correctValue (right column)\n const questionOptions = useMemo(() => {\n if (!currentQuestion?.options) return [];\n return currentQuestion.options;\n }, [currentQuestion?.options]);\n\n // Build dotsOptions from correctValue of each option (right column values)\n // Shuffle them so they don't appear in the same order as the left column\n const dotsOptions = useMemo(() => {\n const dots = questionOptions\n .map((opt) => ({ label: opt.correctValue || '' }))\n .filter((opt) => opt.label !== '');\n // Use question ID as seed for consistent shuffle\n return shuffleWithSeed(dots, currentQuestion?.id || '');\n }, [questionOptions, currentQuestion?.id]);\n\n // Build options for display (left column with correct answer mapping)\n const options = useMemo(() => {\n return questionOptions.map((opt) => ({\n id: opt.id,\n label: opt.option,\n correctOption: opt.correctValue || '',\n }));\n }, [questionOptions]);\n\n // Parse stored matching answers\n // In result mode, read from matchingAnswers array, fallback to answer JSON\n // In default mode, read from currentAnswer.answer JSON string\n const parsedAnswers: Record<string, string> = useMemo(() => {\n if (\n variant === QuizVariant.RESULT &&\n currentQuestionResult?.matchingAnswers\n ) {\n // Convert matchingAnswers array to Record<optionId, selectedValue>\n const answers: Record<string, string> = {};\n for (const match of currentQuestionResult.matchingAnswers) {\n answers[match.optionId] = match.selectedValue;\n }\n return answers;\n }\n // Fallback to answer JSON string (for older/partially migrated submissions)\n return parseStoredAnswers(\n variant,\n currentQuestionResult?.answer,\n currentAnswer?.answer\n );\n }, [\n variant,\n currentQuestionResult?.matchingAnswers,\n currentQuestionResult?.answer,\n currentAnswer?.answer,\n ]);\n\n const [userAnswers, setUserAnswers] = useState<UserAnswer[]>([]);\n\n // Initialize userAnswers from options and stored answers\n useEffect(() => {\n if (options.length > 0) {\n setUserAnswers(\n options.map((option) => {\n const storedValue = parsedAnswers[option.id] || null;\n return {\n option: option.label,\n dotOption: storedValue,\n correctOption: option.correctOption,\n isCorrect: storedValue\n ? storedValue === option.correctOption\n : null,\n };\n })\n );\n }\n }, [options, parsedAnswers]);\n\n const handleSelectDot = (optionIndex: number, dotValue: string) => {\n const option = options[optionIndex];\n if (!option || !currentQuestion) return;\n\n // Update local state\n setUserAnswers((prev) => {\n const next = [...prev];\n next[optionIndex] = {\n option: option.label,\n dotOption: dotValue,\n correctOption: option.correctOption,\n isCorrect: dotValue ? dotValue === option.correctOption : null,\n };\n return next;\n });\n\n // Get the LATEST answers directly from the store (not from closure)\n const latestStoreAnswer = getUserAnswerByQuestionId(currentQuestion.id);\n let baseAnswers: Record<string, string> = {};\n if (latestStoreAnswer?.answer) {\n try {\n const parsed = JSON.parse(latestStoreAnswer.answer);\n baseAnswers = normalizeAnswerData(parsed);\n } catch {\n // Invalid JSON, start fresh\n }\n }\n\n // Persist to shared store as JSON\n const newAnswers = {\n ...baseAnswers,\n ...parsedAnswers,\n [option.id]: dotValue,\n };\n selectDissertativeAnswer(currentQuestion.id, JSON.stringify(newAnswers));\n };\n\n const getLetterByIndex = (index: number) => String.fromCodePoint(97 + index); // 'a', 'b', 'c'...\n\n const isDefaultVariant = variant === QuizVariant.DEFAULT;\n const assignedDots = new Set(\n userAnswers.map((a) => a.dotOption).filter(Boolean)\n );\n\n if (options.length === 0) {\n return (\n <QuizContainer className={cn('', paddingBottom)}>\n <Text size=\"sm\" className=\"text-text-500 italic\">\n Nenhuma opção de relacionamento disponível\n </Text>\n </QuizContainer>\n );\n }\n\n return (\n <>\n <QuizSubTitle subTitle=\"Alternativas\" />\n\n <QuizContainer className={cn('', paddingBottom)}>\n <div className=\"flex flex-col gap-3.5\">\n {options.map((option, index) => {\n const answer = userAnswers[index] || {\n option: option.label,\n dotOption: null,\n correctOption: option.correctOption,\n isCorrect: null,\n };\n const variantCorrect = getAnswerStatus(answer.isCorrect);\n return (\n <section key={option.id} className=\"flex flex-col gap-2\">\n <div\n className={cn(\n 'grid grid-cols-[1fr_auto] items-center gap-4 p-2 rounded-md',\n !isDefaultVariant && variantCorrect\n ? getStatusStyles(variantCorrect)\n : ''\n )}\n >\n <HtmlMathRenderer\n content={prependLetterToHtml(\n getLetterByIndex(index),\n option.label\n )}\n className=\"text-text-900 text-sm\"\n />\n\n {isDefaultVariant ? (\n <Select\n size=\"medium\"\n value={answer.dotOption || undefined}\n onValueChange={(value) => handleSelectDot(index, value)}\n >\n <SelectTrigger\n className=\"w-[180px] overflow-hidden truncate\"\n title={\n answer.dotOption\n ? stripHtmlTags(answer.dotOption)\n : undefined\n }\n >\n <SelectValue placeholder=\"Selecione opção\" />\n </SelectTrigger>\n\n <SelectContent>\n {dotsOptions\n .filter(\n (dot) =>\n !assignedDots.has(dot.label) ||\n answer.dotOption === dot.label\n )\n .map((dot) => (\n <SelectItem\n key={dot.label}\n value={dot.label}\n title={stripHtmlTags(dot.label)}\n truncate\n >\n {stripHtmlTags(dot.label)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n ) : (\n <div>\n {answer.isCorrect === null\n ? null\n : getStatusBadge(variantCorrect)}\n </div>\n )}\n </div>\n\n {!isDefaultVariant && (\n <span className=\"flex flex-row gap-2 items-center\">\n <Text size=\"2xs\" className=\"text-text-800\">\n Resposta selecionada:{' '}\n {answer.dotOption\n ? stripHtmlTags(answer.dotOption)\n : 'Nenhuma'}\n </Text>\n {answer.isCorrect === false && (\n <Text size=\"2xs\" className=\"text-text-800\">\n Resposta correta: {stripHtmlTags(answer.correctOption)}\n </Text>\n )}\n </span>\n )}\n </section>\n );\n })}\n </div>\n </QuizContainer>\n </>\n );\n};\n\nconst QuizFill = ({ paddingBottom }: QuizVariantInterface) => {\n const {\n getCurrentQuestion,\n getQuestionResultByQuestionId,\n getCurrentAnswer,\n selectDissertativeAnswer,\n variant,\n getUserAnswerByQuestionId,\n } = useQuizStore();\n\n const currentQuestion = getCurrentQuestion();\n const currentQuestionResult = getQuestionResultByQuestionId(\n currentQuestion?.id || ''\n );\n const currentAnswer = getCurrentAnswer();\n\n const baseId = useId();\n\n // Get the additionalContent from the question (contains text with {optionId} placeholders)\n // In result mode, prefer the persisted snapshot to match parsedAnswers\n const additionalContent =\n variant === QuizVariant.RESULT\n ? currentQuestionResult?.additionalContent ||\n currentQuestion?.additionalContent ||\n ''\n : currentQuestion?.additionalContent || '';\n\n // Get the options from the question\n // In result mode, prefer the persisted snapshot to match parsedAnswers\n const questionOptions =\n variant === QuizVariant.RESULT\n ? currentQuestionResult?.options || currentQuestion?.options || []\n : currentQuestion?.options || [];\n\n // Shuffled options for dropdown display (consistent per question)\n const shuffledOptions = useMemo(() => {\n return shuffleWithSeed(questionOptions, currentQuestion?.id || '');\n }, [questionOptions, currentQuestion?.id]);\n\n // Parse current answers\n // In result mode, read from fillAnswers object, fallback to answer JSON\n // In default mode, read from currentAnswer.answer JSON string\n const parsedAnswers: Record<string, string> = useMemo(() => {\n if (variant === QuizVariant.RESULT && currentQuestionResult?.fillAnswers) {\n // fillAnswers is already a Record<placeholderId, selectedOptionId>\n return currentQuestionResult.fillAnswers;\n }\n // Fallback to answer JSON string (for older/partially migrated submissions)\n return parseStoredAnswers(\n variant,\n currentQuestionResult?.answer,\n currentAnswer?.answer\n );\n }, [\n variant,\n currentQuestionResult?.fillAnswers,\n currentQuestionResult?.answer,\n currentAnswer?.answer,\n ]);\n\n const [localAnswers, setLocalAnswers] =\n useState<Record<string, string>>(parsedAnswers);\n\n // Sync local answers when question changes\n useEffect(() => {\n setLocalAnswers(parsedAnswers);\n }, [parsedAnswers, currentQuestion?.id]);\n\n // Handle select change\n const handleSelectChange = (placeholderId: string, optionId: string) => {\n if (!currentQuestion) return;\n\n // Get the LATEST answers directly from the store (not from closure)\n const latestStoreAnswer = getUserAnswerByQuestionId(currentQuestion.id);\n let baseAnswers: Record<string, string> = {};\n if (latestStoreAnswer?.answer) {\n try {\n const parsed = JSON.parse(latestStoreAnswer.answer);\n baseAnswers = normalizeAnswerData(parsed);\n } catch {\n // Invalid JSON, start fresh\n }\n }\n\n const newAnswers = {\n ...baseAnswers,\n ...localAnswers,\n [placeholderId]: optionId,\n };\n setLocalAnswers(newAnswers);\n\n // Save to store as JSON string\n selectDissertativeAnswer(currentQuestion.id, JSON.stringify(newAnswers));\n };\n\n // Get option text by ID\n const getOptionTextById = (optionId: string): string => {\n const option = questionOptions.find((opt) => opt.id === optionId);\n return stripHtmlTags(option?.option || '');\n };\n\n // Check if an answer is correct (for result mode)\n const isAnswerCorrect = (placeholderId: string): boolean => {\n const selectedOptionId = localAnswers[placeholderId];\n // The placeholder ID is the correct option ID\n return selectedOptionId === placeholderId;\n };\n\n // Render the select for default mode\n const renderDefaultSelect = (placeholderId: string) => {\n const selectedOptionId = localAnswers[placeholderId];\n\n return (\n <span\n key={placeholderId}\n className=\"inline-block align-middle mx-1 my-2\"\n style={{ display: 'inline-block', verticalAlign: 'middle' }}\n >\n <Select\n value={selectedOptionId || undefined}\n onValueChange={(value) => handleSelectChange(placeholderId, value)}\n >\n <SelectTrigger\n className=\"w-auto min-w-[120px] max-w-[200px] h-7 px-2 bg-background border-gray-300 overflow-hidden truncate\"\n title={stripHtmlTags(\n shuffledOptions.find((o) => o.id === selectedOptionId)?.option ||\n ''\n )}\n >\n <SelectValue placeholder=\"Selecione opção\" />\n </SelectTrigger>\n <SelectContent>\n {shuffledOptions.map((option) => (\n <SelectItem\n key={option.id}\n value={option.id}\n title={stripHtmlTags(option.option)}\n truncate\n >\n {stripHtmlTags(option.option)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </span>\n );\n };\n\n // Render the result badge\n const renderResultBadge = (placeholderId: string) => {\n const selectedOptionId = localAnswers[placeholderId];\n const selectedOptionText = getOptionTextById(selectedOptionId);\n const isCorrect = isAnswerCorrect(placeholderId);\n\n if (!selectedOptionId) {\n return (\n <span\n key={placeholderId}\n className=\"inline-block align-middle mx-1 my-2\"\n style={{ display: 'inline-block', verticalAlign: 'middle' }}\n >\n <Badge\n variant=\"solid\"\n action=\"error\"\n iconRight={<XCircleIcon />}\n size=\"large\"\n className=\"py-1 px-2\"\n >\n Não respondido\n </Badge>\n </span>\n );\n }\n\n return (\n <span\n key={placeholderId}\n className=\"inline-block align-middle mx-1 my-2\"\n style={{ display: 'inline-block', verticalAlign: 'middle' }}\n >\n <Badge\n variant=\"solid\"\n action={isCorrect ? 'success' : 'error'}\n iconRight={isCorrect ? <CheckCircleIcon /> : <XCircleIcon />}\n size=\"large\"\n className=\"py-1 px-2\"\n >\n <span className=\"text-text-900\">{selectedOptionText}</span>\n </Badge>\n </span>\n );\n };\n\n // Render the correct answer for resolution\n const renderResolutionAnswer = (placeholderId: string) => {\n // The placeholderId IS the correct option ID\n const correctOptionText = getOptionTextById(placeholderId);\n\n return (\n <span className=\"inline mx-1 text-success-600 font-semibold border-b-2 border-success-600\">\n {correctOptionText}\n </span>\n );\n };\n\n // Parse text and render with selects\n const renderTextWithSelects = (text: string, isResolution?: boolean) => {\n const elements: Array<{ element: string | ReactNode; id: string }> = [];\n let lastIndex = 0;\n const nextId = () => elements.length;\n\n // Match {uuid} placeholders (UUID format or any alphanumeric with hyphens)\n const regex = /\\{([a-zA-Z0-9-]+)\\}/g;\n let match;\n\n while ((match = regex.exec(text)) !== null) {\n const [fullMatch, placeholderId] = match;\n const startIndex = match.index;\n\n // Add text before the placeholder\n if (startIndex > lastIndex) {\n elements.push({\n element: text.slice(lastIndex, startIndex),\n id: `${baseId}-text-${nextId()}`,\n });\n }\n\n // Add the appropriate element based on variant and mode\n if (isResolution) {\n elements.push({\n element: renderResolutionAnswer(placeholderId),\n id: `${baseId}-resolution-${nextId()}`,\n });\n } else if (variant === QuizVariant.DEFAULT) {\n elements.push({\n element: renderDefaultSelect(placeholderId),\n id: `${baseId}-select-${nextId()}`,\n });\n } else {\n elements.push({\n element: renderResultBadge(placeholderId),\n id: `${baseId}-result-${nextId()}`,\n });\n }\n\n lastIndex = match.index + fullMatch.length;\n }\n\n // Add remaining text\n if (lastIndex < text.length) {\n elements.push({\n element: text.slice(lastIndex),\n id: `${baseId}-text-${nextId()}`,\n });\n }\n\n return elements;\n };\n\n // Render HTML content with selects replacing placeholders\n const renderHtmlWithSelects = (\n htmlContent: string,\n isResolution?: boolean\n ) => {\n // For now, we'll strip HTML and render plain text with selects\n // This preserves the placeholder functionality while handling HTML content\n const textContent = stripHtmlTags(htmlContent);\n return renderTextWithSelects(textContent, isResolution);\n };\n\n if (!currentQuestion || !additionalContent) {\n return (\n <>\n <QuizSubTitle subTitle=\"Preenchimento\" />\n <QuizContainer className=\"h-auto pb-0\">\n <div className=\"space-y-6 px-4 h-auto\">\n <Text\n size=\"md\"\n color=\"text-text-600\"\n weight=\"normal\"\n className={cn(paddingBottom)}\n >\n Nenhum conteúdo disponível para esta questão.\n </Text>\n </div>\n </QuizContainer>\n </>\n );\n }\n\n return (\n <>\n <QuizSubTitle subTitle=\"Preencha as lacunas\" />\n\n <QuizContainer\n className={cn('', variant !== QuizVariant.RESULT && paddingBottom)}\n >\n <div className=\"px-4\">\n <Text\n as=\"div\"\n size=\"lg\"\n color=\"text-text-900\"\n weight=\"normal\"\n className=\"leading-[2.5] *:inline\"\n >\n {renderHtmlWithSelects(additionalContent).map((element) => (\n <Fragment key={element.id}>{element.element}</Fragment>\n ))}\n </Text>\n </div>\n </QuizContainer>\n\n {variant === QuizVariant.RESULT && (\n <>\n <QuizSubTitle subTitle=\"Resposta correta\" />\n\n <QuizContainer className={cn('', paddingBottom)}>\n <div className=\"px-4\">\n <Text\n as=\"div\"\n size=\"lg\"\n color=\"text-text-900\"\n weight=\"normal\"\n className=\"leading-[2.5] *:inline\"\n >\n {renderHtmlWithSelects(additionalContent, true).map(\n (element) => (\n <Fragment key={element.id}>{element.element}</Fragment>\n )\n )}\n </Text>\n </div>\n </QuizContainer>\n </>\n )}\n </>\n );\n};\n\nconst QuizImageQuestion = ({ paddingBottom }: QuizVariantInterface) => {\n const {\n variant,\n getCurrentQuestion,\n getCurrentAnswer,\n selectDissertativeAnswer,\n getQuestionResultByQuestionId,\n } = useQuizStore();\n\n const currentQuestion = getCurrentQuestion();\n const currentAnswer = getCurrentAnswer();\n const currentQuestionResult = getQuestionResultByQuestionId(\n currentQuestion?.id || ''\n );\n\n // Get image URL from additionalContent\n const imageUrl = currentQuestion?.additionalContent || '';\n\n // Parse correct coordinates from first option (stored as JSON: {\"x\": number, \"y\": number})\n const correctPositionRelative = useMemo(() => {\n if (!currentQuestion?.options || currentQuestion.options.length === 0) {\n return { x: 0.5, y: 0.5 }; // Default center\n }\n try {\n const coords = JSON.parse(currentQuestion.options[0].option);\n if (typeof coords.x === 'number' && typeof coords.y === 'number') {\n // Coordinates are stored as percentages (0-100), convert to relative (0-1)\n return { x: coords.x / 100, y: coords.y / 100 };\n }\n } catch {\n // Fall back to default\n }\n return { x: 0.5, y: 0.5 };\n }, [currentQuestion?.options]);\n\n // Calculate correctRadiusRelative automatically based on the circle dimensions\n const calculateCorrectRadiusRelative = (): number => {\n // The correct answer circle has width: 15% and height: 30%\n // We'll use the average of these as the radius\n const circleWidthRelative = 0.15; // 15%\n const circleHeightRelative = 0.3; // 30%\n\n // Calculate the average radius (half of the average of width and height)\n const averageRadius = (circleWidthRelative + circleHeightRelative) / 4;\n\n // Add a small tolerance for better user experience\n const tolerance = 0.02; // 2% tolerance\n\n return averageRadius + tolerance;\n };\n\n const correctRadiusRelative = calculateCorrectRadiusRelative();\n\n // Parse user's answer from stored JSON or result\n const storedUserAnswer = useMemo(() => {\n if (variant === QuizVariant.RESULT && currentQuestionResult?.answer) {\n try {\n const coords = JSON.parse(currentQuestionResult.answer);\n if (typeof coords.x === 'number' && typeof coords.y === 'number') {\n return { x: coords.x / 100, y: coords.y / 100 };\n }\n } catch {\n // Fall back to null\n }\n } else if (currentAnswer?.answer) {\n try {\n const coords = JSON.parse(currentAnswer.answer);\n if (typeof coords.x === 'number' && typeof coords.y === 'number') {\n return { x: coords.x / 100, y: coords.y / 100 };\n }\n } catch {\n // Fall back to null\n }\n }\n return null;\n }, [variant, currentQuestionResult?.answer, currentAnswer?.answer]);\n\n const [clickPositionRelative, setClickPositionRelative] = useState<{\n x: number;\n y: number;\n } | null>(storedUserAnswer);\n\n // Sync with stored answer when it changes\n useEffect(() => {\n setClickPositionRelative(storedUserAnswer);\n }, [storedUserAnswer]);\n\n // Helper function to safely convert click coordinates to relative coordinates\n const convertToRelativeCoordinates = (\n x: number,\n y: number,\n rect: DOMRect\n ): { x: number; y: number } => {\n // Guard against division by zero or extremely small dimensions\n const safeWidth = Math.max(rect.width, 0.001);\n const safeHeight = Math.max(rect.height, 0.001);\n\n // Convert to relative coordinates and clamp to [0, 1] range\n const xRelative = Math.max(0, Math.min(1, x / safeWidth));\n const yRelative = Math.max(0, Math.min(1, y / safeHeight));\n\n return { x: xRelative, y: yRelative };\n };\n\n const handleImageClick = (event: MouseEvent<HTMLButtonElement>) => {\n if (variant === QuizVariant.RESULT) return;\n\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX - rect.left;\n const y = event.clientY - rect.top;\n\n // Use helper function for safe conversion\n const positionRelative = convertToRelativeCoordinates(x, y, rect);\n setClickPositionRelative(positionRelative);\n\n // Save the answer to the store (as percentages 0-100)\n if (currentQuestion) {\n const answerJson = JSON.stringify({\n x: Math.round(positionRelative.x * 100),\n y: Math.round(positionRelative.y * 100),\n });\n selectDissertativeAnswer(currentQuestion.id, answerJson);\n }\n };\n\n const handleKeyboardActivate = () => {\n if (variant === QuizVariant.RESULT) return;\n // Choose a deterministic position for keyboard activation; center is a reasonable default\n const centerPosition = { x: 0.5, y: 0.5 };\n setClickPositionRelative(centerPosition);\n\n // Save the answer to the store (as percentages 0-100)\n if (currentQuestion) {\n const answerJson = JSON.stringify({\n x: Math.round(centerPosition.x * 100),\n y: Math.round(centerPosition.y * 100),\n });\n selectDissertativeAnswer(currentQuestion.id, answerJson);\n }\n };\n\n const isCorrect = () => {\n if (!clickPositionRelative) return false;\n\n const distance = Math.sqrt(\n Math.pow(clickPositionRelative.x - correctPositionRelative.x, 2) +\n Math.pow(clickPositionRelative.y - correctPositionRelative.y, 2)\n );\n\n return distance <= correctRadiusRelative;\n };\n\n const getUserCircleColorClasses = () => {\n if (variant === QuizVariant.DEFAULT) {\n return 'bg-indicator-primary/70 border-[#F8CC2E]';\n }\n\n if (variant === QuizVariant.RESULT) {\n return isCorrect()\n ? 'bg-success-600/70 border-white' // Green for correct answer\n : 'bg-indicator-error/70 border-white'; // Red for incorrect answer\n }\n\n return 'bg-success-600/70 border-white';\n };\n\n return (\n <>\n <QuizSubTitle subTitle=\"Clique na área correta\" />\n\n <QuizContainer className={cn('', paddingBottom)}>\n <div\n data-testid=\"quiz-image-container\"\n className=\"space-y-6 p-3 relative inline-block\"\n >\n