TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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