analytica-frontend-lib
Version:
Repositório público dos componentes utilizados nas plataformas da Analytica Ensino
1 lines • 186 kB
Source Map (JSON)
{"version":3,"sources":["../../src/components/Support/Support.tsx","../../src/utils/utils.ts","../../src/components/Text/Text.tsx","../../src/components/SelectionButton/SelectionButton.tsx","../../src/components/Input/Input.tsx","../../src/components/TextArea/TextArea.tsx","../../src/components/Button/Button.tsx","../../src/components/Select/Select.tsx","../../src/components/Badge/Badge.tsx","../../src/components/Skeleton/Skeleton.tsx","../../src/components/Toast/Toast.tsx","../../src/components/Menu/Menu.tsx","../../src/components/Support/schema/index.ts","../../src/components/Support/components/TicketModal.tsx","../../src/components/Divider/Divider.tsx","../../src/components/Modal/Modal.tsx","../../src/components/Modal/utils/videoUtils.ts","../../src/types/support.ts","../../src/components/Support/utils/supportUtils.tsx"],"sourcesContent":["import { useState, useEffect } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport {\n KeyIcon,\n InfoIcon,\n BugIcon,\n CaretRightIcon,\n} from '@phosphor-icons/react';\nimport dayjs from 'dayjs';\nimport Text from '../Text/Text';\nimport SelectionButton from '../SelectionButton/SelectionButton';\nimport Input from '../Input/Input';\nimport TextArea from '../TextArea/TextArea';\nimport Button from '../Button/Button';\nimport Select, {\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '../Select/Select';\nimport Badge from '../Badge/Badge';\nimport { SkeletonText, SkeletonRounded } from '../Skeleton/Skeleton';\nimport Toast from '../Toast/Toast';\nimport Menu, { MenuContent, MenuItem } from '../Menu/Menu';\nimport { supportSchema, SupportFormData } from './schema';\nimport { TicketModal } from './components/TicketModal';\nimport {\n SupportTicket,\n SupportStatus,\n SupportCategory,\n TabType,\n ProblemType,\n getStatusBadgeAction,\n getStatusText,\n getCategoryText,\n CreateSupportTicketRequest,\n CreateSupportTicketResponse,\n SupportTicketAPI,\n GetSupportTicketsResponse,\n mapApiStatusToInternal,\n mapInternalStatusToApi,\n SupportApiClient,\n} from '../../types/support';\nimport { getCategoryIcon } from './utils/supportUtils';\nimport SupportImage from '../../assets/img/suporthistory.png';\n\n// Individual ticket component to reduce nesting\nconst TicketCard = ({\n ticket,\n onTicketClick,\n}: {\n ticket: SupportTicket;\n onTicketClick: (ticket: SupportTicket) => void;\n}) => (\n <button\n key={ticket.id}\n type=\"button\"\n className=\"flex items-center justify-between p-4 bg-background rounded-xl cursor-pointer w-full text-left hover:bg-background-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2\"\n onClick={() => onTicketClick(ticket)}\n >\n <div className=\"flex flex-col\">\n <Text size=\"xs\" weight=\"bold\" className=\"text-text-900\">\n {ticket.title}\n </Text>\n </div>\n\n <div className=\"flex items-center gap-3\">\n <Badge\n variant=\"solid\"\n className=\"flex items-center gap-1\"\n action={getStatusBadgeAction(ticket.status)}\n >\n {getStatusText(ticket.status)}\n </Badge>\n <Badge variant=\"solid\" className=\"flex items-center gap-1\" action=\"muted\">\n {getCategoryIcon(ticket.category, 18)}\n {getCategoryText(ticket.category)}\n </Badge>\n <CaretRightIcon size={24} className=\"text-text-800\" />\n </div>\n </button>\n);\n\n// Ticket group component to reduce nesting\nconst TicketGroup = ({\n date,\n tickets,\n onTicketClick,\n}: {\n date: string;\n tickets: SupportTicket[];\n onTicketClick: (ticket: SupportTicket) => void;\n}) => (\n <div key={date} className=\"space-y-4\">\n <Text size=\"md\" weight=\"bold\" className=\"text-text-900\">\n {dayjs(date).format('DD MMM YYYY')}\n </Text>\n <div className=\"space-y-3\">\n {tickets.map((ticket) => (\n <TicketCard\n key={ticket.id}\n ticket={ticket}\n onTicketClick={onTicketClick}\n />\n ))}\n </div>\n </div>\n);\n\n// Empty state component\nconst EmptyState = ({ imageSrc }: { imageSrc?: string }) => (\n <div className=\"flex flex-row justify-center items-center mt-48\">\n {imageSrc && <img src={imageSrc} alt=\"Imagem de suporte\" />}\n <Text size=\"3xl\" weight=\"semibold\">\n Nenhum pedido encontrado.\n </Text>\n </div>\n);\n\n// Skeleton component for ticket loading\nconst TicketSkeleton = () => (\n <div className=\"space-y-6\">\n {[0, 1].map((groupIndex) => (\n <div key={groupIndex} className=\"space-y-4\">\n {/* Date skeleton */}\n <SkeletonText width=\"150px\" height={20} />\n\n {/* Tickets skeleton */}\n <div className=\"space-y-3\">\n {[0, 1].map((ticketIndex) => (\n <SkeletonRounded\n key={ticketIndex}\n width=\"100%\"\n height={72}\n className=\"p-4\"\n />\n ))}\n </div>\n </div>\n ))}\n </div>\n);\n\nexport interface SupportProps {\n /** API client instance for making requests */\n apiClient: SupportApiClient;\n /** Current user ID */\n userId?: string;\n /** Custom empty state image source (optional, uses default if not provided) */\n emptyStateImage?: string;\n /** Title displayed in the header */\n title?: string;\n /** Callback when a ticket is successfully created */\n onTicketCreated?: () => void;\n /** Callback when a ticket is successfully closed */\n onTicketClosed?: () => void;\n}\n\nconst Support = ({\n apiClient,\n userId,\n emptyStateImage,\n title = 'Suporte',\n onTicketCreated,\n onTicketClosed,\n}: SupportProps) => {\n const [activeTab, setActiveTab] = useState<TabType>('criar-pedido');\n const [selectedProblem, setSelectedProblem] = useState<ProblemType>(null);\n\n // Filtros do histórico\n const [statusFilter, setStatusFilter] = useState<string>('todos');\n const [categoryFilter, setCategoryFilter] = useState<string>('todos');\n\n // Estado do modal\n const [selectedTicket, setSelectedTicket] = useState<SupportTicket | null>(\n null\n );\n const [isModalOpen, setIsModalOpen] = useState(false);\n\n // Estados para feedback\n const [submitError, setSubmitError] = useState<string | null>(null);\n const [showSuccessToast, setShowSuccessToast] = useState(false);\n const [showCloseSuccessToast, setShowCloseSuccessToast] = useState(false);\n const [showCloseErrorToast, setShowCloseErrorToast] = useState(false);\n\n // Estados para histórico\n const [allTickets, setAllTickets] = useState<SupportTicketAPI[]>([]);\n const [loadingTickets, setLoadingTickets] = useState(false);\n const [currentPage, setCurrentPage] = useState(1);\n const ITEMS_PER_PAGE = 10;\n\n // Pagination handlers\n const handlePrevPage = () => {\n if (currentPage > 1) {\n setCurrentPage(currentPage - 1);\n }\n };\n\n const handleNextPage = () => {\n const totalPages = Math.ceil(filteredTickets.length / ITEMS_PER_PAGE);\n if (currentPage < totalPages) {\n setCurrentPage(currentPage + 1);\n }\n };\n\n // useEffect para buscar tickets quando mudar aba ou filtros\n useEffect(() => {\n if (activeTab === 'historico') {\n fetchTickets(statusFilter);\n setCurrentPage(1); // Reset to first page when filters change\n }\n }, [activeTab, statusFilter]);\n\n // Reset page when category filter changes\n useEffect(() => {\n setCurrentPage(1);\n }, [categoryFilter]);\n\n // Funções auxiliares\n\n // Converter dados da API para formato do componente\n const convertApiTicketToComponent = (\n apiTicket: SupportTicketAPI\n ): SupportTicket => {\n return {\n id: apiTicket.id,\n title: apiTicket.subject,\n status: mapApiStatusToInternal(apiTicket.status),\n createdAt: apiTicket.createdAt,\n category: apiTicket.type as SupportCategory,\n description: apiTicket.description,\n };\n };\n\n // Filtrar tickets por categoria e ordenar do mais novo para o mais antigo\n const filteredTickets = allTickets\n .map(convertApiTicketToComponent)\n .filter((ticket) => {\n const categoryMatch =\n categoryFilter === 'todos' || ticket.category === categoryFilter;\n return categoryMatch;\n })\n .sort(\n (a, b) =>\n new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()\n );\n\n // Calcular paginação no frontend\n const totalPages = Math.ceil(filteredTickets.length / ITEMS_PER_PAGE);\n const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;\n const endIndex = startIndex + ITEMS_PER_PAGE;\n const paginatedTickets = filteredTickets.slice(startIndex, endIndex);\n\n const groupedTickets = paginatedTickets.reduce(\n (groups, ticket) => {\n const date = dayjs(ticket.createdAt).format('YYYY-MM-DD');\n if (!groups[date]) {\n groups[date] = [];\n }\n groups[date].push(ticket);\n return groups;\n },\n {} as Record<string, SupportTicket[]>\n );\n\n // React Hook Form setup\n const {\n register,\n handleSubmit,\n setValue,\n reset,\n formState: { errors, isSubmitting },\n } = useForm<SupportFormData>({\n resolver: zodResolver(supportSchema),\n defaultValues: {\n problemType: undefined,\n title: '',\n description: '',\n },\n });\n\n // Fetch tickets from API - buscar todos de uma vez com limit=100\n const fetchTickets = async (status?: string) => {\n setLoadingTickets(true);\n try {\n const params = new URLSearchParams({\n page: '1',\n limit: '100', // Buscar o máximo permitido pela API\n });\n\n if (status && status !== 'todos') {\n // Convert internal status to API status\n const apiStatus = mapInternalStatusToApi(status as SupportStatus);\n params.append('status', apiStatus);\n }\n\n const response = await apiClient.get<GetSupportTicketsResponse>(\n `/support?${params.toString()}`\n );\n\n setAllTickets(response.data.data.support);\n } catch (error) {\n console.error('Erro ao buscar tickets:', error);\n setAllTickets([]);\n } finally {\n setLoadingTickets(false);\n }\n };\n\n // Handle problem type selection\n const handleProblemSelection = (type: ProblemType) => {\n setSelectedProblem(type);\n if (type) {\n setValue('problemType', type, { shouldValidate: true });\n }\n };\n\n // Handle form submission\n const onSubmit = async (data: SupportFormData) => {\n setSubmitError(null);\n\n try {\n const requestData: CreateSupportTicketRequest = {\n subject: data.title,\n description: data.description,\n type: data.problemType,\n };\n\n await apiClient.post<CreateSupportTicketResponse>(\n '/support',\n requestData\n );\n\n // Show success toast\n setShowSuccessToast(true);\n setTimeout(() => setShowSuccessToast(false), 4000);\n\n // Reset form after successful submission\n setSelectedProblem(null);\n reset();\n\n // Switch to history tab and refresh tickets list\n setActiveTab('historico');\n fetchTickets(statusFilter);\n\n // Call callback if provided\n onTicketCreated?.();\n } catch (error) {\n console.error('Erro ao criar ticket de suporte:', error);\n setSubmitError('Erro ao criar ticket. Tente novamente.');\n }\n };\n\n // Handle ticket click\n const handleTicketClick = (ticket: SupportTicket) => {\n setSelectedTicket(ticket);\n setIsModalOpen(true);\n };\n\n // Handle modal close\n const handleModalClose = () => {\n setIsModalOpen(false);\n setSelectedTicket(null);\n };\n\n // Handle ticket close\n const handleTicketClose = async (ticketId: string) => {\n try {\n await apiClient.patch(`/support/${ticketId}`, {\n status: mapInternalStatusToApi(SupportStatus.ENCERRADO),\n });\n\n // Show success toast\n setShowCloseSuccessToast(true);\n setTimeout(() => setShowCloseSuccessToast(false), 4000);\n\n // Refresh tickets list\n if (activeTab === 'historico') {\n fetchTickets(statusFilter);\n }\n\n // Call callback if provided\n onTicketClosed?.();\n } catch (error) {\n console.error('Erro ao encerrar ticket:', error);\n // Show error toast\n setShowCloseErrorToast(true);\n setTimeout(() => setShowCloseErrorToast(false), 4000);\n }\n };\n\n const problemTypes = [\n {\n id: SupportCategory.TECNICO,\n title: 'Técnico',\n icon: <BugIcon size={24} />,\n },\n {\n id: SupportCategory.ACESSO,\n title: 'Acesso',\n icon: <KeyIcon size={24} />,\n },\n {\n id: SupportCategory.OUTROS,\n title: 'Outros',\n icon: <InfoIcon size={24} />,\n },\n ];\n\n // Determine which image to use for empty state\n const emptyImage = emptyStateImage || SupportImage;\n\n return (\n <div className=\"flex flex-col w-full h-full relative justify-start items-center mb-5 overflow-y-auto\">\n <div className=\"flex flex-col w-full h-full max-w-[992px] z-10 lg:px-0 px-4\">\n {/* Header */}\n <div className=\"space-y-4\">\n <div className=\"flex w-full mb-4 flex-row items-center justify-between not-lg:gap-4 lg:gap-6\">\n <h1 className=\"font-bold leading-[28px] tracking-[0.2px] text-text-950 text-xl mt-4 sm:text-2xl sm:flex-1 sm:self-end sm:mt-0\">\n {title}\n </h1>\n <div className=\"sm:flex-shrink-0 sm:self-end\">\n <Menu\n value={activeTab}\n defaultValue=\"criar-pedido\"\n variant=\"menu2\"\n onValueChange={(value) => setActiveTab(value as TabType)}\n className=\"bg-transparent shadow-none px-0\"\n >\n <MenuContent variant=\"menu2\">\n <MenuItem\n variant=\"menu2\"\n value=\"criar-pedido\"\n className=\"whitespace-nowrap px-17 not-sm:px-5\"\n >\n Criar Pedido\n </MenuItem>\n <MenuItem\n variant=\"menu2\"\n value=\"historico\"\n className=\"whitespace-nowrap px-17 not-sm:px-5\"\n >\n Histórico\n </MenuItem>\n </MenuContent>\n </Menu>\n </div>\n </div>\n\n {/* Content for Criar Pedido tab */}\n {activeTab === 'criar-pedido' && (\n <div className=\"space-y-2\">\n <Text as=\"h2\" size=\"md\" weight=\"bold\" className=\"text-text-900\">\n Selecione o tipo de problema\n </Text>\n\n <div className=\"flex flex-col sm:flex-row gap-2 sm:gap-4\">\n {problemTypes.map((type) => (\n <SelectionButton\n key={type.id}\n icon={type.icon}\n label={type.title}\n selected={selectedProblem === type.id}\n onClick={() => handleProblemSelection(type.id)}\n className=\"w-full p-4\"\n />\n ))}\n </div>\n {errors.problemType && (\n <Text size=\"sm\" className=\"text-red-500 mt-1\">\n {errors.problemType.message}\n </Text>\n )}\n </div>\n )}\n\n {selectedProblem && activeTab === 'criar-pedido' && (\n <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-4\">\n <div className=\"space-y-1\">\n <Input\n size=\"large\"\n variant=\"rounded\"\n label=\"Título\"\n placeholder=\"Digite o título\"\n {...register('title')}\n errorMessage={errors.title?.message}\n />\n </div>\n <div className=\"space-y-1\">\n <TextArea\n size=\"large\"\n label=\"Descrição\"\n placeholder=\"Descreva o problema aqui\"\n {...register('description')}\n errorMessage={errors.description?.message}\n />\n </div>\n <Button\n size=\"large\"\n className=\"float-end mt-10\"\n type=\"submit\"\n disabled={isSubmitting}\n >\n {isSubmitting ? 'Enviando...' : 'Enviar Pedido'}\n </Button>\n\n {/* Error message */}\n {submitError && (\n <div className=\"mt-4 p-4 bg-red-100 border border-red-400 text-red-700 rounded\">\n <Text size=\"sm\" className=\"text-red-700\">\n {submitError}\n </Text>\n </div>\n )}\n </form>\n )}\n\n {/* Content for Historico tab */}\n {activeTab === 'historico' && (\n <div className=\"space-y-6\">\n {/* Filtros */}\n <div className=\"flex gap-4\">\n <div className=\"flex flex-col flex-1/2 space-y-1\">\n <Select\n label=\"Status\"\n size=\"large\"\n value={statusFilter}\n onValueChange={setStatusFilter}\n >\n <SelectTrigger variant=\"rounded\" className=\"\">\n <SelectValue placeholder=\"Todos\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"todos\">Todos</SelectItem>\n <SelectItem value={SupportStatus.ABERTO}>\n Aberto\n </SelectItem>\n <SelectItem value={SupportStatus.RESPONDIDO}>\n Respondido\n </SelectItem>\n <SelectItem value={SupportStatus.ENCERRADO}>\n Encerrado\n </SelectItem>\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"flex flex-col flex-1/2 space-y-1\">\n <Select\n label=\"Tipo\"\n size=\"large\"\n value={categoryFilter}\n onValueChange={setCategoryFilter}\n >\n <SelectTrigger variant=\"rounded\" className=\"\">\n <SelectValue placeholder=\"Todos\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"todos\">Todos</SelectItem>\n <SelectItem value={SupportCategory.TECNICO}>\n <BugIcon size={16} /> Técnico\n </SelectItem>\n <SelectItem value={SupportCategory.ACESSO}>\n <KeyIcon size={16} /> Acesso\n </SelectItem>\n <SelectItem value={SupportCategory.OUTROS}>\n <InfoIcon size={16} /> Outros\n </SelectItem>\n </SelectContent>\n </Select>\n </div>\n </div>\n\n {/* Lista de tickets */}\n {(() => {\n if (loadingTickets) {\n return <TicketSkeleton />;\n }\n\n if (Object.keys(groupedTickets).length === 0) {\n return <EmptyState imageSrc={emptyImage} />;\n }\n\n return (\n <div className=\"space-y-6\">\n {Object.entries(groupedTickets)\n .sort(\n ([a], [b]) =>\n new Date(b).getTime() - new Date(a).getTime()\n )\n .map(([date, tickets]) => (\n <TicketGroup\n key={date}\n date={date}\n tickets={tickets}\n onTicketClick={handleTicketClick}\n />\n ))}\n </div>\n );\n })()}\n\n {/* Paginação */}\n {!loadingTickets && totalPages > 1 && (\n <div className=\"flex justify-center items-center gap-4 mt-6\">\n <Button\n variant=\"outline\"\n size=\"small\"\n onClick={handlePrevPage}\n disabled={currentPage === 1}\n >\n Anterior\n </Button>\n <Text size=\"sm\" className=\"text-text-600\">\n Página {currentPage} de {totalPages}\n </Text>\n <Button\n variant=\"outline\"\n size=\"small\"\n onClick={handleNextPage}\n disabled={currentPage === totalPages}\n >\n Próxima\n </Button>\n </div>\n )}\n </div>\n )}\n </div>\n </div>\n\n {/* Modal do ticket */}\n {selectedTicket && (\n <TicketModal\n ticket={selectedTicket}\n isOpen={isModalOpen}\n onClose={handleModalClose}\n onTicketClose={handleTicketClose}\n apiClient={apiClient}\n userId={userId}\n />\n )}\n\n {/* Success Toast */}\n {showSuccessToast && (\n <div className=\"fixed top-4 left-1/2 transform -translate-x-1/2 z-50\">\n <Toast\n title=\"Pedido enviado!\"\n description=\"Agora você pode acompanhar o andamento do seu pedido.\"\n variant=\"solid\"\n onClose={() => setShowSuccessToast(false)}\n />\n </div>\n )}\n\n {/* Close Success Toast */}\n {showCloseSuccessToast && (\n <div className=\"fixed top-4 left-1/2 transform -translate-x-1/2 z-50\">\n <Toast\n title=\"Pedido encerrado!\"\n description=\"\"\n variant=\"solid\"\n onClose={() => setShowCloseSuccessToast(false)}\n />\n </div>\n )}\n\n {/* Close Error Toast */}\n {showCloseErrorToast && (\n <div className=\"fixed top-4 left-1/2 transform -translate-x-1/2 z-50\">\n <Toast\n title=\"Erro ao encerrar pedido\"\n description=\"Não foi possível encerrar o pedido. Tente novamente.\"\n variant=\"solid\"\n action=\"warning\"\n onClose={() => setShowCloseErrorToast(false)}\n />\n </div>\n )}\n </div>\n );\n};\n\nexport default Support;\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 { 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 { ButtonHTMLAttributes, ReactNode, forwardRef } from 'react';\nimport { cn } from '../../utils/utils';\n\n/**\n * SelectionButton component props interface\n */\ntype SelectionButtonProps = {\n /** Ícone a ser exibido no botão */\n icon: ReactNode;\n /** Texto/label a ser exibido ao lado do ícone */\n label: string;\n /** Estado de seleção do botão */\n selected?: boolean;\n /** Additional CSS classes to apply */\n className?: string;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\n/**\n * SelectionButton component for Analytica Ensino platforms\n *\n * Um botão com ícone e texto para ações e navegação com estado de seleção.\n * Ideal para filtros, tags, categorias, seleção de tipos, etc.\n * Suporta forwardRef para acesso programático ao elemento DOM.\n *\n * @param icon - O ícone a ser exibido no botão\n * @param label - O texto/label a ser exibido\n * @param selected - Estado de seleção do botão\n * @param className - Classes CSS adicionais\n * @param props - Todos os outros atributos HTML padrão de button\n * @returns Um elemento button estilizado\n *\n * @example\n * ```tsx\n * <SelectionButton\n * icon={<TagIcon />}\n * label=\"Categoria\"\n * selected={false}\n * onClick={() => handleSelection()}\n * />\n * ```\n *\n * @example\n * ```tsx\n * // Usando ref para foco programático\n * const buttonRef = useRef<HTMLButtonElement>(null);\n *\n * const handleFocus = () => {\n * buttonRef.current?.focus();\n * };\n *\n * <SelectionButton\n * ref={buttonRef}\n * icon={<TagIcon />}\n * label=\"Categoria\"\n * selected={isSelected}\n * onClick={() => setSelected(!isSelected)}\n * />\n * ```\n */\nconst SelectionButton = forwardRef<HTMLButtonElement, SelectionButtonProps>(\n (\n { icon, label, selected = false, className = '', disabled, ...props },\n ref\n ) => {\n // Classes base para todos os estados\n const baseClasses = [\n 'inline-flex',\n 'items-center',\n 'justify-start',\n 'gap-2',\n 'p-4',\n 'rounded-xl',\n 'cursor-pointer',\n 'border',\n 'border-border-50',\n 'bg-background',\n 'text-sm',\n 'text-text-700',\n 'font-bold',\n 'shadow-soft-shadow-1',\n 'hover:bg-background-100',\n 'focus-visible:outline-none',\n 'focus-visible:ring-2',\n 'focus-visible:ring-indicator-info',\n 'focus-visible:ring-offset-0',\n 'focus-visible:shadow-none',\n 'active:ring-2',\n 'active:ring-primary-950',\n 'active:ring-offset-0',\n 'active:shadow-none',\n 'disabled:opacity-50',\n 'disabled:cursor-not-allowed',\n ];\n\n const stateClasses = selected\n ? ['ring-primary-950', 'ring-2', 'ring-offset-0', 'shadow-none']\n : [];\n\n const allClasses = [...baseClasses, ...stateClasses].join(' ');\n\n return (\n <button\n ref={ref}\n type=\"button\"\n className={cn(allClasses, className)}\n disabled={disabled}\n aria-pressed={selected}\n {...props}\n >\n <span className=\"flex items-center justify-center w-6 h-6\">{icon}</span>\n <span>{label}</span>\n </button>\n );\n }\n);\n\nSelectionButton.displayName = 'SelectionButton';\n\nexport default SelectionButton;\n","import { WarningCircle, Eye, EyeSlash } from 'phosphor-react';\nimport {\n InputHTMLAttributes,\n ReactNode,\n forwardRef,\n useState,\n useId,\n useMemo,\n} from 'react';\n\n/**\n * Lookup table for size classes\n */\nconst SIZE_CLASSES = {\n small: 'text-sm',\n medium: 'text-md',\n large: 'text-lg',\n 'extra-large': 'text-xl',\n} as const;\n\n/**\n * Lookup table for state classes\n */\nconst STATE_CLASSES = {\n default:\n 'border-border-300 placeholder:text-text-600 hover:border-border-400',\n error: 'border-2 border-indicator-error placeholder:text-text-600',\n disabled:\n 'border-border-300 placeholder:text-text-600 cursor-not-allowed opacity-40',\n 'read-only':\n 'border-transparent !text-text-600 cursor-default focus:outline-none bg-transparent',\n} as const;\n\n/**\n * Lookup table for variant classes\n */\nconst VARIANT_CLASSES = {\n outlined: 'border rounded-lg',\n underlined:\n 'border-0 border-b rounded-none bg-transparent focus:outline-none focus:border-primary-950 focus:border-b-2',\n rounded: 'border rounded-full',\n} as const;\n\n/**\n * Input component props interface\n */\ntype InputProps = {\n /** Label text displayed above the input */\n label?: string;\n /** Helper text displayed below the input */\n helperText?: string;\n /** Error message displayed below the input */\n errorMessage?: string;\n /** Size of the input */\n size?: 'small' | 'medium' | 'large' | 'extra-large';\n /** Visual variant of the input */\n variant?: 'outlined' | 'underlined' | 'rounded';\n /** Current state of the input */\n state?: 'default' | 'error' | 'disabled' | 'read-only';\n /** Icon to display on the left side of the input */\n iconLeft?: ReactNode;\n /** Icon to display on the right side of the input */\n iconRight?: ReactNode;\n /** Additional CSS classes to apply to the input */\n className?: string;\n /** Additional CSS classes to apply to the container */\n containerClassName?: string;\n} & Omit<InputHTMLAttributes<HTMLInputElement>, 'size'>;\n\n/**\n * Input component for Analytica Ensino platforms\n *\n * A flexible input component with multiple sizes, states, and support for icons.\n * Includes label, helper text, and error message functionality.\n * Features automatic password visibility toggle for password inputs.\n *\n * @param label - Optional label text displayed above the input\n * @param helperText - Optional helper text displayed below the input\n * @param errorMessage - Optional error message displayed below the input\n * @param size - The size variant (small, medium, large, extra-large)\n * @param variant - The visual variant (outlined, underlined, rounded)\n * @param state - The current state (default, error, disabled, read-only)\n * @param iconLeft - Optional icon displayed on the left side\n * @param iconRight - Optional icon displayed on the right side (overridden by password toggle for password inputs)\n * @param type - Input type (text, email, password, etc.) - password type automatically includes show/hide toggle\n * @param className - Additional CSS classes for the input\n * @param containerClassName - Additional CSS classes for the container\n * @param props - All other standard input HTML attributes\n * @returns A styled input element with optional label and helper text\n *\n * @example\n * ```tsx\n * // Basic input\n * <Input\n * label=\"Email\"\n * placeholder=\"Digite seu email\"\n * helperText=\"Usaremos apenas para contato\"\n * size=\"medium\"\n * variant=\"outlined\"\n * state=\"default\"\n * />\n *\n * // Password input with automatic toggle\n * <Input\n * label=\"Senha\"\n * type=\"password\"\n * placeholder=\"Digite sua senha\"\n * helperText=\"Clique no olho para mostrar/ocultar\"\n * />\n * ```\n */\n// Helper functions to reduce cognitive complexity\nconst getActualState = (\n disabled?: boolean,\n readOnly?: boolean,\n errorMessage?: string,\n state?: string\n): keyof typeof STATE_CLASSES => {\n if (disabled) return 'disabled';\n if (readOnly) return 'read-only';\n if (errorMessage) return 'error';\n return (state as keyof typeof STATE_CLASSES) || 'default';\n};\n\nconst getIconSize = (size: string) => {\n const iconSizeClasses = {\n small: 'w-4 h-4',\n medium: 'w-5 h-5',\n large: 'w-6 h-6',\n 'extra-large': 'w-7 h-7',\n };\n return (\n iconSizeClasses[size as keyof typeof iconSizeClasses] ||\n iconSizeClasses.medium\n );\n};\n\nconst getPasswordToggleConfig = (\n type?: string,\n disabled?: boolean,\n readOnly?: boolean,\n showPassword?: boolean,\n iconRight?: ReactNode\n) => {\n const isPasswordType = type === 'password';\n const shouldShowPasswordToggle = isPasswordType && !disabled && !readOnly;\n\n let actualIconRight = iconRight;\n let ariaLabel: string | undefined;\n\n if (shouldShowPasswordToggle) {\n actualIconRight = showPassword ? <EyeSlash /> : <Eye />;\n ariaLabel = showPassword ? 'Ocultar senha' : 'Mostrar senha';\n }\n\n return { shouldShowPasswordToggle, actualIconRight, ariaLabel };\n};\n\nconst getCombinedClasses = (\n actualState: keyof typeof STATE_CLASSES,\n variant: keyof typeof VARIANT_CLASSES\n) => {\n const stateClasses = STATE_CLASSES[actualState];\n const variantClasses = VARIANT_CLASSES[variant];\n\n // Special case: error state with underlined variant\n if (actualState === 'error' && variant === 'underlined') {\n return 'border-0 border-b-2 border-indicator-error rounded-none bg-transparent focus:outline-none focus:border-primary-950 placeholder:text-text-600';\n }\n\n // Special case: read-only state with underlined variant\n if (actualState === 'read-only' && variant === 'underlined') {\n return 'border-0 border-b-0 rounded-none bg-transparent focus:outline-none !text-text-900 cursor-default';\n }\n\n return `${stateClasses} ${variantClasses}`;\n};\n\nconst Input = forwardRef<HTMLInputElement, InputProps>(\n (\n {\n label,\n helperText,\n errorMessage,\n size = 'medium',\n variant = 'outlined',\n state = 'default',\n iconLeft,\n iconRight,\n className = '',\n containerClassName = '',\n disabled,\n readOnly,\n required,\n id,\n type = 'text',\n ...props\n },\n ref\n ) => {\n // State for password visibility toggle\n const [showPassword, setShowPassword] = useState(false);\n const isPasswordType = type === 'password';\n const actualType = isPasswordType && showPassword ? 'text' : type;\n const actualState = getActualState(disabled, readOnly, errorMessage, state);\n\n // Get classes from lookup tables\n const sizeClasses = SIZE_CLASSES[size];\n const combinedClasses = useMemo(\n () => getCombinedClasses(actualState, variant),\n [actualState, variant]\n );\n const iconSize = getIconSize(size);\n\n const baseClasses = `bg-background w-full py-2 ${\n actualState === 'read-only' ? 'px-0' : 'px-3'\n } font-normal text-text-900 focus:outline-primary-950`;\n\n // Generate unique ID if not provided\n const generatedId = useId();\n const inputId = id ?? `input-${generatedId}`;\n\n // Handle password visibility toggle\n const togglePasswordVisibility = () => setShowPassword(!showPassword);\n\n // Get password toggle configuration\n const { shouldShowPasswordToggle, actualIconRight, ariaLabel } =\n getPasswordToggleConfig(\n type,\n disabled,\n readOnly,\n showPassword,\n iconRight\n );\n\n return (\n <div className={`${containerClassName}`}>\n {/* Label */}\n {label && (\n <label\n htmlFor={inputId}\n className={`block font-bold text-text-900 mb-1.5 ${sizeClasses}`}\n >\n {label}{' '}\n {required && <span className=\"text-indicator-error\">*</span>}\n </label>\n )}\n\n {/* Input Container */}\n <div className=\"relative\">\n {/* Left Icon */}\n {iconLeft && (\n <div className=\"absolute left-3 top-1/2 transform -translate-y-1/2 pointer-events-none\">\n <span\n className={`${iconSize} text-text-400 flex items-center justify-center`}\n >\n {iconLeft}\n </span>\n </div>\n )}\n\n {/* Input Field */}\n <input\n ref={ref}\n id={inputId}\n type={actualType}\n className={`${baseClasses} ${sizeClasses} ${combinedClasses} ${\n iconLeft ? 'pl-10' : ''\n } ${actualIconRight ? 'pr-10' : ''} ${className}`}\n disabled={disabled}\n readOnly={readOnly}\n required={required}\n aria-invalid={actualState === 'error' ? 'true' : undefined}\n {...props}\n />\n\n {/* Right Icon */}\n {actualIconRight &&\n (shouldShowPasswordToggle ? (\n <button\n type=\"button\"\n className=\"absolute right-3 top-1/2 transform -translate-y-1/2 cursor-pointer border-0 bg-transparent p-0\"\n onClick={togglePasswordVisibility}\n aria-label={ariaLabel}\n >\n <span\n className={`${iconSize} text-text-400 flex items-center justify-center hover:text-text-600 transition-colors`}\n >\n {actualIconRight}\n </span>\n </button>\n ) : (\n <div className=\"absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none\">\n <span\n className={`${iconSize} text-text-400 flex items-center justify-center`}\n >\n {actualIconRight}\n </span>\n </div>\n ))}\n </div>\n\n {/* Helper Text or Error Message */}\n <div className=\"mt-1.5 gap-1.5\">\n {helperText && <p className=\"text-sm text-text-500\">{helperText}</p>}\n {errorMessage && (\n <p className=\"flex gap-1 items-center text-sm text-indicator-error\">\n <WarningCircle size={16} /> {errorMessage}\n </p>\n )}\n </div>\n </div>\n );\n }\n);\n\nexport default Input;\n","import {\n TextareaHTMLAttributes,\n ReactNode,\n forwardRef,\n useState,\n useId,\n ChangeEvent,\n FocusEvent,\n} from 'react';\nimport { WarningCircle } from 'phosphor-react';\nimport Text from '../Text/Text';\nimport { cn } from '../../utils/utils';\n\n/**\n * TextArea size variants\n */\ntype TextAreaSize = 'small' | 'medium' | 'large' | 'extraLarge';\n\n/**\n * TextArea visual state\n */\ntype TextAreaState = 'default' | 'hovered' | 'focused' | 'invalid' | 'disabled';\n\n/**\n * Size configurations with exact pixel specifications\n */\nconst SIZE_CLASSES = {\n small: {\n textarea: 'h-24 text-sm', // 96px height, 14px font\n textSize: 'sm' as const,\n },\n medium: {\n textarea: 'h-24 text-base', // 96px height, 16px font\n textSize: 'md' as const,\n },\n large: {\n textarea: 'h-24 text-lg', // 96px height, 18px font\n textSize: 'lg' as const,\n },\n extraLarge: {\n textarea: 'h-24 text-xl', // 96px height, 20px font\n textSize: 'xl' as const,\n },\n} as const;\n\n/**\n * Base textarea styling classes using design system colors\n */\nconst BASE_TEXTAREA_CLASSES =\n 'w-full box-border p-3 bg-background border border-solid rounded-[4px] resize-none focus:outline-none font-roboto font-normal leading-[150%] placeholder:text-text-600 transition-all duration-200';\n\n/**\n * State-based styling classes using design system colors from styles.css\n */\nconst STATE_CLASSES = {\n default: {\n base: 'border-border-300 bg-background text-text-600',\n hover: 'hover:border-border-400',\n focus: 'focus:border-border-500',\n },\n hovered: {\n base: 'border-border-400 bg-background text-text-600',\n hover: '',\n focus: 'focus:border-border-500',\n },\n focused: {\n base: 'border-2 border-primary-950 bg-background text-text-900',\n hover: '',\n focus: '',\n },\n invalid: {\n base: 'border-2 border-red-700 bg-white text-gray-800',\n hover: 'hover:border-red-700',\n focus: 'focus:border-red-700',\n },\n disabled: {\n base: 'border-border-300 bg-background text-text-600 cursor-not-allowed opacity-40',\n hover: '',\n focus: '',\n },\n} as const;\n\n/**\n * TextArea component props interface\n */\nexport type TextAreaProps = {\n /** Label text to display above the textarea */\n label?: ReactNode;\n /** Size variant of the textarea */\n size?: TextAreaSize;\n /** Visual state of the textarea */\n state?: TextAreaState;\n /** Error message to display */\n errorMessage?: string;\n /** Helper text to display */\n helperMessage?: string;\n /** Additional CSS classes */\n className?: string;\n /** Label CSS classes */\n labelClassName?: string;\n /** Show character count when maxLength is provided */\n showCharacterCount?: boolean;\n} & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'size'>;\n\n/**\n * TextArea component for Analytica Ensino platforms\n *\n * A textarea component with essential states, sizes and themes.\n * Uses exact design specifications with 288px width, 96px height, and specific\n * color values. Includes Text component integration for consistent typography.\n *\n * @example\n * ```tsx\n * // Basic textarea\n * <TextArea label=\"Description\" placeholder=\"Enter description...\" />\n *\n * // Small size\n * <TextArea size=\"small\" label=\"Comment\" />\n *\n * // Invalid state\n * <TextArea state=\"invalid\" label=\"Required field\" errorMessage=\"This field is required\" />\n *\n * // Disabled state\n * <TextArea disabled label=\"Read-only field\" />\n * ```\n */\nconst TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(\n (\n {\n label,\n size = 'medium',\n state = 'default',\n errorMessage,\n helperMessage,\n className = '',\n labelClassName = '',\n disabled,\n id,\n onChange,\n placeholder,\n required,\n showCharacterCount = false,\n maxLength,\n value,\n ...props\n },\n ref\n ) => {\n // Generate unique ID if not provided\n const generatedId = useId();\n const inputId = id ?? `textarea-${generatedId}`;\n\n // Internal state for focus tracking\n const [isFocused, setIsFocused] = useState(false);\n\n // Calculate current character count\n const currentLength = typeof value === 'string' ? value.length : 0;\n const isNearLimit = maxLength && currentLength >= maxLength * 0.8;\n\n // Handle change events\n const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {\n onChange?.(event);\n };\n\n // Handle focus events\n const handleFocus = (event: FocusEvent<HTMLTextAreaElement>) => {\n setIsFocused(true);\n props.onFocus?.(event);\n };\n\n // Handle blur events\n const handleBlur = (event: FocusEvent<HTMLTextAreaElement>) => {\n setIsFocused(false);\n props.onBlur?.(event);\n };\n\n // Determine current state based on props and focus\n let currentState = disabled ? 'disabled' : state;\n\n // Override state based on focus\n if (\n isFocused &&\n currentState !== 'invalid' &&\n currentState !== 'disabled'\n ) {\n currentState = 'focused';\n }\n\n // Get size classes\n const sizeClasses = SIZE_CLASSES[size];\n\n // Get styling classes\n const stateClasses = STATE_CLASSES[currentState];\n\n // Get final textarea classes\n const textareaClasses = cn(\n BASE_TEXTAREA_CLASSES,\n sizeClasses.textarea,\n stateClasses.base,\n stateClasses.hover,\n stateClasses.focus,\n className\n );\n\n return (\n <div className={`flex flex-col`}>\n {/* Label */}\n {label && (\n <Text\n as=\"label\"\n htmlFor={inputId}\n size={sizeClasses.textSize}\n weight=\"medium\"\n color=\"text-text-950\"\n className={cn('mb-1.5', labelClassName)}\n >\n {label}{' '}\n {required && <span className=\"text-indicator-error\">*</span>}\n </Text>\n )}\n\n {/* Textarea */}\n <textarea\n ref={ref}\n id={inputId}\n disabled={disabled}\n onChange={handleChange}\n onFocus={handleFocus}\n onBlur={handleBlur}\n className={textareaClasses}\n placeholder={placeholder}\n required={required}\n maxLength={maxLength}\n value={value}\n {...props}\n />\n\n {/* Error message */}\n {errorMessage && (\n <p className=\"flex gap-1 items-center text-sm text-indicator-error mt-1.5\">\n <WarningCircle size={16} /> {errorMessage}\n </p>\n )}\n\n {/* Helper text or Character count */}\n {!errorMessage && showCharacterCount && maxLength && (\n <Text\n size=\"sm\"\n weight=\"normal\"\n className={`mt-1.5 ${isNearLimit ? 'text-indicator-warning' : 'text-text-500'}`}\n >\n {currentLength}/{maxLength} caracteres\n </Text>\n )}\n {!errorMessage &&\n helperMessage &&\n !(showCharacterCount && maxLength) && (\n <Text size=\"sm\" weight=\"normal\" className=\"mt-1.5 text-text-500\">\n {helperMessage}\n </Text>\n )}\n </div>\n );\n }\n);\n\nTextArea.displayName = 'TextArea';\n\nexport default TextArea;\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-v