Frontend: design system, mobile shell + tab bar, search sheet, home tiles, touch charts, asset page, compare, account polish (agents P, Q)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
69 changed files +3,073 −893
added
apps/web/public/apple-touch-icon.png
+0 −0
Binary file not shown.
added
apps/web/public/icon-192.png
+0 −0
Binary file not shown.
added
apps/web/public/icon-512.png
+0 −0
Binary file not shown.
added
apps/web/public/icon-maskable-512.png
+0 −0
Binary file not shown.
added
apps/web/public/icon.svg
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#0a0a0b"/><path d="M20 46V18h13.5c6.4 0 10.5 3.6 10.5 9.2 0 4.2-2.3 7.2-6.1 8.4L45 46h-7.3l-6.2-9.4H26.4V46H20zm6.4-14.6h6.5c3.2 0 5.1-1.6 5.1-4.2s-1.9-4.1-5.1-4.1h-6.5v8.3z" fill="#ffffff"/><path d="M12 52l9-9 6 5 11-12 8 7" stroke="#3ccb82" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg> | |
modified
apps/web/src/app/(account)/layout.tsx
+2 −2
@@ -12,8 +12,8 @@ export default async function AccountLayout({ children }: { children: ReactNode | ||
| 12 | 12 | const rows = await db().select({ n: count() }).from(notifications).where(and(eq(notifications.userId, user.id), isNull(notifications.readAt))); |
| 13 | 13 | const n = rows[0]?.n ?? 0; |
| 14 | 14 | return ( |
| 15 | − <div className="grid gap-6 lg:grid-cols-[200px_minmax(0,1fr)]"> | |
| 16 | − <aside className="lg:sticky lg:top-[72px] lg:self-start"> | |
| 15 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[200px_minmax(0,1fr)]"> | |
| 16 | + <aside className="min-w-0 lg:sticky lg:top-[calc(var(--ri-header-h,52px)+16px)] lg:self-start"> | |
| 17 | 17 | <div className="mb-3 hidden items-center gap-2 px-2 lg:flex"> |
| 18 | 18 | <span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-accent-fg">{(user.name ?? user.email).slice(0, 1).toUpperCase()}</span> |
| 19 | 19 | <div className="min-w-0"> |
modified
apps/web/src/app/about/page.tsx
+1 −1
@@ -8,7 +8,7 @@ export const metadata: Metadata = { title: 'About', description: 'RareIndex is t | ||
| 8 | 8 | export default function AboutPage() { |
| 9 | 9 | return ( |
| 10 | 10 | <div className="mx-auto max-w-3xl"> |
| 11 | − <PageHeader title="About RareIndex" description="RareIndex structures the collectible economy: one canonical asset graph with every important variation, every observable listing, every verified historical sale and every meaningful market signal that can legally and reliably be obtained." /> | |
| 11 | + <PageHeader kicker="Company" title="About RareIndex" description="RareIndex structures the collectible economy: one canonical asset graph with every important variation, every observable listing, every verified historical sale and every meaningful market signal that can legally and reliably be obtained." /> | |
| 12 | 12 | <Card className="p-6" as="article"> |
| 13 | 13 | <h2 className="text-base font-semibold">Mission</h2> |
| 14 | 14 | <p className="mt-2 text-[14px] leading-relaxed text-muted">Whenever someone asks “What is this collectible worth?” or “What is happening in the collectibles market?”, RareIndex should be the answer — a research-grade market data terminal for cards, watches, sneakers, LEGO, comics, video games, coins, art and every other collectible category, rather than another marketplace aggregator.</p> |
modified
apps/web/src/app/api-docs/page.tsx
+1 −1
@@ -29,7 +29,7 @@ const TIERS = [ | ||
| 29 | 29 | export default function ApiDocsPage() { |
| 30 | 30 | return ( |
| 31 | 31 | <div> |
| 32 | − <PageHeader title="API" description="Programmatic access to the same data that powers the site. Every response includes evidence fields (sample size, confidence, updated_at) and source attribution — never a bare number." /> | |
| 32 | + <PageHeader kicker="Developers" title="API" description="Programmatic access to the same data that powers the site. Every response includes evidence fields (sample size, confidence, updated_at) and source attribution — never a bare number." /> | |
| 33 | 33 | <Card className="p-5"> |
| 34 | 34 | <h2 className="text-sm font-semibold">Base URL & authentication</h2> |
| 35 | 35 | <pre className="mt-2 overflow-x-auto rounded-md bg-sunken p-3 text-[12px] leading-relaxed"><code>{`https://www.rareindex.io/api/v1 # same-origin path |
modified
apps/web/src/app/asset/[slug]/page.tsx
+29 −21
@@ -5,7 +5,8 @@ import { Tabs } from '@/components/ui/tabs'; | ||
| 5 | 5 | import { Skeleton } from '@/components/ui/primitives'; |
| 6 | 6 | import { AssetHeader } from '@/components/asset/asset-header'; |
| 7 | 7 | import { ASSET_TABS, AnalysisTab, ComparablesTab, GradesTab, HistoryTab, ImagesTab, ListingsTab, OverviewTab, PopulationTab, SalesTab, SourcesTab, type AssetTab } from '@/components/asset/asset-tabs'; |
| 8 | −import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice } from '@/lib/queries/assets'; | |
| 8 | +import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, isWatchedBy } from '@/lib/queries/assets'; | |
| 9 | +import { getCurrentUser } from '@/lib/auth/session'; | |
| 9 | 10 | import { catName, categoryPath } from '@/lib/taxonomy'; |
| 10 | 11 | import { sp1, spEnum, spInt, type SP } from '@/lib/search-params'; |
| 11 | 12 | import { fmtMoney } from '@/lib/format'; |
@@ -36,7 +37,8 @@ export default async function AssetPage({ params, searchParams }: { params: Prom | ||
| 36 | 37 | const found = await getAssetBySlug(slug); |
| 37 | 38 | if (!found) notFound(); |
| 38 | 39 | const tab = spEnum<AssetTab>(sp, 'tab', ASSET_TABS, 'overview'); |
| 39 | − const [variants, live] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id)]); | |
| 40 | + const [variants, live, imgs, user] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id), getAssetImages(found.id, 12), getCurrentUser().catch(() => null)]); | |
| 41 | + const watched = await isWatchedBy(user?.id ?? null, found.id); | |
| 40 | 42 | // asset_stats is rebuilt by the valuation worker; until then fall back to live counts from canonical tables. |
| 41 | 43 | const asset = { |
| 42 | 44 | ...found, |
@@ -54,13 +56,17 @@ export default async function AssetPage({ params, searchParams }: { params: Prom | ||
| 54 | 56 | const href = (t: AssetTab, v: string | null = variant?.id ?? null) => `/asset/${asset.slug}?tab=${t}${v ? `&v=${v}` : ''}`; |
| 55 | 57 | const [listingsForLd, guide] = await Promise.all([getAssetListings(asset.id, { limit: 20 }), (variant ? variant.rivUsd : asset.rivUsd) === null ? getLatestGuidePrice(asset.id, variant?.id ?? null) : Promise.resolve(null)]); |
| 56 | 58 | const crumbs = categoryPath(asset.categorySlug); |
| 59 | + const seen = new Set<string>(); | |
| 60 | + const images = [asset.heroImageUrl ? { url: asset.heroImageUrl, caption: null } : null, ...imgs.map((im) => ({ url: im.url, caption: im.attribution ?? im.sourceId ?? null }))] | |
| 61 | + .filter((im): im is { url: string; caption: string | null } => Boolean(im)) | |
| 62 | + .filter((im) => (seen.has(im.url) ? false : (seen.add(im.url), true))); | |
| 57 | 63 | |
| 58 | 64 | const jsonLd = [ |
| 59 | 65 | { |
| 60 | 66 | '@context': 'https://schema.org', |
| 61 | 67 | '@type': 'Product', |
| 62 | 68 | name: asset.title, |
| 63 | − image: asset.heroImageUrl ?? undefined, | |
| 69 | + image: images.slice(0, 5).map((i) => i.url), | |
| 64 | 70 | description: asset.description ?? `${asset.title} — ${catName(asset.categorySlug)} collectible tracked by RareIndex.`, |
| 65 | 71 | brand: asset.brand ? { '@type': 'Brand', name: asset.brand } : undefined, |
| 66 | 72 | category: crumbs.map((c) => c.name).join(' > '), |
@@ -88,25 +94,27 @@ export default async function AssetPage({ params, searchParams }: { params: Prom | ||
| 88 | 94 | ]; |
| 89 | 95 | |
| 90 | 96 | return ( |
| 91 | − <div> | |
| 97 | + <div className="pb-16 md:pb-0"> | |
| 92 | 98 | <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> |
| 93 | − <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} guide={guide} /> | |
| 94 | − <Tabs | |
| 95 | − className="mt-6 mb-4" | |
| 96 | − active={tab} | |
| 97 | − tabs={[ | |
| 98 | − { id: 'overview', label: 'Overview', href: href('overview') }, | |
| 99 | − { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') }, | |
| 100 | − { id: 'listings', label: 'Listings', count: variant ? variant.activeListings : asset.activeListings, href: href('listings') }, | |
| 101 | − { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') }, | |
| 102 | − { id: 'population', label: 'Population', href: href('population') }, | |
| 103 | − { id: 'images', label: 'Images', href: href('images') }, | |
| 104 | − { id: 'history', label: 'History', href: href('history') }, | |
| 105 | − { id: 'comparables', label: 'Comparables', href: href('comparables') }, | |
| 106 | − { id: 'analysis', label: 'AI Analysis', href: href('analysis') }, | |
| 107 | − { id: 'sources', label: 'Sources', count: asset.sourcesCount || null, href: href('sources') }, | |
| 108 | − ]} | |
| 109 | − /> | |
| 99 | + <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} guide={guide} images={images} watched={watched} /> | |
| 100 | + <div className="sticky top-[var(--ri-header-h,56px)] z-30 -mx-4 bg-bg/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-bg/80 sm:static sm:mx-0 sm:bg-transparent sm:px-0 sm:backdrop-blur-none"> | |
| 101 | + <Tabs | |
| 102 | + className="mt-6 mb-4" | |
| 103 | + active={tab} | |
| 104 | + tabs={[ | |
| 105 | + { id: 'overview', label: 'Overview', href: href('overview') }, | |
| 106 | + { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') }, | |
| 107 | + { id: 'listings', label: 'Listings', count: variant ? variant.activeListings : asset.activeListings, href: href('listings') }, | |
| 108 | + { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') }, | |
| 109 | + { id: 'population', label: 'Population', href: href('population') }, | |
| 110 | + { id: 'images', label: 'Images', count: images.length || null, href: href('images') }, | |
| 111 | + { id: 'history', label: 'History', href: href('history') }, | |
| 112 | + { id: 'comparables', label: 'Comparables', href: href('comparables') }, | |
| 113 | + { id: 'analysis', label: 'AI Analysis', href: href('analysis') }, | |
| 114 | + { id: 'sources', label: 'Sources', count: asset.sourcesCount || null, href: href('sources') }, | |
| 115 | + ]} | |
| 116 | + /> | |
| 117 | + </div> | |
| 110 | 118 | <Suspense key={`${tab}-${variant?.id ?? ''}-${page}`} fallback={<Skeleton className="h-96" />}> |
| 111 | 119 | {tab === 'overview' ? <OverviewTab asset={asset} variant={variant} /> : null} |
| 112 | 120 | {tab === 'sales' ? <SalesTab asset={asset} variant={variant} page={page} /> : null} |
modified
apps/web/src/app/auctions/calendar/page.tsx
+1 −1
@@ -28,7 +28,7 @@ export default async function CalendarPage({ searchParams }: { searchParams: Pro | ||
| 28 | 28 | const params = { category: category ?? undefined, house: house ?? undefined }; |
| 29 | 29 | return ( |
| 30 | 30 | <div> |
| 31 | − <PageHeader crumbs={[{ label: 'Auctions', href: '/auctions' }, { label: 'Calendar' }]} title="Auction calendar" description="Auctions closing in the selected window, grouped by day (UTC)." compact /> | |
| 31 | + <PageHeader kicker="Auction houses" crumbs={[{ label: 'Auctions', href: '/auctions' }, { label: 'Calendar' }]} title="Auction calendar" description="Auctions closing in the selected window, grouped by day (UTC)." compact /> | |
| 32 | 32 | <div className="mb-4 flex flex-wrap items-end justify-between gap-3"> |
| 33 | 33 | <Segmented active={range} options={[{ id: 'today', label: 'Today', href: withParams('/auctions/calendar', { ...params, range: 'today' }) }, { id: 'week', label: 'This week', href: withParams('/auctions/calendar', { ...params, range: 'week' }) }, { id: 'month', label: 'This month', href: withParams('/auctions/calendar', { ...params, range: 'month' }) }]} /> |
| 34 | 34 | <Suspense> |
modified
apps/web/src/app/auctions/page.tsx
+1 −1
@@ -22,7 +22,7 @@ export default async function AuctionsPage({ searchParams }: { searchParams: Pro | ||
| 22 | 22 | ]); |
| 23 | 23 | return ( |
| 24 | 24 | <div> |
| 25 | − <PageHeader | |
| 25 | + <PageHeader kicker="Auction houses" | |
| 26 | 26 | title="Auctions" |
| 27 | 27 | description="Lots aggregated from auction-house connectors. Estimates and bids are the house’s own figures; hammer prices flow into Sales once an auction ends and the lot is matched to a canonical asset." |
| 28 | 28 | compact |
modified
apps/web/src/app/categories/page.tsx
+1 −1
@@ -19,7 +19,7 @@ export default async function CategoriesPage() { | ||
| 19 | 19 | const groups = [1, 2, 3].map((phase) => ({ phase, families: FAMILIES.filter((f) => f.phase === phase) })); |
| 20 | 20 | return ( |
| 21 | 21 | <div> |
| 22 | − <PageHeader title="Categories" description={`An extensible taxonomy of ${FAMILIES.length} collectible families. Phase 1 is the launch wedge; every family is architected for data from day one. New categories are proposed automatically from market data and approved by an editor.`} /> | |
| 22 | + <PageHeader kicker="Taxonomy" title="Categories" description={`An extensible taxonomy of ${FAMILIES.length} collectible families. Phase 1 is the launch wedge; every family is architected for data from day one. New categories are proposed automatically from market data and approved by an editor.`} /> | |
| 23 | 23 | {groups.map((g) => ( |
| 24 | 24 | <section key={g.phase} className="mb-8"> |
| 25 | 25 | <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold"> |
modified
apps/web/src/app/compare/compare-form.tsx
+75 −28
@@ -1,7 +1,8 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | |
| 3 | −import { useRouter } from 'next/navigation'; | |
| 3 | +import { useRouter, useSearchParams } from 'next/navigation'; | |
| 4 | 4 | import { useEffect, useState } from 'react'; |
| 5 | +import { Check, Link2, Search, X } from 'lucide-react'; | |
| 5 | 6 | |
| 6 | 7 | interface Suggestion { |
| 7 | 8 | type: string; |
@@ -15,6 +16,7 @@ const SLOTS = ['a', 'b', 'c', 'd'] as const; | ||
| 15 | 16 | /** Four slot pickers with suggestions; values are asset slugs or index tickers, stored in the URL. */ |
| 16 | 17 | export function CompareForm({ initial }: { initial: string[] }) { |
| 17 | 18 | const router = useRouter(); |
| 19 | + const params = useSearchParams(); | |
| 18 | 20 | const [values, setValues] = useState<string[]>([...initial, '', '', '', ''].slice(0, 4)); |
| 19 | 21 | const [typing, setTyping] = useState<{ i: number; q: string } | null>(null); |
| 20 | 22 | const [items, setItems] = useState<Suggestion[]>([]); |
@@ -25,7 +27,7 @@ export function CompareForm({ initial }: { initial: string[] }) { | ||
| 25 | 27 | const t = setTimeout(() => { |
| 26 | 28 | fetch(`/api/search/suggest?q=${encodeURIComponent(typing.q)}`, { signal: ctrl.signal }) |
| 27 | 29 | .then((r) => r.json()) |
| 28 | − .then((d: { items: Suggestion[] }) => setItems((d.items ?? []).filter((x) => x.type === 'asset' || x.type === 'index'))) | |
| 30 | + .then((d: { items: Suggestion[] }) => setItems((d.items ?? []).filter((x) => x.type === 'asset' || x.type === 'index').slice(0, 8))) | |
| 29 | 31 | .catch(() => {}); |
| 30 | 32 | }, 150); |
| 31 | 33 | return () => { |
@@ -37,42 +39,63 @@ export function CompareForm({ initial }: { initial: string[] }) { | ||
| 37 | 39 | const apply = (vals: string[]) => { |
| 38 | 40 | const sp = new URLSearchParams(); |
| 39 | 41 | vals.forEach((v, i) => { |
| 40 | − if (v) sp.set(SLOTS[i]!, v); | |
| 42 | + if (v.trim()) sp.set(SLOTS[i]!, v.trim()); | |
| 41 | 43 | }); |
| 44 | + const w = params?.get('w'); | |
| 45 | + if (w) sp.set('w', w); | |
| 42 | 46 | router.push(`/compare?${sp.toString()}`); |
| 43 | 47 | }; |
| 44 | 48 | |
| 45 | 49 | return ( |
| 46 | 50 | <form |
| 47 | − className="card grid gap-2 p-3 sm:grid-cols-4" | |
| 51 | + className="card grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4" | |
| 48 | 52 | onSubmit={(e) => { |
| 49 | 53 | e.preventDefault(); |
| 50 | 54 | apply(values); |
| 51 | 55 | }} |
| 52 | 56 | > |
| 53 | 57 | {SLOTS.map((s, i) => ( |
| 54 | − <label key={s} className="relative flex flex-col gap-0.5 text-[10px] font-medium uppercase tracking-wider text-subtle"> | |
| 58 | + <label key={s} className="relative flex flex-col gap-1 text-[10px] font-medium uppercase tracking-wider text-subtle"> | |
| 55 | 59 | Slot {s.toUpperCase()} |
| 56 | − <input | |
| 57 | − value={values[i] ?? ''} | |
| 58 | − onChange={(e) => { | |
| 59 | − const v = [...values]; | |
| 60 | − v[i] = e.target.value; | |
| 61 | − setValues(v); | |
| 62 | − setTyping({ i, q: e.target.value }); | |
| 63 | − if (e.target.value.trim().length < 2) setItems([]); | |
| 64 | − }} | |
| 65 | − onBlur={() => | |
| 66 | − setTimeout(() => { | |
| 67 | − setTyping(null); | |
| 68 | − setItems([]); | |
| 69 | − }, 150) | |
| 70 | − } | |
| 71 | − placeholder="asset slug or RARE-TCG" | |
| 72 | − className="h-9 rounded-md border border-border bg-elevated px-2 text-[13px] normal-case tracking-normal text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" | |
| 73 | − /> | |
| 60 | + <span className="relative"> | |
| 61 | + <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-subtle" /> | |
| 62 | + <input | |
| 63 | + value={values[i] ?? ''} | |
| 64 | + onChange={(e) => { | |
| 65 | + const v = [...values]; | |
| 66 | + v[i] = e.target.value; | |
| 67 | + setValues(v); | |
| 68 | + setTyping({ i, q: e.target.value }); | |
| 69 | + if (e.target.value.trim().length < 2) setItems([]); | |
| 70 | + }} | |
| 71 | + onBlur={() => | |
| 72 | + setTimeout(() => { | |
| 73 | + setTyping(null); | |
| 74 | + setItems([]); | |
| 75 | + }, 150) | |
| 76 | + } | |
| 77 | + placeholder="Search an asset or RARE-TCG" | |
| 78 | + autoComplete="off" | |
| 79 | + className="h-10 w-full rounded-md border border-border bg-elevated pl-8 pr-8 text-base normal-case tracking-normal text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none md:h-9 md:text-[13px]" | |
| 80 | + /> | |
| 81 | + {values[i] ? ( | |
| 82 | + <button | |
| 83 | + type="button" | |
| 84 | + aria-label={`Clear slot ${s.toUpperCase()}`} | |
| 85 | + onClick={() => { | |
| 86 | + const v = [...values]; | |
| 87 | + v[i] = ''; | |
| 88 | + setValues(v); | |
| 89 | + apply(v); | |
| 90 | + }} | |
| 91 | + className="absolute right-1.5 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-subtle hover:bg-inset hover:text-fg" | |
| 92 | + > | |
| 93 | + <X className="h-3.5 w-3.5" /> | |
| 94 | + </button> | |
| 95 | + ) : null} | |
| 96 | + </span> | |
| 74 | 97 | {typing?.i === i && items.length ? ( |
| 75 | − <ul className="absolute left-0 top-full z-20 mt-1 w-full overflow-hidden rounded-md border border-border bg-elevated text-[12px] normal-case tracking-normal shadow-pop"> | |
| 98 | + <ul className="absolute left-0 top-full z-20 mt-1 w-full overflow-hidden rounded-md border border-border bg-elevated text-[13px] normal-case tracking-normal shadow-pop"> | |
| 76 | 99 | {items.map((it) => { |
| 77 | 100 | const val = it.href.startsWith('/asset/') ? it.href.slice(7) : it.href.startsWith('/rareindex/') ? it.href.slice(11) : it.label; |
| 78 | 101 | return ( |
@@ -87,7 +110,7 @@ export function CompareForm({ initial }: { initial: string[] }) { | ||
| 87 | 110 | setTyping(null); |
| 88 | 111 | apply(v); |
| 89 | 112 | }} |
| 90 | − className="block w-full truncate px-2 py-1.5 text-left text-fg hover:bg-sunken" | |
| 113 | + className="block w-full truncate px-2.5 py-2 text-left text-fg hover:bg-sunken" | |
| 91 | 114 | > |
| 92 | 115 | {it.label} <span className="text-subtle">· {it.sublabel}</span> |
| 93 | 116 | </button> |
@@ -98,12 +121,36 @@ export function CompareForm({ initial }: { initial: string[] }) { | ||
| 98 | 121 | ) : null} |
| 99 | 122 | </label> |
| 100 | 123 | ))} |
| 101 | − <div className="sm:col-span-4 flex items-center justify-between text-[11px] text-subtle"> | |
| 102 | − <span>Tip: paste slugs from asset URLs; tickers: RARE, RARE-TCG, RARE-SPORT, RARE-WATCH, RARE-SNEAKER, RARE-LEGO, RARE-COMIC, RARE-GAME…</span> | |
| 103 | − <button type="submit" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg"> | |
| 124 | + <div className="flex items-center justify-between gap-3 text-[11px] text-subtle sm:col-span-2 lg:col-span-4"> | |
| 125 | + <span className="hidden sm:inline">Tickers: RARE, RARE-TCG, RARE-SPORT, RARE-WATCH, RARE-SNEAKER, RARE-LEGO, RARE-COMIC, RARE-GAME…</span> | |
| 126 | + <button type="submit" className="inline-flex h-10 items-center rounded-md bg-accent px-4 text-xs font-medium text-accent-fg md:h-9"> | |
| 104 | 127 | Compare |
| 105 | 128 | </button> |
| 106 | 129 | </div> |
| 107 | 130 | </form> |
| 108 | 131 | ); |
| 109 | 132 | } |
| 133 | + | |
| 134 | +export function ShareButton() { | |
| 135 | + const [done, setDone] = useState(false); | |
| 136 | + return ( | |
| 137 | + <button | |
| 138 | + type="button" | |
| 139 | + onClick={async () => { | |
| 140 | + const url = window.location.href; | |
| 141 | + try { | |
| 142 | + if (navigator.share) await navigator.share({ title: 'RareIndex — Compare', url }); | |
| 143 | + else await navigator.clipboard.writeText(url); | |
| 144 | + setDone(true); | |
| 145 | + setTimeout(() => setDone(false), 1800); | |
| 146 | + } catch { | |
| 147 | + /* cancelled */ | |
| 148 | + } | |
| 149 | + }} | |
| 150 | + className="inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-elevated px-3 text-[12px] font-medium text-fg hover:bg-inset" | |
| 151 | + > | |
| 152 | + {done ? <Check className="h-3.5 w-3.5 text-gain" /> : <Link2 className="h-3.5 w-3.5" />} | |
| 153 | + {done ? 'Link copied' : 'Share'} | |
| 154 | + </button> | |
| 155 | + ); | |
| 156 | +} | |
modified
apps/web/src/app/compare/page.tsx
+150 −53
@@ -1,88 +1,159 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { PageHeader } from '@/components/ui/page-header'; |
| 4 | −import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum } from '@/components/ui/primitives'; | |
| 4 | +import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives'; | |
| 5 | 5 | import { LineChart } from '@/components/charts/line-chart'; |
| 6 | 6 | import { getAssetsBySlugs, getAssetSnapshots } from '@/lib/queries/assets'; |
| 7 | 7 | import { getIndex, getIndexSeries } from '@/lib/queries/indices'; |
| 8 | 8 | import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format'; |
| 9 | 9 | import { catName } from '@/lib/taxonomy'; |
| 10 | 10 | import type { SP } from '@/lib/search-params'; |
| 11 | −import { CompareForm } from './compare-form'; | |
| 11 | +import { Thumb } from '@/components/market/bits'; | |
| 12 | +import { CompareForm, ShareButton } from './compare-form'; | |
| 12 | 13 | |
| 13 | 14 | export const metadata: Metadata = { title: 'Compare', description: 'Compare up to four collectible assets or RareIndex indices side by side: indexed performance and key metrics.' }; |
| 14 | 15 | |
| 15 | 16 | const SLOTS = ['a', 'b', 'c', 'd'] as const; |
| 17 | +const SERIES = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)']; | |
| 18 | +const WINDOWS: Array<{ id: string; days: number; label: string }> = [ | |
| 19 | + { id: '3m', days: 92, label: '3M' }, | |
| 20 | + { id: '1y', days: 366, label: '1Y' }, | |
| 21 | + { id: '2y', days: 731, label: '2Y' }, | |
| 22 | + { id: '5y', days: 1827, label: '5Y' }, | |
| 23 | +]; | |
| 24 | + | |
| 25 | +type Column = { key: string; label: string; short: string; href: string; color: string; kind: 'asset' | 'index'; image?: string | null; rows: Array<[string, React.ReactNode]> }; | |
| 16 | 26 | |
| 17 | 27 | export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) { |
| 18 | 28 | const sp = await searchParams; |
| 19 | 29 | const keys = SLOTS.map((k) => (Array.isArray(sp[k]) ? sp[k]![0] : sp[k])).filter((v): v is string => Boolean(v)); |
| 30 | + const win = WINDOWS.find((w) => w.id === (Array.isArray(sp.w) ? sp.w[0] : sp.w)) ?? WINDOWS[2]!; | |
| 20 | 31 | const assetSlugs = keys.filter((k) => !k.toUpperCase().startsWith('RARE')); |
| 21 | 32 | const tickers = keys.filter((k) => k.toUpperCase().startsWith('RARE')); |
| 22 | − const [assets, indices] = await Promise.all([getAssetsBySlugs(assetSlugs), Promise.all(tickers.map((t) => getIndex(t)))]); | |
| 23 | − const series = await Promise.all([ | |
| 24 | − ...assets.map(async (a) => ({ id: a.slug, label: a.title.length > 40 ? `${a.title.slice(0, 39)}…` : a.title, points: (await getAssetSnapshots(a.id, '', 730)).filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! })) })), | |
| 25 | − ...indices.filter((i): i is NonNullable<typeof i> => Boolean(i)).map(async (i) => ({ id: i.ticker, label: i.ticker, dashed: true, points: (await getIndexSeries(i.id, 730)).map((p) => ({ x: p.date, y: p.value })) })), | |
| 26 | − ]); | |
| 27 | − const found = assets.length + indices.filter(Boolean).length; | |
| 33 | + const [assets, indicesRaw] = await Promise.all([getAssetsBySlugs(assetSlugs), Promise.all(tickers.map((t) => getIndex(t)))]); | |
| 34 | + const indices = indicesRaw.filter((i): i is NonNullable<typeof i> => Boolean(i)); | |
| 35 | + // keep the URL order so colours follow the entity, never the rank | |
| 36 | + const ordered = keys.map((k) => assets.find((a) => a.slug === k) ?? indices.find((i) => i.ticker.toUpperCase() === k.toUpperCase()) ?? null).filter((x): x is NonNullable<typeof x> => Boolean(x)); | |
| 37 | + | |
| 38 | + const series = await Promise.all( | |
| 39 | + ordered.map(async (item, i) => { | |
| 40 | + if ('slug' in item) { | |
| 41 | + const pts = (await getAssetSnapshots(item.id, '', win.days)).filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! })); | |
| 42 | + return { id: item.slug, label: item.title.length > 34 ? `${item.title.slice(0, 33)}…` : item.title, color: SERIES[i % 4], points: pts }; | |
| 43 | + } | |
| 44 | + const pts = (await getIndexSeries(item.id, win.days)).map((p) => ({ x: p.date, y: p.value })); | |
| 45 | + return { id: item.ticker, label: item.ticker, dashed: true, color: SERIES[i % 4], points: pts }; | |
| 46 | + }), | |
| 47 | + ); | |
| 48 | + | |
| 49 | + const columns: Column[] = ordered.map((item, i) => { | |
| 50 | + if ('slug' in item) { | |
| 51 | + const a = item; | |
| 52 | + return { | |
| 53 | + key: a.slug, | |
| 54 | + label: a.title, | |
| 55 | + short: a.title.length > 28 ? `${a.title.slice(0, 27)}…` : a.title, | |
| 56 | + href: `/asset/${a.slug}`, | |
| 57 | + color: SERIES[i % 4]!, | |
| 58 | + kind: 'asset', | |
| 59 | + image: a.heroImageUrl, | |
| 60 | + rows: [ | |
| 61 | + ['Category', catName(a.categorySlug)], | |
| 62 | + ['RIV', a.rivUsd === null ? <span className="text-subtle">—</span> : <span className="font-semibold">{fmtMoney(a.rivUsd)}</span>], | |
| 63 | + ['Confidence', a.rivSampleSize ? `${confidenceLabel(a.rivConfidence)} · n=${a.rivSampleSize}` : <span className="text-subtle">—</span>], | |
| 64 | + ['1D', <Delta key="d1" value={a.change1d} />], | |
| 65 | + ['7D', <Delta key="d7" value={a.change7d} />], | |
| 66 | + ['30D', <Delta key="d30" value={a.change30d} />], | |
| 67 | + ['1Y', <Delta key="d1y" value={a.change1y} />], | |
| 68 | + ['Latest sale', a.latestSaleUsd === null ? <span className="text-subtle">—</span> : `${fmtMoney(a.latestSaleUsd)} · ${fmtRelative(a.latestSaleAt)}`], | |
| 69 | + ['Sales 30D / all', `${fmtNum(a.sales30d)} / ${fmtNum(a.salesCount)}`], | |
| 70 | + ['Active listings', fmtNum(a.activeListings)], | |
| 71 | + ['Liquidity', a.liquidityScore == null ? <span className="text-subtle">—</span> : `${Math.round(a.liquidityScore)}/100`], | |
| 72 | + ['Rarity', a.rarityScore == null ? <span className="text-subtle">—</span> : `${Math.round(a.rarityScore)}/100`], | |
| 73 | + ['Trending', a.trendingScore == null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1)], | |
| 74 | + ], | |
| 75 | + }; | |
| 76 | + } | |
| 77 | + const ix = item; | |
| 78 | + return { | |
| 79 | + key: ix.ticker, | |
| 80 | + label: ix.name, | |
| 81 | + short: ix.ticker, | |
| 82 | + href: `/rareindex/${ix.ticker}`, | |
| 83 | + color: SERIES[i % 4]!, | |
| 84 | + kind: 'index', | |
| 85 | + rows: [ | |
| 86 | + ['Category', 'Index'], | |
| 87 | + ['Value', ix.latest ? <span className="font-semibold">{ix.latest.value.toFixed(2)}</span> : <Badge>building</Badge>], | |
| 88 | + ['Coverage', ix.latest?.coverage != null ? `${Math.round(ix.latest.coverage * 100)}%` : <span className="text-subtle">—</span>], | |
| 89 | + ['1D', <Delta key="d1" value={ix.change1d} />], | |
| 90 | + ['7D', <Delta key="d7" value={ix.change7d} />], | |
| 91 | + ['30D', <Delta key="d30" value={ix.change30d} />], | |
| 92 | + ['1Y', <Delta key="d1y" value={ix.change1y} />], | |
| 93 | + ['Median sale', ix.latest?.medianSaleUsd != null ? fmtMoney(ix.latest.medianSaleUsd) : <span className="text-subtle">—</span>], | |
| 94 | + ['Transactions', ix.latest ? fmtNum(ix.latest.transactions) : <span className="text-subtle">—</span>], | |
| 95 | + ['Constituents', ix.latest ? fmtNum(ix.latest.constituentsCount) : <span className="text-subtle">—</span>], | |
| 96 | + ['Liquidity', ix.latest?.liquidityScore != null ? `${Math.round(ix.latest.liquidityScore)}/100` : <span className="text-subtle">—</span>], | |
| 97 | + ['Rarity', <span key="r" className="text-subtle">—</span>], | |
| 98 | + ['Momentum', ix.latest?.momentum != null ? ix.latest.momentum.toFixed(2) : <span className="text-subtle">—</span>], | |
| 99 | + ], | |
| 100 | + }; | |
| 101 | + }); | |
| 102 | + | |
| 103 | + const qs = (w: string) => { | |
| 104 | + const p = new URLSearchParams(); | |
| 105 | + keys.forEach((k, i) => p.set(SLOTS[i]!, k)); | |
| 106 | + p.set('w', w); | |
| 107 | + return `/compare?${p.toString()}`; | |
| 108 | + }; | |
| 109 | + const metricLabels = columns[0]?.rows.map((r) => r[0]) ?? []; | |
| 110 | + | |
| 28 | 111 | return ( |
| 29 | 112 | <div> |
| 30 | − <PageHeader title="Compare" description="Up to four assets or indices. Performance is rebased to 0% at the start of the common window using each item's daily RIV or index value; metrics are shown side by side with their evidence." compact /> | |
| 113 | + <PageHeader title="Compare" description="Up to four assets or indices. Performance is rebased to 0% at the start of the window using each item's daily RIV or index value; metrics sit side by side with their evidence." compact actions={keys.length ? <ShareButton /> : null} /> | |
| 31 | 114 | <CompareForm initial={keys} /> |
| 32 | − {found ? ( | |
| 115 | + {columns.length ? ( | |
| 33 | 116 | <div className="mt-4 grid gap-4"> |
| 34 | − <Card className="p-4"> | |
| 35 | − <h2 className="text-sm font-semibold">Indexed performance · 2 years</h2> | |
| 36 | − <LineChart ariaLabel="Indexed performance comparison" height={320} indexed className="mt-3" series={series} emptyLabel="No daily history yet for the selected items" /> | |
| 117 | + <Card className="p-3 sm:p-4"> | |
| 118 | + <div className="flex flex-wrap items-center justify-between gap-2"> | |
| 119 | + <h2 className="text-sm font-semibold">Indexed performance</h2> | |
| 120 | + <div className="inline-flex rounded-md border border-border bg-sunken p-0.5 text-[11px] font-medium" role="group" aria-label="Window"> | |
| 121 | + {WINDOWS.map((w) => ( | |
| 122 | + <Link key={w.id} href={qs(w.id)} scroll={false} className={cn('num h-7 rounded-[4px] px-2.5 leading-7', w.id === win.id ? 'bg-elevated text-fg shadow-card' : 'text-muted hover:text-fg')} aria-current={w.id === win.id ? 'true' : undefined}> | |
| 123 | + {w.label} | |
| 124 | + </Link> | |
| 125 | + ))} | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + <LineChart ariaLabel="Indexed performance comparison" height={300} indexed className="mt-2" series={series} emptyLabel="No daily history yet for the selected items in this window" /> | |
| 37 | 129 | </Card> |
| 38 | − <Card className="overflow-hidden"> | |
| 39 | − <CardHeader title="Key metrics" /> | |
| 130 | + | |
| 131 | + {/* desktop: matrix */} | |
| 132 | + <Card className="hidden overflow-hidden md:block"> | |
| 133 | + <CardHeader title="Key metrics" subtitle="Every estimate carries its evidence; “—” means data unavailable, never zero." /> | |
| 40 | 134 | <Table> |
| 41 | 135 | <thead> |
| 42 | 136 | <tr> |
| 43 | 137 | <th className={th}>Metric</th> |
| 44 | − {assets.map((a) => ( | |
| 45 | − <th key={a.id} className={cn(th, 'text-right')}> | |
| 46 | − <Link href={`/asset/${a.slug}`} className="hover:underline"> | |
| 47 | − {a.title.length > 32 ? `${a.title.slice(0, 31)}…` : a.title} | |
| 48 | − </Link> | |
| 49 | − </th> | |
| 50 | − ))} | |
| 51 | − {indices.filter(Boolean).map((i) => ( | |
| 52 | − <th key={i!.ticker} className={cn(th, 'text-right')}> | |
| 53 | − <Link href={`/rareindex/${i!.ticker}`} className="hover:underline"> | |
| 54 | − {i!.ticker} | |
| 55 | − </Link> | |
| 138 | + {columns.map((c) => ( | |
| 139 | + <th key={c.key} className={cn(th, 'text-right')}> | |
| 140 | + <span className="inline-flex items-center gap-1.5"> | |
| 141 | + <span className="inline-block h-2 w-2 rounded-full" style={{ background: c.color }} /> | |
| 142 | + <Link href={c.href} className="hover:underline"> | |
| 143 | + {c.short} | |
| 144 | + </Link> | |
| 145 | + </span> | |
| 56 | 146 | </th> |
| 57 | 147 | ))} |
| 58 | 148 | </tr> |
| 59 | 149 | </thead> |
| 60 | 150 | <tbody> |
| 61 | − {[ | |
| 62 | − ['Category', assets.map((a) => catName(a.categorySlug)), indices.map(() => 'Index')], | |
| 63 | − ['RIV / value', assets.map((a) => (a.rivUsd === null ? '—' : fmtMoney(a.rivUsd))), indices.map((i) => (i?.latest ? i.latest.value.toFixed(2) : 'building'))], | |
| 64 | − ['Confidence', assets.map((a) => `${confidenceLabel(a.rivConfidence)} (n=${a.rivSampleSize})`), indices.map((i) => (i?.latest?.coverage != null ? `coverage ${Math.round(i.latest.coverage * 100)}%` : '—'))], | |
| 65 | − ['1D', assets.map((a) => <Delta key={a.id} value={a.change1d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change1d} />)], | |
| 66 | − ['7D', assets.map((a) => <Delta key={a.id} value={a.change7d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change7d} />)], | |
| 67 | − ['30D', assets.map((a) => <Delta key={a.id} value={a.change30d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change30d} />)], | |
| 68 | − ['1Y', assets.map((a) => <Delta key={a.id} value={a.change1y} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change1y} />)], | |
| 69 | − ['Latest sale', assets.map((a) => (a.latestSaleUsd === null ? '—' : `${fmtMoney(a.latestSaleUsd)} · ${fmtRelative(a.latestSaleAt)}`)), indices.map((i) => (i?.latest?.medianSaleUsd != null ? `median ${fmtMoney(i.latest.medianSaleUsd)}` : '—'))], | |
| 70 | − ['Sales (30D / all)', assets.map((a) => `${fmtNum(a.sales30d)} / ${fmtNum(a.salesCount)}`), indices.map((i) => (i?.latest ? fmtNum(i.latest.transactions) : '—'))], | |
| 71 | − ['Active listings', assets.map((a) => fmtNum(a.activeListings)), indices.map(() => '—')], | |
| 72 | − ['Liquidity', assets.map((a) => (a.liquidityScore == null ? '—' : Math.round(a.liquidityScore))), indices.map((i) => (i?.latest?.liquidityScore != null ? Math.round(i.latest.liquidityScore) : '—'))], | |
| 73 | − ['Rarity', assets.map((a) => (a.rarityScore == null ? '—' : Math.round(a.rarityScore))), indices.map(() => '—')], | |
| 74 | − ['Trending', assets.map((a) => (a.trendingScore == null ? '—' : a.trendingScore.toFixed(1))), indices.map((i) => (i?.latest?.momentum != null ? i.latest.momentum.toFixed(2) : '—'))], | |
| 75 | − ].map(([label, av, iv]) => ( | |
| 76 | − <tr key={label as string}> | |
| 77 | − <td className={cn(td, 'text-muted')}>{label as string}</td> | |
| 78 | − {(av as React.ReactNode[]).map((v, i) => ( | |
| 79 | − <td key={`a${i}`} className={tdNum}> | |
| 80 | − {v} | |
| 81 | − </td> | |
| 82 | − ))} | |
| 83 | − {(iv as React.ReactNode[]).map((v, i) => ( | |
| 84 | − <td key={`i${i}`} className={tdNum}> | |
| 85 | − {v} | |
| 151 | + {metricLabels.map((label, ri) => ( | |
| 152 | + <tr key={label} className="hover:bg-sunken"> | |
| 153 | + <td className={cn(td, 'text-muted')}>{label}</td> | |
| 154 | + {columns.map((c) => ( | |
| 155 | + <td key={c.key} className={tdNum}> | |
| 156 | + {c.rows[ri]?.[1] ?? <span className="text-subtle">—</span>} | |
| 86 | 157 | </td> |
| 87 | 158 | ))} |
| 88 | 159 | </tr> |
@@ -90,10 +161,36 @@ export default async function ComparePage({ searchParams }: { searchParams: Prom | ||
| 90 | 161 | </tbody> |
| 91 | 162 | </Table> |
| 92 | 163 | </Card> |
| 164 | + | |
| 165 | + {/* phone: one card per item, swipeable */} | |
| 166 | + <div className="md:hidden"> | |
| 167 | + <h2 className="mb-2 text-sm font-semibold">Key metrics</h2> | |
| 168 | + <div className="scrollbar-none -mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-1"> | |
| 169 | + {columns.map((c) => ( | |
| 170 | + <article key={c.key} className="card w-[86vw] max-w-sm shrink-0 snap-center overflow-hidden"> | |
| 171 | + <header className="flex items-center gap-2.5 border-b border-border px-3 py-2.5" style={{ boxShadow: `inset 3px 0 0 ${c.color}` }}> | |
| 172 | + {c.image !== undefined ? <Thumb src={c.image} alt="" size={36} rounded="rounded-md" /> : null} | |
| 173 | + <Link href={c.href} className="min-w-0 flex-1 truncate text-[13px] font-semibold text-fg"> | |
| 174 | + {c.label} | |
| 175 | + </Link> | |
| 176 | + </header> | |
| 177 | + <dl className="divide-y divide-border text-[12px]"> | |
| 178 | + {c.rows.map(([k, v]) => ( | |
| 179 | + <div key={k} className="flex items-center justify-between gap-3 px-3 py-1.5"> | |
| 180 | + <dt className="text-muted">{k}</dt> | |
| 181 | + <dd className="num text-right text-fg">{v}</dd> | |
| 182 | + </div> | |
| 183 | + ))} | |
| 184 | + </dl> | |
| 185 | + </article> | |
| 186 | + ))} | |
| 187 | + </div> | |
| 188 | + <p className="mt-1 text-center text-[10px] text-subtle">Swipe to compare</p> | |
| 189 | + </div> | |
| 93 | 190 | </div> |
| 94 | 191 | ) : ( |
| 95 | 192 | <Card className="mt-4"> |
| 96 | − <EmptyState title="Pick items to compare" description="Add asset slugs (from any asset URL) or index tickers such as RARE, RARE-TCG, RARE-WATCH." /> | |
| 193 | + <EmptyState title="Pick items to compare" description="Search an asset name or use index tickers such as RARE, RARE-TCG, RARE-WATCH. Up to four items; the URL is shareable." /> | |
| 97 | 194 | </Card> |
| 98 | 195 | )} |
| 99 | 196 | </div> |
modified
apps/web/src/app/data/page.tsx
+1 −1
@@ -50,7 +50,7 @@ export default async function DataPage() { | ||
| 50 | 50 | const active = rows.filter((r) => r.counts.assets > 0); |
| 51 | 51 | return ( |
| 52 | 52 | <div> |
| 53 | − <PageHeader title="Data" description="What RareIndex covers today, where it comes from, and how the numbers are computed. Coverage figures are live counts." /> | |
| 53 | + <PageHeader kicker="Transparency" title="Data" description="What RareIndex covers today, where it comes from, and how the numbers are computed. Coverage figures are live counts." /> | |
| 54 | 54 | <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8"> |
| 55 | 55 | {[ |
| 56 | 56 | ['Assets', fmtNum(stats.assets)], |
modified
apps/web/src/app/explore/page.tsx
+45 −40
@@ -47,38 +47,36 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom | ||
| 47 | 47 | const params = pick(sp, KEYS); |
| 48 | 48 | return ( |
| 49 | 49 | <div> |
| 50 | − <PageHeader title="Explore" description="Every tracked collectible asset — priced or not — sortable by data richness, RareIndex Valuation, momentum, liquidity or rarity. Filters are reflected in the URL." compact /> | |
| 51 | − <div className="mb-2 flex flex-wrap items-center gap-1 text-[11px]"> | |
| 52 | − <span className="mr-1 text-subtle">Show</span> | |
| 53 | − {HAS_OPTIONS.map((h) => ( | |
| 54 | − <a key={h.id || 'all'} href={withParams('/explore', { ...pick(sp, KEYS), has: h.id || null, page: null })} className={`rounded-full border px-2 py-0.5 ${has === h.id ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:border-border-strong hover:text-fg'}`}> | |
| 55 | − {h.label} | |
| 56 | − </a> | |
| 57 | − ))} | |
| 58 | − </div> | |
| 59 | − <div className="mb-3 flex flex-wrap items-end justify-between gap-3"> | |
| 60 | − <Suspense> | |
| 61 | − <FilterBar | |
| 62 | − fields={[ | |
| 63 | − { name: 'q', label: 'Search', type: 'text', placeholder: 'title contains…', width: 'w-44' }, | |
| 64 | − { name: 'category', label: 'Category', type: 'select', options: categoryOptions, width: 'w-52' }, | |
| 65 | − { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.filter((g) => g.slug !== 'raw').map((g) => ({ value: g.slug, label: g.name })), width: 'w-32' }, | |
| 66 | − { name: 'grade', label: 'Grade', type: 'text', placeholder: 'e.g. 10', width: 'w-20' }, | |
| 67 | − { name: 'min', label: 'Min RIV $', type: 'number', placeholder: '0', width: 'w-24' }, | |
| 68 | − { name: 'max', label: 'Max RIV $', type: 'number', placeholder: '∞', width: 'w-24' }, | |
| 69 | − { name: 'liq', label: 'Liquidity ≥', type: 'number', placeholder: '0–100', width: 'w-20' }, | |
| 70 | − { name: 'rar', label: 'Rarity ≥', type: 'number', placeholder: '0–100', width: 'w-20' }, | |
| 71 | − { name: 'from', label: 'Year from', type: 'number', placeholder: '1900', width: 'w-20' }, | |
| 72 | − { name: 'to', label: 'Year to', type: 'number', placeholder: '2026', width: 'w-20' }, | |
| 73 | − ]} | |
| 74 | − /> | |
| 75 | − </Suspense> | |
| 76 | − <div className="flex items-center gap-2"> | |
| 77 | − <Segmented active={view} options={[{ id: 'table', label: 'Table', href: withParams('/explore', { ...params, view: 'table', page: null }) }, { id: 'grid', label: 'Cards', href: withParams('/explore', { ...params, view: 'grid', page: null }) }]} /> | |
| 78 | − <label className="flex items-center gap-1 text-[11px] text-subtle"> | |
| 79 | − Sort | |
| 80 | − <SortSelect current={sort} params={params} /> | |
| 81 | − </label> | |
| 50 | + <PageHeader kicker="Browse" title="Explore" description="Every tracked collectible asset — priced or not — sortable by data richness, RareIndex Valuation, momentum, liquidity or rarity. Filters are reflected in the URL." compact /> | |
| 51 | + <div className="mb-3 flex flex-col gap-2"> | |
| 52 | + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial"> | |
| 53 | + {HAS_OPTIONS.map((h) => ( | |
| 54 | + <a key={h.id || 'all'} href={withParams('/explore', { ...pick(sp, KEYS), has: h.id || null, page: null })} className={`chip ${has === h.id ? 'chip-active' : ''}`}> | |
| 55 | + {h.label} | |
| 56 | + </a> | |
| 57 | + ))} | |
| 58 | + </div> | |
| 59 | + <div className="flex flex-wrap items-end justify-between gap-2"> | |
| 60 | + <Suspense> | |
| 61 | + <FilterBar | |
| 62 | + fields={[ | |
| 63 | + { name: 'q', label: 'Search', type: 'text', placeholder: 'title contains…', width: 'w-44' }, | |
| 64 | + { name: 'category', label: 'Category', type: 'select', options: categoryOptions, width: 'w-52' }, | |
| 65 | + { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.filter((g) => g.slug !== 'raw').map((g) => ({ value: g.slug, label: g.name })), width: 'w-32' }, | |
| 66 | + { name: 'grade', label: 'Grade', type: 'text', placeholder: 'e.g. 10', width: 'w-20' }, | |
| 67 | + { name: 'min', label: 'Min RIV $', type: 'number', placeholder: '0', width: 'w-24' }, | |
| 68 | + { name: 'max', label: 'Max RIV $', type: 'number', placeholder: '∞', width: 'w-24' }, | |
| 69 | + { name: 'liq', label: 'Liquidity ≥', type: 'number', placeholder: '0–100', width: 'w-20' }, | |
| 70 | + { name: 'rar', label: 'Rarity ≥', type: 'number', placeholder: '0–100', width: 'w-20' }, | |
| 71 | + { name: 'from', label: 'Year from', type: 'number', placeholder: '1900', width: 'w-20' }, | |
| 72 | + { name: 'to', label: 'Year to', type: 'number', placeholder: '2026', width: 'w-20' }, | |
| 73 | + ]} | |
| 74 | + /> | |
| 75 | + </Suspense> | |
| 76 | + <div className="flex items-center gap-2"> | |
| 77 | + <SortMenu current={sort} params={params} /> | |
| 78 | + <Segmented active={view} options={[{ id: 'table', label: 'Table', href: withParams('/explore', { ...params, view: 'table', page: null }) }, { id: 'grid', label: 'Cards', href: withParams('/explore', { ...params, view: 'grid', page: null }) }]} /> | |
| 79 | + </div> | |
| 82 | 80 | </div> |
| 83 | 81 | </div> |
| 84 | 82 | <Suspense fallback={<Skeleton className="h-96" />}> |
@@ -96,15 +94,22 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom | ||
| 96 | 94 | ); |
| 97 | 95 | } |
| 98 | 96 | |
| 99 | −function SortSelect({ current, params }: { current: ExploreSort; params: Record<string, string | undefined> }) { | |
| 97 | +function SortMenu({ current, params }: { current: ExploreSort; params: Record<string, string | undefined> }) { | |
| 100 | 98 | return ( |
| 101 | − <span className="inline-flex flex-wrap gap-1"> | |
| 102 | − {SORTS.map((s) => ( | |
| 103 | − <a key={s} href={withParams('/explore', { ...params, sort: s, page: null })} className={`rounded-sm px-1.5 py-0.5 ${s === current ? 'bg-inset font-medium text-fg' : 'hover:text-fg'}`}> | |
| 104 | − {SORT_LABEL[s]} | |
| 105 | − </a> | |
| 106 | − ))} | |
| 107 | − </span> | |
| 99 | + <details className="relative"> | |
| 100 | + <summary className="plain btn btn-sm h-8 cursor-pointer gap-1.5 text-[12px]"> | |
| 101 | + <span className="text-subtle">Sort</span> | |
| 102 | + <span className="font-medium text-fg">{SORT_LABEL[current]}</span> | |
| 103 | + <span aria-hidden className="text-subtle">▾</span> | |
| 104 | + </summary> | |
| 105 | + <div className="absolute right-0 top-full z-30 mt-1 w-56 overflow-hidden rounded-md border border-border bg-elevated py-1 shadow-pop"> | |
| 106 | + {SORTS.map((s) => ( | |
| 107 | + <a key={s} href={withParams('/explore', { ...params, sort: s, page: null })} className={`flex min-h-[40px] items-center px-3 text-[13px] hover:bg-sunken md:min-h-[32px] ${s === current ? 'font-medium text-fg' : 'text-muted'}`} aria-current={s === current ? 'true' : undefined}> | |
| 108 | + {SORT_LABEL[s]} | |
| 109 | + </a> | |
| 110 | + ))} | |
| 111 | + </div> | |
| 112 | + </details> | |
| 108 | 113 | ); |
| 109 | 114 | } |
| 110 | 115 | |
deleted
apps/web/src/app/favicon.ico
+0 −0
Binary file not shown.
modified
apps/web/src/app/globals.css
+373 −84
@@ -2,116 +2,121 @@ | ||
| 2 | 2 | |
| 3 | 3 | /* |
| 4 | 4 | RareIndex design language (§160–§161): institutional market terminal × modern luxury house. |
| 5 | − Neutral base, accents reserved for gains / losses / index identity / rarity / alerts. | |
| 6 | − Light and dark are first-class; `data-theme` on <html> overrides system preference. | |
| 5 | + Neutral base; accents reserved for gains / losses / index identity / rarity / alerts. | |
| 6 | + Light and dark are first-class; `data-theme` on <html> overrides the system preference. | |
| 7 | + Rhythm: 4-pt spacing, hairline borders, 6 px radii, tabular numerals, 160 ms ease-out motion. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | :root { |
| 10 | − --ri-bg: #fafaf9; | |
| 11 | + --ri-bg: #f7f7f5; | |
| 11 | 12 | --ri-bg-elevated: #ffffff; |
| 12 | − --ri-bg-sunken: #f4f4f3; | |
| 13 | − --ri-bg-inset: #ececea; | |
| 14 | − --ri-fg: #0b0b0c; | |
| 15 | − --ri-fg-muted: #55555b; | |
| 16 | − --ri-fg-subtle: #8a8a91; | |
| 17 | − --ri-border: #e4e4e2; | |
| 18 | − --ri-border-strong: #c9c9c5; | |
| 19 | − --ri-accent: #0b0b0c; | |
| 13 | + --ri-bg-sunken: #f1f1ef; | |
| 14 | + --ri-bg-inset: #e9e9e6; | |
| 15 | + --ri-fg: #0a0a0b; | |
| 16 | + --ri-fg-muted: #4f4f56; | |
| 17 | + --ri-fg-subtle: #7d7d86; | |
| 18 | + --ri-border: #e3e3e0; | |
| 19 | + --ri-border-strong: #c4c4bf; | |
| 20 | + --ri-accent: #0a0a0b; | |
| 20 | 21 | --ri-accent-fg: #ffffff; |
| 21 | − --ri-gain: #0f8a4a; | |
| 22 | − --ri-gain-bg: #e6f5ec; | |
| 23 | − --ri-loss: #c8262c; | |
| 24 | − --ri-loss-bg: #fbe9ea; | |
| 22 | + --ri-gain: #0d7a42; | |
| 23 | + --ri-gain-bg: #e3f3ea; | |
| 24 | + --ri-loss: #bf2229; | |
| 25 | + --ri-loss-bg: #fbe7e8; | |
| 25 | 26 | --ri-flat: #6b6b72; |
| 26 | − --ri-index: #1f3a8a; | |
| 27 | − --ri-index-bg: #e8edfa; | |
| 28 | − --ri-rarity: #7c3aed; | |
| 29 | − --ri-rarity-bg: #f0e9fe; | |
| 30 | − --ri-alert: #b45309; | |
| 31 | − --ri-alert-bg: #fdf1e3; | |
| 32 | − --ri-gold: #a16207; | |
| 27 | + --ri-index: #1e3a8a; | |
| 28 | + --ri-index-bg: #e6ebf9; | |
| 29 | + --ri-rarity: #6d28d9; | |
| 30 | + --ri-rarity-bg: #eee7fd; | |
| 31 | + --ri-alert: #a34a06; | |
| 32 | + --ri-alert-bg: #fdefe0; | |
| 33 | + --ri-gold: #96620a; | |
| 33 | 34 | --ri-series-1: #2f5bd6; |
| 34 | 35 | --ri-series-2: #c2410c; |
| 35 | 36 | --ri-series-3: #0e9384; |
| 36 | 37 | --ri-series-4: #7c3aed; |
| 37 | 38 | --ri-series-muted: #b8b8bd; |
| 38 | − --ri-ring: rgba(11, 11, 12, 0.18); | |
| 39 | − --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 1px rgba(0, 0, 0, 0.03); | |
| 40 | − --ri-shadow-lg: 0 10px 30px -12px rgba(0, 0, 0, 0.18); | |
| 39 | + --ri-ring: rgba(10, 10, 11, 0.22); | |
| 40 | + --ri-shadow: 0 1px 1px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.04); | |
| 41 | + --ri-shadow-lg: 0 12px 32px -12px rgba(0, 0, 0, 0.22), 0 2px 6px rgba(0, 0, 0, 0.06); | |
| 41 | 42 | --ri-radius: 6px; |
| 42 | 43 | --ri-radius-sm: 4px; |
| 43 | 44 | --ri-radius-lg: 10px; |
| 45 | + --ri-ease: cubic-bezier(0.2, 0.7, 0.2, 1); | |
| 46 | + --ri-dur: 160ms; | |
| 47 | + --ri-header-h: 52px; | |
| 48 | + --ri-tabbar-h: 56px; | |
| 44 | 49 | color-scheme: light; |
| 45 | 50 | } |
| 46 | 51 | |
| 47 | 52 | html[data-theme="dark"] { |
| 48 | − --ri-bg: #0b0c0e; | |
| 49 | − --ri-bg-elevated: #121316; | |
| 50 | − --ri-bg-sunken: #08090a; | |
| 51 | − --ri-bg-inset: #1a1b1f; | |
| 52 | − --ri-fg: #f2f2f0; | |
| 53 | − --ri-fg-muted: #a8a8ad; | |
| 54 | − --ri-fg-subtle: #6f6f76; | |
| 55 | − --ri-border: #24252a; | |
| 56 | − --ri-border-strong: #3a3b42; | |
| 57 | − --ri-accent: #f2f2f0; | |
| 58 | − --ri-accent-fg: #0b0c0e; | |
| 59 | − --ri-gain: #34c47c; | |
| 60 | − --ri-gain-bg: rgba(52, 196, 124, 0.12); | |
| 61 | − --ri-loss: #ef5a5f; | |
| 62 | − --ri-loss-bg: rgba(239, 90, 95, 0.12); | |
| 53 | + --ri-bg: #0a0b0d; | |
| 54 | + --ri-bg-elevated: #111215; | |
| 55 | + --ri-bg-sunken: #070809; | |
| 56 | + --ri-bg-inset: #191a1e; | |
| 57 | + --ri-fg: #f3f3f1; | |
| 58 | + --ri-fg-muted: #ababb1; | |
| 59 | + --ri-fg-subtle: #75757d; | |
| 60 | + --ri-border: #232429; | |
| 61 | + --ri-border-strong: #3c3d44; | |
| 62 | + --ri-accent: #f3f3f1; | |
| 63 | + --ri-accent-fg: #0a0b0d; | |
| 64 | + --ri-gain: #3ccb82; | |
| 65 | + --ri-gain-bg: rgba(60, 203, 130, 0.13); | |
| 66 | + --ri-loss: #f26065; | |
| 67 | + --ri-loss-bg: rgba(242, 96, 101, 0.13); | |
| 63 | 68 | --ri-flat: #8b8b93; |
| 64 | − --ri-index: #7c9cf5; | |
| 65 | − --ri-index-bg: rgba(124, 156, 245, 0.12); | |
| 66 | − --ri-rarity: #a78bfa; | |
| 67 | − --ri-rarity-bg: rgba(167, 139, 250, 0.12); | |
| 68 | − --ri-alert: #f0a34c; | |
| 69 | − --ri-alert-bg: rgba(240, 163, 76, 0.12); | |
| 70 | − --ri-gold: #d4a53a; | |
| 69 | + --ri-index: #86a3f7; | |
| 70 | + --ri-index-bg: rgba(134, 163, 247, 0.13); | |
| 71 | + --ri-rarity: #b19bfb; | |
| 72 | + --ri-rarity-bg: rgba(177, 155, 251, 0.13); | |
| 73 | + --ri-alert: #f2a852; | |
| 74 | + --ri-alert-bg: rgba(242, 168, 82, 0.13); | |
| 75 | + --ri-gold: #d9ad45; | |
| 71 | 76 | --ri-series-1: #5b7fe6; |
| 72 | 77 | --ri-series-2: #cf7a1f; |
| 73 | 78 | --ri-series-3: #199c8a; |
| 74 | 79 | --ri-series-4: #8f6fe8; |
| 75 | 80 | --ri-series-muted: #4a4b52; |
| 76 | − --ri-ring: rgba(242, 242, 240, 0.22); | |
| 77 | − --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); | |
| 78 | − --ri-shadow-lg: 0 14px 40px -14px rgba(0, 0, 0, 0.7); | |
| 81 | + --ri-ring: rgba(243, 243, 241, 0.26); | |
| 82 | + --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); | |
| 83 | + --ri-shadow-lg: 0 16px 44px -16px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.4); | |
| 79 | 84 | color-scheme: dark; |
| 80 | 85 | } |
| 81 | 86 | |
| 82 | 87 | @media (prefers-color-scheme: dark) { |
| 83 | 88 | html:not([data-theme="light"]) { |
| 84 | − --ri-bg: #0b0c0e; | |
| 85 | − --ri-bg-elevated: #121316; | |
| 86 | − --ri-bg-sunken: #08090a; | |
| 87 | − --ri-bg-inset: #1a1b1f; | |
| 88 | − --ri-fg: #f2f2f0; | |
| 89 | − --ri-fg-muted: #a8a8ad; | |
| 90 | − --ri-fg-subtle: #6f6f76; | |
| 91 | − --ri-border: #24252a; | |
| 92 | − --ri-border-strong: #3a3b42; | |
| 93 | − --ri-accent: #f2f2f0; | |
| 94 | − --ri-accent-fg: #0b0c0e; | |
| 95 | − --ri-gain: #34c47c; | |
| 96 | − --ri-gain-bg: rgba(52, 196, 124, 0.12); | |
| 97 | − --ri-loss: #ef5a5f; | |
| 98 | − --ri-loss-bg: rgba(239, 90, 95, 0.12); | |
| 89 | + --ri-bg: #0a0b0d; | |
| 90 | + --ri-bg-elevated: #111215; | |
| 91 | + --ri-bg-sunken: #070809; | |
| 92 | + --ri-bg-inset: #191a1e; | |
| 93 | + --ri-fg: #f3f3f1; | |
| 94 | + --ri-fg-muted: #ababb1; | |
| 95 | + --ri-fg-subtle: #75757d; | |
| 96 | + --ri-border: #232429; | |
| 97 | + --ri-border-strong: #3c3d44; | |
| 98 | + --ri-accent: #f3f3f1; | |
| 99 | + --ri-accent-fg: #0a0b0d; | |
| 100 | + --ri-gain: #3ccb82; | |
| 101 | + --ri-gain-bg: rgba(60, 203, 130, 0.13); | |
| 102 | + --ri-loss: #f26065; | |
| 103 | + --ri-loss-bg: rgba(242, 96, 101, 0.13); | |
| 99 | 104 | --ri-flat: #8b8b93; |
| 100 | − --ri-index: #7c9cf5; | |
| 101 | − --ri-index-bg: rgba(124, 156, 245, 0.12); | |
| 102 | − --ri-rarity: #a78bfa; | |
| 103 | − --ri-rarity-bg: rgba(167, 139, 250, 0.12); | |
| 104 | − --ri-alert: #f0a34c; | |
| 105 | − --ri-alert-bg: rgba(240, 163, 76, 0.12); | |
| 106 | − --ri-gold: #d4a53a; | |
| 105 | + --ri-index: #86a3f7; | |
| 106 | + --ri-index-bg: rgba(134, 163, 247, 0.13); | |
| 107 | + --ri-rarity: #b19bfb; | |
| 108 | + --ri-rarity-bg: rgba(177, 155, 251, 0.13); | |
| 109 | + --ri-alert: #f2a852; | |
| 110 | + --ri-alert-bg: rgba(242, 168, 82, 0.13); | |
| 111 | + --ri-gold: #d9ad45; | |
| 107 | 112 | --ri-series-1: #5b7fe6; |
| 108 | 113 | --ri-series-2: #cf7a1f; |
| 109 | 114 | --ri-series-3: #199c8a; |
| 110 | 115 | --ri-series-4: #8f6fe8; |
| 111 | 116 | --ri-series-muted: #4a4b52; |
| 112 | − --ri-ring: rgba(242, 242, 240, 0.22); | |
| 113 | − --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); | |
| 114 | − --ri-shadow-lg: 0 14px 40px -14px rgba(0, 0, 0, 0.7); | |
| 117 | + --ri-ring: rgba(243, 243, 241, 0.26); | |
| 118 | + --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); | |
| 119 | + --ri-shadow-lg: 0 16px 44px -16px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.4); | |
| 115 | 120 | color-scheme: dark; |
| 116 | 121 | } |
| 117 | 122 | } |
@@ -151,6 +156,7 @@ html[data-theme="dark"] { | ||
| 151 | 156 | --radius-lg: var(--ri-radius-lg); |
| 152 | 157 | --shadow-card: var(--ri-shadow); |
| 153 | 158 | --shadow-pop: var(--ri-shadow-lg); |
| 159 | + --ease-out-soft: var(--ri-ease); | |
| 154 | 160 | } |
| 155 | 161 | |
| 156 | 162 | @layer base { |
@@ -158,15 +164,20 @@ html[data-theme="dark"] { | ||
| 158 | 164 | height: 100%; |
| 159 | 165 | -webkit-text-size-adjust: 100%; |
| 160 | 166 | scroll-behavior: smooth; |
| 167 | + scroll-padding-top: calc(var(--ri-header-h) + 8px); | |
| 161 | 168 | } |
| 162 | 169 | body { |
| 163 | 170 | min-height: 100%; |
| 164 | 171 | background: var(--ri-bg); |
| 165 | 172 | color: var(--ri-fg); |
| 166 | 173 | font-family: var(--font-sans); |
| 167 | − font-feature-settings: "cv11", "ss01", "tnum"; | |
| 174 | + font-size: 14px; | |
| 175 | + line-height: 1.45; | |
| 176 | + font-feature-settings: "cv11", "ss01", "tnum", "calt"; | |
| 168 | 177 | text-rendering: optimizeLegibility; |
| 169 | 178 | -webkit-font-smoothing: antialiased; |
| 179 | + -moz-osx-font-smoothing: grayscale; | |
| 180 | + overflow-x: clip; | |
| 170 | 181 | } |
| 171 | 182 | ::selection { |
| 172 | 183 | background: var(--ri-index-bg); |
@@ -174,6 +185,11 @@ html[data-theme="dark"] { | ||
| 174 | 185 | :focus-visible { |
| 175 | 186 | outline: 2px solid var(--ri-ring); |
| 176 | 187 | outline-offset: 2px; |
| 188 | + border-radius: var(--ri-radius-sm); | |
| 189 | + } | |
| 190 | + a, | |
| 191 | + button { | |
| 192 | + transition: color var(--ri-dur) var(--ri-ease), background-color var(--ri-dur) var(--ri-ease), border-color var(--ri-dur) var(--ri-ease), box-shadow var(--ri-dur) var(--ri-ease), opacity var(--ri-dur) var(--ri-ease); | |
| 177 | 193 | } |
| 178 | 194 | input, |
| 179 | 195 | select, |
@@ -184,14 +200,84 @@ html[data-theme="dark"] { | ||
| 184 | 200 | input, |
| 185 | 201 | select, |
| 186 | 202 | textarea { |
| 187 | − font-size: 14px; | |
| 203 | + font-size: 13px; | |
| 188 | 204 | } |
| 189 | 205 | } |
| 190 | 206 | h1, h2, h3, h4 { |
| 191 | − letter-spacing: -0.015em; | |
| 207 | + letter-spacing: -0.02em; | |
| 208 | + text-wrap: balance; | |
| 209 | + } | |
| 210 | + p { | |
| 211 | + text-wrap: pretty; | |
| 212 | + } | |
| 213 | + img, | |
| 214 | + svg { | |
| 215 | + display: inline-block; | |
| 216 | + vertical-align: middle; | |
| 192 | 217 | } |
| 218 | + table { | |
| 219 | + font-variant-numeric: tabular-nums; | |
| 220 | + } | |
| 221 | + @media (prefers-reduced-motion: reduce) { | |
| 222 | + *, | |
| 223 | + *::before, | |
| 224 | + *::after { | |
| 225 | + animation-duration: 0.01ms !important; | |
| 226 | + animation-iteration-count: 1 !important; | |
| 227 | + transition-duration: 0.01ms !important; | |
| 228 | + scroll-behavior: auto !important; | |
| 229 | + } | |
| 230 | + } | |
| 231 | +} | |
| 232 | + | |
| 233 | +/* ---------- Type scale ---------- */ | |
| 234 | +@utility t-display { | |
| 235 | + font-size: clamp(2.25rem, 1.4rem + 3.6vw, 4rem); | |
| 236 | + line-height: 1.02; | |
| 237 | + font-weight: 600; | |
| 238 | + letter-spacing: -0.035em; | |
| 239 | +} | |
| 240 | +@utility t-headline { | |
| 241 | + font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem); | |
| 242 | + line-height: 1.1; | |
| 243 | + font-weight: 600; | |
| 244 | + letter-spacing: -0.025em; | |
| 245 | +} | |
| 246 | +@utility t-title { | |
| 247 | + font-size: 1.0625rem; | |
| 248 | + line-height: 1.3; | |
| 249 | + font-weight: 600; | |
| 250 | + letter-spacing: -0.015em; | |
| 251 | +} | |
| 252 | +@utility t-subtitle { | |
| 253 | + font-size: 0.875rem; | |
| 254 | + line-height: 1.35; | |
| 255 | + font-weight: 600; | |
| 256 | + letter-spacing: -0.01em; | |
| 257 | +} | |
| 258 | +@utility t-body { | |
| 259 | + font-size: 0.875rem; | |
| 260 | + line-height: 1.5; | |
| 261 | +} | |
| 262 | +@utility t-small { | |
| 263 | + font-size: 0.8125rem; | |
| 264 | + line-height: 1.4; | |
| 265 | +} | |
| 266 | +@utility t-caption { | |
| 267 | + font-size: 0.6875rem; | |
| 268 | + line-height: 1.3; | |
| 269 | + color: var(--ri-fg-subtle); | |
| 270 | +} | |
| 271 | +@utility t-label { | |
| 272 | + font-size: 0.625rem; | |
| 273 | + line-height: 1.2; | |
| 274 | + font-weight: 600; | |
| 275 | + letter-spacing: 0.08em; | |
| 276 | + text-transform: uppercase; | |
| 277 | + color: var(--ri-fg-subtle); | |
| 193 | 278 | } |
| 194 | 279 | |
| 280 | +/* ---------- Numbers ---------- */ | |
| 195 | 281 | @utility num { |
| 196 | 282 | font-variant-numeric: tabular-nums lining-nums; |
| 197 | 283 | font-feature-settings: "tnum", "lnum"; |
@@ -199,7 +285,10 @@ html[data-theme="dark"] { | ||
| 199 | 285 | @utility mono-num { |
| 200 | 286 | font-family: var(--font-mono); |
| 201 | 287 | font-variant-numeric: tabular-nums; |
| 288 | + letter-spacing: -0.01em; | |
| 202 | 289 | } |
| 290 | + | |
| 291 | +/* ---------- Surfaces ---------- */ | |
| 203 | 292 | @utility hairline { |
| 204 | 293 | border-color: var(--ri-border); |
| 205 | 294 | } |
@@ -209,22 +298,222 @@ html[data-theme="dark"] { | ||
| 209 | 298 | border-radius: var(--ri-radius); |
| 210 | 299 | box-shadow: var(--ri-shadow); |
| 211 | 300 | } |
| 301 | +@utility card-hover { | |
| 302 | + transition: border-color var(--ri-dur) var(--ri-ease), box-shadow var(--ri-dur) var(--ri-ease), transform var(--ri-dur) var(--ri-ease); | |
| 303 | + &:hover { | |
| 304 | + border-color: var(--ri-border-strong); | |
| 305 | + box-shadow: var(--ri-shadow-lg); | |
| 306 | + } | |
| 307 | + &:active { | |
| 308 | + transform: translateY(0.5px); | |
| 309 | + } | |
| 310 | +} | |
| 311 | +@utility surface-sunken { | |
| 312 | + background: var(--ri-bg-sunken); | |
| 313 | +} | |
| 314 | +@utility btn { | |
| 315 | + display: inline-flex; | |
| 316 | + align-items: center; | |
| 317 | + justify-content: center; | |
| 318 | + gap: 0.375rem; | |
| 319 | + height: 2.25rem; | |
| 320 | + padding-inline: 0.75rem; | |
| 321 | + border-radius: var(--ri-radius); | |
| 322 | + border: 1px solid var(--ri-border); | |
| 323 | + background: var(--ri-bg-elevated); | |
| 324 | + color: var(--ri-fg); | |
| 325 | + font-size: 0.8125rem; | |
| 326 | + font-weight: 500; | |
| 327 | + white-space: nowrap; | |
| 328 | + &:hover { | |
| 329 | + border-color: var(--ri-border-strong); | |
| 330 | + background: var(--ri-bg-sunken); | |
| 331 | + } | |
| 332 | +} | |
| 333 | +@utility btn-primary { | |
| 334 | + display: inline-flex; | |
| 335 | + align-items: center; | |
| 336 | + justify-content: center; | |
| 337 | + gap: 0.375rem; | |
| 338 | + height: 2.25rem; | |
| 339 | + padding-inline: 0.875rem; | |
| 340 | + border-radius: var(--ri-radius); | |
| 341 | + border: 1px solid var(--ri-accent); | |
| 342 | + background: var(--ri-accent); | |
| 343 | + color: var(--ri-accent-fg); | |
| 344 | + font-size: 0.8125rem; | |
| 345 | + font-weight: 600; | |
| 346 | + white-space: nowrap; | |
| 347 | + &:hover { | |
| 348 | + opacity: 0.9; | |
| 349 | + } | |
| 350 | +} | |
| 351 | +@utility btn-sm { | |
| 352 | + height: 1.75rem; | |
| 353 | + padding-inline: 0.5rem; | |
| 354 | + font-size: 0.75rem; | |
| 355 | +} | |
| 356 | +@utility chip { | |
| 357 | + display: inline-flex; | |
| 358 | + align-items: center; | |
| 359 | + gap: 0.25rem; | |
| 360 | + height: 1.625rem; | |
| 361 | + padding-inline: 0.625rem; | |
| 362 | + border-radius: 999px; | |
| 363 | + border: 1px solid var(--ri-border); | |
| 364 | + background: var(--ri-bg-elevated); | |
| 365 | + color: var(--ri-fg-muted); | |
| 366 | + font-size: 0.75rem; | |
| 367 | + font-weight: 500; | |
| 368 | + white-space: nowrap; | |
| 369 | + &:hover { | |
| 370 | + border-color: var(--ri-border-strong); | |
| 371 | + color: var(--ri-fg); | |
| 372 | + } | |
| 373 | +} | |
| 374 | +@utility chip-active { | |
| 375 | + border-color: var(--ri-fg); | |
| 376 | + background: var(--ri-accent); | |
| 377 | + color: var(--ri-accent-fg); | |
| 378 | + &:hover { | |
| 379 | + color: var(--ri-accent-fg); | |
| 380 | + } | |
| 381 | +} | |
| 382 | + | |
| 383 | +/* ---------- Layout helpers ---------- */ | |
| 212 | 384 | @utility scrollbar-none { |
| 213 | 385 | scrollbar-width: none; |
| 214 | − &::-webkit-scrollbar { display: none; } | |
| 386 | + &::-webkit-scrollbar { | |
| 387 | + display: none; | |
| 388 | + } | |
| 215 | 389 | } |
| 216 | 390 | @utility safe-bottom { |
| 217 | 391 | padding-bottom: max(env(safe-area-inset-bottom), 0.75rem); |
| 218 | 392 | } |
| 393 | +/* Horizontal snap rail: full-bleed on mobile, gutters preserved. */ | |
| 394 | +@utility rail { | |
| 395 | + display: flex; | |
| 396 | + gap: 0.5rem; | |
| 397 | + overflow-x: auto; | |
| 398 | + overscroll-behavior-x: contain; | |
| 399 | + scroll-snap-type: x mandatory; | |
| 400 | + scroll-padding-inline: 1rem; | |
| 401 | + padding-inline: 1rem; | |
| 402 | + margin-inline: -1rem; | |
| 403 | + scrollbar-width: none; | |
| 404 | + -webkit-overflow-scrolling: touch; | |
| 405 | + &::-webkit-scrollbar { | |
| 406 | + display: none; | |
| 407 | + } | |
| 408 | + & > * { | |
| 409 | + scroll-snap-align: start; | |
| 410 | + flex: 0 0 auto; | |
| 411 | + } | |
| 412 | + @media (min-width: 640px) { | |
| 413 | + scroll-padding-inline: 1.5rem; | |
| 414 | + padding-inline: 1.5rem; | |
| 415 | + margin-inline: -1.5rem; | |
| 416 | + } | |
| 417 | +} | |
| 418 | +/* Dense tables: sticky first column on narrow screens, hairline rows. */ | |
| 419 | +@utility table-sticky { | |
| 420 | + & th:first-child, | |
| 421 | + & td:first-child { | |
| 422 | + position: sticky; | |
| 423 | + left: 0; | |
| 424 | + z-index: 2; | |
| 425 | + background: var(--ri-bg-elevated); | |
| 426 | + box-shadow: inset -1px 0 0 var(--ri-border); | |
| 427 | + } | |
| 428 | + & thead th:first-child { | |
| 429 | + z-index: 3; | |
| 430 | + } | |
| 431 | + & tbody tr:hover td:first-child { | |
| 432 | + background: var(--ri-bg-sunken); | |
| 433 | + } | |
| 434 | +} | |
| 435 | +/* Space for the mobile tab bar. */ | |
| 436 | +@utility tabbar-pad { | |
| 437 | + padding-bottom: calc(var(--ri-tabbar-h) + env(safe-area-inset-bottom) + 1rem); | |
| 438 | + @media (min-width: 768px) { | |
| 439 | + padding-bottom: 4rem; | |
| 440 | + } | |
| 441 | +} | |
| 442 | +@utility line-clamp-2 { | |
| 443 | + display: -webkit-box; | |
| 444 | + -webkit-line-clamp: 2; | |
| 445 | + -webkit-box-orient: vertical; | |
| 446 | + overflow: hidden; | |
| 447 | +} | |
| 219 | 448 | |
| 449 | +/* ---------- Motion ---------- */ | |
| 220 | 450 | @keyframes ri-tape { |
| 221 | − from { transform: translateX(0); } | |
| 222 | − to { transform: translateX(-50%); } | |
| 451 | + from { | |
| 452 | + transform: translateX(0); | |
| 453 | + } | |
| 454 | + to { | |
| 455 | + transform: translateX(-50%); | |
| 456 | + } | |
| 223 | 457 | } |
| 224 | 458 | .tape-track { |
| 225 | − animation: ri-tape 60s linear infinite; | |
| 459 | + animation: ri-tape 70s linear infinite; | |
| 460 | + will-change: transform; | |
| 461 | +} | |
| 462 | +.tape-track:hover, | |
| 463 | +.tape-track:focus-within { | |
| 464 | + animation-play-state: paused; | |
| 465 | +} | |
| 466 | +@keyframes ri-fade-up { | |
| 467 | + from { | |
| 468 | + opacity: 0; | |
| 469 | + transform: translateY(6px); | |
| 470 | + } | |
| 471 | + to { | |
| 472 | + opacity: 1; | |
| 473 | + transform: none; | |
| 474 | + } | |
| 475 | +} | |
| 476 | +.fade-up { | |
| 477 | + animation: ri-fade-up 320ms var(--ri-ease) both; | |
| 478 | +} | |
| 479 | +@keyframes ri-sheet-in { | |
| 480 | + from { | |
| 481 | + opacity: 0; | |
| 482 | + transform: translateY(12px); | |
| 483 | + } | |
| 484 | + to { | |
| 485 | + opacity: 1; | |
| 486 | + transform: none; | |
| 487 | + } | |
| 488 | +} | |
| 489 | +.sheet-in { | |
| 490 | + animation: ri-sheet-in 200ms var(--ri-ease) both; | |
| 491 | +} | |
| 492 | +@keyframes ri-pulse-dot { | |
| 493 | + 0%, | |
| 494 | + 100% { | |
| 495 | + opacity: 1; | |
| 496 | + } | |
| 497 | + 50% { | |
| 498 | + opacity: 0.35; | |
| 499 | + } | |
| 500 | +} | |
| 501 | +.live-dot { | |
| 502 | + animation: ri-pulse-dot 1.6s ease-in-out infinite; | |
| 226 | 503 | } |
| 227 | −.tape-track:hover { animation-play-state: paused; } | |
| 228 | 504 | @media (prefers-reduced-motion: reduce) { |
| 229 | − .tape-track { animation: none; } | |
| 505 | + .tape-track, | |
| 506 | + .fade-up, | |
| 507 | + .sheet-in, | |
| 508 | + .live-dot { | |
| 509 | + animation: none; | |
| 510 | + } | |
| 511 | +} | |
| 512 | + | |
| 513 | +/* Native <details> caret hidden where we render our own. */ | |
| 514 | +summary.plain { | |
| 515 | + list-style: none; | |
| 516 | +} | |
| 517 | +summary.plain::-webkit-details-marker { | |
| 518 | + display: none; | |
| 230 | 519 | } |
modified
apps/web/src/app/grading/page.tsx
+1 −1
@@ -17,7 +17,7 @@ export default async function GradingPage() { | ||
| 17 | 17 | for (const p of premiums) (byCat.get(p.categorySlug) ?? byCat.set(p.categorySlug, []).get(p.categorySlug)!).push(p); |
| 18 | 18 | return ( |
| 19 | 19 | <div> |
| 20 | − <PageHeader title="Grading" description="Grades are tracked per grading company. A market multiplier is measured empirically from sales of the same asset in different grades (§118): PSA 10 ≠ BGS 10 ≠ CGC 10 until the data says otherwise." /> | |
| 20 | + <PageHeader kicker="Reference" title="Grading" description="Grades are tracked per grading company. A market multiplier is measured empirically from sales of the same asset in different grades (§118): PSA 10 ≠ BGS 10 ≠ CGC 10 until the data says otherwise." /> | |
| 21 | 21 | <Card className="overflow-hidden"> |
| 22 | 22 | <CardHeader title="Grading companies" subtitle="Scale, categories and observed activity in RareIndex" /> |
| 23 | 23 | <Table> |
added
apps/web/src/app/icon.png
+0 −0
Binary file not shown.
modified
apps/web/src/app/layout.tsx
+15 −5
@@ -3,10 +3,11 @@ import { Geist, Geist_Mono } from 'next/font/google'; | ||
| 3 | 3 | import './globals.css'; |
| 4 | 4 | import { SiteHeader } from '@/components/layout/site-header'; |
| 5 | 5 | import { SiteFooter } from '@/components/layout/site-footer'; |
| 6 | +import { TabBar } from '@/components/layout/tab-bar'; | |
| 6 | 7 | import { THEME_SCRIPT } from '@/components/layout/theme-toggle'; |
| 7 | 8 | |
| 8 | −const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] }); | |
| 9 | −const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] }); | |
| 9 | +const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'], display: 'swap' }); | |
| 10 | +const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'], display: 'swap' }); | |
| 10 | 11 | |
| 11 | 12 | const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io'; |
| 12 | 13 | |
@@ -15,6 +16,9 @@ export const metadata: Metadata = { | ||
| 15 | 16 | title: { default: 'RareIndex — The Global Market for Collectibles', template: '%s · RareIndex' }, |
| 16 | 17 | description: 'Discover, price, track and analyze collectible assets across the world’s marketplaces and auction houses.', |
| 17 | 18 | applicationName: 'RareIndex', |
| 19 | + manifest: '/manifest.webmanifest', | |
| 20 | + appleWebApp: { capable: true, statusBarStyle: 'default', title: 'RareIndex' }, | |
| 21 | + formatDetection: { telephone: false }, | |
| 18 | 22 | openGraph: { type: 'website', siteName: 'RareIndex', url: SITE_URL, title: 'RareIndex — The Global Market for Collectibles', description: 'Prices, sales, listings, rarity, liquidity and indices for collectible assets.' }, |
| 19 | 23 | twitter: { card: 'summary_large_image', title: 'RareIndex', description: 'The global market for collectibles.' }, |
| 20 | 24 | robots: { index: true, follow: true }, |
@@ -25,8 +29,8 @@ export const viewport: Viewport = { | ||
| 25 | 29 | initialScale: 1, |
| 26 | 30 | viewportFit: 'cover', |
| 27 | 31 | themeColor: [ |
| 28 | − { media: '(prefers-color-scheme: light)', color: '#fafaf9' }, | |
| 29 | − { media: '(prefers-color-scheme: dark)', color: '#0b0c0e' }, | |
| 32 | + { media: '(prefers-color-scheme: light)', color: '#f7f7f5' }, | |
| 33 | + { media: '(prefers-color-scheme: dark)', color: '#0a0b0d' }, | |
| 30 | 34 | ], |
| 31 | 35 | }; |
| 32 | 36 | |
@@ -37,9 +41,15 @@ export default function RootLayout({ children }: { children: React.ReactNode }) | ||
| 37 | 41 | <script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} /> |
| 38 | 42 | </head> |
| 39 | 43 | <body className="flex min-h-full flex-col"> |
| 44 | + <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[100] focus:rounded-md focus:bg-elevated focus:px-3 focus:py-2 focus:text-sm focus:shadow-pop"> | |
| 45 | + Skip to content | |
| 46 | + </a> | |
| 40 | 47 | <SiteHeader /> |
| 41 | − <main className="mx-auto w-full max-w-[1440px] flex-1 px-4 pb-16 pt-4 sm:px-6">{children}</main> | |
| 48 | + <main id="main" className="tabbar-pad mx-auto w-full max-w-[1440px] flex-1 px-4 pt-4 sm:px-6"> | |
| 49 | + {children} | |
| 50 | + </main> | |
| 42 | 51 | <SiteFooter /> |
| 52 | + <TabBar /> | |
| 43 | 53 | </body> |
| 44 | 54 | </html> |
| 45 | 55 | ); |
modified
apps/web/src/app/listings/page.tsx
+1 −1
@@ -19,7 +19,7 @@ export default async function ListingsPage({ searchParams }: { searchParams: Pro | ||
| 19 | 19 | const filters = { category: sp1(sp, 'category') ?? null, source: sp1(sp, 'source') ?? null, grader: sp1(sp, 'grader') ?? null, minUsd: spNum(sp, 'min'), maxUsd: spNum(sp, 'max'), minDiscount: spNum(sp, 'disc') === null ? null : spNum(sp, 'disc')! / 100, listingType: sp1(sp, 'type') ?? null, q: sp1(sp, 'q') ?? null, sort: spEnum(sp, 'sort', ['newest', 'price_asc', 'price_desc', 'discount', 'ending'] as const, 'newest'), page: spInt(sp, 'page'), pageSize: 50 }; |
| 20 | 20 | return ( |
| 21 | 21 | <div> |
| 22 | − <PageHeader title="Listings" description="Current asks observed on public marketplaces. A listing price is not a market value; the “vs RIV” column shows the discount or premium to the RareIndex Valuation with its confidence — analytical data, not investment advice (§123)." compact /> | |
| 22 | + <PageHeader kicker="Marketplaces" title="Listings" description="Current asks observed on public marketplaces. A listing price is not a market value; the “vs RIV” column shows the discount or premium to the RareIndex Valuation with its confidence — analytical data, not investment advice (§123)." compact /> | |
| 23 | 23 | <Suspense> |
| 24 | 24 | <FilterBar |
| 25 | 25 | className="mb-3" |
added
apps/web/src/app/manifest.ts
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +import type { MetadataRoute } from 'next'; | |
| 2 | + | |
| 3 | +export default function manifest(): MetadataRoute.Manifest { | |
| 4 | + return { | |
| 5 | + name: 'RareIndex — The Global Market for Collectibles', | |
| 6 | + short_name: 'RareIndex', | |
| 7 | + description: 'Prices, sales, listings, rarity, liquidity and indices for collectible assets.', | |
| 8 | + start_url: '/', | |
| 9 | + display: 'standalone', | |
| 10 | + orientation: 'portrait', | |
| 11 | + background_color: '#f7f7f5', | |
| 12 | + theme_color: '#0a0a0b', | |
| 13 | + categories: ['finance', 'shopping', 'news'], | |
| 14 | + icons: [ | |
| 15 | + { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, | |
| 16 | + { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, | |
| 17 | + { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }, | |
| 18 | + { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, | |
| 19 | + ], | |
| 20 | + shortcuts: [ | |
| 21 | + { name: 'Scanner', url: '/scanner', description: 'Identify and price a collectible from a photo' }, | |
| 22 | + { name: 'Markets', url: '/markets', description: 'Category markets and indices' }, | |
| 23 | + { name: 'Watchlist', url: '/watchlist', description: 'Assets you follow' }, | |
| 24 | + ], | |
| 25 | + }; | |
| 26 | +} | |
modified
apps/web/src/app/market-map/page.tsx
+1 −1
@@ -32,7 +32,7 @@ export default async function MarketMapPage({ searchParams }: { searchParams: Pr | ||
| 32 | 32 | const q = (p: string, s: string) => `/market-map?p=${p}&size=${s}${parent ? `&family=${parent}` : ''}`; |
| 33 | 33 | return ( |
| 34 | 34 | <div> |
| 35 | − <PageHeader | |
| 35 | + <PageHeader kicker="Heatmap" | |
| 36 | 36 | crumbs={parent ? [{ label: 'Market map', href: '/market-map' }, { label: getCategory(parent)?.name ?? parent }] : undefined} |
| 37 | 37 | title={parent ? `${getCategory(parent)?.name ?? parent} map` : 'Market map'} |
| 38 | 38 | description="Area is a real activity measure (volume, sales or tracked assets); colour is the period change of the category index — grey means no change is computable yet. Click a family to drill into its subcategories." |
modified
apps/web/src/app/markets/[slug]/page.tsx
+23 −9
@@ -92,17 +92,31 @@ export default async function MarketPage({ params, searchParams }: { params: Pro | ||
| 92 | 92 | </Suspense> |
| 93 | 93 | </div> |
| 94 | 94 | |
| 95 | − <Suspense fallback={<Skeleton className="mt-4 h-72" />}> | |
| 96 | − <IndexPanel slug={slug} /> | |
| 97 | − </Suspense> | |
| 95 | + {counts.sales > 0 || counts.listings > 0 ? ( | |
| 96 | + <> | |
| 97 | + <Suspense fallback={<Skeleton className="mt-4 h-72" />}> | |
| 98 | + <IndexPanel slug={slug} /> | |
| 99 | + </Suspense> | |
| 98 | 100 | |
| 99 | − <Suspense fallback={<Skeleton className="mt-4 h-72" />}> | |
| 100 | − <Movers slug={slug} /> | |
| 101 | − </Suspense> | |
| 101 | + <Suspense fallback={<Skeleton className="mt-4 h-72" />}> | |
| 102 | + <Movers slug={slug} /> | |
| 103 | + </Suspense> | |
| 102 | 104 | |
| 103 | − <Suspense fallback={<Skeleton className="mt-4 h-72" />}> | |
| 104 | − <Activity slug={slug} /> | |
| 105 | − </Suspense> | |
| 105 | + <Suspense fallback={<Skeleton className="mt-4 h-72" />}> | |
| 106 | + <Activity slug={slug} /> | |
| 107 | + </Suspense> | |
| 108 | + </> | |
| 109 | + ) : ( | |
| 110 | + <Card className="mt-4 p-4"> | |
| 111 | + <p className="t-subtitle text-fg">Market analytics publish once verified sales or live listings exist in {node.name}.</p> | |
| 112 | + <p className="mt-1 text-[13px] leading-relaxed text-muted"> | |
| 113 | + Movers, volume, records, value opportunities, auctions and the category index all derive from observed transactions. Until then the catalogue above is complete and guide prices are shown where a price-guide source reports them (labelled, never presented as market value).{' '} | |
| 114 | + <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline"> | |
| 115 | + How coverage grows → | |
| 116 | + </Link> | |
| 117 | + </p> | |
| 118 | + </Card> | |
| 119 | + )} | |
| 106 | 120 | |
| 107 | 121 | <Suspense fallback={<Skeleton className="mt-4 h-40" />}> |
| 108 | 122 | <Structure slug={slug} /> |
modified
apps/web/src/app/markets/page.tsx
+28 −9
@@ -2,9 +2,12 @@ import type { Metadata } from 'next'; | ||
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { PageHeader } from '@/components/ui/page-header'; |
| 4 | 4 | import { MarketTable } from '@/components/market/market-table'; |
| 5 | −import { getMarketRows } from '@/lib/queries/markets'; | |
| 5 | +import { CategoryGrid } from '@/components/market/category-grid'; | |
| 6 | +import { Card, CardHeader } from '@/components/ui/primitives'; | |
| 7 | +import { getCategorySparklines, getMarketRows } from '@/lib/queries/markets'; | |
| 8 | +import { getCategoryThumbnails } from '@/lib/queries/assets'; | |
| 6 | 9 | import { listIndices } from '@/lib/queries/indices'; |
| 7 | −import { fmtNum } from '@/lib/format'; | |
| 10 | +import { fmtMoney, fmtNum } from '@/lib/format'; | |
| 8 | 11 | |
| 9 | 12 | export const metadata: Metadata = { title: 'Markets', description: 'Collectible category markets: index performance, sales, volume, listings, liquidity and market capitalization estimates.' }; |
| 10 | 13 | export const revalidate = 300; |
@@ -12,30 +15,46 @@ export const revalidate = 300; | ||
| 12 | 15 | export default async function MarketsPage() { |
| 13 | 16 | const [rows, indices] = await Promise.all([getMarketRows(), listIndices()]); |
| 14 | 17 | const active = rows.filter((r) => r.counts.assets > 0); |
| 15 | − const totals = active.reduce((a, r) => ({ assets: a.assets + r.counts.assets, sales: a.sales + r.counts.sales, listings: a.listings + r.counts.listings }), { assets: 0, sales: 0, listings: 0 }); | |
| 18 | + const [thumbs, sparklines] = await Promise.all([getCategoryThumbnails(active.map((r) => r.node.slug)), getCategorySparklines(active.map((r) => r.node.slug), 90)]); | |
| 19 | + const totals = active.reduce((a, r) => ({ assets: a.assets + r.counts.assets, priced: a.priced + r.counts.priced, sales: a.sales + r.counts.sales, sales30d: a.sales30d + r.counts.sales30d, volume: a.volume + (r.counts.volume30dUsd ?? 0), listings: a.listings + r.counts.listings }), { assets: 0, priced: 0, sales: 0, sales30d: 0, volume: 0, listings: 0 }); | |
| 16 | 20 | const published = indices.filter((i) => i.latest).length; |
| 17 | 21 | return ( |
| 18 | 22 | <div> |
| 19 | 23 | <PageHeader |
| 20 | − title="Markets" | |
| 24 | + kicker="Markets" | |
| 25 | + title="Category markets" | |
| 21 | 26 | description="One row per collectible family. Index and period changes come from category snapshots; counts come live from the canonical tables. Families without data are listed so coverage is transparent." |
| 22 | 27 | meta={ |
| 23 | 28 | <> |
| 24 | − <span className="num">{fmtNum(active.length)} families with data · {fmtNum(totals.assets)} assets · {fmtNum(totals.sales)} sales · {fmtNum(totals.listings)} active listings</span> | |
| 25 | − <span>·</span> | |
| 26 | 29 | <Link href="/rareindex" className="hover:text-fg"> |
| 27 | 30 | {published}/{indices.length} indices published |
| 28 | 31 | </Link> |
| 29 | − <span>·</span> | |
| 32 | + <span aria-hidden>·</span> | |
| 30 | 33 | <Link href="/market-map" className="hover:text-fg"> |
| 31 | 34 | Heatmap view → |
| 32 | 35 | </Link> |
| 36 | + <span aria-hidden>·</span> | |
| 37 | + <Link href="/categories" className="hover:text-fg"> | |
| 38 | + Full taxonomy → | |
| 39 | + </Link> | |
| 33 | 40 | </> |
| 34 | 41 | } |
| 42 | + stats={[ | |
| 43 | + { label: 'Families with data', value: fmtNum(active.length), sub: `of ${fmtNum(rows.length)}` }, | |
| 44 | + { label: 'Tracked assets', value: fmtNum(totals.assets), sub: `${fmtNum(totals.priced)} with valuation` }, | |
| 45 | + { label: 'Sales (all time)', value: fmtNum(totals.sales) }, | |
| 46 | + { label: 'Sales · 30D', value: fmtNum(totals.sales30d) }, | |
| 47 | + { label: 'Volume · 30D', value: totals.volume ? fmtMoney(totals.volume, 'USD', { compact: true }) : '—' }, | |
| 48 | + { label: 'Active listings', value: fmtNum(totals.listings) }, | |
| 49 | + ]} | |
| 35 | 50 | /> |
| 36 | − <div className="card overflow-hidden"> | |
| 51 | + <section className="mb-6"> | |
| 52 | + <CategoryGrid rows={active} thumbs={thumbs} sparklines={sparklines} mobileLimit={8} /> | |
| 53 | + </section> | |
| 54 | + <Card className="overflow-hidden"> | |
| 55 | + <CardHeader title="All families" subtitle="Heat cells are period changes of the category index; grey means not yet computable" /> | |
| 37 | 56 | <MarketTable rows={rows} /> |
| 38 | − </div> | |
| 57 | + </Card> | |
| 39 | 58 | <p className="mt-3 text-[11px] leading-relaxed text-subtle">Market capitalization estimates multiply an estimated population by a representative value and are shown only with their confidence label; they are indicative, never precise (§124). Listing prices are not confirmed transactions.</p> |
| 40 | 59 | </div> |
| 41 | 60 | ); |
modified
apps/web/src/app/news/page.tsx
+1 −1
@@ -20,7 +20,7 @@ export default async function NewsPage({ searchParams }: { searchParams: Promise | ||
| 20 | 20 | const res = await listNews({ type, category, page, limit: 40 }); |
| 21 | 21 | return ( |
| 22 | 22 | <div> |
| 23 | − <PageHeader title="News" description="Aggregated from public sources with attribution. AI summaries, when present, always link to the original article and never replace it (§156)." compact /> | |
| 23 | + <PageHeader kicker="Editorial" title="News" description="Aggregated from public sources with attribution. AI summaries, when present, always link to the original article and never replace it (§156)." compact /> | |
| 24 | 24 | <Tabs active={type ?? 'all'} tabs={[{ id: 'all', label: 'All', href: '/news' }, ...TYPES.map((t) => ({ id: t, label: humanize(t), href: `/news?type=${t}` }))]} className="mb-4" /> |
| 25 | 25 | {res.items.length ? ( |
| 26 | 26 | <> |
modified
apps/web/src/app/page.tsx
+124 −73
@@ -4,16 +4,15 @@ import { Hero } from '@/components/home/hero'; | ||
| 4 | 4 | import { IndexTape } from '@/components/market/index-strip'; |
| 5 | 5 | import { AssetRail, LatestTransactions, LotsRail, MarketInsights, RadarRail, SalesRail } from '@/components/home/sections'; |
| 6 | 6 | import { CategoryGrid } from '@/components/market/category-grid'; |
| 7 | −import { attachGuidePrices, getCategoryThumbnails, getLatestObservationsFeed } from '@/lib/queries/assets'; | |
| 7 | +import { TileSkeleton } from '@/components/market/asset-tile'; | |
| 8 | +import { attachGuidePrices, getCategoryThumbnails, getLatestObservationsFeed, rankedAssets } from '@/lib/queries/assets'; | |
| 8 | 9 | import { fmtNum } from '@/lib/format'; |
| 9 | −import { Skeleton } from '@/components/ui/primitives'; | |
| 10 | +import { Card, Delta, Skeleton, TableSkeleton } from '@/components/ui/primitives'; | |
| 10 | 11 | import { getLatestSales, getSiteStats } from '@/lib/queries/site'; |
| 11 | −import { listIndices } from '@/lib/queries/indices'; | |
| 12 | −import { rankedAssets } from '@/lib/queries/assets'; | |
| 13 | −import { getMarketRows } from '@/lib/queries/markets'; | |
| 12 | +import { getIndexSeries, listIndices } from '@/lib/queries/indices'; | |
| 13 | +import { getCategorySparklines, getMarketRows } from '@/lib/queries/markets'; | |
| 14 | 14 | import { getTopSales, listLots, listRadar } from '@/lib/queries/market-lists'; |
| 15 | 15 | import { LineChart } from '@/components/charts/line-chart'; |
| 16 | −import { getIndexSeries } from '@/lib/queries/indices'; | |
| 17 | 16 | |
| 18 | 17 | export const revalidate = 120; |
| 19 | 18 | |
@@ -27,14 +26,17 @@ export default async function HomePage() { | ||
| 27 | 26 | <div className="mt-8"> |
| 28 | 27 | <IndexTape indices={indices} /> |
| 29 | 28 | </div> |
| 30 | − <div className="px-4 pt-6 sm:px-6"> | |
| 31 | − <Suspense fallback={<Skeleton className="h-64 w-full" />}> | |
| 32 | − <FlagshipPanel /> | |
| 29 | + <div className="flex flex-col gap-10 px-4 pt-8 sm:px-6"> | |
| 30 | + <Suspense fallback={<Skeleton className="h-40" />}> | |
| 31 | + <Categories /> | |
| 33 | 32 | </Suspense> |
| 34 | − <Suspense fallback={<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-72" />)}</div>}> | |
| 33 | + <Suspense fallback={<RailFallback />}> | |
| 35 | 34 | <Discovery /> |
| 36 | 35 | </Suspense> |
| 37 | − <Suspense fallback={<Skeleton className="mt-4 h-48" />}> | |
| 36 | + <Suspense fallback={<Skeleton className="h-72 w-full" />}> | |
| 37 | + <FlagshipPanel /> | |
| 38 | + </Suspense> | |
| 39 | + <Suspense fallback={<TableSkeleton rows={10} />}> | |
| 38 | 40 | <Latest /> |
| 39 | 41 | </Suspense> |
| 40 | 42 | </div> |
@@ -42,18 +44,67 @@ export default async function HomePage() { | ||
| 42 | 44 | ); |
| 43 | 45 | } |
| 44 | 46 | |
| 47 | +function RailFallback() { | |
| 48 | + return ( | |
| 49 | + <div className="flex flex-col gap-10"> | |
| 50 | + {[0, 1].map((k) => ( | |
| 51 | + <div key={k}> | |
| 52 | + <Skeleton className="mb-3 h-5 w-40" /> | |
| 53 | + <div className="rail lg:mx-0 lg:grid lg:grid-cols-4 lg:overflow-visible lg:px-0 xl:grid-cols-6 2xl:grid-cols-8 lg:[&>*]:w-auto"> | |
| 54 | + {Array.from({ length: 8 }).map((_, i) => ( | |
| 55 | + <TileSkeleton key={i} /> | |
| 56 | + ))} | |
| 57 | + </div> | |
| 58 | + </div> | |
| 59 | + ))} | |
| 60 | + </div> | |
| 61 | + ); | |
| 62 | +} | |
| 63 | + | |
| 64 | +async function Categories() { | |
| 65 | + const markets = await getMarketRows(); | |
| 66 | + const families = markets.filter((r) => r.counts.assets > 0); | |
| 67 | + const [thumbs, sparklines] = await Promise.all([getCategoryThumbnails(markets.map((r) => r.node.slug)), getCategorySparklines(markets.map((r) => r.node.slug), 90)]); | |
| 68 | + return ( | |
| 69 | + <section> | |
| 70 | + <div className="mb-3 flex items-end justify-between gap-3"> | |
| 71 | + <div> | |
| 72 | + <h2 className="t-title text-fg">Browse by category</h2> | |
| 73 | + <p className="mt-0.5 text-xs text-muted">Live counts from the canonical tables · 90-day category index where published</p> | |
| 74 | + </div> | |
| 75 | + <Link href="/categories" className="num shrink-0 text-xs font-medium text-muted hover:text-fg"> | |
| 76 | + {fmtNum(families.length)} of {fmtNum(markets.length)} families with data → | |
| 77 | + </Link> | |
| 78 | + </div> | |
| 79 | + <CategoryGrid rows={families} thumbs={thumbs} sparklines={sparklines} limit={12} mobileLimit={6} /> | |
| 80 | + </section> | |
| 81 | + ); | |
| 82 | +} | |
| 83 | + | |
| 45 | 84 | async function FlagshipPanel() { |
| 46 | 85 | const indices = await listIndices(); |
| 47 | 86 | const rare = indices.find((i) => i.isFlagship); |
| 48 | − const subs = indices.filter((i) => !i.isFlagship && i.latest); | |
| 87 | + const subs = indices.filter((i) => !i.isFlagship); | |
| 88 | + const published = subs.filter((i) => i.latest); | |
| 89 | + const building = subs.filter((i) => !i.latest).sort((a, b) => b.pricedAssets / b.minConstituents - a.pricedAssets / a.minConstituents); | |
| 49 | 90 | const series = rare ? await getIndexSeries(rare.id, 365) : []; |
| 50 | 91 | return ( |
| 51 | − <section className="grid gap-4 lg:grid-cols-[2fr_1fr]"> | |
| 52 | − <div className="card p-4"> | |
| 53 | − <div className="flex items-baseline justify-between gap-3"> | |
| 92 | + <section className="grid gap-4 lg:grid-cols-[3fr_2fr]"> | |
| 93 | + <Card className="p-4"> | |
| 94 | + <div className="flex flex-wrap items-baseline justify-between gap-3"> | |
| 54 | 95 | <div> |
| 55 | − <h2 className="text-sm font-semibold text-fg">RARE · RareIndex Global Collectibles Index</h2> | |
| 56 | − <p className="text-xs text-muted">Chain-linked, equal-weighted across subindices · base 1,000 on 2024-01-01</p> | |
| 96 | + <p className="t-label">RARE · Global Collectibles Index</p> | |
| 97 | + {rare?.latest ? ( | |
| 98 | + <p className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> | |
| 99 | + <span className="num text-2xl font-semibold text-fg">{rare.latest.value.toFixed(2)}</span> | |
| 100 | + <Delta value={rare.change1d} arrow className="text-sm" /> | |
| 101 | + <span className="text-[11px] text-subtle">1D</span> | |
| 102 | + <Delta value={rare.change30d} className="text-sm" /> | |
| 103 | + <span className="text-[11px] text-subtle">30D</span> | |
| 104 | + </p> | |
| 105 | + ) : ( | |
| 106 | + <p className="mt-1 text-sm text-muted">Chain-linked, equal-weighted across subindices · base 1,000 on 2024-01-01</p> | |
| 107 | + )} | |
| 57 | 108 | </div> |
| 58 | 109 | <Link href="/rareindex/RARE" className="text-xs font-medium text-muted hover:text-fg"> |
| 59 | 110 | Index detail → |
@@ -62,37 +113,57 @@ async function FlagshipPanel() { | ||
| 62 | 113 | <div className="mt-3"> |
| 63 | 114 | <LineChart ariaLabel="RARE index over the last year" height={220} series={[{ id: 'rare', label: 'RARE', kind: 'area', points: series.map((p) => ({ x: p.date, y: p.value })) }]} emptyLabel={rare ? `Index building — publishes once ${rare.minConstituents} priced constituents exist (${rare.pricedAssets} today)` : 'Index not configured'} /> |
| 64 | 115 | </div> |
| 65 | − </div> | |
| 66 | − <div className="card p-4"> | |
| 67 | − <h2 className="text-sm font-semibold text-fg">Subindices</h2> | |
| 68 | − <p className="text-xs text-muted">Daily change · latest published value</p> | |
| 69 | − {subs.length ? ( | |
| 116 | + </Card> | |
| 117 | + <Card className="flex flex-col p-4"> | |
| 118 | + <div className="flex items-baseline justify-between"> | |
| 119 | + <p className="t-label">Subindices</p> | |
| 120 | + <Link href="/rareindex" className="text-xs font-medium text-muted hover:text-fg"> | |
| 121 | + All indices → | |
| 122 | + </Link> | |
| 123 | + </div> | |
| 124 | + {published.length ? ( | |
| 70 | 125 | <ul className="mt-2 divide-y divide-border"> |
| 71 | − {subs.slice(0, 10).map((i) => ( | |
| 126 | + {published.slice(0, 8).map((i) => ( | |
| 72 | 127 | <li key={i.ticker}> |
| 73 | − <Link href={`/rareindex/${i.ticker}`} className="flex items-center justify-between py-1.5 text-[12px] hover:bg-sunken"> | |
| 74 | − <span className="flex items-center gap-2"> | |
| 75 | − <span className="inline-block h-2 w-2 rounded-full" style={{ background: i.color ?? 'var(--ri-index)' }} /> | |
| 76 | − <span className="font-medium text-fg">{i.ticker}</span> | |
| 128 | + <Link href={`/rareindex/${i.ticker}`} className="flex min-h-[40px] items-center justify-between gap-3 text-[12px] hover:bg-sunken"> | |
| 129 | + <span className="flex min-w-0 items-center gap-2"> | |
| 130 | + <span className="inline-block h-2 w-2 shrink-0 rounded-[2px]" style={{ background: i.color ?? 'var(--ri-index)' }} /> | |
| 131 | + <span className="mono-num font-semibold text-fg">{i.ticker}</span> | |
| 132 | + <span className="truncate text-muted">{i.name.replace('RareIndex ', '')}</span> | |
| 77 | 133 | </span> |
| 78 | − <span className="num flex items-center gap-3"> | |
| 134 | + <span className="num flex shrink-0 items-center gap-3"> | |
| 79 | 135 | <span className="text-muted">{i.latest!.value.toFixed(2)}</span> |
| 80 | − <span className={i.change1d === null ? 'text-subtle' : i.change1d >= 0 ? 'text-gain' : 'text-loss'}>{i.change1d === null ? '—' : `${i.change1d >= 0 ? '+' : ''}${(i.change1d * 100).toFixed(2)}%`}</span> | |
| 136 | + <Delta value={i.change1d} arrow /> | |
| 81 | 137 | </span> |
| 82 | 138 | </Link> |
| 83 | 139 | </li> |
| 84 | 140 | ))} |
| 85 | 141 | </ul> |
| 86 | − ) : ( | |
| 87 | − <p className="mt-6 text-center text-xs text-subtle"> | |
| 88 | − No subindex has reached its minimum constituent count yet. | |
| 89 | − <br /> | |
| 90 | − <Link href="/rareindex" className="underline hover:text-fg"> | |
| 91 | − See build progress | |
| 92 | − </Link> | |
| 93 | − </p> | |
| 94 | − )} | |
| 95 | − </div> | |
| 142 | + ) : null} | |
| 143 | + {building.length ? ( | |
| 144 | + <div className="mt-3 border-t border-border pt-3"> | |
| 145 | + <p className="t-caption mb-1.5">Building · priced constituents vs. minimum</p> | |
| 146 | + <ul className="grid grid-cols-2 gap-x-4 gap-y-1.5"> | |
| 147 | + {building.slice(0, 8).map((i) => { | |
| 148 | + const pct = Math.min(100, Math.round((i.pricedAssets / Math.max(1, i.minConstituents)) * 100)); | |
| 149 | + return ( | |
| 150 | + <li key={i.ticker} className="text-[11px]"> | |
| 151 | + <span className="flex items-center justify-between"> | |
| 152 | + <span className="mono-num text-muted">{i.ticker.replace('RARE-', '')}</span> | |
| 153 | + <span className="num text-subtle"> | |
| 154 | + {i.pricedAssets}/{i.minConstituents} | |
| 155 | + </span> | |
| 156 | + </span> | |
| 157 | + <span className="mt-0.5 block h-1 w-full overflow-hidden rounded-full bg-inset"> | |
| 158 | + <span className="block h-full rounded-full bg-index" style={{ width: `${pct}%` }} /> | |
| 159 | + </span> | |
| 160 | + </li> | |
| 161 | + ); | |
| 162 | + })} | |
| 163 | + </ul> | |
| 164 | + </div> | |
| 165 | + ) : null} | |
| 166 | + </Card> | |
| 96 | 167 | </section> |
| 97 | 168 | ); |
| 98 | 169 | } |
@@ -113,46 +184,30 @@ async function Discovery() { | ||
| 113 | 184 | rankedAssets('documented', { limit: 8 }), |
| 114 | 185 | rankedAssets('with_sales', { limit: 8 }), |
| 115 | 186 | ]); |
| 116 | − await Promise.all([attachGuidePrices(newest), attachGuidePrices(documented)]); | |
| 117 | − const families = markets.filter((r) => r.counts.assets > 0); | |
| 118 | − const thumbs = await getCategoryThumbnails(markets.map((r) => r.node.slug)); | |
| 187 | + await Promise.all([attachGuidePrices(newest), attachGuidePrices(documented), attachGuidePrices(trending), attachGuidePrices(newestPriced)]); | |
| 119 | 188 | // Honest fallbacks while valuations are still being computed: each rail states what it shows. |
| 120 | 189 | const trendingRail = trending.length |
| 121 | 190 | ? { title: 'Trending Now', subtitle: 'Price × volume × listing momentum', items: trending, metric: 'trending' as const, href: '/trending' } |
| 122 | 191 | : newestPriced.length |
| 123 | − ? { title: 'Recently valued', subtitle: 'Newest RareIndex Valuations (trending needs momentum history)', items: newestPriced, metric: 'riv' as const, href: '/explore?has=valuation&sort=riv' } | |
| 124 | − : { title: 'Best-documented assets', subtitle: 'Images plus sales or guide prices — trending publishes once momentum history exists', items: documented, metric: 'guide' as const, href: '/explore?has=observations' }; | |
| 192 | + ? { title: 'Recently valued', subtitle: 'Newest RareIndex Valuations — trending publishes once momentum history exists', items: newestPriced, metric: 'riv' as const, href: '/explore?has=valuation&sort=riv' } | |
| 193 | + : { title: 'Best-documented assets', subtitle: 'Images plus sales or guide prices', items: documented, metric: 'guide' as const, href: '/explore?has=observations' }; | |
| 125 | 194 | const moversItems = movers.length ? movers : losers; |
| 126 | 195 | const moversRail = moversItems.length |
| 127 | 196 | ? { title: 'Biggest Movers', subtitle: '7-day change in RareIndex Valuation', items: moversItems, metric: 'change7d' as const, href: '/explore?sort=change7d' } |
| 128 | 197 | : withSales.length |
| 129 | − ? { title: 'Latest sold assets', subtitle: 'Most recent verified sale per asset — movers need a 7-day valuation history', items: withSales, metric: 'riv' as const, href: '/sales' } | |
| 198 | + ? { title: 'Latest sold assets', subtitle: 'Most recent verified sale per asset — movers need a 7-day valuation history', items: withSales, metric: 'latestSale' as const, href: '/sales' } | |
| 130 | 199 | : null; |
| 131 | 200 | return ( |
| 132 | 201 | <> |
| 133 | − <section className="mt-6"> | |
| 134 | − <div className="mb-2 flex items-baseline justify-between"> | |
| 135 | − <h2 className="text-base font-semibold tracking-tight text-fg">Browse by category</h2> | |
| 136 | − <span className="num text-[11px] text-muted"> | |
| 137 | − {fmtNum(families.length)} families with data ·{' '} | |
| 138 | − <Link href="/categories" className="hover:text-fg"> | |
| 139 | − all {fmtNum(markets.length)} → | |
| 140 | − </Link> | |
| 141 | − </span> | |
| 142 | − </div> | |
| 143 | − <CategoryGrid rows={markets} thumbs={thumbs} limit={12} /> | |
| 144 | − </section> | |
| 145 | − <section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3"> | |
| 146 | − <AssetRail title={trendingRail.title} subtitle={trendingRail.subtitle} href={trendingRail.href} items={trendingRail.items} metric={trendingRail.metric} /> | |
| 147 | − {moversRail ? <AssetRail title={moversRail.title} subtitle={moversRail.subtitle} href={moversRail.href} items={moversRail.items} metric={moversRail.metric} /> : null} | |
| 148 | − <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} hideWhenEmpty /> | |
| 149 | − <RadarRail items={radar} hideWhenEmpty /> | |
| 150 | − <LotsRail items={lots} hideWhenEmpty /> | |
| 151 | − <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" /> | |
| 152 | − <MarketInsights rows={markets} /> | |
| 153 | − {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null} | |
| 154 | − {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV (analytical, not advice)" href="/listings?sort=discount" items={opportunities} metric="opportunity" /> : null} | |
| 155 | − </section> | |
| 202 | + <AssetRail title={trendingRail.title} subtitle={trendingRail.subtitle} href={trendingRail.href} items={trendingRail.items} metric={trendingRail.metric} priority /> | |
| 203 | + {moversRail ? <AssetRail title={moversRail.title} subtitle={moversRail.subtitle} href={moversRail.href} items={moversRail.items} metric={moversRail.metric} /> : null} | |
| 204 | + <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} hideWhenEmpty /> | |
| 205 | + <RadarRail items={radar} hideWhenEmpty /> | |
| 206 | + <LotsRail items={lots} hideWhenEmpty /> | |
| 207 | + <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" /> | |
| 208 | + {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV — analytical data, not advice" href="/listings?sort=discount" items={opportunities} metric="opportunity" /> : null} | |
| 209 | + {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null} | |
| 210 | + <MarketInsights rows={markets} /> | |
| 156 | 211 | </> |
| 157 | 212 | ); |
| 158 | 213 | } |
@@ -160,9 +215,5 @@ async function Discovery() { | ||
| 160 | 215 | async function Latest() { |
| 161 | 216 | const items = await getLatestSales(12); |
| 162 | 217 | const observations = items.length ? [] : await getLatestObservationsFeed(12); |
| 163 | − return ( | |
| 164 | − <section className="mt-6"> | |
| 165 | − <LatestTransactions items={items} observations={observations} /> | |
| 166 | − </section> | |
| 167 | − ); | |
| 218 | + return <LatestTransactions items={items} observations={observations} />; | |
| 168 | 219 | } |
modified
apps/web/src/app/price-history/page.tsx
+1 −1
@@ -31,7 +31,7 @@ export default async function PriceHistoryPage({ searchParams }: { searchParams: | ||
| 31 | 31 | const asset = slug ? await getAssetBySlug(slug) : null; |
| 32 | 32 | return ( |
| 33 | 33 | <div> |
| 34 | − <PageHeader title="Price history" description="Historical series for any asset: verified sales as points, daily RIV, lowest ask and volume. Never interpolated; gaps are gaps." compact /> | |
| 34 | + <PageHeader kicker="Series" title="Price history" description="Historical series for any asset: verified sales as points, daily RIV, lowest ask and volume. Never interpolated; gaps are gaps." compact /> | |
| 35 | 35 | <form action="/price-history" className="mb-4 flex max-w-xl gap-2"> |
| 36 | 36 | <input name="q" defaultValue={q} placeholder="Find an asset… e.g. Charizard 1st edition PSA 10" className="h-10 flex-1 rounded-md border border-border bg-elevated px-3 text-[14px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" aria-label="Find an asset" /> |
| 37 | 37 | <button type="submit" className="h-10 rounded-md bg-accent px-4 text-sm font-medium text-accent-fg"> |
modified
apps/web/src/app/radar/page.tsx
+1 −1
@@ -19,7 +19,7 @@ export default async function RadarPage({ searchParams }: { searchParams: Promis | ||
| 19 | 19 | const items = await listRadar({ kind, limit: 100 }); |
| 20 | 20 | return ( |
| 21 | 21 | <div> |
| 22 | − <PageHeader title="Rare Radar" description="Signals detected by the pipeline (§155): each finding cites its evidence (listing, sale, population report). Findings expire; nothing here is a recommendation." compact /> | |
| 22 | + <PageHeader kicker="Signals" title="Rare Radar" description="Signals detected by the pipeline (§155): each finding cites its evidence (listing, sale, population report). Findings expire; nothing here is a recommendation." compact /> | |
| 23 | 23 | <Tabs active={kind ?? 'all'} tabs={[{ id: 'all', label: 'All', href: '/radar' }, ...KINDS.map((k) => ({ id: k, label: humanize(k), href: `/radar?kind=${k}` }))]} className="mb-4" /> |
| 24 | 24 | {items.length ? ( |
| 25 | 25 | <ul className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3"> |
modified
apps/web/src/app/rareindex/page.tsx
+1 −1
@@ -20,7 +20,7 @@ export default async function RareIndexPage() { | ||
| 20 | 20 | const corr = await getCorrelationMatrix(365); |
| 21 | 21 | return ( |
| 22 | 22 | <div> |
| 23 | − <PageHeader | |
| 23 | + <PageHeader kicker="Indices" | |
| 24 | 24 | title="RareIndex" |
| 25 | 25 | description="RARE aggregates the subindices; each subindex is a chain-linked index of priced constituents in its families (equal-weighted by default, base 1,000 on 2024-01-01). An index publishes only once it has enough priced constituents, and every daily value stores its coverage." |
| 26 | 26 | meta={ |
modified
apps/web/src/app/records/page.tsx
+1 −1
@@ -15,7 +15,7 @@ export default async function RecordsPage() { | ||
| 15 | 15 | const [records, top] = await Promise.all([getRecordSales(), getTopSales(100)]); |
| 16 | 16 | return ( |
| 17 | 17 | <div> |
| 18 | − <PageHeader title="Record sales" description="Highest verified transactions per collectible family (§154). Only sales with confidence ≥ 80% and a source link qualify; private treaty sales reported without documentation are excluded." /> | |
| 18 | + <PageHeader kicker="Records" title="Record sales" description="Highest verified transactions per collectible family (§154). Only sales with confidence ≥ 80% and a source link qualify; private treaty sales reported without documentation are excluded." /> | |
| 19 | 19 | {records.length ? ( |
| 20 | 20 | <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> |
| 21 | 21 | {records.map((r) => ( |
modified
apps/web/src/app/sales/page.tsx
+1 −1
@@ -19,7 +19,7 @@ export default async function SalesPage({ searchParams }: { searchParams: Promis | ||
| 19 | 19 | const filters = { category: sp1(sp, 'category') ?? null, source: sp1(sp, 'source') ?? null, grader: sp1(sp, 'grader') ?? null, minUsd: spNum(sp, 'min'), maxUsd: spNum(sp, 'max'), days: spNum(sp, 'days'), saleType: sp1(sp, 'type') ?? null, q: sp1(sp, 'q') ?? null, sort: spEnum(sp, 'sort', ['date', 'price'] as const, 'date'), page: spInt(sp, 'page'), pageSize: 50 }; |
| 20 | 20 | return ( |
| 21 | 21 | <div> |
| 22 | − <PageHeader title="Sales" description="Observed transactions only — never asking prices. Each row links to its source; prices show USD at the historical exchange rate of the sale date, with the native amount beneath." compact /> | |
| 22 | + <PageHeader kicker="Transactions" title="Sales" description="Observed transactions only — never asking prices. Each row links to its source; prices show USD at the historical exchange rate of the sale date, with the native amount beneath." compact /> | |
| 23 | 23 | <Suspense> |
| 24 | 24 | <FilterBar |
| 25 | 25 | className="mb-3" |
modified
apps/web/src/app/search/page.tsx
+1 −1
@@ -23,7 +23,7 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi | ||
| 23 | 23 | const page = spInt(sp, 'page'); |
| 24 | 24 | return ( |
| 25 | 25 | <div> |
| 26 | − <PageHeader title={q ? <span>Results for “{q}”</span> : 'Search'} description="Hybrid search: lexical + trigram + popularity, with structured filters parsed from your words (grader, grade, year, price bounds, condition, edition)." compact /> | |
| 26 | + <PageHeader kicker="Search" title={q ? <span>Results for “{q}”</span> : 'Search'} description="Hybrid search: lexical + trigram + popularity, with structured filters parsed from your words (grader, grade, year, price bounds, condition, edition)." compact /> | |
| 27 | 27 | <form action="/search" role="search" className="mb-4 flex max-w-2xl gap-2"> |
| 28 | 28 | <input name="q" defaultValue={q} type="search" placeholder="1999 Charizard PSA 10, Rolex Daytona under $20,000, sealed Mario 64…" className="h-10 flex-1 rounded-md border border-border bg-elevated px-3 text-[14px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" aria-label="Search" /> |
| 29 | 29 | {category ? <input type="hidden" name="category" value={category} /> : null} |
modified
apps/web/src/app/trending/page.tsx
+1 −1
@@ -20,7 +20,7 @@ export default async function TrendingPage({ searchParams }: { searchParams: Pro | ||
| 20 | 20 | const [trending, cats, gainers7, gainers30] = await Promise.all([rankedAssets('trending', { scope, limit: 60 }), getTrendingCategories(12), rankedAssets('gainers', { scope, limit: 12, window: '7d' }), rankedAssets('gainers', { scope, limit: 12, window: '30d' })]); |
| 21 | 21 | return ( |
| 22 | 22 | <div> |
| 23 | − <PageHeader | |
| 23 | + <PageHeader kicker="Momentum" | |
| 24 | 24 | title="Trending" |
| 25 | 25 | description="Trending = price momentum × volume momentum × search momentum × listing momentum × news momentum, each normalised (§152). Scores are computed by the pipeline from observed data only." |
| 26 | 26 | meta={ |
modified
apps/web/src/components/account/charts.tsx
+6 −4
@@ -4,7 +4,8 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | import { cn } from '@/lib/format'; |
| 6 | 6 | |
| 7 | −const PALETTE = ['var(--ri-index)', 'var(--ri-gold)', 'var(--ri-rarity)', 'var(--ri-gain)', 'var(--ri-alert)', 'var(--ri-fg-muted)', 'var(--ri-loss)', 'var(--ri-fg-subtle)']; | |
| 7 | +// Categorical identity: the four validated series tokens in fixed order; beyond four, fold into muted steps ('Other'). | |
| 8 | +const PALETTE = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)', 'var(--ri-fg-muted)', 'var(--ri-fg-subtle)', 'var(--ri-border-strong)', 'var(--ri-border)']; | |
| 8 | 9 | |
| 9 | 10 | export function Donut({ data, size = 120, thickness = 14, className, centerLabel, centerValue }: { data: Array<{ label: string; value: number }>; size?: number; thickness?: number; className?: string; centerLabel?: string; centerValue?: string }) { |
| 10 | 11 | const total = data.reduce((a, d) => a + d.value, 0); |
@@ -18,7 +19,8 @@ export function Donut({ data, size = 120, thickness = 14, className, centerLabel | ||
| 18 | 19 | {total > 0 |
| 19 | 20 | ? data.map((d, i) => { |
| 20 | 21 | const len = (d.value / total) * c; |
| 21 | − const el = <circle key={d.label} cx={size / 2} cy={size / 2} r={r} fill="none" stroke={PALETTE[i % PALETTE.length]} strokeWidth={thickness} strokeDasharray={`${len} ${c - len}`} strokeDashoffset={-offset} transform={`rotate(-90 ${size / 2} ${size / 2})`} />; | |
| 22 | + const gap = data.length > 1 ? Math.min(2, len / 2) : 0; // 2px surface gap between segments | |
| 23 | + const el = <circle key={d.label} cx={size / 2} cy={size / 2} r={r} fill="none" stroke={PALETTE[i % PALETTE.length]} strokeWidth={thickness} strokeDasharray={`${Math.max(0, len - gap)} ${c - len + gap}`} strokeDashoffset={-offset} transform={`rotate(-90 ${size / 2} ${size / 2})`} />; | |
| 22 | 24 | offset += len; |
| 23 | 25 | return el; |
| 24 | 26 | }) |
@@ -85,7 +87,7 @@ export function LineChart({ series, height = 180, className, formatY = (v: numbe | ||
| 85 | 87 | <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={ariaLabel} preserveAspectRatio="none"> |
| 86 | 88 | {ticks.map((t) => ( |
| 87 | 89 | <g key={t}> |
| 88 | − <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} stroke="var(--ri-border)" strokeDasharray="2 3" /> | |
| 90 | + <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} stroke="var(--ri-border)" /> | |
| 89 | 91 | <text x={padL - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ri-fg-subtle)" style={{ fontVariantNumeric: 'tabular-nums' }}> |
| 90 | 92 | {formatY(t)} |
| 91 | 93 | </text> |
@@ -99,7 +101,7 @@ export function LineChart({ series, height = 180, className, formatY = (v: numbe | ||
| 99 | 101 | return ( |
| 100 | 102 | <g key={s.name}> |
| 101 | 103 | {showArea && i === 0 ? <path d={`${d} L${x(last.date).toFixed(1)},${H - padB} L${x(pts[0]!.date).toFixed(1)},${H - padB} Z`} fill={color} opacity={0.06} /> : null} |
| 102 | − <path d={d} fill="none" stroke={color} strokeWidth={1.6} strokeDasharray={s.dashed ? '4 3' : undefined} vectorEffect="non-scaling-stroke" /> | |
| 104 | + <path d={d} fill="none" stroke={color} strokeWidth={2} strokeDasharray={s.dashed ? '4 3' : undefined} vectorEffect="non-scaling-stroke" /> | |
| 103 | 105 | <circle cx={x(last.date)} cy={y(last.value)} r={2.5} fill={color} /> |
| 104 | 106 | </g> |
| 105 | 107 | ); |
modified
apps/web/src/components/account/form.tsx
+1 −1
@@ -7,7 +7,7 @@ import { passwordStrength } from '@/lib/auth/password-strength'; | ||
| 7 | 7 | import type { ActionState } from '@/lib/auth/state'; |
| 8 | 8 | import { cn } from '@/lib/format'; |
| 9 | 9 | |
| 10 | −export const inputClass = 'block w-full rounded-md border border-border bg-elevated px-3 py-2 text-[15px] text-fg shadow-none placeholder:text-subtle focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-[var(--ri-ring)] md:text-sm disabled:opacity-60'; | |
| 10 | +export const inputClass = 'block w-full rounded-md border border-border bg-elevated px-3 py-2 text-base text-fg shadow-none placeholder:text-subtle focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-[var(--ri-ring)] md:text-sm disabled:opacity-60'; | |
| 11 | 11 | |
| 12 | 12 | export function Field({ label, name, error, hint, children, className }: { label: ReactNode; name: string; error?: string | null; hint?: ReactNode; children: ReactNode; className?: string }) { |
| 13 | 13 | return ( |
added
apps/web/src/components/asset/asset-actions.tsx
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Bookmark, FolderPlus, GitCompareArrows, Sparkles } from 'lucide-react'; | |
| 3 | +import { toggleWatchAction } from '@/lib/account/actions'; | |
| 4 | +import { cn } from '@/lib/format'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Asset actions: Watch (server action, signs in first when anonymous), Add to collection, Compare, | |
| 8 | + * Ask AI. Rendered inline on desktop and as a sticky bottom bar on phones (§165). | |
| 9 | + */ | |
| 10 | +export function AssetActions({ assetId, slug, watched = false, variant = 'inline', className }: { assetId: string; slug: string; watched?: boolean; variant?: 'inline' | 'bar'; className?: string }) { | |
| 11 | + const back = `/asset/${slug}`; | |
| 12 | + const btn = variant === 'bar' ? 'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-md py-1.5 text-[10px] font-medium text-muted active:bg-inset' : 'inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-elevated px-3 text-[12px] font-medium text-fg hover:bg-inset'; | |
| 13 | + const icon = variant === 'bar' ? 'h-5 w-5' : 'h-3.5 w-3.5'; | |
| 14 | + return ( | |
| 15 | + <div className={cn(variant === 'bar' ? 'fixed inset-x-0 z-40 border-t border-border bg-elevated/95 backdrop-blur supports-[backdrop-filter]:bg-elevated/85 md:hidden' : 'flex flex-wrap gap-2', className)} style={variant === 'bar' ? { bottom: 'calc(var(--ri-tabbar-h, 0px) + env(safe-area-inset-bottom))' } : undefined}> | |
| 16 | + <div className={cn(variant === 'bar' ? 'mx-auto flex max-w-lg items-stretch gap-1 px-2 py-1' : 'contents')}> | |
| 17 | + <form action={toggleWatchAction} className={variant === 'bar' ? 'flex flex-1' : 'contents'}> | |
| 18 | + <input type="hidden" name="targetType" value="asset" /> | |
| 19 | + <input type="hidden" name="targetId" value={assetId} /> | |
| 20 | + <input type="hidden" name="back" value={back} /> | |
| 21 | + <button type="submit" className={cn(btn, watched && (variant === 'bar' ? 'text-fg' : 'border-fg'))} aria-pressed={watched}> | |
| 22 | + <Bookmark className={cn(icon, watched && 'fill-current')} /> | |
| 23 | + {watched ? 'Watching' : 'Watch'} | |
| 24 | + </button> | |
| 25 | + </form> | |
| 26 | + <Link href={`/collections?add=${assetId}`} className={btn}> | |
| 27 | + <FolderPlus className={icon} /> | |
| 28 | + {variant === 'bar' ? 'Collect' : 'Add to collection'} | |
| 29 | + </Link> | |
| 30 | + <Link href={`/compare?a=${slug}`} className={btn}> | |
| 31 | + <GitCompareArrows className={icon} /> | |
| 32 | + Compare | |
| 33 | + </Link> | |
| 34 | + <Link href={`/research?asset=${slug}`} className={cn(btn, variant === 'inline' && 'border-transparent bg-accent text-accent-fg hover:opacity-90')}> | |
| 35 | + <Sparkles className={icon} /> | |
| 36 | + Ask AI | |
| 37 | + </Link> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + ); | |
| 41 | +} | |
added
apps/web/src/components/asset/asset-gallery.tsx
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import Image from 'next/image'; | |
| 4 | +import { useCallback, useEffect, useRef, useState } from 'react'; | |
| 5 | +import { ChevronLeft, ChevronRight, Maximize2, X } from 'lucide-react'; | |
| 6 | +import { cn } from '@/lib/format'; | |
| 7 | + | |
| 8 | +export interface GalleryImage { | |
| 9 | + url: string; | |
| 10 | + caption?: string | null; | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Hero image with a full-screen, swipeable gallery (scroll-snap + keyboard). Images are shown | |
| 15 | + * `object-contain` on a neutral surface so cards, watches and boxes keep their proportions. | |
| 16 | + */ | |
| 17 | +export function AssetGallery({ images, alt, className, priority = true }: { images: GalleryImage[]; alt: string; className?: string; priority?: boolean }) { | |
| 18 | + const [open, setOpen] = useState(false); | |
| 19 | + const [index, setIndex] = useState(0); | |
| 20 | + const track = useRef<HTMLDivElement>(null); | |
| 21 | + const hero = images[0]; | |
| 22 | + | |
| 23 | + const go = useCallback( | |
| 24 | + (i: number) => { | |
| 25 | + const n = images.length; | |
| 26 | + const next = ((i % n) + n) % n; | |
| 27 | + setIndex(next); | |
| 28 | + track.current?.children[next]?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); | |
| 29 | + }, | |
| 30 | + [images.length], | |
| 31 | + ); | |
| 32 | + | |
| 33 | + useEffect(() => { | |
| 34 | + if (!open) return; | |
| 35 | + const onKey = (e: KeyboardEvent) => { | |
| 36 | + if (e.key === 'Escape') setOpen(false); | |
| 37 | + if (e.key === 'ArrowRight') go(index + 1); | |
| 38 | + if (e.key === 'ArrowLeft') go(index - 1); | |
| 39 | + }; | |
| 40 | + document.addEventListener('keydown', onKey); | |
| 41 | + document.body.style.overflow = 'hidden'; | |
| 42 | + return () => { | |
| 43 | + document.removeEventListener('keydown', onKey); | |
| 44 | + document.body.style.overflow = ''; | |
| 45 | + }; | |
| 46 | + }, [open, index, go]); | |
| 47 | + | |
| 48 | + if (!hero) { | |
| 49 | + return ( | |
| 50 | + <div className={cn('relative flex items-center justify-center overflow-hidden rounded-lg border border-border bg-inset', className)}> | |
| 51 | + <span className="text-[10px] font-medium uppercase tracking-wider text-subtle">No image</span> | |
| 52 | + </div> | |
| 53 | + ); | |
| 54 | + } | |
| 55 | + | |
| 56 | + return ( | |
| 57 | + <> | |
| 58 | + <button type="button" onClick={() => setOpen(true)} className={cn('group relative block overflow-hidden rounded-lg border border-border bg-inset text-left', className)} aria-label={`Open image gallery for ${alt}`}> | |
| 59 | + <Image src={hero.url} alt={alt} fill sizes="(max-width: 768px) 100vw, 360px" className="object-contain p-2 transition-transform duration-300 group-hover:scale-[1.02]" priority={priority} /> | |
| 60 | + <span className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/55 px-1.5 py-1 text-[10px] font-medium text-white backdrop-blur-sm"> | |
| 61 | + <Maximize2 className="h-3 w-3" /> | |
| 62 | + {images.length > 1 ? `${images.length} photos` : 'View'} | |
| 63 | + </span> | |
| 64 | + </button> | |
| 65 | + {open ? ( | |
| 66 | + <div className="fixed inset-0 z-[60] flex flex-col bg-black/95 text-white" role="dialog" aria-modal="true" aria-label={`${alt} gallery`}> | |
| 67 | + <div className="flex items-center justify-between px-3 pt-[max(env(safe-area-inset-top),0.5rem)] pb-2 text-xs"> | |
| 68 | + <span className="num text-white/80"> | |
| 69 | + {index + 1} / {images.length} | |
| 70 | + </span> | |
| 71 | + <button type="button" onClick={() => setOpen(false)} aria-label="Close gallery" className="inline-flex h-9 w-9 items-center justify-center rounded-md hover:bg-white/10"> | |
| 72 | + <X className="h-5 w-5" /> | |
| 73 | + </button> | |
| 74 | + </div> | |
| 75 | + <div | |
| 76 | + ref={track} | |
| 77 | + className="scrollbar-none flex flex-1 snap-x snap-mandatory overflow-x-auto" | |
| 78 | + onScroll={(e) => { | |
| 79 | + const el = e.currentTarget; | |
| 80 | + const i = Math.round(el.scrollLeft / el.clientWidth); | |
| 81 | + if (i !== index) setIndex(i); | |
| 82 | + }} | |
| 83 | + > | |
| 84 | + {images.map((im, i) => ( | |
| 85 | + <figure key={`${im.url}-${i}`} className="relative flex h-full w-full shrink-0 snap-center items-center justify-center p-2"> | |
| 86 | + <Image src={im.url} alt={`${alt} — image ${i + 1}`} fill sizes="100vw" className="object-contain" unoptimized /> | |
| 87 | + {im.caption ? <figcaption className="absolute bottom-3 left-3 right-3 truncate text-center text-[11px] text-white/70">{im.caption}</figcaption> : null} | |
| 88 | + </figure> | |
| 89 | + ))} | |
| 90 | + </div> | |
| 91 | + {images.length > 1 ? ( | |
| 92 | + <div className="flex items-center justify-center gap-6 px-3 pb-[max(env(safe-area-inset-bottom),0.75rem)] pt-2"> | |
| 93 | + <button type="button" onClick={() => go(index - 1)} aria-label="Previous image" className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"> | |
| 94 | + <ChevronLeft className="h-5 w-5" /> | |
| 95 | + </button> | |
| 96 | + <div className="flex gap-1.5"> | |
| 97 | + {images.slice(0, 12).map((_, i) => ( | |
| 98 | + <span key={i} className={cn('h-1.5 w-1.5 rounded-full', i === index ? 'bg-white' : 'bg-white/30')} /> | |
| 99 | + ))} | |
| 100 | + </div> | |
| 101 | + <button type="button" onClick={() => go(index + 1)} aria-label="Next image" className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"> | |
| 102 | + <ChevronRight className="h-5 w-5" /> | |
| 103 | + </button> | |
| 104 | + </div> | |
| 105 | + ) : ( | |
| 106 | + <div className="pb-[max(env(safe-area-inset-bottom),0.75rem)]" /> | |
| 107 | + )} | |
| 108 | + </div> | |
| 109 | + ) : null} | |
| 110 | + </> | |
| 111 | + ); | |
| 112 | +} | |
modified
apps/web/src/components/asset/asset-header.tsx
+118 −61
@@ -1,13 +1,19 @@ | ||
| 1 | −import Image from 'next/image'; | |
| 2 | 1 | import Link from 'next/link'; |
| 3 | 2 | import type { AssetDetail, VariantRow, GuidePrice } from '@/lib/queries/assets'; |
| 4 | 3 | import { fmtMoney, fmtNum, fmtRelative, fmtDate, cn, confidenceLabel } from '@/lib/format'; |
| 5 | 4 | import { catName, categoryCrumbs } from '@/lib/taxonomy'; |
| 6 | 5 | import { Breadcrumbs } from '@/components/ui/page-header'; |
| 7 | −import { Badge, Delta, Stat } from '@/components/ui/primitives'; | |
| 8 | −import { Evidence, ScoreMeter } from '@/components/ui/evidence'; | |
| 6 | +import { Badge, Delta } from '@/components/ui/primitives'; | |
| 7 | +import { ScoreMeter } from '@/components/ui/evidence'; | |
| 8 | +import { AssetGallery, type GalleryImage } from './asset-gallery'; | |
| 9 | +import { AssetActions } from './asset-actions'; | |
| 10 | +import { VariantTable } from './variant-table'; | |
| 9 | 11 | |
| 10 | −export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = null }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string; guide?: GuidePrice | null }) { | |
| 12 | +/** | |
| 13 | + * Asset page header (§126): gallery + identity on the left, the valuation panel on the right. | |
| 14 | + * Mobile-first: gallery → title → variant chips → valuation → metrics; desktop: two columns. | |
| 15 | + */ | |
| 16 | +export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = null, images, watched = false }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string; guide?: GuidePrice | null; images: GalleryImage[]; watched?: boolean }) { | |
| 11 | 17 | const v = activeVariant; |
| 12 | 18 | const riv = v ? v.rivUsd : asset.rivUsd; |
| 13 | 19 | const low = v ? v.rivLowUsd : asset.rivLowUsd; |
@@ -16,17 +22,33 @@ export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = n | ||
| 16 | 22 | const n = v ? v.rivSampleSize : asset.rivSampleSize; |
| 17 | 23 | const latest = v ? v.latestSaleUsd : asset.latestSaleUsd; |
| 18 | 24 | const latestAt = v ? v.latestSaleAt : asset.latestSaleAt; |
| 25 | + const confLabel = confidenceLabel(conf); | |
| 26 | + const confTone = confLabel === 'High' ? 'gain' : confLabel === 'Medium' ? 'index' : confLabel === 'Low' ? 'alert' : 'neutral'; | |
| 27 | + | |
| 28 | + const facts: Array<[string, React.ReactNode]> = ( | |
| 29 | + [ | |
| 30 | + ['Set', asset.setName ? <Link key="s" href={asset.setSlug ? `/set/${asset.setSlug}` : '#'} className="hover:underline">{asset.setName}{asset.setCode ? ` (${asset.setCode})` : ''}</Link> : null], | |
| 31 | + ['Number', asset.number], | |
| 32 | + ['Brand', asset.brand ? <Link key="b" href={`/brand/${encodeURIComponent(asset.brand)}`} className="hover:underline">{asset.brand}</Link> : null], | |
| 33 | + ['Franchise', asset.franchise], | |
| 34 | + ['Reference', asset.reference], | |
| 35 | + ['Model', asset.model], | |
| 36 | + ['Release', asset.releaseDate], | |
| 37 | + ['Production', asset.productionQuantity != null ? fmtNum(asset.productionQuantity) : null], | |
| 38 | + ['Original MSRP', asset.originalMsrp != null ? fmtMoney(asset.originalMsrp, asset.originalMsrpCurrency ?? 'USD') : null], | |
| 39 | + ] as Array<[string, React.ReactNode]> | |
| 40 | + ).filter(([, val]) => val); | |
| 41 | + | |
| 19 | 42 | return ( |
| 20 | 43 | <header> |
| 21 | 44 | <Breadcrumbs items={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(asset.categorySlug), ...(asset.setSlug && asset.setName ? [{ label: asset.setName, href: `/set/${asset.setSlug}` }] : []), { label: asset.name }]} /> |
| 22 | − <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]"> | |
| 23 | − <div className="flex gap-4"> | |
| 24 | − <div className="relative h-28 w-28 shrink-0 overflow-hidden rounded-md border border-border bg-inset sm:h-40 sm:w-40"> | |
| 25 | − {asset.heroImageUrl ? <Image src={asset.heroImageUrl} alt={asset.title} fill sizes="160px" className="object-contain" priority /> : <span className="absolute inset-0 flex items-center justify-center text-[10px] uppercase tracking-wider text-subtle">No image</span>} | |
| 26 | − </div> | |
| 45 | + <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start"> | |
| 46 | + {/* identity */} | |
| 47 | + <div className="grid gap-4 sm:grid-cols-[200px_minmax(0,1fr)] md:grid-cols-[240px_minmax(0,1fr)]"> | |
| 48 | + <AssetGallery images={images} alt={asset.title} className="aspect-[4/3] w-full sm:aspect-square" /> | |
| 27 | 49 | <div className="min-w-0"> |
| 28 | − <h1 className="text-xl font-semibold tracking-tight text-fg sm:text-2xl">{asset.title}</h1> | |
| 29 | − <div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px]"> | |
| 50 | + <h1 className="text-[22px] font-semibold leading-tight tracking-tight text-fg sm:text-2xl lg:text-[26px]">{asset.title}</h1> | |
| 51 | + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px]"> | |
| 30 | 52 | <Link href={`/markets/${asset.categorySlug}`}> |
| 31 | 53 | <Badge tone="index">{catName(asset.categorySlug)}</Badge> |
| 32 | 54 | </Link> |
@@ -37,98 +59,133 @@ export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = n | ||
| 37 | 59 | {asset.rarity ? <Badge tone="rarity">{asset.rarity}</Badge> : null} |
| 38 | 60 | {asset.verified ? <Badge tone="gain">Verified record</Badge> : null} |
| 39 | 61 | </div> |
| 40 | − <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-[12px] sm:grid-cols-3"> | |
| 41 | − {[ | |
| 42 | − ['Set', asset.setName ? <Link key="s" href={asset.setSlug ? `/set/${asset.setSlug}` : '#'} className="hover:underline">{asset.setName}{asset.setCode ? ` (${asset.setCode})` : ''}</Link> : null], | |
| 43 | − ['Number', asset.number], | |
| 44 | − ['Brand', asset.brand ? <Link key="b" href={`/brand/${encodeURIComponent(asset.brand)}`} className="hover:underline">{asset.brand}</Link> : null], | |
| 45 | − ['Franchise', asset.franchise], | |
| 46 | − ['Reference', asset.reference], | |
| 47 | − ['Model', asset.model], | |
| 48 | − ['Release', asset.releaseDate], | |
| 49 | − ['Production', asset.productionQuantity != null ? fmtNum(asset.productionQuantity) : null], | |
| 50 | − ['Original MSRP', asset.originalMsrp != null ? fmtMoney(asset.originalMsrp, asset.originalMsrpCurrency ?? 'USD') : null], | |
| 51 | − ] | |
| 52 | − .filter(([, val]) => val) | |
| 53 | − .map(([k, val]) => ( | |
| 54 | − <div key={k as string} className="min-w-0"> | |
| 62 | + {facts.length ? ( | |
| 63 | + <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[12px] sm:grid-cols-3"> | |
| 64 | + {facts.slice(0, 9).map(([k, val]) => ( | |
| 65 | + <div key={k} className="min-w-0"> | |
| 55 | 66 | <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt> |
| 56 | 67 | <dd className="truncate text-fg">{val}</dd> |
| 57 | 68 | </div> |
| 58 | 69 | ))} |
| 59 | − </dl> | |
| 70 | + </dl> | |
| 71 | + ) : null} | |
| 60 | 72 | {variants.length ? ( |
| 61 | − <div className="mt-3"> | |
| 62 | − <p className="mb-1 text-[10px] font-medium uppercase tracking-wider text-subtle">Variant / grade</p> | |
| 63 | − <div className="flex flex-wrap gap-1"> | |
| 64 | − <Link href={tabHref(null)} scroll={false} className={cn('rounded-md border px-2 py-1 text-[11px]', !v ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:text-fg')}> | |
| 73 | + <div className="mt-4"> | |
| 74 | + <p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-subtle">Variant / grade</p> | |
| 75 | + <div className="scrollbar-none -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0"> | |
| 76 | + <Chip href={tabHref(null)} active={!v}> | |
| 65 | 77 | All variants |
| 66 | − </Link> | |
| 67 | − {variants.slice(0, 12).map((x) => ( | |
| 68 | − <Link key={x.id} href={tabHref(x.id)} scroll={false} className={cn('rounded-md border px-2 py-1 text-[11px]', v?.id === x.id ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:text-fg')} title={`${x.salesCount} sales`}> | |
| 78 | + </Chip> | |
| 79 | + {variants.slice(0, 16).map((x) => ( | |
| 80 | + <Chip key={x.id} href={tabHref(x.id)} active={v?.id === x.id} title={`${x.salesCount} sales`}> | |
| 69 | 81 | {x.label} |
| 70 | − {x.salesCount ? <span className="num ml-1 opacity-70">{x.salesCount}</span> : null} | |
| 71 | − </Link> | |
| 82 | + {x.salesCount ? <span className={cn('num ml-1.5 rounded-sm px-1 text-[10px]', v?.id === x.id ? 'bg-white/20' : 'bg-inset text-subtle')}>{x.salesCount}</span> : null} | |
| 83 | + </Chip> | |
| 72 | 84 | ))} |
| 73 | 85 | </div> |
| 74 | 86 | </div> |
| 75 | 87 | ) : null} |
| 88 | + <div className="mt-4 hidden md:block"> | |
| 89 | + <AssetActions assetId={asset.id} slug={asset.slug} watched={watched} /> | |
| 90 | + </div> | |
| 91 | + </div> | |
| 92 | + <div className="hidden sm:col-span-2 lg:block"> | |
| 93 | + <VariantTable variants={variants} href={tabHref} activeId={v?.id ?? null} /> | |
| 76 | 94 | </div> |
| 77 | 95 | </div> |
| 96 | + | |
| 97 | + {/* valuation panel */} | |
| 78 | 98 | <aside className="card p-4"> |
| 79 | 99 | <div className="flex items-baseline justify-between"> |
| 80 | 100 | <span className="text-[10px] font-semibold uppercase tracking-wider text-subtle">RareIndex Valuation{v ? ` · ${v.label}` : ''}</span> |
| 81 | − <span className="text-[10px] text-subtle">RIV</span> | |
| 101 | + <Badge tone={confTone}>{confLabel === 'Insufficient' ? 'No RIV' : `${confLabel} confidence`}</Badge> | |
| 82 | 102 | </div> |
| 83 | 103 | {riv !== null ? ( |
| 84 | 104 | <> |
| 85 | − <div className="num mt-1 text-3xl font-semibold tracking-tight text-fg">{fmtMoney(riv)}</div> | |
| 86 | − <div className="num text-[12px] text-muted"> | |
| 87 | − Range {fmtMoney(low)} – {fmtMoney(high)} | |
| 105 | + <div className="mt-1 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(riv)}</div> | |
| 106 | + <div className="num mt-1.5 text-[12px] text-muted"> | |
| 107 | + Range {fmtMoney(low)} – {fmtMoney(high)} · <span className="text-fg">{fmtNum(n)}</span> sales used · updated {fmtRelative(asset.updatedAt)} | |
| 88 | 108 | </div> |
| 109 | + <RangeBar low={low} high={high} value={riv} latest={latest} /> | |
| 89 | 110 | </> |
| 90 | 111 | ) : guide ? ( |
| 91 | 112 | <div className="mt-1"> |
| 92 | − <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">No RIV yet · latest guide price</div> | |
| 93 | − <div className="num mt-0.5 text-3xl font-semibold tracking-tight text-fg">{fmtMoney(guide.priceUsd)}</div> | |
| 94 | − <p className="num text-[11px] text-muted"> | |
| 113 | + <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">Latest guide price</div> | |
| 114 | + <div className="mt-0.5 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(guide.priceUsd)}</div> | |
| 115 | + <p className="num mt-1.5 text-[11px] text-muted"> | |
| 95 | 116 | {guide.sourceName} · {guide.priceKind.replace(/_/g, ' ')} |
| 96 | 117 | {guide.currency !== 'USD' ? ` · ${fmtMoney(guide.price, guide.currency)}` : ''} |
| 97 | 118 | {guide.variantLabel ? ` · ${guide.variantLabel}` : ''} · observed {fmtDate(guide.observationDate)} |
| 98 | 119 | </p> |
| 99 | − <p className="mt-1 text-[11px] leading-snug text-subtle">A price-guide observation, not a transaction and not a RareIndex valuation. RIV publishes once enough verified sales exist.</p> | |
| 120 | + <p className="mt-1.5 text-[11px] leading-snug text-subtle">A price-guide observation, not a transaction and not a RareIndex valuation. RIV publishes once enough verified sales exist.</p> | |
| 100 | 121 | </div> |
| 101 | 122 | ) : asset.minAskUsd !== null ? ( |
| 102 | 123 | <div className="mt-1"> |
| 103 | − <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">No RIV yet · lowest current ask</div> | |
| 104 | − <div className="num mt-0.5 text-3xl font-semibold tracking-tight text-fg">{fmtMoney(asset.minAskUsd)}</div> | |
| 105 | − <p className="mt-1 text-[11px] leading-snug text-subtle">An asking price across {fmtNum(asset.activeListings)} live listing{asset.activeListings === 1 ? '' : 's'} — not a confirmed transaction.</p> | |
| 124 | + <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">Lowest current ask</div> | |
| 125 | + <div className="mt-0.5 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(asset.minAskUsd)}</div> | |
| 126 | + <p className="mt-1.5 text-[11px] leading-snug text-subtle">An asking price across {fmtNum(asset.activeListings)} live listing{asset.activeListings === 1 ? '' : 's'} — not a confirmed transaction.</p> | |
| 106 | 127 | </div> |
| 107 | 128 | ) : ( |
| 108 | 129 | <div className="mt-1"> |
| 109 | − <div className="text-2xl font-semibold text-subtle">Data unavailable</div> | |
| 110 | − <p className="text-[11px] text-muted">Catalogued, but no verified sale, guide price or listing has been observed yet. Watch this asset to be notified when evidence arrives.</p> | |
| 130 | + <div className="text-xl font-semibold text-subtle">Data unavailable</div> | |
| 131 | + <p className="mt-1 text-[11px] leading-snug text-muted">Catalogued, but no verified sale, guide price or listing has been observed yet. Watch this asset to be notified when evidence arrives.</p> | |
| 111 | 132 | </div> |
| 112 | 133 | )} |
| 113 | − <Evidence className="mt-2" confidence={conf} sampleSize={n} updatedAt={asset.updatedAt} /> | |
| 114 | − <dl className="mt-3 grid grid-cols-2 gap-3 border-t border-border pt-3"> | |
| 115 | − <Stat label="Latest sale" value={latest !== null ? fmtMoney(latest) : '—'} sub={latestAt ? fmtRelative(latestAt) : undefined} /> | |
| 116 | − <Stat label="30D change" value={<Delta value={v ? v.change30d : asset.change30d} />} /> | |
| 117 | − <Stat label="1Y change" value={<Delta value={v ? v.change1y : asset.change1y} />} /> | |
| 118 | − <Stat label="1D change" value={<Delta value={asset.change1d} />} /> | |
| 119 | − <Stat label="All-time high" value={asset.athUsd !== null ? fmtMoney(asset.athUsd) : '—'} sub={asset.athAt ? fmtDate(asset.athAt) : undefined} /> | |
| 120 | − <Stat label="All-time low" value={asset.atlUsd !== null ? fmtMoney(asset.atlUsd) : '—'} sub={asset.atlAt ? fmtDate(asset.atlAt) : undefined} /> | |
| 121 | − <Stat label="Active listings" value={fmtNum(v ? v.activeListings : asset.activeListings)} sub={(v ? v.minAskUsd : asset.minAskUsd) !== null ? `low ask ${fmtMoney(v ? v.minAskUsd : asset.minAskUsd)}` : undefined} /> | |
| 122 | − <Stat label="Sales 30D" value={fmtNum(v ? v.sales30d : asset.sales30d)} sub={`${fmtNum(v ? v.salesCount : asset.salesCount)} all time`} /> | |
| 134 | + | |
| 135 | + <dl className="mt-4 grid grid-cols-2 gap-x-3 gap-y-3 border-t border-border pt-3 sm:grid-cols-4 lg:grid-cols-2"> | |
| 136 | + <Metric label="Latest sale" value={latest !== null ? fmtMoney(latest) : null} sub={latestAt ? fmtRelative(latestAt) : undefined} /> | |
| 137 | + <Metric label="1D · 30D · 1Y" value={<span className="flex flex-wrap gap-x-1.5"><Delta value={asset.change1d} digits={1} /><Delta value={v ? v.change30d : asset.change30d} digits={1} /><Delta value={v ? v.change1y : asset.change1y} digits={1} /></span>} /> | |
| 138 | + <Metric label="All-time high" value={asset.athUsd !== null ? fmtMoney(asset.athUsd) : null} sub={asset.athAt ? fmtDate(asset.athAt) : undefined} /> | |
| 139 | + <Metric label="All-time low" value={asset.atlUsd !== null ? fmtMoney(asset.atlUsd) : null} sub={asset.atlAt ? fmtDate(asset.atlAt) : undefined} /> | |
| 140 | + <Metric label="Active listings" value={fmtNum(v ? v.activeListings : asset.activeListings)} sub={(v ? v.minAskUsd : asset.minAskUsd) !== null ? `low ask ${fmtMoney(v ? v.minAskUsd : asset.minAskUsd)}` : undefined} /> | |
| 141 | + <Metric label="Sales 30D" value={fmtNum(v ? v.sales30d : asset.sales30d)} sub={`${fmtNum(v ? v.salesCount : asset.salesCount)} all time`} /> | |
| 123 | 142 | </dl> |
| 124 | − <div className="mt-3 grid grid-cols-1 gap-2 border-t border-border pt-3"> | |
| 143 | + <div className="mt-3 grid grid-cols-1 gap-2.5 border-t border-border pt-3 sm:grid-cols-3 lg:grid-cols-1"> | |
| 125 | 144 | <ScoreMeter label="Liquidity" value={v ? v.liquidityScore : asset.liquidityScore} /> |
| 126 | 145 | <ScoreMeter label="Rarity" value={asset.rarityScore} tone="rarity" /> |
| 127 | 146 | <ScoreMeter label="Data quality" value={asset.dataQuality} tone="gain" /> |
| 128 | 147 | </div> |
| 129 | − <p className="mt-2 text-[10px] leading-relaxed text-subtle">Valuation is an estimate ({confidenceLabel(conf)} confidence) from observed sales, not an offer or appraisal. RareIndex does not authenticate items.</p> | |
| 148 | + <p className="mt-3 text-[10px] leading-relaxed text-subtle">Estimate from observed public sales, not an offer or appraisal. RareIndex does not authenticate items.</p> | |
| 130 | 149 | </aside> |
| 131 | 150 | </div> |
| 151 | + <AssetActions assetId={asset.id} slug={asset.slug} watched={watched} variant="bar" /> | |
| 132 | 152 | </header> |
| 133 | 153 | ); |
| 134 | 154 | } |
| 155 | + | |
| 156 | +function Chip({ href, active, children, title }: { href: string; active: boolean; children: React.ReactNode; title?: string }) { | |
| 157 | + return ( | |
| 158 | + <Link href={href} scroll={false} title={title} className={cn('inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full border px-3 text-[12px] font-medium', active ? 'border-fg bg-accent text-accent-fg' : 'border-border bg-elevated text-muted hover:border-border-strong hover:text-fg')} aria-current={active ? 'true' : undefined}> | |
| 159 | + {children} | |
| 160 | + </Link> | |
| 161 | + ); | |
| 162 | +} | |
| 163 | + | |
| 164 | +function Metric({ label, value, sub }: { label: string; value: React.ReactNode | null; sub?: string }) { | |
| 165 | + return ( | |
| 166 | + <div className="min-w-0"> | |
| 167 | + <dt className="text-[10px] font-medium uppercase tracking-wider text-subtle">{label}</dt> | |
| 168 | + <dd className="num mt-0.5 truncate text-[15px] font-semibold leading-tight text-fg">{value ?? <span className="text-subtle" title="Data unavailable">—</span>}</dd> | |
| 169 | + {sub ? <dd className="truncate text-[11px] text-muted">{sub}</dd> : null} | |
| 170 | + </div> | |
| 171 | + ); | |
| 172 | +} | |
| 173 | + | |
| 174 | +/** Low–high band with RIV and latest sale markers (a meter, not a chart: shows where the estimate sits). */ | |
| 175 | +function RangeBar({ low, high, value, latest }: { low: number | null; high: number | null; value: number; latest: number | null }) { | |
| 176 | + if (low === null || high === null || high <= low) return null; | |
| 177 | + const pos = (x: number) => `${Math.max(2, Math.min(98, ((x - low) / (high - low)) * 100))}%`; | |
| 178 | + return ( | |
| 179 | + <div className="mt-3" aria-hidden> | |
| 180 | + <div className="relative h-1.5 w-full rounded-full bg-inset"> | |
| 181 | + <div className="absolute inset-y-0 rounded-full" style={{ left: '0%', right: '0%', background: 'var(--ri-index-bg)' }} /> | |
| 182 | + <span className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-elevated" style={{ left: pos(value), background: 'var(--ri-index)' }} title="RIV" /> | |
| 183 | + {latest !== null && latest >= low && latest <= high ? <span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-elevated" style={{ left: pos(latest), background: 'var(--ri-series-2, #c2410c)' }} title="Latest sale" /> : null} | |
| 184 | + </div> | |
| 185 | + <div className="num mt-1 flex justify-between text-[10px] text-subtle"> | |
| 186 | + <span>low {fmtMoney(low)}</span> | |
| 187 | + <span>high {fmtMoney(high)}</span> | |
| 188 | + </div> | |
| 189 | + </div> | |
| 190 | + ); | |
| 191 | +} | |
modified
apps/web/src/components/asset/asset-tabs.tsx
+27 −20
@@ -1,12 +1,14 @@ | ||
| 1 | 1 | import Image from 'next/image'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import type { AssetDetail, VariantRow } from '@/lib/queries/assets'; |
| 4 | −import { getAssetImages, getAssetListings, getAssetObservations, getAssetSalePoints, getAssetSales, getAssetSnapshots, getAssetSources, getComparables, getGradeDistribution, getLatestValuation, getMarketplaceDistribution, getPopulation, getPriceDistribution, getSetSiblings, getSimilarAssets, attachGuidePrices } from '@/lib/queries/assets'; | |
| 4 | +import { getAssetImages, getAssetListings, getAssetObservations, getAssetSalePoints, getAssetSales, getAssetSnapshots, getAssetSources, getComparables, getGradeDistribution, getLatestValuation, getMarketplaceDistribution, getPopulation, getPriceDistribution, getSetSiblings, getSimilarAssets, attachGuidePrices, getValuationHistory } from '@/lib/queries/assets'; | |
| 5 | 5 | import { AssetCardGrid } from '@/components/market/asset-list'; |
| 6 | 6 | import { fmtDate, fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format'; |
| 7 | 7 | import { gradeLabel, humanize, catName } from '@/lib/taxonomy'; |
| 8 | 8 | import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge, Delta } from '@/components/ui/primitives'; |
| 9 | 9 | import { LineChart } from '@/components/charts/line-chart'; |
| 10 | +import { PriceChart } from '@/components/charts/price-chart'; | |
| 11 | +import { SalesCards, ListingsCards } from './sales-cards'; | |
| 10 | 12 | import { BarChart } from '@/components/charts/bar-chart'; |
| 11 | 13 | import { SalesTable, ListingsTable } from '@/components/market/sales-table'; |
| 12 | 14 | import { SourceLink, Thumb } from '@/components/market/bits'; |
@@ -17,8 +19,8 @@ export type AssetTab = (typeof ASSET_TABS)[number]; | ||
| 17 | 19 | |
| 18 | 20 | export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) { |
| 19 | 21 | const vid = variant?.id ?? ''; |
| 20 | − const [snaps, points, dist, sales, listings, obs, valuation, siblings, similar] = await Promise.all([ | |
| 21 | − getAssetSnapshots(asset.id, vid, 730), | |
| 22 | + const [snaps, points, dist, sales, listings, obs, valuation, siblings, similar, valHist] = await Promise.all([ | |
| 23 | + getAssetSnapshots(asset.id, vid), | |
| 22 | 24 | getAssetSalePoints(asset.id), |
| 23 | 25 | getPriceDistribution(asset.id, variant?.id ?? null), |
| 24 | 26 | getAssetSales(asset.id, { variantId: variant?.id ?? null, limit: 8 }), |
@@ -27,7 +29,9 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari | ||
| 27 | 29 | getLatestValuation(asset.id, variant?.id ?? null), |
| 28 | 30 | getSetSiblings(asset, 8).then(attachGuidePrices), |
| 29 | 31 | getSimilarAssets(asset, 8).then(attachGuidePrices), |
| 32 | + getValuationHistory(asset.id, variant?.id ?? null), | |
| 30 | 33 | ]); |
| 34 | + const bandByDate = new Map(valHist.map((h) => [h.date, h])); | |
| 31 | 35 | const similarOnly = similar.filter((x) => !siblings.some((y) => y.id === x.id)); |
| 32 | 36 | const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points; |
| 33 | 37 | const rivSeries = snaps.filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! })); |
@@ -46,18 +50,16 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari | ||
| 46 | 50 | Full history → |
| 47 | 51 | </Link> |
| 48 | 52 | </div> |
| 49 | − <LineChart | |
| 53 | + <PriceChart | |
| 50 | 54 | ariaLabel={`Price history for ${asset.title}`} |
| 51 | − height={300} | |
| 52 | − currency | |
| 55 | + height={320} | |
| 53 | 56 | className="mt-3" |
| 57 | + defaultPeriod={scatter.length > 30 || rivSeries.length > 400 ? '1Y' : 'ALL'} | |
| 54 | 58 | reference={riv !== null ? { value: riv, label: `RIV ${fmtMoney(riv)}` } : null} |
| 55 | − series={[ | |
| 56 | − { id: 'sales', label: 'Sales', kind: 'dots', points: scatter.map((p) => ({ x: p.date, y: p.usd, meta: `${gradeLabel(p.grader, p.grade) ?? 'raw'} · ${p.sourceName}` })) }, | |
| 57 | − ...(rivSeries.length ? [{ id: 'riv', label: 'RIV', points: rivSeries }] : []), | |
| 58 | − ...(askSeries.length ? [{ id: 'ask', label: 'Lowest ask', dashed: true, points: askSeries }] : []), | |
| 59 | − ...(obsSeries.length ? [{ id: 'obs', label: 'Guide price', dashed: true, points: obsSeries }] : []), | |
| 60 | − ]} | |
| 59 | + sales={scatter.map((p) => ({ date: p.date, usd: p.usd, label: `${gradeLabel(p.grader, p.grade) ?? 'raw'} · ${p.sourceName}`, variantId: p.variantId }))} | |
| 60 | + riv={rivSeries.map((p) => { const b = bandByDate.get(p.x); return { x: p.x, y: p.y, low: b?.lowUsd ?? null, high: b?.highUsd ?? null }; })} | |
| 61 | + ask={askSeries} | |
| 62 | + guide={obsSeries} | |
| 61 | 63 | emptyLabel="No priced observations yet for this asset" |
| 62 | 64 | /> |
| 63 | 65 | </Card> |
@@ -141,11 +143,13 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari | ||
| 141 | 143 | <div className="grid gap-4 lg:grid-cols-2"> |
| 142 | 144 | <Card className="overflow-hidden"> |
| 143 | 145 | <CardHeader title="Recent sales" action={<Link href={`?tab=sales${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All {sales.total} →</Link>} /> |
| 144 | − <SalesTable items={sales.items} showAsset={false} /> | |
| 146 | + <div className="md:hidden"><SalesCards items={sales.items} /></div> | |
| 147 | + <div className="hidden md:block"><SalesTable items={sales.items} showAsset={false} /></div> | |
| 145 | 148 | </Card> |
| 146 | 149 | <Card className="overflow-hidden"> |
| 147 | 150 | <CardHeader title="Live listings" subtitle="Asking prices, not market value" action={<Link href={`?tab=listings${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All →</Link>} /> |
| 148 | − <ListingsTable items={listings} showAsset={false} /> | |
| 151 | + <div className="md:hidden"><ListingsCards items={listings} /></div> | |
| 152 | + <div className="hidden md:block"><ListingsTable items={listings} showAsset={false} /></div> | |
| 149 | 153 | </Card> |
| 150 | 154 | </div> |
| 151 | 155 | {asset.description ? ( |
@@ -188,20 +192,21 @@ export async function SalesTab({ asset, variant, page }: { asset: AssetDetail; v | ||
| 188 | 192 | return ( |
| 189 | 193 | <Card className="overflow-hidden"> |
| 190 | 194 | <CardHeader title={`Sales${variant ? ` · ${variant.label}` : ''}`} subtitle={`${res.total.toLocaleString('en-US')} observed transactions · flagged rows are kept for audit (§116) and excluded from valuation`} /> |
| 191 | − <SalesTable items={res.items} showAsset={false} /> | |
| 195 | + <div className="md:hidden"><SalesCards items={res.items} /></div> | |
| 196 | + <div className="hidden md:block"><SalesTable items={res.items} showAsset={false} /></div> | |
| 192 | 197 | {res.total > pageSize ? ( |
| 193 | − <div className="flex items-center justify-between px-4 py-2 text-xs text-muted"> | |
| 198 | + <div className="flex items-center justify-between px-4 py-3 text-xs text-muted"> | |
| 194 | 199 | <span className="num"> |
| 195 | 200 | page {page} / {Math.ceil(res.total / pageSize)} |
| 196 | 201 | </span> |
| 197 | 202 | <span className="flex gap-2"> |
| 198 | 203 | {page > 1 ? ( |
| 199 | − <Link href={`?tab=sales&page=${page - 1}${variant ? `&v=${variant.id}` : ''}`} className="hover:text-fg"> | |
| 204 | + <Link href={`?tab=sales&page=${page - 1}${variant ? `&v=${variant.id}` : ''}`} className="inline-flex h-9 items-center rounded-md border border-border px-3 font-medium text-fg hover:bg-inset"> | |
| 200 | 205 | ← Newer |
| 201 | 206 | </Link> |
| 202 | 207 | ) : null} |
| 203 | 208 | {page * pageSize < res.total ? ( |
| 204 | − <Link href={`?tab=sales&page=${page + 1}${variant ? `&v=${variant.id}` : ''}`} className="hover:text-fg"> | |
| 209 | + <Link href={`?tab=sales&page=${page + 1}${variant ? `&v=${variant.id}` : ''}`} className="inline-flex h-9 items-center rounded-md border border-border px-3 font-medium text-fg hover:bg-inset"> | |
| 205 | 210 | Older → |
| 206 | 211 | </Link> |
| 207 | 212 | ) : null} |
@@ -219,12 +224,14 @@ export async function ListingsTab({ asset, variant }: { asset: AssetDetail; vari | ||
| 219 | 224 | <div className="grid gap-4"> |
| 220 | 225 | <Card className="overflow-hidden"> |
| 221 | 226 | <CardHeader title="Active listings" subtitle="Sorted by asking price (USD). Cross-listings of the same physical item are grouped when detected." /> |
| 222 | − <ListingsTable items={live} showAsset={false} /> | |
| 227 | + <div className="md:hidden"><ListingsCards items={live} /></div> | |
| 228 | + <div className="hidden md:block"><ListingsTable items={live} showAsset={false} /></div> | |
| 223 | 229 | </Card> |
| 224 | 230 | {ended.length ? ( |
| 225 | 231 | <Card className="overflow-hidden"> |
| 226 | 232 | <CardHeader title="Ended / removed listings" subtitle="Listing lifecycle is retained (new → price changed → sold/removed)" /> |
| 227 | − <ListingsTable items={ended} showAsset={false} /> | |
| 233 | + <div className="md:hidden"><ListingsCards items={ended} /></div> | |
| 234 | + <div className="hidden md:block"><ListingsTable items={ended} showAsset={false} /></div> | |
| 228 | 235 | </Card> |
| 229 | 236 | ) : null} |
| 230 | 237 | </div> |
added
apps/web/src/components/asset/sales-cards.tsx
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +import type { SaleRow, ListingRow } from '@/lib/queries/assets'; | |
| 2 | +import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format'; | |
| 3 | +import { humanize } from '@/lib/taxonomy'; | |
| 4 | +import { Badge, Delta, EmptyState } from '@/components/ui/primitives'; | |
| 5 | +import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits'; | |
| 6 | + | |
| 7 | +/** Phone-first card lists for sales and listings (tables stay for md+). */ | |
| 8 | +export function SalesCards({ items, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.' }: { items: SaleRow[]; className?: string; emptyDescription?: string }) { | |
| 9 | + if (!items.length) return <EmptyState title="No sales recorded" description={emptyDescription} />; | |
| 10 | + return ( | |
| 11 | + <ul className={cn('divide-y divide-border', className)}> | |
| 12 | + {items.map((s) => ( | |
| 13 | + <li key={s.id} className="flex items-start gap-3 px-3 py-2.5"> | |
| 14 | + <Thumb src={s.imageUrls[0]} alt="" size={44} rounded="rounded-md" /> | |
| 15 | + <div className="min-w-0 flex-1"> | |
| 16 | + <div className="flex items-baseline justify-between gap-2"> | |
| 17 | + <span className="num text-[15px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span> | |
| 18 | + <span className="text-[11px] text-muted" title={s.saleDate.toISOString()}> | |
| 19 | + {fmtDate(s.saleDate)} | |
| 20 | + </span> | |
| 21 | + </div> | |
| 22 | + {s.price !== null && s.currency && s.currency !== 'USD' ? <div className="num text-[10px] text-subtle">{fmtMoney(s.price, s.currency)} native</div> : null} | |
| 23 | + <p className="mt-0.5 truncate text-[12px] text-muted" title={s.rawTitle}> | |
| 24 | + {s.rawTitle} | |
| 25 | + </p> | |
| 26 | + <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted"> | |
| 27 | + <GradeBadge grader={s.grader} grade={s.grade} /> | |
| 28 | + {s.condition ? <span>{humanize(s.condition)}</span> : null} | |
| 29 | + <span>{humanize(s.saleType)}</span> | |
| 30 | + <SourceLink name={s.sourceName} url={s.sourceUrl} /> | |
| 31 | + {s.status !== 'valid' ? <Badge tone="alert">{s.status}</Badge> : null} | |
| 32 | + </div> | |
| 33 | + </div> | |
| 34 | + </li> | |
| 35 | + ))} | |
| 36 | + </ul> | |
| 37 | + ); | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function ListingsCards({ items, className, emptyDescription = 'Live listings appear once marketplace connectors run.' }: { items: ListingRow[]; className?: string; emptyDescription?: string }) { | |
| 41 | + if (!items.length) return <EmptyState title="No active listings" description={emptyDescription} />; | |
| 42 | + return ( | |
| 43 | + <ul className={cn('divide-y divide-border', className)}> | |
| 44 | + {items.map((l) => ( | |
| 45 | + <li key={l.id} className="flex items-start gap-3 px-3 py-2.5"> | |
| 46 | + <Thumb src={l.imageUrls[0]} alt="" size={44} rounded="rounded-md" /> | |
| 47 | + <div className="min-w-0 flex-1"> | |
| 48 | + <div className="flex items-baseline justify-between gap-2"> | |
| 49 | + <span className="num text-[15px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span> | |
| 50 | + {l.discountToRiv !== null ? ( | |
| 51 | + <span className="inline-flex items-center gap-1 text-[11px]"> | |
| 52 | + <span className="text-subtle">vs RIV</span> | |
| 53 | + <Delta value={-l.discountToRiv} /> | |
| 54 | + </span> | |
| 55 | + ) : ( | |
| 56 | + <span className="text-[11px] text-subtle">{fmtRelative(l.lastSeenAt)}</span> | |
| 57 | + )} | |
| 58 | + </div> | |
| 59 | + {l.price !== null && l.currency && l.currency !== 'USD' ? <div className="num text-[10px] text-subtle">{fmtMoney(l.price, l.currency)} native</div> : null} | |
| 60 | + <p className="mt-0.5 truncate text-[12px] text-muted" title={l.rawTitle}> | |
| 61 | + {l.rawTitle} | |
| 62 | + </p> | |
| 63 | + <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted"> | |
| 64 | + <GradeBadge grader={l.grader} grade={l.grade} /> | |
| 65 | + <span>{humanize(l.listingType)}</span> | |
| 66 | + {l.seller ? <span className="truncate">{l.seller}</span> : null} | |
| 67 | + {l.location ? <span className="text-subtle">{l.location}</span> : null} | |
| 68 | + <SourceLink name={l.sourceName} url={l.sourceUrl} /> | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + </li> | |
| 72 | + ))} | |
| 73 | + </ul> | |
| 74 | + ); | |
| 75 | +} | |
added
apps/web/src/components/asset/variant-table.tsx
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import type { VariantRow } from '@/lib/queries/assets'; | |
| 3 | +import { fmtMoney, fmtNum, fmtRelative, cn, confidenceLabel } from '@/lib/format'; | |
| 4 | +import { Table, th, td, tdNum, Badge } from '@/components/ui/primitives'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Dense variant/grade matrix for md+ screens (phones use the chip selector): RIV with its band and | |
| 8 | + * confidence, latest sale, sales and listings per variant. Rows link to the variant view. | |
| 9 | + */ | |
| 10 | +export function VariantTable({ variants, href, activeId, className }: { variants: VariantRow[]; href: (id: string | null) => string; activeId: string | null; className?: string }) { | |
| 11 | + const rows = variants.slice(0, 8); | |
| 12 | + if (rows.length < 2) return null; | |
| 13 | + return ( | |
| 14 | + <div className={cn('card overflow-hidden', className)}> | |
| 15 | + <div className="flex items-baseline justify-between border-b border-border px-3 py-2"> | |
| 16 | + <h2 className="text-[12px] font-semibold text-fg">By variant / grade</h2> | |
| 17 | + <span className="text-[10px] text-subtle">{variants.length} tracked · click a row to focus</span> | |
| 18 | + </div> | |
| 19 | + <Table> | |
| 20 | + <thead> | |
| 21 | + <tr> | |
| 22 | + <th className={th}>Variant</th> | |
| 23 | + <th className={cn(th, 'text-right')}>RIV</th> | |
| 24 | + <th className={cn(th, 'text-right')}>Range</th> | |
| 25 | + <th className={th}>Confidence</th> | |
| 26 | + <th className={cn(th, 'text-right')}>Latest sale</th> | |
| 27 | + <th className={cn(th, 'text-right')}>Sales</th> | |
| 28 | + <th className={cn(th, 'text-right')}>Listings</th> | |
| 29 | + </tr> | |
| 30 | + </thead> | |
| 31 | + <tbody> | |
| 32 | + {rows.map((v) => { | |
| 33 | + const label = confidenceLabel(v.rivConfidence); | |
| 34 | + return ( | |
| 35 | + <tr key={v.id} className={cn('hover:bg-sunken', activeId === v.id && 'bg-inset')}> | |
| 36 | + <td className={cn(td, 'font-medium')}> | |
| 37 | + <Link href={href(v.id)} scroll={false} className="hover:underline"> | |
| 38 | + {v.label} | |
| 39 | + </Link> | |
| 40 | + </td> | |
| 41 | + <td className={cn(tdNum, 'font-semibold')}>{v.rivUsd === null ? <span className="text-subtle">—</span> : fmtMoney(v.rivUsd)}</td> | |
| 42 | + <td className={cn(tdNum, 'text-muted')}>{v.rivLowUsd !== null && v.rivHighUsd !== null ? `${fmtMoney(v.rivLowUsd)} – ${fmtMoney(v.rivHighUsd)}` : <span className="text-subtle">—</span>}</td> | |
| 43 | + <td className={td}>{v.rivUsd === null ? <span className="text-subtle">—</span> : <Badge tone={label === 'High' ? 'gain' : label === 'Medium' ? 'index' : label === 'Low' ? 'alert' : 'neutral'}>{label} · n={v.rivSampleSize}</Badge>}</td> | |
| 44 | + <td className={tdNum}> | |
| 45 | + {v.latestSaleUsd === null ? <span className="text-subtle">—</span> : fmtMoney(v.latestSaleUsd)} | |
| 46 | + {v.latestSaleAt ? <span className="block text-[10px] text-subtle">{fmtRelative(v.latestSaleAt)}</span> : null} | |
| 47 | + </td> | |
| 48 | + <td className={tdNum}>{fmtNum(v.salesCount)}</td> | |
| 49 | + <td className={tdNum}>{fmtNum(v.activeListings)}</td> | |
| 50 | + </tr> | |
| 51 | + ); | |
| 52 | + })} | |
| 53 | + </tbody> | |
| 54 | + </Table> | |
| 55 | + </div> | |
| 56 | + ); | |
| 57 | +} | |
modified
apps/web/src/components/charts/line-chart.tsx
+38 −16
@@ -105,11 +105,11 @@ export function LineChart({ series, height = 260, currency = false, indexed = fa | ||
| 105 | 105 | const yPad = (yMax - yMin) * 0.08; |
| 106 | 106 | yMin -= yPad; |
| 107 | 107 | yMax += yPad; |
| 108 | − const ticks = niceTicks(yMin, yMax, 4); | |
| 109 | − const sx = (x: number) => pad.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw); | |
| 110 | − const sy = (y: number) => pad.top + ih - ((y - yMin) / (yMax - yMin)) * ih; | |
| 108 | + const ticks = niceTicks(yMin, yMax, W < 480 ? 3 : 4); | |
| 109 | + const sx = (x: number) => Math.round((pad.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw)) * 100) / 100; | |
| 110 | + const sy = (y: number) => Math.round((pad.top + ih - ((y - yMin) / (yMax - yMin)) * ih) * 100) / 100; | |
| 111 | 111 | const span = xMax - xMin; |
| 112 | − const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.floor(iw / 110))); | |
| 112 | + const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 100)))); | |
| 113 | 113 | const fmtY = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%` : compactNumber(v, { currency })); |
| 114 | 114 | const fmtYFull = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(2)}%` : fullNumber(v, { currency })); |
| 115 | 115 | |
@@ -134,10 +134,11 @@ export function LineChart({ series, height = 260, currency = false, indexed = fa | ||
| 134 | 134 | const linePath = (pts: Array<{ x: number; y: number }>, step = false) => |
| 135 | 135 | pts.map((p, i) => (i === 0 ? `M${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}` : step ? `H${sx(p.x).toFixed(1)}V${sy(p.y).toFixed(1)}` : `L${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`)).join(' '); |
| 136 | 136 | |
| 137 | − const tooltipLeft = hover !== null ? Math.min(Math.max(hover + 12, pad.left), W - 200) : 0; | |
| 137 | + const narrow = W < 560; | |
| 138 | + const tooltipLeft = hover !== null ? (narrow ? pad.left : Math.min(Math.max(hover + 12, pad.left), W - 200)) : 0; | |
| 138 | 139 | |
| 139 | 140 | return ( |
| 140 | − <div ref={ref} className={`relative ${className ?? ''}`}> | |
| 141 | + <div ref={ref} className={`relative min-w-0 max-w-full overflow-hidden ${className ?? ''}`}> | |
| 141 | 142 | {prepared.length > 1 ? ( |
| 142 | 143 | <ul className="mb-1 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend"> |
| 143 | 144 | {prepared.map((s) => ( |
@@ -149,26 +150,28 @@ export function LineChart({ series, height = 260, currency = false, indexed = fa | ||
| 149 | 150 | </ul> |
| 150 | 151 | ) : null} |
| 151 | 152 | <svg |
| 152 | − width={W} | |
| 153 | + width="100%" | |
| 153 | 154 | height={H} |
| 154 | 155 | viewBox={`0 0 ${W} ${H}`} |
| 156 | + preserveAspectRatio="none" | |
| 155 | 157 | role="img" |
| 156 | 158 | aria-label={ariaLabel} |
| 157 | − className="block select-none" | |
| 158 | − onMouseMove={(e) => { | |
| 159 | + className="block max-w-full select-none" | |
| 160 | + style={{ touchAction: 'pan-y' }} | |
| 161 | + onPointerDown={(e) => { | |
| 159 | 162 | const rect = e.currentTarget.getBoundingClientRect(); |
| 160 | − const x = e.clientX - rect.left; | |
| 163 | + const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W; | |
| 161 | 164 | setHover(x >= pad.left && x <= pad.left + iw ? x : null); |
| 162 | 165 | }} |
| 163 | − onMouseLeave={() => setHover(null)} | |
| 164 | − onTouchMove={(e) => { | |
| 166 | + onPointerMove={(e) => { | |
| 167 | + if (e.pointerType !== 'mouse' && e.buttons === 0) return; | |
| 165 | 168 | const rect = e.currentTarget.getBoundingClientRect(); |
| 166 | − const t = e.touches[0]; | |
| 167 | − if (!t) return; | |
| 168 | − const x = t.clientX - rect.left; | |
| 169 | + const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W; | |
| 169 | 170 | setHover(x >= pad.left && x <= pad.left + iw ? x : null); |
| 170 | 171 | }} |
| 171 | − onTouchEnd={() => setHover(null)} | |
| 172 | + onPointerLeave={(e) => { | |
| 173 | + if (e.pointerType === 'mouse') setHover(null); | |
| 174 | + }} | |
| 172 | 175 | > |
| 173 | 176 | <defs> |
| 174 | 177 | {prepared.map((s) => ( |
@@ -245,6 +248,25 @@ export function LineChart({ series, height = 260, currency = false, indexed = fa | ||
| 245 | 248 | </div> |
| 246 | 249 | ) : null} |
| 247 | 250 | {yLabel ? <div className="mt-1 text-[10px] uppercase tracking-wider text-subtle">{yLabel}</div> : null} |
| 251 | + <table className="sr-only"> | |
| 252 | + <caption>{ariaLabel}</caption> | |
| 253 | + <thead> | |
| 254 | + <tr> | |
| 255 | + <th>Series</th> | |
| 256 | + <th>Date</th> | |
| 257 | + <th>Value</th> | |
| 258 | + </tr> | |
| 259 | + </thead> | |
| 260 | + <tbody> | |
| 261 | + {prepared.flatMap((s) => s.pts.slice(-120).map((p, i) => ( | |
| 262 | + <tr key={`${s.id}-${i}`}> | |
| 263 | + <td>{s.label}</td> | |
| 264 | + <td>{fmtDayFull(p.x)}</td> | |
| 265 | + <td>{fmtYFull(p.y)}</td> | |
| 266 | + </tr> | |
| 267 | + )))} | |
| 268 | + </tbody> | |
| 269 | + </table> | |
| 248 | 270 | </div> |
| 249 | 271 | ); |
| 250 | 272 | } |
added
apps/web/src/components/charts/price-chart.tsx
+400 −0
@@ -0,0 +1,400 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; | |
| 4 | +import { compactNumber, fmtDay, fmtDayFull, fullNumber, niceTicks, parseDay } from './scale'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Price-history chart for asset pages: verified sales (dots), daily RareIndex Valuation (line) with its | |
| 8 | + * low–high band, lowest ask and guide observations (dashed), a current-RIV reference line, period | |
| 9 | + * control (URL hash), log/linear toggle and a touch-first crosshair. Follows the dataviz spec: | |
| 10 | + * 2px lines, ≥8px markers with a surface ring, hairline solid grid, ≤5 axis ticks, text in text tokens, | |
| 11 | + * legend for ≥2 series, hidden data table for assistive tech. | |
| 12 | + */ | |
| 13 | + | |
| 14 | +export interface SalePoint { | |
| 15 | + date: string; | |
| 16 | + usd: number; | |
| 17 | + label?: string; | |
| 18 | + variantId?: string | null; | |
| 19 | +} | |
| 20 | +export interface DailyPoint { | |
| 21 | + x: string; | |
| 22 | + y: number; | |
| 23 | + low?: number | null; | |
| 24 | + high?: number | null; | |
| 25 | +} | |
| 26 | +export type Period = '1M' | '3M' | '6M' | '1Y' | '3Y' | '5Y' | 'ALL'; | |
| 27 | +const PERIODS: Array<{ id: Period; days: number | null }> = [ | |
| 28 | + { id: '1M', days: 31 }, | |
| 29 | + { id: '3M', days: 92 }, | |
| 30 | + { id: '6M', days: 183 }, | |
| 31 | + { id: '1Y', days: 366 }, | |
| 32 | + { id: '3Y', days: 1096 }, | |
| 33 | + { id: '5Y', days: 1827 }, | |
| 34 | + { id: 'ALL', days: null }, | |
| 35 | +]; | |
| 36 | + | |
| 37 | +export interface PriceChartProps { | |
| 38 | + sales: SalePoint[]; | |
| 39 | + riv?: DailyPoint[]; | |
| 40 | + ask?: DailyPoint[]; | |
| 41 | + guide?: DailyPoint[]; | |
| 42 | + reference?: { value: number; label: string } | null; | |
| 43 | + height?: number; | |
| 44 | + ariaLabel: string; | |
| 45 | + emptyLabel?: string; | |
| 46 | + /** URL hash key used to persist the period (default "p") */ | |
| 47 | + hashKey?: string; | |
| 48 | + defaultPeriod?: Period; | |
| 49 | + className?: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +const C = { | |
| 53 | + riv: 'var(--ri-series-1, #2f5bd6)', | |
| 54 | + sales: 'var(--ri-series-2, #c2410c)', | |
| 55 | + ask: 'var(--ri-series-3, #0e9384)', | |
| 56 | + guide: 'var(--ri-series-4, #7c3aed)', | |
| 57 | +}; | |
| 58 | + | |
| 59 | +function writeHash(key: string, value: string) { | |
| 60 | + if (typeof window === 'undefined') return; | |
| 61 | + const parts = window.location.hash.replace(/^#/, '').split('&').filter((kv) => kv && !kv.startsWith(`${key}=`)); | |
| 62 | + parts.push(`${key}=${encodeURIComponent(value)}`); | |
| 63 | + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${parts.join('&')}`); | |
| 64 | + for (const l of hashListeners) l(); | |
| 65 | +} | |
| 66 | +const hashListeners = new Set<() => void>(); | |
| 67 | +function subscribeHash(cb: () => void) { | |
| 68 | + hashListeners.add(cb); | |
| 69 | + window.addEventListener('hashchange', cb); | |
| 70 | + return () => { | |
| 71 | + hashListeners.delete(cb); | |
| 72 | + window.removeEventListener('hashchange', cb); | |
| 73 | + }; | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function PriceChart({ sales, riv = [], ask = [], guide = [], reference = null, height = 300, ariaLabel, emptyLabel = 'No priced observations yet for this asset', hashKey = 'p', defaultPeriod = 'ALL', className }: PriceChartProps) { | |
| 77 | + const wrap = useRef<HTMLDivElement>(null); | |
| 78 | + const [width, setWidth] = useState(720); | |
| 79 | + // Period/scale live in the URL hash (shareable, survives reloads). Read through an external store so | |
| 80 | + // the server render (no hash) and the client stay consistent without effects. | |
| 81 | + const hash = useSyncExternalStore(subscribeHash, () => (typeof window === 'undefined' ? '' : window.location.hash), () => ''); | |
| 82 | + const hashPeriod = (hash.replace(/^#/, '').split('&').find((kv) => kv.startsWith(`${hashKey}=`))?.slice(hashKey.length + 1) ?? null) as Period | null; | |
| 83 | + const period: Period = hashPeriod && PERIODS.some((x) => x.id === hashPeriod) ? hashPeriod : defaultPeriod; | |
| 84 | + const scaleChoice = hash.includes('scale=log') ? 'log' : hash.includes('scale=linear') ? 'linear' : null; | |
| 85 | + const [hover, setHover] = useState<{ px: number; sticky: boolean } | null>(null); | |
| 86 | + const uid = useId(); | |
| 87 | + | |
| 88 | + useEffect(() => { | |
| 89 | + const el = wrap.current; | |
| 90 | + if (!el) return; | |
| 91 | + const ro = new ResizeObserver((entries) => { | |
| 92 | + for (const e of entries) setWidth(Math.max(280, Math.floor(e.contentRect.width))); | |
| 93 | + }); | |
| 94 | + ro.observe(el); | |
| 95 | + return () => ro.disconnect(); | |
| 96 | + }, []); | |
| 97 | + | |
| 98 | + const data = useMemo(() => { | |
| 99 | + const s = sales.map((p) => ({ x: parseDay(p.date), y: p.usd, label: p.label ?? null })).filter((p) => Number.isFinite(p.x) && p.y > 0).sort((a, b) => a.x - b.x); | |
| 100 | + const line = (pts: DailyPoint[]) => pts.map((p) => ({ x: parseDay(p.x), y: p.y, low: p.low ?? null, high: p.high ?? null })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.y > 0).sort((a, b) => a.x - b.x); | |
| 101 | + return { sales: s, riv: line(riv), ask: line(ask), guide: line(guide) }; | |
| 102 | + }, [sales, riv, ask, guide]); | |
| 103 | + | |
| 104 | + const all = [...data.sales, ...data.riv, ...data.ask, ...data.guide]; | |
| 105 | + const lastX = all.length ? Math.max(...all.map((p) => p.x)) : 0; | |
| 106 | + const days = PERIODS.find((p) => p.id === period)?.days ?? null; | |
| 107 | + const cutoff = days ? lastX - days * 86_400_000 : -Infinity; | |
| 108 | + const vis = { | |
| 109 | + sales: data.sales.filter((p) => p.x >= cutoff), | |
| 110 | + riv: data.riv.filter((p) => p.x >= cutoff), | |
| 111 | + ask: data.ask.filter((p) => p.x >= cutoff), | |
| 112 | + guide: data.guide.filter((p) => p.x >= cutoff), | |
| 113 | + }; | |
| 114 | + const visAll = [...vis.sales, ...vis.riv, ...vis.ask, ...vis.guide]; | |
| 115 | + const available = PERIODS.filter((p) => p.days === null || all.some((pt) => pt.x < lastX - p.days! * 86_400_000) || p.id === period); | |
| 116 | + | |
| 117 | + const setPeriodPersist = useCallback((p: Period) => writeHash(hashKey, p), [hashKey]); | |
| 118 | + const setLog = (v: boolean) => writeHash('scale', v ? 'log' : 'linear'); | |
| 119 | + | |
| 120 | + if (all.length === 0) { | |
| 121 | + return ( | |
| 122 | + <div ref={wrap} className={className}> | |
| 123 | + <div className="flex items-center justify-center rounded-md border border-dashed border-border text-xs text-subtle" style={{ height: Math.min(height, 180) }}> | |
| 124 | + {emptyLabel} | |
| 125 | + </div> | |
| 126 | + </div> | |
| 127 | + ); | |
| 128 | + } | |
| 129 | + | |
| 130 | + // ---- scales | |
| 131 | + const pad = { top: 14, right: 58, bottom: 26, left: 6 }; | |
| 132 | + const W = width; | |
| 133 | + const H = height; | |
| 134 | + const iw = W - pad.left - pad.right; | |
| 135 | + const ih = H - pad.top - pad.bottom; | |
| 136 | + const xsAll = visAll.length ? visAll.map((p) => p.x) : [lastX]; | |
| 137 | + let xMin = Math.min(...xsAll); | |
| 138 | + let xMax = Math.max(...xsAll); | |
| 139 | + if (days) xMin = Math.min(xMin, cutoff); | |
| 140 | + if (xMax - xMin < 7 * 86_400_000) { | |
| 141 | + xMin -= 15 * 86_400_000; | |
| 142 | + xMax += 15 * 86_400_000; | |
| 143 | + } | |
| 144 | + const ysRaw = visAll.flatMap((p) => ('low' in p && p.low != null && p.high != null ? [p.y, p.low, p.high] : [p.y])).concat(reference ? [reference.value] : []); | |
| 145 | + const positive = ysRaw.filter((y) => y > 0); | |
| 146 | + const spread = positive.length ? Math.max(...positive) / Math.min(...positive) : 1; | |
| 147 | + const log = scaleChoice ? scaleChoice === 'log' : spread > 40; | |
| 148 | + const canLog = log && ysRaw.every((y) => y > 0); | |
| 149 | + const ty = (y: number) => (canLog ? Math.log10(y) : y); | |
| 150 | + const fy = (v: number) => (canLog ? 10 ** v : v); | |
| 151 | + let yMin = Math.min(...ysRaw.map(ty)); | |
| 152 | + let yMax = Math.max(...ysRaw.map(ty)); | |
| 153 | + if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) { | |
| 154 | + yMin = 0; | |
| 155 | + yMax = 1; | |
| 156 | + } | |
| 157 | + if (yMin === yMax) { | |
| 158 | + yMin -= Math.abs(yMin) * 0.05 || 1; | |
| 159 | + yMax += Math.abs(yMax) * 0.05 || 1; | |
| 160 | + } | |
| 161 | + const yPad = (yMax - yMin) * 0.08; | |
| 162 | + yMin -= yPad; | |
| 163 | + yMax += yPad; | |
| 164 | + const ticks = canLog ? logTicks(fy(yMin), fy(yMax)) : niceTicks(yMin, yMax, W < 480 ? 3 : 4); | |
| 165 | + const sx = (x: number) => Math.round((pad.left + ((x - xMin) / (xMax - xMin)) * iw) * 100) / 100; | |
| 166 | + const sy = (y: number) => Math.round((pad.top + ih - ((ty(y) - yMin) / (yMax - yMin)) * ih) * 100) / 100; | |
| 167 | + const span = xMax - xMin; | |
| 168 | + const xTicks = timeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 96)))); | |
| 169 | + const linePath = (pts: Array<{ x: number; y: number }>) => pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' '); | |
| 170 | + const band = vis.riv.filter((p) => p.low != null && p.high != null); | |
| 171 | + const bandPath = band.length >= 2 ? `${band.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.high!).toFixed(1)}`).join(' ')} ${[...band].reverse().map((p) => `L${sx(p.x).toFixed(1)},${sy(p.low!).toFixed(1)}`).join(' ')} Z` : null; | |
| 172 | + | |
| 173 | + // ---- hover: nearest date across series | |
| 174 | + const hoverX = hover ? xMin + ((hover.px - pad.left) / iw) * (xMax - xMin) : null; | |
| 175 | + const nearest = <T extends { x: number }>(pts: T[]): T | null => { | |
| 176 | + if (hoverX === null || !pts.length) return null; | |
| 177 | + let best = pts[0]!; | |
| 178 | + let bd = Infinity; | |
| 179 | + for (const p of pts) { | |
| 180 | + const d = Math.abs(p.x - hoverX); | |
| 181 | + if (d < bd) { | |
| 182 | + bd = d; | |
| 183 | + best = p; | |
| 184 | + } | |
| 185 | + } | |
| 186 | + return best; | |
| 187 | + }; | |
| 188 | + const hRiv = nearest(vis.riv); | |
| 189 | + const hAsk = nearest(vis.ask); | |
| 190 | + const hGuide = nearest(vis.guide); | |
| 191 | + // sales: nearest within 3% of the span so a finger lands on a dot, not a random one far away | |
| 192 | + const hSaleCand = nearest(vis.sales); | |
| 193 | + const hSale = hSaleCand && hoverX !== null && Math.abs(hSaleCand.x - hoverX) <= Math.max(span * 0.03, 86_400_000) ? hSaleCand : null; | |
| 194 | + const hDate = hover ? [hSale?.x, hRiv?.x, hAsk?.x, hGuide?.x].filter((x): x is number => x !== undefined).sort((a, b) => Math.abs(a - hoverX!) - Math.abs(b - hoverX!))[0] ?? null : null; | |
| 195 | + | |
| 196 | + const onPointer = (e: React.PointerEvent<SVGSVGElement>, sticky: boolean) => { | |
| 197 | + const rect = e.currentTarget.getBoundingClientRect(); | |
| 198 | + const px = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W; | |
| 199 | + if (px >= pad.left - 8 && px <= pad.left + iw + 8) setHover({ px: Math.min(pad.left + iw, Math.max(pad.left, px)), sticky }); | |
| 200 | + }; | |
| 201 | + | |
| 202 | + const series = [ | |
| 203 | + vis.sales.length ? { id: 'sales', label: `Sales (${vis.sales.length})`, color: C.sales, kind: 'dots' as const } : null, | |
| 204 | + vis.riv.length ? { id: 'riv', label: 'RIV (daily)', color: C.riv, kind: 'line' as const } : null, | |
| 205 | + bandPath ? { id: 'band', label: 'RIV low–high', color: C.riv, kind: 'band' as const } : null, | |
| 206 | + vis.ask.length ? { id: 'ask', label: 'Lowest ask', color: C.ask, kind: 'dashed' as const } : null, | |
| 207 | + vis.guide.length ? { id: 'guide', label: 'Guide price', color: C.guide, kind: 'dashed' as const } : null, | |
| 208 | + ].filter((s): s is NonNullable<typeof s> => Boolean(s)); | |
| 209 | + | |
| 210 | + const readout = hDate !== null; | |
| 211 | + const fmtFull = (v: number) => fullNumber(v, { currency: true }); | |
| 212 | + | |
| 213 | + return ( | |
| 214 | + <div ref={wrap} className={`relative min-w-0 max-w-full overflow-hidden ${className ?? ''}`}> | |
| 215 | + {/* controls */} | |
| 216 | + <div className="mb-2 flex items-center justify-between gap-2"> | |
| 217 | + <div className="scrollbar-none -mx-1 flex min-w-0 flex-1 gap-0.5 overflow-x-auto px-1" role="group" aria-label="Period"> | |
| 218 | + {available.map((p) => ( | |
| 219 | + <button key={p.id} type="button" aria-pressed={period === p.id} onClick={() => setPeriodPersist(p.id)} className={`num h-8 min-w-[40px] shrink-0 rounded-md px-2 text-[12px] font-medium ${period === p.id ? 'bg-accent text-accent-fg' : 'text-muted hover:bg-inset hover:text-fg'}`}> | |
| 220 | + {p.id} | |
| 221 | + </button> | |
| 222 | + ))} | |
| 223 | + </div> | |
| 224 | + <div className="flex shrink-0 items-center gap-1 text-[11px]"> | |
| 225 | + <button type="button" aria-pressed={canLog} onClick={() => setLog(!log)} disabled={!ysRaw.every((y) => y > 0)} className={`h-8 rounded-md border px-2 font-medium ${canLog ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:text-fg'} disabled:opacity-40`} title="Logarithmic scale"> | |
| 226 | + Log | |
| 227 | + </button> | |
| 228 | + </div> | |
| 229 | + </div> | |
| 230 | + | |
| 231 | + {/* readout (touch-friendly: never under the finger) */} | |
| 232 | + <div className="mb-1 flex min-h-[34px] flex-wrap items-center gap-x-3 gap-y-0.5 rounded-md bg-sunken px-2.5 py-1.5 text-[11px]" aria-live="polite"> | |
| 233 | + {readout ? ( | |
| 234 | + <> | |
| 235 | + <span className="font-medium text-fg">{fmtDayFull(hDate!)}</span> | |
| 236 | + {hSale ? ( | |
| 237 | + <span className="inline-flex items-center gap-1 text-muted"> | |
| 238 | + <i className="inline-block h-2 w-2 rounded-full" style={{ background: C.sales }} /> | |
| 239 | + Sale <b className="num font-semibold text-fg">{fmtFull(hSale.y)}</b> | |
| 240 | + {hSale.label ? <span className="text-subtle">· {hSale.label}</span> : null} | |
| 241 | + </span> | |
| 242 | + ) : null} | |
| 243 | + {hRiv ? ( | |
| 244 | + <span className="inline-flex items-center gap-1 text-muted"> | |
| 245 | + <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.riv }} /> | |
| 246 | + RIV <b className="num font-semibold text-fg">{fmtFull(hRiv.y)}</b> | |
| 247 | + {hRiv.low != null && hRiv.high != null ? <span className="num text-subtle">({compactNumber(hRiv.low, { currency: true })}–{compactNumber(hRiv.high, { currency: true })})</span> : null} | |
| 248 | + </span> | |
| 249 | + ) : null} | |
| 250 | + {hAsk ? ( | |
| 251 | + <span className="inline-flex items-center gap-1 text-muted"> | |
| 252 | + <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.ask }} /> | |
| 253 | + Ask <b className="num font-semibold text-fg">{fmtFull(hAsk.y)}</b> | |
| 254 | + </span> | |
| 255 | + ) : null} | |
| 256 | + {hGuide ? ( | |
| 257 | + <span className="inline-flex items-center gap-1 text-muted"> | |
| 258 | + <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.guide }} /> | |
| 259 | + Guide <b className="num font-semibold text-fg">{fmtFull(hGuide.y)}</b> | |
| 260 | + </span> | |
| 261 | + ) : null} | |
| 262 | + </> | |
| 263 | + ) : ( | |
| 264 | + <span className="text-subtle"> | |
| 265 | + {visAll.length ? <>Touch or hover the chart · {vis.sales.length.toLocaleString('en-US')} sales · {vis.riv.length.toLocaleString('en-US')} daily valuations{canLog ? ' · log scale' : ''}</> : 'No observations in this period'} | |
| 266 | + </span> | |
| 267 | + )} | |
| 268 | + </div> | |
| 269 | + | |
| 270 | + <svg | |
| 271 | + width="100%" | |
| 272 | + height={H} | |
| 273 | + viewBox={`0 0 ${W} ${H}`} | |
| 274 | + preserveAspectRatio="none" | |
| 275 | + role="img" | |
| 276 | + aria-label={ariaLabel} | |
| 277 | + className="block max-w-full select-none" | |
| 278 | + style={{ touchAction: 'pan-y' }} | |
| 279 | + onPointerDown={(e) => onPointer(e, e.pointerType !== 'mouse')} | |
| 280 | + onPointerMove={(e) => { | |
| 281 | + if (e.pointerType === 'mouse' || e.buttons > 0) onPointer(e, e.pointerType !== 'mouse'); | |
| 282 | + }} | |
| 283 | + onPointerLeave={(e) => { | |
| 284 | + if (e.pointerType === 'mouse') setHover(null); | |
| 285 | + }} | |
| 286 | + > | |
| 287 | + <defs> | |
| 288 | + <clipPath id={`${uid}-clip`}> | |
| 289 | + <rect x={pad.left - 6} y={0} width={iw + 12} height={H} /> | |
| 290 | + </clipPath> | |
| 291 | + </defs> | |
| 292 | + {ticks.map((t) => ( | |
| 293 | + <g key={t}> | |
| 294 | + <line x1={pad.left} x2={pad.left + iw} y1={sy(t)} y2={sy(t)} stroke="var(--ri-border)" strokeWidth={1} shapeRendering="crispEdges" /> | |
| 295 | + <text x={W - pad.right + 6} y={sy(t)} dy="0.32em" fontSize={10} fill="var(--ri-fg-subtle)" className="num"> | |
| 296 | + {compactNumber(t, { currency: true })} | |
| 297 | + </text> | |
| 298 | + </g> | |
| 299 | + ))} | |
| 300 | + {xTicks.map((t) => ( | |
| 301 | + <text key={t} x={sx(t)} y={H - 8} fontSize={10} textAnchor="middle" fill="var(--ri-fg-subtle)"> | |
| 302 | + {fmtDay(t, span)} | |
| 303 | + </text> | |
| 304 | + ))} | |
| 305 | + <g clipPath={`url(#${uid}-clip)`}> | |
| 306 | + {bandPath ? <path d={bandPath} fill={C.riv} opacity={0.1} /> : null} | |
| 307 | + {vis.guide.length >= 2 ? <path d={linePath(vis.guide)} fill="none" stroke={C.guide} strokeWidth={2} strokeDasharray="4 4" strokeLinejoin="round" strokeLinecap="round" /> : null} | |
| 308 | + {vis.ask.length >= 2 ? <path d={linePath(vis.ask)} fill="none" stroke={C.ask} strokeWidth={2} strokeDasharray="4 4" strokeLinejoin="round" strokeLinecap="round" /> : null} | |
| 309 | + {vis.riv.length >= 2 ? <path d={linePath(vis.riv)} fill="none" stroke={C.riv} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" /> : null} | |
| 310 | + {vis.riv.length === 1 ? <circle cx={sx(vis.riv[0]!.x)} cy={sy(vis.riv[0]!.y)} r={4} fill={C.riv} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null} | |
| 311 | + {reference ? ( | |
| 312 | + <g> | |
| 313 | + <line x1={pad.left} x2={pad.left + iw} y1={sy(reference.value)} y2={sy(reference.value)} stroke="var(--ri-fg-subtle)" strokeWidth={1} strokeDasharray="2 3" /> | |
| 314 | + <text x={pad.left + 4} y={sy(reference.value) - 4} fontSize={10} fill="var(--ri-fg-muted)"> | |
| 315 | + {reference.label} | |
| 316 | + </text> | |
| 317 | + </g> | |
| 318 | + ) : null} | |
| 319 | + {vis.sales.map((p, i) => ( | |
| 320 | + <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={vis.sales.length > 400 ? 3 : 4} fill={C.sales} stroke="var(--ri-bg-elevated)" strokeWidth={vis.sales.length > 400 ? 1 : 1.5} opacity={0.9} /> | |
| 321 | + ))} | |
| 322 | + {hDate !== null ? <line x1={sx(hDate)} x2={sx(hDate)} y1={pad.top} y2={pad.top + ih} stroke="var(--ri-border-strong)" strokeWidth={1} /> : null} | |
| 323 | + {hSale ? <circle cx={sx(hSale.x)} cy={sy(hSale.y)} r={6} fill={C.sales} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null} | |
| 324 | + {hRiv && hDate !== null ? <circle cx={sx(hRiv.x)} cy={sy(hRiv.y)} r={5} fill={C.riv} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null} | |
| 325 | + </g> | |
| 326 | + </svg> | |
| 327 | + | |
| 328 | + {series.length > 1 ? ( | |
| 329 | + <ul className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend"> | |
| 330 | + {series.map((s) => ( | |
| 331 | + <li key={s.id} className="flex items-center gap-1.5"> | |
| 332 | + {s.kind === 'dots' ? <span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} /> : s.kind === 'band' ? <span className="inline-block h-2.5 w-4 rounded-sm" style={{ background: s.color, opacity: 0.2 }} /> : <span className="inline-block h-0.5 w-4 rounded-full" style={{ background: s.color, ...(s.kind === 'dashed' ? { backgroundImage: `repeating-linear-gradient(90deg, ${s.color} 0 4px, transparent 4px 7px)` } : {}) }} />} | |
| 333 | + {s.label} | |
| 334 | + </li> | |
| 335 | + ))} | |
| 336 | + </ul> | |
| 337 | + ) : null} | |
| 338 | + | |
| 339 | + {/* accessible data table */} | |
| 340 | + <table className="sr-only"> | |
| 341 | + <caption>{ariaLabel}</caption> | |
| 342 | + <thead> | |
| 343 | + <tr> | |
| 344 | + <th>Date</th> | |
| 345 | + <th>Series</th> | |
| 346 | + <th>USD</th> | |
| 347 | + </tr> | |
| 348 | + </thead> | |
| 349 | + <tbody> | |
| 350 | + {vis.sales.slice(-200).map((p, i) => ( | |
| 351 | + <tr key={`s${i}`}> | |
| 352 | + <td>{fmtDayFull(p.x)}</td> | |
| 353 | + <td>Sale{p.label ? ` (${p.label})` : ''}</td> | |
| 354 | + <td>{fmtFull(p.y)}</td> | |
| 355 | + </tr> | |
| 356 | + ))} | |
| 357 | + {vis.riv.slice(-200).map((p, i) => ( | |
| 358 | + <tr key={`r${i}`}> | |
| 359 | + <td>{fmtDayFull(p.x)}</td> | |
| 360 | + <td>RIV</td> | |
| 361 | + <td>{fmtFull(p.y)}</td> | |
| 362 | + </tr> | |
| 363 | + ))} | |
| 364 | + </tbody> | |
| 365 | + </table> | |
| 366 | + </div> | |
| 367 | + ); | |
| 368 | +} | |
| 369 | + | |
| 370 | +function timeTicks(min: number, max: number, count: number): number[] { | |
| 371 | + if (max <= min) return [min]; | |
| 372 | + const span = max - min; | |
| 373 | + const day = 86_400_000; | |
| 374 | + const candidates = [day, 2 * day, 7 * day, 14 * day, 30 * day, 61 * day, 91 * day, 182 * day, 365 * day, 730 * day, 1826 * day, 3652 * day]; | |
| 375 | + const want = Math.max(3, count); | |
| 376 | + // largest step that still yields at least `want` ticks; otherwise the smallest step | |
| 377 | + let step = candidates[0]!; | |
| 378 | + for (const c of candidates) if (span / c >= want - 1) step = c; | |
| 379 | + const out: number[] = []; | |
| 380 | + const start = Math.ceil(min / step) * step; | |
| 381 | + for (let t = start; t <= max; t += step) out.push(t); | |
| 382 | + while (out.length > count + 1) out.splice(1, 1); // thin if the step produced too many | |
| 383 | + if (out.length < 2) return [min + span * 0.15, min + span * 0.5, min + span * 0.85]; | |
| 384 | + return out; | |
| 385 | +} | |
| 386 | + | |
| 387 | +function logTicks(min: number, max: number): number[] { | |
| 388 | + const out: number[] = []; | |
| 389 | + const lo = Math.floor(Math.log10(Math.max(min, 1e-9))); | |
| 390 | + const hi = Math.ceil(Math.log10(Math.max(max, 1e-9))); | |
| 391 | + for (let e = lo; e <= hi; e++) { | |
| 392 | + for (const m of [1, 2, 5]) { | |
| 393 | + const v = m * 10 ** e; | |
| 394 | + if (v >= min && v <= max) out.push(v); | |
| 395 | + } | |
| 396 | + } | |
| 397 | + if (out.length <= 5) return out; | |
| 398 | + const n = out.length; | |
| 399 | + return [0, 1, 2, 3, 4].map((i) => out[Math.round((i * (n - 1)) / 4)]!); | |
| 400 | +} | |
modified
apps/web/src/components/home/hero.tsx
+29 −38
@@ -1,50 +1,41 @@ | ||
| 1 | 1 | import Link from 'next/link'; |
| 2 | −import { Search } from 'lucide-react'; | |
| 3 | 2 | import type { SiteStats } from '@/lib/queries/site'; |
| 4 | 3 | import { fmtNum, fmtRelative } from '@/lib/format'; |
| 4 | +import { HeroSearch } from '@/components/search/search-sheet'; | |
| 5 | +import { StatStrip } from '@/components/ui/primitives'; | |
| 5 | 6 | |
| 6 | −const EXAMPLES = ['1999 Charizard PSA 10', 'Rolex Daytona Panda', 'sealed Mario 64', 'first edition Harry Potter UK', 'Michael Jordan rookie PSA 10', 'LEGO Millennium Falcon retired']; | |
| 7 | +const CHIPS = ['1999 Charizard PSA 10', 'Rolex Daytona 116500LN', 'LEGO 10179', 'Black Lotus Alpha', 'Super Mario 64 sealed', 'Amazing Spider-Man 300']; | |
| 7 | 8 | |
| 8 | 9 | export function Hero({ stats }: { stats: SiteStats }) { |
| 9 | 10 | return ( |
| 10 | − <section className="pt-8 sm:pt-14"> | |
| 11 | + <section className="fade-up pt-6 sm:pt-12"> | |
| 11 | 12 | <div className="mx-auto max-w-3xl text-center"> |
| 12 | − <p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-subtle">RareIndex · Collectibles market intelligence</p> | |
| 13 | − <h1 className="text-3xl font-semibold tracking-tight text-fg sm:text-5xl">The Global Market for Collectibles</h1> | |
| 14 | − <p className="mx-auto mt-3 max-w-2xl text-sm leading-relaxed text-muted sm:text-base">Discover, price, track and analyze collectible assets across the world’s marketplaces and auction houses.</p> | |
| 15 | − <form action="/search" role="search" className="mx-auto mt-6 flex max-w-2xl items-center gap-2"> | |
| 16 | − <label className="relative flex-1"> | |
| 17 | − <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-subtle" /> | |
| 18 | − <input name="q" type="search" autoComplete="off" placeholder="Search cards, watches, games, sneakers, LEGO, art…" className="h-12 w-full rounded-md border border-border bg-elevated pl-10 pr-3 text-[15px] text-fg shadow-card placeholder:text-subtle focus:border-border-strong focus:outline-none" aria-label="Search collectibles" /> | |
| 19 | − </label> | |
| 20 | − <button type="submit" className="h-12 shrink-0 rounded-md bg-accent px-4 text-sm font-medium text-accent-fg hover:opacity-90"> | |
| 21 | − Search | |
| 22 | − </button> | |
| 23 | − </form> | |
| 24 | − <ul className="mt-3 flex flex-wrap justify-center gap-1.5 text-[11px] text-muted"> | |
| 25 | − {EXAMPLES.map((e) => ( | |
| 26 | − <li key={e}> | |
| 27 | − <Link href={`/search?q=${encodeURIComponent(e)}`} className="rounded-full border border-border px-2 py-0.5 hover:border-border-strong hover:text-fg"> | |
| 28 | − {e} | |
| 29 | − </Link> | |
| 30 | − </li> | |
| 31 | − ))} | |
| 32 | − </ul> | |
| 13 | + <p className="t-label mb-3 flex items-center justify-center gap-2"> | |
| 14 | + <span className="live-dot inline-block h-1.5 w-1.5 rounded-full bg-gain" aria-hidden /> | |
| 15 | + Collectibles market intelligence | |
| 16 | + </p> | |
| 17 | + <h1 className="t-display text-fg">The Global Market for Collectibles</h1> | |
| 18 | + <p className="mx-auto mt-3 max-w-xl text-[15px] leading-relaxed text-muted sm:text-base">Discover, price, track and analyze collectible assets across the world’s marketplaces and auction houses.</p> | |
| 19 | + <div className="mt-6"> | |
| 20 | + <HeroSearch chips={CHIPS} /> | |
| 21 | + </div> | |
| 33 | 22 | </div> |
| 34 | − <dl className="mx-auto mt-8 grid max-w-4xl grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border text-center sm:grid-cols-5"> | |
| 35 | − {[ | |
| 36 | − ['Tracked assets', fmtNum(stats.assets)], | |
| 37 | − ['Indexed sales', fmtNum(stats.sales)], | |
| 38 | − ['Price observations', fmtNum(stats.observations)], | |
| 39 | − ['Sources', fmtNum(stats.sources)], | |
| 40 | − ['Last update', stats.lastUpdateAt ? fmtRelative(stats.lastUpdateAt) : '—'], | |
| 41 | − ].map(([k, v]) => ( | |
| 42 | − <div key={k} className="bg-elevated px-3 py-3"> | |
| 43 | − <dt className="text-[10px] font-medium uppercase tracking-wider text-subtle">{k}</dt> | |
| 44 | − <dd className="num mt-0.5 text-lg font-semibold text-fg">{v}</dd> | |
| 45 | − </div> | |
| 46 | − ))} | |
| 47 | − </dl> | |
| 23 | + <StatStrip | |
| 24 | + className="mx-auto mt-8 max-w-4xl" | |
| 25 | + items={[ | |
| 26 | + { label: 'Tracked assets', value: fmtNum(stats.assets), href: '/explore' }, | |
| 27 | + { label: 'Indexed sales', value: fmtNum(stats.sales), href: '/sales' }, | |
| 28 | + { label: 'Price observations', value: fmtNum(stats.observations), href: '/explore?has=observations' }, | |
| 29 | + { label: 'Live listings', value: fmtNum(stats.activeListings), href: '/listings' }, | |
| 30 | + { label: 'Last update', value: stats.lastUpdateAt ? fmtRelative(stats.lastUpdateAt) : '—', sub: `${fmtNum(stats.sources)} sources · ${fmtNum(stats.connectors)} connectors` }, | |
| 31 | + ]} | |
| 32 | + /> | |
| 33 | + <p className="mt-2 text-center text-[11px] text-subtle"> | |
| 34 | + Counts are live from the canonical tables · valuations carry confidence and sample size ·{' '} | |
| 35 | + <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline"> | |
| 36 | + methodology | |
| 37 | + </Link> | |
| 38 | + </p> | |
| 48 | 39 | </section> |
| 49 | 40 | ); |
| 50 | 41 | } |
modified
apps/web/src/components/home/sections.tsx
+192 −128
@@ -5,149 +5,172 @@ import type { LatestSale } from '@/lib/queries/site'; | ||
| 5 | 5 | import type { MarketRow } from '@/lib/queries/markets'; |
| 6 | 6 | import { fmtMoney, fmtRelative, fmtNum, cn, fmtDate } from '@/lib/format'; |
| 7 | 7 | import { catName, humanize } from '@/lib/taxonomy'; |
| 8 | −import { Card, CardHeader, Delta, EmptyState } from '@/components/ui/primitives'; | |
| 8 | +import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives'; | |
| 9 | 9 | import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits'; |
| 10 | +import { AssetTile, Rail, RailHeader, type TileMetric } from '@/components/market/asset-tile'; | |
| 10 | 11 | import { heatStyle } from '@/components/charts/heat'; |
| 11 | 12 | |
| 12 | −function Row({ href, image, title, sub, right, rightSub }: { href: string; image?: string | null; title: string; sub?: string | null; right: React.ReactNode; rightSub?: React.ReactNode }) { | |
| 13 | − return ( | |
| 14 | − <li> | |
| 15 | − <Link href={href} className="flex items-center gap-2.5 px-4 py-2 hover:bg-sunken"> | |
| 16 | − <Thumb src={image} alt="" size={32} /> | |
| 17 | − <span className="min-w-0 flex-1"> | |
| 18 | − <span className="block truncate text-[13px] font-medium text-fg">{title}</span> | |
| 19 | − {sub ? <span className="block truncate text-[11px] text-muted">{sub}</span> : null} | |
| 20 | − </span> | |
| 21 | − <span className="num flex shrink-0 flex-col items-end text-[12px] leading-tight"> | |
| 22 | − <span className="font-medium text-fg">{right}</span> | |
| 23 | − {rightSub ? <span className="text-[11px]">{rightSub}</span> : null} | |
| 24 | − </span> | |
| 25 | − </Link> | |
| 26 | − </li> | |
| 27 | − ); | |
| 28 | −} | |
| 29 | − | |
| 30 | 13 | const EMPTY = 'Populates as the pipeline ingests data.'; |
| 31 | 14 | |
| 32 | −export function AssetRail({ title, subtitle, href, items, metric, empty = EMPTY }: { title: string; subtitle?: string; href: string; items: AssetCardData[]; metric: 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new' | 'guide'; empty?: string }) { | |
| 15 | +/** Editorial rail of poster tiles (snap-scroll on mobile, grid from lg). */ | |
| 16 | +export function AssetRail({ title, subtitle, href, items, metric, empty = EMPTY, priority = false }: { title: string; subtitle?: string; href: string; items: AssetCardData[]; metric: TileMetric; empty?: string; priority?: boolean }) { | |
| 33 | 17 | return ( |
| 34 | − <Card> | |
| 35 | − <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} /> | |
| 18 | + <section> | |
| 19 | + <RailHeader title={title} subtitle={subtitle} href={href} /> | |
| 36 | 20 | {items.length ? ( |
| 37 | − <ul className="divide-y divide-border"> | |
| 38 | − {items.map((a) => ( | |
| 39 | − <Row | |
| 40 | − key={a.id} | |
| 41 | − href={`/asset/${a.slug}`} | |
| 42 | − image={a.heroImageUrl} | |
| 43 | − title={a.title} | |
| 44 | − sub={`${catName(a.categorySlug)}${a.year ? ` · ${a.year}` : ''}`} | |
| 45 | − right={a.rivUsd === null ? (a.guideUsd != null ? <span className="text-muted" title="Price-guide observation, not a transaction">{fmtMoney(a.guideUsd)}</span> : <span className="text-subtle">—</span>) : fmtMoney(a.rivUsd)} | |
| 46 | − rightSub={ | |
| 47 | − metric === 'guide' ? <span className="text-muted">{a.rivUsd === null && a.guideUsd != null ? `guide · ${a.guideSource ?? ''}` : a.rivUsd !== null ? `RIV · ${a.rivSampleSize} sales` : 'catalogued'}</span> | |
| 48 | − : metric === 'trending' ? <span className="text-muted">score {a.trendingScore?.toFixed(1) ?? '—'}</span> | |
| 49 | − : metric === 'watchers' ? <span className="text-muted">{fmtNum(a.watchers)} watching</span> | |
| 50 | − : metric === 'new' ? <span className="text-muted">added {fmtRelative(a.updatedAt)}</span> | |
| 51 | − : metric === 'riv' ? <span className="text-muted">{a.rivSampleSize} sales</span> | |
| 52 | − : metric === 'opportunity' ? <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} /> | |
| 53 | − : <Delta value={metric === 'change1d' ? a.change1d : metric === 'change30d' ? a.change30d : a.change7d} /> | |
| 54 | − } | |
| 55 | − /> | |
| 21 | + <Rail> | |
| 22 | + {items.slice(0, 8).map((a, i) => ( | |
| 23 | + <AssetTile key={a.id} a={a} metric={metric} priority={priority && i < 4} /> | |
| 56 | 24 | ))} |
| 57 | − </ul> | |
| 25 | + </Rail> | |
| 58 | 26 | ) : ( |
| 59 | − <EmptyState title="Nothing to show yet" description={empty} className="py-8" /> | |
| 27 | + <Card> | |
| 28 | + <EmptyState title="Nothing to show yet" description={empty} compact /> | |
| 29 | + </Card> | |
| 60 | 30 | )} |
| 61 | − </Card> | |
| 31 | + </section> | |
| 32 | + ); | |
| 33 | +} | |
| 34 | + | |
| 35 | +function SaleTile({ s }: { s: SaleRow }) { | |
| 36 | + return ( | |
| 37 | + <Link href={s.assetSlug ? `/asset/${s.assetSlug}` : '/sales'} className="card card-hover group flex w-[220px] flex-col gap-2 p-2 sm:w-[240px]"> | |
| 38 | + <span className="flex items-start gap-2.5"> | |
| 39 | + <Thumb src={s.heroImageUrl ?? s.imageUrls[0]} alt="" size={56} rounded="rounded-[5px]" /> | |
| 40 | + <span className="min-w-0 flex-1"> | |
| 41 | + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={s.assetTitle ?? s.rawTitle}> | |
| 42 | + {s.assetTitle ?? s.rawTitle} | |
| 43 | + </span> | |
| 44 | + <span className="mt-0.5 block truncate text-[11px] text-muted">{catName(s.categorySlug)}</span> | |
| 45 | + </span> | |
| 46 | + </span> | |
| 47 | + <span className="flex items-end justify-between gap-2 border-t border-border pt-2"> | |
| 48 | + <span className="flex flex-col leading-tight"> | |
| 49 | + <span className="num text-[15px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span> | |
| 50 | + <span className="t-caption"> | |
| 51 | + {fmtDate(s.saleDate)} · {s.sourceName} | |
| 52 | + </span> | |
| 53 | + </span> | |
| 54 | + <GradeBadge grader={s.grader} grade={s.grade} /> | |
| 55 | + </span> | |
| 56 | + </Link> | |
| 62 | 57 | ); |
| 63 | 58 | } |
| 64 | 59 | |
| 65 | 60 | export function SalesRail({ title, subtitle, href, items, hideWhenEmpty = false }: { title: string; subtitle?: string; href: string; items: SaleRow[]; hideWhenEmpty?: boolean }) { |
| 66 | 61 | if (hideWhenEmpty && !items.length) return null; |
| 67 | 62 | return ( |
| 68 | − <Card> | |
| 69 | − <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} /> | |
| 63 | + <section> | |
| 64 | + <RailHeader title={title} subtitle={subtitle} href={href} /> | |
| 70 | 65 | {items.length ? ( |
| 71 | − <ul className="divide-y divide-border"> | |
| 72 | − {items.map((s) => ( | |
| 73 | − <Row key={s.id} href={s.assetSlug ? `/asset/${s.assetSlug}` : '/sales'} image={s.heroImageUrl ?? s.imageUrls[0]} title={s.assetTitle ?? s.rawTitle} sub={`${s.sourceName} · ${fmtDate(s.saleDate)}`} right={fmtMoney(s.priceUsd)} rightSub={<GradeBadge grader={s.grader} grade={s.grade} />} /> | |
| 66 | + <Rail cols="lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6"> | |
| 67 | + {items.slice(0, 8).map((s) => ( | |
| 68 | + <SaleTile key={s.id} s={s} /> | |
| 74 | 69 | ))} |
| 75 | − </ul> | |
| 70 | + </Rail> | |
| 76 | 71 | ) : ( |
| 77 | − <EmptyState title="No verified sales yet" description="Record sales are drawn from verified transactions only (§154)." className="py-8" /> | |
| 72 | + <Card> | |
| 73 | + <EmptyState title="No verified sales yet" description="Record sales are drawn from verified transactions only (§154)." compact /> | |
| 74 | + </Card> | |
| 78 | 75 | )} |
| 79 | − </Card> | |
| 76 | + </section> | |
| 80 | 77 | ); |
| 81 | 78 | } |
| 82 | 79 | |
| 83 | 80 | export function RadarRail({ items, hideWhenEmpty = false }: { items: RadarRow[]; hideWhenEmpty?: boolean }) { |
| 84 | 81 | if (hideWhenEmpty && !items.length) return null; |
| 85 | 82 | return ( |
| 86 | − <Card> | |
| 87 | − <CardHeader title="Rare Finds" subtitle="Rare Radar: unusual appearances and discrepancies" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} /> | |
| 83 | + <section> | |
| 84 | + <RailHeader title="Rare Finds" subtitle="Rare Radar: unusual appearances and discrepancies" href="/radar" hrefLabel="Open radar" /> | |
| 88 | 85 | {items.length ? ( |
| 89 | − <ul className="divide-y divide-border"> | |
| 90 | − {items.map((r) => ( | |
| 91 | − <Row key={r.id} href={`/asset/${r.assetSlug}`} image={r.heroImageUrl} title={r.assetTitle} sub={`${humanize(r.kind)} · ${fmtRelative(r.detectedAt)}`} right={r.rivUsd === null ? <span className="text-subtle">—</span> : fmtMoney(r.rivUsd)} rightSub={<span className="text-muted">signal {r.score.toFixed(0)}</span>} /> | |
| 86 | + <Rail cols="lg:grid-cols-3 xl:grid-cols-4"> | |
| 87 | + {items.slice(0, 8).map((r) => ( | |
| 88 | + <Link key={r.id} href={`/asset/${r.assetSlug}`} className="card card-hover flex w-[240px] gap-2.5 p-2.5 sm:w-[260px]"> | |
| 89 | + <Thumb src={r.heroImageUrl} alt="" size={52} rounded="rounded-[5px]" /> | |
| 90 | + <span className="min-w-0 flex-1"> | |
| 91 | + <Badge tone={r.kind === 'record_sale' ? 'gold' : r.kind === 'price_discrepancy' ? 'alert' : 'rarity'}>{humanize(r.kind)}</Badge> | |
| 92 | + <span className="mt-1 line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{r.assetTitle}</span> | |
| 93 | + <span className="num block text-[11px] text-muted"> | |
| 94 | + {r.rivUsd === null ? 'RIV —' : `RIV ${fmtMoney(r.rivUsd)}`} · signal {r.score.toFixed(0)} · {fmtRelative(r.detectedAt)} | |
| 95 | + </span> | |
| 96 | + </span> | |
| 97 | + </Link> | |
| 92 | 98 | ))} |
| 93 | − </ul> | |
| 99 | + </Rail> | |
| 94 | 100 | ) : ( |
| 95 | − <EmptyState title="Radar is quiet" description="Findings appear when the pipeline detects first listings in years, ultra-low populations or price discrepancies." className="py-8" /> | |
| 101 | + <Card> | |
| 102 | + <EmptyState title="Radar is quiet" description="Findings appear when the pipeline detects first listings in years, ultra-low populations or price discrepancies." compact /> | |
| 103 | + </Card> | |
| 96 | 104 | )} |
| 97 | − </Card> | |
| 105 | + </section> | |
| 98 | 106 | ); |
| 99 | 107 | } |
| 100 | 108 | |
| 101 | 109 | export function LotsRail({ items, hideWhenEmpty = false }: { items: LotRow[]; hideWhenEmpty?: boolean }) { |
| 102 | 110 | if (hideWhenEmpty && !items.length) return null; |
| 103 | 111 | return ( |
| 104 | − <Card> | |
| 105 | − <CardHeader title="Auctions Ending Soon" subtitle="Next 72 hours" action={<Link href="/auctions" className="text-muted hover:text-fg">All auctions →</Link>} /> | |
| 112 | + <section> | |
| 113 | + <RailHeader title="Auctions Ending Soon" subtitle="Next 72 hours · estimates and bids are the house’s own figures" href="/auctions" hrefLabel="All auctions" /> | |
| 106 | 114 | {items.length ? ( |
| 107 | − <ul className="divide-y divide-border"> | |
| 108 | − {items.map((l) => ( | |
| 109 | − <li key={l.id}> | |
| 110 | − <a href={l.url} target="_blank" rel="noopener nofollow" className="flex items-center gap-2.5 px-4 py-2 hover:bg-sunken"> | |
| 111 | − <Thumb src={l.imageUrls[0]} alt="" size={32} /> | |
| 112 | − <span className="min-w-0 flex-1"> | |
| 113 | − <span className="block truncate text-[13px] font-medium text-fg">{l.title}</span> | |
| 114 | − <span className="block truncate text-[11px] text-muted"> | |
| 115 | − {l.auctionHouse} · ends {fmtRelative(l.endsAt)} | |
| 116 | − </span> | |
| 115 | + <Rail cols="lg:grid-cols-3 xl:grid-cols-4"> | |
| 116 | + {items.slice(0, 8).map((l) => ( | |
| 117 | + <a key={l.id} href={l.url} target="_blank" rel="noopener nofollow" className="card card-hover flex w-[240px] gap-2.5 p-2.5 sm:w-[260px]"> | |
| 118 | + <Thumb src={l.imageUrls[0]} alt="" size={52} rounded="rounded-[5px]" /> | |
| 119 | + <span className="min-w-0 flex-1"> | |
| 120 | + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.title}</span> | |
| 121 | + <span className="block truncate text-[11px] text-muted"> | |
| 122 | + {l.auctionHouse} · ends {fmtRelative(l.endsAt)} | |
| 117 | 123 | </span> |
| 118 | − <span className="num text-[12px] font-medium text-fg">{l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span> | |
| 119 | − </a> | |
| 120 | − </li> | |
| 124 | + <span className="num mt-1 block text-[13px] font-semibold text-fg">{l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span> | |
| 125 | + </span> | |
| 126 | + </a> | |
| 121 | 127 | ))} |
| 122 | − </ul> | |
| 128 | + </Rail> | |
| 123 | 129 | ) : ( |
| 124 | − <EmptyState title="No lots ending soon" description="Auction lots appear once auction-house connectors are live." className="py-8" /> | |
| 130 | + <Card> | |
| 131 | + <EmptyState title="No lots ending soon" description="Auction lots appear once auction-house connectors are live." compact /> | |
| 132 | + </Card> | |
| 125 | 133 | )} |
| 126 | − </Card> | |
| 134 | + </section> | |
| 127 | 135 | ); |
| 128 | 136 | } |
| 129 | 137 | |
| 130 | 138 | export function MarketInsights({ rows }: { rows: MarketRow[] }) { |
| 131 | − const withData = rows.filter((r) => r.counts.assets > 0); | |
| 139 | + const withData = rows.filter((r) => r.counts.assets > 0).sort((a, b) => b.counts.sales - a.counts.sales || b.counts.assets - a.counts.assets); | |
| 132 | 140 | return ( |
| 133 | 141 | <Card> |
| 134 | − <CardHeader title="Market Insights" subtitle="Category performance and activity" action={<Link href="/markets" className="text-muted hover:text-fg">Markets →</Link>} /> | |
| 142 | + <CardHeader title="Market Insights" subtitle="Category activity · 30-day change of the category index when published" action={<Link href="/markets" className="text-muted hover:text-fg">Markets →</Link>} /> | |
| 135 | 143 | {withData.length ? ( |
| 136 | − <ul className="divide-y divide-border"> | |
| 137 | − {withData.slice(0, 10).map((r) => ( | |
| 138 | − <li key={r.node.slug}> | |
| 139 | − <Link href={`/markets/${r.node.slug}`} className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-4 py-2 text-[12px] hover:bg-sunken"> | |
| 140 | − <span className="truncate font-medium text-fg">{r.node.name}</span> | |
| 141 | − <span className="num text-muted">{fmtNum(r.counts.assets)} assets · {fmtNum(r.counts.sales)} sales</span> | |
| 142 | − <span className="num rounded-sm px-1.5 py-0.5" style={heatStyle(r.snapshot?.change30d)}> | |
| 144 | + <Table sticky={false}> | |
| 145 | + <thead> | |
| 146 | + <tr> | |
| 147 | + <th className={th}>Category</th> | |
| 148 | + <th className={cn(th, 'text-right')}>Assets</th> | |
| 149 | + <th className={cn(th, 'text-right')}>Sales</th> | |
| 150 | + <th className={cn(th, 'text-right')}>Listings</th> | |
| 151 | + <th className={cn(th, 'text-right')}>30D</th> | |
| 152 | + </tr> | |
| 153 | + </thead> | |
| 154 | + <tbody> | |
| 155 | + {withData.slice(0, 10).map((r) => ( | |
| 156 | + <tr key={r.node.slug} className="hover:bg-sunken"> | |
| 157 | + <td className={td}> | |
| 158 | + <Link href={`/markets/${r.node.slug}`} className="font-medium text-fg hover:underline"> | |
| 159 | + {r.node.name} | |
| 160 | + </Link> | |
| 161 | + </td> | |
| 162 | + <td className={tdNum}>{fmtNum(r.counts.assets)}</td> | |
| 163 | + <td className={tdNum}>{fmtNum(r.counts.sales)}</td> | |
| 164 | + <td className={tdNum}>{fmtNum(r.counts.listings)}</td> | |
| 165 | + <td className={tdNum} style={heatStyle(r.snapshot?.change30d)}> | |
| 143 | 166 | <Delta value={r.snapshot?.change30d} /> |
| 144 | − </span> | |
| 145 | − </Link> | |
| 146 | − </li> | |
| 147 | − ))} | |
| 148 | − </ul> | |
| 167 | + </td> | |
| 168 | + </tr> | |
| 169 | + ))} | |
| 170 | + </tbody> | |
| 171 | + </Table> | |
| 149 | 172 | ) : ( |
| 150 | − <EmptyState title="No category data yet" description={EMPTY} className="py-8" /> | |
| 173 | + <EmptyState title="No category data yet" description={EMPTY} compact /> | |
| 151 | 174 | )} |
| 152 | 175 | </Card> |
| 153 | 176 | ); |
@@ -165,55 +188,96 @@ export interface LatestObservation { | ||
| 165 | 188 | sourceName: string; |
| 166 | 189 | } |
| 167 | 190 | |
| 191 | +/** Latest transactions as a dense, attributed table; falls back to guide-price observations when no sale exists yet. */ | |
| 168 | 192 | export function LatestTransactions({ items, observations = [] }: { items: LatestSale[]; observations?: LatestObservation[] }) { |
| 169 | 193 | if (!items.length && observations.length) { |
| 170 | 194 | return ( |
| 171 | 195 | <Card> |
| 172 | − <CardHeader title="Latest guide price observations" subtitle="No verified sales indexed yet — these are price-guide observations (not transactions), shown with their source and date" action={<Link href="/explore?has=observations" className="text-muted hover:text-fg">Browse →</Link>} /> | |
| 173 | − <ul className="grid grid-cols-1 divide-y divide-border md:grid-cols-2 md:divide-y-0 lg:grid-cols-3"> | |
| 174 | − {observations.map((o) => ( | |
| 175 | − <li key={o.id} className="border-b border-border md:[&:nth-child(2n)]:border-l lg:[&:nth-child(2n)]:border-l-0 lg:[&:nth-child(3n+2)]:border-l lg:[&:nth-child(3n)]:border-l"> | |
| 176 | − <Link href={`/asset/${o.assetSlug}`} className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-sunken"> | |
| 177 | − <Thumb src={o.heroImageUrl} alt="" size={36} /> | |
| 178 | − <span className="min-w-0 flex-1"> | |
| 179 | − <span className="block truncate text-[13px] font-medium text-fg">{o.assetTitle}</span> | |
| 180 | − <span className="block truncate text-[11px] text-muted"> | |
| 181 | − {catName(o.categorySlug)} · {o.sourceName} · {humanize(o.priceKind)} · {fmtDate(o.observationDate)} | |
| 196 | + <CardHeader title="Latest guide price observations" subtitle="No verified sales indexed yet — price-guide observations (not transactions), with source and date" action={<Link href="/explore?has=observations" className="text-muted hover:text-fg">Browse →</Link>} /> | |
| 197 | + <Table> | |
| 198 | + <thead> | |
| 199 | + <tr> | |
| 200 | + <th className={th}>Asset</th> | |
| 201 | + <th className={th}>Kind</th> | |
| 202 | + <th className={th}>Source</th> | |
| 203 | + <th className={th}>Date</th> | |
| 204 | + <th className={cn(th, 'text-right')}>Price</th> | |
| 205 | + </tr> | |
| 206 | + </thead> | |
| 207 | + <tbody> | |
| 208 | + {observations.map((o) => ( | |
| 209 | + <tr key={o.id} className="hover:bg-sunken"> | |
| 210 | + <td className={cn(td, 'max-w-[320px]')}> | |
| 211 | + <span className="flex items-center gap-2.5"> | |
| 212 | + <Thumb src={o.heroImageUrl} alt="" size={32} /> | |
| 213 | + <span className="min-w-0"> | |
| 214 | + <Link href={`/asset/${o.assetSlug}`} className="block truncate font-medium text-fg hover:underline"> | |
| 215 | + {o.assetTitle} | |
| 216 | + </Link> | |
| 217 | + <span className="block truncate text-[11px] text-muted">{catName(o.categorySlug)}</span> | |
| 218 | + </span> | |
| 182 | 219 | </span> |
| 183 | − </span> | |
| 184 | − <span className="num text-[13px] font-semibold text-muted">{fmtMoney(o.priceUsd)}</span> | |
| 185 | − </Link> | |
| 186 | − </li> | |
| 187 | − ))} | |
| 188 | − </ul> | |
| 220 | + </td> | |
| 221 | + <td className={cn(td, 'text-muted')}>{humanize(o.priceKind)}</td> | |
| 222 | + <td className={cn(td, 'text-muted')}>{o.sourceName}</td> | |
| 223 | + <td className={cn(td, 'text-muted')}>{fmtDate(o.observationDate)}</td> | |
| 224 | + <td className={cn(tdNum, 'font-semibold text-muted')}>{fmtMoney(o.priceUsd)}</td> | |
| 225 | + </tr> | |
| 226 | + ))} | |
| 227 | + </tbody> | |
| 228 | + </Table> | |
| 189 | 229 | </Card> |
| 190 | 230 | ); |
| 191 | 231 | } |
| 192 | 232 | return ( |
| 193 | 233 | <Card> |
| 194 | − <CardHeader title="Latest transactions" subtitle="Most recent verified sales across sources" action={<Link href="/sales" className="text-muted hover:text-fg">All sales →</Link>} /> | |
| 234 | + <CardHeader title="Latest transactions" subtitle="Most recent verified sales across sources · USD at the sale-date exchange rate" action={<Link href="/sales" className="text-muted hover:text-fg">All sales →</Link>} /> | |
| 195 | 235 | {items.length ? ( |
| 196 | − <ul className="grid grid-cols-1 divide-y divide-border md:grid-cols-2 md:divide-y-0 lg:grid-cols-3"> | |
| 197 | − {items.map((s) => ( | |
| 198 | − <li key={s.id} className="border-b border-border md:[&:nth-child(2n)]:border-l lg:[&:nth-child(2n)]:border-l-0 lg:[&:nth-child(3n+2)]:border-l lg:[&:nth-child(3n)]:border-l"> | |
| 199 | − <Link href={`/asset/${s.assetSlug}`} className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-sunken"> | |
| 200 | − <Thumb src={s.heroImageUrl} alt="" size={36} /> | |
| 201 | − <span className="min-w-0 flex-1"> | |
| 202 | − <span className="block truncate text-[13px] font-medium text-fg">{s.assetTitle}</span> | |
| 203 | − <span className="flex items-center gap-1.5 text-[11px] text-muted"> | |
| 204 | − <GradeBadge grader={s.grader} grade={s.grade} /> | |
| 205 | − <span className="truncate"> | |
| 206 | − {fmtDate(s.saleDate)} · <SourceLink name={s.sourceName} url={null} /> | |
| 236 | + <Table> | |
| 237 | + <thead> | |
| 238 | + <tr> | |
| 239 | + <th className={th}>Asset</th> | |
| 240 | + <th className={th}>Grade</th> | |
| 241 | + <th className={th}>Type</th> | |
| 242 | + <th className={th}>Source</th> | |
| 243 | + <th className={th}>Date</th> | |
| 244 | + <th className={cn(th, 'text-right')}>Price</th> | |
| 245 | + </tr> | |
| 246 | + </thead> | |
| 247 | + <tbody> | |
| 248 | + {items.map((s) => ( | |
| 249 | + <tr key={s.id} className="hover:bg-sunken"> | |
| 250 | + <td className={cn(td, 'max-w-[320px]')}> | |
| 251 | + <span className="flex items-center gap-2.5"> | |
| 252 | + <Thumb src={s.heroImageUrl} alt="" size={32} /> | |
| 253 | + <span className="min-w-0"> | |
| 254 | + <Link href={`/asset/${s.assetSlug}`} className="block truncate font-medium text-fg hover:underline"> | |
| 255 | + {s.assetTitle} | |
| 256 | + </Link> | |
| 257 | + <span className="block truncate text-[11px] text-muted">{catName(s.categorySlug)}</span> | |
| 207 | 258 | </span> |
| 208 | 259 | </span> |
| 209 | − </span> | |
| 210 | − <span className="num text-[13px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span> | |
| 211 | − </Link> | |
| 212 | − </li> | |
| 213 | − ))} | |
| 214 | − </ul> | |
| 260 | + </td> | |
| 261 | + <td className={td}> | |
| 262 | + <GradeBadge grader={s.grader} grade={s.grade} /> | |
| 263 | + </td> | |
| 264 | + <td className={cn(td, 'text-muted')}>{humanize(s.saleType)}</td> | |
| 265 | + <td className={td}> | |
| 266 | + <SourceLink name={s.sourceName} url={s.sourceUrl} /> | |
| 267 | + </td> | |
| 268 | + <td className={cn(td, 'text-muted')}>{fmtDate(s.saleDate)}</td> | |
| 269 | + <td className={tdNum}> | |
| 270 | + <span className="flex flex-col items-end leading-tight"> | |
| 271 | + <span className="font-semibold text-fg">{fmtMoney(s.priceUsd)}</span> | |
| 272 | + {s.currency !== 'USD' ? <span className="text-[10px] text-subtle">{fmtMoney(s.price, s.currency)}</span> : null} | |
| 273 | + </span> | |
| 274 | + </td> | |
| 275 | + </tr> | |
| 276 | + ))} | |
| 277 | + </tbody> | |
| 278 | + </Table> | |
| 215 | 279 | ) : ( |
| 216 | − <EmptyState title="No transactions yet" description="Verified sales stream in here as connectors run." className="py-8" /> | |
| 280 | + <EmptyState title="No transactions yet" description="Verified sales stream in here as connectors run." compact /> | |
| 217 | 281 | )} |
| 218 | 282 | </Card> |
| 219 | 283 | ); |
modified
apps/web/src/components/layout/header-search.tsx
+10 −109
@@ -1,116 +1,17 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | |
| 3 | −import { useRouter } from 'next/navigation'; | |
| 4 | −import { Search } from 'lucide-react'; | |
| 5 | −import { useEffect, useRef, useState } from 'react'; | |
| 3 | +import { SearchTrigger } from '@/components/search/search-sheet'; | |
| 6 | 4 | |
| 7 | −interface Suggestion { | |
| 8 | − type: string; | |
| 9 | − label: string; | |
| 10 | − sublabel: string | null; | |
| 11 | − href: string; | |
| 12 | −} | |
| 13 | − | |
| 14 | −/** Header search with instant suggestions (/api/search/suggest); full results at /search. */ | |
| 5 | +/** Header search: a ⌘K field on desktop, an icon on mobile — both open the full search sheet. */ | |
| 15 | 6 | export function HeaderSearch() { |
| 16 | − const router = useRouter(); | |
| 17 | − const [q, setQ] = useState(''); | |
| 18 | − const [items, setItems] = useState<Suggestion[]>([]); | |
| 19 | − const [open, setOpen] = useState(false); | |
| 20 | − const [active, setActive] = useState(-1); | |
| 21 | − const box = useRef<HTMLFormElement>(null); | |
| 22 | − | |
| 23 | − useEffect(() => { | |
| 24 | − if (q.trim().length < 2) return; | |
| 25 | − const ctrl = new AbortController(); | |
| 26 | − const t = setTimeout(() => { | |
| 27 | − fetch(`/api/search/suggest?q=${encodeURIComponent(q)}`, { signal: ctrl.signal }) | |
| 28 | − .then((r) => r.json()) | |
| 29 | − .then((d: { items: Suggestion[] }) => { | |
| 30 | − setItems(d.items ?? []); | |
| 31 | − setOpen(true); | |
| 32 | − setActive(-1); | |
| 33 | − }) | |
| 34 | − .catch(() => {}); | |
| 35 | − }, 120); | |
| 36 | − return () => { | |
| 37 | − clearTimeout(t); | |
| 38 | − ctrl.abort(); | |
| 39 | − }; | |
| 40 | − }, [q]); | |
| 41 | − | |
| 42 | − useEffect(() => { | |
| 43 | − const onDoc = (e: MouseEvent) => { | |
| 44 | − if (!box.current?.contains(e.target as Node)) setOpen(false); | |
| 45 | − }; | |
| 46 | − document.addEventListener('mousedown', onDoc); | |
| 47 | − return () => document.removeEventListener('mousedown', onDoc); | |
| 48 | − }, []); | |
| 49 | − | |
| 50 | − const go = (href: string) => { | |
| 51 | − setOpen(false); | |
| 52 | − router.push(href); | |
| 53 | − }; | |
| 54 | − | |
| 55 | 7 | return ( |
| 56 | − <form | |
| 57 | − ref={box} | |
| 58 | − role="search" | |
| 59 | − className="relative hidden items-center md:flex" | |
| 60 | − onSubmit={(e) => { | |
| 61 | − e.preventDefault(); | |
| 62 | − if (active >= 0 && items[active]) return go(items[active]!.href); | |
| 63 | − const query = q.trim(); | |
| 64 | − if (query) go(`/search?q=${encodeURIComponent(query)}`); | |
| 65 | − }} | |
| 66 | − > | |
| 67 | − <Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-subtle" /> | |
| 68 | − <input | |
| 69 | − type="search" | |
| 70 | − value={q} | |
| 71 | − onChange={(e) => { | |
| 72 | − setQ(e.target.value); | |
| 73 | − if (e.target.value.trim().length < 2) { | |
| 74 | − setItems([]); | |
| 75 | − setOpen(false); | |
| 76 | − } | |
| 77 | − }} | |
| 78 | − role="combobox" | |
| 79 | − aria-controls="header-search-listbox" | |
| 80 | − onFocus={() => items.length && setOpen(true)} | |
| 81 | − onKeyDown={(e) => { | |
| 82 | − if (e.key === 'ArrowDown') { | |
| 83 | − e.preventDefault(); | |
| 84 | − setActive((a) => Math.min(items.length - 1, a + 1)); | |
| 85 | − } else if (e.key === 'ArrowUp') { | |
| 86 | − e.preventDefault(); | |
| 87 | − setActive((a) => Math.max(-1, a - 1)); | |
| 88 | − } else if (e.key === 'Escape') setOpen(false); | |
| 89 | − }} | |
| 90 | − placeholder="Search cards, watches, games, sneakers, LEGO…" | |
| 91 | − className="h-8 w-64 rounded-md border border-border bg-sunken pl-8 pr-2 text-[13px] text-fg placeholder:text-subtle transition-[width] focus:w-80 focus:border-border-strong focus:outline-none xl:w-80 xl:focus:w-96" | |
| 92 | − aria-label="Search collectibles" | |
| 93 | − aria-autocomplete="list" | |
| 94 | − aria-expanded={open} | |
| 95 | − autoComplete="off" | |
| 96 | − /> | |
| 97 | − {open && items.length ? ( | |
| 98 | − <ul id="header-search-listbox" className="absolute left-0 top-full z-50 mt-1 w-[26rem] overflow-hidden rounded-md border border-border bg-elevated py-1 text-[13px] shadow-pop" role="listbox"> | |
| 99 | − {items.map((it, i) => ( | |
| 100 | − <li key={`${it.type}-${it.href}`} role="option" aria-selected={i === active}> | |
| 101 | − <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => go(it.href)} className={`flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left ${i === active ? 'bg-inset' : 'hover:bg-sunken'}`}> | |
| 102 | − <span className="truncate text-fg">{it.label}</span> | |
| 103 | − <span className="shrink-0 text-[10px] uppercase tracking-wider text-subtle">{it.sublabel ?? it.type}</span> | |
| 104 | − </button> | |
| 105 | − </li> | |
| 106 | − ))} | |
| 107 | − <li> | |
| 108 | − <button type="submit" className="w-full px-3 py-1.5 text-left text-[12px] text-muted hover:bg-sunken"> | |
| 109 | − Search all results for “{q}” → | |
| 110 | − </button> | |
| 111 | − </li> | |
| 112 | − </ul> | |
| 113 | − ) : null} | |
| 114 | − </form> | |
| 8 | + <> | |
| 9 | + <div className="hidden md:block"> | |
| 10 | + <SearchTrigger variant="field" /> | |
| 11 | + </div> | |
| 12 | + <div className="md:hidden"> | |
| 13 | + <SearchTrigger variant="icon" /> | |
| 14 | + </div> | |
| 15 | + </> | |
| 115 | 16 | ); |
| 116 | 17 | } |
added
apps/web/src/components/layout/logo.tsx
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +/** The RareIndex mark: a square with an "R" and a rising tick — used in the header, PWA icons and OG images. */ | |
| 2 | +export function LogoMark({ className }: { className?: string }) { | |
| 3 | + return ( | |
| 4 | + <svg viewBox="0 0 64 64" className={className} aria-hidden focusable="false"> | |
| 5 | + <rect width="64" height="64" rx="12" fill="var(--ri-accent, #0a0a0b)" /> | |
| 6 | + <path d="M20 46V18h13.5c6.4 0 10.5 3.6 10.5 9.2 0 4.2-2.3 7.2-6.1 8.4L45 46h-7.3l-6.2-9.4H26.4V46H20zm6.4-14.6h6.5c3.2 0 5.1-1.6 5.1-4.2s-1.9-4.1-5.1-4.1h-6.5v8.3z" fill="var(--ri-accent-fg, #ffffff)" /> | |
| 7 | + <path d="M12 52l9-9 6 5 11-12 8 7" stroke="var(--ri-gain, #3ccb82)" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" opacity="0.95" /> | |
| 8 | + </svg> | |
| 9 | + ); | |
| 10 | +} | |
modified
apps/web/src/components/layout/mobile-nav.tsx
+39 −18
@@ -3,8 +3,9 @@ | ||
| 3 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { useEffect, useState } from 'react'; |
| 5 | 5 | import { usePathname } from 'next/navigation'; |
| 6 | −import { Menu, X } from 'lucide-react'; | |
| 6 | +import { ChevronRight, Menu, X } from 'lucide-react'; | |
| 7 | 7 | import type { NavItem } from '@/config/nav'; |
| 8 | +import { LogoMark } from './logo'; | |
| 8 | 9 | |
| 9 | 10 | export function MobileNav({ items, groups }: { items: NavItem[]; groups: Array<{ id: NavItem['group']; label: string }> }) { |
| 10 | 11 | const [open, setOpen] = useState(false); |
@@ -17,41 +18,61 @@ export function MobileNav({ items, groups }: { items: NavItem[]; groups: Array<{ | ||
| 17 | 18 | } |
| 18 | 19 | useEffect(() => { |
| 19 | 20 | document.body.style.overflow = open ? 'hidden' : ''; |
| 21 | + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); | |
| 22 | + window.addEventListener('keydown', onKey); | |
| 20 | 23 | return () => { |
| 21 | 24 | document.body.style.overflow = ''; |
| 25 | + window.removeEventListener('keydown', onKey); | |
| 22 | 26 | }; |
| 23 | 27 | }, [open]); |
| 24 | 28 | return ( |
| 25 | 29 | <div className="lg:hidden"> |
| 26 | − <button type="button" aria-label="Open menu" aria-expanded={open} className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-inset hover:text-fg" onClick={() => setOpen(true)}> | |
| 30 | + <button type="button" aria-label="Open menu" aria-expanded={open} className="-ml-1 inline-flex h-10 w-10 items-center justify-center rounded-md text-fg hover:bg-inset" onClick={() => setOpen(true)}> | |
| 27 | 31 | <Menu className="h-5 w-5" /> |
| 28 | 32 | </button> |
| 29 | 33 | {open ? ( |
| 30 | − <div className="fixed inset-0 z-50 flex" role="dialog" aria-modal="true"> | |
| 31 | − <div className="absolute inset-0 bg-black/40" onClick={() => setOpen(false)} /> | |
| 32 | − <div className="relative flex h-[100dvh] w-[86vw] max-w-sm flex-col overflow-y-auto bg-elevated shadow-pop safe-bottom"> | |
| 33 | − <div className="flex h-14 items-center justify-between border-b border-border px-4"> | |
| 34 | − <span className="text-sm font-semibold">Menu</span> | |
| 35 | − <button type="button" aria-label="Close menu" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-inset" onClick={() => setOpen(false)}> | |
| 34 | + <div className="fixed inset-0 z-[65] flex" role="dialog" aria-modal="true" aria-label="Menu"> | |
| 35 | + <div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={() => setOpen(false)} /> | |
| 36 | + <div className="sheet-in relative flex h-[100dvh] w-[88vw] max-w-sm flex-col bg-elevated shadow-pop"> | |
| 37 | + <div className="flex h-[var(--ri-header-h)] items-center justify-between border-b border-border px-4" style={{ marginTop: 'env(safe-area-inset-top)' }}> | |
| 38 | + <span className="flex items-center gap-2"> | |
| 39 | + <LogoMark className="h-6 w-6" /> | |
| 40 | + <span className="text-[15px] font-semibold tracking-tight"> | |
| 41 | + Rare<span className="text-muted">Index</span> | |
| 42 | + </span> | |
| 43 | + </span> | |
| 44 | + <button type="button" aria-label="Close menu" className="inline-flex h-10 w-10 items-center justify-center rounded-md text-muted hover:bg-inset" onClick={() => setOpen(false)}> | |
| 36 | 45 | <X className="h-5 w-5" /> |
| 37 | 46 | </button> |
| 38 | 47 | </div> |
| 39 | − <nav className="flex-1 px-2 py-3"> | |
| 48 | + <nav className="flex-1 overflow-y-auto overscroll-contain px-2 py-3 safe-bottom"> | |
| 40 | 49 | {groups.map((g) => ( |
| 41 | 50 | <div key={g.id} className="mb-3"> |
| 42 | − <p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-subtle">{g.label}</p> | |
| 51 | + <p className="t-label px-2 pb-1">{g.label}</p> | |
| 43 | 52 | {items |
| 44 | 53 | .filter((n) => n.group === g.id) |
| 45 | − .map((n) => ( | |
| 46 | − <Link key={n.href} href={n.href} className={`block rounded-md px-2 py-2 text-[15px] ${pathname === n.href ? 'bg-inset font-medium text-fg' : 'text-muted'}`}> | |
| 47 | − {n.label} | |
| 48 | − </Link> | |
| 49 | − ))} | |
| 54 | + .map((n) => { | |
| 55 | + const active = pathname === n.href; | |
| 56 | + return ( | |
| 57 | + <Link key={n.href} href={n.href} className={`flex min-h-[44px] items-center justify-between gap-3 rounded-md px-2 py-2 ${active ? 'bg-inset' : 'hover:bg-sunken'}`}> | |
| 58 | + <span className="min-w-0"> | |
| 59 | + <span className={`block text-[15px] ${active ? 'font-medium text-fg' : 'text-fg'}`}>{n.label}</span> | |
| 60 | + <span className="block truncate text-[11px] text-subtle">{n.description}</span> | |
| 61 | + </span> | |
| 62 | + <ChevronRight className="h-4 w-4 shrink-0 text-subtle" /> | |
| 63 | + </Link> | |
| 64 | + ); | |
| 65 | + })} | |
| 50 | 66 | </div> |
| 51 | 67 | ))} |
| 52 | − <Link href="/account" className="mt-2 block rounded-md border border-border px-3 py-2 text-center text-sm font-medium"> | |
| 53 | − Sign in | |
| 54 | − </Link> | |
| 68 | + <div className="mt-2 grid grid-cols-2 gap-2 px-1"> | |
| 69 | + <Link href="/login" className="btn h-11"> | |
| 70 | + Sign in | |
| 71 | + </Link> | |
| 72 | + <Link href="/signup" className="btn-primary h-11"> | |
| 73 | + Create account | |
| 74 | + </Link> | |
| 75 | + </div> | |
| 55 | 76 | </nav> |
| 56 | 77 | </div> |
| 57 | 78 | </div> |
modified
apps/web/src/components/layout/site-footer.tsx
+19 −4
@@ -6,15 +6,19 @@ export function SiteFooter() { | ||
| 6 | 6 | return ( |
| 7 | 7 | <footer className="mt-16 border-t border-border bg-sunken"> |
| 8 | 8 | <div className="mx-auto max-w-[1440px] px-4 py-10 sm:px-6"> |
| 9 | − <div className="grid grid-cols-2 gap-8 md:grid-cols-6"> | |
| 9 | + <div className="grid grid-cols-2 gap-x-6 gap-y-8 md:grid-cols-6"> | |
| 10 | 10 | <div className="col-span-2 md:col-span-2"> |
| 11 | 11 | <Logo /> |
| 12 | 12 | <p className="mt-3 max-w-xs text-xs leading-relaxed text-muted">The global data layer for collectible assets: prices, sales, listings, rarity, liquidity and indices across marketplaces and auction houses.</p> |
| 13 | + <p className="mt-3 flex items-center gap-1.5 text-[11px] text-subtle"> | |
| 14 | + <span className="live-dot inline-block h-1.5 w-1.5 rounded-full bg-gain" aria-hidden /> | |
| 15 | + Pipelines running · data refreshed continuously | |
| 16 | + </p> | |
| 13 | 17 | </div> |
| 14 | 18 | {NAV_GROUPS.filter((g) => g.id !== 'discover').map((g) => ( |
| 15 | 19 | <div key={g.id}> |
| 16 | − <p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-subtle">{g.label}</p> | |
| 17 | − <ul className="space-y-1"> | |
| 20 | + <p className="t-label mb-2">{g.label}</p> | |
| 21 | + <ul className="space-y-1.5"> | |
| 18 | 22 | {NAV.filter((n) => n.group === g.id).map((n) => ( |
| 19 | 23 | <li key={n.href}> |
| 20 | 24 | <Link href={n.href} className="text-xs text-muted hover:text-fg"> |
@@ -34,7 +38,18 @@ export function SiteFooter() { | ||
| 34 | 38 | </Link> |
| 35 | 39 | . |
| 36 | 40 | </p> |
| 37 | − <p className="mt-2">© {new Date().getFullYear()} RareIndex.io</p> | |
| 41 | + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 42 | + <span>© {new Date().getFullYear()} RareIndex.io</span> | |
| 43 | + <Link href="/data" className="hover:text-fg"> | |
| 44 | + Methodology | |
| 45 | + </Link> | |
| 46 | + <Link href="/api-docs" className="hover:text-fg"> | |
| 47 | + API | |
| 48 | + </Link> | |
| 49 | + <Link href="/about" className="hover:text-fg"> | |
| 50 | + About | |
| 51 | + </Link> | |
| 52 | + </p> | |
| 38 | 53 | </div> |
| 39 | 54 | </div> |
| 40 | 55 | </footer> |
modified
apps/web/src/components/layout/site-header.tsx
+18 −14
@@ -4,41 +4,45 @@ import { ThemeToggle } from './theme-toggle'; | ||
| 4 | 4 | import { MobileNav } from './mobile-nav'; |
| 5 | 5 | import { HeaderSearch } from './header-search'; |
| 6 | 6 | import { UserMenu } from '@/components/account/user-menu'; |
| 7 | +import { LogoMark } from './logo'; | |
| 7 | 8 | |
| 8 | −export function Logo({ className }: { className?: string }) { | |
| 9 | +export function Logo({ className, compact = false }: { className?: string; compact?: boolean }) { | |
| 9 | 10 | return ( |
| 10 | 11 | <Link href="/" className={`flex items-center gap-2 ${className ?? ''}`} aria-label="RareIndex home"> |
| 11 | − <span className="inline-flex h-6 w-6 items-center justify-center rounded-sm bg-accent text-[11px] font-bold text-accent-fg">R</span> | |
| 12 | − <span className="text-[15px] font-semibold tracking-tight text-fg"> | |
| 13 | − Rare<span className="text-muted">Index</span> | |
| 14 | − </span> | |
| 12 | + <LogoMark className="h-6 w-6" /> | |
| 13 | + {!compact ? ( | |
| 14 | + <span className="text-[15px] font-semibold tracking-tight text-fg"> | |
| 15 | + Rare<span className="text-muted">Index</span> | |
| 16 | + </span> | |
| 17 | + ) : null} | |
| 15 | 18 | </Link> |
| 16 | 19 | ); |
| 17 | 20 | } |
| 18 | 21 | |
| 19 | 22 | export function SiteHeader() { |
| 20 | 23 | return ( |
| 21 | − <header className="sticky top-0 z-40 border-b border-border bg-bg/85 backdrop-blur supports-[backdrop-filter]:bg-bg/70"> | |
| 22 | − <div className="mx-auto flex h-14 max-w-[1440px] items-center gap-3 px-4 sm:px-6"> | |
| 24 | + <header className="sticky top-0 z-40 border-b border-border bg-bg/85 backdrop-blur-md supports-[backdrop-filter]:bg-bg/70" style={{ paddingTop: 'env(safe-area-inset-top)' }}> | |
| 25 | + <div className="mx-auto flex h-[var(--ri-header-h)] max-w-[1440px] items-center gap-2 px-3 sm:px-6"> | |
| 23 | 26 | <MobileNav items={NAV} groups={NAV_GROUPS} /> |
| 24 | 27 | <Logo /> |
| 25 | − <nav className="ml-4 hidden items-center gap-1 lg:flex" aria-label="Primary"> | |
| 28 | + <nav className="ml-3 hidden items-center gap-0.5 lg:flex" aria-label="Primary"> | |
| 26 | 29 | {PRIMARY_NAV.map((n) => ( |
| 27 | 30 | <Link key={n.href} href={n.href} className="rounded-md px-2.5 py-1.5 text-[13px] font-medium text-muted hover:bg-inset hover:text-fg"> |
| 28 | 31 | {n.label} |
| 29 | 32 | </Link> |
| 30 | 33 | ))} |
| 31 | 34 | <details className="group relative"> |
| 32 | − <summary className="cursor-pointer list-none rounded-md px-2.5 py-1.5 text-[13px] font-medium text-muted hover:bg-inset hover:text-fg [&::-webkit-details-marker]:hidden">More</summary> | |
| 33 | − <div className="absolute left-0 top-full z-50 mt-1 grid w-[640px] grid-cols-3 gap-4 rounded-lg border border-border bg-elevated p-4 shadow-pop"> | |
| 35 | + <summary className="plain cursor-pointer rounded-md px-2.5 py-1.5 text-[13px] font-medium text-muted hover:bg-inset hover:text-fg group-open:bg-inset group-open:text-fg">More</summary> | |
| 36 | + <div className="absolute left-0 top-full z-50 mt-1.5 grid w-[680px] grid-cols-3 gap-5 rounded-lg border border-border bg-elevated p-4 shadow-pop"> | |
| 34 | 37 | {NAV_GROUPS.map((g) => ( |
| 35 | 38 | <div key={g.id}> |
| 36 | − <p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-subtle">{g.label}</p> | |
| 39 | + <p className="t-label mb-1.5">{g.label}</p> | |
| 37 | 40 | <ul className="space-y-0.5"> |
| 38 | 41 | {NAV.filter((n) => n.group === g.id).map((n) => ( |
| 39 | 42 | <li key={n.href}> |
| 40 | − <Link href={n.href} className="block rounded-sm px-1.5 py-1 text-[13px] text-muted hover:bg-inset hover:text-fg"> | |
| 41 | − {n.label} | |
| 43 | + <Link href={n.href} className="block rounded-sm px-1.5 py-1 hover:bg-inset"> | |
| 44 | + <span className="block text-[13px] text-fg">{n.label}</span> | |
| 45 | + <span className="block truncate text-[11px] text-subtle">{n.description}</span> | |
| 42 | 46 | </Link> |
| 43 | 47 | </li> |
| 44 | 48 | ))} |
@@ -48,7 +52,7 @@ export function SiteHeader() { | ||
| 48 | 52 | </div> |
| 49 | 53 | </details> |
| 50 | 54 | </nav> |
| 51 | − <div className="ml-auto flex items-center gap-1.5"> | |
| 55 | + <div className="ml-auto flex items-center gap-1"> | |
| 52 | 56 | <HeaderSearch /> |
| 53 | 57 | <ThemeToggle /> |
| 54 | 58 | <UserMenu /> |
added
apps/web/src/components/layout/tab-bar.tsx
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { usePathname } from 'next/navigation'; | |
| 5 | +import { Compass, Home, LineChart, ScanLine, UserRound } from 'lucide-react'; | |
| 6 | +import { cn } from '@/lib/format'; | |
| 7 | + | |
| 8 | +const TABS = [ | |
| 9 | + { href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' }, | |
| 10 | + { href: '/explore', label: 'Explore', icon: Compass, match: (p: string) => p.startsWith('/explore') || p.startsWith('/search') || p.startsWith('/asset') || p.startsWith('/set') || p.startsWith('/brand') }, | |
| 11 | + { href: '/markets', label: 'Markets', icon: LineChart, match: (p: string) => p.startsWith('/markets') || p.startsWith('/rareindex') || p.startsWith('/trending') || p.startsWith('/sales') || p.startsWith('/listings') || p.startsWith('/auctions') || p.startsWith('/market-map') }, | |
| 12 | + { href: '/scanner', label: 'Scanner', icon: ScanLine, match: (p: string) => p.startsWith('/scanner') || p.startsWith('/research') }, | |
| 13 | + { href: '/account', label: 'Account', icon: UserRound, match: (p: string) => p.startsWith('/account') || p.startsWith('/collections') || p.startsWith('/watchlist') || p.startsWith('/alerts') || p.startsWith('/login') || p.startsWith('/signup') || p.startsWith('/notifications') }, | |
| 14 | +]; | |
| 15 | + | |
| 16 | +/** Mobile bottom tab bar (§165): five primary destinations, safe-area aware, hidden from md up. */ | |
| 17 | +export function TabBar() { | |
| 18 | + const pathname = usePathname() ?? '/'; | |
| 19 | + if (pathname.startsWith('/admin')) return null; | |
| 20 | + return ( | |
| 21 | + <nav className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-elevated/92 backdrop-blur-md md:hidden" aria-label="Primary mobile" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}> | |
| 22 | + <ul className="grid h-[var(--ri-tabbar-h)] grid-cols-5"> | |
| 23 | + {TABS.map((t) => { | |
| 24 | + const active = t.match(pathname); | |
| 25 | + const Icon = t.icon; | |
| 26 | + return ( | |
| 27 | + <li key={t.href}> | |
| 28 | + <Link href={t.href} aria-current={active ? 'page' : undefined} className={cn('flex h-full flex-col items-center justify-center gap-0.5 text-[10px] font-medium', active ? 'text-fg' : 'text-subtle')}> | |
| 29 | + <span className={cn('flex h-7 w-12 items-center justify-center rounded-full transition-colors', active && 'bg-inset')}> | |
| 30 | + <Icon className="h-[18px] w-[18px]" strokeWidth={active ? 2.25 : 1.75} /> | |
| 31 | + </span> | |
| 32 | + {t.label} | |
| 33 | + </Link> | |
| 34 | + </li> | |
| 35 | + ); | |
| 36 | + })} | |
| 37 | + </ul> | |
| 38 | + </nav> | |
| 39 | + ); | |
| 40 | +} | |
modified
apps/web/src/components/market/asset-browser.tsx
+5 −5
@@ -71,9 +71,9 @@ export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in thi | ||
| 71 | 71 | </form> |
| 72 | 72 | } |
| 73 | 73 | /> |
| 74 | − <div className="flex flex-col gap-2 border-b border-border px-4 py-2 text-[11px]"> | |
| 74 | + <div className="flex flex-col gap-1.5 border-b border-border px-4 py-2 text-[11px]"> | |
| 75 | 75 | {subcategories.length ? ( |
| 76 | − <div className="flex flex-wrap items-center gap-1"> | |
| 76 | + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1"> | |
| 77 | 77 | <span className="mr-1 text-subtle">Subcategory</span> |
| 78 | 78 | <Chip href={link({ sub: null })} active={!sub}> |
| 79 | 79 | All |
@@ -85,7 +85,7 @@ export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in thi | ||
| 85 | 85 | ))} |
| 86 | 86 | </div> |
| 87 | 87 | ) : null} |
| 88 | − <div className="flex flex-wrap items-center gap-1"> | |
| 88 | + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1"> | |
| 89 | 89 | <span className="mr-1 text-subtle">Show</span> |
| 90 | 90 | {HAS.map((h) => ( |
| 91 | 91 | <Chip key={h.id || 'all'} href={link({ has: h.id || null })} active={(has ?? '') === h.id}> |
@@ -93,7 +93,7 @@ export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in thi | ||
| 93 | 93 | </Chip> |
| 94 | 94 | ))} |
| 95 | 95 | </div> |
| 96 | − <div className="flex flex-wrap items-center gap-1"> | |
| 96 | + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1"> | |
| 97 | 97 | <span className="mr-1 text-subtle">Sort</span> |
| 98 | 98 | {SORTS.map((s) => ( |
| 99 | 99 | <Chip key={s} href={link({ sort: s === 'relevance' ? null : s })} active={sort === s}> |
@@ -134,7 +134,7 @@ export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in thi | ||
| 134 | 134 | |
| 135 | 135 | function Chip({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) { |
| 136 | 136 | return ( |
| 137 | − <Link href={href} scroll={false} className={cn('rounded-full border px-2 py-0.5', active ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:border-border-strong hover:text-fg')}> | |
| 137 | + <Link href={href} scroll={false} className={cn('chip h-7 text-[11px]', active && 'chip-active')}> | |
| 138 | 138 | {children} |
| 139 | 139 | </Link> |
| 140 | 140 | ); |
modified
apps/web/src/components/market/asset-list.tsx
+20 −18
@@ -4,6 +4,7 @@ import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format | ||
| 4 | 4 | import { catName } from '@/lib/taxonomy'; |
| 5 | 5 | import { Delta, Table, th, td, tdNum, EmptyState } from '@/components/ui/primitives'; |
| 6 | 6 | import { Thumb } from './bits'; |
| 7 | +import { PriceLine, TileImage } from './asset-tile'; | |
| 7 | 8 | |
| 8 | 9 | export type AssetColumn = 'riv' | 'change1d' | 'change7d' | 'change30d' | 'change1y' | 'latestSale' | 'sales' | 'sales30d' | 'listings' | 'minAsk' | 'liquidity' | 'rarity' | 'trending' | 'opportunity' | 'watchers' | 'updated'; |
| 9 | 10 | |
@@ -134,39 +135,40 @@ export function AssetTable({ items, columns = DEFAULT_COLUMNS, rank = false, sta | ||
| 134 | 135 | ); |
| 135 | 136 | } |
| 136 | 137 | |
| 137 | −/** Compact card for mobile grids and discovery rails. */ | |
| 138 | +/** Compact horizontal card for mobile lists and grids: image, title, set/year, honest price line, one metric. */ | |
| 138 | 139 | export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCardData; className?: string; metric?: 'change30d' | 'change7d' | 'change1d' | 'latestSale' | 'opportunity' | 'trending' }) { |
| 140 | + const value = metric === 'change1d' ? a.change1d : metric === 'change7d' ? a.change7d : metric === 'change30d' ? a.change30d : null; | |
| 139 | 141 | const metricNode = |
| 140 | 142 | metric === 'latestSale' ? ( |
| 141 | − <span className="text-[11px] text-muted">{a.latestSaleUsd === null ? (a.number ? `#${a.number}` : a.setName ?? '') : `Last sale ${fmtMoney(a.latestSaleUsd)}`}</span> | |
| 143 | + a.latestSaleUsd !== null && a.rivUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : a.number ? <span className="num text-[11px] text-subtle">#{a.number}</span> : null | |
| 142 | 144 | ) : metric === 'opportunity' ? ( |
| 143 | − <span className="text-[11px] text-muted"> | |
| 144 | − Opportunity <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[11px]" /> | |
| 145 | − </span> | |
| 145 | + a.valueOpportunity !== null ? <span className="text-[11px] text-muted">vs RIV <Delta value={-a.valueOpportunity} className="text-[11px]" /></span> : null | |
| 146 | 146 | ) : metric === 'trending' ? ( |
| 147 | − <span className="text-[11px] text-muted">Trending {a.trendingScore === null ? '—' : a.trendingScore.toFixed(1)}</span> | |
| 148 | − ) : ( | |
| 147 | + a.trendingScore !== null ? <span className="num text-[11px] text-muted">trend {a.trendingScore.toFixed(1)}</span> : null | |
| 148 | + ) : value !== null ? ( | |
| 149 | 149 | <span className="text-[11px] text-muted"> |
| 150 | − {metric === 'change1d' ? '1D' : metric === 'change7d' ? '7D' : '30D'} <Delta value={metric === 'change1d' ? a.change1d : metric === 'change7d' ? a.change7d : a.change30d} className="text-[11px]" /> | |
| 150 | + {metric === 'change1d' ? '1D' : metric === 'change7d' ? '7D' : '30D'} <Delta value={value} arrow className="text-[11px]" /> | |
| 151 | 151 | </span> |
| 152 | − ); | |
| 152 | + ) : a.salesCount ? ( | |
| 153 | + <span className="num text-[11px] text-subtle">{fmtNum(a.salesCount)} sales</span> | |
| 154 | + ) : null; | |
| 153 | 155 | return ( |
| 154 | − <Link href={`/asset/${a.slug}`} className={cn('card flex gap-3 p-3 transition-colors hover:border-border-strong', className)}> | |
| 155 | − <Thumb src={a.heroImageUrl} alt="" size={56} rounded="rounded-md" /> | |
| 156 | + <Link href={`/asset/${a.slug}`} className={cn('card card-hover group flex min-h-[88px] gap-3 p-2.5', className)}> | |
| 157 | + <span className="w-16 shrink-0"> | |
| 158 | + <TileImage src={a.heroImageUrl} alt="" familySlug={a.familySlug} sizes="64px" /> | |
| 159 | + </span> | |
| 156 | 160 | <span className="flex min-w-0 flex-1 flex-col"> |
| 157 | − <span className="truncate text-[13px] font-medium text-fg" title={a.title}> | |
| 161 | + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={a.title}> | |
| 158 | 162 | {a.title} |
| 159 | 163 | </span> |
| 160 | − <span className="truncate text-[11px] text-muted"> | |
| 164 | + <span className="mt-0.5 truncate text-[11px] text-muted"> | |
| 161 | 165 | {catName(a.categorySlug)} |
| 162 | 166 | {a.setName ? ` · ${a.setName}` : ''} |
| 163 | 167 | {a.year ? ` · ${a.year}` : ''} |
| 164 | 168 | </span> |
| 165 | − <span className="mt-auto flex items-baseline justify-between gap-2 pt-1"> | |
| 166 | − <span className="num text-sm font-semibold text-fg" title={a.rivUsd === null ? (a.guideUsd != null ? `Price-guide observation from ${a.guideSource ?? 'a guide'} (${a.guideDate ?? ''}) — not a transaction` : 'Insufficient evidence for a valuation') : `RareIndex Valuation · ${confidenceLabel(a.rivConfidence)} confidence · ${a.rivSampleSize} sales`}> | |
| 167 | − {a.rivUsd === null ? (a.guideUsd != null ? <span className="font-medium text-muted">{fmtMoney(a.guideUsd)} <span className="text-[10px] font-normal text-subtle">guide</span></span> : <span className="text-subtle">RIV —</span>) : fmtMoney(a.rivUsd)} | |
| 168 | − </span> | |
| 169 | − {metricNode} | |
| 169 | + <span className="mt-auto flex items-end justify-between gap-2 pt-1.5"> | |
| 170 | + <PriceLine a={a} size="sm" /> | |
| 171 | + <span className="shrink-0 text-right">{metricNode}</span> | |
| 170 | 172 | </span> |
| 171 | 173 | </span> |
| 172 | 174 | </Link> |
added
apps/web/src/components/market/asset-tile.tsx
+150 −0
@@ -0,0 +1,150 @@ | ||
| 1 | +import Image from 'next/image'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import type { ReactNode } from 'react'; | |
| 4 | +import type { AssetCard as AssetCardData } from '@/lib/queries/assets'; | |
| 5 | +import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format'; | |
| 6 | +import { catName } from '@/lib/taxonomy'; | |
| 7 | +import { Delta } from '@/components/ui/primitives'; | |
| 8 | + | |
| 9 | +export type TileMetric = 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new' | 'guide' | 'latestSale'; | |
| 10 | + | |
| 11 | +/** Object image: cards/comics are portrait (4:5), everything else squares; always `contain` on a neutral field. */ | |
| 12 | +function aspectFor(familySlug: string): string { | |
| 13 | + return familySlug === 'trading_cards' || familySlug === 'sports_cards' || familySlug === 'comics' || familySlug === 'manga' || familySlug === 'books' ? 'aspect-[4/5]' : 'aspect-square'; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function TileImage({ src, alt, familySlug, sizes = '(min-width: 1280px) 200px, (min-width: 768px) 25vw, 45vw', priority = false, className }: { src: string | null | undefined; alt: string; familySlug: string; sizes?: string; priority?: boolean; className?: string }) { | |
| 17 | + return ( | |
| 18 | + <span className={cn('relative block w-full overflow-hidden rounded-[5px] bg-sunken', aspectFor(familySlug), className)}> | |
| 19 | + {src ? ( | |
| 20 | + <Image src={src} alt={alt} fill sizes={sizes} priority={priority} loading={priority ? undefined : 'lazy'} className="object-contain p-2 transition-transform duration-300 ease-out group-hover:scale-[1.03]" unoptimized={src.endsWith('.svg')} /> | |
| 21 | + ) : ( | |
| 22 | + <span className="absolute inset-0 flex items-center justify-center"> | |
| 23 | + <span className="t-label">No image</span> | |
| 24 | + </span> | |
| 25 | + )} | |
| 26 | + </span> | |
| 27 | + ); | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** Price line: RIV with confidence, or the guide price honestly labelled, or the latest sale. */ | |
| 31 | +export function PriceLine({ a, size = 'md', className }: { a: AssetCardData; size?: 'sm' | 'md' | 'lg'; className?: string }) { | |
| 32 | + const valueCls = size === 'lg' ? 'text-xl' : size === 'sm' ? 'text-[13px]' : 'text-[15px]'; | |
| 33 | + if (a.rivUsd !== null) { | |
| 34 | + return ( | |
| 35 | + <span className={cn('flex flex-col leading-tight', className)} title={`RareIndex Valuation · ${confidenceLabel(a.rivConfidence)} confidence · ${a.rivSampleSize} sales`}> | |
| 36 | + <span className={cn('num font-semibold text-fg', valueCls)}>{fmtMoney(a.rivUsd)}</span> | |
| 37 | + <span className="t-caption"> | |
| 38 | + RIV · {confidenceLabel(a.rivConfidence)} · n={a.rivSampleSize} | |
| 39 | + </span> | |
| 40 | + </span> | |
| 41 | + ); | |
| 42 | + } | |
| 43 | + if (a.guideUsd !== null && a.guideUsd !== undefined) { | |
| 44 | + return ( | |
| 45 | + <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`}> | |
| 46 | + <span className={cn('num font-semibold text-muted', valueCls)}>{fmtMoney(a.guideUsd)}</span> | |
| 47 | + <span className="t-caption">Guide · {a.guideSource ?? a.guideKind ?? 'observed'}</span> | |
| 48 | + </span> | |
| 49 | + ); | |
| 50 | + } | |
| 51 | + if (a.latestSaleUsd !== null) { | |
| 52 | + return ( | |
| 53 | + <span className={cn('flex flex-col leading-tight', className)}> | |
| 54 | + <span className={cn('num font-semibold text-fg', valueCls)}>{fmtMoney(a.latestSaleUsd)}</span> | |
| 55 | + <span className="t-caption">Last sale · {fmtRelative(a.latestSaleAt)}</span> | |
| 56 | + </span> | |
| 57 | + ); | |
| 58 | + } | |
| 59 | + return ( | |
| 60 | + <span className={cn('flex flex-col leading-tight', className)} title="Insufficient evidence for a valuation"> | |
| 61 | + <span className={cn('num font-semibold text-subtle', valueCls)}>—</span> | |
| 62 | + <span className="t-caption">Catalogued · no price yet</span> | |
| 63 | + </span> | |
| 64 | + ); | |
| 65 | +} | |
| 66 | + | |
| 67 | +function MetricNode({ a, metric }: { a: AssetCardData; metric: TileMetric }) { | |
| 68 | + switch (metric) { | |
| 69 | + case 'change1d': | |
| 70 | + return <Delta value={a.change1d} arrow className="text-[12px]" />; | |
| 71 | + case 'change7d': | |
| 72 | + return <Delta value={a.change7d} arrow className="text-[12px]" />; | |
| 73 | + case 'change30d': | |
| 74 | + return <Delta value={a.change30d} arrow className="text-[12px]" />; | |
| 75 | + case 'trending': | |
| 76 | + return <span className="num text-[11px] text-muted">score {a.trendingScore?.toFixed(1) ?? '—'}</span>; | |
| 77 | + case 'watchers': | |
| 78 | + return <span className="num text-[11px] text-muted">{fmtNum(a.watchers)} watching</span>; | |
| 79 | + case 'opportunity': | |
| 80 | + return ( | |
| 81 | + <span className="text-[11px] text-muted"> | |
| 82 | + vs RIV <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[12px]" /> | |
| 83 | + </span> | |
| 84 | + ); | |
| 85 | + case 'new': | |
| 86 | + return <span className="text-[11px] text-muted">added {fmtRelative(a.updatedAt)}</span>; | |
| 87 | + case 'riv': | |
| 88 | + return <span className="num text-[11px] text-muted">{fmtNum(a.salesCount)} sales</span>; | |
| 89 | + case 'latestSale': | |
| 90 | + return a.latestSaleUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : null; | |
| 91 | + default: | |
| 92 | + return null; | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** Poster-style asset tile for rails and grids. */ | |
| 97 | +export function AssetTile({ a, metric = 'change30d', className, priority = false, width = 'w-[164px] sm:w-[188px]' }: { a: AssetCardData; metric?: TileMetric; className?: string; priority?: boolean; width?: string }) { | |
| 98 | + return ( | |
| 99 | + <Link href={`/asset/${a.slug}`} className={cn('card card-hover group flex flex-col gap-2 p-2', width, className)}> | |
| 100 | + <TileImage src={a.heroImageUrl} alt="" familySlug={a.familySlug} priority={priority} /> | |
| 101 | + <span className="flex min-h-[2.6em] flex-col"> | |
| 102 | + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={a.title}> | |
| 103 | + {a.title} | |
| 104 | + </span> | |
| 105 | + <span className="mt-0.5 truncate text-[11px] text-muted"> | |
| 106 | + {a.setName ?? catName(a.categorySlug)} | |
| 107 | + {a.year ? ` · ${a.year}` : ''} | |
| 108 | + </span> | |
| 109 | + </span> | |
| 110 | + <span className="mt-auto flex items-end justify-between gap-2 border-t border-border pt-2"> | |
| 111 | + <PriceLine a={a} size="sm" /> | |
| 112 | + <span className="shrink-0 text-right"> | |
| 113 | + <MetricNode a={a} metric={metric} /> | |
| 114 | + </span> | |
| 115 | + </span> | |
| 116 | + </Link> | |
| 117 | + ); | |
| 118 | +} | |
| 119 | + | |
| 120 | +/** Horizontal snap rail on small screens; wraps into a grid from lg. */ | |
| 121 | +export function Rail({ children, className, cols = 'lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8' }: { children: ReactNode; className?: string; cols?: string }) { | |
| 122 | + return <div className={cn('rail lg:mx-0 lg:grid lg:overflow-visible lg:px-0 lg:[&>*]:w-auto', cols, className)}>{children}</div>; | |
| 123 | +} | |
| 124 | + | |
| 125 | +export function RailHeader({ title, subtitle, href, hrefLabel = 'View all', className }: { title: ReactNode; subtitle?: ReactNode; href?: string; hrefLabel?: string; className?: string }) { | |
| 126 | + return ( | |
| 127 | + <div className={cn('mb-3 flex items-end justify-between gap-3', className)}> | |
| 128 | + <div className="min-w-0"> | |
| 129 | + <h2 className="t-title text-fg">{title}</h2> | |
| 130 | + {subtitle ? <p className="mt-0.5 truncate text-xs text-muted">{subtitle}</p> : null} | |
| 131 | + </div> | |
| 132 | + {href ? ( | |
| 133 | + <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg"> | |
| 134 | + {hrefLabel} → | |
| 135 | + </Link> | |
| 136 | + ) : null} | |
| 137 | + </div> | |
| 138 | + ); | |
| 139 | +} | |
| 140 | + | |
| 141 | +export function TileSkeleton({ width = 'w-[164px] sm:w-[188px]' }: { width?: string }) { | |
| 142 | + return ( | |
| 143 | + <div className={cn('card flex flex-col gap-2 p-2', width)} aria-hidden> | |
| 144 | + <div className="aspect-[4/5] w-full animate-pulse rounded-[5px] bg-inset" /> | |
| 145 | + <div className="h-3 w-4/5 animate-pulse rounded-sm bg-inset" /> | |
| 146 | + <div className="h-3 w-1/2 animate-pulse rounded-sm bg-inset" /> | |
| 147 | + <div className="mt-2 h-4 w-1/3 animate-pulse rounded-sm bg-inset" /> | |
| 148 | + </div> | |
| 149 | + ); | |
| 150 | +} | |
modified
apps/web/src/components/market/category-grid.tsx
+24 −14
@@ -1,27 +1,37 @@ | ||
| 1 | +import Image from 'next/image'; | |
| 1 | 2 | import Link from 'next/link'; |
| 2 | 3 | import type { MarketRow } from '@/lib/queries/markets'; |
| 3 | 4 | import { fmtNum, cn } from '@/lib/format'; |
| 4 | −import { Thumb } from '@/components/market/bits'; | |
| 5 | −import { Badge } from '@/components/ui/primitives'; | |
| 5 | +import { Delta } from '@/components/ui/primitives'; | |
| 6 | +import { Sparkline } from '@/components/charts/sparkline'; | |
| 6 | 7 | |
| 7 | −/** Browse-by-category grid: thumbnail of a representative asset, live counts, coverage state. */ | |
| 8 | −export function CategoryGrid({ rows, thumbs, className, limit }: { rows: MarketRow[]; thumbs: Map<string, string>; className?: string; limit?: number }) { | |
| 9 | − const sorted = [...rows].sort((a, b) => b.counts.assets - a.counts.assets || a.node.sortOrder - b.node.sortOrder); | |
| 8 | +/** Browse-by-category tiles: representative image, live counts, 30-day change and an optional sparkline. */ | |
| 9 | +export function CategoryGrid({ rows, thumbs, className, limit, sparklines, mobileLimit }: { rows: MarketRow[]; thumbs: Map<string, string>; className?: string; limit?: number; sparklines?: Map<string, number[]>; /** hide tiles beyond this index below the sm breakpoint */ mobileLimit?: number }) { | |
| 10 | + const sorted = [...rows].sort((a, b) => b.counts.sales - a.counts.sales || b.counts.assets - a.counts.assets || a.node.sortOrder - b.node.sortOrder); | |
| 10 | 11 | const shown = limit ? sorted.slice(0, limit) : sorted; |
| 11 | 12 | return ( |
| 12 | 13 | <div className={cn('grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6', className)}> |
| 13 | − {shown.map(({ node, counts, snapshot }) => { | |
| 14 | + {shown.map(({ node, counts, snapshot }, idx) => { | |
| 15 | + const hiddenOnMobile = mobileLimit !== undefined && idx >= mobileLimit; | |
| 14 | 16 | const empty = counts.assets === 0; |
| 17 | + const src = thumbs.get(node.slug); | |
| 18 | + const spark = sparklines?.get(node.slug); | |
| 15 | 19 | return ( |
| 16 | − <Link key={node.slug} href={`/markets/${node.slug}`} className={cn('card flex items-center gap-2.5 p-2.5 transition-colors hover:border-border-strong', empty && 'opacity-60')}> | |
| 17 | − <Thumb src={thumbs.get(node.slug)} alt="" size={44} rounded="rounded-md" /> | |
| 18 | − <span className="min-w-0 flex-1"> | |
| 19 | − <span className="block truncate text-[13px] font-medium text-fg">{node.name}</span> | |
| 20 | − <span className="num block truncate text-[11px] text-muted">{empty ? 'no data yet' : `${fmtNum(counts.assets, { compact: true })} assets${counts.sales ? ` · ${fmtNum(counts.sales, { compact: true })} sales` : ''}`}</span> | |
| 21 | − <span className="mt-0.5 flex items-center gap-1 text-[10px]"> | |
| 22 | − {counts.priced ? <Badge tone="gain">{fmtNum(counts.priced, { compact: true })} priced</Badge> : !empty ? <Badge>catalog</Badge> : null} | |
| 23 | − {snapshot?.indexValue != null ? <Badge tone="index">index</Badge> : null} | |
| 20 | + <Link key={node.slug} href={`/markets/${node.slug}`} className={cn('card card-hover group flex flex-col overflow-hidden', empty && 'opacity-60', hiddenOnMobile && 'hidden sm:flex')}> | |
| 21 | + <span className="relative block aspect-[5/3] w-full overflow-hidden bg-sunken"> | |
| 22 | + {src ? <Image src={src} alt="" fill sizes="(min-width: 1280px) 220px, (min-width: 640px) 33vw, 50vw" className="object-contain p-3 transition-transform duration-300 ease-out group-hover:scale-[1.04]" unoptimized={src.endsWith('.svg')} /> : <span className="absolute inset-0 flex items-center justify-center"><span className="t-label">{empty ? 'No data yet' : node.short ?? node.name}</span></span>} | |
| 23 | + {spark && spark.length > 2 ? ( | |
| 24 | + <span className="absolute bottom-1.5 right-1.5 rounded-sm bg-elevated/80 px-1 backdrop-blur-sm"> | |
| 25 | + <Sparkline values={spark} width={64} height={18} /> | |
| 26 | + </span> | |
| 27 | + ) : null} | |
| 28 | + </span> | |
| 29 | + <span className="flex flex-col gap-0.5 px-2.5 py-2"> | |
| 30 | + <span className="flex items-center justify-between gap-2"> | |
| 31 | + <span className="truncate text-[13px] font-semibold text-fg">{node.name}</span> | |
| 32 | + {snapshot?.change30d != null ? <Delta value={snapshot.change30d} className="text-[11px]" /> : null} | |
| 24 | 33 | </span> |
| 34 | + <span className="num truncate text-[11px] text-muted">{empty ? 'catalogue pending' : `${fmtNum(counts.assets, { compact: true })} assets${counts.sales ? ` · ${fmtNum(counts.sales, { compact: true })} sales` : counts.priced ? ` · ${fmtNum(counts.priced, { compact: true })} priced` : ''}`}</span> | |
| 25 | 35 | </span> |
| 26 | 36 | </Link> |
| 27 | 37 | ); |
modified
apps/web/src/components/market/index-strip.tsx
+33 −21
@@ -1,32 +1,44 @@ | ||
| 1 | 1 | import Link from 'next/link'; |
| 2 | 2 | import type { IndexRow } from '@/lib/queries/indices'; |
| 3 | −import { fmtNum, cn } from '@/lib/format'; | |
| 4 | −import { Delta } from '@/components/ui/primitives'; | |
| 3 | +import { fmtNum, cn, fmtPct, deltaClass } from '@/lib/format'; | |
| 5 | 4 | |
| 6 | −/** Live tape (§163): marquee of indices; indices without a published value show their build status. */ | |
| 5 | +/** | |
| 6 | + * Live tape (§163): a terminal-style ticker of the indices. Published indices show value + daily change; | |
| 7 | + * indices still building show their constituent progress. Pauses on hover/focus; static under reduced motion. | |
| 8 | + */ | |
| 7 | 9 | export function IndexTape({ indices, className }: { indices: IndexRow[]; className?: string }) { |
| 8 | 10 | if (!indices.length) return null; |
| 9 | − const items = indices.map((i) => ( | |
| 10 | − <Link key={i.ticker} href={`/rareindex/${i.ticker}`} className="flex shrink-0 items-center gap-2 px-4 text-[12px] hover:bg-inset" style={{ borderLeft: `2px solid ${i.color ?? 'var(--ri-index)'}` }}> | |
| 11 | − <span className="font-semibold tracking-wide text-fg">{i.ticker}</span> | |
| 12 | − {i.latest ? ( | |
| 13 | − <> | |
| 14 | − <span className="num text-muted">{fmtNum(i.latest.value, { digits: 2 })}</span> | |
| 15 | − <Delta value={i.change1d} /> | |
| 16 | − </> | |
| 17 | − ) : ( | |
| 18 | − <span className="text-subtle"> | |
| 19 | − building · {i.pricedAssets}/{i.minConstituents} | |
| 20 | − </span> | |
| 21 | − )} | |
| 22 | − </Link> | |
| 23 | − )); | |
| 11 | + const ordered = [...indices].sort((a, b) => Number(Boolean(b.latest)) - Number(Boolean(a.latest)) || (a.isFlagship ? -1 : 0) - (b.isFlagship ? -1 : 0)); | |
| 12 | + const items = ordered.map((i) => { | |
| 13 | + const up = (i.change1d ?? 0) > 0; | |
| 14 | + const down = (i.change1d ?? 0) < 0; | |
| 15 | + return ( | |
| 16 | + <Link key={i.ticker} href={`/rareindex/${i.ticker}`} className="mono-num flex h-9 shrink-0 items-center gap-2.5 border-r border-border px-4 text-[12px] hover:bg-inset" aria-label={`${i.ticker}${i.latest ? ` ${fmtNum(i.latest.value, { digits: 2 })} ${fmtPct(i.change1d)}` : ' building'}`}> | |
| 17 | + <span className="inline-block h-2 w-2 rounded-[2px]" style={{ background: i.color ?? 'var(--ri-index)' }} aria-hidden /> | |
| 18 | + <span className="font-semibold tracking-wide text-fg">{i.ticker}</span> | |
| 19 | + {i.latest ? ( | |
| 20 | + <> | |
| 21 | + <span className="text-muted">{fmtNum(i.latest.value, { digits: 2 })}</span> | |
| 22 | + <span className={cn('font-medium', deltaClass(i.change1d))}> | |
| 23 | + {up ? '▲' : down ? '▼' : '■'} {fmtPct(i.change1d)} | |
| 24 | + </span> | |
| 25 | + </> | |
| 26 | + ) : ( | |
| 27 | + <span className="text-subtle"> | |
| 28 | + building {i.pricedAssets}/{i.minConstituents} | |
| 29 | + </span> | |
| 30 | + )} | |
| 31 | + </Link> | |
| 32 | + ); | |
| 33 | + }); | |
| 24 | 34 | return ( |
| 25 | − <div className={cn('relative overflow-hidden border-y border-border bg-elevated', className)} aria-label="Index tape"> | |
| 26 | − <div className="tape-track flex w-max py-1.5"> | |
| 35 | + <div className={cn('relative overflow-hidden border-y border-border bg-elevated', className)} aria-label="Index tape" role="marquee"> | |
| 36 | + <div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-elevated to-transparent" aria-hidden /> | |
| 37 | + <div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-elevated to-transparent" aria-hidden /> | |
| 38 | + <div className="tape-track flex w-max"> | |
| 27 | 39 | {items} |
| 28 | 40 | {items.map((it, k) => ( |
| 29 | − <span key={`dup-${k}`} aria-hidden> | |
| 41 | + <span key={`dup-${k}`} aria-hidden className="contents"> | |
| 30 | 42 | {it} |
| 31 | 43 | </span> |
| 32 | 44 | ))} |
modified
apps/web/src/components/market/sales-table.tsx
+79 −2
@@ -5,8 +5,47 @@ import { humanize } from '@/lib/taxonomy'; | ||
| 5 | 5 | import { Table, th, td, tdNum, EmptyState, Badge, Delta } from '@/components/ui/primitives'; |
| 6 | 6 | import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits'; |
| 7 | 7 | |
| 8 | −export function SalesTable({ items, showAsset = true, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.' }: { items: SaleRow[]; showAsset?: boolean; className?: string; emptyDescription?: string }) { | |
| 8 | +/** Card list for sales on small screens (image, title, grade, price, source). */ | |
| 9 | +export function SalesCards({ items, className }: { items: SaleRow[]; className?: string }) { | |
| 10 | + return ( | |
| 11 | + <ul className={cn('divide-y divide-border', className)}> | |
| 12 | + {items.map((s) => ( | |
| 13 | + <li key={s.id}> | |
| 14 | + <a href={s.assetSlug ? `/asset/${s.assetSlug}` : s.sourceUrl} className="flex min-h-[64px] items-center gap-3 px-3 py-2.5 hover:bg-sunken"> | |
| 15 | + <Thumb src={s.heroImageUrl ?? s.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" /> | |
| 16 | + <span className="min-w-0 flex-1"> | |
| 17 | + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{s.assetTitle ?? s.rawTitle}</span> | |
| 18 | + <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted"> | |
| 19 | + <GradeBadge grader={s.grader} grade={s.grade} /> | |
| 20 | + <span>{fmtDate(s.saleDate)}</span> | |
| 21 | + <span>· {s.sourceName}</span> | |
| 22 | + </span> | |
| 23 | + </span> | |
| 24 | + <span className="num flex shrink-0 flex-col items-end leading-tight"> | |
| 25 | + <span className="text-[14px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span> | |
| 26 | + {s.currency !== 'USD' ? <span className="text-[10px] text-subtle">{fmtMoney(s.price, s.currency)}</span> : <span className="text-[10px] text-subtle">{humanize(s.saleType)}</span>} | |
| 27 | + </span> | |
| 28 | + </a> | |
| 29 | + </li> | |
| 30 | + ))} | |
| 31 | + </ul> | |
| 32 | + ); | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function SalesTable({ items, showAsset = true, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.', responsive = true }: { items: SaleRow[]; showAsset?: boolean; className?: string; emptyDescription?: string; responsive?: boolean }) { | |
| 9 | 36 | if (!items.length) return <EmptyState title="No sales recorded" description={emptyDescription} />; |
| 37 | + if (responsive) { | |
| 38 | + return ( | |
| 39 | + <> | |
| 40 | + <div className="md:hidden"> | |
| 41 | + <SalesCards items={items} /> | |
| 42 | + </div> | |
| 43 | + <div className="hidden md:block"> | |
| 44 | + <SalesTable items={items} showAsset={showAsset} className={className} responsive={false} /> | |
| 45 | + </div> | |
| 46 | + </> | |
| 47 | + ); | |
| 48 | + } | |
| 10 | 49 | return ( |
| 11 | 50 | <Table className={className}> |
| 12 | 51 | <thead> |
@@ -63,8 +102,46 @@ export function SalesTable({ items, showAsset = true, className, emptyDescriptio | ||
| 63 | 102 | ); |
| 64 | 103 | } |
| 65 | 104 | |
| 66 | −export function ListingsTable({ items, showAsset = true, className, emptyDescription = 'Live listings appear once marketplace connectors run.' }: { items: ListingRow[]; showAsset?: boolean; className?: string; emptyDescription?: string }) { | |
| 105 | +export function ListingsCards({ items, className }: { items: ListingRow[]; className?: string }) { | |
| 106 | + return ( | |
| 107 | + <ul className={cn('divide-y divide-border', className)}> | |
| 108 | + {items.map((l) => ( | |
| 109 | + <li key={l.id}> | |
| 110 | + <a href={l.assetSlug ? `/asset/${l.assetSlug}` : l.sourceUrl} className="flex min-h-[64px] items-center gap-3 px-3 py-2.5 hover:bg-sunken"> | |
| 111 | + <Thumb src={l.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" /> | |
| 112 | + <span className="min-w-0 flex-1"> | |
| 113 | + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.assetTitle ?? l.rawTitle}</span> | |
| 114 | + <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted"> | |
| 115 | + <GradeBadge grader={l.grader} grade={l.grade} /> | |
| 116 | + <span>{l.sourceName}</span> | |
| 117 | + {l.location ? <span>· {l.location}</span> : null} | |
| 118 | + </span> | |
| 119 | + </span> | |
| 120 | + <span className="num flex shrink-0 flex-col items-end leading-tight"> | |
| 121 | + <span className="text-[14px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span> | |
| 122 | + {l.discountToRiv !== null ? <Delta value={-l.discountToRiv} className="text-[11px]" /> : <span className="text-[10px] text-subtle">{humanize(l.listingType)}</span>} | |
| 123 | + </span> | |
| 124 | + </a> | |
| 125 | + </li> | |
| 126 | + ))} | |
| 127 | + </ul> | |
| 128 | + ); | |
| 129 | +} | |
| 130 | + | |
| 131 | +export function ListingsTable({ items, showAsset = true, className, emptyDescription = 'Live listings appear once marketplace connectors run.', responsive = true }: { items: ListingRow[]; showAsset?: boolean; className?: string; emptyDescription?: string; responsive?: boolean }) { | |
| 67 | 132 | if (!items.length) return <EmptyState title="No active listings" description={emptyDescription} />; |
| 133 | + if (responsive) { | |
| 134 | + return ( | |
| 135 | + <> | |
| 136 | + <div className="md:hidden"> | |
| 137 | + <ListingsCards items={items} /> | |
| 138 | + </div> | |
| 139 | + <div className="hidden md:block"> | |
| 140 | + <ListingsTable items={items} showAsset={showAsset} className={className} responsive={false} /> | |
| 141 | + </div> | |
| 142 | + </> | |
| 143 | + ); | |
| 144 | + } | |
| 68 | 145 | return ( |
| 69 | 146 | <Table className={className}> |
| 70 | 147 | <thead> |
modified
apps/web/src/components/research/research-client.tsx
+6 −6
@@ -127,12 +127,12 @@ export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiRea | ||
| 127 | 127 | ))} |
| 128 | 128 | </ul> |
| 129 | 129 | </aside> |
| 130 | − <section className="card flex min-h-[70dvh] flex-col"> | |
| 130 | + <section className="card flex h-[calc(100dvh-var(--ri-header-h,52px)-var(--ri-tabbar-h,56px)-7rem)] min-h-[420px] flex-col md:h-[calc(100dvh-var(--ri-header-h,52px)-9rem)]"> | |
| 131 | 131 | <div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs text-muted"> |
| 132 | 132 | <span className="inline-flex items-center gap-1.5"><Sparkles className="h-3.5 w-3.5" /> {aiReady ? `Grounded research · ${model}` : 'AI provider not configured'}</span> |
| 133 | 133 | <span>Tool calls are shown inline</span> |
| 134 | 134 | </div> |
| 135 | − <div className="flex-1 space-y-4 overflow-y-auto px-4 py-4"> | |
| 135 | + <div className="flex-1 space-y-4 overflow-y-auto overscroll-contain px-3 py-3 sm:px-4 sm:py-4"> | |
| 136 | 136 | {turns.length === 0 ? ( |
| 137 | 137 | <div> |
| 138 | 138 | <p className="mb-3 text-sm text-muted">Try one of these:</p> |
@@ -147,7 +147,7 @@ export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiRea | ||
| 147 | 147 | ) : null} |
| 148 | 148 | {turns.map((t) => ( |
| 149 | 149 | <div key={t.id} className={cn('flex', t.role === 'user' ? 'justify-end' : 'justify-start')}> |
| 150 | − <div className={cn('max-w-[92%] rounded-lg px-3.5 py-2.5 text-sm', t.role === 'user' ? 'bg-accent text-accent-fg' : 'bg-sunken')}> | |
| 150 | + <div className={cn('max-w-[94%] overflow-x-auto rounded-lg px-3.5 py-2.5 text-sm sm:max-w-[88%]', t.role === 'user' ? 'bg-accent text-accent-fg' : 'bg-sunken')}> | |
| 151 | 151 | {t.tools.length ? ( |
| 152 | 152 | <ul className="mb-2 space-y-1"> |
| 153 | 153 | {t.tools.map((tool) => ( |
@@ -165,7 +165,7 @@ export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiRea | ||
| 165 | 165 | <div ref={bottom} /> |
| 166 | 166 | </div> |
| 167 | 167 | <form |
| 168 | − className="flex items-end gap-2 border-t border-border p-3" | |
| 168 | + className="sticky bottom-0 flex items-end gap-2 border-t border-border bg-elevated p-2 sm:p-3" | |
| 169 | 169 | onSubmit={(e) => { |
| 170 | 170 | e.preventDefault(); |
| 171 | 171 | void send(input); |
@@ -183,9 +183,9 @@ export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiRea | ||
| 183 | 183 | rows={2} |
| 184 | 184 | placeholder={aiReady ? 'Ask about an asset, a category, an index, movers or a screen…' : 'AI provider not configured'} |
| 185 | 185 | disabled={!aiReady} |
| 186 | − className="min-h-[44px] flex-1 resize-none rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" | |
| 186 | + className="max-h-40 min-h-[44px] flex-1 resize-none rounded-md border border-border bg-sunken px-3 py-2 text-base text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none md:text-sm" | |
| 187 | 187 | /> |
| 188 | − <button type="submit" disabled={!aiReady || busy || !input.trim()} aria-label="Send" className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-accent text-accent-fg disabled:opacity-50"> | |
| 188 | + <button type="submit" disabled={!aiReady || busy || !input.trim()} aria-label="Send" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-accent text-accent-fg disabled:opacity-50"> | |
| 189 | 189 | {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} |
| 190 | 190 | </button> |
| 191 | 191 | </form> |
modified
apps/web/src/components/scanner/scanner-client.tsx
+8 −10
@@ -150,15 +150,13 @@ export function ScannerClient({ aiReady }: { aiReady: boolean }) { | ||
| 150 | 150 | void addFiles(e.dataTransfer.files); |
| 151 | 151 | }} |
| 152 | 152 | > |
| 153 | − <p className="text-xs text-muted">Drop up to 5 photos: front, back, label, seal, serial.</p> | |
| 154 | − <div className="flex gap-2"> | |
| 155 | − <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset" onClick={() => fileRef.current?.click()}> | |
| 156 | − <ImagePlus className="h-3.5 w-3.5" /> Upload | |
| 157 | − </button> | |
| 158 | − <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset md:hidden" onClick={() => camRef.current?.click()}> | |
| 159 | − <Camera className="h-3.5 w-3.5" /> Take photo | |
| 160 | − </button> | |
| 161 | − </div> | |
| 153 | + <button type="button" className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-semibold text-accent-fg active:opacity-90 md:hidden" onClick={() => camRef.current?.click()}> | |
| 154 | + <Camera className="h-5 w-5" /> Take a photo | |
| 155 | + </button> | |
| 156 | + <p className="text-xs text-muted"><span className="hidden md:inline">Drop up to 5 photos here: </span><span className="md:hidden">Up to 5 photos: </span>front, back, label, seal, serial.</p> | |
| 157 | + <button type="button" className="inline-flex h-10 items-center gap-1.5 rounded-md border border-border bg-elevated px-3 text-xs font-medium hover:bg-inset md:h-9" onClick={() => fileRef.current?.click()}> | |
| 158 | + <ImagePlus className="h-3.5 w-3.5" /> Upload from library | |
| 159 | + </button> | |
| 162 | 160 | <input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void addFiles(e.target.files)} /> |
| 163 | 161 | <input ref={camRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => void addFiles(e.target.files)} /> |
| 164 | 162 | </div> |
@@ -188,7 +186,7 @@ export function ScannerClient({ aiReady }: { aiReady: boolean }) { | ||
| 188 | 186 | <textarea value={text} onChange={(e) => setText(e.target.value)} rows={5} placeholder="e.g. 1999 Pokémon Base Set Charizard holo, 1st edition stamp, PSA 9 slab #12345678" className="mt-1 w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" /> |
| 189 | 187 | </label> |
| 190 | 188 | )} |
| 191 | − <button type="button" disabled={!canSubmit} onClick={() => void submit()} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg disabled:opacity-50"> | |
| 189 | + <button type="button" disabled={!canSubmit} onClick={() => void submit()} className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-md bg-accent px-3 text-sm font-semibold text-accent-fg disabled:opacity-50"> | |
| 192 | 190 | {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {busy ? 'Identifying…' : 'Identify & price'} |
| 193 | 191 | </button> |
| 194 | 192 | {error ? <p className="rounded-md bg-loss-bg px-3 py-2 text-xs text-loss">{error}</p> : null} |
added
apps/web/src/components/search/search-sheet.tsx
+330 −0
@@ -0,0 +1,330 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| 6 | +import { ArrowUpRight, Clock, Search, TrendingUp, X } from 'lucide-react'; | |
| 7 | +import { cn } from '@/lib/format'; | |
| 8 | + | |
| 9 | +export interface Suggestion { | |
| 10 | + type: string; | |
| 11 | + label: string; | |
| 12 | + sublabel: string | null; | |
| 13 | + href: string; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const RECENT_KEY = 'ri-recent-searches'; | |
| 17 | +const TRENDING = ['1999 Charizard PSA 10', 'Rolex Daytona 116500LN', 'LEGO 10179', 'Black Lotus Alpha', 'Blue-Eyes White Dragon LOB', 'Super Mario 64 sealed', 'Amazing Spider-Man 300', 'Patek Philippe Nautilus 5711']; | |
| 18 | +const GROUP_LABEL: Record<string, string> = { asset: 'Assets', set: 'Sets & releases', category: 'Categories', brand: 'Brands', source: 'Sources', index: 'Indices' }; | |
| 19 | +const GROUP_ORDER = ['asset', 'set', 'category', 'brand', 'index', 'source']; | |
| 20 | + | |
| 21 | +function readRecent(): string[] { | |
| 22 | + try { | |
| 23 | + const raw = localStorage.getItem(RECENT_KEY); | |
| 24 | + const arr = raw ? (JSON.parse(raw) as unknown) : []; | |
| 25 | + return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string').slice(0, 8) : []; | |
| 26 | + } catch { | |
| 27 | + return []; | |
| 28 | + } | |
| 29 | +} | |
| 30 | +export function pushRecent(q: string) { | |
| 31 | + try { | |
| 32 | + const next = [q, ...readRecent().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8); | |
| 33 | + localStorage.setItem(RECENT_KEY, JSON.stringify(next)); | |
| 34 | + } catch { | |
| 35 | + /* ignore */ | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** Highlight query tokens inside a label (case-insensitive, word-agnostic). */ | |
| 40 | +export function Highlight({ text, q }: { text: string; q: string }) { | |
| 41 | + const tokens = q.toLowerCase().split(/\s+/).filter((t) => t.length >= 2); | |
| 42 | + if (!tokens.length) return <>{text}</>; | |
| 43 | + const re = new RegExp(`(${tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig'); | |
| 44 | + const parts = text.split(re); | |
| 45 | + return ( | |
| 46 | + <> | |
| 47 | + {parts.map((p, i) => (re.test(p) ? <mark key={i} className="rounded-[2px] bg-index-bg text-fg">{p}</mark> : <span key={i}>{p}</span>))} | |
| 48 | + </> | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * Full-screen search sheet (mobile) / command palette (desktop, ⌘K). Grouped instant results from | |
| 54 | + * /api/search/suggest, recent searches (localStorage), trending prompts and keyboard navigation. | |
| 55 | + */ | |
| 56 | +export function SearchSheet({ open, onClose, initialQuery = '' }: { open: boolean; onClose: () => void; initialQuery?: string }) { | |
| 57 | + const router = useRouter(); | |
| 58 | + const [q, setQ] = useState(initialQuery); | |
| 59 | + const [items, setItems] = useState<Suggestion[]>([]); | |
| 60 | + const [loading, setLoading] = useState(false); | |
| 61 | + const [active, setActive] = useState(-1); | |
| 62 | + const inputRef = useRef<HTMLInputElement>(null); | |
| 63 | + // Only rendered client-side once opened (SSR renders nothing), so reading localStorage here is safe. | |
| 64 | + const recent = useMemo(() => (open && typeof window !== 'undefined' ? readRecent() : []), [open]); | |
| 65 | + | |
| 66 | + useEffect(() => { | |
| 67 | + if (!open) return; | |
| 68 | + const t = setTimeout(() => inputRef.current?.focus(), 30); | |
| 69 | + document.body.style.overflow = 'hidden'; | |
| 70 | + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); | |
| 71 | + window.addEventListener('keydown', onKey); | |
| 72 | + return () => { | |
| 73 | + clearTimeout(t); | |
| 74 | + document.body.style.overflow = ''; | |
| 75 | + window.removeEventListener('keydown', onKey); | |
| 76 | + }; | |
| 77 | + }, [open, onClose]); | |
| 78 | + | |
| 79 | + useEffect(() => { | |
| 80 | + if (!open) return; | |
| 81 | + const query = q.trim(); | |
| 82 | + if (query.length < 2) return; | |
| 83 | + const ctrl = new AbortController(); | |
| 84 | + const t = setTimeout(() => { | |
| 85 | + setLoading(true); | |
| 86 | + fetch(`/api/search/suggest?q=${encodeURIComponent(query)}`, { signal: ctrl.signal }) | |
| 87 | + .then((r) => r.json()) | |
| 88 | + .then((d: { items: Suggestion[] }) => { | |
| 89 | + setItems(d.items ?? []); | |
| 90 | + setActive(-1); | |
| 91 | + }) | |
| 92 | + .catch(() => {}) | |
| 93 | + .finally(() => setLoading(false)); | |
| 94 | + }, 110); | |
| 95 | + return () => { | |
| 96 | + clearTimeout(t); | |
| 97 | + ctrl.abort(); | |
| 98 | + }; | |
| 99 | + }, [q, open]); | |
| 100 | + | |
| 101 | + const groups = useMemo(() => { | |
| 102 | + const m = new Map<string, Suggestion[]>(); | |
| 103 | + for (const it of items) (m.get(it.type) ?? m.set(it.type, []).get(it.type)!).push(it); | |
| 104 | + return [...m.entries()].sort((a, b) => GROUP_ORDER.indexOf(a[0]) - GROUP_ORDER.indexOf(b[0])); | |
| 105 | + }, [items]); | |
| 106 | + const flat = useMemo(() => groups.flatMap(([, g]) => g), [groups]); | |
| 107 | + | |
| 108 | + const go = useCallback( | |
| 109 | + (href: string, label?: string) => { | |
| 110 | + if (label) pushRecent(label); | |
| 111 | + onClose(); | |
| 112 | + router.push(href); | |
| 113 | + }, | |
| 114 | + [onClose, router], | |
| 115 | + ); | |
| 116 | + const submit = () => { | |
| 117 | + const query = q.trim(); | |
| 118 | + if (active >= 0 && flat[active]) return go(flat[active]!.href, query || flat[active]!.label); | |
| 119 | + if (query) go(`/search?q=${encodeURIComponent(query)}`, query); | |
| 120 | + }; | |
| 121 | + | |
| 122 | + if (!open) return null; | |
| 123 | + const showIdle = q.trim().length < 2; | |
| 124 | + return ( | |
| 125 | + <div className="fixed inset-0 z-[70]" role="dialog" aria-modal="true" aria-label="Search"> | |
| 126 | + <div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={onClose} /> | |
| 127 | + <div className="sheet-in absolute inset-x-0 top-0 mx-auto flex h-[100dvh] w-full flex-col bg-elevated shadow-pop md:top-[8vh] md:h-auto md:max-h-[76vh] md:max-w-2xl md:rounded-xl md:border md:border-border"> | |
| 128 | + <form | |
| 129 | + role="search" | |
| 130 | + className="flex items-center gap-2 border-b border-border px-3 py-2 md:px-4" | |
| 131 | + onSubmit={(e) => { | |
| 132 | + e.preventDefault(); | |
| 133 | + submit(); | |
| 134 | + }} | |
| 135 | + > | |
| 136 | + <Search className="h-5 w-5 shrink-0 text-subtle" /> | |
| 137 | + <input | |
| 138 | + ref={inputRef} | |
| 139 | + value={q} | |
| 140 | + onChange={(e) => { | |
| 141 | + setQ(e.target.value); | |
| 142 | + if (e.target.value.trim().length < 2) { | |
| 143 | + setItems([]); | |
| 144 | + setActive(-1); | |
| 145 | + } | |
| 146 | + }} | |
| 147 | + onKeyDown={(e) => { | |
| 148 | + if (e.key === 'ArrowDown') { | |
| 149 | + e.preventDefault(); | |
| 150 | + setActive((a) => Math.min(flat.length - 1, a + 1)); | |
| 151 | + } else if (e.key === 'ArrowUp') { | |
| 152 | + e.preventDefault(); | |
| 153 | + setActive((a) => Math.max(-1, a - 1)); | |
| 154 | + } | |
| 155 | + }} | |
| 156 | + type="search" | |
| 157 | + enterKeyHint="search" | |
| 158 | + autoComplete="off" | |
| 159 | + autoCapitalize="off" | |
| 160 | + spellCheck={false} | |
| 161 | + placeholder="Search cards, watches, games, sneakers, LEGO, art…" | |
| 162 | + aria-label="Search collectibles" | |
| 163 | + aria-autocomplete="list" | |
| 164 | + aria-controls="search-sheet-list" | |
| 165 | + className="h-12 min-w-0 flex-1 bg-transparent text-[16px] text-fg placeholder:text-subtle focus:outline-none md:h-11 md:text-[15px]" | |
| 166 | + /> | |
| 167 | + {q ? ( | |
| 168 | + <button type="button" onClick={() => setQ('')} className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted hover:bg-inset" aria-label="Clear"> | |
| 169 | + <X className="h-4 w-4" /> | |
| 170 | + </button> | |
| 171 | + ) : null} | |
| 172 | + <button type="button" onClick={onClose} className="inline-flex h-10 items-center rounded-md border border-border px-2.5 text-[12px] font-medium text-muted hover:bg-inset md:h-8"> | |
| 173 | + <span className="md:hidden">Cancel</span> | |
| 174 | + <span className="hidden md:inline">Esc</span> | |
| 175 | + </button> | |
| 176 | + </form> | |
| 177 | + | |
| 178 | + <div id="search-sheet-list" className="flex-1 overflow-y-auto overscroll-contain safe-bottom" role="listbox"> | |
| 179 | + {showIdle ? ( | |
| 180 | + <div className="px-3 py-3 md:px-4"> | |
| 181 | + {recent.length ? ( | |
| 182 | + <section className="mb-4"> | |
| 183 | + <p className="t-label mb-1.5 flex items-center gap-1.5"> | |
| 184 | + <Clock className="h-3 w-3" /> Recent | |
| 185 | + </p> | |
| 186 | + <ul className="flex flex-wrap gap-1.5"> | |
| 187 | + {recent.map((r) => ( | |
| 188 | + <li key={r}> | |
| 189 | + <button type="button" onClick={() => go(`/search?q=${encodeURIComponent(r)}`, r)} className="chip"> | |
| 190 | + {r} | |
| 191 | + </button> | |
| 192 | + </li> | |
| 193 | + ))} | |
| 194 | + </ul> | |
| 195 | + </section> | |
| 196 | + ) : null} | |
| 197 | + <section> | |
| 198 | + <p className="t-label mb-1.5 flex items-center gap-1.5"> | |
| 199 | + <TrendingUp className="h-3 w-3" /> Try | |
| 200 | + </p> | |
| 201 | + <ul className="divide-y divide-border"> | |
| 202 | + {TRENDING.map((t) => ( | |
| 203 | + <li key={t}> | |
| 204 | + <button type="button" onClick={() => go(`/search?q=${encodeURIComponent(t)}`, t)} className="flex min-h-[44px] w-full items-center justify-between gap-3 px-1 text-left text-[14px] text-fg hover:bg-sunken"> | |
| 205 | + <span>{t}</span> | |
| 206 | + <ArrowUpRight className="h-4 w-4 text-subtle" /> | |
| 207 | + </button> | |
| 208 | + </li> | |
| 209 | + ))} | |
| 210 | + </ul> | |
| 211 | + </section> | |
| 212 | + <p className="mt-4 text-[11px] leading-relaxed text-subtle">Understands graders and grades (“PSA 10”), years, price bounds (“under $5,000”), conditions (“sealed”, “CIB”) and set codes (“LOB-001”, “116500LN”).</p> | |
| 213 | + </div> | |
| 214 | + ) : ( | |
| 215 | + <div className="py-1"> | |
| 216 | + {groups.map(([type, g]) => ( | |
| 217 | + <section key={type} className="pb-1"> | |
| 218 | + <p className="t-label px-4 pb-1 pt-2">{GROUP_LABEL[type] ?? type}</p> | |
| 219 | + <ul> | |
| 220 | + {g.map((it) => { | |
| 221 | + const idx = flat.indexOf(it); | |
| 222 | + return ( | |
| 223 | + <li key={`${it.type}-${it.href}`} role="option" aria-selected={idx === active}> | |
| 224 | + <button type="button" onMouseEnter={() => setActive(idx)} onClick={() => go(it.href, q.trim())} className={cn('flex min-h-[44px] w-full items-center justify-between gap-3 px-4 py-1.5 text-left', idx === active ? 'bg-inset' : 'hover:bg-sunken')}> | |
| 225 | + <span className="min-w-0"> | |
| 226 | + <span className="block truncate text-[14px] text-fg"> | |
| 227 | + <Highlight text={it.label} q={q} /> | |
| 228 | + </span> | |
| 229 | + {it.sublabel ? <span className="block truncate text-[11px] text-muted">{it.sublabel}</span> : null} | |
| 230 | + </span> | |
| 231 | + <ArrowUpRight className="h-4 w-4 shrink-0 text-subtle" /> | |
| 232 | + </button> | |
| 233 | + </li> | |
| 234 | + ); | |
| 235 | + })} | |
| 236 | + </ul> | |
| 237 | + </section> | |
| 238 | + ))} | |
| 239 | + {!loading && !flat.length ? <p className="px-4 py-6 text-center text-[13px] text-muted">No instant matches. Press Enter to run a full search.</p> : null} | |
| 240 | + <button type="button" onClick={submit} className="mt-1 flex min-h-[48px] w-full items-center justify-between border-t border-border px-4 text-left text-[13px] text-muted hover:bg-sunken"> | |
| 241 | + <span> | |
| 242 | + Search all results for <span className="font-medium text-fg">“{q.trim()}”</span> | |
| 243 | + </span> | |
| 244 | + <kbd className="rounded-sm border border-border px-1.5 text-[10px] text-subtle">↵</kbd> | |
| 245 | + </button> | |
| 246 | + </div> | |
| 247 | + )} | |
| 248 | + </div> | |
| 249 | + </div> | |
| 250 | + </div> | |
| 251 | + ); | |
| 252 | +} | |
| 253 | + | |
| 254 | +/** Icon/button trigger that opens the sheet; global ⌘K / Ctrl+K shortcut. */ | |
| 255 | +export function SearchTrigger({ variant = 'icon', className, initialQuery }: { variant?: 'icon' | 'field' | 'hero'; className?: string; initialQuery?: string }) { | |
| 256 | + const [open, setOpen] = useState(false); | |
| 257 | + const close = useCallback(() => setOpen(false), []); | |
| 258 | + useEffect(() => { | |
| 259 | + const onKey = (e: KeyboardEvent) => { | |
| 260 | + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { | |
| 261 | + e.preventDefault(); | |
| 262 | + setOpen(true); | |
| 263 | + } | |
| 264 | + }; | |
| 265 | + window.addEventListener('keydown', onKey); | |
| 266 | + return () => window.removeEventListener('keydown', onKey); | |
| 267 | + }, []); | |
| 268 | + return ( | |
| 269 | + <> | |
| 270 | + {variant === 'icon' ? ( | |
| 271 | + <button type="button" onClick={() => setOpen(true)} className={cn('inline-flex h-10 w-10 items-center justify-center rounded-md text-fg hover:bg-inset md:h-8 md:w-8', className)} aria-label="Search"> | |
| 272 | + <Search className="h-5 w-5" /> | |
| 273 | + </button> | |
| 274 | + ) : variant === 'field' ? ( | |
| 275 | + <button type="button" onClick={() => setOpen(true)} className={cn('group flex h-8 w-64 items-center gap-2 rounded-md border border-border bg-sunken px-2.5 text-left text-[13px] text-subtle hover:border-border-strong xl:w-80', className)} aria-label="Search (⌘K)"> | |
| 276 | + <Search className="h-3.5 w-3.5" /> | |
| 277 | + <span className="flex-1 truncate">Search cards, watches, games…</span> | |
| 278 | + <kbd className="rounded-sm border border-border bg-elevated px-1 text-[10px] text-subtle">⌘K</kbd> | |
| 279 | + </button> | |
| 280 | + ) : ( | |
| 281 | + <button type="button" onClick={() => setOpen(true)} className={cn('flex h-13 w-full items-center gap-3 rounded-lg border border-border bg-elevated px-4 text-left shadow-card hover:border-border-strong', className)} aria-label="Search collectibles"> | |
| 282 | + <Search className="h-5 w-5 text-subtle" /> | |
| 283 | + <span className="flex-1 truncate text-[16px] text-subtle">Search cards, watches, games, sneakers, LEGO, art…</span> | |
| 284 | + <span className="btn-primary btn-sm hidden h-8 sm:inline-flex">Search</span> | |
| 285 | + </button> | |
| 286 | + )} | |
| 287 | + <SearchSheet open={open} onClose={close} initialQuery={initialQuery} /> | |
| 288 | + </> | |
| 289 | + ); | |
| 290 | +} | |
| 291 | + | |
| 292 | +/** Non-JS friendly hero search: a real form that submits to /search; enhanced with the sheet on focus. */ | |
| 293 | +export function HeroSearch({ chips }: { chips: string[] }) { | |
| 294 | + const [open, setOpen] = useState(false); | |
| 295 | + const close = useCallback(() => setOpen(false), []); | |
| 296 | + return ( | |
| 297 | + <div className="mx-auto w-full max-w-2xl"> | |
| 298 | + <form action="/search" role="search" className="relative flex items-center"> | |
| 299 | + <Search className="pointer-events-none absolute left-4 h-5 w-5 text-subtle" /> | |
| 300 | + <input | |
| 301 | + name="q" | |
| 302 | + type="search" | |
| 303 | + autoComplete="off" | |
| 304 | + enterKeyHint="search" | |
| 305 | + placeholder="Search cards, watches, games, sneakers, LEGO, art…" | |
| 306 | + aria-label="Search collectibles" | |
| 307 | + onFocus={(e) => { | |
| 308 | + // Use the rich sheet when JS is available; keep the plain form as the fallback. | |
| 309 | + e.currentTarget.blur(); | |
| 310 | + setOpen(true); | |
| 311 | + }} | |
| 312 | + className="h-13 w-full rounded-lg border border-border bg-elevated pl-12 pr-24 text-[16px] text-fg shadow-card placeholder:text-subtle focus:border-border-strong focus:outline-none" | |
| 313 | + /> | |
| 314 | + <button type="submit" className="btn-primary absolute right-1.5 h-10 px-4 text-[13px]"> | |
| 315 | + Search | |
| 316 | + </button> | |
| 317 | + </form> | |
| 318 | + <ul className="mt-3 flex flex-wrap items-center justify-center gap-1.5"> | |
| 319 | + {chips.map((c) => ( | |
| 320 | + <li key={c}> | |
| 321 | + <Link href={`/search?q=${encodeURIComponent(c)}`} className="chip" onClick={() => pushRecent(c)}> | |
| 322 | + {c} | |
| 323 | + </Link> | |
| 324 | + </li> | |
| 325 | + ))} | |
| 326 | + </ul> | |
| 327 | + <SearchSheet open={open} onClose={close} /> | |
| 328 | + </div> | |
| 329 | + ); | |
| 330 | +} | |
modified
apps/web/src/components/ui/filter-bar.tsx
+71 −11
@@ -1,7 +1,8 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | |
| 3 | 3 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; |
| 4 | −import { useTransition, type ReactNode } from 'react'; | |
| 4 | +import { useEffect, useState, useTransition, type ReactNode } from 'react'; | |
| 5 | +import { SlidersHorizontal, X } from 'lucide-react'; | |
| 5 | 6 | import { cn } from '@/lib/format'; |
| 6 | 7 | |
| 7 | 8 | export interface FilterField { |
@@ -16,12 +17,25 @@ export interface FilterField { | ||
| 16 | 17 | /** |
| 17 | 18 | * Generic URL-state filter bar. Every change updates the search params (resetting `page`), so |
| 18 | 19 | * server components re-render with the new filters and every view is shareable. |
| 20 | + * On small screens the fields collapse behind a "Filters" button that opens a bottom sheet. | |
| 19 | 21 | */ |
| 20 | 22 | export function FilterBar({ fields, className, children, resetKeys }: { fields: FilterField[]; className?: string; children?: ReactNode; resetKeys?: string[] }) { |
| 21 | 23 | const router = useRouter(); |
| 22 | 24 | const pathname = usePathname(); |
| 23 | 25 | const sp = useSearchParams(); |
| 24 | 26 | const [pending, start] = useTransition(); |
| 27 | + const [open, setOpen] = useState(false); | |
| 28 | + | |
| 29 | + useEffect(() => { | |
| 30 | + if (!open) return; | |
| 31 | + document.body.style.overflow = 'hidden'; | |
| 32 | + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); | |
| 33 | + window.addEventListener('keydown', onKey); | |
| 34 | + return () => { | |
| 35 | + document.body.style.overflow = ''; | |
| 36 | + window.removeEventListener('keydown', onKey); | |
| 37 | + }; | |
| 38 | + }, [open]); | |
| 25 | 39 | |
| 26 | 40 | const update = (name: string, value: string) => { |
| 27 | 41 | const next = new URLSearchParams(sp.toString()); |
@@ -37,16 +51,16 @@ export function FilterBar({ fields, className, children, resetKeys }: { fields: | ||
| 37 | 51 | next.delete('page'); |
| 38 | 52 | start(() => router.replace(`${pathname}${next.toString() ? `?${next}` : ''}`, { scroll: false })); |
| 39 | 53 | }; |
| 40 | − const hasAny = fields.some((f) => sp.get(f.name)); | |
| 41 | − const input = 'h-8 rounded-md border border-border bg-elevated px-2 text-[12px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none'; | |
| 54 | + const activeCount = fields.filter((f) => sp.get(f.name)).length; | |
| 55 | + const input = 'h-9 md:h-8 rounded-md border border-border bg-elevated px-2 text-[16px] md:text-[12px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none'; | |
| 42 | 56 | |
| 43 | − return ( | |
| 44 | − <div className={cn('flex flex-wrap items-end gap-2', pending && 'opacity-70', className)}> | |
| 57 | + const controls = (stacked: boolean) => ( | |
| 58 | + <> | |
| 45 | 59 | {fields.map((f) => ( |
| 46 | − <label key={f.name} className="flex flex-col gap-0.5 text-[10px] font-medium uppercase tracking-wider text-subtle"> | |
| 60 | + <label key={f.name} className={cn('flex flex-col gap-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-subtle', stacked && 'w-full')}> | |
| 47 | 61 | {f.label} |
| 48 | 62 | {f.type === 'select' ? ( |
| 49 | − <select className={cn(input, f.width ?? 'w-40')} value={sp.get(f.name) ?? ''} onChange={(e) => update(f.name, e.target.value)}> | |
| 63 | + <select className={cn(input, stacked ? 'w-full' : (f.width ?? 'w-40'))} value={sp.get(f.name) ?? ''} onChange={(e) => update(f.name, e.target.value)}> | |
| 50 | 64 | <option value="">{f.placeholder ?? 'All'}</option> |
| 51 | 65 | {f.options?.map((o) => ( |
| 52 | 66 | <option key={o.value} value={o.value}> |
@@ -58,7 +72,7 @@ export function FilterBar({ fields, className, children, resetKeys }: { fields: | ||
| 58 | 72 | <input |
| 59 | 73 | type={f.type} |
| 60 | 74 | inputMode={f.type === 'number' ? 'decimal' : undefined} |
| 61 | − className={cn(input, f.width ?? 'w-28')} | |
| 75 | + className={cn(input, stacked ? 'w-full' : (f.width ?? 'w-28'))} | |
| 62 | 76 | placeholder={f.placeholder} |
| 63 | 77 | defaultValue={sp.get(f.name) ?? ''} |
| 64 | 78 | onBlur={(e) => { |
@@ -72,10 +86,56 @@ export function FilterBar({ fields, className, children, resetKeys }: { fields: | ||
| 72 | 86 | </label> |
| 73 | 87 | ))} |
| 74 | 88 | {children} |
| 75 | − {hasAny ? ( | |
| 76 | − <button type="button" onClick={reset} className="h-8 rounded-md px-2 text-[12px] text-muted hover:text-fg"> | |
| 77 | − Clear | |
| 89 | + </> | |
| 90 | + ); | |
| 91 | + | |
| 92 | + return ( | |
| 93 | + <div className={cn(pending && 'opacity-70', className)}> | |
| 94 | + {/* Desktop / tablet: inline */} | |
| 95 | + <div className="hidden flex-wrap items-end gap-2 md:flex"> | |
| 96 | + {controls(false)} | |
| 97 | + {activeCount ? ( | |
| 98 | + <button type="button" onClick={reset} className="h-8 rounded-md px-2 text-[12px] text-muted hover:text-fg"> | |
| 99 | + Clear | |
| 100 | + </button> | |
| 101 | + ) : null} | |
| 102 | + </div> | |
| 103 | + {/* Mobile: button + bottom sheet */} | |
| 104 | + <div className="flex items-center gap-2 md:hidden"> | |
| 105 | + <button type="button" onClick={() => setOpen(true)} className="btn h-10 min-h-[44px] gap-2" aria-haspopup="dialog" aria-expanded={open}> | |
| 106 | + <SlidersHorizontal className="h-4 w-4" /> | |
| 107 | + Filters | |
| 108 | + {activeCount ? <span className="num rounded-full bg-accent px-1.5 text-[10px] font-semibold text-accent-fg">{activeCount}</span> : null} | |
| 78 | 109 | </button> |
| 110 | + {activeCount ? ( | |
| 111 | + <button type="button" onClick={reset} className="h-10 rounded-md px-2 text-[13px] text-muted hover:text-fg"> | |
| 112 | + Clear | |
| 113 | + </button> | |
| 114 | + ) : null} | |
| 115 | + </div> | |
| 116 | + {open ? ( | |
| 117 | + <div className="fixed inset-0 z-[60] md:hidden" role="dialog" aria-modal="true" aria-label="Filters"> | |
| 118 | + <div className="absolute inset-0 bg-black/45" onClick={() => setOpen(false)} /> | |
| 119 | + <div className="sheet-in absolute inset-x-0 bottom-0 max-h-[88dvh] overflow-y-auto rounded-t-2xl border-t border-border bg-elevated shadow-pop safe-bottom"> | |
| 120 | + <div className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-elevated px-4 py-3"> | |
| 121 | + <span className="t-subtitle">Filters</span> | |
| 122 | + <button type="button" onClick={() => setOpen(false)} className="inline-flex h-10 w-10 items-center justify-center rounded-md text-muted hover:bg-inset" aria-label="Close filters"> | |
| 123 | + <X className="h-5 w-5" /> | |
| 124 | + </button> | |
| 125 | + </div> | |
| 126 | + <div className="flex flex-col gap-3 px-4 py-4">{controls(true)}</div> | |
| 127 | + <div className="sticky bottom-0 flex gap-2 border-t border-border bg-elevated px-4 py-3"> | |
| 128 | + {activeCount ? ( | |
| 129 | + <button type="button" onClick={reset} className="btn h-11 flex-1"> | |
| 130 | + Clear all | |
| 131 | + </button> | |
| 132 | + ) : null} | |
| 133 | + <button type="button" onClick={() => setOpen(false)} className="btn-primary h-11 flex-1"> | |
| 134 | + Show results | |
| 135 | + </button> | |
| 136 | + </div> | |
| 137 | + </div> | |
| 138 | + </div> | |
| 79 | 139 | ) : null} |
| 80 | 140 | </div> |
| 81 | 141 | ); |
modified
apps/web/src/components/ui/page-header.tsx
+20 −10
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | import Link from 'next/link'; |
| 2 | 2 | import type { ReactNode } from 'react'; |
| 3 | 3 | import { cn } from '@/lib/format'; |
| 4 | +import { StatStrip } from './primitives'; | |
| 4 | 5 | |
| 5 | 6 | export interface Crumb { |
| 6 | 7 | label: string; |
@@ -10,11 +11,15 @@ export interface Crumb { | ||
| 10 | 11 | export function Breadcrumbs({ items, className }: { items: Crumb[]; className?: string }) { |
| 11 | 12 | if (!items.length) return null; |
| 12 | 13 | return ( |
| 13 | − <nav aria-label="Breadcrumb" className={cn('mb-2 text-[11px] text-subtle', className)}> | |
| 14 | − <ol className="flex flex-wrap items-center gap-1"> | |
| 14 | + <nav aria-label="Breadcrumb" className={cn('mb-2 overflow-x-auto scrollbar-none text-[11px] text-subtle', className)}> | |
| 15 | + <ol className="flex min-w-max items-center gap-1"> | |
| 15 | 16 | {items.map((c, i) => ( |
| 16 | 17 | <li key={`${c.label}-${i}`} className="flex items-center gap-1"> |
| 17 | − {i > 0 ? <span aria-hidden>/</span> : null} | |
| 18 | + {i > 0 ? ( | |
| 19 | + <span aria-hidden className="text-border-strong"> | |
| 20 | + / | |
| 21 | + </span> | |
| 22 | + ) : null} | |
| 18 | 23 | {c.href && i < items.length - 1 ? ( |
| 19 | 24 | <Link href={c.href} className="hover:text-fg"> |
| 20 | 25 | {c.label} |
@@ -29,18 +34,23 @@ export function Breadcrumbs({ items, className }: { items: Crumb[]; className?: | ||
| 29 | 34 | ); |
| 30 | 35 | } |
| 31 | 36 | |
| 32 | −export function PageHeader({ title, description, crumbs, actions, meta, className, compact }: { title: ReactNode; description?: ReactNode; crumbs?: Crumb[]; actions?: ReactNode; meta?: ReactNode; className?: string; compact?: boolean }) { | |
| 37 | +/** | |
| 38 | + * Consistent page header: breadcrumb → kicker → title → one-line context → meta row → optional KPI strip. | |
| 39 | + */ | |
| 40 | +export function PageHeader({ title, description, crumbs, actions, meta, className, compact, kicker, stats }: { title: ReactNode; description?: ReactNode; crumbs?: Crumb[]; actions?: ReactNode; meta?: ReactNode; className?: string; compact?: boolean; kicker?: ReactNode; stats?: Array<{ label: ReactNode; value: ReactNode; sub?: ReactNode; href?: string }> }) { | |
| 33 | 41 | return ( |
| 34 | 42 | <div className={cn(compact ? 'mb-4' : 'mb-6', className)}> |
| 35 | 43 | {crumbs ? <Breadcrumbs items={crumbs} /> : null} |
| 36 | − <div className="flex flex-wrap items-end justify-between gap-3"> | |
| 37 | − <div className="min-w-0"> | |
| 38 | − <h1 className={cn('font-semibold tracking-tight text-fg', compact ? 'text-xl' : 'text-2xl sm:text-3xl')}>{title}</h1> | |
| 39 | − {description ? <p className="mt-1 max-w-3xl text-sm leading-relaxed text-muted">{description}</p> : null} | |
| 40 | − {meta ? <div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted">{meta}</div> : null} | |
| 44 | + <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3"> | |
| 45 | + <div className="min-w-0 flex-1"> | |
| 46 | + {kicker ? <p className="t-label mb-1.5">{kicker}</p> : null} | |
| 47 | + <h1 className={cn('text-fg', compact ? 't-title sm:text-xl' : 't-headline')}>{title}</h1> | |
| 48 | + {description ? <p className={cn('mt-1.5 max-w-3xl text-muted', compact ? 'text-[13px] leading-relaxed' : 'text-sm leading-relaxed')}>{description}</p> : null} | |
| 49 | + {meta ? <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">{meta}</div> : null} | |
| 41 | 50 | </div> |
| 42 | − {actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null} | |
| 51 | + {actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null} | |
| 43 | 52 | </div> |
| 53 | + {stats?.length ? <StatStrip items={stats} className="mt-4" /> : null} | |
| 44 | 54 | </div> |
| 45 | 55 | ); |
| 46 | 56 | } |
modified
apps/web/src/components/ui/pagination.tsx
+34 −11
@@ -1,7 +1,8 @@ | ||
| 1 | 1 | import Link from 'next/link'; |
| 2 | +import { ChevronLeft, ChevronRight } from 'lucide-react'; | |
| 2 | 3 | import { cn } from '@/lib/format'; |
| 3 | 4 | |
| 4 | −/** URL-driven pagination: keeps every other search param. */ | |
| 5 | +/** URL-driven pagination: keeps every other search param. Touch targets ≥ 44 px on mobile. */ | |
| 5 | 6 | export function Pagination({ page, pageSize, total, basePath, params, className }: { page: number; pageSize: number; total: number; basePath: string; params: Record<string, string | undefined>; className?: string }) { |
| 6 | 7 | const pages = Math.max(1, Math.ceil(total / pageSize)); |
| 7 | 8 | if (pages <= 1) return null; |
@@ -15,21 +16,43 @@ export function Pagination({ page, pageSize, total, basePath, params, className | ||
| 15 | 16 | }; |
| 16 | 17 | const from = (page - 1) * pageSize + 1; |
| 17 | 18 | const to = Math.min(total, page * pageSize); |
| 18 | − const btn = 'inline-flex h-8 items-center rounded-md border border-border px-2.5 text-xs font-medium hover:bg-inset aria-disabled:pointer-events-none aria-disabled:opacity-40'; | |
| 19 | + const btn = 'inline-flex h-10 min-w-[44px] items-center justify-center gap-1 rounded-md border border-border bg-elevated px-3 text-xs font-medium hover:bg-sunken aria-disabled:pointer-events-none aria-disabled:opacity-40 md:h-8 md:min-w-0'; | |
| 20 | + const around: number[] = []; | |
| 21 | + for (let p = Math.max(1, page - 2); p <= Math.min(pages, page + 2); p++) around.push(p); | |
| 19 | 22 | return ( |
| 20 | − <nav className={cn('mt-4 flex items-center justify-between gap-3 text-xs text-muted', className)} aria-label="Pagination"> | |
| 23 | + <nav className={cn('mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-muted', className)} aria-label="Pagination"> | |
| 21 | 24 | <span className="num"> |
| 22 | 25 | {from.toLocaleString('en-US')}–{to.toLocaleString('en-US')} of {total.toLocaleString('en-US')} |
| 23 | 26 | </span> |
| 24 | − <div className="flex items-center gap-1.5"> | |
| 25 | − <Link href={href(Math.max(1, page - 1))} aria-disabled={page <= 1} className={btn}> | |
| 26 | − Previous | |
| 27 | + <div className="flex items-center gap-1"> | |
| 28 | + <Link href={href(Math.max(1, page - 1))} aria-disabled={page <= 1} className={btn} aria-label="Previous page"> | |
| 29 | + <ChevronLeft className="h-3.5 w-3.5" /> | |
| 30 | + <span className="hidden sm:inline">Prev</span> | |
| 27 | 31 | </Link> |
| 28 | − <span className="num px-2"> | |
| 29 | − {page} / {pages} | |
| 30 | − </span> | |
| 31 | − <Link href={href(Math.min(pages, page + 1))} aria-disabled={page >= pages} className={btn}> | |
| 32 | − Next | |
| 32 | + {around[0]! > 1 ? ( | |
| 33 | + <> | |
| 34 | + <Link href={href(1)} className={cn(btn, 'num')}> | |
| 35 | + 1 | |
| 36 | + </Link> | |
| 37 | + {around[0]! > 2 ? <span className="px-1 text-subtle">…</span> : null} | |
| 38 | + </> | |
| 39 | + ) : null} | |
| 40 | + {around.map((p) => ( | |
| 41 | + <Link key={p} href={href(p)} aria-current={p === page ? 'page' : undefined} className={cn(btn, 'num', p === page && 'border-fg bg-accent text-accent-fg hover:bg-accent')}> | |
| 42 | + {p} | |
| 43 | + </Link> | |
| 44 | + ))} | |
| 45 | + {around[around.length - 1]! < pages ? ( | |
| 46 | + <> | |
| 47 | + {around[around.length - 1]! < pages - 1 ? <span className="px-1 text-subtle">…</span> : null} | |
| 48 | + <Link href={href(pages)} className={cn(btn, 'num')}> | |
| 49 | + {pages.toLocaleString('en-US')} | |
| 50 | + </Link> | |
| 51 | + </> | |
| 52 | + ) : null} | |
| 53 | + <Link href={href(Math.min(pages, page + 1))} aria-disabled={page >= pages} className={btn} aria-label="Next page"> | |
| 54 | + <span className="hidden sm:inline">Next</span> | |
| 55 | + <ChevronRight className="h-3.5 w-3.5" /> | |
| 33 | 56 | </Link> |
| 34 | 57 | </div> |
| 35 | 58 | </nav> |
modified
apps/web/src/components/ui/primitives.tsx
+105 −25
@@ -5,26 +5,40 @@ import { cn, deltaClass, fmtPct } from '@/lib/format'; | ||
| 5 | 5 | /** Minimal, dense UI primitives. Keep them boring; the data is the design. */ |
| 6 | 6 | |
| 7 | 7 | export function Card({ children, className, as: Tag = 'section', id }: { children: ReactNode; className?: string; as?: 'section' | 'div' | 'article'; id?: string }) { |
| 8 | − return <Tag className={cn('card', className)} id={id}>{children}</Tag>; | |
| 8 | + return ( | |
| 9 | + <Tag className={cn('card', className)} id={id}> | |
| 10 | + {children} | |
| 11 | + </Tag> | |
| 12 | + ); | |
| 9 | 13 | } |
| 10 | 14 | |
| 11 | −export function CardHeader({ title, subtitle, action, className }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string }) { | |
| 15 | +export function CardHeader({ title, subtitle, action, className, icon }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string; icon?: ReactNode }) { | |
| 12 | 16 | return ( |
| 13 | 17 | <header className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)}> |
| 14 | − <div className="min-w-0"> | |
| 15 | − <h2 className="text-sm font-semibold tracking-tight text-fg">{title}</h2> | |
| 16 | − {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null} | |
| 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> | |
| 17 | 24 | </div> |
| 18 | 25 | {action ? <div className="shrink-0 text-xs">{action}</div> : null} |
| 19 | 26 | </header> |
| 20 | 27 | ); |
| 21 | 28 | } |
| 22 | 29 | |
| 23 | −export function Delta({ value, digits = 2, className }: { value: number | null | undefined; digits?: number; className?: string }) { | |
| 24 | − return <span className={cn('num font-medium', deltaClass(value), className)}>{fmtPct(value, digits)}</span>; | |
| 30 | +export 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 | + ); | |
| 25 | 39 | } |
| 26 | 40 | |
| 27 | −export function Badge({ children, tone = 'neutral', className }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string }) { | |
| 41 | +export function Badge({ children, tone = 'neutral', className, dot = false }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string; dot?: boolean }) { | |
| 28 | 42 | const tones: Record<string, string> = { |
| 29 | 43 | neutral: 'bg-inset text-muted', |
| 30 | 44 | gain: 'bg-gain-bg text-gain', |
@@ -34,22 +48,55 @@ export function Badge({ children, tone = 'neutral', className }: { children: Rea | ||
| 34 | 48 | alert: 'bg-alert-bg text-alert', |
| 35 | 49 | gold: 'bg-alert-bg text-gold', |
| 36 | 50 | }; |
| 37 | − return <span className={cn('inline-flex items-center rounded-sm px-1.5 py-0.5 text-[11px] font-medium leading-4 tracking-wide', tones[tone], className)}>{children}</span>; | |
| 51 | + return ( | |
| 52 | + <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)}> | |
| 53 | + {dot ? <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> : null} | |
| 54 | + {children} | |
| 55 | + </span> | |
| 56 | + ); | |
| 38 | 57 | } |
| 39 | 58 | |
| 40 | −export function Stat({ label, value, sub, className }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string }) { | |
| 59 | +export function Stat({ label, value, sub, className, size = 'md' }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string; size?: 'sm' | 'md' | 'lg' }) { | |
| 41 | 60 | return ( |
| 42 | − <div className={cn('flex flex-col gap-0.5', className)}> | |
| 43 | − <span className="text-[11px] font-medium uppercase tracking-wider text-subtle">{label}</span> | |
| 44 | − <span className="num text-lg font-semibold leading-tight text-fg">{value}</span> | |
| 45 | − {sub ? <span className="text-xs text-muted">{sub}</span> : null} | |
| 61 | + <div className={cn('flex min-w-0 flex-col gap-0.5', className)}> | |
| 62 | + <span className="t-label">{label}</span> | |
| 63 | + <span className={cn('num truncate font-semibold leading-tight text-fg', size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-sm' : 'text-lg')}>{value}</span> | |
| 64 | + {sub ? <span className="truncate text-xs text-muted">{sub}</span> : null} | |
| 46 | 65 | </div> |
| 47 | 66 | ); |
| 48 | 67 | } |
| 49 | 68 | |
| 50 | −export function EmptyState({ title, description, action, className }: { title: ReactNode; description?: ReactNode; action?: ReactNode; className?: string }) { | |
| 69 | +/** Dense KPI strip used under page headers and in the hero. */ | |
| 70 | +export function StatStrip({ items, className, cols }: { items: Array<{ label: ReactNode; value: ReactNode; sub?: ReactNode; href?: string }>; className?: string; cols?: string }) { | |
| 71 | + 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'); | |
| 72 | + return ( | |
| 73 | + <dl className={cn('grid gap-px overflow-hidden rounded-md border border-border bg-border', grid, className)}> | |
| 74 | + {items.map((it, i) => { | |
| 75 | + const inner = ( | |
| 76 | + <> | |
| 77 | + <dt className="t-label">{it.label}</dt> | |
| 78 | + <dd className="num mt-1 truncate text-[17px] font-semibold leading-none text-fg sm:text-lg">{it.value}</dd> | |
| 79 | + {it.sub ? <dd className="mt-1 truncate text-[11px] text-muted">{it.sub}</dd> : null} | |
| 80 | + </> | |
| 81 | + ); | |
| 82 | + return it.href ? ( | |
| 83 | + <Link key={i} href={it.href} className="block min-w-0 bg-elevated px-3 py-2.5 hover:bg-sunken"> | |
| 84 | + {inner} | |
| 85 | + </Link> | |
| 86 | + ) : ( | |
| 87 | + <div key={i} className="min-w-0 bg-elevated px-3 py-2.5"> | |
| 88 | + {inner} | |
| 89 | + </div> | |
| 90 | + ); | |
| 91 | + })} | |
| 92 | + </dl> | |
| 93 | + ); | |
| 94 | +} | |
| 95 | + | |
| 96 | +export function EmptyState({ title, description, action, className, compact }: { title: ReactNode; description?: ReactNode; action?: ReactNode; className?: string; compact?: boolean }) { | |
| 51 | 97 | return ( |
| 52 | − <div className={cn('flex flex-col items-center justify-center gap-2 px-6 py-12 text-center', className)}> | |
| 98 | + <div className={cn('flex flex-col items-center justify-center gap-1.5 px-6 text-center', compact ? 'py-6' : 'py-12', className)}> | |
| 99 | + <span className="mb-1 h-1 w-8 rounded-full bg-inset" aria-hidden /> | |
| 53 | 100 | <p className="text-sm font-medium text-fg">{title}</p> |
| 54 | 101 | {description ? <p className="max-w-md text-xs leading-relaxed text-muted">{description}</p> : null} |
| 55 | 102 | {action ? <div className="mt-2">{action}</div> : null} |
@@ -59,15 +106,22 @@ export function EmptyState({ title, description, action, className }: { title: R | ||
| 59 | 106 | |
| 60 | 107 | /** Honest placeholder for metrics without enough evidence (§191–§192). */ |
| 61 | 108 | export function Unavailable({ reason = 'Data unavailable', className }: { reason?: string; className?: string }) { |
| 62 | − return <span className={cn('text-subtle', className)} title={reason}>—</span>; | |
| 109 | + return ( | |
| 110 | + <span className={cn('text-subtle', className)} title={reason}> | |
| 111 | + — | |
| 112 | + </span> | |
| 113 | + ); | |
| 63 | 114 | } |
| 64 | 115 | |
| 65 | −export function SectionTitle({ children, href, hrefLabel = 'View all', className }: { children: ReactNode; href?: string; hrefLabel?: string; className?: string }) { | |
| 116 | +export function SectionTitle({ children, href, hrefLabel = 'View all', className, subtitle }: { children: ReactNode; href?: string; hrefLabel?: string; className?: string; subtitle?: ReactNode }) { | |
| 66 | 117 | return ( |
| 67 | − <div className={cn('mb-3 flex items-baseline justify-between gap-3', className)}> | |
| 68 | − <h2 className="text-base font-semibold tracking-tight text-fg">{children}</h2> | |
| 118 | + <div className={cn('mb-3 flex items-end justify-between gap-3', className)}> | |
| 119 | + <div className="min-w-0"> | |
| 120 | + <h2 className="t-title text-fg">{children}</h2> | |
| 121 | + {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null} | |
| 122 | + </div> | |
| 69 | 123 | {href ? ( |
| 70 | − <Link href={href} className="text-xs font-medium text-muted hover:text-fg"> | |
| 124 | + <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg"> | |
| 71 | 125 | {hrefLabel} → |
| 72 | 126 | </Link> |
| 73 | 127 | ) : null} |
@@ -79,13 +133,39 @@ export function Skeleton({ className }: { className?: string }) { | ||
| 79 | 133 | return <div className={cn('animate-pulse rounded-sm bg-inset', className)} aria-hidden />; |
| 80 | 134 | } |
| 81 | 135 | |
| 82 | −export function Table({ children, className, dense = true }: { children: ReactNode; className?: string; dense?: boolean }) { | |
| 136 | +/** Skeleton matching a dense table (header + n rows) to avoid layout shift. */ | |
| 137 | +export function TableSkeleton({ rows = 8, className }: { rows?: number; className?: string }) { | |
| 83 | 138 | return ( |
| 84 | − <div className={cn('w-full overflow-x-auto scrollbar-none', className)}> | |
| 85 | − <table className={cn('w-full border-collapse text-left', dense ? 'text-[13px]' : 'text-sm')}>{children}</table> | |
| 139 | + <div className={cn('card overflow-hidden', className)} aria-hidden> | |
| 140 | + <div className="h-9 border-b border-border bg-elevated" /> | |
| 141 | + {Array.from({ length: rows }).map((_, i) => ( | |
| 142 | + <div key={i} className="flex items-center gap-3 border-b border-border px-3 py-2 last:border-b-0"> | |
| 143 | + <Skeleton className="h-8 w-8" /> | |
| 144 | + <Skeleton className="h-3 w-1/3" /> | |
| 145 | + <Skeleton className="ml-auto h-3 w-16" /> | |
| 146 | + <Skeleton className="h-3 w-12" /> | |
| 147 | + </div> | |
| 148 | + ))} | |
| 86 | 149 | </div> |
| 87 | 150 | ); |
| 88 | 151 | } |
| 89 | −export const th = 'sticky top-0 z-[1] whitespace-nowrap border-b border-border bg-elevated px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-subtle'; | |
| 152 | + | |
| 153 | +export function Table({ children, className, dense = true, sticky = true }: { children: ReactNode; className?: string; dense?: boolean; sticky?: boolean }) { | |
| 154 | + return ( | |
| 155 | + <div className={cn('w-full overflow-x-auto overscroll-x-contain scrollbar-none', className)}> | |
| 156 | + <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> | |
| 157 | + </div> | |
| 158 | + ); | |
| 159 | +} | |
| 160 | +export 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'; | |
| 90 | 161 | export const td = 'whitespace-nowrap border-b border-border px-3 py-2 align-middle'; |
| 91 | 162 | export const tdNum = `${td} num text-right`; |
| 163 | + | |
| 164 | +/** Sequential page "kicker" — small uppercase label above a title. */ | |
| 165 | +export function Kicker({ children, className }: { children: ReactNode; className?: string }) { | |
| 166 | + return <p className={cn('t-label mb-2', className)}>{children}</p>; | |
| 167 | +} | |
| 168 | + | |
| 169 | +export function Divider({ className }: { className?: string }) { | |
| 170 | + return <hr className={cn('border-0 border-t border-border', className)} />; | |
| 171 | +} | |
modified
apps/web/src/components/ui/tabs.tsx
+4 −4
@@ -13,7 +13,7 @@ export interface TabDef { | ||
| 13 | 13 | export function Tabs({ tabs, active, className }: { tabs: TabDef[]; active: string; className?: string }) { |
| 14 | 14 | return ( |
| 15 | 15 | <div className={cn('scrollbar-none -mx-4 overflow-x-auto border-b border-border px-4 sm:mx-0 sm:px-0', className)} role="tablist"> |
| 16 | − <div className="flex min-w-max gap-1"> | |
| 16 | + <div className="flex min-w-max gap-0.5"> | |
| 17 | 17 | {tabs.map((t) => { |
| 18 | 18 | const isActive = t.id === active; |
| 19 | 19 | return ( |
@@ -23,10 +23,10 @@ export function Tabs({ tabs, active, className }: { tabs: TabDef[]; active: stri | ||
| 23 | 23 | role="tab" |
| 24 | 24 | aria-selected={isActive} |
| 25 | 25 | scroll={false} |
| 26 | − className={cn('-mb-px inline-flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-[13px] font-medium', isActive ? 'border-fg text-fg' : 'border-transparent text-muted hover:text-fg')} | |
| 26 | + className={cn('-mb-px inline-flex min-h-[44px] items-center gap-1.5 whitespace-nowrap border-b-2 px-3 text-[13px] font-medium md:min-h-[38px]', isActive ? 'border-fg text-fg' : 'border-transparent text-muted hover:border-border-strong hover:text-fg')} | |
| 27 | 27 | > |
| 28 | 28 | {t.label} |
| 29 | − {t.count !== undefined && t.count !== null ? <span className="num rounded-sm bg-inset px-1 text-[10px] text-muted">{t.count.toLocaleString('en-US')}</span> : null} | |
| 29 | + {t.count !== undefined && t.count !== null ? <span className={cn('num rounded-sm px-1 text-[10px]', isActive ? 'bg-accent text-accent-fg' : 'bg-inset text-muted')}>{t.count.toLocaleString('en-US')}</span> : null} | |
| 30 | 30 | </Link> |
| 31 | 31 | ); |
| 32 | 32 | })} |
@@ -40,7 +40,7 @@ export function Segmented({ options, active, className }: { options: Array<{ id: | ||
| 40 | 40 | return ( |
| 41 | 41 | <div className={cn('inline-flex rounded-md border border-border bg-sunken p-0.5 text-[11px] font-medium', className)}> |
| 42 | 42 | {options.map((o) => ( |
| 43 | − <Link key={o.id} href={o.href} scroll={false} className={cn('rounded-[4px] px-2 py-1', o.id === active ? 'bg-elevated text-fg shadow-card' : 'text-muted hover:text-fg')} aria-current={o.id === active ? 'true' : undefined}> | |
| 43 | + <Link key={o.id} href={o.href} scroll={false} className={cn('inline-flex min-h-[28px] items-center rounded-[4px] px-2.5', o.id === active ? 'bg-elevated text-fg shadow-card' : 'text-muted hover:text-fg')} aria-current={o.id === active ? 'true' : undefined}> | |
| 44 | 44 | {o.label} |
| 45 | 45 | </Link> |
| 46 | 46 | ))} |
modified
apps/web/src/lib/queries/assets.ts
+26 −1
@@ -782,7 +782,11 @@ export const getSetFromAssets = cache(async (slug: string) => { | ||
| 782 | 782 | export const getCategoryThumbnails = cache(async (slugs: string[]): Promise<Map<string, string>> => { |
| 783 | 783 | if (!slugs.length) return new Map(); |
| 784 | 784 | const r = await rows<Record<string, unknown>>(sql` |
| 785 | − SELECT c.slug, (SELECT a.hero_image_url FROM assets a WHERE a.category_slug = c.slug AND a.hero_image_url IS NOT NULL ORDER BY a.created_at DESC LIMIT 1) AS url | |
| 785 | + SELECT c.slug, ( | |
| 786 | + SELECT a.hero_image_url FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id | |
| 787 | + WHERE (a.category_slug = c.slug OR a.family_slug = c.slug) AND a.hero_image_url IS NOT NULL | |
| 788 | + ORDER BY s.sales_count DESC NULLS LAST, s.riv_usd DESC NULLS LAST, a.created_at DESC LIMIT 1 | |
| 789 | + ) AS url | |
| 786 | 790 | FROM unnest(${textArray(slugs)}) AS c(slug) |
| 787 | 791 | `); |
| 788 | 792 | return new Map(r.filter((x) => x.url).map((x) => [String(x.slug), String(x.url)])); |
@@ -816,3 +820,24 @@ export const getScopeCounts = cache(async (f: { scope?: string[] | null; set?: s | ||
| 816 | 820 | `); |
| 817 | 821 | return { assets: int(x?.assets), priced: int(x?.priced), withSales: int(x?.with_sales), withObservations: int(x?.with_obs), withImages: int(x?.with_images), listings: int(x?.listings) }; |
| 818 | 822 | }); |
| 823 | + | |
| 824 | +/** Valuation history (RIV with low/high band) from the valuations table, one point per day. */ | |
| 825 | +export const getValuationHistory = cache(async (assetId: string, variantId: string | null = null, days = 1830): Promise<Array<{ date: string; rivUsd: number; lowUsd: number | null; highUsd: number | null; confidence: number; sampleSize: number }>> => { | |
| 826 | + const r = await rows<Record<string, unknown>>(sql` | |
| 827 | + SELECT DISTINCT ON (computed_at::date) computed_at::date AS d, riv_usd, low_usd, high_usd, confidence, sample_size | |
| 828 | + FROM valuations | |
| 829 | + WHERE asset_id = ${assetId} AND ${variantId ? sql`variant_id = ${variantId}` : sql`variant_id IS NULL`} AND riv_usd IS NOT NULL AND computed_at >= now() - (${days}::int || ' days')::interval | |
| 830 | + ORDER BY computed_at::date, computed_at DESC | |
| 831 | + `); | |
| 832 | + return r.map((x) => ({ date: String(x.d), rivUsd: Number(x.riv_usd), lowUsd: num(x.low_usd), highUsd: num(x.high_usd), confidence: Number(x.confidence ?? 0), sampleSize: int(x.sample_size) })); | |
| 833 | +}); | |
| 834 | + | |
| 835 | +/** Whether the signed-in user watches this asset (null when anonymous). */ | |
| 836 | +export async function isWatchedBy(userId: string | null, assetId: string): Promise<boolean> { | |
| 837 | + if (!userId) return false; | |
| 838 | + const x = await one<Record<string, unknown>>(sql` | |
| 839 | + SELECT 1 AS ok FROM watchlist_items wi JOIN watchlists w ON w.id = wi.watchlist_id | |
| 840 | + WHERE w.user_id = ${userId} AND wi.target_type = 'asset' AND wi.target_id = ${assetId} LIMIT 1 | |
| 841 | + `); | |
| 842 | + return Boolean(x); | |
| 843 | +} | |
modified
apps/web/src/lib/queries/markets.ts
+18 −1
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | import 'server-only'; |
| 2 | 2 | import { cache } from 'react'; |
| 3 | 3 | import { CATEGORIES, FAMILIES, getCategory, type CategoryNode } from '@rareindex/taxonomy'; |
| 4 | −import { rows, one, sql, num, int, str, date } from './_util'; | |
| 4 | +import { rows, one, sql, num, int, str, date, textArray } from './_util'; | |
| 5 | 5 | import { categoryScope, getSetFromAssets } from './assets'; |
| 6 | 6 | |
| 7 | 7 | export interface CategorySnapshot { |
@@ -104,6 +104,23 @@ export const getCategorySeries = cache(async (slug: string, days: number | null | ||
| 104 | 104 | return r.map(toSnap); |
| 105 | 105 | }); |
| 106 | 106 | |
| 107 | +/** Compact per-category index series (values only) for sparklines; categories without snapshots are absent. */ | |
| 108 | +export const getCategorySparklines = cache(async (slugs: string[], days = 90): Promise<Map<string, number[]>> => { | |
| 109 | + if (!slugs.length) return new Map(); | |
| 110 | + const r = await rows<{ category_slug: string; values: unknown }>(sql` | |
| 111 | + SELECT category_slug, array_agg(index_value ORDER BY date) AS values | |
| 112 | + FROM category_snapshots | |
| 113 | + WHERE category_slug = ANY(${textArray(slugs)}) AND index_value IS NOT NULL AND date >= current_date - ${days}::int | |
| 114 | + GROUP BY category_slug | |
| 115 | + `); | |
| 116 | + const out = new Map<string, number[]>(); | |
| 117 | + for (const x of r) { | |
| 118 | + const vals = (Array.isArray(x.values) ? x.values : []).map((v) => Number(v)).filter((v) => Number.isFinite(v)); | |
| 119 | + if (vals.length >= 3) out.set(String(x.category_slug), vals); | |
| 120 | + } | |
| 121 | + return out; | |
| 122 | +}); | |
| 123 | + | |
| 107 | 124 | export const getCategoryRow = cache(async (slug: string): Promise<MarketRow | null> => { |
| 108 | 125 | const node = getCategory(slug); |
| 109 | 126 | if (!node) return null; |
| 110 | 127 | |