'use client' /** * — THE portrait (rail/strip) card anatomy, shared by * EVERY entity card's `size="portrait"` density (case study, blog post, * customer interview, program, investor update, onboarding guide, …). * * One rail = one anatomy (2026 card-UI practice): identical zone boxes across * all content types — * media : aspect-[1200/630] slot + content-type chip overlay * title : fixed 72px zone, 3-line clamp (typography per entity via * `titleClassName` — e.g. Azeret Mono for programs) * footer : fixed 60px person zone (author avatar + name + subtitle, with * the optional bottom-right icon overlay — the case-study MSP * pattern) * Card surface, padding (p-6/gap-6) and the hover treatment (accent border + * accent shadow) are encoded HERE exactly once, so no per-card drift is * possible. Entity cards only MAP their row onto these props. * * Media rule ("our common way"): the REAL cover always wins. Wide covers * (OG-style 1200×630) fill the slot `object-cover`; a non-wide cover (square * podcast artwork) renders contained on an `useImageEdgeColor` background — * the SAME edge-sampled fill the media/news cards use (OGLinkPreview) — never * a flat filler and never the generic placeholder while a real image exists. * The cover → placeholder → hide resolution is the shared * `useCoverImageFallback` chain (one fallback logic for all entity cards). * * The content-type chip is the common and lives ABOVE the title * (an eyebrow inside the title zone), never overlaid on the artwork — branded * covers carry their own marks (Flamingo logo top-left) that an overlay would * collide with. */ import React, { useEffect, useState } from 'react' import Image from '../../../embed-shims/next-image' import { Card } from '../../ui/card' import { StatusBadge } from '../../ui/status-badge' import { useImageEdgeColor } from '../../../hooks/ui/use-image-edge-color' import { useCoverImageFallback } from './use-cover-image-fallback' import { cn } from '../../../utils/cn' /** Sources narrower than this are "not wide" → contained on the edge-color * fill. Wide covers are 1200/630 ≈ 1.9; squares are 1.0; 1.3 splits safely. */ const MIN_WIDE_RATIO = 1.3 export interface EntityPortraitPerson { name: string avatarUrl?: string | null subtitle?: string | null /** Small round overlay on the avatar's bottom-right corner (e.g. MSP icon). */ iconOverlayUrl?: string | null } export interface EntityPortraitCardProps { href: string target?: '_blank' rel?: string /** Content-type chip ('Case Study', 'Podcast', …). OMIT to hide the chip — * single-type rails don't need per-card type identification; only mixed * rails pass it. */ typeLabel?: string imageUrl?: string | null /** Branded wide OG fallback — used when `imageUrl` is missing, errors, or * isn't wide. */ placeholderUrl?: string | null imageAlt: string title: string /** Typography override for the title (entity identity), zone box unchanged. */ titleClassName?: string person?: EntityPortraitPerson | null className?: string } export function EntityPortraitCard({ href, target, rel, typeLabel, imageUrl, placeholderUrl, imageAlt, title, titleClassName, person, className, }: EntityPortraitCardProps) { const { src, onError: onMediaError } = useCoverImageFallback(imageUrl, placeholderUrl) // null = unknown (assume wide until the image reports its natural size). const [isWide, setIsWide] = useState(null) useEffect(() => { // Re-detect whenever the resolved source changes (prop change OR the // fallback chain advancing to the placeholder). setIsWide(null) }, [src]) const onMediaLoad = (e: React.SyntheticEvent) => { const img = e.currentTarget if (img.naturalWidth > 0 && img.naturalHeight > 0) { setIsWide(img.naturalWidth / img.naturalHeight >= MIN_WIDE_RATIO) } } // Edge-sampled slot fill for non-wide covers — the SAME hook/fallback the // media/news cards use (og-link-preview.tsx). const edgeColor = useImageEdgeColor(isWide === false ? src : null, 'var(--color-bg-surface)') return ( {/* Media zone — the real cover always wins: wide → cover-fill; non-wide → contained on the edge-color fill (news-card treatment). */}
{src && ( {imageAlt} )}
{/* Title zone — fixed box. Mixed rails: common StatusBadge eyebrow + 2-line title (chip lives here, never on the artwork). Single-type rails (no typeLabel): centered 3-line title. */}
{typeLabel && ( )}

{title}

{/* Person/footer zone — fixed box (kept even when empty so every card in a rail shares baselines). */}
{person && (
{person.avatarUrl ? ( {person.name} ) : (
{person.name.charAt(0).toUpperCase()}
)} {person.iconOverlayUrl && (
)}
{person.name} {person.subtitle && ( {person.subtitle} )}
)}
) }