'use client' import React from 'react' import Image from '../../../embed-shims/next-image' import { InteractiveCard } from '../../ui/interactive-card' import { StatusBadge } from '../../ui/status-badge' import { SquareAvatar } from '../../ui/square-avatar' import { AlertTriangle, Eye, Package, Play, Sparkles, TrendingUp, Wrench, } from 'lucide-react' import { cn } from '../../../utils/cn' /** * Card density. Two variants, both actively used across openframe + flamingo * apps and the related-content rail: * * - `lg`: rich large card used everywhere a product release is the focal * item — openframe's `/releases` catalog row, flamingo's DevCenter tab, * investor-update related-content section. Three zones — hero (16:9 cover * + version pill + title + summary), changelog stats strip (icons + * counts), metadata grid footer (Type · Status · Released · Author). The * grid mirrors the hub's `` byte-for-byte (see lg * branch comment). * * - `sm`: compact horizontal layout (~80px tall) for inline rendering inside * chat messages and other tight surfaces. Drops `

` (block-only, * illegal inside markdown `

`) for `` text, swaps the outer * `InteractiveCard` for a ``-anchored link, and collapses to: * 56px icon + 1-line title + 1-line meta (version · date). * * A previous `default` variant (vertical title+description / version+date * column) was deleted in the 2026-05 DRY pass — it had a single consumer * (the hub's RelatedContentSection) that has since moved to `lg`. */ export type ProductReleaseCardSize = 'lg' | 'sm' /** * Minimal structural `` prop bundle the consumer composes (typically * via the hub's `useNavLink` hook — single source of truth for click * routing). Kept structural here so the OSS lib has zero hub coupling; * the consumer's `NavLinkProps` is type-compatible by shape. * * When supplied, the outer element renders as `` so * the SAME routing decision (modifier-click → browser default, cross- * origin → new tab, same-origin → `router.push`) runs identically to * every other entity card. When absent, the legacy `onClick` branch is * used (back-compat for the public `/releases` page). */ export interface ProductReleaseCardAnchorProps { href: string target?: '_blank' rel?: 'noopener noreferrer' onClick?: (e: React.MouseEvent) => void } export interface ProductReleaseCardProps { /** Release title */ title: string /** Release summary/description */ summary?: string | null /** Version string (e.g., "1.2.0") */ version: string /** Formatted date string for display */ formattedDate: string /** * Legacy click handler — kept for back-compat with public-page callers * (e.g. `/releases` tab) that route via `router.push()` directly. When * `anchorProps` is also supplied, `anchorProps` wins and this is * ignored. */ onClick?: () => void /** * `` prop bundle from the consumer's `useNavLink` (or equivalent). * When provided, the card's outer element renders as a real anchor so * routing (cross-origin → new tab, same-origin → soft RSC nav, * modifier-click → browser default) is owned by the hook — the card * writes NO click logic of its own. This is the path every other * entity card uses; `onClick` is only kept for the one legacy caller. */ anchorProps?: ProductReleaseCardAnchorProps /** Additional CSS classes */ className?: string /** Card density. Defaults to `'lg'` (the canonical large card). */ size?: ProductReleaseCardSize // ─── Catalog-only props (ignored by `default` / `sm` branches) ───────── /** Cover image URL. Falls back to a neutral `Package`-icon placeholder. */ coverImage?: string | null /** Drives the Play overlay on the cover. Caller sets `true` when the cover * came from a `*_video_thumbnail` field. */ hasVideoCover?: boolean /** Release type for the metadata grid's first cell. */ releaseType?: 'major' | 'minor' | 'patch' | 'beta' | 'alpha' /** Release status for the metadata grid's second cell. */ releaseStatus?: 'alpha' | 'beta' | 'stable' | 'deprecated' /** Pre-computed `StatusBadge` colorScheme for the release-type chip. The * hub consumer maps `release_type → colorScheme` via its local helper so * the OSS card stays mapping-agnostic. */ releaseTypeBadgeColor?: 'error' | 'cyan' | 'success' | 'warning' /** View count for the optional microline below the metadata grid. Hidden * when zero or undefined. */ viewCount?: number /** Hydrated author (from the hub DAL's `hydrateAuthor`). When present, * renders as the last cell of the metadata grid. */ author?: { full_name: string avatar_url: string | null job_title: string | null } /** Per-category counts for the changelog stats strip. The whole strip * is hidden when total === 0. */ changelogCounts?: { features: number fixes: number improvements: number breaking: number } } export function ProductReleaseCard({ title, summary, version, formattedDate, onClick, anchorProps, className, size = 'lg', coverImage, hasVideoCover, releaseType, releaseStatus, releaseTypeBadgeColor, viewCount, author, changelogCounts, }: ProductReleaseCardProps) { // ----- LG branch (rich /releases catalog row + related-content rail) ----- // The card has THREE zones: // 1. Hero — cover image LEFT, version pill + title + summary RIGHT. // 2. Changelog strip — icons + counts (hidden when total === 0). // 3. Metadata grid footer — bordered grid of [Type | Status | Released // | Author] cells. This grid INLINES the hub's // visual treatment by hand (SAME bordered grid with value-cell + // author-cell shapes, byte-for-byte). The OSS lib has zero hub // coupling by design; we cannot import the hub's // . This is the SAME inline-duplication policy // documented for the COMPACT_CARD_* string set in the chat-card // file. If the hub's visual changes (cell // padding, divider styles, avatar size, etc.), update this branch // in lockstep. if (size === 'lg') { const totalChangelog = (changelogCounts?.features ?? 0) + (changelogCounts?.fixes ?? 0) + (changelogCounts?.improvements ?? 0) + (changelogCounts?.breaking ?? 0) // Build the metadata-grid cell array — mirrors the hub's // EntityAuthorCard composition. ALWAYS render all 3 value cells // (Type / Status / Released) — missing values render as a plain // em-dash + label so the grid keeps a fixed 4-cell shape (matching // the skeleton). The Author cell is also always rendered below // (effectiveAuthor falls back to a placeholder shape). This is // load-to-resolve baseline parity: any conditional cell would // introduce a reflow when the skeleton resolves. // // Plan note: em-dash placeholders read as plain text (NOT a colored // StatusBadge for the Type cell) so empty badges don't look broken // next to populated badges. type ValueCell = { value: string label: string uppercase: boolean colorScheme?: 'error' | 'cyan' | 'success' | 'warning' } const valueCells: ValueCell[] = [ releaseType && releaseTypeBadgeColor ? { value: releaseType.toUpperCase(), label: 'Type', uppercase: true, colorScheme: releaseTypeBadgeColor, } : { value: '—', label: 'Type', uppercase: false }, releaseStatus ? { value: releaseStatus.toUpperCase(), label: 'Status', uppercase: true, } : { value: '—', label: 'Status', uppercase: false }, formattedDate ? { value: formattedDate, label: 'Released', uppercase: false, } : { value: '—', label: 'Released', uppercase: false }, ] // EMPTY_AUTHOR_PLACEHOLDER shape — mirrors the hub's // EMPTY_AUTHOR_PLACEHOLDER constant exported from // components/shared/entity-author-card.tsx (hub can't be imported // here; the two are kept in lockstep per the inline-duplication // policy documented in the catalog branch comment above). const effectiveAuthor = author?.full_name ? author : { full_name: '—', avatar_url: null, job_title: 'Unknown' } // Fixed 4-cell grid (Type / Status / Released / Author) so the // skeleton's shape matches the loaded card exactly. The earlier // dynamic `gridColsClass` ternary collapsed missing cells and // caused 28-56px reflow on resolve. const gridColsClass = 'md:grid-cols-4' const dividerClass = 'border-b md:border-b-0 md:border-r border-ods-border' const frameClass = cn( 'group bg-ods-card border border-ods-border rounded-lg overflow-hidden', 'flex flex-col p-6 gap-4', 'transition-all duration-300 ease-out transform hover:translate-y-[-2px]', 'hover:border-ods-accent hover:shadow-lg hover:shadow-ods-accent/[0.08]', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ods-bg', 'no-underline', className, ) const innerLayout = ( <> {/* HERO ZONE — cover LEFT + version pill + title + summary RIGHT */}

{coverImage ? ( {title} ) : (
)} {hasVideoCover && coverImage && ( )}
v{version}
{/* Title — reserve a fixed 2-line height so cards with 1-line titles don't shrink and the catalog skeleton-to- content transition is shift-free. Mirrors the onboarding-guide catalog card. */}

{title}

{/* Summary — fixed 3-line height. `line-clamp-3` caps long summaries at 3 lines; `min-h` reserves the same vertical space when content is shorter, so the catalog grid stays row-consistent regardless of per-card content length. Heights derived from text-sm md:text-base × leading-relaxed (1.625): 14×1.625×3 ≈ 68 px mobile, 16×1.625×3 ≈ 78 px desktop. */}

{summary ?? ''}

{/* CHANGELOG STRIP — ALWAYS rendered so the skeleton's always-on changelog placeholder matches the loaded shape (zero reflow on resolve). When `totalChangelog === 0`, an empty-state line takes the same vertical space as the populated row. */}
{totalChangelog > 0 && changelogCounts ? ( <> {changelogCounts.features > 0 && ( {changelogCounts.features} {changelogCounts.features === 1 ? 'feature' : 'features'} )} {changelogCounts.fixes > 0 && ( {changelogCounts.fixes} {changelogCounts.fixes === 1 ? 'fix' : 'fixes'} )} {changelogCounts.improvements > 0 && ( {changelogCounts.improvements} {changelogCounts.improvements === 1 ? 'improvement' : 'improvements'} )} {changelogCounts.breaking > 0 && ( {changelogCounts.breaking} breaking )} ) : ( No changelog entries yet )}
{/* METADATA GRID FOOTER — fixed 4-cell shape (Type / Status / Released / Author) so the skeleton mirrors the loaded card exactly. Empty value cells render em-dash + label (plain text, no colored badge — em-dash badges read as broken next to populated ones); the Author cell falls back to the EMPTY_AUTHOR_PLACEHOLDER shape declared above. */}
{valueCells.map((cell, i) => (
{cell.colorScheme ? ( ) : (

{cell.uppercase ? cell.value.toLocaleUpperCase() : cell.value}

)}

{cell.label}

))}

{effectiveAuthor.full_name}

{effectiveAuthor.job_title || 'Author'}

{typeof viewCount === 'number' && viewCount > 0 && (
{viewCount.toLocaleString()} views
)} ) // Outer-element three-branch decision tree, matching the existing // `default` branch precedence at lines ~200-241. PRECEDENCE: // anchorProps WINS over onClick. if (anchorProps) { return (
{innerLayout} ) } if (onClick) { return ( {innerLayout} ) } // Non-interactive fallback — strip the hover lift / accent-border so // the cursor doesn't lie about clickability. return (
{innerLayout}
) } // ----- COMPACT branch (chat / tight surfaces) ------------------------------ // Outer must be a phrasing-content element (`` or ``) — block // elements like `
`/`

