TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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, VsRiv } 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 <VsRiv discount={a.valueOpportunity} className="text-[12px]" />65 );66 case 'new':67 return <span className="text-[11px] text-muted">added {fmtRelative(a.updatedAt)}</span>;68 case 'riv':69 return <span className="num text-[11px] text-muted">{fmtNum(a.salesCount)} sales</span>;70 case 'latestSale':71 return a.latestSaleUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : null;72 default:73 return null;74 }75}7677/** Poster-style asset tile for rails and grids. */78export function AssetTile({ a, metric = 'change30d', className, priority = false, width = 'w-[164px] sm:w-[188px]' }: { a: AssetCardData; metric?: TileMetric; className?: string; priority?: boolean; width?: string }) {79 return (80 <Link href={`/asset/${a.slug}`} className={cn('card card-hover group flex flex-col gap-2 p-2', width, className)}>81 <TileImage src={a.heroImageUrl} alt="" familySlug={a.familySlug} priority={priority} />82 <span className="flex min-h-[2.6em] flex-col">83 <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={a.title}>84 {a.title}85 </span>86 <span className="mt-0.5 truncate text-[11px] text-muted">87 {a.setName ?? catName(a.categorySlug)}88 {a.year ? ` · ${a.year}` : ''}89 </span>90 </span>91 <span className="mt-auto flex items-end justify-between gap-2 border-t border-border pt-2">92 <PriceLine a={a} size="sm" />93 <span className="shrink-0 text-right">94 <MetricNode a={a} metric={metric} />95 </span>96 </span>97 </Link>98 );99}100101/** Horizontal snap rail on small screens; wraps into a grid from lg. */102export function Rail({ children, className, cols = 'lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8' }: { children: ReactNode; className?: string; cols?: string }) {103 return <div className={cn('rail lg:mx-0 lg:grid lg:overflow-visible lg:px-0 lg:[&>*]:w-auto', cols, className)}>{children}</div>;104}105106export function RailHeader({ title, subtitle, href, hrefLabel = 'View all', className }: { title: ReactNode; subtitle?: ReactNode; href?: string; hrefLabel?: string; className?: string }) {107 return (108 <div className={cn('mb-3 flex items-end justify-between gap-3', className)}>109 <div className="min-w-0">110 <h2 className="t-title text-fg">{title}</h2>111 {subtitle ? <p className="mt-0.5 truncate text-xs text-muted">{subtitle}</p> : null}112 </div>113 {href ? (114 <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg">115 {hrefLabel} →116 </Link>117 ) : null}118 </div>119 );120}121122export function TileSkeleton({ width = 'w-[164px] sm:w-[188px]' }: { width?: string }) {123 return (124 <div className={cn('card flex flex-col gap-2 p-2', width)} aria-hidden>125 <div className="aspect-[4/5] w-full animate-pulse rounded-[5px] bg-inset" />126 <div className="h-3 w-4/5 animate-pulse rounded-sm bg-inset" />127 <div className="h-3 w-1/2 animate-pulse rounded-sm bg-inset" />128 <div className="mt-2 h-4 w-1/3 animate-pulse rounded-sm bg-inset" />129 </div>130 );131}132