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%
11.7 KB · 211 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { Suspense } from 'react';4import { FAMILIES } from '@rareindex/taxonomy';5import { PageHeader } from '@/components/ui/page-header';6import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge, StatStrip } from '@/components/ui/primitives';7import { Tabs } from '@/components/ui/tabs';8import { FilterBar } from '@/components/ui/filter-bar';9import { Pagination } from '@/components/ui/pagination';10import { LotsIntelCards, LotsIntelTable } from '@/components/market/lots-table';11import { getLotOverview, listAuctionHouses, listAuctions, listLotsPaged, type LotSort } from '@/lib/queries/market-lists';12import { feeScheduleFor } from '@rareindex/valuation';13import { feeScheduleSummary, FeeConfidenceBadge } from '@/components/market/fee-schedule';14import { houseSlug, ENDING_WINDOWS, endingHours } from '@/lib/auction-house';15import { fmtDate, fmtNum, fmtRelative, cn } from '@/lib/format';16import { catName } from '@/lib/taxonomy';17import { sp1, spEnum, spInt, type SP } from '@/lib/search-params';1819export const metadata: Metadata = { title: 'Auctions', description: 'Live and upcoming collectible auction lots with the buyer-pays all-in cost, the RareIndex Valuation and bid-vs-RIV, across auction houses worldwide.' };2021const TABS = ['open', 'ending', 'below', 'results', 'houses'] as const;2223export default async function AuctionsPage({ searchParams }: { searchParams: Promise<SP> }) {24  const sp = await searchParams;25  const tab = spEnum(sp, 'tab', TABS, 'open');26  const category = sp1(sp, 'category') ?? null;27  const house = sp1(sp, 'house') ?? null;28  const ending = sp1(sp, 'ending') ?? null;29  const below = sp1(sp, 'below') === '1' || tab === 'below';30  const sort = spEnum(sp, 'sort', ['ending', 'value', 'discount', 'bids'] as const, tab === 'below' ? 'discount' : 'ending') as LotSort;31  const page = spInt(sp, 'page');32  const hours = tab === 'ending' ? (endingHours(ending) ?? 72) : endingHours(ending);3334  const [overview, houses, auctions, lots] = await Promise.all([35    getLotOverview(),36    listAuctionHouses(),37    tab === 'houses' ? Promise.resolve([]) : listAuctions({ limit: 40, house }),38    tab === 'houses'39      ? Promise.resolve({ items: [], total: 0, page: 1, pageSize: 50 })40      : listLotsPaged({41          open: tab !== 'results',42          status: tab === 'results' ? 'ended' : null,43          endingWithinHours: hours,44          category,45          house,46          maxVsRiv: below ? -0.1 : null,47          sort: tab === 'results' ? (sort === 'ending' ? 'value' : sort) : sort,48          page,49          pageSize: 50,50        }),51  ]);5253  const params = { tab, category: category ?? undefined, house: house ?? undefined, ending: ending ?? undefined, below: below && tab !== 'below' ? '1' : undefined, sort: sort !== 'ending' ? sort : undefined };54  const q = (extra: Record<string, string | undefined>) => {55    const u = new URLSearchParams();56    for (const [k, v] of Object.entries({ ...params, ...extra })) if (v) u.set(k, v);57    const s = u.toString();58    return `/auctions${s ? `?${s}` : ''}`;59  };6061  return (62    <div>63      <PageHeader64        kicker="Auction intelligence"65        title="Auctions"66        description="Live and upcoming lots from auction-house connectors with the buyer-pays cost and the RareIndex Valuation side by side. All-in = hammer + buyer's premium from the house's published or estimated schedule; VAT/duties/shipping not included. Model analytics, not advice."67        compact68        actions={69          <Link href="/auctions/calendar" className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium hover:bg-inset">70            Auction calendar →71          </Link>72        }73      />74      <StatStrip75        className="mb-4"76        items={[77          { label: 'Open lots', value: fmtNum(overview.open), sub: 'live + upcoming' },78          { label: 'Ending 24 h', value: fmtNum(overview.ending24h), href: q({ tab: 'ending', ending: '24h' }) },79          { label: 'With bids', value: fmtNum(overview.withBids) },80          { label: 'Below RIV', value: fmtNum(overview.belowRiv), sub: overview.assessed ? `of ${fmtNum(overview.assessed)} assessed` : 'not assessed yet', href: q({ tab: 'below' }) },81          { label: 'Houses', value: fmtNum(overview.houses), href: q({ tab: 'houses' }) },82        ]}83      />84      <Tabs85        active={tab}86        tabs={[87          { id: 'open', label: 'Open lots', href: q({ tab: 'open' }) },88          { id: 'ending', label: 'Ending soon', href: q({ tab: 'ending' }) },89          { id: 'below', label: 'Below RIV', count: overview.belowRiv || null, href: q({ tab: 'below' }) },90          { id: 'results', label: 'Results', href: q({ tab: 'results' }) },91          { id: 'houses', label: 'Auction houses', count: houses.length, href: q({ tab: 'houses' }) },92        ]}93        className="mb-4"94      />9596      {tab === 'houses' ? (97        <Card className="overflow-hidden">98          <CardHeader title="Auction houses" subtitle="Sources with auction connectors · buyer's premium schedule on file when known" />99          {houses.length ? (100            <Table>101              <thead>102                <tr>103                  <th className={th}>House</th>104                  <th className={th}>Buyer&apos;s premium</th>105                  <th className={cn(th, 'text-right')}>Auctions</th>106                  <th className={cn(th, 'text-right')}>Lots tracked</th>107                  <th className={cn(th, 'text-right')}>Open</th>108                </tr>109              </thead>110              <tbody>111                {houses.map((h) => {112                  const s = feeScheduleFor(h.house);113                  return (114                    <tr key={h.house} className="hover:bg-sunken">115                      <td className={cn(td, 'font-medium')}>116                        <Link href={`/auctions/house/${houseSlug(h.house)}`} className="hover:underline">117                          {h.house}118                        </Link>119                      </td>120                      <td className={cn(td, 'text-muted')}>121                        {s ? (122                          <span className="inline-flex flex-wrap items-center gap-1.5">123                            <span className="num">{feeScheduleSummary(s)}</span>124                            <FeeConfidenceBadge confidence={s.confidence} />125                          </span>126                        ) : (127                          <FeeConfidenceBadge confidence="default" />128                        )}129                      </td>130                      <td className={tdNum}>{fmtNum(h.auctions)}</td>131                      <td className={tdNum}>{fmtNum(h.lots)}</td>132                      <td className={tdNum}>{fmtNum(h.upcoming)}</td>133                    </tr>134                  );135                })}136              </tbody>137            </Table>138          ) : (139            <EmptyState title="No auction houses connected yet" description="Auction-house connectors (results and catalogues) are part of the connector roadmap." />140          )}141        </Card>142      ) : (143        <>144          <div className="mb-3 flex flex-wrap items-center justify-between gap-2">145            <Suspense>146              <FilterBar147                resetKeys={['page']}148                fields={[149                  { name: 'category', label: 'Category', type: 'select', options: FAMILIES.map((f) => ({ value: f.slug, label: f.name })), width: 'w-44' },150                  { name: 'house', label: 'House', type: 'select', options: houses.map((h) => ({ value: h.house, label: h.house })), width: 'w-44' },151                  ...(tab === 'results' ? [] : [{ name: 'ending', label: 'Ending within', type: 'select' as const, options: ENDING_WINDOWS.map((w) => ({ value: w.id, label: w.label })), placeholder: tab === 'ending' ? '72 h' : 'Any', width: 'w-32' }]),152                  { name: 'sort', label: 'Sort', type: 'select', options: [{ value: 'ending', label: 'Ending soonest' }, { value: 'discount', label: 'Most below RIV' }, { value: 'bids', label: 'Most bids' }, { value: 'value', label: 'Highest value' }], placeholder: tab === 'below' ? 'Most below RIV' : 'Ending soonest', width: 'w-40' },153                  ...(tab === 'below' || tab === 'results' ? [] : [{ name: 'below', label: 'Below RIV only', type: 'select' as const, options: [{ value: '1', label: 'Yes' }], placeholder: 'No', width: 'w-32' }]),154                ]}155              />156            </Suspense>157            <p className="text-[11px] text-subtle">158              {fmtNum(lots.total)} lot{lots.total === 1 ? '' : 's'}159              {tab === 'ending' ? ` · ending within ${hours} h` : ''}160              {below ? ' · assessed all-in bid or estimate ≥ 10 % below the RIV of the same variant' : ''}161            </p>162          </div>163          <div className="grid gap-4 xl:grid-cols-[3fr_1fr]">164            <Card className="overflow-hidden">165              <CardHeader166                title={tab === 'ending' ? `Lots ending in the next ${hours} hours` : tab === 'below' ? 'Lots below the RareIndex Valuation' : tab === 'results' ? 'Recorded results' : 'Open lots'}167                subtitle={tab === 'below' ? 'Gated exactly like listings: transaction-based RIV of the same variant, ≥ 5 sales, medium+ confidence; opening prices without a bid use the low estimate (“est.”). Deeper than −50 % is held for review.' : 'Estimates and bids are the house’s own figures; all-in adds the buyer’s premium.'}168              />169              <div className="md:hidden">170                <LotsIntelCards items={lots.items} />171                {!lots.items.length ? <EmptyState title="No lots" description={tab === 'below' ? 'No assessed lot is below its RIV right now — the auctions worker assesses open lots hourly.' : 'Lots appear once auction-house connectors publish catalogues.'} /> : null}172              </div>173              <div className="hidden md:block">174                <LotsIntelTable items={lots.items} emptyDescription={tab === 'below' ? 'No assessed lot is below its RIV right now — the auctions worker assesses open lots hourly.' : undefined} />175              </div>176              <Pagination page={lots.page} pageSize={lots.pageSize} total={lots.total} basePath="/auctions" params={params} className="border-t border-border px-4 py-3" />177            </Card>178            <Card className="overflow-hidden">179              <CardHeader title="Auctions" subtitle="Catalogues tracked" />180              {auctions.length ? (181                <ul className="divide-y divide-border text-[12px]">182                  {auctions.map((a) => (183                    <li key={a.id} className="px-4 py-2">184                      <a href={a.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">185                        {a.name}186                      </a>187                      <div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">188                        <Badge tone={a.status === 'live' ? 'gain' : a.status === 'upcoming' ? 'index' : 'neutral'}>{a.status}</Badge>189                        <Link href={`/auctions/house/${houseSlug(a.auctionHouse)}`} className="hover:text-fg">190                          {a.auctionHouse}191                        </Link>192                        <span>· {a.endsAt ? `ends ${fmtDate(a.endsAt)} (${fmtRelative(a.endsAt)})` : a.startsAt ? `starts ${fmtDate(a.startsAt)}` : 'dates n/a'}</span>193                        <span>· {a.lotsTracked} lots{a.lotCount ? ` / ${a.lotCount}` : ''}</span>194                        {a.categorySlugs.slice(0, 2).map((c) => (195                          <span key={c}>· {catName(c)}</span>196                        ))}197                      </div>198                    </li>199                  ))}200                </ul>201              ) : (202                <EmptyState title="No auctions tracked" className="py-8" />203              )}204            </Card>205          </div>206        </>207      )}208    </div>209  );210}211