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%

Auction intelligence (web): /auctions rebuilt (open / ending soon / below RIV / results / houses, filters, all-in estimates, bid vs RIV), auction-house pages with buyer-premium schedules and stats, asset Auctions tab, all-in price + fee-basis pills in sales tables, methodology section 16

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent 1b5f855

16 changed files +972 −82

modified apps/web/src/app/asset/[slug]/page.tsx +5 −2
@@ -4,7 +4,8 @@ import { Suspense } from 'react';
4 4 import { Tabs } from '@/components/ui/tabs';
5 5 import { Skeleton } from '@/components/ui/primitives';
6 6 import { AssetHeader } from '@/components/asset/asset-header';
7 −import { ASSET_TABS, AnalysisTab, ComparablesTab, GradesTab, HistoryTab, ImagesTab, ListingsTab, OverviewTab, PopulationTab, SalesTab, SourcesTab, type AssetTab } from '@/components/asset/asset-tabs';
7 +import { ASSET_TABS, AnalysisTab, AuctionsTab, ComparablesTab, GradesTab, HistoryTab, ImagesTab, ListingsTab, OverviewTab, PopulationTab, SalesTab, SourcesTab, type AssetTab } from '@/components/asset/asset-tabs';
8 +import { countAssetLots } from '@/lib/queries/market-lists';
8 9 import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, getPopulation, isWatchedBy } from '@/lib/queries/assets';
9 10 import { getCurrentUser } from '@/lib/auth/session';
10 11 import { catName, categoryPath } from '@/lib/taxonomy';
@@ -37,7 +38,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
37 38 const found = await getAssetBySlug(slug);
38 39 if (!found) notFound();
39 40 const tab = spEnum<AssetTab>(sp, 'tab', ASSET_TABS, 'overview');
40 − const [variants, live, imgs, user, popReports] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id), getAssetImages(found.id, 12), getCurrentUser().catch(() => null), getPopulation(found.id)]);
41 + const [variants, live, imgs, user, popReports, lotCount] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id), getAssetImages(found.id, 12), getCurrentUser().catch(() => null), getPopulation(found.id), countAssetLots(found.id)]);
41 42 // latest published population total across graders (for the rarity explainer); null when no report exists
42 43 const population = popReports.length ? popReports.reduce((a, r) => (r.reportDate > a.reportDate ? r : a)).total : null;
43 44 const watched = await isWatchedBy(user?.id ?? null, found.id);
@@ -107,6 +108,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
107 108 { id: 'overview', label: 'Overview', href: href('overview') },
108 109 { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') },
109 110 { id: 'listings', label: 'Listings', count: variant ? variant.activeListings : asset.activeListings, href: href('listings') },
111 + { id: 'auctions', label: 'Auctions', count: lotCount || null, href: href('auctions') },
110 112 { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') },
111 113 { id: 'population', label: 'Population', href: href('population') },
112 114 { id: 'images', label: 'Images', count: images.length || null, href: href('images') },
@@ -121,6 +123,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
121 123 {tab === 'overview' ? <OverviewTab asset={asset} variant={variant} /> : null}
122 124 {tab === 'sales' ? <SalesTab asset={asset} variant={variant} page={page} /> : null}
123 125 {tab === 'listings' ? <ListingsTab asset={asset} variant={variant} /> : null}
126 + {tab === 'auctions' ? <AuctionsTab asset={asset} /> : null}
124 127 {tab === 'grades' ? <GradesTab asset={asset} /> : null}
125 128 {tab === 'population' ? <PopulationTab asset={asset} /> : null}
126 129 {tab === 'images' ? <ImagesTab asset={asset} /> : null}
added apps/web/src/app/auctions/house/[slug]/page.tsx +101 −0
@@ -0,0 +1,101 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { feeScheduleFor } from '@rareindex/valuation';
5 +import { PageHeader } from '@/components/ui/page-header';
6 +import { Card, CardHeader, EmptyState, StatStrip } from '@/components/ui/primitives';
7 +import { LotsIntelCards, LotsIntelTable } from '@/components/market/lots-table';
8 +import { SalesCards, SalesTable } from '@/components/market/sales-table';
9 +import { FeeScheduleTable, FeeConfidenceBadge } from '@/components/market/fee-schedule';
10 +import { getAuctionHouseStats, listAuctionHouses, listHouseResults, listLots } from '@/lib/queries/market-lists';
11 +import { resolveHouse } from '@/lib/auction-house';
12 +import { fmtMoney, fmtNum, fmtPct, fmtRelative } from '@/lib/format';
13 +
14 +export const revalidate = 300;
15 +
16 +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
17 + const { slug } = await params;
18 + const h = resolveHouse(slug, await listAuctionHouses());
19 + return h ? { title: `${h.house} — auctions, results & buyer's premium`, description: `${h.house} on RareIndex: open lots with all-in cost vs RIV, recorded results and the buyer's premium schedule used for all-in estimates.` } : { title: 'Auction house' };
20 +}
21 +
22 +export default async function AuctionHousePage({ params }: { params: Promise<{ slug: string }> }) {
23 + const { slug } = await params;
24 + const houses = await listAuctionHouses();
25 + const h = resolveHouse(slug, houses);
26 + if (!h) notFound();
27 + const [stats, lots, results] = await Promise.all([getAuctionHouseStats(h.house), listLots({ house: h.house, open: true, limit: 60, sort: 'ending' }), listHouseResults(h.house, 50)]);
28 + const schedule = feeScheduleFor(h.house);
29 + return (
30 + <div>
31 + <PageHeader
32 + kicker="Auction house"
33 + crumbs={[{ label: 'Auctions', href: '/auctions' }, { label: h.house }]}
34 + title={h.house}
35 + description="Objective metrics only (§129): lots tracked, results recorded, median and record all-in sale, and the buyer's premium schedule RareIndex uses to turn hammer prices into buyer-pays costs. Sell-through is shown only when the house's results are recorded per lot."
36 + compact
37 + meta={
38 + <>
39 + <Link href={`/auctions?house=${encodeURIComponent(h.house)}`} className="hover:text-fg">
40 + Open lots →
41 + </Link>
42 + <Link href={`/auctions/calendar?house=${encodeURIComponent(h.house)}`} className="hover:text-fg">
43 + Calendar →
44 + </Link>
45 + </>
46 + }
47 + />
48 + <StatStrip
49 + className="mb-4"
50 + items={[
51 + { label: 'Open lots', value: fmtNum(stats?.openLots ?? h.upcoming), sub: stats ? `${fmtNum(stats.liveLots)} live · ${fmtNum(stats.lotsWithBids)} with bids` : undefined },
52 + { label: 'Below RIV', value: stats?.lotsAssessed ? fmtNum(stats.lotsBelowRiv) : '—', sub: stats?.lotsAssessed ? `of ${fmtNum(stats.lotsAssessed)} assessed` : 'not assessed yet' },
53 + { label: 'Results recorded', value: fmtNum(stats?.results ?? 0), sub: stats?.lastResultAt ? `last ${fmtRelative(stats.lastResultAt)}` : undefined },
54 + { label: 'Median all-in · 1Y', value: stats?.medianAllInUsd365d !== null && stats?.medianAllInUsd365d !== undefined ? fmtMoney(stats.medianAllInUsd365d) : '—', sub: stats?.results365d ? `${fmtNum(stats.results365d)} results` : 'no result in 12 months' },
55 + { label: 'Record all-in · 1Y', value: stats?.maxAllInUsd365d !== null && stats?.maxAllInUsd365d !== undefined ? fmtMoney(stats.maxAllInUsd365d) : '—' },
56 + { label: 'Sell-through', value: stats?.sellThrough !== null && stats?.sellThrough !== undefined ? fmtPct(stats.sellThrough, 0, false) : '—', sub: stats?.sellThrough !== null && stats?.sellThrough !== undefined ? 'lots sold / lots ended' : 'not derivable from this source' },
57 + ]}
58 + />
59 + <div className="grid gap-4 xl:grid-cols-[2fr_1fr]">
60 + <div className="space-y-4">
61 + <Card className="overflow-hidden">
62 + <CardHeader title="Open lots" subtitle="All-in = hammer + buyer's premium (schedule below); bid vs RIV gated like listings." action={<Link href={`/auctions?house=${encodeURIComponent(h.house)}`} className="text-muted hover:text-fg">All →</Link>} />
63 + <div className="md:hidden">
64 + <LotsIntelCards items={lots} />
65 + {!lots.length ? <EmptyState title="No open lots" compact /> : null}
66 + </div>
67 + <div className="hidden md:block">
68 + <LotsIntelTable items={lots} showHouse={false} emptyTitle="No open lots" emptyDescription="This house has no live or upcoming lot tracked right now." />
69 + </div>
70 + </Card>
71 + <Card className="overflow-hidden">
72 + <CardHeader title="Recent results" subtitle={stats?.estimatedPremiumShare !== null && stats?.estimatedPremiumShare !== undefined ? `Recorded sales from this house. Premium estimated on ${fmtPct(stats.estimatedPremiumShare, 0, false)} of results with a fee basis; the rest report the premium as included or none.` : 'Recorded sales from this house; fee basis appears once the fees worker has run.'} />
73 + <div className="md:hidden">
74 + <SalesCards items={results} />
75 + </div>
76 + <div className="hidden md:block">
77 + <SalesTable items={results} />
78 + </div>
79 + {!results.length ? <EmptyState title="No results recorded" description="Results flow into Sales once a result connector runs for this house." compact /> : null}
80 + </Card>
81 + </div>
82 + <Card className="overflow-hidden">
83 + <CardHeader title="Buyer's premium" subtitle="Used to compute all-in costs (§35)" />
84 + <div className="p-4">
85 + {schedule ? (
86 + <FeeScheduleTable schedule={schedule} />
87 + ) : (
88 + <div className="space-y-2 text-[12px] text-muted">
89 + <FeeConfidenceBadge confidence="default" />
90 + <p>No schedule on file for this house. When a result is recorded as hammer-only, RareIndex adds a default 22 % premium and labels the all-in figure “premium added (default)”; when the connector does not state whether the premium is included, the price is shown as recorded with “fees unknown”.</p>
91 + <p className="text-subtle">
92 + Schedules live in <code className="mono-num">data/fees/auction-houses.json</code> — see the <Link href="/methodology#fees" className="underline-offset-2 hover:underline">methodology</Link>.
93 + </p>
94 + </div>
95 + )}
96 + </div>
97 + </Card>
98 + </div>
99 + </div>
100 + );
101 +}
modified apps/web/src/app/auctions/page.tsx +159 −58
@@ -1,30 +1,69 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 +import { Suspense } from 'react';
4 +import { FAMILIES } from '@rareindex/taxonomy';
3 5 import { PageHeader } from '@/components/ui/page-header';
4 −import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
6 +import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge, StatStrip } from '@/components/ui/primitives';
5 7 import { Tabs } from '@/components/ui/tabs';
6 −import { LotsTable } from '@/components/market/sales-table';
7 −import { listAuctionHouses, listAuctions, listLots } from '@/lib/queries/market-lists';
8 +import { FilterBar } from '@/components/ui/filter-bar';
9 +import { Pagination } from '@/components/ui/pagination';
10 +import { LotsIntelCards, LotsIntelTable } from '@/components/market/lots-table';
11 +import { getLotOverview, listAuctionHouses, listAuctions, listLotsPaged, type LotSort } from '@/lib/queries/market-lists';
12 +import { feeScheduleFor } from '@rareindex/valuation';
13 +import { feeScheduleSummary, FeeConfidenceBadge } from '@/components/market/fee-schedule';
14 +import { houseSlug, ENDING_WINDOWS, endingHours } from '@/lib/auction-house';
8 15 import { fmtDate, fmtNum, fmtRelative, cn } from '@/lib/format';
9 16 import { catName } from '@/lib/taxonomy';
10 −import { spEnum, type SP } from '@/lib/search-params';
17 +import { sp1, spEnum, spInt, type SP } from '@/lib/search-params';
11 18
12 −export const metadata: Metadata = { title: 'Auctions', description: 'Auction houses, live and upcoming lots, ending-soon lots and results across the collectibles market.' };
19 +export 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.' };
20 +
21 +const TABS = ['open', 'ending', 'below', 'results', 'houses'] as const;
13 22
14 23 export default async function AuctionsPage({ searchParams }: { searchParams: Promise<SP> }) {
15 24 const sp = await searchParams;
16 − const tab = spEnum(sp, 'tab', ['ending', 'live', 'upcoming', 'results', 'houses'] as const, 'ending');
17 − const [houses, ending, lots, auctions] = await Promise.all([
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);
33 +
34 + const [overview, houses, auctions, lots] = await Promise.all([
35 + getLotOverview(),
18 36 listAuctionHouses(),
19 − listLots({ endingWithinHours: 72, limit: 60 }),
20 − tab === 'results' ? listLots({ status: 'ended', limit: 60, sort: 'value' }) : tab === 'live' ? listLots({ status: 'live', limit: 60 }) : tab === 'upcoming' ? listLots({ status: 'upcoming', limit: 60 }) : Promise.resolve([]),
21 − listAuctions({ limit: 100 }),
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 + }),
22 51 ]);
52 +
53 + 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 + };
60 +
23 61 return (
24 62 <div>
25 − <PageHeader kicker="Auction houses"
63 + <PageHeader
64 + kicker="Auction intelligence"
26 65 title="Auctions"
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."
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."
28 67 compact
29 68 actions={
30 69 <Link href="/auctions/calendar" className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium hover:bg-inset">
@@ -32,77 +71,139 @@ export default async function AuctionsPage({ searchParams }: { searchParams: Pro
32 71 </Link>
33 72 }
34 73 />
74 + <StatStrip
75 + 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 + />
35 84 <Tabs
36 85 active={tab}
37 86 tabs={[
38 − { id: 'ending', label: 'Ending soon', count: ending.length, href: '/auctions?tab=ending' },
39 − { id: 'live', label: 'Live', href: '/auctions?tab=live' },
40 − { id: 'upcoming', label: 'Upcoming', href: '/auctions?tab=upcoming' },
41 − { id: 'results', label: 'Results', href: '/auctions?tab=results' },
42 − { id: 'houses', label: 'Auction houses', count: houses.length, href: '/auctions?tab=houses' },
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' }) },
43 92 ]}
44 93 className="mb-4"
45 94 />
95 +
46 96 {tab === 'houses' ? (
47 97 <Card className="overflow-hidden">
48 − <CardHeader title="Auction houses" subtitle="Sources with auction connectors" />
98 + <CardHeader title="Auction houses" subtitle="Sources with auction connectors · buyer's premium schedule on file when known" />
49 99 {houses.length ? (
50 100 <Table>
51 101 <thead>
52 102 <tr>
53 103 <th className={th}>House</th>
104 + <th className={th}>Buyer&apos;s premium</th>
54 105 <th className={cn(th, 'text-right')}>Auctions</th>
55 106 <th className={cn(th, 'text-right')}>Lots tracked</th>
56 107 <th className={cn(th, 'text-right')}>Open</th>
57 108 </tr>
58 109 </thead>
59 110 <tbody>
60 − {houses.map((h) => (
61 − <tr key={h.house}>
62 − <td className={cn(td, 'font-medium')}>{h.house}</td>
63 − <td className={tdNum}>{fmtNum(h.auctions)}</td>
64 − <td className={tdNum}>{fmtNum(h.lots)}</td>
65 − <td className={tdNum}>{fmtNum(h.upcoming)}</td>
66 − </tr>
67 − ))}
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 + })}
68 136 </tbody>
69 137 </Table>
70 138 ) : (
71 − <EmptyState title="No auction houses connected yet" description="Auction-house connectors (results and catalogues) are part of the Phase 1 connector roadmap." />
139 + <EmptyState title="No auction houses connected yet" description="Auction-house connectors (results and catalogues) are part of the connector roadmap." />
72 140 )}
73 141 </Card>
74 142 ) : (
75 − <div className="grid gap-4 xl:grid-cols-[2fr_1fr]">
76 − <Card className="overflow-hidden">
77 − <CardHeader title={tab === 'ending' ? 'Lots ending in the next 72 hours' : tab === 'results' ? 'Recent results by value' : `${tab[0]!.toUpperCase()}${tab.slice(1)} lots`} />
78 − <LotsTable items={tab === 'ending' ? ending : lots} />
79 − </Card>
80 − <Card className="overflow-hidden">
81 − <CardHeader title="Auctions" subtitle="Catalogues tracked" />
82 − {auctions.length ? (
83 − <ul className="divide-y divide-border text-[12px]">
84 − {auctions.slice(0, 40).map((a) => (
85 − <li key={a.id} className="px-4 py-2">
86 − <a href={a.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">
87 − {a.name}
88 − </a>
89 − <div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
90 − <Badge tone={a.status === 'live' ? 'gain' : a.status === 'upcoming' ? 'index' : 'neutral'}>{a.status}</Badge>
91 − <span>{a.auctionHouse}</span>
92 − <span>· {a.endsAt ? `ends ${fmtDate(a.endsAt)} (${fmtRelative(a.endsAt)})` : a.startsAt ? `starts ${fmtDate(a.startsAt)}` : 'dates n/a'}</span>
93 − <span>· {a.lotsTracked} lots{a.lotCount ? ` / ${a.lotCount}` : ''}</span>
94 − {a.categorySlugs.slice(0, 2).map((c) => (
95 − <span key={c}>· {catName(c)}</span>
96 − ))}
97 − </div>
98 − </li>
99 − ))}
100 − </ul>
101 − ) : (
102 − <EmptyState title="No auctions tracked" className="py-8" />
103 − )}
104 − </Card>
105 − </div>
143 + <>
144 + <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
145 + <Suspense>
146 + <FilterBar
147 + 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 + <CardHeader
166 + 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 + </>
106 207 )}
107 208 </div>
108 209 );
modified apps/web/src/app/markets/[slug]/page.tsx +3 −2
@@ -9,7 +9,8 @@ import { AssetBrowser } from '@/components/market/asset-browser';
9 9 import { Thumb } from '@/components/market/bits';
10 10 import type { SP } from '@/lib/search-params';
11 11 import { AssetTable } from '@/components/market/asset-list';
12 −import { SalesTable, ListingsTable, LotsTable } from '@/components/market/sales-table';
12 +import { SalesTable, ListingsTable } from '@/components/market/sales-table';
13 +import { LotsIntelTable } from '@/components/market/lots-table';
13 14 import { LineChart } from '@/components/charts/line-chart';
14 15 import { BarChart } from '@/components/charts/bar-chart';
15 16 import { getBrandsInCategory, getCategoryRow, getCategorySeries, getMarketRows, getPopulationTrend, getSetsInCategory } from '@/lib/queries/markets';
@@ -208,7 +209,7 @@ async function Activity({ slug }: { slug: string }) {
208 209 </Card>
209 210 <Card>
210 211 <CardHeader title="Biggest auctions" subtitle="Lots by hammer, bid or high estimate" action={<Link href="/auctions" className="text-muted hover:text-fg">Auctions →</Link>} />
211 − <LotsTable items={lots} />
212 + <LotsIntelTable items={lots} />
212 213 </Card>
213 214 <Card>
214 215 <CardHeader title="Population trends" subtitle="Total graded population reported per grader" />
modified apps/web/src/app/methodology/page.tsx +46 −1
@@ -1,7 +1,8 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import type { ReactNode } from 'react';
4 −import { ASK_ANOMALY_HIGH_RATIO, ASK_ANOMALY_LOW_RATIO, ASK_MIN_CONFIDENCE, ASK_MIN_MATCH_CONFIDENCE, ASK_MIN_SAMPLE, DEAL_REVIEW_THRESHOLD, DEAL_THRESHOLD, MAX_PLAUSIBLE_CHANGE, PREMIUM_THRESHOLD } from '@rareindex/valuation';
4 +import { ASK_ANOMALY_HIGH_RATIO, ASK_ANOMALY_LOW_RATIO, ASK_MIN_CONFIDENCE, ASK_MIN_MATCH_CONFIDENCE, ASK_MIN_SAMPLE, DEAL_REVIEW_THRESHOLD, DEAL_THRESHOLD, FEE_SCHEDULES, FEE_SCHEDULE_AS_OF, MAX_PLAUSIBLE_CHANGE, PREMIUM_THRESHOLD } from '@rareindex/valuation';
5 +import { FeeConfidenceBadge, feeScheduleSummary } from '@/components/market/fee-schedule';
5 6 import { PageHeader } from '@/components/ui/page-header';
6 7 import { Badge, Card } from '@/components/ui/primitives';
7 8 import { cn } from '@/lib/format';
@@ -37,6 +38,7 @@ const SECTIONS: Array<{ id: string; title: string }> = [
37 38 { id: 'indices', title: 'Indices' },
38 39 { id: 'fx', title: 'Currencies' },
39 40 { id: 'freshness', title: 'Freshness & limits' },
41 + { id: 'fees', title: 'Auction fees & all-in cost' },
40 42 ];
41 43
42 44 export default function MethodologyPage() {
@@ -299,6 +301,49 @@ export default function MethodologyPage() {
299 301 </Link>
300 302 </P>
301 303 </Section>
304 +
305 + <Section id="fees" n={16} title="Auction fees & all-in cost" lead="A hammer price is not what a buyer pays. RareIndex never compares a hammer with a marketplace price without adding the buyer's premium — and labels how it did so.">
306 + <Formula>all-in = hammer + buyer&apos;s premium(hammer, house schedule) &nbsp;·&nbsp; VAT/sales tax on the premium, duties and shipping excluded</Formula>
307 + <H>How the basis is chosen</H>
308 + <Table
309 + head={['Record', 'Treatment', 'Label']}
310 + rows={[
311 + ['Connector states the premium is included', 'price kept as recorded', 'premium included'],
312 + ['Connector states hammer only', 'premium added from the house schedule (marginal tiers, minimum/maximum, fixed fee), default 22 % when the house is not on file', 'premium added (published / ≈ estimated / default)'],
313 + ['Connector does not say, house on file', 'premium added from the schedule — auction houses invoice a premium by default', 'premium added (published / ≈ estimated)'],
314 + ['Connector does not say, house unknown', 'price kept as recorded and flagged', 'fees unknown'],
315 + ['Fixed-price marketplace or dealer', 'no buyer premium; the asking price is what the buyer pays', 'no buyer premium'],
316 + ]}
317 + />
318 + <P>
319 + Live lots are assessed on the same basis: the current bid (or, without a bid, the low estimate — labelled “est.”) is converted to USD at the current ECB rate, the premium is added, and the result is compared with the RIV of the lot&apos;s variant under the same gates as listings (transaction-based RIV, ≥ {ASK_MIN_SAMPLE} sales, confidence ≥ {pct(ASK_MIN_CONFIDENCE)}, plausibility band, review threshold). Opening prices without a bid are never called deals on their own.
320 + </P>
321 + <H>Schedules on file (as of {FEE_SCHEDULE_AS_OF})</H>
322 + <P className="text-subtle">“Published” = read from the house&apos;s terms at that date. “≈ Approximate” = widely reported schedule not re-verified — every all-in figure built on it is shown with ≈ and the pill “premium added (≈ estimated)”. Houses not listed fall back to a labelled default of 22 %.</P>
323 + <div className="overflow-x-auto">
324 + <table className="w-full text-[12px]">
325 + <thead>
326 + <tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-subtle">
327 + <th className="py-1.5 pr-3">House</th>
328 + <th className="py-1.5 pr-3">Buyer&apos;s premium</th>
329 + <th className="py-1.5 pr-3">Currency</th>
330 + <th className="py-1.5 pr-3">Confidence</th>
331 + </tr>
332 + </thead>
333 + <tbody>
334 + {[...FEE_SCHEDULES].sort((a, b) => a.name.localeCompare(b.name)).map((s) => (
335 + <tr key={s.id} className="border-b border-border/60">
336 + <td className="py-1.5 pr-3 font-medium">{s.source ? <a href={s.source} target="_blank" rel="noopener nofollow" className="underline-offset-2 hover:underline">{s.name}</a> : s.name}</td>
337 + <td className="num py-1.5 pr-3">{feeScheduleSummary(s)}</td>
338 + <td className="py-1.5 pr-3 text-muted">{s.currency}</td>
339 + <td className="py-1.5 pr-3"><FeeConfidenceBadge confidence={s.confidence} /></td>
340 + </tr>
341 + ))}
342 + </tbody>
343 + </table>
344 + </div>
345 + <Ref>data/fees/auction-houses.json · packages/valuation/src/fees.ts (buyerPremium, allInPrice) · workers/auctions</Ref>
346 + </Section>
302 347 </div>
303 348 </div>
304 349 </div>
modified apps/web/src/components/asset/asset-tabs.tsx +27 −1
@@ -18,8 +18,10 @@ import { Evidence } from '@/components/ui/evidence';
18 18 import { getAssetDepth, getAssetLiquidation } from '@/lib/queries/depth';
19 19 import { DepthLiquidationCard } from './depth-card';
20 20 import { countVerification } from '@rareindex/valuation';
21 +import { listAssetLots } from '@/lib/queries/market-lists';
22 +import { LotsIntelCards, LotsIntelTable } from '@/components/market/lots-table';
21 23
22 −export const ASSET_TABS = ['overview', 'sales', 'listings', 'grades', 'population', 'images', 'history', 'comparables', 'analysis', 'sources'] as const;
24 +export const ASSET_TABS = ['overview', 'sales', 'listings', 'auctions', 'grades', 'population', 'images', 'history', 'comparables', 'analysis', 'sources'] as const;
23 25 export type AssetTab = (typeof ASSET_TABS)[number];
24 26
25 27 export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {
@@ -36,6 +38,7 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
36 38 getSimilarAssets(asset, 8).then(attachGuidePrices),
37 39 getValuationHistory(asset.id, variant?.id ?? null),
38 40 ]);
41 + const lots = await listAssetLots(asset.id, 6);
39 42 const [depth, liquidation] = await Promise.all([
40 43 getAssetDepth(asset.id, variant?.id ?? null),
41 44 getAssetLiquidation({ id: asset.id, categorySlug: asset.categorySlug, rivUsd: variant ? variant.rivUsd : asset.rivUsd, rivLowUsd: variant ? variant.rivLowUsd : asset.rivLowUsd, rivHighUsd: variant ? variant.rivHighUsd : asset.rivHighUsd }),
@@ -156,6 +159,13 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
156 159 </Card>
157 160 </div>
158 161 <DepthLiquidationCard depth={depth} liquidation={liquidation} riv={riv} variantLabel={variant?.label ?? null} />
162 + {lots.length ? (
163 + <Card className="overflow-hidden">
164 + <CardHeader title="Live auctions" subtitle="All-in = hammer + buyer's premium (house schedule); bid vs RIV gated like listings — model analytics, not advice" action={<Link href="?tab=auctions" className="text-muted hover:text-fg">All →</Link>} />
165 + <div className="md:hidden"><LotsIntelCards items={lots} /></div>
166 + <div className="hidden md:block"><LotsIntelTable items={lots} /></div>
167 + </Card>
168 + ) : null}
159 169 <div className="grid gap-4 lg:grid-cols-2">
160 170 <Card className="overflow-hidden">
161 171 <CardHeader title="Recent sales" action={<Link href={`?tab=sales${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All {sales.total} →</Link>} />
@@ -254,6 +264,22 @@ export async function ListingsTab({ asset, variant }: { asset: AssetDetail; vari
254 264 );
255 265 }
256 266
267 +export async function AuctionsTab({ asset }: { asset: AssetDetail }) {
268 + const lots = await listAssetLots(asset.id, 100);
269 + return (
270 + <Card className="overflow-hidden">
271 + <CardHeader title="Live and upcoming auction lots" subtitle="The house's estimate and current bid in native currency; the all-in column adds the buyer's premium from the house's published or estimated schedule (VAT/duties/shipping excluded). Bid vs RIV uses the same gates as listings. Model analytics, not advice." />
272 + <div className="md:hidden">
273 + <LotsIntelCards items={lots} />
274 + {!lots.length ? <EmptyState title="No open auction lot" description="Lots matched to this asset appear when auction-house connectors publish catalogues." /> : null}
275 + </div>
276 + <div className="hidden md:block">
277 + <LotsIntelTable items={lots} emptyTitle="No open auction lot" emptyDescription="Lots matched to this asset appear when auction-house connectors publish catalogues." />
278 + </div>
279 + </Card>
280 + );
281 +}
282 +
257 283 export async function GradesTab({ asset }: { asset: AssetDetail }) {
258 284 const rows = await getGradeDistribution(asset.id);
259 285 const graded = rows.filter((r) => r.grader !== 'raw');
modified apps/web/src/components/asset/sales-cards.tsx +10 −3
@@ -2,7 +2,7 @@ import type { SaleRow, ListingRow } from '@/lib/queries/assets';
2 2 import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format';
3 3 import { humanize } from '@/lib/taxonomy';
4 4 import { EmptyState, VsRiv } from '@/components/ui/primitives';
5 −import { GradeBadge, SourceLink, Thumb, VerificationBadge } from '@/components/market/bits';
5 +import { FeeBasisPill, GradeBadge, SourceLink, Thumb, VerificationBadge } from '@/components/market/bits';
6 6
7 7 /** Phone-first card lists for sales and listings (tables stay for md+). */
8 8 export function SalesCards({ items, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.' }: { items: SaleRow[]; className?: string; emptyDescription?: string }) {
@@ -14,12 +14,19 @@ export function SalesCards({ items, className, emptyDescription = 'Sales appear
14 14 <Thumb src={s.imageUrls[0]} alt="" size={44} rounded="rounded-md" />
15 15 <div className="min-w-0 flex-1">
16 16 <div className="flex items-baseline justify-between gap-2">
17 − <span className="num text-[15px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>
17 + <span className="num text-[15px] font-semibold text-fg">{s.allInUsd !== null && Math.abs(s.allInUsd - s.priceUsd) >= 0.5 ? `${s.feeBasis === 'added_approximate' || s.feeBasis === 'added_default' ? '≈ ' : ''}${fmtMoney(s.allInUsd)}` : fmtMoney(s.priceUsd)}</span>
18 18 <span className="text-[11px] text-muted" title={s.saleDate.toISOString()}>
19 19 {fmtDate(s.saleDate)}
20 20 </span>
21 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}
22 + {s.allInUsd !== null && Math.abs(s.allInUsd - s.priceUsd) >= 0.5 ? (
23 + <div className="num flex items-center gap-1.5 text-[10px] text-subtle">
24 + <span>{fmtMoney(s.priceUsd)} hammer{s.price !== null && s.currency && s.currency !== 'USD' ? ` · ${fmtMoney(s.price, s.currency)}` : ''}</span>
25 + <FeeBasisPill basis={s.feeBasis} rate={s.buyerPremiumRate} className="text-[9px]" />
26 + </div>
27 + ) : s.price !== null && s.currency && s.currency !== 'USD' ? (
28 + <div className="num text-[10px] text-subtle">{fmtMoney(s.price, s.currency)} native</div>
29 + ) : null}
23 30 <p className="mt-0.5 truncate text-[12px] text-muted" title={s.rawTitle}>
24 31 {s.rawTitle}
25 32 </p>
modified apps/web/src/components/home/sections.tsx +6 −3
@@ -5,7 +5,7 @@ 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, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
8 +import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge, VsRiv } from '@/components/ui/primitives';
9 9 import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';
10 10 import { AssetTile, Rail, RailHeader, type TileMetric } from '@/components/market/asset-tile';
11 11 import { heatStyle } from '@/components/charts/heat';
@@ -110,7 +110,7 @@ export function LotsRail({ items, hideWhenEmpty = false }: { items: LotRow[]; hi
110 110 if (hideWhenEmpty && !items.length) return null;
111 111 return (
112 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" />
113 + <RailHeader title="Auctions Ending Soon" subtitle="Next 72 hours · all-in adds the buyer’s premium; bid vs RIV where assessed" href="/auctions?tab=ending" hrefLabel="All auctions" />
114 114 {items.length ? (
115 115 <Rail cols="lg:grid-cols-3 xl:grid-cols-4">
116 116 {items.slice(0, 8).map((l) => (
@@ -121,7 +121,10 @@ export function LotsRail({ items, hideWhenEmpty = false }: { items: LotRow[]; hi
121 121 <span className="block truncate text-[11px] text-muted">
122 122 {l.auctionHouse} · ends {fmtRelative(l.endsAt)}
123 123 </span>
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>
124 + <span className="num mt-1 flex items-baseline justify-between gap-2 text-[13px] font-semibold text-fg">
125 + <span>{l.allInBidUsd !== null || l.allInEstimateLowUsd !== null ? `${l.feeBasis === 'added_approximate' || l.feeBasis === 'added_default' ? '≈ ' : ''}${fmtMoney(l.allInBidUsd ?? l.allInEstimateLowUsd)}` : l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span>
126 + {l.bidVsRiv !== null || l.estimateVsRiv !== null ? <VsRiv discount={l.bidVsRiv ?? l.estimateVsRiv} className="text-[11px]" showLabel={false} /> : <span className="text-[10px] font-normal text-subtle">{l.allInBidUsd !== null || l.allInEstimateLowUsd !== null ? 'all-in' : ''}</span>}
127 + </span>
125 128 </span>
126 129 </a>
127 130 ))}
modified apps/web/src/components/market/bits.tsx +28 −1
@@ -1,6 +1,7 @@
1 1 import Link from 'next/link';
2 2 import { ExternalLink } from 'lucide-react';
3 −import { cn, fmtMoney } from '@/lib/format';
3 +import { cn, fmtMoney, fmtPct } from '@/lib/format';
4 +import { feeBasisLabel } from '@rareindex/valuation';
4 5 import { catName, gradeLabel } from '@/lib/taxonomy';
5 6 import { Badge } from '@/components/ui/primitives';
6 7
@@ -76,3 +77,29 @@ export function VerificationBadge({ label, reason, className }: { label: 'verifi
76 77 </Badge>
77 78 );
78 79 }
80 +
81 +/** Fee basis of an all-in figure (§35): premium included / added (published, ≈ estimated, default) / none / unknown. */
82 +export function FeeBasisPill({ basis, rate, className }: { basis: string | null | undefined; rate?: number | null; className?: string }) {
83 + if (!basis) return null;
84 + const approx = basis === 'added_approximate' || basis === 'added_default';
85 + const text =
86 + basis === 'included' ? 'premium included' : basis === 'none' ? 'no buyer premium' : basis === 'unknown' ? 'fees unknown' : `+ premium ${approx ? '≈ ' : ''}${rate !== null && rate !== undefined ? fmtPct(rate, rate < 0.1 ? 1 : 0, false) : ''}`.trim();
87 + const tone = basis === 'unknown' ? 'alert' : basis === 'included' || basis === 'none' ? 'neutral' : 'index';
88 + return (
89 + <Badge tone={tone} className={className}>
90 + <span title={`${feeBasisLabel(basis)}. VAT/sales tax on the premium, duties and shipping are not included.`}>{text}</span>
91 + </Badge>
92 + );
93 +}
94 +
95 +/** Buyer-pays amount in USD with the fee basis under it; falls back to the recorded price when no all-in figure exists. */
96 +export function AllInCell({ allInUsd, priceUsd, basis, rate, className }: { allInUsd: number | null | undefined; priceUsd: number | null | undefined; basis?: string | null; rate?: number | null; className?: string }) {
97 + const v = allInUsd ?? priceUsd;
98 + const differs = allInUsd !== null && allInUsd !== undefined && priceUsd !== null && priceUsd !== undefined && Math.abs(allInUsd - priceUsd) >= 0.5;
99 + return (
100 + <span className={cn('num inline-flex flex-col items-end leading-tight', className)}>
101 + <span className="font-medium text-fg">{basis === 'added_approximate' || basis === 'added_default' ? '≈ ' : ''}{fmtMoney(v)}</span>
102 + {differs || (basis && basis !== 'none') ? <FeeBasisPill basis={basis} rate={rate} className="mt-0.5 text-[9px]" /> : null}
103 + </span>
104 + );
105 +}
added apps/web/src/components/market/fee-schedule.tsx +83 −0
@@ -0,0 +1,83 @@
1 +import { FEE_SCHEDULE_AS_OF, type FeeSchedule } from '@rareindex/valuation';
2 +import { fmtMoney, fmtPct, cn } from '@/lib/format';
3 +import { Badge, Table, th, td, tdNum } from '@/components/ui/primitives';
4 +
5 +export function FeeConfidenceBadge({ confidence, className }: { confidence: FeeSchedule['confidence'] | 'unknown'; className?: string }) {
6 + const map: Record<string, { tone: 'gain' | 'index' | 'neutral' | 'alert'; label: string; title: string }> = {
7 + published: { tone: 'gain', label: 'Published', title: `Taken from the house's published terms as of ${FEE_SCHEDULE_AS_OF}` },
8 + approximate: { tone: 'index', label: '≈ Approximate', title: 'Widely reported schedule not re-verified against the current terms — every all-in figure built on it is labelled as an estimate' },
9 + none: { tone: 'neutral', label: 'No buyer premium', title: 'The house bills the hammer price; the seller pays the commission' },
10 + default: { tone: 'alert', label: 'Default 22 %', title: 'House not on file — a typical 22 % premium is applied and labelled' },
11 + unknown: { tone: 'alert', label: 'Unknown', title: 'No schedule on file' },
12 + };
13 + const m = map[confidence] ?? map.unknown!;
14 + return (
15 + <Badge tone={m.tone} className={className}>
16 + <span title={m.title}>{m.label}</span>
17 + </Badge>
18 + );
19 +}
20 +
21 +/** Buyer's premium schedule of one house (§35): marginal tiers, minimum/maximum, fixed fee, confidence and source. */
22 +export function FeeScheduleTable({ schedule, className }: { schedule: FeeSchedule; className?: string }) {
23 + const cur = schedule.currency;
24 + const tiers = schedule.tiers.map((t, i) => ({ from: i === 0 ? 0 : (schedule.tiers[i - 1]!.upTo ?? 0), to: t.upTo, rate: t.rate }));
25 + return (
26 + <div className={cn('space-y-2', className)}>
27 + <Table>
28 + <thead>
29 + <tr>
30 + <th className={th}>Hammer price ({cur})</th>
31 + <th className={cn(th, 'text-right')}>Buyer&apos;s premium</th>
32 + </tr>
33 + </thead>
34 + <tbody>
35 + {tiers.map((t, i) => (
36 + <tr key={i}>
37 + <td className={td}>{t.to === null ? `above ${fmtMoney(t.from, cur)}` : t.from === 0 ? `up to ${fmtMoney(t.to, cur)}` : `${fmtMoney(t.from, cur)} – ${fmtMoney(t.to, cur)}`}</td>
38 + <td className={tdNum}>{fmtPct(t.rate, t.rate * 100 % 1 ? 1 : 0, false)}</td>
39 + </tr>
40 + ))}
41 + {schedule.minimum !== undefined ? (
42 + <tr>
43 + <td className={td}>Minimum premium</td>
44 + <td className={tdNum}>{fmtMoney(schedule.minimum, cur)}</td>
45 + </tr>
46 + ) : null}
47 + {schedule.maximum !== undefined ? (
48 + <tr>
49 + <td className={td}>Maximum premium</td>
50 + <td className={tdNum}>{fmtMoney(schedule.maximum, cur)}</td>
51 + </tr>
52 + ) : null}
53 + {schedule.fixedFee !== undefined ? (
54 + <tr>
55 + <td className={td}>Fixed fee per lot</td>
56 + <td className={tdNum}>{fmtMoney(schedule.fixedFee, cur)}</td>
57 + </tr>
58 + ) : null}
59 + </tbody>
60 + </Table>
61 + <p className="text-[11px] text-muted">
62 + <FeeConfidenceBadge confidence={schedule.confidence} className="mr-1.5" />
63 + Schedule as of {FEE_SCHEDULE_AS_OF}.{schedule.note ? ` ${schedule.note}` : ''} VAT or sales tax on the premium, import duties and shipping are not included.
64 + {schedule.source ? (
65 + <>
66 + {' '}
67 + <a href={schedule.source} target="_blank" rel="noopener nofollow" className="underline-offset-2 hover:underline">
68 + Source ↗
69 + </a>
70 + </>
71 + ) : null}
72 + </p>
73 + </div>
74 + );
75 +}
76 +
77 +/** One-line summary of a schedule for lists: "27 % → 15 % (3 tiers)" or "20 %". */
78 +export function feeScheduleSummary(s: FeeSchedule): string {
79 + const rates = s.tiers.map((t) => fmtPct(t.rate, t.rate * 100 % 1 ? 1 : 0, false));
80 + const base = rates.length === 1 ? rates[0]! : `${rates[0]} → ${rates.at(-1)} (${rates.length} tiers)`;
81 + const extras = [s.minimum !== undefined ? `min ${fmtMoney(s.minimum, s.currency)}` : null, s.maximum !== undefined ? `cap ${fmtMoney(s.maximum, s.currency)}` : null, s.fixedFee !== undefined ? `+ ${fmtMoney(s.fixedFee, s.currency)}/lot` : null].filter(Boolean);
82 + return extras.length ? `${base} · ${extras.join(' · ')}` : base;
83 +}
added apps/web/src/components/market/lots-table.tsx +202 −0
@@ -0,0 +1,202 @@
1 +import Link from 'next/link';
2 +import type { LotRow } from '@/lib/queries/market-lists';
3 +import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format';
4 +import { Table, th, td, tdNum, EmptyState, Badge, VsRiv } from '@/components/ui/primitives';
5 +import { FeeBasisPill, GradeBadge, Thumb } from './bits';
6 +
7 +/**
8 + * Auction intelligence table (§33–§35): the house's own estimate and bid in native currency, the
9 + * buyer-pays all-in cost in USD (hammer + buyer's premium from the house schedule) and the gated
10 + * comparison with the RareIndex Valuation. Lots the worker has not assessed yet say so.
11 + */
12 +export function LotsIntelTable({ items, className, showHouse = true, emptyTitle = 'No auction lots', emptyDescription = 'Lots appear once auction-house connectors publish catalogues.' }: { items: LotRow[]; className?: string; showHouse?: boolean; emptyTitle?: string; emptyDescription?: string }) {
13 + if (!items.length) return <EmptyState title={emptyTitle} description={emptyDescription} />;
14 + return (
15 + <Table className={className}>
16 + <thead>
17 + <tr>
18 + <th className={th}>Lot</th>
19 + {showHouse ? <th className={th}>House · auction</th> : null}
20 + <th className={cn(th, 'text-right')}>Estimate</th>
21 + <th className={cn(th, 'text-right')}>Current bid</th>
22 + <th className={cn(th, 'text-right')} title="Buyer-pays cost in USD: hammer + buyer's premium from the house's published or estimated schedule. VAT/duties/shipping not included.">
23 + All-in est.
24 + </th>
25 + <th className={cn(th, 'text-right')}>RIV</th>
26 + <th className={cn(th, 'text-right')} title="(all-in bid − RIV) / RIV of the lot's variant. Falls back to the low estimate (“est.”) when there is no bid. Same gates as listings.">
27 + Bid vs RIV
28 + </th>
29 + <th className={th}>Ends</th>
30 + </tr>
31 + </thead>
32 + <tbody>
33 + {items.map((l) => (
34 + <LotIntelRow key={l.id} l={l} showHouse={showHouse} />
35 + ))}
36 + </tbody>
37 + </Table>
38 + );
39 +}
40 +
41 +function LotIntelRow({ l, showHouse }: { l: LotRow; showHouse: boolean }) {
42 + const cur = l.currency ?? 'USD';
43 + const hasBid = l.currentBid !== null && (l.bidCount ?? 1) > 0;
44 + const approx = l.feeBasis === 'added_approximate' || l.feeBasis === 'added_default';
45 + const allIn = l.hammerPriceUsd ?? l.allInBidUsd ?? l.allInEstimateLowUsd;
46 + const allInIsEstimate = l.hammerPriceUsd === null && l.allInBidUsd === null && l.allInEstimateLowUsd !== null;
47 + const vs = l.bidVsRiv ?? l.estimateVsRiv;
48 + const vsIsEstimate = l.bidVsRiv === null && l.estimateVsRiv !== null;
49 + return (
50 + <tr className="hover:bg-sunken">
51 + <td className={cn(td, 'max-w-[360px]')}>
52 + <span className="flex items-center gap-2">
53 + <Thumb src={l.imageUrls[0]} alt="" size={32} />
54 + <span className="min-w-0">
55 + <a href={l.url} target="_blank" rel="noopener nofollow" className="block truncate font-medium text-fg hover:underline" title={l.title}>
56 + {l.lotNumber ? `Lot ${l.lotNumber} · ` : ''}
57 + {l.title}
58 + </a>
59 + <span className="flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted">
60 + {l.assetSlug ? (
61 + <Link href={`/asset/${l.assetSlug}`} className="truncate hover:text-fg">
62 + {l.assetTitle}
63 + </Link>
64 + ) : (
65 + <span className="text-subtle">Not yet matched to a canonical asset</span>
66 + )}
67 + <GradeBadge grader={l.grader} grade={l.grade} />
68 + </span>
69 + </span>
70 + </span>
71 + </td>
72 + {showHouse ? (
73 + <td className={cn(td, 'text-muted')}>
74 + {l.auctionHouseSlug ? (
75 + <Link href={`/auctions/house/${l.auctionHouseSlug}`} className="hover:text-fg">
76 + {l.auctionHouse}
77 + </Link>
78 + ) : (
79 + l.auctionHouse
80 + )}
81 + {l.auctionName ? <span className="block max-w-[200px] truncate text-[10px] text-subtle">{l.auctionName}</span> : null}
82 + </td>
83 + ) : null}
84 + <td className={tdNum}>
85 + {l.estimateLow !== null || l.estimateHigh !== null ? (
86 + <span className="inline-flex flex-col items-end leading-tight">
87 + <span>
88 + {fmtMoney(l.estimateLow, cur)} – {fmtMoney(l.estimateHigh, cur)}
89 + </span>
90 + {cur !== 'USD' && (l.estimateLowUsd !== null || l.estimateHighUsd !== null) ? <span className="text-[10px] text-subtle">{fmtMoney(l.estimateLowUsd)} – {fmtMoney(l.estimateHighUsd)}</span> : null}
91 + </span>
92 + ) : (
93 + <span className="text-subtle">—</span>
94 + )}
95 + </td>
96 + <td className={tdNum}>
97 + {l.hammerPrice !== null ? (
98 + <span className="inline-flex flex-col items-end leading-tight">
99 + <span className="font-medium">{fmtMoney(l.hammerPrice, cur)}</span>
100 + <span className="text-[10px] text-subtle">hammer</span>
101 + </span>
102 + ) : hasBid ? (
103 + <span className="inline-flex flex-col items-end leading-tight">
104 + <span>{fmtMoney(l.currentBid, cur)}</span>
105 + <span className="text-[10px] text-subtle">{l.bidCount !== null ? `${fmtNum(l.bidCount)} bid${l.bidCount === 1 ? '' : 's'}` : 'bid'}</span>
106 + </span>
107 + ) : l.currentBid !== null ? (
108 + <span className="inline-flex flex-col items-end leading-tight text-muted" title="Opening price — no bid placed yet">
109 + <span>{fmtMoney(l.currentBid, cur)}</span>
110 + <span className="text-[10px] text-subtle">opening · 0 bids</span>
111 + </span>
112 + ) : (
113 + <span className="text-subtle">—</span>
114 + )}
115 + </td>
116 + <td className={tdNum}>
117 + {allIn !== null ? (
118 + <span className="inline-flex flex-col items-end leading-tight">
119 + <span className="font-medium text-fg">
120 + {approx ? '≈ ' : ''}
121 + {fmtMoney(allIn)}
122 + </span>
123 + <span className="flex items-center gap-1 text-[10px] text-subtle">
124 + {allInIsEstimate ? <span>on low est.</span> : null}
125 + <FeeBasisPill basis={l.feeBasis} rate={l.buyerPremiumRate} className="text-[9px]" />
126 + </span>
127 + </span>
128 + ) : l.assessedAt ? (
129 + <span className="text-subtle" title="No price to assess (no bid and no estimate)">—</span>
130 + ) : (
131 + <span className="text-[10px] text-subtle">Not assessed yet</span>
132 + )}
133 + </td>
134 + <td className={tdNum}>
135 + {l.rivUsd !== null ? (
136 + <span className="inline-flex flex-col items-end leading-tight">
137 + <span>{fmtMoney(l.rivUsd)}</span>
138 + <span className="text-[10px] text-subtle">
139 + {confidenceLabel(l.rivConfidence)} · n={l.rivSampleSize}
140 + </span>
141 + </span>
142 + ) : (
143 + <span className="text-subtle">—</span>
144 + )}
145 + </td>
146 + <td className={tdNum}>
147 + {vs !== null ? (
148 + <span className="inline-flex flex-col items-end leading-tight">
149 + <VsRiv discount={vs} showLabel={false} />
150 + {vsIsEstimate ? <span className="text-[10px] text-subtle">est. vs RIV</span> : null}
151 + </span>
152 + ) : l.assessedAt && l.assessmentVerdict === 'ungated' ? (
153 + <span className="text-subtle" title="Not compared: the valuation does not meet the gates (transaction-based RIV, ≥ 5 sales, medium+ confidence, same variant)">—</span>
154 + ) : l.assessedAt && l.assessmentVerdict === 'anomaly' ? (
155 + <Badge tone="alert">Data/identity anomaly</Badge>
156 + ) : l.assessedAt ? (
157 + <span className="text-subtle">—</span>
158 + ) : (
159 + <span className="text-[10px] text-subtle">Not assessed yet</span>
160 + )}
161 + </td>
162 + <td className={cn(td, 'whitespace-nowrap text-muted')}>
163 + {l.endsAt ? <span title={l.endsAt.toISOString()}>{fmtRelative(l.endsAt)}</span> : '—'}
164 + <Badge tone={l.status === 'live' ? 'gain' : l.status === 'upcoming' ? 'index' : 'neutral'} className="ml-1.5">
165 + {l.status}
166 + </Badge>
167 + </td>
168 + </tr>
169 + );
170 +}
171 +
172 +/** Card list of lots for phones (image, title, house, all-in, bid vs RIV, ends). */
173 +export function LotsIntelCards({ items, className }: { items: LotRow[]; className?: string }) {
174 + if (!items.length) return null;
175 + return (
176 + <ul className={cn('divide-y divide-border', className)}>
177 + {items.map((l) => {
178 + const allIn = l.hammerPriceUsd ?? l.allInBidUsd ?? l.allInEstimateLowUsd;
179 + const vs = l.bidVsRiv ?? l.estimateVsRiv;
180 + const approx = l.feeBasis === 'added_approximate' || l.feeBasis === 'added_default';
181 + return (
182 + <li key={l.id}>
183 + <a href={l.url} target="_blank" rel="noopener nofollow" className="flex min-h-[64px] items-center gap-3 px-3 py-2.5 hover:bg-sunken">
184 + <Thumb src={l.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" />
185 + <span className="min-w-0 flex-1">
186 + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.title}</span>
187 + <span className="mt-0.5 block truncate text-[11px] text-muted">
188 + {l.auctionHouse}
189 + {l.endsAt ? ` · ends ${fmtRelative(l.endsAt)}` : ''}
190 + </span>
191 + </span>
192 + <span className="num flex shrink-0 flex-col items-end leading-tight">
193 + <span className="text-[14px] font-semibold text-fg">{allIn !== null ? `${approx ? '≈ ' : ''}${fmtMoney(allIn)}` : l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span>
194 + {vs !== null ? <VsRiv discount={vs} className="text-[11px]" showLabel={false} /> : <span className="text-[10px] text-subtle">{allIn !== null ? 'all-in' : 'not assessed'}</span>}
195 + </span>
196 + </a>
197 + </li>
198 + );
199 + })}
200 + </ul>
201 + );
202 +}
modified apps/web/src/components/market/sales-table.tsx +16 −4
@@ -3,7 +3,7 @@ import type { LotRow } from '@/lib/queries/market-lists';
3 3 import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format';
4 4 import { humanize } from '@/lib/taxonomy';
5 5 import { Table, th, td, tdNum, EmptyState, Badge, VsRiv } from '@/components/ui/primitives';
6 −import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb, VerificationBadge } from './bits';
6 +import { AllInCell, AssetTitleCell, FeeBasisPill, GradeBadge, PriceCell, SourceLink, Thumb, VerificationBadge } from './bits';
7 7
8 8 /** Card list for sales on small screens (image, title, grade, price, source). */
9 9 export function SalesCards({ items, className }: { items: SaleRow[]; className?: string }) {
@@ -22,8 +22,8 @@ export function SalesCards({ items, className }: { items: SaleRow[]; className?:
22 22 </span>
23 23 </span>
24 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>}
25 + <span className="text-[14px] font-semibold text-fg">{s.allInUsd !== null && Math.abs(s.allInUsd - s.priceUsd) >= 0.5 ? `${s.feeBasis === 'added_approximate' || s.feeBasis === 'added_default' ? '≈ ' : ''}${fmtMoney(s.allInUsd)}` : fmtMoney(s.priceUsd)}</span>
26 + {s.allInUsd !== null && Math.abs(s.allInUsd - s.priceUsd) >= 0.5 ? <span className="text-[10px] text-subtle">{fmtMoney(s.priceUsd)} hammer</span> : 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 27 </span>
28 28 </a>
29 29 </li>
@@ -82,7 +82,19 @@ export function SalesTable({ items, showAsset = true, className, emptyDescriptio
82 82 {s.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(s.condition)}</span> : null}
83 83 </td>
84 84 <td className={tdNum}>
85 − <PriceCell usd={s.priceUsd} native={s.price} currency={s.currency} />
85 + {s.allInUsd !== null && Math.abs(s.allInUsd - s.priceUsd) >= 0.5 ? (
86 + <span className="inline-flex flex-col items-end leading-tight">
87 + <AllInCell allInUsd={s.allInUsd} priceUsd={s.priceUsd} basis={s.feeBasis} rate={s.buyerPremiumRate} />
88 + <span className="text-[10px] text-subtle" title="Recorded (hammer) price">
89 + {fmtMoney(s.priceUsd)} hammer{s.currency !== 'USD' ? ` · ${fmtMoney(s.price, s.currency)}` : ''}
90 + </span>
91 + </span>
92 + ) : (
93 + <span className="inline-flex flex-col items-end leading-tight">
94 + <PriceCell usd={s.priceUsd} native={s.price} currency={s.currency} />
95 + {s.feeBasis && s.feeBasis !== 'none' ? <FeeBasisPill basis={s.feeBasis} rate={s.buyerPremiumRate} className="mt-0.5 text-[9px]" /> : null}
96 + </span>
97 + )}
86 98 </td>
87 99 <td className={cn(td, 'text-muted')}>
88 100 {humanize(s.saleType)}
added apps/web/src/lib/auction-house.test.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { endingHours, houseSlug, resolveHouse } from './auction-house';
3 +
4 +describe('auction house slugs', () => {
5 + it('slugifies house names deterministically and drops apostrophes', () => {
6 + expect(houseSlug("Sotheby's")).toBe('sothebys');
7 + expect(houseSlug('Hake’s Auctions')).toBe('hakes-auctions');
8 + expect(houseSlug('Yahoo! Auctions Japan (ヤフオク!)')).toMatch(/^yahoo-auctions-japan/);
9 + expect(houseSlug(' ')).toBe('house');
10 + });
11 + it('resolves a slug back to the registry entry', () => {
12 + const houses = [{ house: "Sotheby's", lots: 1 }, { house: 'Bonhams', lots: 2 }];
13 + expect(resolveHouse('sothebys', houses)?.house).toBe("Sotheby's");
14 + expect(resolveHouse('BONHAMS', houses)?.lots).toBe(2);
15 + expect(resolveHouse('christies', houses)).toBeNull();
16 + });
17 + it('maps ending-window ids to hours', () => {
18 + expect(endingHours('24h')).toBe(24);
19 + expect(endingHours('7d')).toBe(168);
20 + expect(endingHours('nope')).toBeNull();
21 + });
22 +});
added apps/web/src/lib/auction-house.ts +24 −0
@@ -0,0 +1,24 @@
1 +import { slugify } from '@rareindex/shared';
2 +
3 +/** URL slug for an auction house name (`/auctions/house/<slug>`). Deterministic; no DB round-trip. */
4 +export function houseSlug(name: string): string {
5 + return slugify(name.replace(/[’']/g, '')) || 'house';
6 +}
7 +
8 +/** Resolve a URL slug back to the house name known by the registry; null when nothing matches. */
9 +export function resolveHouse<T extends { house: string }>(slug: string, houses: readonly T[]): T | null {
10 + const s = slug.trim().toLowerCase();
11 + return houses.find((h) => houseSlug(h.house) === s) ?? null;
12 +}
13 +
14 +/** Ending-within presets exposed on /auctions (hours). */
15 +export const ENDING_WINDOWS = [
16 + { id: '24h', label: '24 h', hours: 24 },
17 + { id: '72h', label: '72 h', hours: 72 },
18 + { id: '7d', label: '7 days', hours: 168 },
19 +] as const;
20 +export type EndingWindow = (typeof ENDING_WINDOWS)[number]['id'];
21 +
22 +export function endingHours(id: string | null | undefined): number | null {
23 + return ENDING_WINDOWS.find((w) => w.id === id)?.hours ?? null;
24 +}
modified apps/web/src/lib/queries/assets.ts +8 −1
@@ -366,6 +366,10 @@ export interface SaleRow {
366 366 flags: string[];
367 367 variantId: string | null;
368 368 buyerPremiumIncluded: boolean | null;
369 + /** buyer-pays USD (price + estimated buyer premium when hammer-only), fee basis and rate (§35); null until the fees worker has run */
370 + allInUsd: number | null;
371 + feeBasis: string | null;
372 + buyerPremiumRate: number | null;
369 373 /** source metadata used by the heuristic verification label (§39) */
370 374 sourceType: string | null;
371 375 sourceTrust: number | null;
@@ -401,6 +405,9 @@ export function toSaleRow(x: Record<string, unknown>): SaleRow {
401 405 flags: (x.flags as string[]) ?? [],
402 406 variantId: str(x.variant_id),
403 407 buyerPremiumIncluded: x.buyer_premium_included === null || x.buyer_premium_included === undefined ? null : Boolean(x.buyer_premium_included),
408 + allInUsd: num(x.all_in_usd),
409 + feeBasis: str(x.fee_basis),
410 + buyerPremiumRate: num(x.buyer_premium_rate),
404 411 sourceType: str(x.source_type),
405 412 sourceTrust: num(x.trust_score),
406 413 ...(() => {
@@ -414,7 +421,7 @@ export function toSaleRow(x: Record<string, unknown>): SaleRow {
414 421 };
415 422 }
416 423
417 −export const SALE_SELECT = sql`s.id, s.sale_date, s.price, s.currency, s.price_usd, s.sale_type, s.grader, s.grade, s.condition, s.certification_number, s.source_id, src.name AS source_name, s.source_url, s.auction_house, s.location, s.raw_title, s.image_urls, s.confidence, s.status, s.flags, s.variant_id, s.buyer_premium_included, src.source_type, src.trust_score`;
424 +export const SALE_SELECT = sql`s.id, s.sale_date, s.price, s.currency, s.price_usd, s.sale_type, s.grader, s.grade, s.condition, s.certification_number, s.source_id, src.name AS source_name, s.source_url, s.auction_house, s.location, s.raw_title, s.image_urls, s.confidence, s.status, s.flags, s.variant_id, s.buyer_premium_included, s.all_in_usd, s.fee_basis, s.buyer_premium_rate, src.source_type, src.trust_score`;
418 425
419 426 export async function getAssetSales(assetId: string, opts: { variantId?: string | null; limit?: number; offset?: number; includeFlagged?: boolean } = {}): Promise<{ items: SaleRow[]; total: number }> {
420 427 const limit = opts.limit ?? 50;
modified apps/web/src/lib/queries/market-lists.ts +232 −6
@@ -3,6 +3,7 @@ import { cache } from 'react';
3 3 import type { SQL } from 'drizzle-orm';
4 4 import { rows, one, sql, num, int, str, date, joinAnd, textArray, iso } from './_util';
5 5 import { categoryScope, SALE_SELECT, LISTING_SELECT, toSaleRow, toListingRow, type SaleRow, type ListingRow } from './assets';
6 +import { houseSlug } from '@/lib/auction-house';
6 7
7 8 // ---------- Sales ----------
8 9 export interface SalesFilters {
@@ -157,6 +158,7 @@ export interface LotRow {
157 158 auctionId: string;
158 159 auctionName: string | null;
159 160 auctionHouse: string | null;
161 + auctionHouseSlug: string | null;
160 162 assetSlug: string | null;
161 163 assetTitle: string | null;
162 164 categorySlug: string | null;
@@ -176,23 +178,247 @@ export interface LotRow {
176 178 grader: string | null;
177 179 grade: string | null;
178 180 rivUsd: number | null;
181 + rivConfidence: number | null;
182 + rivSampleSize: number;
183 + // ---- USD normalisation + assessment (§33–§35); null until the auctions worker has assessed the lot ----
184 + estimateLowUsd: number | null;
185 + estimateHighUsd: number | null;
186 + currentBidUsd: number | null;
187 + hammerPriceUsd: number | null;
188 + buyerPremiumRate: number | null;
189 + feeBasis: string | null;
190 + allInBidUsd: number | null;
191 + allInEstimateLowUsd: number | null;
192 + allInEstimateHighUsd: number | null;
193 + rivUsdAtAssessment: number | null;
194 + /** (all-in bid − RIV) / RIV — negative = below the valuation */
195 + bidVsRiv: number | null;
196 + /** (all-in low estimate − RIV) / RIV */
197 + estimateVsRiv: number | null;
198 + assessmentVerdict: string | null;
199 + assessedAt: Date | null;
179 200 }
180 201
181 −export async function listLots(opts: { status?: 'upcoming' | 'live' | 'ended' | null; endingWithinHours?: number | null; auctionId?: string | null; category?: string | null; limit?: number; sort?: 'ending' | 'value' } = {}): Promise<LotRow[]> {
202 +export type LotSort = 'ending' | 'value' | 'discount' | 'bids';
203 +
204 +export interface LotFilters {
205 + status?: 'upcoming' | 'live' | 'ended' | null;
206 + /** live or upcoming and not yet ended */
207 + open?: boolean;
208 + endingWithinHours?: number | null;
209 + auctionId?: string | null;
210 + category?: string | null;
211 + house?: string | null;
212 + assetId?: string | null;
213 + /** only lots whose assessed all-in bid (or estimate) is a gated deal */
214 + verdict?: 'deal' | null;
215 + /** keep lots with bid_vs_riv (or estimate_vs_riv) ≤ this value, e.g. −0.1 */
216 + maxVsRiv?: number | null;
217 + hasEstimate?: boolean;
218 + hasBid?: boolean;
219 + limit?: number;
220 + page?: number;
221 + pageSize?: number;
222 + sort?: LotSort;
223 +}
224 +
225 +const LOT_SELECT = sql`l.*, au.name AS auction_name, au.auction_house, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, st.riv_usd, st.riv_confidence, coalesce(st.riv_sample_size, 0) AS riv_sample_size`;
226 +
227 +function toLotRow(x: Record<string, unknown>): LotRow {
228 + const house = str(x.auction_house);
229 + return {
230 + id: String(x.id),
231 + auctionId: String(x.auction_id),
232 + auctionName: str(x.auction_name),
233 + auctionHouse: house,
234 + auctionHouseSlug: house ? houseSlug(house) : null,
235 + assetSlug: str(x.asset_slug),
236 + assetTitle: str(x.asset_title),
237 + categorySlug: str(x.category_slug),
238 + lotNumber: str(x.lot_number),
239 + title: String(x.title),
240 + url: String(x.url),
241 + estimateLow: num(x.estimate_low),
242 + estimateHigh: num(x.estimate_high),
243 + currentBid: num(x.current_bid),
244 + hammerPrice: num(x.hammer_price),
245 + currency: str(x.currency),
246 + bidCount: num(x.bid_count),
247 + startsAt: date(x.starts_at),
248 + endsAt: date(x.ends_at),
249 + status: String(x.status),
250 + imageUrls: (x.image_urls as string[]) ?? [],
251 + grader: str(x.grader),
252 + grade: str(x.grade),
253 + rivUsd: num(x.riv_usd),
254 + rivConfidence: num(x.riv_confidence),
255 + rivSampleSize: int(x.riv_sample_size),
256 + estimateLowUsd: num(x.estimate_low_usd),
257 + estimateHighUsd: num(x.estimate_high_usd),
258 + currentBidUsd: num(x.current_bid_usd),
259 + hammerPriceUsd: num(x.hammer_price_usd),
260 + buyerPremiumRate: num(x.buyer_premium_rate),
261 + feeBasis: str(x.fee_basis),
262 + allInBidUsd: num(x.all_in_bid_usd),
263 + allInEstimateLowUsd: num(x.all_in_estimate_low_usd),
264 + allInEstimateHighUsd: num(x.all_in_estimate_high_usd),
265 + rivUsdAtAssessment: num(x.riv_usd_at_assessment),
266 + bidVsRiv: num(x.bid_vs_riv),
267 + estimateVsRiv: num(x.estimate_vs_riv),
268 + assessmentVerdict: str(x.assessment_verdict),
269 + assessedAt: date(x.assessed_at),
270 + };
271 +}
272 +
273 +function lotWhere(opts: LotFilters): SQL[] {
182 274 const where: SQL[] = [];
183 275 if (opts.status) where.push(sql`l.status = ${opts.status}`);
276 + if (opts.open) where.push(sql`l.status IN ('live', 'upcoming') AND (l.ends_at IS NULL OR l.ends_at > now())`);
184 277 if (opts.endingWithinHours) where.push(sql`l.ends_at BETWEEN now() AND now() + (${opts.endingWithinHours}::int || ' hours')::interval`);
185 278 if (opts.auctionId) where.push(sql`l.auction_id = ${opts.auctionId}`);
279 + if (opts.assetId) where.push(sql`l.asset_id = ${opts.assetId}`);
186 280 if (opts.category) where.push(sql`a.category_slug IN ${categoryScope(opts.category)}`);
187 − const order = opts.sort === 'value' ? sql`coalesce(l.hammer_price, l.current_bid, l.estimate_high) DESC NULLS LAST` : sql`l.ends_at ASC NULLS LAST`;
281 + if (opts.house) where.push(sql`au.auction_house = ${opts.house}`);
282 + if (opts.verdict === 'deal') where.push(sql`l.assessment_verdict = 'deal'`);
283 + if (opts.maxVsRiv != null) where.push(sql`coalesce(l.bid_vs_riv, l.estimate_vs_riv) <= ${opts.maxVsRiv} AND l.assessment_verdict IN ('deal', 'fair', 'premium')`);
284 + if (opts.hasEstimate) where.push(sql`l.estimate_low IS NOT NULL`);
285 + if (opts.hasBid) where.push(sql`l.current_bid IS NOT NULL AND coalesce(l.bid_count, 1) > 0`);
286 + return where;
287 +}
288 +
289 +const LOT_ORDER: Record<LotSort, SQL> = {
290 + ending: sql`l.ends_at ASC NULLS LAST`,
291 + value: sql`coalesce(l.hammer_price_usd, l.all_in_bid_usd, l.all_in_estimate_high_usd, l.hammer_price, l.current_bid, l.estimate_high) DESC NULLS LAST`,
292 + // most below RIV first; unassessed lots last
293 + discount: sql`coalesce(l.bid_vs_riv, l.estimate_vs_riv) ASC NULLS LAST, l.ends_at ASC NULLS LAST`,
294 + bids: sql`l.bid_count DESC NULLS LAST, l.ends_at ASC NULLS LAST`,
295 +};
296 +
297 +const LOT_FROM = sql`FROM auction_lots l LEFT JOIN auctions au ON au.id = l.auction_id LEFT JOIN assets a ON a.id = l.asset_id LEFT JOIN asset_stats st ON st.asset_id = a.id`;
298 +
299 +export async function listLots(opts: LotFilters = {}): Promise<LotRow[]> {
300 + const r = await rows<Record<string, unknown>>(sql`
301 + SELECT ${LOT_SELECT} ${LOT_FROM}
302 + WHERE ${joinAnd(lotWhere(opts))} ORDER BY ${LOT_ORDER[opts.sort ?? 'ending']} LIMIT ${opts.limit ?? 50}
303 + `);
304 + return r.map(toLotRow);
305 +}
306 +
307 +/** Paginated lots for /auctions and house pages (count(*) OVER() total). */
308 +export async function listLotsPaged(opts: LotFilters = {}): Promise<{ items: LotRow[]; total: number; page: number; pageSize: number }> {
309 + const pageSize = Math.min(Math.max(opts.pageSize ?? 50, 1), 200);
310 + const page = Math.max(1, opts.page ?? 1);
188 311 const r = await rows<Record<string, unknown>>(sql`
189 − SELECT l.*, au.name AS auction_name, au.auction_house, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, st.riv_usd
190 − FROM auction_lots l LEFT JOIN auctions au ON au.id = l.auction_id LEFT JOIN assets a ON a.id = l.asset_id LEFT JOIN asset_stats st ON st.asset_id = a.id
191 − WHERE ${joinAnd(where)} ORDER BY ${order} LIMIT ${opts.limit ?? 50}
312 + SELECT ${LOT_SELECT}, count(*) OVER() AS total ${LOT_FROM}
313 + WHERE ${joinAnd(lotWhere(opts))} ORDER BY ${LOT_ORDER[opts.sort ?? 'ending']} LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
192 314 `);
193 − return r.map((x) => ({ id: String(x.id), auctionId: String(x.auction_id), auctionName: str(x.auction_name), auctionHouse: str(x.auction_house), assetSlug: str(x.asset_slug), assetTitle: str(x.asset_title), categorySlug: str(x.category_slug), lotNumber: str(x.lot_number), title: String(x.title), url: String(x.url), estimateLow: num(x.estimate_low), estimateHigh: num(x.estimate_high), currentBid: num(x.current_bid), hammerPrice: num(x.hammer_price), currency: str(x.currency), bidCount: num(x.bid_count), startsAt: date(x.starts_at), endsAt: date(x.ends_at), status: String(x.status), imageUrls: (x.image_urls as string[]) ?? [], grader: str(x.grader), grade: str(x.grade), rivUsd: num(x.riv_usd) }));
315 + return { items: r.map(toLotRow), total: r.length ? int(r[0]!.total) : 0, page, pageSize };
194 316 }
195 317
318 +/** Live/upcoming lots for one asset (all variants), soonest ending first. */
319 +export const listAssetLots = cache(async (assetId: string, limit = 50): Promise<LotRow[]> => listLots({ assetId, open: true, limit, sort: 'ending' }));
320 +
321 +export const countAssetLots = cache(async (assetId: string): Promise<number> => {
322 + const r = await one<{ n: number }>(sql`SELECT count(*)::int AS n FROM auction_lots l WHERE l.asset_id = ${assetId} AND l.status IN ('live', 'upcoming') AND (l.ends_at IS NULL OR l.ends_at > now())`);
323 + return r?.n ?? 0;
324 +});
325 +
326 +/** Headline numbers for the /auctions strip. */
327 +export const getLotOverview = cache(async (): Promise<{ open: number; ending24h: number; withBids: number; belowRiv: number; assessed: number; houses: number }> => {
328 + const r = await one<Record<string, unknown>>(sql`
329 + SELECT count(*) FILTER (WHERE l.status IN ('live','upcoming') AND (l.ends_at IS NULL OR l.ends_at > now())) AS open,
330 + count(*) FILTER (WHERE l.ends_at BETWEEN now() AND now() + interval '24 hours') AS ending24h,
331 + count(*) FILTER (WHERE l.status IN ('live','upcoming') AND (l.ends_at IS NULL OR l.ends_at > now()) AND l.current_bid IS NOT NULL AND coalesce(l.bid_count, 1) > 0) AS with_bids,
332 + count(*) FILTER (WHERE l.status IN ('live','upcoming') AND (l.ends_at IS NULL OR l.ends_at > now()) AND l.assessment_verdict = 'deal') AS below_riv,
333 + count(*) FILTER (WHERE l.status IN ('live','upcoming') AND (l.ends_at IS NULL OR l.ends_at > now()) AND l.assessed_at IS NOT NULL) AS assessed,
334 + count(DISTINCT au.auction_house) FILTER (WHERE l.status IN ('live','upcoming')) AS houses
335 + FROM auction_lots l LEFT JOIN auctions au ON au.id = l.auction_id
336 + `);
337 + return { open: int(r?.open), ending24h: int(r?.ending24h), withBids: int(r?.with_bids), belowRiv: int(r?.below_riv), assessed: int(r?.assessed), houses: int(r?.houses) };
338 +});
339 +
340 +export interface AuctionHouseStats {
341 + house: string;
342 + auctions: number;
343 + upcomingAuctions: number;
344 + lotsTracked: number;
345 + openLots: number;
346 + liveLots: number;
347 + lotsWithBids: number;
348 + lotsAssessed: number;
349 + lotsBelowRiv: number;
350 + /** results = sales carrying this auction house */
351 + results: number;
352 + results365d: number;
353 + medianAllInUsd365d: number | null;
354 + maxAllInUsd365d: number | null;
355 + /** share of results whose premium had to be estimated (fee_basis added_*); null when no result has a fee basis yet */
356 + estimatedPremiumShare: number | null;
357 + /** lots ended with a recorded hammer / lots ended — null when the house's results are not recorded per lot */
358 + sellThrough: number | null;
359 + lastResultAt: Date | null;
360 +}
361 +
362 +export const getAuctionHouseStats = cache(async (house: string): Promise<AuctionHouseStats | null> => {
363 + const lots = await one<Record<string, unknown>>(sql`
364 + SELECT count(DISTINCT au.id) AS auctions,
365 + count(DISTINCT au.id) FILTER (WHERE au.status <> 'ended') AS upcoming_auctions,
366 + count(l.id) AS lots,
367 + count(l.id) FILTER (WHERE l.status IN ('live','upcoming') AND (l.ends_at IS NULL OR l.ends_at > now())) AS open_lots,
368 + count(l.id) FILTER (WHERE l.status = 'live') AS live_lots,
369 + count(l.id) FILTER (WHERE l.status IN ('live','upcoming') AND l.current_bid IS NOT NULL AND coalesce(l.bid_count, 1) > 0) AS with_bids,
370 + count(l.id) FILTER (WHERE l.status IN ('live','upcoming') AND l.assessed_at IS NOT NULL) AS assessed,
371 + count(l.id) FILTER (WHERE l.status IN ('live','upcoming') AND l.assessment_verdict = 'deal') AS below_riv,
372 + count(l.id) FILTER (WHERE l.status = 'ended') AS ended,
373 + count(l.id) FILTER (WHERE l.status = 'ended' AND l.hammer_price IS NOT NULL) AS ended_sold
374 + FROM auctions au LEFT JOIN auction_lots l ON l.auction_id = au.id WHERE au.auction_house = ${house}
375 + `);
376 + const sales = await one<Record<string, unknown>>(sql`
377 + SELECT count(*) AS results,
378 + count(*) FILTER (WHERE s.sale_date >= now() - interval '365 days') AS results_365,
379 + percentile_cont(0.5) WITHIN GROUP (ORDER BY coalesce(s.all_in_usd, s.price_usd)) FILTER (WHERE s.sale_date >= now() - interval '365 days') AS median_365,
380 + max(coalesce(s.all_in_usd, s.price_usd)) FILTER (WHERE s.sale_date >= now() - interval '365 days') AS max_365,
381 + count(*) FILTER (WHERE s.fee_basis IS NOT NULL) AS with_basis,
382 + count(*) FILTER (WHERE s.fee_basis LIKE 'added_%') AS estimated_basis,
383 + max(s.sale_date) AS last_result
384 + FROM sales s WHERE s.status = 'valid' AND s.auction_house = ${house}
385 + `);
386 + if (!lots && !sales) return null;
387 + const auctions = int(lots?.auctions);
388 + const results = int(sales?.results);
389 + if (!auctions && !results) return null;
390 + const ended = int(lots?.ended);
391 + const withBasis = int(sales?.with_basis);
392 + return {
393 + house,
394 + auctions,
395 + upcomingAuctions: int(lots?.upcoming_auctions),
396 + lotsTracked: int(lots?.lots),
397 + openLots: int(lots?.open_lots),
398 + liveLots: int(lots?.live_lots),
399 + lotsWithBids: int(lots?.with_bids),
400 + lotsAssessed: int(lots?.assessed),
401 + lotsBelowRiv: int(lots?.below_riv),
402 + results,
403 + results365d: int(sales?.results_365),
404 + medianAllInUsd365d: num(sales?.median_365),
405 + maxAllInUsd365d: num(sales?.max_365),
406 + estimatedPremiumShare: withBasis ? int(sales?.estimated_basis) / withBasis : null,
407 + sellThrough: ended >= 20 && int(lots?.ended_sold) > 0 ? int(lots?.ended_sold) / ended : null,
408 + lastResultAt: date(sales?.last_result),
409 + };
410 +});
411 +
412 +/** Recent results (sales) recorded for an auction house. */
413 +export const listHouseResults = cache(async (house: string, limit = 50): Promise<SaleRow[]> => {
414 + const r = await rows<Record<string, unknown>>(sql`
415 + SELECT ${SALE_SELECT}, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, a.hero_image_url
416 + FROM sales s JOIN assets a ON a.id = s.asset_id JOIN sources src ON src.id = s.source_id
417 + WHERE s.status = 'valid' AND s.auction_house = ${house} ORDER BY s.sale_date DESC LIMIT ${limit}
418 + `);
419 + return r.map(toSaleRow);
420 +});
421 +
196 422 export const listAuctionHouses = cache(async (): Promise<Array<{ house: string; auctions: number; lots: number; upcoming: number }>> => {
197 423 const r = await rows<Record<string, unknown>>(sql`
198 424 SELECT au.auction_house AS house, count(DISTINCT au.id) AS auctions, count(l.id) AS lots, count(DISTINCT au.id) FILTER (WHERE au.status <> 'ended') AS upcoming
199 425