'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?.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?.substring(0, 2) || '??'}
)}
{item.title}
{item.quarter}, {item.id}
{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)}
/>
)}
)
}