"use client"; import { useState, useEffect, Fragment, ComponentType, type ReactNode } from 'react'; import Link from '../../../embed-shims/next-link'; import { useRouter } from '../../../embed-shims/next-navigation'; import { Card, CardContent } from '../../ui/card'; // PageShell (wide) — match the related-content/FAQ rail container the hub // renders below this view (was ArticleDetailLayout, 1280px — narrower than // the rail, see hub detail-container alignment decision 2026-06-10). import { PageShell } from '../../layout/article-detail-layout'; import { PageLayout } from '../../layout/page-layout'; import { FadePreview } from '../../ui/fade-preview'; import { ReleaseChangelogSection } from '../../ui/release-changelog-section'; import { RichMarkdownRenderer } from '../../ui/markdown'; import { EntityTagBadges } from '../../features/entity-tag-badges'; import { EntityMetadataAuthorCell } from '../../chat/entity-cards/entity-author-card'; import type { EntityAuthor } from '../../../types/entity-author'; import { MediaGalleryStrip } from '../media-gallery-strip'; import { GitHubIcon } from '../../icons/github-icon'; import { AlertTriangle, ExternalLink, BookMarked, Sparkles, TrendingUp, Wrench } from 'lucide-react'; import { formatReleaseDate } from '../../../utils/date-formatters'; import { contentFetch } from '../../../utils/embed-content-fetch'; import { Video } from '../../features/video'; import { type CaptionSrtFields } from '../../features/captions-url'; import { useCaptions } from '../../features/use-captions'; import { DetailPageSkeleton } from '../detail-page-skeleton'; import type { ChangelogEntry } from '../../../types/product-release'; import type { TagAssoc } from '../../../types/blog'; import type { VideoTeaser } from '../../../types/video-processing'; import { DEFAULT_VIDEO_BITES_TITLE, toStripProfile, type VideoBiteStripProfile, } from '../../features/video-bites-shared'; // Types for injectable components export interface MarkdownRendererProps { content: string; } // Canonical RoadmapItem shape lives in chat entity types — see // `src/components/chat/types/entities/roadmap-item.ts`. The product-release // detail page previously declared a structural placeholder // (`{ id; [k: string]: unknown }`) that conflicted with the canonical // shape once the entities barrel was added; re-exporting the canonical // type fixes the collision while keeping the same import path for // downstream consumers of `./release-detail-page`. import type { RoadmapItem } from '../../chat/types/entities/roadmap-item'; import type { DeliveryResponse } from '../../../types/delivery'; // Re-export both types for source-compat with consumers importing // through this module. Canonical sources: // - RoadmapItem → `../../chat/types/entities/roadmap-item` // - DeliveryResponse → `../../../types/delivery` (single source of // truth, shared with the lib `` / `` // components and the new types barrel). export type { RoadmapItem, DeliveryResponse }; export interface RoadmapSectionProps { items: RoadmapItem[]; isLoading: boolean; onItemUpdate?: (item: RoadmapItem) => void; } export interface DeliverySectionProps { data: DeliveryResponse | null; isLoading: boolean; } // Type for the useRelease hook result export interface UseReleaseResult { data: unknown; error: Error | null; isLoading: boolean; } export interface VideoDisplaySectionProps { mainVideoUrl?: string | null; youtubeUrl?: string | null; highlightVideoUrl?: string | null; highlightVideoThumbnail?: string | null; mainVideoPoster?: string | null; title?: string; videoSummary?: string | null; videoBites?: VideoTeaser[]; bitesTitle?: string; filterPublishedBites?: boolean; /** Profile shown in the bites strip's hover overlay (kit sources it from release.author). */ bitesProfile?: VideoBiteStripProfile | null; /** Overlay-footer navigation target (the release the bites originated from). */ bitesHref?: string; srtContent?: string | null; captionsUrl?: string | null; /** VTT captions URL for the highlight reel (variant=highlight). */ highlightCaptionsUrl?: string | null; } export interface ReleaseDetailPageProps { slug: string; initialData?: unknown; // Optional pre-fetched data for admin preview // Required: Hook for fetching release data (must be from app-level to use correct QueryClient) useRelease: (slug: string | undefined) => UseReleaseResult; // Injectable components for app-specific rendering MarkdownRenderer?: ComponentType; RoadmapSection?: ComponentType; DeliverySection?: ComponentType; /** Injectable video display section with tabs for full/highlight video + summary + bites */ VideoDisplaySection?: ComponentType; // API endpoints for fetching linked tasks roadmapApiEndpoint?: string; deliveryApiEndpoint?: string; /** Back-button config — same pattern as `DevSectionPage` / * `LegalDocumentPage`. Pass `false` to hide. Default * `{ label: 'Back to home', href: '/' }`. */ backButton?: { label?: string; href?: string } | false; /** Link target for the author name in the metadata grid — the host * computes it (public author page; absent ⇒ plain text). */ authorHref?: string; /** Optional slot rendered inside the page chrome, BELOW the article body — * e.g. the hub's end-of-article author byline + related-content / FAQ rail. * Lets the hub mount this page directly (no local wrapper component) while * embedders that don't have those extras simply omit it. */ relatedContent?: ReactNode; /** Render the standalone ``. Default true. Pass false when the host * layout already provides the page container — only the padding box renders, * avoiding a nested `
`. */ shell?: boolean; } // Default renderer = the lib's `RichMarkdownRenderer` so out-of-the-box // release pages get full rich-link previews, embedded media, social cards, // etc. Hosts that want a different rendering (or a Supabase-aware preset) // override via the `MarkdownRenderer` prop. const DefaultMarkdownRenderer = RichMarkdownRenderer; export function ReleaseDetailPage({ authorHref, slug, initialData, useRelease, MarkdownRenderer = DefaultMarkdownRenderer, RoadmapSection, DeliverySection, VideoDisplaySection, roadmapApiEndpoint = '/api/roadmap', deliveryApiEndpoint = '/api/delivery', backButton, relatedContent, shell = true }: ReleaseDetailPageProps) { const router = useRouter(); const captions = useCaptions(); // `shell` true → standalone ``; false → padding-only box (no nested //
) for hosts whose layout already provides the container. const renderShell = (node: ReactNode) => shell ? {node} :
{node}
; // Use pre-fetched data if provided (admin preview), otherwise fetch via hook (public) const { data: fetchedRelease, error, isLoading } = useRelease(initialData ? undefined : slug); const release = (initialData || fetchedRelease) as Record | undefined; // Back-button config — mirrors DevSectionPage / LegalDocumentPage. // Default: { label: 'Back to home', href: '/' }. Pass `false` to hide // (e.g. embed-mode where the host owns navigation chrome). // Narrowing note: `backButton &&` already eliminates the `false` branch, // so the inner expressions are typed as `{ label?, href? } | undefined`. // Don't re-compare to `false` here — tsc TS2367s on the dead branch. const showBackButton = backButton !== false; const backLabel = (backButton ? backButton.label : undefined) ?? 'Back to home'; const backHref = (backButton ? backButton.href : undefined) ?? '/'; // Fetch roadmap and delivery tasks if linked to this release const [roadmapTasks, setRoadmapTasks] = useState([]); const [deliveryData, setDeliveryData] = useState(null); const [roadmapLoading, setRoadmapLoading] = useState(false); const [deliveryLoading, setDeliveryLoading] = useState(false); useEffect(() => { async function fetchLinkedTasks() { if (!release) return; try { // Fetch roadmap tasks if linked const roadmapTasksData = release.clickup_roadmap_tasks as Array<{ clickup_task_id: string }> | undefined; if (roadmapTasksData && roadmapTasksData.length > 0 && RoadmapSection) { setRoadmapLoading(true); const roadmapIds = roadmapTasksData.map(t => t.clickup_task_id).join(','); const roadmapResponse = await contentFetch(`${roadmapApiEndpoint}?task_ids=${roadmapIds}`); const roadmapData = await roadmapResponse.json(); setRoadmapTasks(roadmapData.items || []); setRoadmapLoading(false); } // Fetch delivery tasks if linked const deliveryTasksData = release.clickup_delivery_tasks as Array<{ clickup_task_id: string }> | undefined; if (deliveryTasksData && deliveryTasksData.length > 0 && DeliverySection) { setDeliveryLoading(true); const deliveryIds = deliveryTasksData.map(t => t.clickup_task_id).join(','); const deliveryResponse = await contentFetch(`${deliveryApiEndpoint}?task_ids=${deliveryIds}`); const deliveryResponseData = await deliveryResponse.json(); setDeliveryData(deliveryResponseData); setDeliveryLoading(false); } } catch (err) { console.error('Error fetching linked tasks:', err); setRoadmapLoading(false); setDeliveryLoading(false); } } fetchLinkedTasks(); }, [release, RoadmapSection, DeliverySection, roadmapApiEndpoint, deliveryApiEndpoint]); // Don't show loading skeleton if we have initialData if (!initialData && isLoading) { // `bare` + `PageShell` so the loading state matches the loaded page's full // width / padding / min-height (the wrapper supplies the page chrome). return renderShell( // Match the loaded page's top offset (TitleBlock's // `pt-[var(--spacing-system-l)]`) so content doesn't jump on load.
); } if (error || !release) { return renderShell(

Release Not Found

The release you're looking for doesn't exist.

); } const hasBreakingChanges = Array.isArray(release.breaking_changes) && release.breaking_changes.length > 0; // Type assertions for release data const releaseTitle = release.title as string; const releaseVersion = release.version as string; const releaseSummary = release.summary as string | null; const releaseContent = release.content as string | null; const releaseDate = release.release_date as string; const releaseType = release.release_type as string; const releaseStatus = release.release_status as string; const releaseMedia = release.release_media as Array<{ id?: string; media_type: string; media_url: string; title?: string }> | undefined; // Field-cast per this file's loose-release idiom (release_type etc. above) // — but to the SHARED EntityAuthor, never an inline shadow author shape. const author = release.author as EntityAuthor | undefined; const githubReleases = release.github_releases as Array<{ id: string; github_release_url: string }> | undefined; const knowledgeBaseLinks = release.knowledge_base_links as Array<{ id?: string; kb_article_path: string }> | string[] | undefined; const migrationGuideUrl = release.migration_guide_url as string | undefined; const documentationUrl = release.documentation_url as string | undefined; const youtubeUrl = release.youtube_url as string | undefined; const mainVideoUrl = release.main_video_url as string | undefined; const videoBites = release.video_bites as VideoTeaser[] | undefined; const highlightVideoUrl = release.highlight_video_url as string | undefined; const highlightVideoThumbnail = release.highlight_video_thumbnail as string | undefined; // Caption tracks — host-provided values win (the hub's wrapper enriches // them); otherwise built HERE from the release's SRT columns, based on // `runtime.endpoints.captionsUrlPrefix` (standard runtime-endpoint wiring), // so embedders get correctly proxied `` URLs with zero per-host code. const entityCaptions = captions.forEntity('product_release', release as unknown as CaptionSrtFields); const captionsUrl = (release.captionsUrl as string | undefined) ?? entityCaptions.captionsUrl; const highlightCaptionsUrl = (release.highlightCaptionsUrl as string | undefined) ?? entityCaptions.highlightCaptionsUrl; const breakingChanges = release.breaking_changes as ChangelogEntry[] | undefined; const featuresAdded = release.features_added as ChangelogEntry[] | undefined; const bugFixed = release.bugs_fixed as ChangelogEntry[] | undefined; const improvements = release.improvements as ChangelogEntry[] | undefined; return renderShell( router.push(backHref) } : undefined } >
{/* Tags — flat product_release_tags[] from entity_tags */} {/* Metadata Grid */}
{/* Release Type */}

{releaseType.toLocaleUpperCase()}

Release Type

{/* Release Status */}

{releaseStatus.toLocaleUpperCase()}

Release Status

{/* Release Date */}

{formatReleaseDate(releaseDate)}

Release Date

{/* Author — the shared metadata-grid author cell (it was extracted FROM this page; rendering the export instead of an inline copy keeps the two in lockstep). The stub author preserves the legacy "Unknown Author" rendering for author-less releases; the job_title-over-roleLabel rule matches the guide detail page. */}
{/* Image gallery — shared strip + lightbox (images) / inline clips. */} {/* Summary */} {releaseSummary && (

{releaseSummary}

)} {/* Video Display Section - Injectable or fallback */} {VideoDisplaySection ? ( ) : ( <> {/* Fallback when no `VideoDisplaySection` is injected. `
{/* * Host slot — end-of-article byline + related-content / FAQ rail. * * KEYED for the same reason as `onboarding-guide-detail-view.tsx`: this is a * member of PageLayout's static children array, and a slot element built in a * SERVER component crosses the RSC boundary as a lazy chunk wrapper, so * `validateChildKeys` marks the wrapper and the fulfilled path never copies * `_store.validated` onto the revived element — it reaches the reconciler * unvalidated and warns. * * Today the hub's only consumer of this view is a `'use client'` wrapper that * builds the slot locally, so no element actually crosses the boundary and * this is pre-emptive. It is kept because the trap is invisible from here: a * future server-built slot would warn with no clue pointing at this line. * A keyed Fragment emits no DOM, so output is byte-identical either way. */} {relatedContent}
); }