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>
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'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's premium(hammer, house schedule) · 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'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'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'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'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 | |