SPB Git forge

spb/rareindex

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