` are illegal inside markdown `

`, so we // cannot reuse `InteractiveCard` (a `

`). // // - When `anchorProps` is set, render as a real `` so the consumer's // click hook (`useNavLink`) owns routing identically to every other // entity card — cross-origin → new tab, same-origin → soft RSC nav. // - Else fall back to legacy `` behavior driven by // `onClick` (kept for the public `/releases` page caller). // - When neither is set, render a static non-interactive span. // // Inner layout mirrors BlogCard/CaseStudyCard compact: 56px square slot + // primary text + 1-line meta + optional summary clamp. if (size === 'sm') { const handleKey = (e: React.KeyboardEvent) => { if (!onClick) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onClick() } } const isInteractive = !!anchorProps || !!onClick const outerClassName = cn( // The base frame (`my-1.5 flex … no-underline`) mirrors the // hub's `COMPACT_CARD_OUTER` in `components/shared/compact- // card/compact-card-classes.ts` byte-for-byte. The OSS lib // can't import from the consumer, so the two strings are // kept identical by hand — if you edit one, edit the other. // // The interactive branch ADDS `cursor-pointer` + a focus- // visible ring. The `` form would normally get focus styling // from the browser, but `no-underline` strips the default // affordance, so explicit ring styles match the `` form. 'my-1.5 flex items-start gap-3 w-full p-2', 'rounded-lg border border-ods-border bg-ods-card no-underline', isInteractive ? 'transition-colors hover:border-ods-text-secondary/40 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-1 focus-visible:ring-offset-ods-card' : 'cursor-default', className, ) const innerChildren = ( <> {/* 56×56 cover slot. Mirrors BlogCard / ProgramCard / OnboardingGuide sm slots — when `coverImage` is set, render the actual image (object-contain to keep landscape thumbs visible); fall back to the `Package` icon when no cover is provided. Play overlay fires only when `hasVideoCover` is true AND a cover image was actually supplied (matches the catalog variant's overlay rule). */} {coverImage ? ( {title} ) : ( )} {hasVideoCover && coverImage && ( )} {/* Text column structure must mirror the hub's `COMPACT_CARD_TEXT_COL` + `COMPACT_CARD_TITLE_ROW` + `COMPACT_CARD_META_ROW_BOX` byte-for-byte. The OSS lib can't import from the consumer, so the strings are duplicated by hand — if you edit them in the hub's compact-card-classes.ts, edit them here too. Per-row heights are fixed (h-5 / h-4 / h-4) so a skeleton placeholder occupies the SAME pixel position as the loaded text — zero load-to-resolve baseline shift. */} {title} {version ? ( {version} ) : null} {formattedDate || 'Product release'} {/* The literal between the curly-quote string is U+00A0 (NBSP). The hub's `COMPACT_CARD_ROW_FILLER` is also NBSP; ASCII space here would let React collapse the child to zero content, breaking baseline parity with the skeleton. Keep these in lockstep. */} {summary || ' '} ) // Anchor variant — consumer's `useNavLink` (or equivalent) owns the // click decision; the card just spreads the prop bundle and renders // a real `` so cmd/ctrl-click new-tab + middle-click work without // any extra JS. This is the SAME pattern BlogCard / CaseStudyCard / // ProgramCard / etc. use across the consumer codebase. if (anchorProps) { return ( {innerChildren} ) } // Legacy fallback — `onClick` (no href). Keeps the public `/releases` // page's existing `router.push(...)` flow working unchanged. When // neither `anchorProps` nor `onClick` is set, renders a static // non-interactive span. return ( {innerChildren} ) } // Unreachable — `size` is typed `'lg' | 'sm'` and both branches return // above. Kept as a defensive throw so a future variant addition that // forgets to return doesn't silently render `undefined`. throw new Error(`ProductReleaseCard: unsupported size '${size as string}'`) }