1import Link from 'next/link';2import type { ReactNode } from 'react';3import type { AssetCard as AssetCardData } from '@/lib/queries/assets';4import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format';5import { catName } from '@/lib/taxonomy';6import { Delta } from '@/components/ui/primitives';78export type TileMetric = 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new' | 'guide' | 'latestSale';910import { TileImage } from '@/components/ui/tile-image';11export { TileImage };1213/** Price line: RIV with confidence, or the guide price honestly labelled, or the latest sale. */14export function PriceLine({ a, size = 'md', className }: { a: AssetCardData; size?: 'sm' | 'md' | 'lg'; className?: string }) {15 const valueCls = size === 'lg' ? 'text-xl' : size === 'sm' ? 'text-[13px]' : 'text-[15px]';16 if (a.rivUsd !== null) {17 return (18 <span className={cn('flex flex-col leading-tight', className)} title={`RareIndex Valuation · ${confidenceLabel(a.rivConfidence)} confidence · ${a.rivSampleSize} sales`}>19 <span className={cn('num font-semibold text-fg', valueCls)}>{fmtMoney(a.rivUsd)}</span>20 <span className="t-caption">21 RIV · {confidenceLabel(a.rivConfidence)} · n={a.rivSampleSize}22 </span>23 </span>24 );25 }26 if (a.guideUsd !== null && a.guideUsd !== undefined) {27 return (28 <span className={cn('flex flex-col leading-tight', className)} title={`Price-guide observation from ${a.guideSource ?? 'a guide'} (${a.guideDate ?? ''}) — not a transaction and not a valuation`}>29 <span className={cn('num font-semibold text-muted', valueCls)}>{fmtMoney(a.guideUsd)}</span>30 <span className="t-caption">Guide · {a.guideSource ?? a.guideKind ?? 'observed'}</span>31 </span>32 );33 }34 if (a.latestSaleUsd !== null) {35 return (36 <span className={cn('flex flex-col leading-tight', className)}>37 <span className={cn('num font-semibold text-fg', valueCls)}>{fmtMoney(a.latestSaleUsd)}</span>38 <span className="t-caption">Last sale · {fmtRelative(a.latestSaleAt)}</span>39 </span>40 );41 }42 return (43 <span className={cn('flex flex-col leading-tight', className)} title="Insufficient evidence for a valuation">44 <span className={cn('num font-semibold text-subtle', valueCls)}>—</span>45 <span className="t-caption">Catalogued · no price yet</span>46 </span>47 );48}4950function MetricNode({ a, metric }: { a: AssetCardData; metric: TileMetric }) {51 switch (metric) {52 case 'change1d':53 return <Delta value={a.change1d} arrow className="text-[12px]" />;54 case 'change7d':55 return <Delta value={a.change7d} arrow className="text-[12px]" />;56 case 'change30d':57 return <Delta value={a.change30d} arrow className="text-[12px]" />;58 case 'trending':59 return <span className="num text-[11px] text-muted">score {a.trendingScore?.toFixed(1) ?? '—'}</span>;60 case 'watchers':61 return <span className="num text-[11px] text-muted">{fmtNum(a.watchers)} watching</span>;62 case 'opportunity':63 return (64 <span className="text-[11px] text-muted">65 vs RIV <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[12px]" />66 </span>67 );68 case 'new':69 return <span className="text-[11px] text-muted">added {fmtRelative(a.updatedAt)}</span>;70 case 'riv':71 return <span className="num text-[11px] text-muted">{fmtNum(a.salesCount)} sales</span>;72 case 'latestSale':73 return a.latestSaleUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : null;74 default:75 return null;76 }77}7879/** Poster-style asset tile for rails and grids. */80export function AssetTile({ a, metric = 'change30d', className, priority = false, width = 'w-[164px] sm:w-[188px]' }: { a: AssetCardData; metric?: TileMetric; className?: string; priority?: boolean; width?: string }) {81 return (82 <Link href={`/asset/${a.slug}`} className={cn('card card-hover group flex flex-col gap-2 p-2', width, className)}>83 <TileImage src={a.heroImageUrl} alt="" familySlug={a.familySlug} priority={priority} />84 <span className="flex min-h-[2.6em] flex-col">85 <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={a.title}>86 {a.title}87 </span>88 <span className="mt-0.5 truncate text-[11px] text-muted">89 {a.setName ?? catName(a.categorySlug)}90 {a.year ? ` · ${a.year}` : ''}91 </span>92 </span>93 <span className="mt-auto flex items-end justify-between gap-2 border-t border-border pt-2">94 <PriceLine a={a} size="sm" />95 <span className="shrink-0 text-right">96 <MetricNode a={a} metric={metric} />97 </span>98 </span>99 </Link>100 );101}102103/** Horizontal snap rail on small screens; wraps into a grid from lg. */104export function Rail({ children, className, cols = 'lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8' }: { children: ReactNode; className?: string; cols?: string }) {105 return <div className={cn('rail lg:mx-0 lg:grid lg:overflow-visible lg:px-0 lg:[&>*]:w-auto', cols, className)}>{children}</div>;106}107108export function RailHeader({ title, subtitle, href, hrefLabel = 'View all', className }: { title: ReactNode; subtitle?: ReactNode; href?: string; hrefLabel?: string; className?: string }) {109 return (110 <div className={cn('mb-3 flex items-end justify-between gap-3', className)}>111 <div className="min-w-0">112 <h2 className="t-title text-fg">{title}</h2>113 {subtitle ? <p className="mt-0.5 truncate text-xs text-muted">{subtitle}</p> : null}114 </div>115 {href ? (116 <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg">117 {hrefLabel} →118 </Link>119 ) : null}120 </div>121 );122}123124export function TileSkeleton({ width = 'w-[164px] sm:w-[188px]' }: { width?: string }) {125 return (126 <div className={cn('card flex flex-col gap-2 p-2', width)} aria-hidden>127 <div className="aspect-[4/5] w-full animate-pulse rounded-[5px] bg-inset" />128 <div className="h-3 w-4/5 animate-pulse rounded-sm bg-inset" />129 <div className="h-3 w-1/2 animate-pulse rounded-sm bg-inset" />130 <div className="mt-2 h-4 w-1/3 animate-pulse rounded-sm bg-inset" />131 </div>132 );133}134