'use client' /** * OnboardingGuideCard (pure presentation + runtime-derived link attrs). * * Three variants: * - `catalog`: rich detail card (hero + author grid) for the public catalog * page. * - `default`: horizontal step-numbered card for "More in {section}" rail. * - `sm`: compact horizontal card for chat-inline rendering. * * Link semantics: the card derives `target`/`rel` from `ChatRuntime.navigation * .decideNewTab` (hub-wired via `HubRuntimeProvider`) and the placeholder * image from the runtime's base API URL (`endpoints.ogPlaceholderUrl`, via * `useEntityCardPlaceholder`). Explicit `target` / `rel` / `placeholderUrl` * props always WIN — chat dispatch and tests can pre-resolve. No runtime * mounted → same-tab + same-origin relative placeholder. */ import React from 'react' import Image from '../../../embed-shims/next-image' import Link from '../../../embed-shims/next-link' import { Clock, ExternalLink, GraduationCap, Play } from 'lucide-react' import { cn } from '../../../utils/cn' import { BlogImagePlaceholder } from './blog-image-placeholder' import { EntityAuthorCard } from './entity-author-card' import { EntityPortraitCard } from './entity-portrait-card' import { formatDurationMMSS } from '../../../utils/format' import type { OnboardingGuide } from '../types/entities/onboarding-guide' import { useEntityCardLink } from './use-entity-card-link' import { useEntityCardPlaceholder } from './use-entity-card-placeholder' import { COMPACT_CARD_OUTER, COMPACT_CARD_IMAGE_SLOT, COMPACT_CARD_SKELETON_IMAGE_SLOT, COMPACT_CARD_SKELETON_OUTER, COMPACT_CARD_TEXT_COL, COMPACT_CARD_TITLE_ROW, COMPACT_CARD_TITLE, COMPACT_CARD_META_ROW_BOX, COMPACT_CARD_SUMMARY, COMPACT_CARD_ROW_FILLER, } from '../utils/compact-card-classes' export interface OnboardingGuideCardProps { guide: OnboardingGuide /** Detail URL resolved by the caller. */ href: string /** When `_blank`, opens in a new tab. Set by chat dispatch via * `computeIsNewTab`. Defaults to same-tab. */ target?: '_blank' rel?: 'noopener noreferrer' targetPlatform?: string | null /** OG placeholder URL used by the catalog + sm variants when no cover. */ placeholderUrl?: string | null size?: 'catalog' | 'default' | 'sm' | 'portrait' /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */ showTypeBadge?: boolean | 'default' | 'sm' | 'portrait' className?: string } /** Markdown source → clean one-line preview prose. The guide cards preview * `video_summary || content`, and `content` is raw markdown — without this, * the clamped summary shows literal `**bold**` / `## heading` noise. */ function stripMarkdownPreview(text: string): string { return text .replace(/```[\s\S]*?```/g, ' ') .replace(/^#{1,6}\s+/gm, '') .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/[*_~`>]/g, '') .replace(/^\s*[-+]\s+/gm, '') .replace(/-{3,}/g, ' ') .replace(/\s+/g, ' ') .trim() // Hard cap — line-clamp is the visual truncation, but a CSS-order // conflict (display utilities vs -webkit-box) once silently killed it // and dumped whole guides into the card. Never ship more than ~2 lines // of source text regardless of CSS. .replace(/^([\s\S]{240})[\s\S]+$/, '$1…') } const HORIZONTAL_SIZE_TOKENS = { default: { padding: 'p-4', step: 'w-8 h-8 text-h6', title: 'text-h5', summaryClamp: 'line-clamp-2', }, } as const export function OnboardingGuideCardSkeleton({ size = 'default' }: { size?: 'catalog' | 'default' | 'sm' }) { if (size === 'catalog') { return (
{[0, 1].map((i) => (
))}
) } if (size === 'sm') { return ( ) } const t = HORIZONTAL_SIZE_TOKENS.default return ( ) } export function OnboardingGuideCard({ guide, href, target: targetProp, rel: relProp, targetPlatform, placeholderUrl: placeholderUrlProp, size = 'default', showTypeBadge = true, className, }: OnboardingGuideCardProps) { const { target, rel } = useEntityCardLink({ href, targetPlatform, target: targetProp, rel: relProp, }) const placeholderUrl = useEntityCardPlaceholder({ title: guide.title, placeholderUrl: placeholderUrlProp, aspect: size === 'sm' ? 'square' : 'wide', }) if (size === 'portrait') { // Rail/strip density — shared shell. Same cover // chain as the catalog variant. const coverImage = guide.featured_image || guide.main_video_thumbnail || guide.og_image_url || null return ( ) } if (size === 'catalog') { const coverImage = guide.featured_image || guide.main_video_thumbnail || guide.og_image_url || null const hasVideoCover = !!(guide.main_video_thumbnail || guide.highlight_video_thumbnail) const stepLabel = typeof guide.step_order === 'number' ? String(guide.step_order).padStart(2, '0') : '—' const durationLabel = typeof guide.highlight_video_duration_ms === 'number' && guide.highlight_video_duration_ms > 0 ? formatDurationMMSS(Math.floor(guide.highlight_video_duration_ms / 1000)) : '' return (
{coverImage ? ( {guide.title} ) : ( )} {hasVideoCover && coverImage && ( )} {durationLabel && ( {durationLabel} )}

{guide.title}

{stripMarkdownPreview(guide.video_summary || guide.content || '')}

) } if (size === 'sm') { const coverImage = guide.featured_image || guide.main_video_thumbnail || guide.og_image_url || null const compactCover = coverImage || placeholderUrl || null const hasVideoCover = !guide.featured_image && !!guide.main_video_thumbnail const summary = stripMarkdownPreview(guide.video_summary || guide.content || '') const author = guide.author?.full_name?.trim() || '' const subtitleParts = [ `Step ${guide.step_order}`, guide.section, author, ].filter((s): s is string => typeof s === 'string' && s.length > 0) return ( {compactCover ? ( {guide.title} ) : ( )} {hasVideoCover && compactCover && ( )} {guide.title} {subtitleParts.join(' · ')} {summary || COMPACT_CARD_ROW_FILLER} ) } // size === 'default' — horizontal step-numbered card for related-rail. const t = HORIZONTAL_SIZE_TOKENS.default const summary = stripMarkdownPreview(guide.video_summary || guide.content || '') return ( {guide.title} {guide.section} {summary && ( {summary} )} ) }