← back to Govarbitrage
src/app/listings/[id]/page.tsx
420 lines
import { notFound } from "next/navigation";
import Link from "next/link";
import { getGatedListingDetail } from "@/lib/listing-detail";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { formatMoney, formatPercent, countdown } from "@/lib/utils";
import { auctionNetworkLinks, boardLinks, internationalNetworkLinks } from "@/lib/marketplace-links";
export const dynamic = "force-dynamic";
const n = (d: unknown): number => (d == null ? 0 : Number(d));
export default async function ListingDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { listing, gated } = await getGatedListingDetail(id);
if (!listing) notFound();
const r = listing.research;
const c = listing.costBreakdown;
const valuationRows: [string, number | null | undefined][] = [
["New Retail", n(r?.newRetail)],
["New Replacement", n(r?.newReplacement)],
["Avg Retail", n(r?.avgRetail)],
["Used Sold", n(r?.usedSoldPrice)],
["Used Asking", n(r?.usedAskingPrice)],
["Wholesale", n(r?.wholesaleValue)],
["Liquidation", n(r?.liquidationValue)],
["Sell Today", n(r?.sellTodayValue)],
["7-Day Value", n(r?.value7Day)],
["30-Day Value", n(r?.value30Day)],
["90-Day Value", n(r?.value90Day)],
["Expected Sale", n(r?.expectedSalePrice)],
];
const costRows: [string, number][] = c
? [
["Winning Bid", n(c.winningBid)],
["Buyer Premium", n(c.buyerPremium)],
["Sales Tax", n(c.salesTax)],
["Shipping", n(c.shipping)],
["Freight", n(c.freight)],
["Insurance", n(c.insurance)],
["Packing", n(c.packing)],
["Pickup Labor", n(c.pickupLabor)],
["Testing", n(c.testing)],
["Repairs", n(c.repairs)],
["Certification", n(c.certification)],
["Marketplace Fees", n(c.marketplaceFees)],
["Payment Fees", n(c.paymentFees)],
["Storage", n(c.storage)],
["Photography", n(c.photography)],
["Listing Labor", n(c.listingLabor)],
]
: [];
return (
<main className="mx-auto max-w-6xl space-y-5 p-5">
<div className="flex items-center gap-3 text-sm">
<Link href="/" className="text-primary hover:underline">
← Dashboard
</Link>
<Badge variant="primary">{listing.source.replace(/_/g, " ")}</Badge>
<span className="text-muted-foreground">#{listing.sourceAuctionId}</span>
{listing.sourceUrl && (
<a href={listing.sourceUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
View source ↗
</a>
)}
</div>
<h1 className="text-2xl font-semibold tracking-tight">{listing.title}</h1>
{/* Gallery */}
<div className="flex gap-3 overflow-x-auto">
{listing.imageUrls.length ? (
listing.imageUrls.map((u) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={u} src={u} alt={listing.title} className="h-48 w-72 flex-shrink-0 rounded-lg border object-cover" />
))
) : (
<div className="flex h-48 w-72 items-center justify-center rounded-lg border text-muted-foreground">
No images
</div>
)}
</div>
{/* Headline metrics */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Metric label="Current Bid" value={formatMoney(n(listing.currentBid))} />
<MetricOrGated label="Rec. Max Bid" value={formatMoney(n(c?.recommendedMaxBid))} gatedPlaceholder="$9,999" gated={gated} accent />
<MetricOrGated label="Expected Net" value={formatMoney(n(c?.expectedNetProfit))} gatedPlaceholder="$9,999" gated={gated} accent />
<MetricOrGated label="ROI" value={formatPercent(n(c?.roi))} gatedPlaceholder="$9,999" gated={gated} accent />
<Metric label="Closes" value={countdown(listing.closingAt)} />
</div>
{gated && (
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-4 text-sm">
<span>
The money math — recommended max bid, valuation ladder, full cost breakdown,
expected net profit, comparables, and buyer leads — is part of the paid tiers.
</span>
<Link href="/pricing" className="rounded-md bg-primary px-3 py-1.5 font-medium text-primary-foreground">
See plans →
</Link>
</CardContent>
</Card>
)}
{!gated && (
<div className="grid gap-5 md:grid-cols-2">
{/* Valuation ladder */}
<Card>
<CardHeader>
<CardTitle>Valuation</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{valuationRows.map(([label, val]) => (
<Row key={label} label={label} value={formatMoney(val)} />
))}
<Row label="Probability of Sale" value={formatPercent(n(r?.probabilityOfSale))} />
<Row label="Days Until Sold" value={`${r?.daysUntilSold ?? "—"}`} />
<Row label="Confidence" value={`${Math.round(n(r?.confidenceScore))}/100`} />
</CardContent>
</Card>
{/* Cost breakdown */}
<Card>
<CardHeader>
<CardTitle>Cost Breakdown & Profit</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{costRows.map(([label, val]) => (
<Row key={label} label={label} value={formatMoney(val)} />
))}
<div className="my-2 border-t" />
<Row label="Total Investment" value={formatMoney(n(c?.totalInvestment))} strong />
<Row label="Expected Returns" value={formatMoney(n(c?.expectedReturns))} strong />
<Row label="Expected Net Profit" value={formatMoney(n(c?.expectedNetProfit))} strong />
<Row label="ROI" value={formatPercent(n(c?.roi))} strong />
<Row label="Annualized" value={formatPercent(n(c?.annualizedReturn))} />
</CardContent>
</Card>
</div>
)}
{/* Score explanations */}
<Card>
<CardHeader>
<CardTitle>Scoring — why each profile scored this listing</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
{listing.scores.map((s) => (
<div key={s.id} className="rounded-lg border p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium">{s.profile.replace(/_/g, " ")}</span>
<Badge variant={s.value >= 70 ? "positive" : s.value >= 50 ? "warning" : "outline"}>
{Math.round(s.value)}
</Badge>
</div>
<div className="mt-1.5 h-1 w-full rounded-full bg-muted">
<div
className="h-1 rounded-full"
style={{
width: `${Math.max(0, Math.min(100, s.value))}%`,
background:
s.value >= 70
? "var(--positive)"
: s.value >= 50
? "var(--warning)"
: "var(--negative)",
}}
/>
</div>
<p className="text-xs text-muted-foreground">{s.explanation}</p>
</div>
))}
</CardContent>
</Card>
<div className="grid gap-5 md:grid-cols-2">
{/* Comparables */}
<Card>
<CardHeader>
<CardTitle>Comparable Sales & Listings</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{listing.comparables.length === 0 && <p className="text-sm text-muted-foreground">None.</p>}
{listing.comparables.map((cp) => (
<div key={cp.id} className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<Badge variant="outline">{cp.kind}</Badge>
<span className="truncate">{cp.title}</span>
</span>
<span className="tabular-nums">{formatMoney(n(cp.price))}</span>
</div>
))}
</CardContent>
</Card>
{/* Terms + risk */}
<Card>
<CardHeader>
<CardTitle>Auction Terms & Risk</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">Condition: </span>
{listing.condition.replace(/_/g, " ")} · Qty {listing.quantity} · {n(listing.weightLbs)} lb
</div>
<div>
<span className="text-muted-foreground">Location: </span>
{[listing.locationCity, listing.locationState, listing.locationZip].filter(Boolean).join(", ") || "—"}
</div>
<p className="text-muted-foreground">{listing.auctionTerms}</p>
{r?.summary && <p className="rounded-md bg-muted p-2 text-xs">{r.summary}</p>}
</CardContent>
</Card>
</div>
{/* Buyer workflow (compliant) */}
<Card>
<CardHeader>
<CardTitle>Buyer Interest (contingent — no ownership represented pre-win)</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{listing.buyerPage ? (
<div>
Interest page: <span className="font-medium">/{listing.buyerPage.slug}</span> ·{" "}
{listing.buyerPage.published ? <Badge variant="positive">Published</Badge> : <Badge variant="outline">Draft</Badge>} ·
est. delivered {formatMoney(n(listing.buyerPage.estDelivered))}
</div>
) : (
<p className="text-muted-foreground">No buyer-interest page yet.</p>
)}
{listing.buyerLeads.map((l) => (
<div key={l.id} className="flex items-center justify-between rounded-md border p-2">
<span>
{l.name} — {l.email} {l.contingent && <Badge variant="outline">contingent</Badge>}
</span>
<span className="tabular-nums">{formatMoney(n(l.offer))}</span>
</div>
))}
</CardContent>
</Card>
{/* CRE agents — deep-link to /agents pre-filled with this listing's location */}
{(listing.locationCity || listing.locationState || listing.locationZip) && (
<Card>
<CardHeader>
<CardTitle>Find Commercial Real Estate Agents Near This Property</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p className="text-muted-foreground">
Buyer / acquisition brokers and leasing agents sourced live from Google Places for{" "}
{[listing.locationCity, listing.locationState].filter(Boolean).join(", ") || listing.locationZip}.
</p>
<div className="flex flex-wrap gap-2">
<Link
href={`/agents?${new URLSearchParams({
...(listing.locationCity ? { city: listing.locationCity } : {}),
...(listing.locationState ? { state: listing.locationState } : {}),
...(listing.locationZip ? { zip: listing.locationZip } : {}),
type: "both",
}).toString()}`}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Find agents (buyer + leasing) →
</Link>
<Link
href={`/agents?${new URLSearchParams({
...(listing.locationCity ? { city: listing.locationCity } : {}),
...(listing.locationState ? { state: listing.locationState } : {}),
...(listing.locationZip ? { zip: listing.locationZip } : {}),
type: "buyer",
}).toString()}`}
className="rounded-md border px-3 py-1.5 text-sm text-primary hover:bg-accent"
>
Buyer / acquisition only →
</Link>
</div>
</CardContent>
</Card>
)}
{/* Compare & source elsewhere */}
<div className="grid gap-5 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Actual offers on other networks</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{auctionNetworkLinks(listing).map((c) => (
<a
key={c.url}
href={c.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-md border px-2 py-1 text-sm text-primary hover:bg-accent"
>
{c.label} ↗
</a>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Goods-needed boards & communities</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{boardLinks(listing).map((c) => (
<a
key={c.url}
href={c.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-md border px-2 py-1 text-sm text-primary hover:bg-accent"
>
{c.label} ↗
</a>
))}
</CardContent>
</Card>
</div>
{/* International offers */}
<Card>
<CardHeader>
<CardTitle>International offers — Europe · Japan · HK · Australia · China</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-5">
{internationalNetworkLinks(listing).map((r) => (
<div key={r.region}>
<div className="mb-1 text-xs font-semibold text-muted-foreground">{r.region}</div>
<div className="flex flex-col gap-1">
{r.links.map((c) => (
<a
key={c.url}
href={c.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{c.label} ↗
</a>
))}
</div>
</div>
))}
</CardContent>
</Card>
{/* History */}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-xs text-muted-foreground">
{listing.events.map((e) => (
<div key={e.id} className="flex justify-between">
<span>{e.message}</span>
<span>{new Date(e.createdAt).toLocaleString()}</span>
</div>
))}
</CardContent>
</Card>
</main>
);
}
function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
return (
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className={`text-lg font-semibold tabular-nums ${accent ? "text-primary" : ""}`}>{value}</div>
</CardContent>
</Card>
);
}
function MetricOrGated({
label,
value,
gatedPlaceholder,
gated,
accent,
}: {
label: string;
value: string;
gatedPlaceholder: string;
gated: boolean;
accent?: boolean;
}) {
return (
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className={`text-lg font-semibold tabular-nums ${accent ? "text-primary" : ""}`}>
{gated ? (
<span className="blur-sm select-none">{gatedPlaceholder}</span>
) : (
value
)}
</div>
</CardContent>
</Card>
);
}
function Row({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<div className={`flex justify-between text-sm ${strong ? "font-semibold" : ""}`}>
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums">{value}</span>
</div>
);
}