UNPKG

analytica-frontend-lib

Version:

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

1 lines 162 kB
{"version":3,"sources":["../../src/components/NotificationCard/NotificationCard.tsx","../../src/utils/utils.ts","../../src/components/DropdownMenu/DropdownMenu.tsx","../../src/components/Button/Button.tsx","../../src/components/Text/Text.tsx","../../src/components/Modal/Modal.tsx","../../src/components/Modal/utils/videoUtils.ts","../../src/components/ThemeToggle/ThemeToggle.tsx","../../src/components/SelectionButton/SelectionButton.tsx","../../src/hooks/useTheme.ts","../../src/store/themeStore.ts","../../src/components/Skeleton/Skeleton.tsx","../../src/components/IconButton/IconButton.tsx","../../src/components/Badge/Badge.tsx","../../src/hooks/useMobile.ts","../../src/store/notificationStore.ts"],"sourcesContent":["import { DotsThreeVertical, Bell } from 'phosphor-react';\nimport { MouseEvent, ReactNode, useState, useEffect } from 'react';\nimport { cn } from '../../utils/utils';\nimport DropdownMenu, {\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from '../DropdownMenu/DropdownMenu';\nimport { SkeletonCard } from '../Skeleton/Skeleton';\nimport IconButton from '../IconButton/IconButton';\nimport Modal from '../Modal/Modal';\nimport Text from '../Text/Text';\nimport Badge from '../Badge/Badge';\nimport { useMobile } from '../../hooks/useMobile';\nimport type {\n Notification,\n NotificationGroup,\n NotificationEntityType,\n} from '../../types/notifications';\nimport { formatTimeAgo } from '../../store/notificationStore';\nimport mockContentImage from '../../assets/img/mock-content.png';\n\n// Extended notification item for component usage with time string\nexport interface NotificationItem extends Omit<Notification, 'createdAt'> {\n time: string;\n createdAt: string | Date;\n}\n\n// Base props shared across all modes\ninterface BaseNotificationProps {\n /**\n * Additional CSS classes\n */\n className?: string;\n /**\n * Empty state image path\n */\n emptyStateImage?: string;\n /**\n * Empty state title\n */\n emptyStateTitle?: string;\n /**\n * Empty state description\n */\n emptyStateDescription?: string;\n}\n\n// Single notification card mode\ninterface SingleNotificationCardMode extends BaseNotificationProps {\n /**\n * Component mode - single card\n */\n mode: 'single';\n /**\n * The notification title\n */\n title: string;\n /**\n * The notification message content\n */\n message: string;\n /**\n * Time displayed (e.g., \"Há 3h\", \"12 Fev\")\n */\n time: string;\n /**\n * Whether the notification has been read\n */\n isRead: boolean;\n /**\n * Callback when user marks notification as read\n */\n onMarkAsRead: () => void;\n /**\n * Callback when user deletes notification\n */\n onDelete: () => void;\n /**\n * Optional callback for navigation action\n */\n onNavigate?: () => void;\n /**\n * Label for the action button (only shown if onNavigate is provided)\n */\n actionLabel?: string;\n}\n\n// List mode\ninterface NotificationListMode extends BaseNotificationProps {\n /**\n * Component mode - list\n */\n mode: 'list';\n /**\n * Array of notifications for list mode\n */\n notifications?: NotificationItem[];\n /**\n * Array of grouped notifications\n */\n groupedNotifications?: NotificationGroup[];\n /**\n * Loading state for list mode\n */\n loading?: boolean;\n /**\n * Error state for list mode\n */\n error?: string | null;\n /**\n * Callback for retry when error occurs\n */\n onRetry?: () => void;\n /**\n * Callback when user marks a notification as read in list mode\n */\n onMarkAsReadById?: (id: string) => void;\n /**\n * Callback when user deletes a notification in list mode\n */\n onDeleteById?: (id: string) => void;\n /**\n * Callback when user navigates from a notification in list mode\n */\n onNavigateById?: (\n entityType?: NotificationEntityType,\n entityId?: string\n ) => void;\n /**\n * Callback when user clicks on a global notification\n */\n onGlobalNotificationClick?: (notification: Notification) => void;\n /**\n * Function to get action label for a notification\n */\n getActionLabel?: (entityType?: NotificationEntityType) => string | undefined;\n /**\n * Custom empty state component\n */\n renderEmpty?: () => ReactNode;\n}\n\n// NotificationCenter mode\ninterface NotificationCenterMode extends BaseNotificationProps {\n /**\n * Component mode - center\n */\n mode: 'center';\n /**\n * Array of grouped notifications\n */\n groupedNotifications?: NotificationGroup[];\n /**\n * Loading state for center mode\n */\n loading?: boolean;\n /**\n * Error state for center mode\n */\n error?: string | null;\n /**\n * Callback for retry when error occurs\n */\n onRetry?: () => void;\n /**\n * Whether center mode is currently active (controls dropdown/modal visibility)\n */\n isActive?: boolean;\n /**\n * Callback when center mode is toggled\n */\n onToggleActive?: () => void;\n /**\n * Number of unread notifications for badge display\n */\n unreadCount?: number;\n /**\n * Callback when all notifications should be marked as read\n */\n onMarkAllAsRead?: () => void;\n /**\n * Callback to fetch notifications (called when center opens)\n */\n onFetchNotifications?: () => void;\n /**\n * Callback when user marks a notification as read in center mode\n */\n onMarkAsReadById?: (id: string) => void;\n /**\n * Callback when user deletes a notification in center mode\n */\n onDeleteById?: (id: string) => void;\n /**\n * Callback when user navigates from a notification in center mode\n */\n onNavigateById?: (\n entityType?: NotificationEntityType,\n entityId?: string\n ) => void;\n /**\n * Function to get action label for a notification\n */\n getActionLabel?: (entityType?: NotificationEntityType) => string | undefined;\n /**\n * Callback when dropdown open state changes (for synchronization with parent)\n */\n onOpenChange?: (open: boolean) => void;\n}\n\n// Union type for all modes\nexport type NotificationCardProps =\n | SingleNotificationCardMode\n | NotificationListMode\n | NotificationCenterMode;\n\n// Legacy interface for backward compatibility\nexport interface LegacyNotificationCardProps extends BaseNotificationProps {\n // Single notification mode props\n title?: string;\n message?: string;\n time?: string;\n isRead?: boolean;\n onMarkAsRead?: () => void;\n onDelete?: () => void;\n onNavigate?: () => void;\n actionLabel?: string;\n\n // List mode props\n notifications?: NotificationItem[];\n groupedNotifications?: NotificationGroup[];\n loading?: boolean;\n error?: string | null;\n onRetry?: () => void;\n onMarkAsReadById?: (id: string) => void;\n onDeleteById?: (id: string) => void;\n onNavigateById?: (\n entityType?: NotificationEntityType,\n entityId?: string\n ) => void;\n onGlobalNotificationClick?: (notification: Notification) => void;\n getActionLabel?: (entityType?: NotificationEntityType) => string | undefined;\n renderEmpty?: () => ReactNode;\n\n // NotificationCenter mode props\n variant?: 'card' | 'center';\n isActive?: boolean;\n onToggleActive?: () => void;\n unreadCount?: number;\n onMarkAllAsRead?: () => void;\n onFetchNotifications?: () => void;\n onOpenChange?: (open: boolean) => void;\n}\n\n/**\n * Empty state component for notifications\n */\nconst NotificationEmpty = ({\n emptyStateImage,\n emptyStateTitle = 'Nenhuma notificação no momento',\n emptyStateDescription = 'Você está em dia com todas as novidades. Volte depois para conferir atualizações!',\n}: {\n emptyStateImage?: string;\n emptyStateTitle?: string;\n emptyStateDescription?: string;\n}) => {\n return (\n <div className=\"flex flex-col items-center justify-center gap-4 p-6 w-full\">\n {/* Notification Icon */}\n {emptyStateImage && (\n <div className=\"w-20 h-20 flex items-center justify-center\">\n <img\n src={emptyStateImage}\n alt=\"Sem notificações\"\n width={82}\n height={82}\n className=\"object-contain\"\n />\n </div>\n )}\n\n {/* Title */}\n <h3 className=\"text-xl font-semibold text-text-950 text-center leading-[23px]\">\n {emptyStateTitle}\n </h3>\n\n {/* Description */}\n <p className=\"text-sm font-normal text-text-400 text-center max-w-[316px] leading-[21px]\">\n {emptyStateDescription}\n </p>\n </div>\n );\n};\n\n/**\n * Notification header component\n */\nconst NotificationHeader = ({\n unreadCount,\n variant = 'modal',\n}: {\n unreadCount: number;\n variant?: 'modal' | 'dropdown';\n}) => {\n return (\n <div className=\"flex items-center justify-between gap-2\">\n {variant === 'modal' ? (\n <Text size=\"sm\" weight=\"bold\" className=\"text-text-950\">\n Notificações\n </Text>\n ) : (\n <h3 className=\"text-sm font-semibold text-text-950\">Notificações</h3>\n )}\n {unreadCount > 0 && (\n <Badge\n variant=\"solid\"\n action=\"info\"\n size=\"small\"\n iconLeft={<Bell size={12} aria-hidden=\"true\" focusable=\"false\" />}\n className=\"border-0\"\n >\n {unreadCount === 1 ? '1 não lida' : `${unreadCount} não lidas`}\n </Badge>\n )}\n </div>\n );\n};\n\n/**\n * Single notification card component\n */\nconst SingleNotificationCard = ({\n title,\n message,\n time,\n isRead,\n onMarkAsRead,\n onDelete,\n onNavigate,\n actionLabel,\n className,\n}: {\n title: string;\n message: string;\n time: string;\n isRead: boolean;\n onMarkAsRead: () => void;\n onDelete: () => void;\n onNavigate?: () => void;\n actionLabel?: string;\n className?: string;\n}) => {\n const handleMarkAsRead = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n if (!isRead) {\n onMarkAsRead();\n }\n };\n\n const handleDelete = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n onDelete();\n };\n\n const handleNavigate = (e: MouseEvent) => {\n e.stopPropagation();\n if (onNavigate) {\n onNavigate();\n }\n };\n\n return (\n <div\n className={cn(\n 'flex flex-col justify-center items-start p-4 gap-2 w-full bg-background border-b border-border-200',\n 'last:border-b-0',\n className\n )}\n >\n {/* Header with unread indicator and actions menu */}\n <div className=\"flex items-center gap-2 w-full\">\n {/* Unread indicator */}\n {!isRead && (\n <div className=\"w-[7px] h-[7px] bg-info-300 rounded-full flex-shrink-0\" />\n )}\n\n {/* Title */}\n <h3 className=\"font-bold text-sm leading-4 text-text-950 flex-grow\">\n {title}\n </h3>\n\n {/* Actions dropdown */}\n <DropdownMenu>\n <DropdownMenuTrigger\n className=\"flex-shrink-0 inline-flex items-center justify-center font-medium bg-transparent text-text-950 cursor-pointer hover:bg-info-50 w-6 h-6 rounded-lg\"\n aria-label=\"Menu de ações\"\n >\n <DotsThreeVertical size={24} />\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"min-w-[160px]\">\n {!isRead && (\n <DropdownMenuItem\n onClick={handleMarkAsRead}\n className=\"text-text-950\"\n >\n Marcar como lida\n </DropdownMenuItem>\n )}\n <DropdownMenuItem onClick={handleDelete} className=\"text-error-600\">\n Deletar\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n\n {/* Message */}\n <p className=\"text-sm leading-[21px] text-text-800 w-full\">{message}</p>\n\n {/* Time and action button */}\n <div className=\"flex items-center justify-between w-full\">\n <span className=\"text-sm font-medium text-text-400\">{time}</span>\n\n {onNavigate && actionLabel && (\n <button\n type=\"button\"\n onClick={handleNavigate}\n className=\"text-sm font-medium text-info-600 hover:text-info-700 cursor-pointer\"\n >\n {actionLabel}\n </button>\n )}\n </div>\n </div>\n );\n};\n\n/**\n * Notification list component for displaying grouped notifications\n */\nconst NotificationList = ({\n groupedNotifications = [],\n loading = false,\n error = null,\n onRetry,\n onMarkAsReadById,\n onDeleteById,\n onNavigateById,\n onGlobalNotificationClick,\n getActionLabel,\n renderEmpty,\n className,\n emptyStateImage,\n}: {\n groupedNotifications?: NotificationGroup[];\n loading?: boolean;\n error?: string | null;\n onRetry?: () => void;\n onMarkAsReadById?: (id: string) => void;\n onDeleteById?: (id: string) => void;\n onNavigateById?: (\n entityType?: NotificationEntityType,\n entityId?: string\n ) => void;\n onGlobalNotificationClick?: (notification: Notification) => void;\n getActionLabel?: (entityType?: NotificationEntityType) => string | undefined;\n renderEmpty?: () => ReactNode;\n className?: string;\n emptyStateImage?: string;\n}) => {\n // State for global notification modal when onGlobalNotificationClick is not provided\n const [globalNotificationModal, setGlobalNotificationModal] = useState<{\n isOpen: boolean;\n notification: Notification | null;\n }>({ isOpen: false, notification: null });\n\n // Default handler for global notifications when onGlobalNotificationClick is not provided\n const handleGlobalNotificationClick = (notification: Notification) => {\n if (onGlobalNotificationClick) {\n onGlobalNotificationClick(notification);\n } else {\n // Open modal as fallback\n setGlobalNotificationModal({\n isOpen: true,\n notification,\n });\n }\n };\n // Error state\n if (error) {\n return (\n <div className=\"flex flex-col items-center gap-4 p-6 w-full\">\n <p className=\"text-sm text-error-600\">{error}</p>\n {onRetry && (\n <button\n type=\"button\"\n onClick={onRetry}\n className=\"text-sm text-info-600 hover:text-info-700\"\n >\n Tentar novamente\n </button>\n )}\n </div>\n );\n }\n\n // Loading state\n if (loading) {\n return (\n <div className=\"flex flex-col gap-0 w-full\">\n {['skeleton-first', 'skeleton-second', 'skeleton-third'].map(\n (skeletonId) => (\n <SkeletonCard\n key={skeletonId}\n className=\"p-4 border-b border-border-200\"\n />\n )\n )}\n </div>\n );\n }\n\n // Empty state\n if (!groupedNotifications || groupedNotifications.length === 0) {\n return renderEmpty ? (\n <div className=\"w-full\">{renderEmpty()}</div>\n ) : (\n <NotificationEmpty />\n );\n }\n\n return (\n <div className={cn('flex flex-col gap-0 w-full', className)}>\n {groupedNotifications.map((group, idx) => (\n <div key={`${group.label}-${idx}`} className=\"flex flex-col\">\n {/* Group header */}\n <div className=\"flex items-end px-4 py-6 pb-4\">\n <h4 className=\"text-lg font-bold text-text-500 flex-grow\">\n {group.label}\n </h4>\n </div>\n\n {/* Notifications in group */}\n {group.notifications.map((notification) => {\n // Check if this is a global notification\n const isGlobalNotification =\n !notification.entityType &&\n !notification.entityId &&\n !notification.activity &&\n !notification.goal;\n\n // Determine navigation handler\n let navigationHandler: (() => void) | undefined;\n if (isGlobalNotification) {\n navigationHandler = () =>\n handleGlobalNotificationClick(notification);\n } else if (\n notification.entityType &&\n notification.entityId &&\n onNavigateById\n ) {\n navigationHandler = () =>\n onNavigateById(\n notification.entityType ?? undefined,\n notification.entityId ?? undefined\n );\n }\n\n // Determine action label\n let actionLabel: string | undefined;\n if (isGlobalNotification) {\n // For global notifications, call getActionLabel without parameters or with null\n actionLabel = getActionLabel?.(undefined);\n } else {\n // For entity-specific notifications, pass the entityType\n actionLabel = getActionLabel?.(\n notification.entityType ?? undefined\n );\n }\n\n return (\n <SingleNotificationCard\n key={notification.id}\n title={notification.title}\n message={notification.message}\n time={\n (notification as Partial<NotificationItem>).time ??\n formatTimeAgo(new Date(notification.createdAt))\n }\n isRead={notification.isRead}\n onMarkAsRead={() => onMarkAsReadById?.(notification.id)}\n onDelete={() => onDeleteById?.(notification.id)}\n onNavigate={navigationHandler}\n actionLabel={actionLabel}\n />\n );\n })}\n </div>\n ))}\n\n {/* Global notification modal for list mode */}\n <Modal\n isOpen={globalNotificationModal.isOpen}\n onClose={() =>\n setGlobalNotificationModal({ isOpen: false, notification: null })\n }\n title={globalNotificationModal.notification?.title || ''}\n description={globalNotificationModal.notification?.message || ''}\n variant=\"activity\"\n image={emptyStateImage || mockContentImage}\n actionLink={\n globalNotificationModal.notification?.actionLink || undefined\n }\n actionLabel=\"Ver mais\"\n size=\"lg\"\n />\n </div>\n );\n};\n\n// Internal props type for NotificationCenter (without mode)\ntype NotificationCenterProps = Omit<NotificationCenterMode, 'mode'>;\n\n/**\n * NotificationCenter component for modal/dropdown mode\n */\nconst NotificationCenter = ({\n isActive,\n onToggleActive,\n unreadCount = 0,\n groupedNotifications = [],\n loading = false,\n error = null,\n onRetry,\n onMarkAsReadById,\n onDeleteById,\n onNavigateById,\n getActionLabel,\n onFetchNotifications,\n onMarkAllAsRead,\n onOpenChange,\n emptyStateImage,\n emptyStateTitle,\n emptyStateDescription,\n className,\n}: NotificationCenterProps) => {\n const { isMobile } = useMobile();\n const [isModalOpen, setIsModalOpen] = useState(false);\n const [globalNotificationModal, setGlobalNotificationModal] = useState<{\n isOpen: boolean;\n notification: Notification | null;\n }>({ isOpen: false, notification: null });\n\n // Handle mobile click\n const handleMobileClick = () => {\n setIsModalOpen(true);\n onFetchNotifications?.();\n };\n\n // Handle desktop click\n const handleDesktopClick = () => {\n onToggleActive?.();\n };\n\n // Handle dropdown open change\n const handleOpenChange = (open: boolean) => {\n const controlledByParent =\n typeof isActive === 'boolean' && typeof onToggleActive === 'function';\n\n // Always notify parent if they want the signal.\n onOpenChange?.(open);\n\n // If parent controls via toggle only (no onOpenChange), keep them in sync on close.\n if (controlledByParent && !onOpenChange && !open && isActive) {\n onToggleActive?.();\n }\n };\n\n // Fetch notifications when dropdown opens\n useEffect(() => {\n if (isActive) {\n onFetchNotifications?.();\n }\n }, [isActive, onFetchNotifications]);\n\n const renderEmptyState = () => (\n <NotificationEmpty\n emptyStateImage={emptyStateImage}\n emptyStateTitle={emptyStateTitle}\n emptyStateDescription={emptyStateDescription}\n />\n );\n\n if (isMobile) {\n return (\n <>\n <IconButton\n active={isModalOpen}\n onClick={handleMobileClick}\n icon={<Bell size={24} className=\"text-primary\" />}\n className={className}\n />\n <Modal\n isOpen={isModalOpen}\n onClose={() => setIsModalOpen(false)}\n title=\"Notificações\"\n size=\"md\"\n hideCloseButton={false}\n closeOnEscape={true}\n >\n <div className=\"flex flex-col h-full max-h-[80vh]\">\n <div className=\"px-0 pb-3 border-b border-border-200\">\n <NotificationHeader unreadCount={unreadCount} variant=\"modal\" />\n {unreadCount > 0 && onMarkAllAsRead && (\n <button\n type=\"button\"\n onClick={onMarkAllAsRead}\n className=\"text-sm font-medium text-info-600 hover:text-info-700 cursor-pointer mt-2\"\n >\n Marcar todas como lidas\n </button>\n )}\n </div>\n <div className=\"flex-1 overflow-y-auto\">\n <NotificationList\n groupedNotifications={groupedNotifications}\n loading={loading}\n error={error}\n onRetry={onRetry}\n onMarkAsReadById={onMarkAsReadById}\n onDeleteById={onDeleteById}\n onNavigateById={(entityType, entityId) => {\n setIsModalOpen(false);\n onNavigateById?.(entityType, entityId);\n }}\n onGlobalNotificationClick={(notification) => {\n setIsModalOpen(false);\n setGlobalNotificationModal({\n isOpen: true,\n notification,\n });\n }}\n getActionLabel={getActionLabel}\n renderEmpty={renderEmptyState}\n emptyStateImage={emptyStateImage}\n />\n </div>\n </div>\n </Modal>\n {/* Global Notification Modal (mobile) */}\n <Modal\n isOpen={globalNotificationModal.isOpen}\n onClose={() =>\n setGlobalNotificationModal({ isOpen: false, notification: null })\n }\n title={globalNotificationModal.notification?.title || ''}\n variant=\"activity\"\n description={globalNotificationModal.notification?.message}\n image={emptyStateImage || mockContentImage}\n actionLink={\n globalNotificationModal.notification?.actionLink || undefined\n }\n actionLabel=\"Ver mais\"\n size=\"lg\"\n />\n </>\n );\n }\n\n return (\n <>\n <DropdownMenu\n {...(typeof isActive === 'boolean'\n ? { open: isActive, onOpenChange: handleOpenChange }\n : { onOpenChange: handleOpenChange })}\n >\n <DropdownMenuTrigger className=\"text-primary cursor-pointer\">\n <IconButton\n active={isActive}\n onClick={handleDesktopClick}\n icon={\n <Bell\n size={24}\n className={isActive ? 'text-primary-950' : 'text-primary'}\n />\n }\n className={className}\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent\n className=\"min-w-[320px] max-w-[400px] max-h-[500px] overflow-hidden\"\n side=\"bottom\"\n align=\"end\"\n >\n <div className=\"flex flex-col\">\n <div className=\"px-4 py-3 border-b border-border-200\">\n <NotificationHeader\n unreadCount={unreadCount}\n variant=\"dropdown\"\n />\n {unreadCount > 0 && onMarkAllAsRead && (\n <button\n type=\"button\"\n onClick={onMarkAllAsRead}\n className=\"text-sm font-medium text-info-600 hover:text-info-700 cursor-pointer mt-2\"\n >\n Marcar todas como lidas\n </button>\n )}\n </div>\n <div className=\"max-h-[350px] overflow-y-auto\">\n <NotificationList\n groupedNotifications={groupedNotifications}\n loading={loading}\n error={error}\n onRetry={onRetry}\n onMarkAsReadById={onMarkAsReadById}\n onDeleteById={onDeleteById}\n onNavigateById={onNavigateById}\n onGlobalNotificationClick={(notification) => {\n setGlobalNotificationModal({\n isOpen: true,\n notification,\n });\n }}\n getActionLabel={getActionLabel}\n renderEmpty={renderEmptyState}\n emptyStateImage={emptyStateImage}\n />\n </div>\n </div>\n </DropdownMenuContent>\n </DropdownMenu>\n\n {/* Global Notification Modal */}\n <Modal\n isOpen={globalNotificationModal.isOpen}\n onClose={() =>\n setGlobalNotificationModal({ isOpen: false, notification: null })\n }\n title={globalNotificationModal.notification?.title || ''}\n variant=\"activity\"\n description={globalNotificationModal.notification?.message}\n image={emptyStateImage || mockContentImage}\n actionLink={\n globalNotificationModal.notification?.actionLink || undefined\n }\n actionLabel=\"Ver mais\"\n size=\"lg\"\n />\n </>\n );\n};\n\n/**\n * NotificationCard component - can display single notification, list of notifications, or center mode\n *\n * @param props - The notification card properties\n * @returns JSX element representing the notification card, list, or center\n */\nconst NotificationCard = (props: NotificationCardProps) => {\n // Use mode discriminator to determine which component to render\n switch (props.mode) {\n case 'single':\n return (\n <SingleNotificationCard\n title={props.title}\n message={props.message}\n time={props.time}\n isRead={props.isRead}\n onMarkAsRead={props.onMarkAsRead}\n onDelete={props.onDelete}\n onNavigate={props.onNavigate}\n actionLabel={props.actionLabel}\n className={props.className}\n />\n );\n\n case 'list':\n return (\n <NotificationList\n groupedNotifications={\n props.groupedNotifications ??\n (props.notifications\n ? [\n {\n label: 'Notificações',\n notifications: props.notifications as Notification[],\n },\n ]\n : [])\n }\n loading={props.loading}\n error={props.error}\n onRetry={props.onRetry}\n onMarkAsReadById={props.onMarkAsReadById}\n onDeleteById={props.onDeleteById}\n onNavigateById={props.onNavigateById}\n onGlobalNotificationClick={props.onGlobalNotificationClick}\n getActionLabel={props.getActionLabel}\n renderEmpty={props.renderEmpty}\n className={props.className}\n emptyStateImage={props.emptyStateImage}\n />\n );\n\n case 'center':\n return <NotificationCenter {...props} />;\n\n default:\n // This should never happen with proper typing, but provides a fallback\n return (\n <div className=\"flex flex-col items-center gap-4 p-6 w-full\">\n <p className=\"text-sm text-text-600\">\n Modo de notificação não reconhecido\n </p>\n </div>\n );\n }\n};\n\n/**\n * Legacy NotificationCard component for backward compatibility\n * Automatically detects mode based on provided props\n */\nexport const LegacyNotificationCard = (props: LegacyNotificationCardProps) => {\n // If variant is center, render NotificationCenter\n if (props.variant === 'center') {\n const centerProps: NotificationCenterMode = {\n mode: 'center',\n ...props,\n };\n return <NotificationCenter {...centerProps} />;\n }\n\n // If we have list-related props, render list mode\n if (\n props.groupedNotifications !== undefined ||\n props.notifications !== undefined ||\n props.loading ||\n props.error\n ) {\n return (\n <NotificationList\n groupedNotifications={\n props.groupedNotifications ??\n (props.notifications\n ? [\n {\n label: 'Notificações',\n notifications: props.notifications as Notification[],\n },\n ]\n : [])\n }\n loading={props.loading}\n error={props.error}\n onRetry={props.onRetry}\n onMarkAsReadById={props.onMarkAsReadById}\n onDeleteById={props.onDeleteById}\n onNavigateById={props.onNavigateById}\n onGlobalNotificationClick={props.onGlobalNotificationClick}\n getActionLabel={props.getActionLabel}\n renderEmpty={props.renderEmpty}\n className={props.className}\n emptyStateImage={props.emptyStateImage}\n />\n );\n }\n\n // If we have single notification props, render single card mode\n if (\n props.title !== undefined &&\n props.message !== undefined &&\n props.time !== undefined &&\n props.isRead !== undefined &&\n props.onMarkAsRead &&\n props.onDelete\n ) {\n const singleProps: SingleNotificationCardMode = {\n mode: 'single',\n title: props.title,\n message: props.message,\n time: props.time,\n isRead: props.isRead,\n onMarkAsRead: props.onMarkAsRead,\n onDelete: props.onDelete,\n onNavigate: props.onNavigate,\n actionLabel: props.actionLabel,\n className: props.className,\n emptyStateImage: props.emptyStateImage,\n emptyStateTitle: props.emptyStateTitle,\n emptyStateDescription: props.emptyStateDescription,\n };\n return (\n <SingleNotificationCard\n title={singleProps.title}\n message={singleProps.message}\n time={singleProps.time}\n isRead={singleProps.isRead}\n onMarkAsRead={singleProps.onMarkAsRead}\n onDelete={singleProps.onDelete}\n onNavigate={singleProps.onNavigate}\n actionLabel={singleProps.actionLabel}\n className={singleProps.className}\n />\n );\n }\n\n // Default empty state if no valid props provided\n return (\n <div className=\"flex flex-col items-center gap-4 p-6 w-full\">\n <p className=\"text-sm text-text-600\">Nenhuma notificação configurada</p>\n </div>\n );\n};\n\nexport default NotificationCard;\nexport type { NotificationGroup };\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 { CaretRight, SignOut, User } from 'phosphor-react';\nimport {\n forwardRef,\n ReactNode,\n ButtonHTMLAttributes,\n useEffect,\n useLayoutEffect,\n useRef,\n HTMLAttributes,\n MouseEvent,\n KeyboardEvent,\n isValidElement,\n Children,\n cloneElement,\n ReactElement,\n useState,\n RefObject,\n CSSProperties,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { create, StoreApi, useStore } from 'zustand';\nimport Button from '../Button/Button';\nimport Text from '../Text/Text';\nimport { cn } from '../../utils/utils';\nimport Modal from '../Modal/Modal';\nimport { ThemeToggle } from '../ThemeToggle/ThemeToggle';\nimport type { ThemeMode } from '@/hooks/useTheme';\nimport { useTheme } from '../../hooks/useTheme';\n\ninterface DropdownStore {\n open: boolean;\n setOpen: (open: boolean) => void;\n}\n\ntype DropdownStoreApi = StoreApi<DropdownStore>;\n\nexport function createDropdownStore(): DropdownStoreApi {\n return create<DropdownStore>((set) => ({\n open: false,\n setOpen: (open) => set({ open }),\n }));\n}\n\nexport const useDropdownStore = (externalStore?: DropdownStoreApi) => {\n if (!externalStore) {\n throw new Error(\n 'Component must be used within a DropdownMenu (store is missing)'\n );\n }\n\n return externalStore;\n};\n\nconst injectStore = (\n children: ReactNode,\n store: DropdownStoreApi\n): ReactNode => {\n return Children.map(children, (child) => {\n if (isValidElement(child)) {\n const typedChild = child as ReactElement<{\n store?: DropdownStoreApi;\n children?: ReactNode;\n }>;\n\n // Aqui tu checa o displayName do componente\n const displayName = (\n typedChild.type as unknown as { displayName: string }\n ).displayName;\n\n // Lista de componentes que devem receber o store\n const allowed = [\n 'DropdownMenuTrigger',\n 'DropdownContent',\n 'DropdownMenuContent',\n 'DropdownMenuSeparator',\n 'DropdownMenuItem',\n 'MenuLabel',\n 'ProfileMenuTrigger',\n 'ProfileMenuHeader',\n 'ProfileMenuFooter',\n 'ProfileToggleTheme',\n ];\n\n let newProps: Partial<{\n store: DropdownStoreApi;\n children: ReactNode;\n }> = {};\n\n if (allowed.includes(displayName)) {\n newProps.store = store;\n }\n\n if (typedChild.props.children) {\n newProps.children = injectStore(typedChild.props.children, store);\n }\n\n return cloneElement(typedChild, newProps);\n }\n\n return child;\n });\n};\n\ninterface DropdownMenuProps {\n children: ReactNode;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n}\n\nconst DropdownMenu = ({\n children,\n open: propOpen,\n onOpenChange,\n}: DropdownMenuProps) => {\n const storeRef = useRef<DropdownStoreApi | null>(null);\n storeRef.current ??= createDropdownStore();\n const store = storeRef.current;\n const { open, setOpen: storeSetOpen } = useStore(store, (s) => s);\n\n const setOpen = (newOpen: boolean) => {\n storeSetOpen(newOpen);\n };\n\n const menuRef = useRef<HTMLDivElement | null>(null);\n\n const handleArrowDownOrArrowUp = (event: globalThis.KeyboardEvent) => {\n const menuContent = menuRef.current?.querySelector('[role=\"menu\"]');\n if (menuContent) {\n event.preventDefault();\n\n const items = Array.from(\n menuContent.querySelectorAll(\n '[role=\"menuitem\"]:not([aria-disabled=\"true\"])'\n )\n ).filter((el): el is HTMLElement => el instanceof HTMLElement);\n\n if (items.length === 0) return;\n\n const focusedItem = document.activeElement as HTMLElement;\n const currentIndex = items.indexOf(focusedItem);\n\n let nextIndex;\n if (event.key === 'ArrowDown') {\n nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % items.length;\n } else {\n // ArrowUp\n nextIndex =\n currentIndex === -1\n ? items.length - 1\n : (currentIndex - 1 + items.length) % items.length;\n }\n\n items[nextIndex]?.focus();\n }\n };\n\n const handleDownkey = (event: globalThis.KeyboardEvent) => {\n if (event.key === 'Escape') {\n setOpen(false);\n } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n handleArrowDownOrArrowUp(event);\n }\n };\n\n const handleClickOutside = (event: Event) => {\n const target = event.target as Node;\n\n if (menuRef.current?.contains(target)) {\n return;\n }\n\n if (\n target instanceof Element &&\n target.closest('[data-dropdown-content=\"true\"]')\n ) {\n return;\n }\n\n setOpen(false);\n };\n\n useEffect(() => {\n if (open) {\n document.addEventListener('pointerdown', handleClickOutside);\n document.addEventListener('keydown', handleDownkey);\n }\n\n return () => {\n document.removeEventListener('pointerdown', handleClickOutside);\n document.removeEventListener('keydown', handleDownkey);\n };\n }, [open]);\n\n useEffect(() => {\n onOpenChange?.(open);\n }, [open, onOpenChange]);\n\n useEffect(() => {\n if (propOpen !== undefined) {\n setOpen(propOpen);\n }\n }, [propOpen]);\n\n return (\n <div className=\"relative\" ref={menuRef}>\n {injectStore(children, store)}\n </div>\n );\n};\n\n// Componentes genéricos do DropdownMenu\nconst DropdownMenuTrigger = forwardRef<\n HTMLButtonElement,\n ButtonHTMLAttributes<HTMLButtonElement> & {\n disabled?: boolean;\n store?: DropdownStoreApi;\n }\n>(({ className, children, onClick, store: externalStore, ...props }, ref) => {\n const store = useDropdownStore(externalStore);\n\n const open = useStore(store, (s) => s.open);\n const toggleOpen = () => store.setState({ open: !open });\n\n return (\n <button\n ref={ref}\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n toggleOpen();\n onClick?.(e);\n }}\n aria-expanded={open}\n className={cn(\n 'appearance-none bg-transparent border-none p-0',\n className\n )}\n {...props}\n >\n {children}\n </button>\n );\n});\nDropdownMenuTrigger.displayName = 'DropdownMenuTrigger';\n\nconst ITEM_SIZE_CLASSES = {\n small: 'text-sm',\n medium: 'text-md',\n} as const;\n\nconst SIDE_CLASSES = {\n top: 'bottom-full',\n right: 'top-full',\n bottom: 'top-full',\n left: 'top-full',\n};\n\nconst ALIGN_CLASSES = {\n start: 'left-0',\n center: 'left-1/2 -translate-x-1/2',\n end: 'right-0',\n};\n\nconst MENUCONTENT_VARIANT_CLASSES = {\n menu: 'p-1',\n profile: 'p-6',\n};\n\nconst MenuLabel = forwardRef<\n HTMLDivElement,\n HTMLAttributes<HTMLDivElement> & {\n inset?: boolean;\n store?: DropdownStoreApi;\n }\n>(({ className, inset, store: _store, ...props }, ref) => {\n return (\n <div\n ref={ref}\n className={cn('text-sm w-full', inset ? 'pl-8' : '', className)}\n {...props}\n />\n );\n});\nMenuLabel.displayName = 'MenuLabel';\n\nconst DropdownMenuContent = forwardRef<\n HTMLDivElement,\n HTMLAttributes<HTMLDivElement> & {\n align?: 'start' | 'center' | 'end';\n side?: 'top' | 'right' | 'bottom' | 'left';\n variant?: 'menu' | 'profile';\n sideOffset?: number;\n store?: DropdownStoreApi;\n portal?: boolean;\n triggerRef?: RefObject<HTMLElement | null>;\n }\n>(\n (\n {\n className,\n align = 'start',\n side = 'bottom',\n variant = 'menu',\n sideOffset = 4,\n children,\n store: externalStore,\n portal = false,\n triggerRef,\n ...props\n },\n ref\n ) => {\n const store = useDropdownStore(externalStore);\n const open = useStore(store, (s) => s.open);\n const [isVisible, setIsVisible] = useState(open);\n const [portalPosition, setPortalPosition] = useState({ top: 0, left: 0 });\n const contentRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (open) {\n setIsVisible(true);\n } else {\n const timer = setTimeout(() => setIsVisible(false), 200);\n return () => clearTimeout(timer);\n }\n }, [open]);\n\n useLayoutEffect(() => {\n if (portal && open && triggerRef?.current) {\n const rect = triggerRef.current.getBoundingClientRect();\n let top = rect.bottom + sideOffset;\n let left = rect.left;\n\n // Handle horizontal sides (left/right)\n if (side === 'left') {\n left = rect.left - sideOffset;\n top = rect.top;\n } else if (side === 'right') {\n left = rect.right + sideOffset;\n top = rect.top;\n } else {\n // Handle vertical sides (top/bottom)\n if (align === 'end') {\n left = rect.right;\n } else if (align === 'center') {\n left = rect.left + rect.width / 2;\n }\n\n if (side === 'top') {\n top = rect.top - sideOffset;\n }\n }\n\n setPortalPosition({ top, left });\n }\n }, [portal, open, triggerRef, align, side, sideOffset]);\n\n if (!isVisible) return null;\n\n const getPositionClasses = () => {\n if (portal) {\n return 'fixed';\n }\n const vertical = SIDE_CLASSES[side];\n const horizontal = ALIGN_CLASSES[align];\n\n return `absolute ${vertical} ${horizontal}`;\n };\n\n const getPortalAlignStyle = () => {\n if (!portal) return {};\n\n const baseStyle: CSSProperties = {\n top: portalPosition.top,\n };\n\n if (align === 'end') {\n baseStyle.right = window.innerWidth - portalPosition.left;\n } else if (align === 'center') {\n baseStyle.left = portalPosition.left;\n baseStyle.transform = 'translateX(-50%)';\n } else {\n baseStyle.left = portalPosition.left;\n }\n\n return baseStyle;\n };\n\n const variantClasses = MENUCONTENT_VARIANT_CLASSES[variant];\n\n const content = (\n <div\n ref={portal ? contentRef : ref}\n role=\"menu\"\n data-dropdown-content=\"true\"\n className={`\n bg-background z-50 min-w-[210px] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md border-border-100\n ${open ? 'animate-in fade-in-0 zoom-in-95' : 'animate-out fade-out-0 zoom-out-95'}\n ${getPositionClasses()}\n ${variantClasses}\n ${className}\n `}\n style={{\n ...(portal\n ? getPortalAlignStyle()\n : {\n marginTop: side === 'bottom' ? sideOffset : undefined,\n marginBottom: side === 'top' ? sideOffset : undefined,\n marginLeft: side === 'right' ? sideOffset : undefined,\n marginRight: side === 'left' ? sideOffset : undefined,\n }),\n }}\n {...props}\n >\n {children}\n </div>\n );\n\n if (portal && typeof document !== 'undefined') {\n return createPortal(content, document.body);\n }\n\n return content;\n }\n);\nDropdownMenuContent.displayName = 'DropdownMenuContent';\n\nconst DropdownMenuItem = forwardRef<\n HTMLDivElement,\n HTMLAttributes<HTMLDivElement> & {\n inset?: boolean;\n size?: 'small' | 'medium';\n iconLeft?: ReactNode;\n iconRight?: ReactNode;\n disabled?: boolean;\n variant?: 'profile' | 'menu';\n store?: DropdownStoreApi;\n preventClose?: boolean;\n }\n>(\n (\n {\n className,\n size = 'small',\n children,\n iconRight,\n iconLeft,\n disabled = false,\n onClick,\n variant = 'menu',\n store: externalStore,\n preventClose = false,\n ...props\n },\n ref\n ) => {\n const store = useDropdownStore(externalStore);\n const setOpen = useStore(store, (s) => s.setOpen);\n const sizeClasses = ITEM_SIZE_CLASSES[size];\n\n const handleClick = (\n e: MouseEvent<HTMLDivElement> | KeyboardEvent<HTMLDivElement>\n ) => {\n if (disabled) {\n e.preventDefault();\n e.stopPropagation();\n return;\n }\n if (e.type === 'click') {\n onClick?.(e as MouseEvent<HTMLDivElement>);\n } else if (e.type === 'keydown') {\n // For keyboard events, call onClick if Enter or Space was pressed\n if (\n (e as KeyboardEvent<HTMLDivElement>).key === 'Enter' ||\n (e as KeyboardEvent<HTMLDivElement>).key === ' '\n ) {\n onClick?.(e as unknown as MouseEvent<HTMLDivElement>);\n }\n // honor any user-provided key handler\n props.onKeyDown?.(e as KeyboardEvent<HTMLDivElement>);\n }\n if (!preventClose) {\n setOpen(false);\n }\n };\n\n const getVariantClasses = () => {\n if (variant === 'profile') {\n return 'relative flex flex-row justify-between select-none items-center gap-2 rounded-sm p-4 text-sm outline-none transition-colors [&>svg]:size-6 [&>svg]:shrink-0';\n }\n return 'relative flex select-none items-center gap-2 rounded-sm p-3 text-sm outline-none transition-colors [&>svg]:size-4 [&>svg]:shrink-0';\n };\n\n const getVariantProps = () => {\n return variant === 'profile' ? { 'data-variant': 'profile' } : {};\n };\n\n return (\n <div\n ref={ref}\n role=\"menuitem\"\n {...getVariantProps()}\n aria-disabled={disabled}\n className={`\n focus-visible:bg-background-50\n ${getVariantClasses()}\n ${sizeClasses}\n ${className}\n ${\n disabled\n ? 'cursor-not-allowed text-text-400'\n : 'cursor-pointer hover:bg-background-50 text-text-700 focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground'\n }\n `}\n onClick={handleClick}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n e.stopPropagation();\n handleClick(e);\n }\n }}\n tabIndex={disabled ? -1 : 0}\n {...props}\n >\n {iconLeft}\n <div className=\"w-full\">{children}</div>\n {iconRight}\n </div>\n );\n }\n);\nDropdownMenuItem.displayName = 'DropdownMenuItem';\n\nconst DropdownMenuSeparator = forwardRef<\n HTMLDivElement,\n HTMLAttributes<HTMLDivElement> & { store?: DropdownStoreApi }\n>(({ className, store: _store, ...props }, ref) => (\n <div\n ref={ref}\n className={cn('my-1 h-px bg-border-200', className)}\n {...props}\n />\n));\nDropdownMenuSeparator.displayName = 'DropdownMenuSeparator';\n\n// Componentes específicos do ProfileMenu\nconst ProfileMenuTrigger = forwardRef<\n HTMLButtonElement,\n ButtonHTMLAttributes<HTMLButtonElement> & { store?: DropdownStoreApi }\n>(({ className, onClick, store: externalStore, ...props }, ref) => {\n const store = useDropdownStore(externalStore);\n const open = useStore(store, (s) => s.open);\n const toggleOpen = () => store.setState({ open: !open });\n\n return (\n <button\n ref={ref}\n className={cn(\n 'rounded-lg size-10 bg-primary-50 flex items-center justify-center cursor-pointer',\n className\n )}\n onClick={(e) => {\n e.stopPropagation();\n toggleOpen();\n onClick?.(e);\n }}\n aria-expanded={open}\n {...props}\n >\n <span className=\"size-6 rounded-full bg-primary-100 flex items-center justify-center\">\n <User className=\"text-primary-950\" size={18} />\n </span>\n </button>\n );\n});\nProfileMenuTrigger.displayName = 'ProfileMenuTrigger';\n\nconst ProfileMenuHeader = forwardRef<\n HTMLDivElement,\n HTMLAttributes<HTMLDivElement> & {\n name: string;\n email: string;\n photoUrl?: string | null;\n store?: DropdownStoreApi;\n }\n>(({ className, name, email, photoUrl, store: _store, ...props }, ref) => {\n return (\n <div\n ref={ref}\n data-component=\"ProfileMenuHeader\"\n className={cn(\n 'flex flex-row gap-4 items-center min-w-[280px]',\n className\n )}\n {...props}\n >\n <span className=\"w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center overflow-hidden flex-shrink-0\">\n {photoUrl ? (\n <img\n src={photoUrl}\n alt=\"Foto de perfil\"\n className=\"w-full h-full object-cover\"\n />\n ) : (\n <User size={34} className=\"text-primary-800\" />\n )}\n </span>\n <div className=\"flex flex-col mi