SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
6.2 KB · 132 lines tsx
Raw Blame History
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