UNPKG

analytica-frontend-lib

Version:

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

1 lines 235 kB
{"version":3,"sources":["../../src/components/CorrectActivityModal/CorrectActivityModal.tsx","../../src/components/Modal/Modal.tsx","../../src/utils/utils.ts","../../src/components/Button/Button.tsx","../../src/components/Modal/utils/videoUtils.ts","../../src/components/Text/Text.tsx","../../src/components/Badge/Badge.tsx","../../src/components/Alternative/Alternative.tsx","../../src/components/Radio/Radio.tsx","../../src/components/Accordation/Accordation.tsx","../../src/components/Card/Card.tsx","../../src/components/ProgressBar/ProgressBar.tsx","../../src/components/IconRender/IconRender.tsx","../../src/assets/icons/subjects/ChatPT.tsx","../../src/assets/icons/subjects/ChatEN.tsx","../../src/assets/icons/subjects/ChatES.tsx","../../src/components/Accordation/AccordionGroup.tsx","../../src/components/FileAttachment/FileAttachment.tsx","../../src/types/studentActivityCorrection.ts"],"sourcesContent":["import { useState, useRef, useEffect } from 'react';\nimport {\n PencilSimple,\n Paperclip,\n X,\n Star,\n Medal,\n WarningCircle,\n} from 'phosphor-react';\nimport type { Icon } from 'phosphor-react';\nimport Modal from '../Modal/Modal';\nimport Text from '../Text/Text';\nimport Button from '../Button/Button';\nimport Badge from '../Badge/Badge';\nimport { AlternativesList } from '../Alternative/Alternative';\nimport { CardAccordation, AccordionGroup } from '../Accordation';\nimport { generateFileId } from '../FileAttachment/FileAttachment';\nimport type { AttachedFile } from '../FileAttachment/FileAttachment';\nimport { cn } from '../../utils/utils';\nimport {\n type StudentActivityCorrectionData,\n getQuestionStatusBadgeConfig,\n} from '../../types/studentActivityCorrection';\n\n/**\n * Props for the CorrectActivityModal component\n */\nexport interface CorrectActivityModalProps {\n /** Whether the modal is open */\n isOpen: boolean;\n /** Function to close the modal */\n onClose: () => void;\n /** Student activity correction data */\n data: StudentActivityCorrectionData | null;\n /** Whether the modal is in view-only mode */\n isViewOnly?: boolean;\n /** Callback when observation is submitted with optional files */\n onObservationSubmit?: (observation: string, files: File[]) => void;\n}\n\n/**\n * Props for the StatCard component\n */\ninterface StatCardProps {\n label: string;\n value: string | number;\n variant: 'score' | 'correct' | 'incorrect';\n}\n\n/**\n * Configuration for each stat card variant\n */\nconst variantConfig: Record<\n StatCardProps['variant'],\n {\n bg: string;\n text: string;\n iconBg: string;\n iconColor: string;\n IconComponent: Icon;\n }\n> = {\n score: {\n bg: 'bg-warning-background',\n text: 'text-warning-600',\n iconBg: 'bg-warning-300',\n iconColor: 'text-white',\n IconComponent: Star,\n },\n correct: {\n bg: 'bg-success-200',\n text: 'text-success-700',\n iconBg: 'bg-indicator-positive',\n iconColor: 'text-text-950',\n IconComponent: Medal,\n },\n incorrect: {\n bg: 'bg-error-100',\n text: 'text-error-700',\n iconBg: 'bg-indicator-negative',\n iconColor: 'text-white',\n IconComponent: WarningCircle,\n },\n};\n\n/**\n * Stat card component for displaying statistics with icon\n * @param props - Component props\n * @returns JSX element\n */\nconst StatCard = ({ label, value, variant }: StatCardProps) => {\n const config = variantConfig[variant];\n const IconComponent = config.IconComponent;\n\n return (\n <div\n className={cn(\n 'border border-border-50 rounded-xl py-4 px-3 flex flex-col items-center justify-center gap-1',\n config.bg\n )}\n >\n <div\n className={cn(\n 'w-[30px] h-[30px] rounded-2xl flex items-center justify-center',\n config.iconBg\n )}\n >\n <IconComponent\n size={16}\n className={config.iconColor}\n weight=\"regular\"\n />\n </div>\n <Text\n className={cn('text-2xs font-bold uppercase text-center', config.text)}\n >\n {label}\n </Text>\n <Text className={cn('text-xl font-bold', config.text)}>{value}</Text>\n </div>\n );\n};\n\n/**\n * Modal component for correcting or viewing student activity details\n *\n * Displays student information, statistics cards, observation section,\n * and a list of questions with their status.\n *\n * @param props - Component props\n * @returns JSX element\n *\n * @example\n * ```tsx\n * <CorrectActivityModal\n * isOpen={isOpen}\n * onClose={() => setIsOpen(false)}\n * data={studentData}\n * isViewOnly={false}\n * onObservationSubmit={(obs, files) => console.log(obs, files)}\n * />\n * ```\n */\nconst CorrectActivityModal = ({\n isOpen,\n onClose,\n data,\n isViewOnly = false,\n onObservationSubmit,\n}: CorrectActivityModalProps) => {\n const [observation, setObservation] = useState('');\n const [isObservationExpanded, setIsObservationExpanded] = useState(false);\n const [isObservationSaved, setIsObservationSaved] = useState(false);\n const [savedObservation, setSavedObservation] = useState('');\n const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);\n const [savedFiles, setSavedFiles] = useState<AttachedFile[]>([]);\n const fileInputRef = useRef<HTMLInputElement>(null);\n\n /**\n * Reset state when modal opens or student changes\n */\n useEffect(() => {\n if (isOpen) {\n setObservation('');\n setIsObservationExpanded(false);\n setIsObservationSaved(false);\n setSavedObservation('');\n setAttachedFiles([]);\n setSavedFiles([]);\n }\n }, [isOpen, data?.studentId]);\n\n /**\n * Handle opening observation section\n */\n const handleOpenObservation = () => {\n setIsObservationExpanded(true);\n };\n\n /**\n * Handle adding files (single file mode - replaces existing file)\n * @param files - Files to add\n */\n const handleFilesAdd = (files: File[]) => {\n const newFile = files[0];\n if (newFile) {\n setAttachedFiles([{ file: newFile, id: generateFileId() }]);\n }\n };\n\n /**\n * Handle removing a file\n * @param id - File ID to remove\n */\n const handleFileRemove = (id: string) => {\n setAttachedFiles((prev) => prev.filter((f) => f.id !== id));\n };\n\n /**\n * Handle saving observation\n */\n const handleSaveObservation = () => {\n if (observation.trim() || attachedFiles.length > 0) {\n setSavedObservation(observation);\n setSavedFiles([...attachedFiles]);\n setIsObservationSaved(true);\n setIsObservationExpanded(false);\n onObservationSubmit?.(\n observation,\n attachedFiles.map((f) => f.file)\n );\n }\n };\n\n /**\n * Handle editing observation\n */\n const handleEditObservation = () => {\n setObservation(savedObservation);\n setAttachedFiles([...savedFiles]);\n setIsObservationSaved(false);\n setIsObservationExpanded(true);\n };\n\n if (!data) return null;\n\n const title = isViewOnly ? 'Detalhes da atividade' : 'Corrigir atividade';\n const formattedScore = data.score === null ? '-' : data.score.toFixed(1);\n\n /**\n * Render observation section based on current state\n * @returns JSX element for observation section\n */\n const renderObservationSection = () => {\n if (isViewOnly) return null;\n\n // State: Saved\n if (isObservationSaved) {\n return (\n <div className=\"bg-background border border-border-100 rounded-lg p-4 space-y-2\">\n <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3\">\n <Text className=\"text-sm font-bold text-text-950\">Observação</Text>\n <div className=\"flex items-center gap-3\">\n {savedFiles.length > 0 && (\n <div className=\"flex items-center gap-2 px-5 h-10 bg-secondary-500 rounded-full min-w-0 max-w-[150px]\">\n <Paperclip\n size={18}\n className=\"text-text-800 flex-shrink-0\"\n />\n <span className=\"text-base font-medium text-text-800 truncate\">\n {savedFiles[0].file.name}\n </span>\n </div>\n )}\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"small\"\n onClick={handleEditObservation}\n className=\"flex items-center gap-2 flex-shrink-0\"\n >\n <PencilSimple size={16} />\n Editar\n </Button>\n </div>\n </div>\n {savedObservation && (\n <div className=\"p-3 bg-background-50 rounded-lg\">\n <Text className=\"text-sm text-text-700\">{savedObservation}</Text>\n </div>\n )}\n </div>\n );\n }\n\n // State: Expanded\n if (isObservationExpanded) {\n return (\n <div className=\"bg-background border border-border-100 rounded-lg p-4 space-y-3\">\n <Text className=\"text-sm font-bold text-text-950\">Observação</Text>\n <textarea\n value={observation}\n onChange={(e) => setObservation(e.target.value)}\n placeholder=\"Escreva uma observação para o estudante\"\n className=\"w-full min-h-[80px] p-3 border border-border-100 rounded-lg text-sm text-text-700 placeholder:text-text-400 resize-none focus:outline-none focus:ring-2 focus:ring-primary-500\"\n />\n {/* Hidden file input */}\n <input\n type=\"file\"\n ref={fileInputRef}\n className=\"hidden\"\n onChange={(e) => {\n const selectedFiles = e.target.files;\n if (selectedFiles && selectedFiles.length > 0) {\n handleFilesAdd(Array.from(selectedFiles));\n }\n if (fileInputRef.current) {\n fileInputRef.current.value = '';\n }\n }}\n aria-label=\"Selecionar arquivo\"\n />\n {/* Buttons row: File indicator or Anexar button left, Salvar right */}\n <div className=\"flex flex-col-reverse sm:flex-row gap-3 sm:justify-between\">\n {attachedFiles.length > 0 ? (\n <div className=\"flex items-center justify-center gap-2 px-5 h-10 bg-secondary-500 rounded-full min-w-0 max-w-[150px]\">\n <Paperclip size={18} className=\"text-text-800 flex-shrink-0\" />\n <span className=\"text-base font-medium text-text-800 truncate\">\n {attachedFiles[0].file.name}\n </span>\n <button\n type=\"button\"\n onClick={() => handleFileRemove(attachedFiles[0].id)}\n className=\"text-text-700 hover:text-text-800 flex-shrink-0\"\n aria-label={`Remover ${attachedFiles[0].file.name}`}\n >\n <X size={18} />\n </button>\n </div>\n ) : (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"small\"\n onClick={() => fileInputRef.current?.click()}\n className=\"flex items-center gap-2\"\n >\n <Paperclip size={18} />\n Anexar\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"small\"\n onClick={handleSaveObservation}\n disabled={!observation.trim() && attachedFiles.length === 0}\n >\n Salvar\n </Button>\n </div>\n {data.observation && (\n <div className=\"p-3 bg-background-50 rounded-lg\">\n <Text className=\"text-xs text-text-500\">\n Observação anterior:\n </Text>\n <Text className=\"text-sm text-text-700\">{data.observation}</Text>\n </div>\n )}\n </div>\n );\n }\n\n // State: Closed (default)\n return (\n <div className=\"bg-background border border-border-100 rounded-lg p-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between\">\n <Text className=\"text-sm font-bold text-text-950\">Observação</Text>\n <Button type=\"button\" size=\"small\" onClick={handleOpenObservation}>\n Incluir\n </Button>\n </div>\n );\n };\n\n return (\n <Modal\n isOpen={isOpen}\n onClose={onClose}\n title={title}\n size=\"lg\"\n contentClassName=\"max-h-[80vh] overflow-y-auto\"\n >\n <div className=\"space-y-6\">\n {/* Student Info */}\n <div className=\"flex items-center gap-3\">\n <div className=\"w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center\">\n <Text className=\"text-lg font-semibold text-primary-700\">\n {data.studentName.charAt(0).toUpperCase()}\n </Text>\n </div>\n <Text className=\"text-lg font-medium text-text-950\">\n {data.studentName}\n </Text>\n </div>\n\n {/* Stats Cards */}\n <div className=\"grid grid-cols-3 gap-4\">\n <StatCard label=\"Nota\" value={formattedScore} variant=\"score\" />\n <StatCard\n label=\"N° de questões corretas\"\n value={data.correctCount}\n variant=\"correct\"\n />\n <StatCard\n label=\"N° de questões incorretas\"\n value={data.incorrectCount}\n variant=\"incorrect\"\n />\n </div>\n\n {/* Observation Section */}\n {renderObservationSection()}\n\n {/* Questions List */}\n <div className=\"space-y-2\">\n <Text className=\"text-sm font-bold text-text-950\">Respostas</Text>\n <AccordionGroup type=\"multiple\" className=\"space-y-2\">\n {data.questions.map((question) => {\n const badgeConfig = getQuestionStatusBadgeConfig(question.status);\n\n return (\n <CardAccordation\n key={question.questionNumber}\n value={`question-${question.questionNumber}`}\n className=\"bg-background rounded-xl\"\n trigger={\n <div className=\"flex items-center justify-between w-full py-3 pr-2\">\n <Text className=\"text-base font-bold text-text-950\">\n Questão {question.questionNumber}\n </Text>\n <Badge\n className={cn(\n 'text-xs px-2 py-1',\n badgeConfig.bgColor,\n badgeConfig.textColor\n )}\n >\n {badgeConfig.label}\n </Badge>\n </div>\n }\n >\n <div className=\"space-y-4 pt-2\">\n {/* Question text */}\n {question.questionText && (\n <div className=\"text-sm text-text-700\">\n {question.questionText}\n </div>\n )}\n\n {/* Alternatives sub-accordion */}\n {question.alternatives &&\n question.alternatives.length > 0 && (\n <CardAccordation\n value={`alternatives-${question.questionNumber}`}\n className=\"border border-border-100 rounded-lg\"\n trigger={\n <div className=\"py-3 pr-2 w-full\">\n <Text className=\"text-sm font-bold text-text-950\">\n Alternativas\n </Text>\n </div>\n }\n >\n <div className=\"pt-2\">\n <AlternativesList\n mode=\"readonly\"\n selectedValue={question.studentAnswer}\n alternatives={question.alternatives.map(\n (alt) => ({\n value: alt.value,\n label: alt.label,\n status: alt.isCorrect ? 'correct' : undefined,\n })\n )}\n />\n </div>\n </CardAccordation>\n )}\n\n {/* Fallback for essay questions */}\n {(!question.alternatives ||\n question.alternatives.length === 0) && (\n <>\n <div className=\"flex gap-2\">\n <Text className=\"text-xs text-text-500\">\n Resposta do aluno:\n </Text>\n <Text className=\"text-xs text-text-700\">\n {question.studentAnswer || 'Não respondeu'}\n </Text>\n </div>\n <div className=\"flex gap-2\">\n <Text className=\"text-xs text-text-500\">\n Resposta correta:\n </Text>\n <Text className=\"text-xs text-success-700\">\n {question.correctAnswer || '-'}\n </Text>\n </div>\n </>\n )}\n </div>\n </CardAccordation>\n );\n })}\n </AccordionGroup>\n </div>\n </div>\n </Modal>\n );\n};\n\nexport default CorrectActivityModal;\n","import { ReactNode, useEffect, useId } from 'react';\nimport { X } from 'phosphor-react';\nimport { cn } from '../../utils/utils';\nimport Button from '../Button/Button';\nimport {\n isYouTubeUrl,\n getYouTubeVideoId,\n getYouTubeEmbedUrl,\n} from './utils/videoUtils';\n\n/**\n * Lookup table for size classes\n */\nconst SIZE_CLASSES = {\n xs: 'max-w-[360px]',\n sm: 'max-w-[420px]',\n md: 'max-w-[510px]',\n lg: 'max-w-[640px]',\n xl: 'max-w-[970px]',\n} as const;\n\n/**\n * Modal component props interface\n */\ntype ModalProps = {\n contentClassName?: string;\n /** Whether the modal is open */\n isOpen: boolean;\n /** Function to close the modal */\n onClose: () => void;\n /** Modal title */\n title: string;\n /** Modal description/content */\n children?: ReactNode;\n /** Size of the modal */\n size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n /** Additional CSS classes for the modal content */\n className?: string;\n /** Whether pressing Escape should close the modal */\n closeOnEscape?: boolean;\n /** Footer content (typically buttons) */\n footer?: ReactNode;\n /** Hide the close button */\n hideCloseButton?: boolean;\n /** Modal variant */\n variant?: 'default' | 'activity';\n /** Description for activity variant */\n description?: string;\n /** Image URL for activity variant */\n image?: string;\n /** Alt text for activity image (leave empty for decorative images) */\n imageAlt?: string;\n /** Action link for activity variant */\n actionLink?: string;\n /** Action button label for activity variant */\n actionLabel?: string;\n};\n\n/**\n * Modal component for Analytica Ensino platforms\n *\n * A flexible modal component with multiple size variants and customizable behavior.\n *\n * @param isOpen - Whether the modal is currently open\n * @param onClose - Callback function called when the modal should be closed\n * @param title - The title displayed at the top of the modal\n * @param children - The main content of the modal\n * @param size - The size variant (xs, sm, md, lg, xl)\n * @param className - Additional CSS classes for the modal content\n * @param closeOnEscape - Whether pressing Escape closes the modal (default: true)\n * @param footer - Footer content, typically action buttons\n * @param hideCloseButton - Whether to hide the X close button (default: false)\n * @returns A modal overlay with content\n *\n * @example\n * ```tsx\n * <Modal\n * isOpen={isModalOpen}\n * onClose={() => setIsModalOpen(false)}\n * title=\"Invite your team\"\n * size=\"md\"\n * footer={\n * <div className=\"flex gap-3\">\n * <Button variant=\"outline\" onClick={() => setIsModalOpen(false)}>Cancel</Button>\n * <Button variant=\"solid\" onClick={handleExplore}>Explore</Button>\n * </div>\n * }\n * >\n * Elevate user interactions with our versatile modals.\n * </Modal>\n * ```\n */\nconst Modal = ({\n isOpen,\n onClose,\n title,\n children,\n size = 'md',\n className = '',\n closeOnEscape = true,\n footer,\n hideCloseButton = false,\n variant = 'default',\n description,\n image,\n imageAlt,\n actionLink,\n actionLabel,\n contentClassName = '',\n}: ModalProps) => {\n const titleId = useId();\n\n // Handle escape key\n useEffect(() => {\n if (!isOpen || !closeOnEscape) return;\n\n const handleEscape = (event: globalThis.KeyboardEvent) => {\n if (event.key === 'Escape') {\n onClose();\n }\n };\n\n document.addEventListener('keydown', handleEscape);\n return () => document.removeEventListener('keydown', handleEscape);\n }, [isOpen, closeOnEscape, onClose]);\n\n // Handle body scroll lock and scrollbar shift fix\n useEffect(() => {\n if (!isOpen) return;\n\n // Calculate scrollbar width before hiding overflow\n const scrollbarWidth =\n window.innerWidth - document.documentElement.clientWidth;\n\n // Save original styles\n const originalOverflow = document.body.style.overflow;\n const originalPaddingRight = document.body.style.paddingRight;\n\n // Apply scroll lock\n document.body.style.overflow = 'hidden';\n\n // Fix scrollbar shift: add padding to compensate for lost scrollbar\n if (scrollbarWidth > 0) {\n document.body.style.paddingRight = `${scrollbarWidth}px`;\n\n // Create overlay to cover the padding area with backdrop color\n const overlay = document.createElement('div');\n overlay.id = 'modal-scrollbar-overlay';\n overlay.style.cssText = `\n position: fixed;\n top: 0;\n right: 0;\n width: ${scrollbarWidth}px;\n height: 100vh;\n background-color: rgb(0 0 0 / 0.6);\n z-index: 40;\n pointer-events: none;\n `;\n document.body.appendChild(overlay);\n }\n\n return () => {\n // Restore original styles\n document.body.style.overflow = originalOverflow;\n document.body.style.paddingRight = originalPaddingRight;\n\n // Remove overlay\n const overlay = document.getElementById('modal-scrollbar-overlay');\n if (overlay) {\n overlay.remove();\n }\n };\n }, [isOpen]);\n\n if (!isOpen) return null;\n\n const sizeClasses = SIZE_CLASSES[size];\n const baseClasses =\n 'bg-secondary-50 rounded-3xl shadow-hard-shadow-2 border border-border-100 w-full mx-4';\n // Reset dialog default styles to prevent positioning issues\n const dialogResetClasses =\n 'p-0 m-0 border-none outline-none max-h-none static';\n const modalClasses = cn(\n baseClasses,\n sizeClasses,\n dialogResetClasses,\n className\n );\n\n // Normalize URLs missing protocol\n const normalizeUrl = (href: string) =>\n /^https?:\\/\\//i.test(href) ? href : `https://${href}`;\n\n // Handle action link click\n const handleActionClick = () => {\n if (actionLink) {\n window.open(normalizeUrl(actionLink), '_blank', 'noopener,noreferrer');\n }\n };\n\n // Activity variant rendering\n if (variant === 'activity') {\n return (\n <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs border-none p-0 m-0 w-full cursor-default\">\n <dialog\n className={modalClasses}\n aria-labelledby={titleId}\n aria-modal=\"true\"\n open\n >\n {/* Header simples com X */}\n <div className=\"flex justify-end p-6 pb-0\">\n {!hideCloseButton && (\n <button\n onClick={onClose}\n className=\"p-1 text-text-500 hover:text-text-700 hover:bg-background-50 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-indicator-info focus:ring-offset-2\"\n aria-label=\"Fechar modal\"\n >\n <X size={18} />\n </button>\n )}\n </div>\n\n {/* Conteúdo centralizado */}\n <div className=\"flex flex-col items-center px-6 pb-6 gap-5\">\n {/* Imagem ilustrativa */}\n {image && (\n <div className=\"flex justify-center\">\n <img\n src={image}\n alt={imageAlt ?? ''}\n className=\"w-[122px] h-[122px] object-contain\"\n />\n </div>\n )}\n\n {/* Título */}\n <h2\n id={titleId}\n className=\"text-lg font-semibold text-text-950 text-center\"\n >\n {title}\n </h2>\n\n {/* Descrição */}\n {description && (\n <p className=\"text-sm font-normal text-text-400 text-center max-w-md leading-[21px]\">\n {description}\n </p>\n )}\n\n {/* Ação: Botão ou Vídeo Embedado */}\n {actionLink && (\n <div className=\"w-full\">\n {(() => {\n const normalized = normalizeUrl(actionLink);\n const isYT = isYouTubeUrl(normalized);\n if (!isYT) return null;\n const id = getYouTubeVideoId(normalized);\n if (!id) {\n return (\n <Button\n variant=\"solid\"\n action=\"primary\"\n size=\"large\"\n className=\"w-full\"\n onClick={handleActionClick}\n >\n {actionLabel || 'Iniciar Atividade'}\n </Button>\n );\n }\n return (\n <iframe\n src={getYouTubeEmbedUrl(id)}\n className=\"w-full aspect-video rounded-lg\"\n allowFullScreen\n allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n title=\"Vídeo YouTube\"\n />\n );\n })()}\n {!isYouTubeUrl(normalizeUrl(actionLink)) && (\n <Button\n variant=\"solid\"\n action=\"primary\"\n size=\"large\"\n className=\"w-full\"\n onClick={handleActionClick}\n >\n {actionLabel || 'Iniciar Atividade'}\n </Button>\n )}\n </div>\n )}\n </div>\n </dialog>\n </div>\n );\n }\n\n // Default variant rendering\n return (\n <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs border-none p-0 m-0 w-full cursor-default\">\n <dialog\n className={modalClasses}\n aria-labelledby={titleId}\n aria-modal=\"true\"\n open\n >\n {/* Header */}\n <div className=\"flex items-center justify-between px-6 py-6\">\n <h2 id={titleId} className=\"text-lg font-semibold text-text-950\">\n {title}\n </h2>\n {!hideCloseButton && (\n <button\n onClick={onClose}\n className=\"p-1 text-text-500 hover:text-text-700 hover:bg-background-50 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-indicator-info focus:ring-offset-2\"\n aria-label=\"Fechar modal\"\n >\n <X size={18} />\n </button>\n )}\n </div>\n\n {/* Content */}\n {children && (\n <div className={cn('px-6 pb-6', contentClassName)}>\n <div className=\"text-text-500 font-normal text-sm leading-6\">\n {children}\n </div>\n </div>\n )}\n\n {/* Footer */}\n {footer && (\n <div className=\"flex justify-end gap-3 px-6 pb-6\">{footer}</div>\n )}\n </dialog>\n </div>\n );\n};\n\nexport default Modal;\n","import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n\nexport { syncDropdownState } from './dropdown';\nexport {\n getSelectedIdsFromCategories,\n toggleArrayItem,\n toggleSingleValue,\n areFiltersEqual,\n} from './activityFilters';\nexport {\n mapQuestionTypeToEnum,\n mapQuestionTypeToEnumRequired,\n} from './questionTypeUtils';\nexport {\n getStatusBadgeConfig,\n formatTimeSpent,\n formatQuestionNumbers,\n formatDateToBrazilian,\n} from './activityDetailsUtils';\n\n/**\n * Retorna a cor hexadecimal com opacidade 0.3 (4d) se não estiver em dark mode.\n * Se estiver em dark mode, retorna a cor original.\n *\n * @param hexColor - Cor hexadecimal (ex: \"#0066b8\" ou \"0066b8\")\n * @param isDark - booleano indicando se está em dark mode\n * @returns string - cor hexadecimal com opacidade se necessário\n */\nexport function getSubjectColorWithOpacity(\n hexColor: string | undefined,\n isDark: boolean\n): string | undefined {\n if (!hexColor) return undefined;\n // Remove o '#' se existir\n let color = hexColor.replace(/^#/, '').toLowerCase();\n\n if (isDark) {\n // Se está em dark mode, sempre remove opacidade se existir\n if (color.length === 8) {\n color = color.slice(0, 6);\n }\n return `#${color}`;\n } else {\n // Se não está em dark mode (light mode)\n let resultColor: string;\n if (color.length === 6) {\n // Adiciona opacidade 0.3 (4D) para cores de 6 dígitos\n resultColor = `#${color}4d`;\n } else if (color.length === 8) {\n // Já tem opacidade, retorna como está\n resultColor = `#${color}`;\n } else {\n // Para outros tamanhos (3, 4, 5 dígitos), retorna como está\n resultColor = `#${color}`;\n }\n return resultColor;\n }\n}\n","import { ButtonHTMLAttributes, ReactNode } from 'react';\nimport { cn } from '../../utils/utils';\n\n/**\n * Lookup table for variant and action class combinations\n */\nconst VARIANT_ACTION_CLASSES = {\n solid: {\n primary:\n 'bg-primary-950 text-text border border-primary-950 hover:bg-primary-800 hover:border-primary-800 focus-visible:outline-none focus-visible:bg-primary-950 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:bg-primary-700 active:border-primary-700 disabled:bg-primary-500 disabled:border-primary-500 disabled:opacity-40 disabled:cursor-not-allowed',\n positive:\n 'bg-success-500 text-text border border-success-500 hover:bg-success-600 hover:border-success-600 focus-visible:outline-none focus-visible:bg-success-500 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:bg-success-700 active:border-success-700 disabled:bg-success-500 disabled:border-success-500 disabled:opacity-40 disabled:cursor-not-allowed',\n negative:\n 'bg-error-500 text-text border border-error-500 hover:bg-error-600 hover:border-error-600 focus-visible:outline-none focus-visible:bg-error-500 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:bg-error-700 active:border-error-700 disabled:bg-error-500 disabled:border-error-500 disabled:opacity-40 disabled:cursor-not-allowed',\n },\n outline: {\n primary:\n 'bg-transparent text-primary-950 border border-primary-950 hover:bg-background-50 hover:text-primary-400 hover:border-primary-400 focus-visible:border-0 focus-visible:outline-none focus-visible:text-primary-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-primary-700 active:border-primary-700 disabled:opacity-40 disabled:cursor-not-allowed',\n positive:\n 'bg-transparent text-success-500 border border-success-300 hover:bg-background-50 hover:text-success-400 hover:border-success-400 focus-visible:border-0 focus-visible:outline-none focus-visible:text-success-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-success-700 active:border-success-700 disabled:opacity-40 disabled:cursor-not-allowed',\n negative:\n 'bg-transparent text-error-500 border border-error-300 hover:bg-background-50 hover:text-error-400 hover:border-error-400 focus-visible:border-0 focus-visible:outline-none focus-visible:text-error-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-error-700 active:border-error-700 disabled:opacity-40 disabled:cursor-not-allowed',\n },\n link: {\n primary:\n 'bg-transparent text-primary-950 hover:text-primary-400 focus-visible:outline-none focus-visible:text-primary-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-primary-700 disabled:opacity-40 disabled:cursor-not-allowed',\n positive:\n 'bg-transparent text-success-500 hover:text-success-400 focus-visible:outline-none focus-visible:text-success-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-success-700 disabled:opacity-40 disabled:cursor-not-allowed',\n negative:\n 'bg-transparent text-error-500 hover:text-error-400 focus-visible:outline-none focus-visible:text-error-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-error-700 disabled:opacity-40 disabled:cursor-not-allowed',\n },\n} as const;\n\n/**\n * Lookup table for size classes\n */\nconst SIZE_CLASSES = {\n 'extra-small': 'text-xs px-3.5 py-2',\n small: 'text-sm px-4 py-2.5',\n medium: 'text-md px-5 py-2.5',\n large: 'text-lg px-6 py-3',\n 'extra-large': 'text-lg px-7 py-3.5',\n} as const;\n\n/**\n * Button component props interface\n */\ntype ButtonProps = {\n /** Content to be displayed inside the button */\n children: ReactNode;\n /** Ícone à esquerda do texto */\n iconLeft?: ReactNode;\n /** Ícone à direita do texto */\n iconRight?: ReactNode;\n /** Size of the button */\n size?: 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large';\n /** Visual variant of the button */\n variant?: 'solid' | 'outline' | 'link';\n /** Action type of the button */\n action?: 'primary' | 'positive' | 'negative';\n /** Additional CSS classes to apply */\n className?: string;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\n/**\n * Button component for Analytica Ensino platforms\n *\n * A flexible button component with multiple variants, sizes and actions.\n *\n * @param children - The content to display inside the button\n * @param size - The size variant (extra-small, small, medium, large, extra-large)\n * @param variant - The visual style variant (solid, outline, link)\n * @param action - The action type (primary, positive, negative)\n * @param className - Additional CSS classes\n * @param props - All other standard button HTML attributes\n * @returns A styled button element\n *\n * @example\n * ```tsx\n * <Button variant=\"solid\" action=\"primary\" size=\"medium\" onClick={() => console.log('clicked')}>\n * Click me\n * </Button>\n * ```\n */\nconst Button = ({\n children,\n iconLeft,\n iconRight,\n size = 'medium',\n variant = 'solid',\n action = 'primary',\n className = '',\n disabled,\n type = 'button',\n ...props\n}: ButtonProps) => {\n // Get classes from lookup tables\n const sizeClasses = SIZE_CLASSES[size];\n const variantClasses = VARIANT_ACTION_CLASSES[variant][action];\n\n const baseClasses =\n 'inline-flex items-center justify-center rounded-full cursor-pointer font-medium';\n\n return (\n <button\n className={cn(baseClasses, variantClasses, sizeClasses, className)}\n disabled={disabled}\n type={type}\n {...props}\n >\n {iconLeft && <span className=\"mr-2 flex items-center\">{iconLeft}</span>}\n {children}\n {iconRight && <span className=\"ml-2 flex items-center\">{iconRight}</span>}\n </button>\n );\n};\n\nexport default Button;\n","/**\n * Video utilities for Modal component\n *\n * Utilities to handle YouTube video embedding and URL detection\n */\n\n/**\n * Check if a given URL is a YouTube URL\n *\n * @param url - The URL to check\n * @returns true if the URL is from YouTube, false otherwise\n */\nexport const isYouTubeUrl = (url: string): boolean => {\n const youtubeRegex =\n /^(https?:\\/\\/)?((www|m|music)\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)\\/.+/i;\n return youtubeRegex.test(url);\n};\n\n/**\n * Validate if hostname is a legitimate YouTube host\n *\n * @param host - The hostname to validate\n * @returns The type of YouTube host or null if invalid\n */\nconst isValidYouTubeHost = (\n host: string\n): 'youtu.be' | 'youtube' | 'nocookie' | null => {\n if (host === 'youtu.be') return 'youtu.be';\n\n const isValidYouTubeCom =\n host === 'youtube.com' ||\n (host.endsWith('.youtube.com') &&\n /^(www|m|music)\\.youtube\\.com$/.test(host));\n\n if (isValidYouTubeCom) return 'youtube';\n\n const isValidNoCookie =\n host === 'youtube-nocookie.com' ||\n (host.endsWith('.youtube-nocookie.com') &&\n /^(www|m|music)\\.youtube-nocookie\\.com$/.test(host));\n\n if (isValidNoCookie) return 'nocookie';\n\n return null;\n};\n\n/**\n * Extract video ID from youtu.be path\n *\n * @param pathname - The URL pathname\n * @returns The video ID or null\n */\nconst extractYoutuBeId = (pathname: string): string | null => {\n const firstSeg = pathname.split('/').filter(Boolean)[0];\n return firstSeg || null;\n};\n\n/**\n * Extract video ID from YouTube or nocookie domains\n *\n * @param pathname - The URL pathname\n * @param searchParams - The URL search parameters\n * @returns The video ID or null\n */\nconst extractYouTubeId = (\n pathname: string,\n searchParams: URLSearchParams\n): string | null => {\n const parts = pathname.split('/').filter(Boolean);\n const [first, second] = parts;\n\n if (first === 'embed' && second) return second;\n if (first === 'shorts' && second) return second;\n if (first === 'live' && second) return second;\n\n const v = searchParams.get('v');\n if (v) return v;\n\n return null;\n};\n\n/**\n * Extract YouTube video ID from URL\n *\n * @param url - The YouTube URL\n * @returns The video ID if found, null otherwise\n */\nexport const getYouTubeVideoId = (url: string): string | null => {\n try {\n const u = new URL(url);\n const hostType = isValidYouTubeHost(u.hostname.toLowerCase());\n\n if (!hostType) return null;\n\n if (hostType === 'youtu.be') {\n return extractYoutuBeId(u.pathname);\n }\n\n return extractYouTubeId(u.pathname, u.searchParams);\n } catch {\n return null;\n }\n};\n\n/**\n * Generate YouTube embed URL for iframe\n *\n * @param videoId - The YouTube video ID\n * @returns The embed URL for the video\n */\nexport const getYouTubeEmbedUrl = (videoId: string): string => {\n return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=0&rel=0&modestbranding=1`;\n};\n","import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';\nimport { cn } from '../../utils/utils';\n\n/**\n * Base text component props\n */\ntype BaseTextProps = {\n /** Content to be displayed */\n children?: ReactNode;\n /** Text size variant */\n size?:\n | '2xs'\n | 'xs'\n | 'sm'\n | 'md'\n | 'lg'\n | 'xl'\n | '2xl'\n | '3xl'\n | '4xl'\n | '5xl'\n | '6xl';\n /** Font weight variant */\n weight?:\n | 'hairline'\n | 'light'\n | 'normal'\n | 'medium'\n | 'semibold'\n | 'bold'\n | 'extrabold'\n | 'black';\n /** Color variant - white for light backgrounds, black for dark backgrounds */\n color?: string;\n /** Additional CSS classes to apply */\n className?: string;\n};\n\n/**\n * Polymorphic text component props that ensures type safety based on the 'as' prop\n */\ntype TextProps<T extends ElementType = 'p'> = BaseTextProps & {\n /** HTML tag to render */\n as?: T;\n} & Omit<ComponentPropsWithoutRef<T>, keyof BaseTextProps>;\n\n/**\n * Text component for Analytica Ensino platforms\n *\n * A flexible polymorphic text component with multiple sizes, weights, and colors.\n * Automatically adapts to dark and light themes with full type safety.\n *\n * @param children - The content to display\n * @param size - The text size variant (2xs, xs, sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl)\n * @param weight - The font weight variant (hairline, light, normal, medium, semibold, bold, extrabold, black)\n * @param color - The color variant - adapts to theme\n * @param as - The HTML tag to render - determines allowed attributes via TypeScript\n * @param className - Additional CSS classes\n * @param props - HTML attributes valid for the chosen tag only\n * @returns A styled text element with type-safe attributes\n *\n * @example\n * ```tsx\n * <Text size=\"lg\" weight=\"bold\" color=\"text-info-800\">\n * This is a large, bold text\n * </Text>\n *\n * <Text as=\"a\" href=\"/link\" target=\"_blank\">\n * Link with type-safe anchor attributes\n * </Text>\n *\n * <Text as=\"button\" onClick={handleClick} disabled>\n * Button with type-safe button attributes\n * </Text>\n * ```\n */\nconst Text = <T extends ElementType = 'p'>({\n children,\n size = 'md',\n weight = 'normal',\n color = 'text-text-950',\n as,\n className = '',\n ...props\n}: TextProps<T>) => {\n let sizeClasses = '';\n let weightClasses = '';\n\n // Text size classes mapping\n const sizeClassMap = {\n '2xs': 'text-2xs',\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-md',\n lg: 'text-lg',\n xl: 'text-xl',\n '2xl': 'text-2xl',\n '3xl': 'text-3xl',\n '4xl': 'text-4xl',\n '5xl': 'text-5xl',\n '6xl': 'text-6xl',\n } as const;\n\n sizeClasses = sizeClassMap[size] ?? sizeClassMap.md;\n\n // Font weight classes mapping\n const weightClassMap = {\n hairline: 'font-hairline',\n light: 'font-light',\n normal: 'font-normal',\n medium: 'font-medium',\n semibold: 'font-semibold',\n bold: 'font-bold',\n extrabold: 'font-extrabold',\n black: 'font-black',\n } as const;\n\n weightClasses = weightClassMap[weight] ?? weightClassMap.normal;\n\n const baseClasses = 'font-primary';\n const Component = as ?? ('p' as ElementType);\n\n return (\n <Component\n className={cn(baseClasses, sizeClasses, weightClasses, color, className)}\n {...props}\n >\n {children}\n </Component>\n );\n};\n\nexport default Text;\n","import { HTMLAttributes, ReactNode } from 'react';\nimport { Bell } from 'phosphor-react';\nimport { cn } from '../../utils/utils';\n\n/**\n * Lookup table for variant and action class combinations\n */\nconst VARIANT_ACTION_CLASSES = {\n solid: {\n error: 'bg-error-background text-error-700 focus-visible:outline-none',\n warning: 'bg-warning text-warning-800 focus-visible:outline-none',\n success: 'bg-success text-success-800 focus-visible:outline-none',\n info: 'bg-info text-info-800 focus-visible:outline-none',\n muted: 'bg-background-muted text-background-800 focus-visible:outline-none',\n },\n outlined: {\n error:\n 'bg-error text-error-700 border border-error-300 focus-visible:outline-none',\n warning:\n 'bg-warning text-warning-800 border border-warning-300 focus-visible:outline-none',\n success:\n 'bg-success text-success-800 border border-success-300 focus-visible:outline-none',\n info: 'bg-info text-info-800 border border-info-300 focus-visible:outline-none',\n muted:\n 'bg-background-muted text-background-800 border border-border-300 focus-visible:outline-none',\n },\n exams: {\n exam1: 'bg-exam-1 text-info-700 focus-visible:outline-none',\n exam2: 'bg-exam-2 text-typography-1 focus-visible:outline-none',\n exam3: 'bg-exam-3 text-typography-2 focus-visible:outline-none',\n exam4: 'bg-exam-4 text-success-700 focus-visible:outline-none',\n },\n examsOutlined: {\n exam1:\n 'bg-exam-1 text-info-700 border border-info-700 focus-visible:outline-none',\n exam2:\n 'bg-exam-2 text-typography-1 border border-typography-1 focus-visible:outline-none',\n exam3:\n 'bg-exam-3 text-typography-2 border border-typography-2 focus-visible:outline-none',\n exam4:\n 'bg-exam-4 text-success-700 border border-success-700 focus-visible:outline-none',\n },\n resultStatus: {\n negative: 'bg-error text-error-800 focus-visible:outline-none',\n positive: 'bg-success text-success-800 focus-visible:outline-none',\n },\n notification: 'text-primary',\n} as const;\n\n/**\n * Lookup table for size classes\n */\nconst SIZE_CLASSES = {\n small: 'text-2xs px-2 py-1',\n medium: 'text-xs px-2 py-1',\n large: 'text-sm px-2 py-1',\n} as const;\n\nconst SIZE_CLASSES_ICON = {\n small: 'size-3',\n medium: 'size-3.5',\n large: 'size-4',\n} as const;\n\n/**\n * Badge component props interface\n */\ntype BadgeProps = {\n /** Content to be displayed inside the badge */\n children?: ReactNode;\n /** Ícone à direita do texto */\n iconRight?: ReactNode;\n /** Ícone à esquerda do texto */\n iconLeft?: ReactNode;\n /** Size of the badge */\n size?: 'small' | 'medium' | 'large';\n /** Visual variant of the badge */\n variant?:\n | 'solid'\n | 'outlined'\n | 'exams'\n | 'examsOutlined'\n | 'resultStatus'\n | 'notification';\n /** Action type of the badge */\n action?:\n | 'error'\n | 'warning'\n | 'success'\n | 'info'\n | 'muted'\n | 'exam1'\n | 'exam2'\n | 'exam3'\n | 'exam4'\n | 'positive'\n | 'negative';\n /** Additional CSS classes to apply */\n className?: string;\n notificationActive?: boolean;\n} & HTMLAttributes<HTMLDivElement>;\n\n/**\n * Badge component for Analytica Ensino platforms\n *\n * A flexible button component with multiple variants, sizes and actions.\n *\n * @param children - The content to display inside the badge\n * @param size - The size variant (extra-small, small, medium, large, extra-large)\n * @param variant - The visual style variant (solid, outline, link)\n * @param action - The action type (primary, positive, negative)\n * @param className - Additional CSS classes\n * @param props - All other standard div HTML attributes\n * @returns A styled badge element\n *\n * @example\n * ```tsx\n * <Badge variant=\"solid\" action=\"info\" size=\"medium\">\n * Information\n * </Badge>\n * ```\n */\nconst Badge = ({\n children,\n iconLeft,\n iconRight,\n size = 'medium',\n variant = 'solid',\n action = 'error',\n className = '',\n notificationActive = false,\n ...props\n}: BadgeProps) => {\n // Get classes from lookup tables\n const sizeClasses = SIZE_CLASSES[size];\n const sizeClassesIcon = SIZE_CLASSES_ICON[size];\n const variantActionMap = VARIANT_ACTION_CLASSES[variant] || {};\n const variantClasses =\n typeof variantActionMap === 'string'\n ? variantActionMap\n : ((variantActionMap as Record<string, string>)[action] ??\n (variantActionMap as Record<string, string>).muted ??\n '');\n\n const baseClasses =\n 'inline-flex items-center justify-center rounded-xs font-normal gap-1 relative';\n\n const baseClassesIcon = 'flex items-center';\n if (variant === 'notification') {\n return (\n <div\n className={cn(baseClasses, variantClasses, sizeClasses, className)}\n {...props}\n >\n <Bell size={24} className=\"text-current\" aria-hidden=\"true\" />\n\n {notificationActive && (\n <span\n data-testid=\"notification-dot\"\n className=\"absolute top-[5px] right-[10px] block h-2 w-2 rounded-full bg-indicator-error ring-2 ring-white\"\n />\n )}\n </div>\n );\n }\n return (\n <div\n className={cn(baseClasses, variantClasses, sizeClasses, className)}\n {...props}\n >\n {iconLeft && (\n <span className={cn(baseClassesIcon, sizeClassesIcon)}>{iconLeft}</span>\n )}\n {children}\n {iconRight && (\n <span className={cn(baseClassesIcon, sizeClassesIcon)}>\n {iconRight}\n </span>\n )}\n </div>\n );\n};\n\nexport default Badge;\n","import { CheckCircle, XCircle } from 'phosphor-react';\nimport Badge from '../Badge/Badge';\nimport { RadioGroup, RadioGroupItem } from '../Radio/Radio';\nimport { forwardRef, HTMLAttributes, useId, useState } from 'react';\nimport { cn } from '../../utils/utils';\n\n/**\n * Interface para definir uma alternativa\n */\nexport interface Alternative {\n value: string;\n label: string;\n status?: 'correct' | 'incorrect' | 'neutral';\n disabled?: boolean;\n description?: string;\n}\n\n/**\n * Props do componente AlternativesList\n */\nexport interface AlternativesListProps {\n /** Lista de alt