SPB Git

spb/rareindex

Public

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 (Sep 11, 2026) parent 1b5f855 Browse files

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 import { Tabs } from '@/components/ui/tabs'; 4 import { Tabs } from '@/components/ui/tabs';
5 import { Skeleton } from '@/components/ui/primitives'; 5 import { Skeleton } from '@/components/ui/primitives';
6 import { AssetHeader } from '@/components/asset/asset-header'; 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 import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, getPopulation, isWatchedBy } from '@/lib/queries/assets'; 9 import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, getPopulation, isWatchedBy } from '@/lib/queries/assets';
9 import { getCurrentUser } from '@/lib/auth/session'; 10 import { getCurrentUser } from '@/lib/auth/session';
10 import { catName, categoryPath } from '@/lib/taxonomy'; 11 import { catName, categoryPath } from '@/lib/taxonomy';
@@ -37,7 +38,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
37 const found = await getAssetBySlug(slug); 38 const found = await getAssetBySlug(slug);
38 if (!found) notFound(); 39 if (!found) notFound();
39 const tab = spEnum<AssetTab>(sp, 'tab', ASSET_TABS, 'overview'); 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 // latest published population total across graders (for the rarity explainer); null when no report exists 42 // latest published population total across graders (for the rarity explainer); null when no report exists
42 const population = popReports.length ? popReports.reduce((a, r) => (r.reportDate > a.reportDate ? r : a)).total : null; 43 const population = popReports.length ? popReports.reduce((a, r) => (r.reportDate > a.reportDate ? r : a)).total : null;
43 const watched = await isWatchedBy(user?.id ?? null, found.id); 44 const watched = await isWatchedBy(user?.id ?? null, found.id);
@@ -107,6 +108,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
107 { id: 'overview', label: 'Overview', href: href('overview') }, 108 { id: 'overview', label: 'Overview', href: href('overview') },
108 { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') }, 109 { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') },
109 { id: 'listings', label: 'Listings', count: variant ? variant.activeListings : asset.activeListings, href: href('listings') }, 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 { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') }, 112 { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') },
111 { id: 'population', label: 'Population', href: href('population') }, 113 { id: 'population', label: 'Population', href: href('population') },
112 { id: 'images', label: 'Images', count: images.length || null, href: href('images') }, 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 {tab === 'overview' ? <OverviewTab asset={asset} variant={variant} /> : null} 123 {tab === 'overview' ? <OverviewTab asset={asset} variant={variant} /> : null}
122 {tab === 'sales' ? <SalesTab asset={asset} variant={variant} page={page} /> : null} 124 {tab === 'sales' ? <SalesTab asset={asset} variant={variant} page={page} /> : null}
123 {tab === 'listings' ? <ListingsTab asset={asset} variant={variant} /> : null} 125 {tab === 'listings' ? <ListingsTab asset={asset} variant={variant} /> : null}
126+ {tab === 'auctions' ? <AuctionsTab asset={asset} /> : null}
124 {tab === 'grades' ? <GradesTab asset={asset} /> : null} 127 {tab === 'grades' ? <GradesTab asset={asset} /> : null}
125 {tab === 'population' ? <PopulationTab asset={asset} /> : null} 128 {tab === 'population' ? <PopulationTab asset={asset} /> : null}
126 {tab === 'images' ? <ImagesTab asset={asset} /> : null} 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 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3+import { Suspense } from 'react';
4+import { FAMILIES } from '@rareindex/taxonomy';
3 import { PageHeader } from '@/components/ui/page-header'; 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 import { Tabs } from '@/components/ui/tabs'; 7 import { Tabs } from '@/components/ui/tabs';
6−import { LotsTable } from '@/components/market/sales-table'; 8+import { FilterBar } from '@/components/ui/filter-bar';
7−import { listAuctionHouses, listAuctions, listLots } from '@/lib/queries/market-lists'; 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 import { fmtDate, fmtNum, fmtRelative, cn } from '@/lib/format'; 15 import { fmtDate, fmtNum, fmtRelative, cn } from '@/lib/format';
9 import { catName } from '@/lib/taxonomy'; 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 export default async function AuctionsPage({ searchParams }: { searchParams: Promise<SP> }) { 23 export default async function AuctionsPage({ searchParams }: { searchParams: Promise<SP> }) {
15 const sp = await searchParams; 24 const sp = await searchParams;
16− const tab = spEnum(sp, 'tab', ['ending', 'live', 'upcoming', 'results', 'houses'] as const, 'ending'); 25+ const tab = spEnum(sp, 'tab', TABS, 'open');
17− const [houses, ending, lots, auctions] = await Promise.all([ 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 listAuctionHouses(), 36 listAuctionHouses(),
19− listLots({ endingWithinHours: 72, limit: 60 }), 37+ tab === 'houses' ? Promise.resolve([]) : listAuctions({ limit: 40, house }),
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([]), 38+ tab === 'houses'
21− listAuctions({ limit: 100 }), 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 return ( 61 return (
24 <div> 62 <div>
25− <PageHeader kicker="Auction houses" 63+ <PageHeader
64+ kicker="Auction intelligence"
26 title="Auctions" 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 compact 67 compact
29 actions={ 68 actions={
30 <Link href="/auctions/calendar" className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium hover:bg-inset"> 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 </Link> 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 <Tabs 84 <Tabs
36 active={tab} 85 active={tab}
37 tabs={[ 86 tabs={[
38− { id: 'ending', label: 'Ending soon', count: ending.length, href: '/auctions?tab=ending' }, 87+ { id: 'open', label: 'Open lots', href: q({ tab: 'open' }) },
39− { id: 'live', label: 'Live', href: '/auctions?tab=live' }, 88+ { id: 'ending', label: 'Ending soon', href: q({ tab: 'ending' }) },
40− { id: 'upcoming', label: 'Upcoming', href: '/auctions?tab=upcoming' }, 89+ { id: 'below', label: 'Below RIV', count: overview.belowRiv || null, href: q({ tab: 'below' }) },
41− { id: 'results', label: 'Results', href: '/auctions?tab=results' }, 90+ { id: 'results', label: 'Results', href: q({ tab: 'results' }) },
42− { id: 'houses', label: 'Auction houses', count: houses.length, href: '/auctions?tab=houses' }, 91+ { id: 'houses', label: 'Auction houses', count: houses.length, href: q({ tab: 'houses' }) },
43 ]} 92 ]}
44 className="mb-4" 93 className="mb-4"
45 /> 94 />
95+
46 {tab === 'houses' ? ( 96 {tab === 'houses' ? (
47 <Card className="overflow-hidden"> 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 {houses.length ? ( 99 {houses.length ? (
50 <Table> 100 <Table>
51 <thead> 101 <thead>
52 <tr> 102 <tr>
53 <th className={th}>House</th> 103 <th className={th}>House</th>
104+ <th className={th}>Buyer&apos;s premium</th>
54 <th className={cn(th, 'text-right')}>Auctions</th> 105 <th className={cn(th, 'text-right')}>Auctions</th>
55 <th className={cn(th, 'text-right')}>Lots tracked</th> 106 <th className={cn(th, 'text-right')}>Lots tracked</th>
56 <th className={cn(th, 'text-right')}>Open</th> 107 <th className={cn(th, 'text-right')}>Open</th>
57 </tr> 108 </tr>
58 </thead> 109 </thead>
59 <tbody> 110 <tbody>
60− {houses.map((h) => ( 111+ {houses.map((h) => {
61− <tr key={h.house}> 112+ const s = feeScheduleFor(h.house);
62− <td className={cn(td, 'font-medium')}>{h.house}</td> 113+ return (
63− <td className={tdNum}>{fmtNum(h.auctions)}</td> 114+ <tr key={h.house} className="hover:bg-sunken">
64− <td className={tdNum}>{fmtNum(h.lots)}</td> 115+ <td className={cn(td, 'font-medium')}>
65− <td className={tdNum}>{fmtNum(h.upcoming)}</td> 116+ <Link href={`/auctions/house/${houseSlug(h.house)}`} className="hover:underline">
66− </tr> 117+ {h.house}
67− ))} 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 </tbody> 136 </tbody>
69 </Table> 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 </Card> 141 </Card>
74 ) : ( 142 ) : (
75− <div className="grid gap-4 xl:grid-cols-[2fr_1fr]"> 143+ <>
76− <Card className="overflow-hidden"> 144+ <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
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`} /> 145+ <Suspense>
78− <LotsTable items={tab === 'ending' ? ending : lots} /> 146+ <FilterBar
79− </Card> 147+ resetKeys={['page']}
80− <Card className="overflow-hidden"> 148+ fields={[
81− <CardHeader title="Auctions" subtitle="Catalogues tracked" /> 149+ { name: 'category', label: 'Category', type: 'select', options: FAMILIES.map((f) => ({ value: f.slug, label: f.name })), width: 'w-44' },
82− {auctions.length ? ( 150+ { name: 'house', label: 'House', type: 'select', options: houses.map((h) => ({ value: h.house, label: h.house })), width: 'w-44' },
83− <ul className="divide-y divide-border text-[12px]"> 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' }]),
84− {auctions.slice(0, 40).map((a) => ( 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' },
85− <li key={a.id} className="px-4 py-2"> 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' }]),
86− <a href={a.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline"> 154+ ]}
87− {a.name} 155+ />
88− </a> 156+ </Suspense>
89− <div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-muted"> 157+ <p className="text-[11px] text-subtle">
90− <Badge tone={a.status === 'live' ? 'gain' : a.status === 'upcoming' ? 'index' : 'neutral'}>{a.status}</Badge> 158+ {fmtNum(lots.total)} lot{lots.total === 1 ? '' : 's'}
91− <span>{a.auctionHouse}</span> 159+ {tab === 'ending' ? ` · ending within ${hours} h` : ''}
92− <span>· {a.endsAt ? `ends ${fmtDate(a.endsAt)} (${fmtRelative(a.endsAt)})` : a.startsAt ? `starts ${fmtDate(a.startsAt)}` : 'dates n/a'}</span> 160+ {below ? ' · assessed all-in bid or estimate ≥ 10 % below the RIV of the same variant' : ''}
93− <span>· {a.lotsTracked} lots{a.lotCount ? ` / ${a.lotCount}` : ''}</span> 161+ </p>
94− {a.categorySlugs.slice(0, 2).map((c) => ( 162+ </div>
95− <span key={c}>· {catName(c)}</span> 163+ <div className="grid gap-4 xl:grid-cols-[3fr_1fr]">
96− ))} 164+ <Card className="overflow-hidden">
97− </div> 165+ <CardHeader
98− </li> 166+ title={tab === 'ending' ? `Lots ending in the next ${hours} hours` : tab === 'below' ? 'Lots below the RareIndex Valuation' : tab === 'results' ? 'Recorded results' : 'Open lots'}
99− ))} 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.'}
100− </ul> 168+ />
101− ) : ( 169+ <div className="md:hidden">
102− <EmptyState title="No auctions tracked" className="py-8" /> 170+ <LotsIntelCards items={lots.items} />
103− )} 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}
104− </Card> 172+ </div>
105− </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 </div> 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 import { Thumb } from '@/components/market/bits'; 9 import { Thumb } from '@/components/market/bits';
10 import type { SP } from '@/lib/search-params'; 10 import type { SP } from '@/lib/search-params';
11 import { AssetTable } from '@/components/market/asset-list'; 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 import { LineChart } from '@/components/charts/line-chart'; 14 import { LineChart } from '@/components/charts/line-chart';
14 import { BarChart } from '@/components/charts/bar-chart'; 15 import { BarChart } from '@/components/charts/bar-chart';
15 import { getBrandsInCategory, getCategoryRow, getCategorySeries, getMarketRows, getPopulationTrend, getSetsInCategory } from '@/lib/queries/markets'; 16 import { getBrandsInCategory, getCategoryRow, getCategorySeries, getMarketRows, getPopulationTrend, getSetsInCategory } from '@/lib/queries/markets';
@@ -208,7 +209,7 @@ async function Activity({ slug }: { slug: string }) {
208 </Card> 209 </Card>
209 <Card> 210 <Card>
210 <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 <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 </Card> 213 </Card>
213 <Card> 214 <Card>
214 <CardHeader title="Population trends" subtitle="Total graded population reported per grader" /> 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 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import type { ReactNode } from 'react'; 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 import { PageHeader } from '@/components/ui/page-header'; 6 import { PageHeader } from '@/components/ui/page-header';
6 import { Badge, Card } from '@/components/ui/primitives'; 7 import { Badge, Card } from '@/components/ui/primitives';
7 import { cn } from '@/lib/format'; 8 import { cn } from '@/lib/format';
@@ -37,6 +38,7 @@ const SECTIONS: Array<{ id: string; title: string }> = [
37 { id: 'indices', title: 'Indices' }, 38 { id: 'indices', title: 'Indices' },
38 { id: 'fx', title: 'Currencies' }, 39 { id: 'fx', title: 'Currencies' },
39 { id: 'freshness', title: 'Freshness & limits' }, 40 { id: 'freshness', title: 'Freshness & limits' },
41+ { id: 'fees', title: 'Auction fees & all-in cost' },
40 ]; 42 ];
41 43
42 export default function MethodologyPage() { 44 export default function MethodologyPage() {
@@ -299,6 +301,49 @@ export default function MethodologyPage() {
299 </Link> 301 </Link>
300 </P> 302 </P>
301 </Section> 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 </div> 347 </div>
303 </div> 348 </div>
304 </div> 349 </div>
modified apps/web/src/components/asset/asset-tabs.tsx +27 −1
@@ -18,8 +18,10 @@ import { Evidence } from '@/components/ui/evidence';
18 import { getAssetDepth, getAssetLiquidation } from '@/lib/queries/depth'; 18 import { getAssetDepth, getAssetLiquidation } from '@/lib/queries/depth';
19 import { DepthLiquidationCard } from './depth-card'; 19 import { DepthLiquidationCard } from './depth-card';
20 import { countVerification } from '@rareindex/valuation'; 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 export type AssetTab = (typeof ASSET_TABS)[number]; 25 export type AssetTab = (typeof ASSET_TABS)[number];
24 26
25 export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) { 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 getSimilarAssets(asset, 8).then(attachGuidePrices), 38 getSimilarAssets(asset, 8).then(attachGuidePrices),
37 getValuationHistory(asset.id, variant?.id ?? null), 39 getValuationHistory(asset.id, variant?.id ?? null),
38 ]); 40 ]);
41+ const lots = await listAssetLots(asset.id, 6);
39 const [depth, liquidation] = await Promise.all([ 42 const [depth, liquidation] = await Promise.all([
40 getAssetDepth(asset.id, variant?.id ?? null), 43 getAssetDepth(asset.id, variant?.id ?? null),
41 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 }), 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 </Card> 159 </Card>
157 </div> 160 </div>
158 <DepthLiquidationCard depth={depth} liquidation={liquidation} riv={riv} variantLabel={variant?.label ?? null} /> 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 <div className="grid gap-4 lg:grid-cols-2"> 169 <div className="grid gap-4 lg:grid-cols-2">
160 <Card className="overflow-hidden"> 170 <Card className="overflow-hidden">
161 <CardHeader title="Recent sales" action={<Link href={`?tab=sales${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All {sales.total} →</Link>} /> 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 export async function GradesTab({ asset }: { asset: AssetDetail }) { 283 export async function GradesTab({ asset }: { asset: AssetDetail }) {
258 const rows = await getGradeDistribution(asset.id); 284 const rows = await getGradeDistribution(asset.id);
259 const graded = rows.filter((r) => r.grader !== 'raw'); 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 import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format'; 2 import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format';
3 import { humanize } from '@/lib/taxonomy'; 3 import { humanize } from '@/lib/taxonomy';
4 import { EmptyState, VsRiv } from '@/components/ui/primitives'; 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 /** Phone-first card lists for sales and listings (tables stay for md+). */ 7 /** Phone-first card lists for sales and listings (tables stay for md+). */
8 export function SalesCards({ items, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.' }: { items: SaleRow[]; className?: string; emptyDescription?: string }) { 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 <Thumb src={s.imageUrls[0]} alt="" size={44} rounded="rounded-md" /> 14 <Thumb src={s.imageUrls[0]} alt="" size={44} rounded="rounded-md" />
15 <div className="min-w-0 flex-1"> 15 <div className="min-w-0 flex-1">
16 <div className="flex items-baseline justify-between gap-2"> 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 <span className="text-[11px] text-muted" title={s.saleDate.toISOString()}> 18 <span className="text-[11px] text-muted" title={s.saleDate.toISOString()}>
19 {fmtDate(s.saleDate)} 19 {fmtDate(s.saleDate)}
20 </span> 20 </span>
21 </div> 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 <p className="mt-0.5 truncate text-[12px] text-muted" title={s.rawTitle}> 30 <p className="mt-0.5 truncate text-[12px] text-muted" title={s.rawTitle}>
24 {s.rawTitle} 31 {s.rawTitle}
25 </p> 32 </p>
modified apps/web/src/components/home/sections.tsx +6 −3
@@ -5,7 +5,7 @@ import type { LatestSale } from '@/lib/queries/site';
5 import type { MarketRow } from '@/lib/queries/markets'; 5 import type { MarketRow } from '@/lib/queries/markets';
6 import { fmtMoney, fmtRelative, fmtNum, cn, fmtDate } from '@/lib/format'; 6 import { fmtMoney, fmtRelative, fmtNum, cn, fmtDate } from '@/lib/format';
7 import { catName, humanize } from '@/lib/taxonomy'; 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 import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits'; 9 import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';
10 import { AssetTile, Rail, RailHeader, type TileMetric } from '@/components/market/asset-tile'; 10 import { AssetTile, Rail, RailHeader, type TileMetric } from '@/components/market/asset-tile';
11 import { heatStyle } from '@/components/charts/heat'; 11 import { heatStyle } from '@/components/charts/heat';
@@ -110,7 +110,7 @@ export function LotsRail({ items, hideWhenEmpty = false }: { items: LotRow[]; hi
110 if (hideWhenEmpty && !items.length) return null; 110 if (hideWhenEmpty && !items.length) return null;
111 return ( 111 return (
112 <section> 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 {items.length ? ( 114 {items.length ? (
115 <Rail cols="lg:grid-cols-3 xl:grid-cols-4"> 115 <Rail cols="lg:grid-cols-3 xl:grid-cols-4">
116 {items.slice(0, 8).map((l) => ( 116 {items.slice(0, 8).map((l) => (
@@ -121,7 +121,10 @@ export function LotsRail({ items, hideWhenEmpty = false }: { items: LotRow[]; hi
121 <span className="block truncate text-[11px] text-muted"> 121 <span className="block truncate text-[11px] text-muted">
122 {l.auctionHouse} · ends {fmtRelative(l.endsAt)} 122 {l.auctionHouse} · ends {fmtRelative(l.endsAt)}
123 </span> 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 </span> 128 </span>
126 </a> 129 </a>
127 ))} 130 ))}
modified apps/web/src/components/market/bits.tsx +28 −1
@@ -1,6 +1,7 @@
1 import Link from 'next/link'; 1 import Link from 'next/link';
2 import { ExternalLink } from 'lucide-react'; 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 import { catName, gradeLabel } from '@/lib/taxonomy'; 5 import { catName, gradeLabel } from '@/lib/taxonomy';
5 import { Badge } from '@/components/ui/primitives'; 6 import { Badge } from '@/components/ui/primitives';
6 7
@@ -76,3 +77,29 @@ export function VerificationBadge({ label, reason, className }: { label: 'verifi
76 </Badge> 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 import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format'; 3 import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format';
4 import { humanize } from '@/lib/taxonomy'; 4 import { humanize } from '@/lib/taxonomy';
5 import { Table, th, td, tdNum, EmptyState, Badge, VsRiv } from '@/components/ui/primitives'; 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 /** Card list for sales on small screens (image, title, grade, price, source). */ 8 /** Card list for sales on small screens (image, title, grade, price, source). */
9 export function SalesCards({ items, className }: { items: SaleRow[]; className?: string }) { 9 export function SalesCards({ items, className }: { items: SaleRow[]; className?: string }) {
@@ -22,8 +22,8 @@ export function SalesCards({ items, className }: { items: SaleRow[]; className?:
22 </span> 22 </span>
23 </span> 23 </span>
24 <span className="num flex shrink-0 flex-col items-end leading-tight"> 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> 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.currency !== 'USD' ? <span className="text-[10px] text-subtle">{fmtMoney(s.price, s.currency)}</span> : <span className="text-[10px] text-subtle">{humanize(s.saleType)}</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 </span> 27 </span>
28 </a> 28 </a>
29 </li> 29 </li>
@@ -82,7 +82,19 @@ export function SalesTable({ items, showAsset = true, className, emptyDescriptio
82 {s.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(s.condition)}</span> : null} 82 {s.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(s.condition)}</span> : null}
83 </td> 83 </td>
84 <td className={tdNum}> 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 </td> 98 </td>
87 <td className={cn(td, 'text-muted')}> 99 <td className={cn(td, 'text-muted')}>
88 {humanize(s.saleType)} 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 flags: string[]; 366 flags: string[];
367 variantId: string | null; 367 variantId: string | null;
368 buyerPremiumIncluded: boolean | null; 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 /** source metadata used by the heuristic verification label (§39) */ 373 /** source metadata used by the heuristic verification label (§39) */
370 sourceType: string | null; 374 sourceType: string | null;
371 sourceTrust: number | null; 375 sourceTrust: number | null;
@@ -401,6 +405,9 @@ export function toSaleRow(x: Record<string, unknown>): SaleRow {
401 flags: (x.flags as string[]) ?? [], 405 flags: (x.flags as string[]) ?? [],
402 variantId: str(x.variant_id), 406 variantId: str(x.variant_id),
403 buyerPremiumIncluded: x.buyer_premium_included === null || x.buyer_premium_included === undefined ? null : Boolean(x.buyer_premium_included), 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 sourceType: str(x.source_type), 411 sourceType: str(x.source_type),
405 sourceTrust: num(x.trust_score), 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 export async function getAssetSales(assetId: string, opts: { variantId?: string | null; limit?: number; offset?: number; includeFlagged?: boolean } = {}): Promise<{ items: SaleRow[]; total: number }> { 426 export async function getAssetSales(assetId: string, opts: { variantId?: string | null; limit?: number; offset?: number; includeFlagged?: boolean } = {}): Promise<{ items: SaleRow[]; total: number }> {
420 const limit = opts.limit ?? 50; 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 import type { SQL } from 'drizzle-orm'; 3 import type { SQL } from 'drizzle-orm';
4 import { rows, one, sql, num, int, str, date, joinAnd, textArray, iso } from './_util'; 4 import { rows, one, sql, num, int, str, date, joinAnd, textArray, iso } from './_util';
5 import { categoryScope, SALE_SELECT, LISTING_SELECT, toSaleRow, toListingRow, type SaleRow, type ListingRow } from './assets'; 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 // ---------- Sales ---------- 8 // ---------- Sales ----------
8 export interface SalesFilters { 9 export interface SalesFilters {
@@ -157,6 +158,7 @@ export interface LotRow {
157 auctionId: string; 158 auctionId: string;
158 auctionName: string | null; 159 auctionName: string | null;
159 auctionHouse: string | null; 160 auctionHouse: string | null;
161+ auctionHouseSlug: string | null;
160 assetSlug: string | null; 162 assetSlug: string | null;
161 assetTitle: string | null; 163 assetTitle: string | null;
162 categorySlug: string | null; 164 categorySlug: string | null;
@@ -176,23 +178,247 @@ export interface LotRow {
176 grader: string | null; 178 grader: string | null;
177 grade: string | null; 179 grade: string | null;
178 rivUsd: number | null; 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 const where: SQL[] = []; 274 const where: SQL[] = [];
183 if (opts.status) where.push(sql`l.status = ${opts.status}`); 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 if (opts.endingWithinHours) where.push(sql`l.ends_at BETWEEN now() AND now() + (${opts.endingWithinHours}::int || ' hours')::interval`); 277 if (opts.endingWithinHours) where.push(sql`l.ends_at BETWEEN now() AND now() + (${opts.endingWithinHours}::int || ' hours')::interval`);
185 if (opts.auctionId) where.push(sql`l.auction_id = ${opts.auctionId}`); 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 if (opts.category) where.push(sql`a.category_slug IN ${categoryScope(opts.category)}`); 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 const r = await rows<Record<string, unknown>>(sql` 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 312+ SELECT ${LOT_SELECT}, count(*) OVER() AS total ${LOT_FROM}
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 313+ WHERE ${joinAnd(lotWhere(opts))} ORDER BY ${LOT_ORDER[opts.sort ?? 'ending']} LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
191− WHERE ${joinAnd(where)} ORDER BY ${order} LIMIT ${opts.limit ?? 50}
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 export const listAuctionHouses = cache(async (): Promise<Array<{ house: string; auctions: number; lots: number; upcoming: number }>> => { 422 export const listAuctionHouses = cache(async (): Promise<Array<{ house: string; auctions: number; lots: number; upcoming: number }>> => {
197 const r = await rows<Record<string, unknown>>(sql` 423 const r = await rows<Record<string, unknown>>(sql`
198 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 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
Menu

Theme