← back to Homesonspec
apps/web/src/app/homes/[id]/page.tsx
302 lines
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@homesonspec/database";
import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";
import CorrectionForm from "./CorrectionForm";
import LeadForm from "./LeadForm";
export const dynamic = "force-dynamic";
const STATUS_LABELS: Record<string, string> = {
MOVE_IN_READY: "Move-in ready",
UNDER_CONSTRUCTION: "Under construction",
PLANNED: "Planned",
};
const STATUS_DOT: Record<string, string> = {
MOVE_IN_READY: "#16a34a",
UNDER_CONSTRUCTION: "#f59e0b",
PLANNED: "#3b82f6",
};
export default async function HomeDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const home = await prisma.inventoryHome.findUnique({
where: { id },
include: {
community: true,
builder: true,
floorPlan: true,
source: true,
incentives: true,
},
});
if (!home || home.status !== "PUBLISHED" || home.isDemo) notFound();
const [communityIncentives, evidence, otherHomes] = await Promise.all([
prisma.incentive.findMany({ where: { communityId: home.communityId, scope: "COMMUNITY" } }),
prisma.sourceEvidence.findMany({
where: { entityType: "INVENTORY_HOME", entityId: home.id },
orderBy: { field: "asc" },
}),
// "Carousel of homes in the project" — other available homes in this community.
prisma.inventoryHome.findMany({
where: {
communityId: home.communityId,
status: "PUBLISHED",
isDemo: false,
freshness: { not: "INACTIVE" },
id: { not: home.id },
},
select: {
id: true, street: true, city: true, state: true, price: true,
beds: true, bathsTotal: true, sqft: true, constructionStatus: true, images: true,
},
orderBy: { publishedAt: "desc" },
take: 12,
}),
]);
const incentives = [...home.incentives, ...communityIncentives];
// Media-rights safe default: builder photos are OFF unless BUILDER_IMAGES_ENABLED=1
// is explicitly set (must match the homepage default — facts-only per docs/data-rights/POLICY.md).
const showImages = process.env.BUILDER_IMAGES_ENABLED === "1";
// Dedupe evidence to one row per field (newest retrieval). Re-crawls produce a
// fresh snapshot each time (dynamic source content), so raw evidence accumulates
// ~1 row per field per crawl — show only the latest so the table stays readable.
const latestEvidence = Object.values(
evidence.reduce<Record<string, (typeof evidence)[number]>>((acc, row) => {
const cur = acc[row.field];
if (!cur || row.retrievedAt > cur.retrievedAt) acc[row.field] = row;
return acc;
}, {}),
).sort((a, b) => a.field.localeCompare(b.field));
const facts: [string, string][] = [
["Price", fmtPrice(home.price === null ? null : Number(home.price))],
["Status", STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus],
["Beds", home.beds?.toString() ?? "Not published"],
["Baths", home.bathsTotal?.toString() ?? "Not published"],
["Square feet", home.sqft?.toLocaleString() ?? "Not published"],
["Stories", home.stories?.toString() ?? "Not published"],
["Garage", home.garageSpaces ? `${home.garageSpaces}-car` : "Not published"],
["Lot", home.lotNumber ? `Lot ${home.lotNumber}` : "Not published"],
[
"Est. completion",
home.estCompletionDate
? home.estCompletionDate.toLocaleDateString(undefined, { month: "long", year: "numeric" })
: home.constructionStatus === "MOVE_IN_READY"
? "Complete"
: "Not published",
],
];
return (
<div className="mx-auto max-w-5xl px-4 py-8">
<nav className="text-sm text-neutral-500">
<Link href="/search" className="hover:text-brand-700">Search</Link> ·{" "}
<Link href={`/communities/${home.community.slug}`} className="hover:text-brand-700">
{home.community.name}
</Link>
</nav>
{/* Branded hero header. Honest — no fabricated photography; builder imagery
renders here once ingested (see the image-ingestion follow-up). */}
<div className="mt-3 overflow-hidden rounded-2xl border border-brand-600/30 shadow-[var(--shadow-card)]">
<div className="relative h-56 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500 sm:h-72">
{showImages && home.images[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={home.images[0]} alt={`${home.street ?? home.community.name} exterior`}
fetchPriority="high" className="absolute inset-0 h-full w-full object-cover" />
) : null}
{/* scrim keeps the wordmark + price legible over any photo */}
<div className="absolute inset-0 bg-gradient-to-t from-brand-950/85 via-brand-950/25 to-brand-950/10" />
<span className="badge-ready absolute left-4 top-4 gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ background: STATUS_DOT[home.constructionStatus] ?? "#16a34a" }} />
{STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus}
</span>
<div className="absolute inset-x-0 bottom-0 flex flex-wrap items-end justify-between gap-3 bg-gradient-to-t from-brand-950/80 to-transparent p-5 text-white">
<div>
<h1 className="font-display text-2xl font-semibold sm:text-3xl">
{home.street ?? "Address available from builder"}
</h1>
<p className="mt-1 text-sm text-brand-100">
{home.city}, {home.state} {home.zip} · {home.community.name}
</p>
</div>
<div className="text-right">
<div className="font-display text-3xl font-semibold" data-testid="detail-price">
{fmtPrice(home.price === null ? null : Number(home.price))}
</div>
{home.previousPrice && home.price && Number(home.previousPrice) !== Number(home.price) ? (
<div className="text-sm text-brand-200 line-through">{fmtPrice(Number(home.previousPrice))}</div>
) : null}
</div>
</div>
</div>
</div>
<p className="mt-2 text-sm text-neutral-600">
Built by{" "}
<Link href={`/builders/${home.builder.slug}`} className="font-semibold text-brand-700 hover:underline">
{home.builder.name}
</Link>
</p>
{showImages && home.images.length > 1 ? (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1" data-testid="home-gallery">
{home.images.slice(0, 8).map((src, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={i} src={src} alt={`${home.community.name} photo ${i + 1}`} loading="lazy"
className="h-20 w-28 shrink-0 rounded-lg object-cover ring-1 ring-neutral-200" />
))}
</div>
) : null}
{/* Verification block — the trust surface. */}
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-neutral-200 bg-white p-3 text-sm shadow-[var(--shadow-card)]" data-testid="verification-block">
<VerificationBadge label={home.verificationLabel} />
<span className="text-neutral-600">
Last verified:{" "}
{home.lastVerifiedAt
? home.lastVerifiedAt.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
: "not yet verified"}
</span>
{home.source ? <span className="text-neutral-500">Source: {home.source.name}</span> : null}
{home.sourceUrl ? (
<a href={home.sourceUrl} rel="nofollow noopener noreferrer" className="text-brand-700 hover:underline">
Original source
</a>
) : null}
</div>
<div className="mt-6 grid gap-8 md:grid-cols-[1fr_320px]">
<div>
<h2 className="font-display text-xl font-semibold text-brand-900">Home facts</h2>
<dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
{facts.map(([label, value]) => (
<div key={label} className="border-b border-neutral-100 py-1.5">
<dt className="text-neutral-500">{label}</dt>
<dd className="font-medium">{value}</dd>
</div>
))}
</dl>
<p className="mt-2 text-xs text-neutral-400">
“Not published” means the source did not state this fact. HomesOnSpec never fills in missing values.
</p>
{incentives.length > 0 && (
<section className="mt-8">
<h2 className="font-display text-xl font-semibold text-brand-900">Incentives</h2>
<ul className="mt-3 space-y-3">
{incentives.map((incentive) => (
<li key={incentive.id} className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm">
<div className="font-semibold">{incentive.title}</div>
{incentive.description && <p className="mt-1 text-neutral-600">{incentive.description}</p>}
<div className="mt-1 text-xs text-neutral-500">
{incentive.expiresAt
? `Expires ${incentive.expiresAt.toLocaleDateString()}`
: incentive.evergreenLabel ?? ""}
</div>
</li>
))}
</ul>
</section>
)}
<section className="mt-8">
<h2 className="font-display text-xl font-semibold text-brand-900">Data provenance</h2>
<p className="mt-1 text-sm text-neutral-500">
Every displayed fact traces to source evidence with a retrieval timestamp.
</p>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-left text-xs" data-testid="evidence-table">
<thead>
<tr className="border-b border-neutral-200 text-neutral-500">
<th className="py-1.5 pr-3">Field</th>
<th className="py-1.5 pr-3">Source text</th>
<th className="py-1.5 pr-3">Retrieved</th>
<th className="py-1.5">Confidence</th>
</tr>
</thead>
<tbody>
{latestEvidence.map((row) => (
<tr key={row.id} className="border-b border-neutral-100">
<td className="py-1.5 pr-3 font-medium">{row.field}</td>
<td className="py-1.5 pr-3">{row.evidenceText ?? <em className="text-neutral-400">not stated by source</em>}</td>
<td className="py-1.5 pr-3">{row.retrievedAt.toLocaleDateString()}</td>
<td className="py-1.5">{(row.confidence * 100).toFixed(0)}%</td>
</tr>
))}
{latestEvidence.length === 0 && (
<tr><td colSpan={4} className="py-2 text-neutral-400">No field-level evidence recorded.</td></tr>
)}
</tbody>
</table>
</div>
</section>
<section className="mt-8">
<CorrectionForm entityId={home.id} />
</section>
</div>
<aside className="self-start lg:sticky lg:top-[84px]">
<div className="card p-4">
<h2 className="font-display text-lg font-semibold text-brand-900">Contact {home.builder.name}</h2>
<p className="mt-1 text-sm text-neutral-600">
This builder is actively selling this home — send a note and they’ll follow up. No obligation.
</p>
{home.community.salesPhone ? (
<a href={`tel:${home.community.salesPhone.replace(/[^0-9+]/g, "")}`}
className="btn-accent mt-3 w-full justify-center" data-testid="sales-phone">
📞 Call {home.community.salesPhone}
</a>
) : null}
<div className="mt-3">
<LeadForm homeId={home.id} communityId={home.communityId} builderName={home.builder.name} />
</div>
</div>
</aside>
</div>
{/* Carousel of homes in the project — other available homes in this community. */}
{otherHomes.length > 0 && (
<section className="mt-12">
<div className="flex items-end justify-between">
<h2 className="font-display text-xl font-semibold text-brand-900">
More homes in {home.community.name}
</h2>
<Link href={`/communities/${home.community.slug}`} className="text-sm font-semibold text-brand-700 hover:text-brand-800">
View community →
</Link>
</div>
<div className="mt-4 flex snap-x gap-4 overflow-x-auto pb-3" data-testid="community-carousel">
{otherHomes.map((h) => (
<Link key={h.id} href={`/homes/${h.id}`} className="card-interactive w-64 shrink-0 snap-start overflow-hidden">
<div className="relative h-32 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500">
{showImages && h.images[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={h.images[0]} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
) : null}
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-white/95 px-2 py-0.5 text-[11px] font-medium text-neutral-700">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: STATUS_DOT[h.constructionStatus] ?? "#16a34a" }} />
{STATUS_LABELS[h.constructionStatus] ?? h.constructionStatus}
</span>
</div>
<div className="p-3">
<div className="font-display text-lg font-semibold text-brand-900">
{fmtPrice(h.price === null ? null : Number(h.price))}
</div>
<div className="mt-0.5 truncate text-sm text-neutral-600">{h.street ?? "Address from builder"}</div>
<div className="mt-1 text-xs text-neutral-500">
{h.beds ?? "—"} bd · {h.bathsTotal?.toString() ?? "—"} ba · {h.sqft?.toLocaleString() ?? "—"} sqft
</div>
</div>
</Link>
))}
</div>
</section>
)}
</div>
);
}