'use client' /** * Public-facing detail view for `/onboarding-guides/`. * * Two modes: * - **controlled** (hub SSR): pass `initialData` (a server-fetched guide). * - **self-fetching** (config-only embed): pass `slug` + `guideEndpoint` * (the api route) and the view fetches the guide itself — no host data * layer. (Plain fetch + useEffect, the DeliveryLists pattern.) * * Everything else flows through lib primitives + the ChatRuntime context * (markdown, video warmup, captions, related-card hrefs via * `runtime.composeContentUrl`). Optional `MarkdownRenderer` lets hosts swap a * renderer with extra plugins. */ import { Fragment, type ComponentType, type ReactNode } from 'react' import { PageLayout } from '../layout/page-layout' import { useRouter } from '../../embed-shims' // PageShell (max-w-[1920px]) — the guide detail content must share the SAME // container sizing as the related-content/FAQ rail the host page renders // below it (which uses the wide shell). ArticleDetailLayout (1280px) made // the detail block visibly narrower than the rail. import { PageShell } from '../layout/article-detail-layout' import { DetailPageSkeleton } from '../shared/detail-page-skeleton' import { EntityVideoSection } from '../features/entity-video-section' import { VideoBitesStrip } from '../features/video-bites-strip' import { toStripProfile } from '../features/video-bites-shared' import { useVideoWarmup } from '../features/use-video-warmup' import { useCaptions } from '../features/use-captions' import { RichMarkdownRenderer } from '../ui/markdown' import { EntityTagBadges } from '../features/entity-tag-badges' import { LoadError } from '../ui/error-state' import { ArticleAuthorByline } from '../shared/article-author-byline' import { EntityAuthorCard } from '../chat/entity-cards/entity-author-card' import { OnboardingGuideCard } from '../chat/entity-cards/onboarding-guide-card' import { useChatRuntime } from '../../contexts/chat-runtime-context' import type { OnboardingGuide } from '../chat/types/entities/onboarding-guide' import type { VideoTeaser } from '../../types/video-processing' import { resolveContentHref } from '../../utils/content-href' import { buildOgPlaceholderUrl } from '../../utils/og-placeholder' import { useSelfFetch } from '../../hooks/use-self-fetch' export interface OnboardingGuideDetailViewProps { /** Server-fetched guide (controlled / SSR mode). Omit it and pass * `slug` + `guideEndpoint` for the self-fetching config-only mode. */ initialData?: OnboardingGuide /** Self-fetch: the guide's slug (the host reads it from its route). */ slug?: string /** Self-fetch: builds the GET url for a single guide (the api route). * e.g. `(s) => \`/content/api/onboarding-guides/${s}\``. */ guideEndpoint?: (slug: string) => string related?: OnboardingGuide[] /** Link target for the author name in the metadata grid — the host * computes it (public author page; absent ⇒ plain text). */ authorHref?: string /** Bio paragraph for the end-of-article author byline. Defaults to the * guide payload's `author.about` (hubs that hydrate it can omit this). */ authorBio?: string | null /** Byline fallback paragraph when the bio is empty — the host passes its * platform-aware copy (the lib has no config awareness). Absent ⇒ the * byline renders nothing below the name when the bio is empty. */ fallbackBio?: string | null /** Optional markdown renderer override. Defaults to lib * ``. */ MarkdownRenderer?: ComponentType<{ content: string }> /** Optional per-row related-card renderer override. */ renderRelatedCard?: (guide: OnboardingGuide) => ReactNode /** Back-link target. Defaults to `basePath`. */ backHref?: string /** Back-link label. Defaults to "Back to Getting Started". */ backLabel?: string /** Base path the related-card hrefs default to when * `runtime.composeContentUrl` is not wired. Default `/onboarding-guides`. */ basePath?: string /** Optional slot rendered inside the page chrome, BELOW the article + related * guides — e.g. the hub's cross-type related-content / FAQ rail. Lets the hub * mount this view directly (no local wrapper); embedders 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 } export function OnboardingGuideDetailView({ authorHref, authorBio, fallbackBio, initialData, slug, guideEndpoint, related = [], MarkdownRenderer = RichMarkdownRenderer, renderRelatedCard, backHref, backLabel = 'Back to Getting Started', basePath = '/onboarding-guides', relatedContent, shell = true, }: OnboardingGuideDetailViewProps) { const resolvedBackHref = backHref ?? basePath const runtime = useChatRuntime() const captions = useCaptions() const router = useRouter() // `shell` true → standalone ``; false → padding-only box (no nested //
) for hosts whose layout already provides the container. const renderShell = (node: ReactNode) => shell ? {node} :
{node}
// Controlled (hub SSR `initialData`) OR self-fetch by slug (config-only embed). const url = initialData ? null : slug && guideEndpoint ? guideEndpoint(slug) : null const { data: guide, isLoading, error, reload } = useSelfFetch(url, { initialData }) // Hooks must run unconditionally — feed them optional-chained values so they // no-op while the guide is still loading. const { ref: videoWarmupRef } = useVideoWarmup({ videoUrl: guide?.main_video_url, supabaseStorageOrigin: runtime?.endpoints.supabaseStorageOrigin, }) if (error || (!guide && !isLoading)) { return renderShell() } if (!guide) { // Skeleton (not a bare "Loading…") for parity with every other shared view — // catalog, roadmap, releases all render a skeleton on first load, so the detail // page shouldn't flash text then content. `bare` + `PageShell` so the loading // state matches the loaded page's full width / padding / min-height. return renderShell( // Match the loaded page's top offset (TitleBlock's // `pt-[var(--spacing-system-l)]`) so content doesn't jump on load.
) } // Track URLs via the ONE captions configurer (`useCaptions`); same-origin // hosts get the relative `/api/captions/...` default. Zero per-page wiring. const { captionsUrl, highlightCaptionsUrl } = captions.forEntity('onboarding_guide', guide) const videoPoster = guide.main_video_thumbnail || guide.featured_image || guide.og_image_url || // `buildOgPlaceholderUrl` always returns a usable string (relative route at // worst), so it's the terminal fallback — no trailing `|| undefined` needed. buildOgPlaceholderUrl(runtime?.endpoints, guide.title, { aspect: 'wide' }) const defaultRenderRelatedCard = (g: OnboardingGuide) => { const cta = resolveContentHref(runtime?.composeContentUrl, { type: 'onboarding_guide', slug: g.slug, basePath, platforms: g.onboarding_guide_platforms, }) return } const renderRelatedCardFn = renderRelatedCard ?? defaultRenderRelatedCard return renderShell( router.push(resolvedBackHref) }} > {/* Tags — flat onboarding_guide_tags[] from entity_tags. */} {/* Metadata grid — Section · Step | Published | Author. */} {/* Video. `main_video_url` (Mux/MP4) and `youtube_url` are independent columns — either populated renders the player. */} {(guide.main_video_url || guide.youtube_url) && (
)} {/* Markdown body */} {guide.content && (
)} {/* Video Bites — unified strip; renders nothing when no published bites. */} {/* End-of-article author byline (avatar + linked name + bio) — the author DESCRIPTION block every article-shaped detail page renders. Hidden when the guide has no author (the byline returns null). */} {/* Related — same-section, ordered by step. */} {related.length > 0 && (

More in {guide.section}

    {related.map((r) => (
  • {renderRelatedCardFn(r)}
  • ))}
)} {/* * Host slot — cross-type related-content / FAQ rail. * * KEYED DELIBERATELY. This is one member of PageLayout's static children * array, and the only member whose element may be created in a SERVER * component and crossed over the RSC boundary. Static JSX children are * normally exempt from the key warning because `jsxs` runs * `validateChildKeys` and stamps `_store.validated = 1` on each element — * but a slot arriving over the wire is referenced as a lazy chunk wrapper, * so validation marks the WRAPPER and the fulfilled path never copies the * flag onto the revived element. It reaches the reconciler with * `validated: 0, key: null` and warns, blaming PageLayout (the owner of the * enclosing element) rather than anything actually at fault. * * A keyed Fragment emits no DOM, so rendered output is byte-identical. * Do NOT "fix" this in PageLayout via `React.Children.toArray` — that would * auto-key every consumer's children and change reconciliation identity for * all of them, and that file carries an explicit FROZEN contract. */} {relatedContent}
) }