TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { Suspense } from 'react';4import { searchAssets, searchSets } from '@rareindex/search';5import { Thumb } from '@/components/market/bits';6import { fmtNum } from '@/lib/format';7import { PageHeader } from '@/components/ui/page-header';8import { Card, CardHeader, EmptyState, Badge, Skeleton } from '@/components/ui/primitives';9import { Pagination } from '@/components/ui/pagination';10import { AssetList } from '@/components/market/asset-list';11import { attachGuidePrices, getAssetsBySlugs } from '@/lib/queries/assets';12import { rows, sql } from '@/lib/queries/_util';13import { CATEGORIES, INDICES, getGrader } from '@rareindex/taxonomy';14import { catName } from '@/lib/taxonomy';15import { sp1, spInt, type SP } from '@/lib/search-params';1617export const metadata: Metadata = { title: 'Search', description: 'Search collectible assets, sets, categories, brands and sources with natural-language filters.' };1819export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) {20 const sp = await searchParams;21 const q = sp1(sp, 'q') ?? '';22 const category = sp1(sp, 'category') ?? null;23 const page = spInt(sp, 'page');24 return (25 <div>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 <form action="/search" role="search" className="mb-4 flex max-w-2xl gap-2">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 {category ? <input type="hidden" name="category" value={category} /> : null}30 <button type="submit" className="h-10 rounded-md bg-accent px-4 text-sm font-medium text-accent-fg">31 Search32 </button>33 </form>34 {q ? (35 <Suspense key={`${q}-${category}-${page}`} fallback={<Skeleton className="h-96" />}>36 <Results q={q} category={category} page={page} />37 </Suspense>38 ) : (39 <Card>40 <EmptyState title="Type anything collectible" description="Card, watch, game, sneaker, LEGO set, comic, coin… Filters like “PSA 10”, “under $5,000”, “sealed” or a year are understood." />41 </Card>42 )}43 </div>44 );45}4647async function Results({ q, category, page }: { q: string; category: string | null; page: number }) {48 const pageSize = 24;49 const [res, setHits] = await Promise.all([searchAssets(q, { limit: pageSize, offset: (page - 1) * pageSize, categorySlug: category }), searchSets(q, 8)]);50 const items = await attachGuidePrices(await getAssetsBySlugs(res.hits.map((h) => h.slug)));51 const p = res.parsed;52 const lower = q.toLowerCase();53 const matchedCats = CATEGORIES.filter((c) => c.name.toLowerCase().includes(lower) || p.categorySlugs.includes(c.slug)).slice(0, 6);54 const matchedIdx = INDICES.filter((i) => i.ticker.toLowerCase().includes(lower) || i.name.toLowerCase().includes(lower)).slice(0, 3);55 const brands = (await rows<{ brand: string; n: number }>(sql`SELECT brand, count(*) AS n FROM assets WHERE brand ILIKE ${'%' + q + '%'} GROUP BY brand ORDER BY n DESC LIMIT 6`)).map((b) => ({ ...b }));56 const sources = (await rows<{ id: string; name: string }>(sql`SELECT id, name FROM sources WHERE name ILIKE ${'%' + q + '%'} LIMIT 4`)).map((s) => ({ ...s }));57 const chips = [58 p.grader ? `${getGrader(p.grader)?.name ?? p.grader.toUpperCase()}${p.grade ? ` ${p.grade}` : ''}` : null,59 p.year ? `year ${p.year}` : null,60 p.priceMin != null ? `≥ $${p.priceMin.toLocaleString('en-US')}` : null,61 p.priceMax != null ? `≤ $${p.priceMax.toLocaleString('en-US')}` : null,62 ...p.conditions,63 ...p.editions,64 ...p.categorySlugs.map((c) => catName(c)),65 ].filter(Boolean) as string[];66 return (67 <div className="grid gap-4 lg:grid-cols-[1fr_280px]">68 <div>69 <div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">70 <span className="num">71 {res.total.toLocaleString('en-US')} assets · {res.tookMs} ms72 </span>73 {chips.length ? <span>· understood:</span> : null}74 {chips.map((c) => (75 <Badge key={c} tone="index">76 {c}77 </Badge>78 ))}79 {p.text && p.text !== q ? <span>· text “{p.text}”</span> : null}80 </div>81 {setHits.length ? (82 <Card className="mb-3">83 <CardHeader title="Sets & releases" subtitle="Matching releases — open one to browse every card or item in it" />84 <ul className="grid grid-cols-1 text-[12px] sm:grid-cols-2">85 {setHits.map((s) => (86 <li key={s.slug} className="border-b border-border">87 <Link href={`/set/${s.slug}`} className="flex items-center gap-2 px-4 py-1.5 hover:bg-sunken">88 <Thumb src={s.thumb} alt="" size={28} />89 <span className="min-w-0 flex-1 truncate font-medium text-fg">90 {s.name}91 {s.code ? <span className="ml-1 text-subtle">{s.code}</span> : null}92 {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}93 </span>94 <span className="num shrink-0 text-muted">95 {catName(s.categorySlug)} · {fmtNum(s.assets)} assets96 </span>97 </Link>98 </li>99 ))}100 </ul>101 </Card>102 ) : null}103 <Card className="overflow-hidden">104 <CardHeader title="Assets" subtitle={res.total ? `${res.total.toLocaleString('en-US')} matching canonical assets · guide prices labelled, RIV when available` : undefined} />105 <AssetList items={items} rank startRank={(page - 1) * pageSize + 1} columns={['riv', 'latestSale', 'change30d', 'sales', 'listings']} emptyTitle="No matching assets" emptyDescription="Try fewer words, a set name or a collector number (e.g. “base set”, “LOB-001”, “116500LN”), or browse the category pages. New assets are added continuously by the connectors." />106 </Card>107 <Pagination page={page} pageSize={pageSize} total={res.total} basePath="/search" params={{ q, category: category ?? undefined }} />108 </div>109 <aside className="grid gap-3 self-start">110 {res.categories.length ? (111 <Card>112 <CardHeader title="Narrow by category" />113 <ul className="divide-y divide-border text-[12px]">114 {res.categories.map((c) => (115 <li key={c.slug}>116 <Link href={`/search?q=${encodeURIComponent(q)}&category=${c.slug}`} className={`flex items-center justify-between px-4 py-1.5 hover:bg-sunken ${category === c.slug ? 'font-medium text-fg' : 'text-muted'}`}>117 {c.name}118 <span className="num">{c.count}</span>119 </Link>120 </li>121 ))}122 {category ? (123 <li>124 <Link href={`/search?q=${encodeURIComponent(q)}`} className="block px-4 py-1.5 text-muted hover:text-fg">125 Clear category126 </Link>127 </li>128 ) : null}129 </ul>130 </Card>131 ) : null}132 {matchedCats.length || matchedIdx.length ? (133 <Card>134 <CardHeader title="Markets & indices" />135 <ul className="divide-y divide-border text-[12px]">136 {matchedCats.map((c) => (137 <li key={c.slug}>138 <Link href={`/markets/${c.slug}`} className="block px-4 py-1.5 text-fg hover:bg-sunken">139 {c.name} <span className="text-subtle">market</span>140 </Link>141 </li>142 ))}143 {matchedIdx.map((i) => (144 <li key={i.ticker}>145 <Link href={`/rareindex/${i.ticker}`} className="block px-4 py-1.5 text-fg hover:bg-sunken">146 {i.ticker} <span className="text-subtle">index</span>147 </Link>148 </li>149 ))}150 </ul>151 </Card>152 ) : null}153 {brands.length ? (154 <Card>155 <CardHeader title="Brands" />156 <ul className="divide-y divide-border text-[12px]">157 {brands.map((b) => (158 <li key={b.brand}>159 <Link href={`/brand/${encodeURIComponent(b.brand)}`} className="flex justify-between px-4 py-1.5 text-fg hover:bg-sunken">160 {b.brand} <span className="num text-subtle">{Number(b.n)}</span>161 </Link>162 </li>163 ))}164 </ul>165 </Card>166 ) : null}167 {sources.length ? (168 <Card>169 <CardHeader title="Sources" />170 <ul className="divide-y divide-border text-[12px]">171 {sources.map((s) => (172 <li key={s.id}>173 <Link href={`/sales?source=${s.id}`} className="block px-4 py-1.5 text-fg hover:bg-sunken">174 {s.name} <span className="text-subtle">sales</span>175 </Link>176 </li>177 ))}178 </ul>179 </Card>180 ) : null}181 </aside>182 </div>183 );184}185