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%
10.1 KB · 204 lines tsx
Raw Blame History
1import Link from 'next/link';2import type { ReactNode } from 'react';3import { cn, deltaClass, fmtPct } from '@/lib/format';45/** Minimal, dense UI primitives. Keep them boring; the data is the design. */67export function Card({ children, className, as: Tag = 'section', id }: { children: ReactNode; className?: string; as?: 'section' | 'div' | 'article'; id?: string }) {8  return (9    <Tag className={cn('card', className)} id={id}>10      {children}11    </Tag>12  );13}1415export function CardHeader({ title, subtitle, action, className, icon }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string; icon?: ReactNode }) {16  return (17    <header className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)}>18      <div className="flex min-w-0 items-start gap-2">19        {icon ? <span className="mt-0.5 text-subtle">{icon}</span> : null}20        <div className="min-w-0">21          <h2 className="t-subtitle text-fg">{title}</h2>22          {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}23        </div>24      </div>25      {action ? <div className="shrink-0 text-xs">{action}</div> : null}26    </header>27  );28}2930export function Delta({ value, digits = 2, className, arrow = false }: { value: number | null | undefined; digits?: number; className?: string; arrow?: boolean }) {31  const has = value !== null && value !== undefined && Number.isFinite(value);32  const glyph = !has || Math.abs(value) < 0.00005 ? '' : value > 0 ? '▲ ' : '▼ ';33  return (34    <span className={cn('num font-medium', deltaClass(value), className)}>35      {arrow && glyph ? <span className="text-[0.7em]">{glyph}</span> : null}36      {fmtPct(value, digits)}37    </span>38  );39}4041/**42 * Ask vs RareIndex Valuation (§83–§84, §196). `discount` = (ask − RIV) / RIV as stored: negative = the ask43 * is below the valuation. Anything implausible (ask < 10 % or > 10× RIV) is a data/identity anomaly and is44 * labelled as such instead of being shown as a percentage — never as a "deal".45 */46export function VsRiv({ discount, className, digits = 1, showLabel = true }: { discount: number | null | undefined; className?: string; digits?: number; showLabel?: boolean }) {47  if (discount === null || discount === undefined || !Number.isFinite(discount)) return <span className={cn('text-subtle', className)} title="No comparable valuation for this variant">—</span>;48  const ratio = 1 + discount;49  if (ratio < 0.1 || ratio > 10) {50    return (51      <span className={cn('inline-flex items-center gap-1 rounded-sm bg-alert-bg px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-alert', className)} title="The asking price is implausible against this valuation (wrong variant, lot, currency or identity). Not a deal until reviewed.">52        Data/identity anomaly53      </span>54    );55  }56  if (discount < -0.5) {57    return (58      <span className={cn('inline-flex items-center gap-1 rounded-sm bg-alert-bg px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-alert', className)} title={`Asking price ${fmtPct(Math.abs(discount), digits, false)} below RIV — more than 50 % below a valuation is almost always a different item, grade or lot. Held for review, not a deal.`}>59        Needs review · {fmtPct(discount, 0)}60      </span>61    );62  }63  const tone = discount <= -0.1 ? 'text-gain' : discount >= 0.1 ? 'text-loss' : 'text-flat';64  const word = discount <= -0.1 ? 'below RIV' : discount >= 0.1 ? 'above RIV' : 'near RIV';65  return (66    <span className={cn('num inline-flex items-baseline gap-1 font-medium', tone, className)} title={`Asking price ${fmtPct(Math.abs(discount), digits, false)} ${word}. Analytical data, not advice.`}>67      {fmtPct(discount, digits)}68      {showLabel ? <span className="text-[0.85em] font-normal text-subtle">vs RIV</span> : null}69    </span>70  );71}7273export function Badge({ children, tone = 'neutral', className, dot = false }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string; dot?: boolean }) {74  const tones: Record<string, string> = {75    neutral: 'bg-inset text-muted',76    gain: 'bg-gain-bg text-gain',77    loss: 'bg-loss-bg text-loss',78    index: 'bg-index-bg text-index',79    rarity: 'bg-rarity-bg text-rarity',80    alert: 'bg-alert-bg text-alert',81    gold: 'bg-alert-bg text-gold',82  };83  return (84    <span className={cn('inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[11px] font-medium leading-4 tracking-wide', tones[tone], className)}>85      {dot ? <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> : null}86      {children}87    </span>88  );89}9091export function Stat({ label, value, sub, className, size = 'md' }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string; size?: 'sm' | 'md' | 'lg' }) {92  return (93    <div className={cn('flex min-w-0 flex-col gap-0.5', className)}>94      <span className="t-label">{label}</span>95      <span className={cn('num truncate font-semibold leading-tight text-fg', size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-sm' : 'text-lg')}>{value}</span>96      {sub ? <span className="truncate text-xs text-muted">{sub}</span> : null}97    </div>98  );99}100101/** Dense KPI strip used under page headers and in the hero. */102export function StatStrip({ items, className, cols }: { items: Array<{ label: ReactNode; value: ReactNode; sub?: ReactNode; href?: string }>; className?: string; cols?: string }) {103  const grid = cols ?? (items.length >= 8 ? 'grid-cols-2 sm:grid-cols-4 lg:grid-cols-8' : items.length >= 6 ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6' : items.length === 5 ? 'grid-cols-2 sm:grid-cols-5' : items.length === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2 sm:grid-cols-3');104  return (105    <dl className={cn('grid gap-px overflow-hidden rounded-md border border-border bg-border', grid, className)}>106      {items.map((it, i) => {107        const inner = (108          <>109            <dt className="t-label">{it.label}</dt>110            <dd className="num mt-1 truncate text-[17px] font-semibold leading-none text-fg sm:text-lg">{it.value}</dd>111            {it.sub ? <dd className="mt-1 truncate text-[11px] text-muted">{it.sub}</dd> : null}112          </>113        );114        return it.href ? (115          <Link key={i} href={it.href} className="block min-w-0 bg-elevated px-3 py-2.5 hover:bg-sunken">116            {inner}117          </Link>118        ) : (119          <div key={i} className="min-w-0 bg-elevated px-3 py-2.5">120            {inner}121          </div>122        );123      })}124    </dl>125  );126}127128export function EmptyState({ title, description, action, className, compact }: { title: ReactNode; description?: ReactNode; action?: ReactNode; className?: string; compact?: boolean }) {129  return (130    <div className={cn('flex flex-col items-center justify-center gap-1.5 px-6 text-center', compact ? 'py-6' : 'py-12', className)}>131      <span className="mb-1 h-1 w-8 rounded-full bg-inset" aria-hidden />132      <p className="text-sm font-medium text-fg">{title}</p>133      {description ? <p className="max-w-md text-xs leading-relaxed text-muted">{description}</p> : null}134      {action ? <div className="mt-2">{action}</div> : null}135    </div>136  );137}138139/** Honest placeholder for metrics without enough evidence (§191–§192). */140export function Unavailable({ reason = 'Data unavailable', className }: { reason?: string; className?: string }) {141  return (142    <span className={cn('text-subtle', className)} title={reason}>143      —144    </span>145  );146}147148export function SectionTitle({ children, href, hrefLabel = 'View all', className, subtitle }: { children: ReactNode; href?: string; hrefLabel?: string; className?: string; subtitle?: ReactNode }) {149  return (150    <div className={cn('mb-3 flex items-end justify-between gap-3', className)}>151      <div className="min-w-0">152        <h2 className="t-title text-fg">{children}</h2>153        {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}154      </div>155      {href ? (156        <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg">157          {hrefLabel} →158        </Link>159      ) : null}160    </div>161  );162}163164export function Skeleton({ className }: { className?: string }) {165  return <div className={cn('animate-pulse rounded-sm bg-inset', className)} aria-hidden />;166}167168/** Skeleton matching a dense table (header + n rows) to avoid layout shift. */169export function TableSkeleton({ rows = 8, className }: { rows?: number; className?: string }) {170  return (171    <div className={cn('card overflow-hidden', className)} aria-hidden>172      <div className="h-9 border-b border-border bg-elevated" />173      {Array.from({ length: rows }).map((_, i) => (174        <div key={i} className="flex items-center gap-3 border-b border-border px-3 py-2 last:border-b-0">175          <Skeleton className="h-8 w-8" />176          <Skeleton className="h-3 w-1/3" />177          <Skeleton className="ml-auto h-3 w-16" />178          <Skeleton className="h-3 w-12" />179        </div>180      ))}181    </div>182  );183}184185export function Table({ children, className, dense = true, sticky = true }: { children: ReactNode; className?: string; dense?: boolean; sticky?: boolean }) {186  return (187    <div className={cn('w-full overflow-x-auto overscroll-x-contain scrollbar-none', className)}>188      <table className={cn('w-full border-collapse text-left', dense ? 'text-[13px]' : 'text-sm', sticky && 'table-sticky md:[&_td:first-child]:static md:[&_th:first-child]:static md:[&_td:first-child]:shadow-none md:[&_th:first-child]:shadow-none')}>{children}</table>189    </div>190  );191}192export const th = 'sticky top-0 z-[1] whitespace-nowrap border-b border-border bg-elevated px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-subtle';193export const td = 'whitespace-nowrap border-b border-border px-3 py-2 align-middle';194export const tdNum = `${td} num text-right`;195196/** Sequential page "kicker" — small uppercase label above a title. */197export function Kicker({ children, className }: { children: ReactNode; className?: string }) {198  return <p className={cn('t-label mb-2', className)}>{children}</p>;199}200201export function Divider({ className }: { className?: string }) {202  return <hr className={cn('border-0 border-t border-border', className)} />;203}204