'use client' /** * RoadmapCard (pure presentation). Two densities — `default` (rich * /roadmap page card with vote buttons + figma/screenshots controls) * and `sm` (compact horizontal for chat-inline). * * The card writes NO click logic — the parent wraps with its own * anchor for the compact branch and supplies vote handlers for the * default branch. */ import React, { useState } from 'react' import Image from '../../../embed-shims/next-image' import { ThumbsUp, ThumbsDown } from 'lucide-react' import { RoadmapVoteButton } from './roadmap-vote-button' import { AvatarStack, type AvatarStackPerson } from '../../ui/avatar-stack' import { FigmaIcon } from '../../icons/figma-icon' import { ImageIcon } from '../../icons/image-icon' import { Button } from '../../ui/button/button' import { StatusBadge, generationTierFromLabel } from '../../ui/status-badge' import { ImageGalleryModal } from '../../ui/image-gallery-modal' import { getProxiedImageUrl } from '../../../utils/image-proxy' import { COMPACT_CARD_META_ROW_BOX, COMPACT_CARD_OUTER, COMPACT_CARD_OUTER_STATIC, COMPACT_CARD_ROW_FILLER, COMPACT_CARD_SKELETON_OUTER, COMPACT_CARD_SUMMARY, COMPACT_CARD_TEXT_COL, COMPACT_CARD_TITLE, COMPACT_CARD_TITLE_ROW, safeHref, } from '../utils/compact-card-classes' import { getStatusColorScheme } from '../utils/agent-status-message' import { getTaskTypeLabel } from '../utils/clickup-task-type-utils' import { TaskTypeIcon } from './task-type-icon' import type { RoadmapItem } from '../types/entities/roadmap-item' type CardSize = 'default' | 'sm' export type VoteType = 'up' | 'down' | null export function RoadmapCardSkeleton({ size = 'default' }: { size?: CardSize }) { if (size === 'sm') { return ( {/* assignee-stack placeholder — mirrors the xs AvatarStack slot so row width doesn't shift when assignees load */} ) } return (
{/* assignee-stack placeholder — matches the xs AvatarStack in the loaded card's action row */}
) } export interface RoadmapCardProps { item: RoadmapItem /** Detail URL for the compact branch (`sm`). Used by the parent * wrapper to drive nav. Default-branch cards don't need href — * voting + screenshot UI is the entire action surface. */ href?: string /** When `_blank`, opens in a new tab. Set by chat dispatch via * `computeIsNewTab`. Defaults to same-tab. */ target?: '_blank' rel?: 'noopener noreferrer' targetPlatform?: string | null /** Compact-branch variant — drives the icon-slot fallback rule. */ cardType?: 'roadmap_item' | 'delivery_item' | 'internal_task' size?: CardSize className?: string /** DOM `id` applied to the card's outer element. `RoadmapGrid` sets * `roadmap-` so chat-card deep-links * (`?search=#roadmap-`) have a target for `useScrollToHash` * to scroll to. `scroll-mt-24` on the outer element keeps the card * BELOW the sticky chrome. */ id?: string // Default-branch vote controls (ignored in `sm`): userVote?: VoteType | null onVote?: (voteType: 'up' | 'down') => void isVoting?: boolean } /** Wire assignees → AvatarStack people (name+avatar only on the wire). */ function assigneePeople( assignees: Array<{ id: number; name: string | null; avatarUrl: string | null }> | undefined, ): AvatarStackPerson[] { return (assignees ?? []).map((a) => ({ key: a.id, name: a.name ?? 'Unknown', avatarUrl: a.avatarUrl, })) } export function RoadmapCard({ item, href, target, rel, size = 'default', cardType = 'roadmap_item', className, id, userVote, onVote, isVoting = false, }: RoadmapCardProps) { const [showScreenshots, setShowScreenshots] = useState(false) const logoUrl = item.icon && item.icon.startsWith('http') ? item.icon : null const iconSrc = logoUrl ? getProxiedImageUrl(logoUrl, { directHttps: true }) || logoUrl : null if (size === 'sm') { const compactHref = safeHref(href ?? null) const hasVotes = (item.upvotes ?? 0) > 0 || (item.downvotes ?? 0) > 0 const hasFigma = !!item.figmaUrl const hasScreenshots = (item.screenshots?.length ?? 0) > 0 const typeLabel = getTaskTypeLabel(item.customItemId) const useTypeIcon = cardType === 'internal_task' || (!iconSrc && item.customItemId != null) const body = ( <> {useTypeIcon ? ( ) : iconSrc ? ( {`${item.title} ) : ( {item.title?.substring(0, 2) || '??'} )} {item.title} {item.status && item.status.trim().length > 0 ? ( ) : null} {(() => { const parts = [ item.quarter, item.targetVersion ? `${item.targetVersion} version` : null, ].filter(Boolean) if (parts.length > 0) return parts.join(' · ') if (cardType === 'delivery_item') { return typeLabel ? `Delivery · ${typeLabel}` : 'Delivery' } if (cardType === 'internal_task') { return typeLabel ?? 'Internal task' } return 'Roadmap item' })()} {hasVotes ? ( {item.upvotes ?? 0} {item.downvotes ?? 0} ) : null} {hasFigma ? ( ) : null} {hasScreenshots ? ( {item.screenshots.length} ) : null} {item.assignees && item.assignees.length > 0 ? ( ) : null} {item.description || COMPACT_CARD_ROW_FILLER} ) if (!compactHref) { return {body} } return ( {body} ) } return (
{iconSrc ? ( {`${item.title} ) : ( {item.title?.substring(0, 2) || '??'} )}

{item.title}

{item.quarter}, {item.id}

{item.description || ''}

{onVote && (
onVote('up')} disabled={isVoting} /> onVote('down')} disabled={isVoting} />
)}
{item.assignees && item.assignees.length > 0 ? ( ) : null} {item.screenshots && item.screenshots.length > 0 && (
{item.screenshots && item.screenshots.length > 0 && ( setShowScreenshots(false)} /> )}
) }