'use client' /** * ProgramCard (pure presentation). Generic card for podcasts / webinars / * events. Three densities — `default` (wide horizontal detail, archive * pages), `sm` (compact horizontal for chat-inline), and `portrait` * (vertical rail/strip card). * * `portrait` exists because mixed-content rails MUST share ONE card anatomy * (2026 card-UI practice: a rail mixes content types, never card layouts — * mixing orientations/aspects in one scroller is the anti-pattern). It * renders the SAME three zones as the other portrait entity cards * (CaseStudyCard et al.): wide media slot → fixed 72px title zone → fixed * 60px person/meta footer, p-6 / gap-6. The program's identity lives in the * Azeret Mono title and the date · duration meta line — not in a different * layout. * * The card writes NO click logic — callers wrap with their own anchor * and pass the resolved detail URL via `href`. */ import React, { useState } from 'react' import Image from '../../../embed-shims/next-image' import { format } from 'date-fns' import { ExternalLink, Clock, Play, Video } from 'lucide-react' import { Button } from '../../ui/button/button' import { SquareAvatar } from '../../ui/square-avatar' import { ImageGalleryModal } from '../../ui/image-gallery-modal' import { cn } from '../../../utils/cn' import { formatDurationCompact, formatTimeWithTimezone, formatDurationFromRange, } from '../../../utils/format' import { COMPACT_CARD_IMAGE_SLOT, COMPACT_CARD_META_ROW_BOX, COMPACT_CARD_OUTER, COMPACT_CARD_ROW_FILLER, COMPACT_CARD_SKELETON_IMAGE_SLOT, COMPACT_CARD_SKELETON_OUTER, COMPACT_CARD_SUMMARY, COMPACT_CARD_TEXT_COL, COMPACT_CARD_TITLE, COMPACT_CARD_TITLE_ROW, } from '../utils/compact-card-classes' import { programItemToStripProfile, type BaseProgramItem, type ProgramAuthorRef, type ProgramConfig, type ProgramMedia, type ProgramHost, } from '../types/entities/program-types' import { EntityPortraitCard } from './entity-portrait-card' import { useEntityCardLink } from './use-entity-card-link' import { useEntityCardPlaceholder } from './use-entity-card-placeholder' type CardSize = 'default' | 'sm' | 'portrait' /** * Format a Date with date-fns pinned to UTC. `date-fns` `format()` reads the * runtime's LOCAL wall-clock, so the same instant renders differently on the * server (Vercel = UTC) and the client (visitor tz) → React #418 hydration * mismatch. Shifting by the local offset before formatting emits the UTC * wall-clock on every machine, so server and client agree. Mirrors the helper * in the hub's `program-header.tsx` (kept local — the lib has no date-fns-tz * dep) and the repo-wide "pin program dates to UTC" convention. */ function formatUtc(date: Date, fmt: string): string { return format(new Date(date.getTime() + date.getTimezoneOffset() * 60_000), fmt) } export function ProgramCardSkeleton({ size = 'default' }: { size?: CardSize }) { if (size === 'sm') { return ( ) } return (
) } export interface ProgramCardProps { config: ProgramConfig item: T media?: ProgramMedia[] renderMeta?: (item: T) => React.ReactNode size?: CardSize /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */ showTypeBadge?: boolean /** Detail URL resolved by the caller. */ href: string /** When `_blank`, opens in a new tab. Set by chat dispatch via * `computeIsNewTab` so the inner `` matches the runtime * nav decision (cross-platform / embed → new tab). Defaults to * same-tab for non-chat callsites. */ target?: '_blank' rel?: 'noopener noreferrer' targetPlatform?: string | null /** OG placeholder URL used by the compact branch when no cover. */ placeholderUrl?: string | null wholeCardClickable?: boolean className?: string } function getHosts(hosts: ProgramHost[] | null | undefined): Array<{ name: string; avatar: string | null }> { if (!hosts) return [] try { if (Array.isArray(hosts)) { return hosts.map((host) => ({ name: host.name || 'Unknown', avatar: host.avatar_url || null, })) } if (typeof hosts === 'string') { const parsed = JSON.parse(hosts) if (Array.isArray(parsed)) { return parsed.map((host: any) => ({ name: host.name || host.display_name || 'Unknown', avatar: host.avatar_url || null, })) } } } catch (error) { console.warn('Failed to parse hosts data:', error) } return [] } function MediaGallery({ images, title }: { images: ProgramMedia[]; title: string }) { const [selectedImageIndex, setSelectedImageIndex] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) const openImageModal = (index: number, event?: React.MouseEvent) => { if (event) event.stopPropagation() setSelectedImageIndex(index) setIsModalOpen(true) } const closeImageModal = () => { setIsModalOpen(false) setSelectedImageIndex(null) } return ( <>
{images.map((mediaItem, index) => (
openImageModal(index, e)} > {`${title}
+
))}
img.media_url)} isOpen={isModalOpen} onClose={closeImageModal} initialIndex={selectedImageIndex || 0} /> ) } export function ProgramCard({ config, item, media = [], renderMeta, size = 'default', showTypeBadge = true, href, target: targetProp, rel: relProp, targetPlatform, placeholderUrl: placeholderUrlProp, wholeCardClickable = false, className, }: ProgramCardProps) { const { target, rel } = useEntityCardLink({ href, targetPlatform, target: targetProp, rel: relProp, }) const placeholderUrl = useEntityCardPlaceholder({ title: item.title, placeholderUrl: placeholderUrlProp, aspect: size === 'sm' ? 'square' : 'wide', }) const coverImage = item.cover_url const images = media.filter((m) => m.media_type === 'image') const hosts = getHosts(item.hosts) const accentColor = 'var(--color-accent-primary)' const isScheduled = 'status' in item && (item as any).status === 'scheduled' // Compact per-type meta (duration / location / start time) — shared by the // `sm` and `portrait` densities. const compactTypeMeta = (): string | null => { if (config.type === 'podcast' && 'duration_seconds' in item && !isScheduled) { const dur = (item as any).duration_seconds if (typeof dur === 'number' && dur > 0) return formatDurationCompact(dur) } else if (config.type === 'event' && 'location_name' in item) { const loc = (item as any).location_name if (typeof loc === 'string' && loc.trim().length > 0) return loc } else if (config.type === 'webinar' && 'start_at' in item) { const w = item as any const time = formatTimeWithTimezone(w.start_at, w.timezone ?? null) const dur = formatDurationFromRange(w.start_at, w.end_at) return dur ? `${time} · ${dur}` : time } return null } const compactDate = (() => { try { return formatUtc(new Date(item.date), 'MMM d, yyyy') } catch { return '' } })() if (size === 'portrait') { // Rail/strip density — mapped onto the shared shell // (media aspect-[1200/630] → h-[72px] title → h-[60px] footer, p-6/gap-6). // Person = author-first via programItemToStripProfile (author → primary // host); the date · duration meta line fills the subtitle when the // profile has no job title. const profile = programItemToStripProfile(item as { author?: ProgramAuthorRef | null; hosts?: ProgramHost[] | null }) const dateMeta = [compactDate, compactTypeMeta()].filter(Boolean).join(' · ') return ( ) } if (size === 'sm') { const itemDate = compactDate const compactCover = coverImage || placeholderUrl || null const typeMeta = compactTypeMeta() const subtitleParts = [itemDate, typeMeta, config.labels?.singular].filter( (s): s is string => typeof s === 'string' && s.length > 0, ) return (
{compactCover ? ( {item.title} ) : ( {config.type === 'podcast' ? : config.type === 'webinar' ? )} {config.type === 'podcast' && !isScheduled && compactCover && ( )} {item.title} {subtitleParts.join(' · ')} {item.description || COMPACT_CARD_ROW_FILLER} ) } const itemDate = new Date(item.date) const dateFormat = formatUtc(itemDate, 'EEEE d MMMM') const defaultRenderMeta = () => { if (config.type === 'podcast' && 'duration_seconds' in item && !isScheduled) { return ( <> {formatDurationCompact((item as any).duration_seconds)} ) } if (config.type === 'event' && 'location_name' in item) { return ( {(item as any).location_name || 'Location TBD'} ) } if (config.type === 'webinar' && 'start_at' in item) { const webinarItem = item as any const duration = formatDurationFromRange(webinarItem.start_at, webinarItem.end_at) return ( <>