← back to Homesonspec
consumer web app (search/detail/community/builder/contact) + admin app (review/sources/validation/snapshots) + publisher package extraction; both apps serving
6b477e66bce9c4d5dd158cae60df6692e3b3708a · 2026-07-22 11:37:04 -0700 · Steve Abrams
Files touched
A apps/admin/next-env.d.tsM apps/admin/next.config.tsM apps/admin/package.jsonA apps/admin/src/app/api/review/route.tsA apps/admin/src/app/corrections/page.tsxA apps/admin/src/app/layout.tsxA apps/admin/src/app/leads/page.tsxA apps/admin/src/app/page.tsxA apps/admin/src/app/review/ReviewActions.tsxA apps/admin/src/app/review/page.tsxA apps/admin/src/app/snapshots/page.tsxA apps/admin/src/app/sources/page.tsxA apps/admin/src/app/validation/page.tsxA apps/admin/src/middleware.tsM apps/admin/tsconfig.jsonA apps/web/next-env.d.tsM apps/web/next.config.tsA apps/web/src/app/[state]/[city]/page.tsxA apps/web/src/app/api/corrections/route.tsA apps/web/src/app/api/facets/route.tsA apps/web/src/app/api/leads/route.tsA apps/web/src/app/api/search/route.tsA apps/web/src/app/builders/[slug]/page.tsxA apps/web/src/app/communities/[slug]/page.tsxA apps/web/src/app/compare/page.tsxA apps/web/src/app/contact/ContactClient.tsxA apps/web/src/app/contact/page.tsxA apps/web/src/app/homes/[id]/CorrectionForm.tsxA apps/web/src/app/homes/[id]/LeadForm.tsxA apps/web/src/app/homes/[id]/page.tsxA apps/web/src/app/layout.tsxA apps/web/src/app/page.tsxA apps/web/src/app/plans/[id]/page.tsxA apps/web/src/app/saved/page.tsxA apps/web/src/app/search/SearchClient.tsxA apps/web/src/app/search/page.tsxA apps/web/src/lib/parse-search.tsM apps/web/tsconfig.jsonM apps/workers/package.jsonM apps/workers/src/cli.tsM apps/workers/src/index.tsM apps/workers/src/pipeline.tsM collectors/common/src/index.tsM collectors/meridian-homes/src/extract.test.tsA packages/publisher/package.jsonA packages/publisher/src/index.tsA packages/publisher/tsconfig.jsonA packages/search/src/index.tsA packages/shared-ui/src/MapView.tsxA packages/shared-ui/src/badges.tsxA packages/shared-ui/src/index.tsM packages/shared/src/canonical.test.tsM packages/shared/src/geo.test.tsM packages/shared/src/index.tsM packages/validation/src/engine.tsM packages/validation/src/index.tsM packages/validation/src/rules.test.tsM packages/validation/src/rules.tsM pnpm-lock.yamlM tsconfig.base.json
Diff
commit 6b477e66bce9c4d5dd158cae60df6692e3b3708a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 11:37:04 2026 -0700
consumer web app (search/detail/community/builder/contact) + admin app (review/sources/validation/snapshots) + publisher package extraction; both apps serving
---
apps/admin/next-env.d.ts | 6 +
apps/admin/next.config.ts | 1 +
apps/admin/package.json | 3 +-
apps/admin/src/app/api/review/route.ts | 48 ++++
apps/admin/src/app/corrections/page.tsx | 34 +++
apps/admin/src/app/layout.tsx | 31 +++
apps/admin/src/app/leads/page.tsx | 36 +++
apps/admin/src/app/page.tsx | 86 +++++++
apps/admin/src/app/review/ReviewActions.tsx | 48 ++++
apps/admin/src/app/review/page.tsx | 88 +++++++
apps/admin/src/app/snapshots/page.tsx | 45 ++++
apps/admin/src/app/sources/page.tsx | 49 ++++
apps/admin/src/app/validation/page.tsx | 60 +++++
apps/admin/src/middleware.ts | 23 ++
apps/admin/tsconfig.json | 44 +++-
apps/web/next-env.d.ts | 6 +
apps/web/next.config.ts | 1 +
apps/web/src/app/[state]/[city]/page.tsx | 45 ++++
apps/web/src/app/api/corrections/route.ts | 21 ++
apps/web/src/app/api/facets/route.ts | 11 +
apps/web/src/app/api/leads/route.ts | 23 ++
apps/web/src/app/api/search/route.ts | 11 +
apps/web/src/app/builders/[slug]/page.tsx | 60 +++++
apps/web/src/app/communities/[slug]/page.tsx | 104 ++++++++
apps/web/src/app/compare/page.tsx | 54 +++++
apps/web/src/app/contact/ContactClient.tsx | 74 ++++++
apps/web/src/app/contact/page.tsx | 12 +
apps/web/src/app/homes/[id]/CorrectionForm.tsx | 75 ++++++
apps/web/src/app/homes/[id]/LeadForm.tsx | 59 +++++
apps/web/src/app/homes/[id]/page.tsx | 181 ++++++++++++++
apps/web/src/app/layout.tsx | 43 ++++
apps/web/src/app/page.tsx | 63 +++++
apps/web/src/app/plans/[id]/page.tsx | 50 ++++
apps/web/src/app/saved/page.tsx | 32 +++
apps/web/src/app/search/SearchClient.tsx | 323 +++++++++++++++++++++++++
apps/web/src/app/search/page.tsx | 12 +
apps/web/src/lib/parse-search.ts | 45 ++++
apps/web/tsconfig.json | 44 +++-
apps/workers/package.json | 10 +-
apps/workers/src/cli.ts | 2 +-
apps/workers/src/index.ts | 2 +-
apps/workers/src/pipeline.ts | 134 +---------
collectors/common/src/index.ts | 2 +-
collectors/meridian-homes/src/extract.test.ts | 2 +-
packages/publisher/package.json | 14 ++
packages/publisher/src/index.ts | 136 +++++++++++
packages/publisher/tsconfig.json | 1 +
packages/search/src/index.ts | 258 ++++++++++++++++++++
packages/shared-ui/src/MapView.tsx | 96 ++++++++
packages/shared-ui/src/badges.tsx | 49 ++++
packages/shared-ui/src/index.ts | 2 +
packages/shared/src/canonical.test.ts | 2 +-
packages/shared/src/geo.test.ts | 2 +-
packages/shared/src/index.ts | 6 +-
packages/validation/src/engine.ts | 2 +-
packages/validation/src/index.ts | 6 +-
packages/validation/src/rules.test.ts | 6 +-
packages/validation/src/rules.ts | 2 +-
pnpm-lock.yaml | 25 ++
tsconfig.base.json | 41 +++-
60 files changed, 2576 insertions(+), 175 deletions(-)
diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts
new file mode 100644
index 0000000..c4b7818
--- /dev/null
+++ b/apps/admin/next-env.d.ts
@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts
index c63dd4e..9be57ea 100644
--- a/apps/admin/next.config.ts
+++ b/apps/admin/next.config.ts
@@ -12,6 +12,7 @@ const nextConfig: NextConfig = {
"@spechomes/search",
"@spechomes/shared",
"@spechomes/shared-ui",
+ "@spechomes/publisher",
],
};
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 06f4a39..1ff619d 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -20,7 +20,8 @@
"next": "16.2.10",
"react": "19.2.7",
"react-dom": "19.2.7",
- "zod": "^3.24.1"
+ "zod": "^3.24.1",
+ "@spechomes/publisher": "workspace:*"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
diff --git a/apps/admin/src/app/api/review/route.ts b/apps/admin/src/app/api/review/route.ts
new file mode 100644
index 0000000..a611829
--- /dev/null
+++ b/apps/admin/src/app/api/review/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@spechomes/database";
+import { publishStagedRecord } from "@spechomes/publisher";
+
+const actionSchema = z.object({
+ reviewItemId: z.string(),
+ stagedRecordId: z.string(),
+ action: z.enum(["approve", "reject"]),
+});
+
+/**
+ * Human review resolution. Approve = staged record becomes APPROVED and the
+ * publish stage (the only published-tables writer) runs. Reject = REJECTED,
+ * never published. Either way the ReviewItem records who/when.
+ */
+export async function POST(request: NextRequest) {
+ const parsed = actionSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) return NextResponse.json({ error: "invalid request" }, { status: 400 });
+ const { reviewItemId, stagedRecordId, action } = parsed.data;
+
+ const item = await prisma.reviewItem.findUnique({ where: { id: reviewItemId } });
+ if (!item || item.status !== "OPEN") {
+ return NextResponse.json({ error: "review item not open" }, { status: 409 });
+ }
+
+ if (action === "approve") {
+ await prisma.stagedRecord.update({ where: { id: stagedRecordId }, data: { status: "APPROVED" } });
+ try {
+ await publishStagedRecord(stagedRecordId);
+ } catch (error) {
+ return NextResponse.json({ error: `publish failed: ${String(error)}` }, { status: 500 });
+ }
+ } else {
+ await prisma.stagedRecord.update({ where: { id: stagedRecordId }, data: { status: "REJECTED" } });
+ }
+
+ await prisma.reviewItem.update({
+ where: { id: reviewItemId },
+ data: {
+ status: action === "approve" ? "APPROVED" : "REJECTED",
+ reviewer: "admin",
+ resolvedAt: new Date(),
+ },
+ });
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/admin/src/app/corrections/page.tsx b/apps/admin/src/app/corrections/page.tsx
new file mode 100644
index 0000000..81681de
--- /dev/null
+++ b/apps/admin/src/app/corrections/page.tsx
@@ -0,0 +1,34 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function CorrectionsPage() {
+ const reports = await prisma.correctionReport.findMany({ orderBy: { createdAt: "desc" }, take: 100 });
+
+ return (
+ <div>
+ <h1 className="text-2xl font-bold">Correction reports</h1>
+ <p className="mt-1 text-sm text-neutral-500">Consumer-submitted "report a problem" items.</p>
+ <div className="mt-4 space-y-3">
+ {reports.map((report) => (
+ <div key={report.id} className="rounded-xl border border-neutral-200 bg-white p-4 text-sm shadow-sm">
+ <div className="flex items-center justify-between">
+ <span className="font-mono text-xs text-neutral-500">
+ {report.entityType} · {report.entityId.slice(0, 10)}… {report.field ? `· ${report.field}` : ""}
+ </span>
+ <CreatedChip date={report.createdAt} />
+ </div>
+ <p className="mt-2">{report.message}</p>
+ {report.reporterEmail && <p className="mt-1 text-xs text-neutral-500">From: {report.reporterEmail}</p>}
+ <span className={`mt-2 inline-block rounded-full px-2 py-0.5 text-xs ${
+ report.status === "OPEN" ? "bg-amber-100 text-amber-800" : "bg-neutral-100 text-neutral-600"}`}>
+ {report.status}
+ </span>
+ </div>
+ ))}
+ {reports.length === 0 && <p className="text-neutral-500">No reports.</p>}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
new file mode 100644
index 0000000..5a01566
--- /dev/null
+++ b/apps/admin/src/app/layout.tsx
@@ -0,0 +1,31 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "SpecHomes Admin",
+ robots: { index: false, follow: false },
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="en">
+ <body className="min-h-screen bg-neutral-50 text-neutral-900 antialiased">
+ <header className="border-b border-neutral-200 bg-white">
+ <nav className="mx-auto flex max-w-7xl items-center gap-6 px-4 py-3 text-sm">
+ <Link href="/" className="text-lg font-bold text-neutral-900">
+ SpecHomes <span className="text-teal-700">Admin</span>
+ </Link>
+ <Link href="/review" className="hover:text-teal-700">Review queue</Link>
+ <Link href="/sources" className="hover:text-teal-700">Sources</Link>
+ <Link href="/validation" className="hover:text-teal-700">Validation log</Link>
+ <Link href="/snapshots" className="hover:text-teal-700">Snapshots</Link>
+ <Link href="/corrections" className="hover:text-teal-700">Corrections</Link>
+ <Link href="/leads" className="hover:text-teal-700">Leads</Link>
+ </nav>
+ </header>
+ <main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
+ </body>
+ </html>
+ );
+}
diff --git a/apps/admin/src/app/leads/page.tsx b/apps/admin/src/app/leads/page.tsx
new file mode 100644
index 0000000..c9d8c4d
--- /dev/null
+++ b/apps/admin/src/app/leads/page.tsx
@@ -0,0 +1,36 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function LeadsPage() {
+ const leads = await prisma.lead.findMany({
+ orderBy: { createdAt: "desc" },
+ take: 100,
+ include: { home: { select: { street: true, city: true, state: true } } },
+ });
+
+ return (
+ <div>
+ <h1 className="text-2xl font-bold">Leads</h1>
+ <p className="mt-1 text-sm text-neutral-500">Builder inquiries recorded by the contact forms (delivery deferred in v1).</p>
+ <div className="mt-4 space-y-3">
+ {leads.map((lead) => (
+ <div key={lead.id} className="rounded-xl border border-neutral-200 bg-white p-4 text-sm shadow-sm">
+ <div className="flex items-center justify-between">
+ <span><span className="font-medium">{lead.name}</span> <span className="text-neutral-500">({lead.email}{lead.phone ? ` · ${lead.phone}` : ""})</span></span>
+ <CreatedChip date={lead.createdAt} />
+ </div>
+ {lead.home && (
+ <p className="mt-1 text-xs text-neutral-500">
+ Re: {lead.home.street}, {lead.home.city}, {lead.home.state}
+ </p>
+ )}
+ {lead.message && <p className="mt-2 text-neutral-700">{lead.message}</p>}
+ </div>
+ ))}
+ {leads.length === 0 && <p className="text-neutral-500">No leads yet.</p>}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx
new file mode 100644
index 0000000..3a9cc97
--- /dev/null
+++ b/apps/admin/src/app/page.tsx
@@ -0,0 +1,86 @@
+import Link from "next/link";
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function AdminDashboard() {
+ const [openReviews, publishedHomes, staleHomes, sources, openCorrections, recentLeads, recentEvents] =
+ await Promise.all([
+ prisma.reviewItem.count({ where: { status: "OPEN" } }),
+ prisma.inventoryHome.count({ where: { status: "PUBLISHED" } }),
+ prisma.inventoryHome.count({ where: { freshness: { in: ["STALE", "REVIEW_REQUIRED"] } } }),
+ prisma.sourceRegistry.findMany({ orderBy: { key: "asc" } }),
+ prisma.correctionReport.count({ where: { status: "OPEN" } }),
+ prisma.lead.findMany({ orderBy: { createdAt: "desc" }, take: 5 }),
+ prisma.validationEvent.findMany({ where: { passed: false }, orderBy: { runAt: "desc" }, take: 5 }),
+ ]);
+
+ const stats: [string, number | string, string][] = [
+ ["Published homes", publishedHomes, "/validation"],
+ ["Open review items", openReviews, "/review"],
+ ["Stale / review-required", staleHomes, "/validation"],
+ ["Open correction reports", openCorrections, "/corrections"],
+ ];
+
+ return (
+ <div>
+ <h1 className="text-2xl font-bold">Operations dashboard</h1>
+ <div className="mt-4 grid gap-4 sm:grid-cols-4">
+ {stats.map(([label, value, href]) => (
+ <Link key={label} href={href} className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm hover:border-teal-400">
+ <div className="text-3xl font-bold">{value}</div>
+ <div className="mt-1 text-sm text-neutral-500">{label}</div>
+ </Link>
+ ))}
+ </div>
+
+ <h2 className="mt-8 text-lg font-semibold">Source health</h2>
+ <div className="mt-3 grid gap-3 sm:grid-cols-3">
+ {sources.map((source) => (
+ <div key={source.id} className="rounded-lg border border-neutral-200 bg-white p-3 text-sm shadow-sm">
+ <div className="flex items-center justify-between">
+ <span className="font-semibold">{source.name}</span>
+ <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
+ source.health === "HEALTHY" ? "bg-emerald-100 text-emerald-800" :
+ source.health === "DEGRADED" ? "bg-amber-100 text-amber-800" :
+ "bg-red-100 text-red-800"}`}>
+ {source.health}
+ </span>
+ </div>
+ <div className="mt-1 text-xs text-neutral-500">{source.collectionMethod} · media rights: {source.mediaRights}</div>
+ <div className="mt-2"><CreatedChip date={source.createdAt} /></div>
+ </div>
+ ))}
+ </div>
+
+ <div className="mt-8 grid gap-6 lg:grid-cols-2">
+ <section>
+ <h2 className="text-lg font-semibold">Recent failed validations</h2>
+ <ul className="mt-2 space-y-2 text-sm">
+ {recentEvents.map((event) => (
+ <li key={event.id} className="rounded-lg border border-neutral-200 bg-white p-3 shadow-sm">
+ <span className="font-mono text-xs text-red-700">{event.ruleId}</span>
+ <p className="mt-0.5 text-neutral-600">{event.message}</p>
+ <div className="mt-1"><CreatedChip date={event.runAt} /></div>
+ </li>
+ ))}
+ {recentEvents.length === 0 && <li className="text-neutral-400">None.</li>}
+ </ul>
+ </section>
+ <section>
+ <h2 className="text-lg font-semibold">Recent leads</h2>
+ <ul className="mt-2 space-y-2 text-sm">
+ {recentLeads.map((lead) => (
+ <li key={lead.id} className="rounded-lg border border-neutral-200 bg-white p-3 shadow-sm">
+ <span className="font-medium">{lead.name}</span> <span className="text-neutral-500">({lead.email})</span>
+ <div className="mt-1"><CreatedChip date={lead.createdAt} /></div>
+ </li>
+ ))}
+ {recentLeads.length === 0 && <li className="text-neutral-400">None yet.</li>}
+ </ul>
+ </section>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/admin/src/app/review/ReviewActions.tsx b/apps/admin/src/app/review/ReviewActions.tsx
new file mode 100644
index 0000000..4954bec
--- /dev/null
+++ b/apps/admin/src/app/review/ReviewActions.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+export default function ReviewActions({
+ reviewItemId,
+ stagedRecordId,
+}: {
+ reviewItemId: string;
+ stagedRecordId: string;
+}) {
+ const router = useRouter();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const act = async (action: "approve" | "reject") => {
+ setBusy(true);
+ setError(null);
+ const res = await fetch("/api/review", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reviewItemId, stagedRecordId, action }),
+ });
+ setBusy(false);
+ if (!res.ok) {
+ setError((await res.json().catch(() => null))?.error ?? "action failed");
+ return;
+ }
+ router.refresh();
+ };
+
+ return (
+ <div className="mt-3 flex items-center gap-3">
+ <button onClick={() => act("approve")} disabled={busy}
+ className="rounded bg-emerald-700 px-4 py-1.5 text-sm font-medium text-white hover:bg-emerald-800 disabled:opacity-50"
+ data-testid="review-approve">
+ Approve & publish
+ </button>
+ <button onClick={() => act("reject")} disabled={busy}
+ className="rounded bg-red-700 px-4 py-1.5 text-sm font-medium text-white hover:bg-red-800 disabled:opacity-50"
+ data-testid="review-reject">
+ Reject
+ </button>
+ {error && <span className="text-sm text-red-600">{error}</span>}
+ </div>
+ );
+}
diff --git a/apps/admin/src/app/review/page.tsx b/apps/admin/src/app/review/page.tsx
new file mode 100644
index 0000000..5051809
--- /dev/null
+++ b/apps/admin/src/app/review/page.tsx
@@ -0,0 +1,88 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+import ReviewActions from "./ReviewActions";
+
+export const dynamic = "force-dynamic";
+
+/** Review queue: staged record + per-field evidence side-by-side, approve/reject. */
+export default async function ReviewQueuePage() {
+ const items = await prisma.reviewItem.findMany({
+ where: { status: "OPEN" },
+ orderBy: { createdAt: "asc" },
+ include: {
+ stagedRecord: {
+ include: {
+ evidence: { orderBy: { field: "asc" } },
+ events: { where: { passed: false } },
+ source: { select: { name: true, key: true } },
+ },
+ },
+ },
+ });
+
+ return (
+ <div>
+ <h1 className="text-2xl font-bold">Review queue</h1>
+ <p className="mt-1 text-sm text-neutral-500">
+ Records validators flagged for human judgment. Approving publishes; rejecting keeps them out.
+ </p>
+ {items.length === 0 && (
+ <p className="mt-8 rounded-lg border border-neutral-200 bg-white p-6 text-center text-neutral-500" data-testid="review-empty">
+ Queue is clear.
+ </p>
+ )}
+ <div className="mt-4 space-y-4">
+ {items.map((item) => {
+ const staged = item.stagedRecord;
+ const payload = (staged?.payload ?? {}) as Record<string, { value: unknown; raw: string | null }>;
+ return (
+ <div key={item.id} className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm" data-testid="review-card">
+ <div className="flex flex-wrap items-center justify-between gap-2">
+ <div>
+ <span className="font-semibold">{staged?.entityType ?? "—"}</span>
+ <span className="ml-2 font-mono text-xs text-neutral-400">{staged?.canonicalKey.slice(0, 12)}…</span>
+ <span className="ml-2 text-xs text-neutral-500">{staged?.source.name}</span>
+ </div>
+ {/* Standing rule: every admin card shows created date + time. */}
+ <CreatedChip date={item.createdAt} />
+ </div>
+ <p className="mt-2 rounded bg-amber-50 p-2 text-sm text-amber-900">{item.reason}</p>
+
+ <div className="mt-3 grid gap-4 md:grid-cols-2">
+ <div>
+ <h3 className="text-xs font-semibold uppercase text-neutral-500">Extracted values</h3>
+ <table className="mt-1 w-full text-xs">
+ <tbody>
+ {Object.entries(payload).map(([field, envelope]) => (
+ <tr key={field} className="border-b border-neutral-100">
+ <td className="py-1 pr-2 font-medium">{field}</td>
+ <td className="py-1">{envelope.value === null ? <em className="text-neutral-400">null</em> : String(envelope.value)}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ <div>
+ <h3 className="text-xs font-semibold uppercase text-neutral-500">Source evidence</h3>
+ <table className="mt-1 w-full text-xs">
+ <tbody>
+ {(staged?.evidence ?? []).map((row) => (
+ <tr key={row.id} className="border-b border-neutral-100">
+ <td className="py-1 pr-2 font-medium">{row.field}</td>
+ <td className="py-1 text-neutral-600">{row.evidenceText ?? <em className="text-neutral-400">not stated</em>}</td>
+ <td className="py-1 pl-2 text-neutral-400">{(row.confidence * 100).toFixed(0)}%</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ {staged && <ReviewActions reviewItemId={item.id} stagedRecordId={staged.id} />}
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/admin/src/app/snapshots/page.tsx b/apps/admin/src/app/snapshots/page.tsx
new file mode 100644
index 0000000..100cc52
--- /dev/null
+++ b/apps/admin/src/app/snapshots/page.tsx
@@ -0,0 +1,45 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function SnapshotsPage() {
+ const snapshots = await prisma.rawSnapshot.findMany({
+ orderBy: { retrievedAt: "desc" },
+ take: 100,
+ include: { source: { select: { name: true } }, _count: { select: { staged: true } } },
+ });
+
+ return (
+ <div>
+ <h1 className="text-2xl font-bold">Raw snapshots</h1>
+ <p className="mt-1 text-sm text-neutral-500">
+ Immutable fetched bodies (stored on disk) — the audit trail behind every extraction.
+ </p>
+ <div className="mt-4 overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm">
+ <table className="w-full text-left text-sm">
+ <thead className="border-b border-neutral-200 text-xs uppercase text-neutral-500">
+ <tr>
+ <th className="px-3 py-2">Source</th>
+ <th className="px-3 py-2">URL</th>
+ <th className="px-3 py-2">Hash</th>
+ <th className="px-3 py-2">Staged</th>
+ <th className="px-3 py-2">Retrieved</th>
+ </tr>
+ </thead>
+ <tbody>
+ {snapshots.map((snapshot) => (
+ <tr key={snapshot.id} className="border-b border-neutral-100">
+ <td className="px-3 py-1.5 text-xs">{snapshot.source.name}</td>
+ <td className="max-w-md truncate px-3 py-1.5 text-xs text-neutral-600">{snapshot.url}</td>
+ <td className="px-3 py-1.5 font-mono text-xs">{snapshot.contentHash.slice(0, 12)}…</td>
+ <td className="px-3 py-1.5">{snapshot._count.staged}</td>
+ <td className="px-3 py-1.5"><CreatedChip date={snapshot.retrievedAt} /></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/admin/src/app/sources/page.tsx b/apps/admin/src/app/sources/page.tsx
new file mode 100644
index 0000000..1064f9b
--- /dev/null
+++ b/apps/admin/src/app/sources/page.tsx
@@ -0,0 +1,49 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function SourcesPage() {
+ const sources = await prisma.sourceRegistry.findMany({
+ orderBy: { key: "asc" },
+ include: { _count: { select: { snapshots: true, staged: true, homes: true } } },
+ });
+
+ return (
+ <div>
+ <h1 className="text-2xl font-bold">Source registry</h1>
+ <p className="mt-1 text-sm text-neutral-500">
+ Every source records its collection method, permissions, media rights, rate limits, and health.
+ Publishing stops automatically when a source degrades.
+ </p>
+ <div className="mt-4 grid gap-4 md:grid-cols-2">
+ {sources.map((source) => (
+ <div key={source.id} className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm" data-testid="source-card">
+ <div className="flex items-center justify-between">
+ <h2 className="font-semibold">{source.name}</h2>
+ <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
+ source.health === "HEALTHY" ? "bg-emerald-100 text-emerald-800" :
+ source.health === "DEGRADED" ? "bg-amber-100 text-amber-800" :
+ source.health === "PAUSED" ? "bg-neutral-200 text-neutral-600" :
+ "bg-red-100 text-red-800"}`}>
+ {source.health}
+ </span>
+ </div>
+ <dl className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
+ <div><dt className="text-neutral-500">Key</dt><dd className="font-mono">{source.key}</dd></div>
+ <div><dt className="text-neutral-500">Method</dt><dd>{source.collectionMethod}</dd></div>
+ <div><dt className="text-neutral-500">Media rights</dt><dd>{source.mediaRights}</dd></div>
+ <div><dt className="text-neutral-500">Rate limit</dt><dd>{source.rateLimitRpm ? `${source.rateLimitRpm}/min` : "n/a"}</dd></div>
+ <div><dt className="text-neutral-500">Fresh interval</dt><dd>{source.freshIntervalHours}h</dd></div>
+ <div><dt className="text-neutral-500">Aging interval</dt><dd>{source.agingIntervalHours}h</dd></div>
+ <div><dt className="text-neutral-500">Snapshots</dt><dd>{source._count.snapshots}</dd></div>
+ <div><dt className="text-neutral-500">Staged / homes</dt><dd>{source._count.staged} / {source._count.homes}</dd></div>
+ </dl>
+ {source.notes && <p className="mt-2 text-xs text-neutral-500">{source.notes}</p>}
+ <div className="mt-2"><CreatedChip date={source.createdAt} /></div>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/admin/src/app/validation/page.tsx b/apps/admin/src/app/validation/page.tsx
new file mode 100644
index 0000000..9deff0d
--- /dev/null
+++ b/apps/admin/src/app/validation/page.tsx
@@ -0,0 +1,60 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function ValidationLogPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ failed?: string }>;
+}) {
+ const { failed } = await searchParams;
+ const onlyFailed = failed === "1";
+ const events = await prisma.validationEvent.findMany({
+ where: onlyFailed ? { passed: false } : {},
+ orderBy: { runAt: "desc" },
+ take: 100,
+ include: { stagedRecord: { select: { entityType: true, canonicalKey: true } } },
+ });
+
+ return (
+ <div>
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-bold">Validation events</h1>
+ <a href={onlyFailed ? "/validation" : "/validation?failed=1"} className="text-sm text-teal-700 underline">
+ {onlyFailed ? "Show all" : "Failed only"}
+ </a>
+ </div>
+ <div className="mt-4 overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm">
+ <table className="w-full text-left text-sm">
+ <thead className="border-b border-neutral-200 text-xs uppercase text-neutral-500">
+ <tr>
+ <th className="px-3 py-2">Rule</th>
+ <th className="px-3 py-2">Severity</th>
+ <th className="px-3 py-2">Result</th>
+ <th className="px-3 py-2">Entity</th>
+ <th className="px-3 py-2">Message</th>
+ <th className="px-3 py-2">Run</th>
+ </tr>
+ </thead>
+ <tbody>
+ {events.map((event) => (
+ <tr key={event.id} className="border-b border-neutral-100">
+ <td className="px-3 py-1.5 font-mono text-xs">{event.ruleId}</td>
+ <td className="px-3 py-1.5">{event.severity}</td>
+ <td className="px-3 py-1.5">
+ <span className={event.passed ? "text-emerald-700" : "font-semibold text-red-700"}>
+ {event.passed ? "pass" : "FAIL"}
+ </span>
+ </td>
+ <td className="px-3 py-1.5 text-xs">{event.stagedRecord.entityType}</td>
+ <td className="px-3 py-1.5 text-xs text-neutral-600">{event.message ?? "—"}</td>
+ <td className="px-3 py-1.5"><CreatedChip date={event.runAt} /></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts
new file mode 100644
index 0000000..a263729
--- /dev/null
+++ b/apps/admin/src/middleware.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from "next/server";
+
+/**
+ * HTTP Basic Auth for the whole admin app (fleet next-auth-gate convention).
+ * Credentials from BASIC_AUTH env ("user:pass").
+ */
+const [USER, PASS] = (process.env.BASIC_AUTH ?? "admin:REDACTED_ADMIN_PW").split(":");
+
+export function middleware(request: NextRequest) {
+ const header = request.headers.get("authorization");
+ if (header?.startsWith("Basic ")) {
+ const [user, pass] = Buffer.from(header.slice(6), "base64").toString().split(":");
+ if (user === USER && pass === PASS) return NextResponse.next();
+ }
+ return new NextResponse("Authentication required", {
+ status: 401,
+ headers: { "WWW-Authenticate": 'Basic realm="SpecHomes Admin"' },
+ });
+}
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
+};
diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json
index b8eeb84..840f56f 100644
--- a/apps/admin/tsconfig.json
+++ b/apps/admin/tsconfig.json
@@ -5,16 +5,42 @@
"allowJs": true,
"incremental": true,
"module": "esnext",
- "plugins": [{ "name": "next" }],
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
"paths": {
- "@/*": ["./src/*"],
- "@spechomes/database": ["../../packages/database/src/index.ts"],
- "@spechomes/schemas": ["../../packages/schemas/src/index.ts"],
- "@spechomes/search": ["../../packages/search/src/index.ts"],
- "@spechomes/shared": ["../../packages/shared/src/index.ts"],
- "@spechomes/shared-ui": ["../../packages/shared-ui/src/index.ts"]
+ "@/*": [
+ "./src/*"
+ ],
+ "@spechomes/database": [
+ "../../packages/database/src/index.ts"
+ ],
+ "@spechomes/schemas": [
+ "../../packages/schemas/src/index.ts"
+ ],
+ "@spechomes/search": [
+ "../../packages/search/src/index.ts"
+ ],
+ "@spechomes/shared": [
+ "../../packages/shared/src/index.ts"
+ ],
+ "@spechomes/shared-ui": [
+ "../../packages/shared-ui/src/index.ts"
+ ],
+ "@spechomes/publisher": [
+ "../../packages/publisher/src/index.ts"
+ ]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
new file mode 100644
index 0000000..c4b7818
--- /dev/null
+++ b/apps/web/next-env.d.ts
@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index c63dd4e..9be57ea 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -12,6 +12,7 @@ const nextConfig: NextConfig = {
"@spechomes/search",
"@spechomes/shared",
"@spechomes/shared-ui",
+ "@spechomes/publisher",
],
};
diff --git a/apps/web/src/app/[state]/[city]/page.tsx b/apps/web/src/app/[state]/[city]/page.tsx
new file mode 100644
index 0000000..c66ddd3
--- /dev/null
+++ b/apps/web/src/app/[state]/[city]/page.tsx
@@ -0,0 +1,45 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+/** Minimal city/metro SEO landing — full editorial treatment deferred. */
+export default async function CityLandingPage({
+ params,
+}: {
+ params: Promise<{ state: string; city: string }>;
+}) {
+ const { state, city } = await params;
+ const stateUpper = state.toUpperCase();
+ if (!/^[a-z]{2}$/i.test(state)) notFound();
+ const cityName = decodeURIComponent(city).replace(/-/g, " ");
+
+ const homes = await prisma.inventoryHome.findMany({
+ where: { state: stateUpper, city: { equals: cityName, mode: "insensitive" }, status: "PUBLISHED" },
+ include: { community: { select: { slug: true, name: true } }, builder: { select: { name: true } } },
+ orderBy: { publishedAt: "desc" },
+ take: 24,
+ });
+ if (homes.length === 0) notFound();
+
+ return (
+ <div className="mx-auto max-w-6xl px-4 py-8">
+ <h1 className="text-3xl font-bold">
+ New construction homes in {homes[0]!.city}, {stateUpper}
+ </h1>
+ <p className="mt-1 text-sm text-neutral-500">{homes.length} available demonstration homes</p>
+ <div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+ {homes.map((home) => (
+ <Link key={home.id} href={`/homes/${home.id}`}
+ className="rounded-xl border border-neutral-200 p-4 shadow-sm hover:border-teal-400">
+ <div className="font-semibold">{fmtPrice(home.price === null ? null : Number(home.price))}</div>
+ <div className="text-sm text-neutral-600">{home.street}</div>
+ <div className="text-sm text-neutral-500">{home.community.name} · {home.builder.name}</div>
+ </Link>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/api/corrections/route.ts b/apps/web/src/app/api/corrections/route.ts
new file mode 100644
index 0000000..70da4ca
--- /dev/null
+++ b/apps/web/src/app/api/corrections/route.ts
@@ -0,0 +1,21 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@spechomes/database";
+
+const correctionSchema = z.object({
+ entityType: z.enum(["INVENTORY_HOME", "COMMUNITY", "FLOOR_PLAN", "INCENTIVE", "BUILDER"]),
+ entityId: z.string().min(1),
+ field: z.string().max(100).optional(),
+ message: z.string().min(3).max(2000),
+ reporterEmail: z.string().email().optional(),
+});
+
+export async function POST(request: NextRequest) {
+ const body = await request.json().catch(() => null);
+ const parsed = correctionSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ error: "invalid report", issues: parsed.error.issues }, { status: 400 });
+ }
+ const report = await prisma.correctionReport.create({ data: parsed.data });
+ return NextResponse.json({ ok: true, id: report.id });
+}
diff --git a/apps/web/src/app/api/facets/route.ts b/apps/web/src/app/api/facets/route.ts
new file mode 100644
index 0000000..2346a62
--- /dev/null
+++ b/apps/web/src/app/api/facets/route.ts
@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from "next/server";
+import { runFacets } from "@spechomes/search";
+import { parseSearchParams } from "@/lib/parse-search";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const params = parseSearchParams(request.nextUrl.searchParams);
+ const facets = await runFacets(params);
+ return NextResponse.json(facets);
+}
diff --git a/apps/web/src/app/api/leads/route.ts b/apps/web/src/app/api/leads/route.ts
new file mode 100644
index 0000000..55bdfad
--- /dev/null
+++ b/apps/web/src/app/api/leads/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@spechomes/database";
+
+const leadSchema = z.object({
+ name: z.string().min(1).max(200),
+ email: z.string().email(),
+ phone: z.string().max(30).optional(),
+ message: z.string().max(2000).optional(),
+ homeId: z.string().optional(),
+ communityId: z.string().optional(),
+});
+
+export async function POST(request: NextRequest) {
+ const body = await request.json().catch(() => null);
+ const parsed = leadSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ error: "invalid lead", issues: parsed.error.issues }, { status: 400 });
+ }
+ const lead = await prisma.lead.create({ data: parsed.data });
+ // Lead DELIVERY to builders is a later stage — v1 records the lead only.
+ return NextResponse.json({ ok: true, id: lead.id });
+}
diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts
new file mode 100644
index 0000000..d3e6e86
--- /dev/null
+++ b/apps/web/src/app/api/search/route.ts
@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from "next/server";
+import { runSearch } from "@spechomes/search";
+import { parseSearchParams } from "@/lib/parse-search";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const params = parseSearchParams(request.nextUrl.searchParams);
+ const result = await runSearch(params);
+ return NextResponse.json(result);
+}
diff --git a/apps/web/src/app/builders/[slug]/page.tsx b/apps/web/src/app/builders/[slug]/page.tsx
new file mode 100644
index 0000000..fdda37a
--- /dev/null
+++ b/apps/web/src/app/builders/[slug]/page.tsx
@@ -0,0 +1,60 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function BuilderPage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const builder = await prisma.builder.findUnique({
+ where: { slug },
+ include: {
+ divisions: true,
+ communities: { include: { _count: { select: { homes: { where: { status: "PUBLISHED" } } } } } },
+ },
+ });
+ if (!builder) notFound();
+
+ const homeCount = await prisma.inventoryHome.count({ where: { builderId: builder.id, status: "PUBLISHED" } });
+
+ return (
+ <div className="mx-auto max-w-5xl px-4 py-8">
+ <h1 className="text-3xl font-bold">{builder.name}</h1>
+ <p className="mt-1 text-sm text-neutral-500">
+ {builder.coverageArea} · {homeCount} available homes · {builder.communities.length} communities
+ </p>
+ <p className="mt-2 text-xs text-neutral-400">
+ Listing on SpecHomes does not imply sponsorship or endorsement by the builder.
+ </p>
+
+ <section className="mt-8">
+ <h2 className="text-xl font-semibold">Communities</h2>
+ <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+ {builder.communities.map((community) => (
+ <Link key={community.id} href={`/communities/${community.slug}`}
+ className="rounded-xl border border-neutral-200 p-4 shadow-sm hover:border-teal-400">
+ <div className="font-semibold">{community.name}</div>
+ <div className="text-sm text-neutral-500">{community.city}, {community.state}</div>
+ <div className="mt-1 text-sm text-neutral-500">
+ {community._count.homes} homes
+ {community.priceMin ? ` · from ${fmtPrice(Number(community.priceMin))}` : ""}
+ </div>
+ </Link>
+ ))}
+ </div>
+ </section>
+
+ {builder.divisions.length > 0 && (
+ <section className="mt-8 text-sm text-neutral-600">
+ <h2 className="text-xl font-semibold text-neutral-900">Divisions</h2>
+ <ul className="mt-2 list-inside list-disc">
+ {builder.divisions.map((division) => (
+ <li key={division.id}>{division.name} — {division.region} ({division.states.join(", ")})</li>
+ ))}
+ </ul>
+ </section>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/app/communities/[slug]/page.tsx b/apps/web/src/app/communities/[slug]/page.tsx
new file mode 100644
index 0000000..daa47c1
--- /dev/null
+++ b/apps/web/src/app/communities/[slug]/page.tsx
@@ -0,0 +1,104 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice, VerificationBadge } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function CommunityPage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const community = await prisma.community.findUnique({
+ where: { slug },
+ include: {
+ builder: true,
+ floorPlans: true,
+ salesOffices: true,
+ incentives: true,
+ homes: { where: { status: "PUBLISHED" }, orderBy: { publishedAt: "desc" } },
+ },
+ });
+ if (!community) notFound();
+
+ return (
+ <div className="mx-auto max-w-6xl px-4 py-8">
+ <h1 className="text-3xl font-bold">{community.name}</h1>
+ <p className="mt-1 text-neutral-600">
+ {community.city}, {community.state} {community.zip} · {community.metro} ·{" "}
+ <Link href={`/builders/${community.builder.slug}`} className="text-teal-700 hover:underline">
+ {community.builder.name}
+ </Link>
+ </p>
+ <div className="mt-2 flex flex-wrap gap-4 text-sm text-neutral-600">
+ {community.priceMin && community.priceMax && (
+ <span>From {fmtPrice(Number(community.priceMin))} to {fmtPrice(Number(community.priceMax))}</span>
+ )}
+ {community.schoolDistrict && <span>School district: {community.schoolDistrict}</span>}
+ <span>
+ HOA:{" "}
+ {community.hoaFeeMonthly ? `$${Number(community.hoaFeeMonthly)}/mo` : community.hoaRequired === false ? "None" : "Not published"}
+ </span>
+ {community.ageRestricted && <span className="font-medium text-violet-700">Age-restricted community</span>}
+ </div>
+
+ {community.incentives.length > 0 && (
+ <section className="mt-6">
+ <h2 className="text-xl font-semibold">Current offers</h2>
+ <ul className="mt-2 grid gap-3 sm:grid-cols-2">
+ {community.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>
+ <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="text-xl font-semibold">Available homes ({community.homes.length})</h2>
+ <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+ {community.homes.map((home) => (
+ <Link key={home.id} href={`/homes/${home.id}`}
+ className="rounded-xl border border-neutral-200 p-4 shadow-sm hover:border-teal-400">
+ <div className="font-semibold">{fmtPrice(home.price === null ? null : Number(home.price))}</div>
+ <div className="mt-0.5 text-sm text-neutral-600">{home.street}</div>
+ <div className="text-sm text-neutral-500">
+ {home.beds ?? "—"} bd · {home.bathsTotal?.toString() ?? "—"} ba · {home.sqft?.toLocaleString() ?? "—"} sqft
+ </div>
+ <div className="mt-2"><VerificationBadge label={home.verificationLabel} /></div>
+ </Link>
+ ))}
+ </div>
+ </section>
+
+ <section className="mt-8">
+ <h2 className="text-xl font-semibold">Floor plans</h2>
+ <div className="mt-3 grid gap-4 sm:grid-cols-3">
+ {community.floorPlans.map((plan) => (
+ <Link key={plan.id} href={`/plans/${plan.id}`}
+ className="rounded-lg border border-neutral-200 p-3 text-sm hover:border-teal-400">
+ <div className="font-semibold">{plan.name}</div>
+ <div className="text-neutral-500">
+ {plan.beds}–{plan.bedsMax ?? plan.beds} bd · {plan.sqft?.toLocaleString()} sqft · from{" "}
+ {fmtPrice(plan.basePrice === null ? null : Number(plan.basePrice))}
+ </div>
+ </Link>
+ ))}
+ </div>
+ </section>
+
+ {community.salesOffices.length > 0 && (
+ <section className="mt-8 text-sm text-neutral-600">
+ <h2 className="text-xl font-semibold text-neutral-900">Sales office</h2>
+ {community.salesOffices.map((office) => (
+ <p key={office.id} className="mt-2">
+ {office.street}, {office.city}, {office.state} {office.zip} · {office.phone}
+ </p>
+ ))}
+ </section>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/app/compare/page.tsx b/apps/web/src/app/compare/page.tsx
new file mode 100644
index 0000000..792bb2e
--- /dev/null
+++ b/apps/web/src/app/compare/page.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { fmtPrice } from "@spechomes/shared-ui";
+
+interface Home {
+ id: string;
+ street: string | null;
+ city: string;
+ state: string;
+ price: string | null;
+ beds: number | null;
+ bathsTotal: string | null;
+ sqft: number | null;
+ stories: number | null;
+ garageSpaces: number | null;
+ constructionStatus: string;
+ community: { name: string };
+ builder: { name: string };
+}
+
+/** v1 stub: compares homes saved to localStorage from search/detail pages. */
+export default function ComparePage() {
+ const [homes, setHomes] = useState<Home[]>([]);
+ const [loaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ const ids: string[] = JSON.parse(localStorage.getItem("spechomes.compare") ?? "[]");
+ if (ids.length === 0) {
+ setLoaded(true);
+ return;
+ }
+ Promise.all(
+ ids.slice(0, 4).map((id) =>
+ fetch(`/api/search?pageSize=1&page=1`).then(() => null).catch(() => null),
+ ),
+ ).finally(() => setLoaded(true));
+ // v1: full compare fetch lands with saved-homes accounts; render IDs for now.
+ setHomes([]);
+ }, []);
+
+ return (
+ <div className="mx-auto max-w-4xl px-4 py-10">
+ <h1 className="text-3xl font-bold">Compare homes</h1>
+ {loaded && homes.length === 0 && (
+ <p className="mt-4 text-neutral-600">
+ Nothing to compare yet. Browse the <Link href="/search" className="text-teal-700 underline">search page</Link>{" "}
+ and add homes — side-by-side comparison arrives with saved-home accounts (deferred in v1).
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/app/contact/ContactClient.tsx b/apps/web/src/app/contact/ContactClient.tsx
new file mode 100644
index 0000000..ff25fb8
--- /dev/null
+++ b/apps/web/src/app/contact/ContactClient.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+
+interface CommunityOption {
+ id: string;
+ name: string;
+ city: string;
+ state: string;
+ builder: { name: string };
+}
+
+export default function ContactClient({ communities }: { communities: CommunityOption[] }) {
+ const [form, setForm] = useState({ name: "", email: "", communityId: "", message: "" });
+ const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");
+
+ return (
+ <div className="mx-auto max-w-lg px-4 py-10">
+ <h1 className="text-3xl font-bold">Contact a builder</h1>
+ <p className="mt-2 text-sm text-neutral-600">
+ Your inquiry goes to the builder's sales team for the community you pick. Demonstration build — no real
+ builder receives anything.
+ </p>
+ {state === "done" ? (
+ <div className="mt-6 rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-emerald-800">
+ Request recorded. A builder representative would follow up.
+ </div>
+ ) : (
+ <form
+ className="mt-6 space-y-3"
+ onSubmit={async (e) => {
+ e.preventDefault();
+ setState("sending");
+ const res = await fetch("/api/leads", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: form.name,
+ email: form.email,
+ communityId: form.communityId || undefined,
+ message: form.message || undefined,
+ }),
+ });
+ setState(res.ok ? "done" : "error");
+ }}
+ >
+ <input required placeholder="Name" value={form.name}
+ onChange={(e) => setForm({ ...form, name: e.target.value })}
+ className="w-full rounded border border-neutral-300 px-3 py-2" />
+ <input required type="email" placeholder="Email" value={form.email}
+ onChange={(e) => setForm({ ...form, email: e.target.value })}
+ className="w-full rounded border border-neutral-300 px-3 py-2" />
+ <select value={form.communityId} onChange={(e) => setForm({ ...form, communityId: e.target.value })}
+ className="w-full rounded border border-neutral-300 px-3 py-2">
+ <option value="">Any community</option>
+ {communities.map((c) => (
+ <option key={c.id} value={c.id}>
+ {c.name} — {c.city}, {c.state} ({c.builder.name})
+ </option>
+ ))}
+ </select>
+ <textarea placeholder="What would you like to know?" rows={4} value={form.message}
+ onChange={(e) => setForm({ ...form, message: e.target.value })}
+ className="w-full rounded border border-neutral-300 px-3 py-2" />
+ <button type="submit" disabled={state === "sending"}
+ className="w-full rounded-lg bg-teal-700 py-2.5 font-medium text-white hover:bg-teal-800 disabled:opacity-50">
+ {state === "sending" ? "Sending…" : "Send inquiry"}
+ </button>
+ {state === "error" && <p className="text-sm text-red-600">Could not send — check the fields.</p>}
+ </form>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/app/contact/page.tsx b/apps/web/src/app/contact/page.tsx
new file mode 100644
index 0000000..0197605
--- /dev/null
+++ b/apps/web/src/app/contact/page.tsx
@@ -0,0 +1,12 @@
+import { prisma } from "@spechomes/database";
+import ContactClient from "./ContactClient";
+
+export const dynamic = "force-dynamic";
+
+export default async function ContactPage() {
+ const communities = await prisma.community.findMany({
+ select: { id: true, name: true, city: true, state: true, builder: { select: { name: true } } },
+ orderBy: [{ state: "asc" }, { name: "asc" }],
+ });
+ return <ContactClient communities={communities} />;
+}
diff --git a/apps/web/src/app/homes/[id]/CorrectionForm.tsx b/apps/web/src/app/homes/[id]/CorrectionForm.tsx
new file mode 100644
index 0000000..c27b894
--- /dev/null
+++ b/apps/web/src/app/homes/[id]/CorrectionForm.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState } from "react";
+
+/** "Report a problem" — the consumer-facing correction control. */
+export default function CorrectionForm({ entityId }: { entityId: string }) {
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState("");
+ const [email, setEmail] = useState("");
+ const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");
+
+ if (state === "done") {
+ return <p className="text-sm text-emerald-700" data-testid="correction-done">Thanks — your report is in our review queue.</p>;
+ }
+
+ return (
+ <div>
+ <button
+ type="button"
+ onClick={() => setOpen((v) => !v)}
+ className="text-sm text-neutral-500 underline hover:text-teal-700"
+ data-testid="correction-toggle"
+ >
+ Report a problem with this listing
+ </button>
+ {open && (
+ <form
+ className="mt-3 max-w-md space-y-2 rounded-lg border border-neutral-200 p-3 text-sm"
+ onSubmit={async (e) => {
+ e.preventDefault();
+ setState("sending");
+ const res = await fetch("/api/corrections", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ entityType: "INVENTORY_HOME",
+ entityId,
+ message,
+ reporterEmail: email || undefined,
+ }),
+ });
+ setState(res.ok ? "done" : "error");
+ }}
+ >
+ <textarea
+ required
+ minLength={3}
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ placeholder="What looks wrong? (price, availability, address…)"
+ className="w-full rounded border border-neutral-300 px-2 py-1"
+ rows={3}
+ data-testid="correction-message"
+ />
+ <input
+ type="email"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ placeholder="Your email (optional)"
+ className="w-full rounded border border-neutral-300 px-2 py-1"
+ />
+ <button
+ type="submit"
+ disabled={state === "sending"}
+ className="rounded bg-neutral-800 px-4 py-1.5 text-white disabled:opacity-50"
+ data-testid="correction-submit"
+ >
+ {state === "sending" ? "Sending…" : "Send report"}
+ </button>
+ {state === "error" && <p className="text-red-600">Could not send — try again.</p>}
+ </form>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/app/homes/[id]/LeadForm.tsx b/apps/web/src/app/homes/[id]/LeadForm.tsx
new file mode 100644
index 0000000..f30809c
--- /dev/null
+++ b/apps/web/src/app/homes/[id]/LeadForm.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useState } from "react";
+
+export default function LeadForm({
+ homeId,
+ communityId,
+ builderName,
+}: {
+ homeId: string;
+ communityId: string;
+ builderName: string;
+}) {
+ const [form, setForm] = useState({ name: "", email: "", phone: "", message: "" });
+ const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");
+
+ if (state === "done") {
+ return (
+ <div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800" data-testid="lead-done">
+ Request sent. {builderName} (demonstration) would receive this inquiry.
+ </div>
+ );
+ }
+
+ return (
+ <form
+ className="rounded-xl border border-neutral-200 p-4 shadow-sm"
+ onSubmit={async (e) => {
+ e.preventDefault();
+ setState("sending");
+ const res = await fetch("/api/leads", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...form, phone: form.phone || undefined, message: form.message || undefined, homeId, communityId }),
+ });
+ setState(res.ok ? "done" : "error");
+ }}
+ >
+ <h2 className="font-semibold">Request info or a tour</h2>
+ <p className="mt-1 text-xs text-neutral-500">Sent to {builderName}. No obligation.</p>
+ <div className="mt-3 space-y-2 text-sm">
+ <input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
+ placeholder="Name" className="w-full rounded border border-neutral-300 px-2 py-1.5" data-testid="lead-name" />
+ <input required type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
+ placeholder="Email" className="w-full rounded border border-neutral-300 px-2 py-1.5" data-testid="lead-email" />
+ <input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })}
+ placeholder="Phone (optional)" className="w-full rounded border border-neutral-300 px-2 py-1.5" />
+ <textarea value={form.message} onChange={(e) => setForm({ ...form, message: e.target.value })}
+ placeholder="Questions? Preferred tour time?" rows={3} className="w-full rounded border border-neutral-300 px-2 py-1.5" />
+ <button type="submit" disabled={state === "sending"}
+ className="w-full rounded-lg bg-teal-700 py-2 font-medium text-white hover:bg-teal-800 disabled:opacity-50"
+ data-testid="lead-submit">
+ {state === "sending" ? "Sending…" : "Contact builder"}
+ </button>
+ {state === "error" && <p className="text-red-600">Could not send — check the fields and try again.</p>}
+ </div>
+ </form>
+ );
+}
diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
new file mode 100644
index 0000000..9aafc01
--- /dev/null
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -0,0 +1,181 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice, VerificationBadge } from "@spechomes/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",
+};
+
+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") notFound();
+
+ const [communityIncentives, evidence] = 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" },
+ }),
+ ]);
+ const incentives = [...home.incentives, ...communityIncentives];
+
+ 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-teal-700">Search</Link> ·{" "}
+ <Link href={`/communities/${home.community.slug}`} className="hover:text-teal-700">
+ {home.community.name}
+ </Link>
+ </nav>
+
+ <div className="mt-3 flex flex-wrap items-start justify-between gap-3">
+ <div>
+ <h1 className="text-3xl font-bold">{home.street ?? "Address available from builder"}</h1>
+ <p className="mt-1 text-neutral-600">
+ {home.city}, {home.state} {home.zip} · {home.community.name} ·{" "}
+ <Link href={`/builders/${home.builder.slug}`} className="text-teal-700 hover:underline">
+ {home.builder.name}
+ </Link>
+ </p>
+ </div>
+ <div className="text-right">
+ <div className="text-3xl font-bold text-teal-800" 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-neutral-400 line-through">{fmtPrice(Number(home.previousPrice))}</div>
+ ) : null}
+ </div>
+ </div>
+
+ {/* Verification block — the trust surface. */}
+ <div className="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-sm" 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-teal-700 hover:underline">
+ Original source
+ </a>
+ ) : null}
+ </div>
+
+ <div className="mt-6 grid gap-8 md:grid-cols-[1fr_320px]">
+ <div>
+ <h2 className="text-xl font-semibold">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. SpecHomes never fills in missing values.
+ </p>
+
+ {incentives.length > 0 && (
+ <section className="mt-8">
+ <h2 className="text-xl font-semibold">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="text-xl font-semibold">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>
+ {evidence.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>
+ ))}
+ {evidence.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>
+ <LeadForm homeId={home.id} communityId={home.communityId} builderName={home.builder.name} />
+ </aside>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
new file mode 100644
index 0000000..2cd57b5
--- /dev/null
+++ b/apps/web/src/app/layout.tsx
@@ -0,0 +1,43 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "SpecHomes — Every new home. Every builder. One search.",
+ description:
+ "Discover, compare, and contact builders for newly constructed homes across the United States. Verified from the source.",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="en">
+ <body className="min-h-screen bg-white text-neutral-900 antialiased">
+ {/* All inventory in this build is synthetic — never present demo data as real. */}
+ <div className="bg-violet-700 px-4 py-1.5 text-center text-xs font-medium text-white">
+ Demonstration inventory — all homes, builders, communities, and offers shown are synthetic sample
+ data, clearly labeled. No real listings.
+ </div>
+ <header className="border-b border-neutral-200">
+ <nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
+ <Link href="/" className="text-xl font-bold tracking-tight text-teal-800">
+ SpecHomes<span className="text-neutral-400">.com</span>
+ </Link>
+ <div className="flex items-center gap-5 text-sm text-neutral-700">
+ <Link href="/search" className="hover:text-teal-700">Search</Link>
+ <Link href="/compare" className="hover:text-teal-700">Compare</Link>
+ <Link href="/saved" className="hover:text-teal-700">Saved</Link>
+ <Link href="/contact" className="hover:text-teal-700">Contact a builder</Link>
+ </div>
+ </nav>
+ </header>
+ <main>{children}</main>
+ <footer className="mt-16 border-t border-neutral-200 py-8 text-center text-xs text-neutral-500">
+ <p>Every new home. Every builder. One search. Verified from the source.</p>
+ <p className="mt-1">
+ Builder inclusion never implies sponsorship. Organic results are never mixed with paid placement.
+ </p>
+ </footer>
+ </body>
+ </html>
+ );
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
new file mode 100644
index 0000000..6fc5901
--- /dev/null
+++ b/apps/web/src/app/page.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import { prisma } from "@spechomes/database";
+
+export const dynamic = "force-dynamic";
+
+export default async function HomePage() {
+ const markets = await prisma.community.groupBy({
+ by: ["metro", "state"],
+ _count: { _all: true },
+ });
+ const homeCount = await prisma.inventoryHome.count({ where: { status: "PUBLISHED" } });
+ const builderCount = await prisma.builder.count();
+
+ return (
+ <div>
+ <section className="bg-gradient-to-b from-teal-900 to-teal-700 px-4 py-20 text-white">
+ <div className="mx-auto max-w-3xl text-center">
+ <h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
+ Every new home. Every builder. One search.
+ </h1>
+ <p className="mt-4 text-lg text-teal-100">
+ Move-in-ready and under-construction homes, verified from the source.
+ </p>
+ <form action="/search" method="get" className="mx-auto mt-8 flex max-w-xl gap-2">
+ <input
+ type="text"
+ name="q"
+ placeholder="City, ZIP, or community — try “Leander” or “85212”"
+ className="w-full rounded-lg border-0 px-4 py-3 text-neutral-900 shadow-lg focus:ring-2 focus:ring-teal-300"
+ />
+ <button
+ type="submit"
+ className="rounded-lg bg-white px-6 py-3 font-semibold text-teal-800 shadow-lg hover:bg-teal-50"
+ >
+ Search
+ </button>
+ </form>
+ <p className="mt-4 text-sm text-teal-200">
+ {homeCount} verified demonstration homes · {builderCount} builders · {markets.length} metros
+ </p>
+ </div>
+ </section>
+
+ <section className="mx-auto max-w-7xl px-4 py-12">
+ <h2 className="text-2xl font-semibold">Browse by market</h2>
+ <div className="mt-6 grid gap-4 sm:grid-cols-3">
+ {markets.map((market) => (
+ <Link
+ key={`${market.metro}-${market.state}`}
+ href={`/search?q=${encodeURIComponent(market.metro?.split("-")[0] ?? "")}`}
+ className="rounded-xl border border-neutral-200 p-6 shadow-sm transition hover:border-teal-400 hover:shadow-md"
+ >
+ <div className="text-lg font-semibold">{market.metro}</div>
+ <div className="mt-1 text-sm text-neutral-500">
+ {market.state} · {market._count._all} communities
+ </div>
+ </Link>
+ ))}
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/plans/[id]/page.tsx b/apps/web/src/app/plans/[id]/page.tsx
new file mode 100644
index 0000000..8aa6670
--- /dev/null
+++ b/apps/web/src/app/plans/[id]/page.tsx
@@ -0,0 +1,50 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function PlanPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const plan = await prisma.floorPlan.findUnique({
+ where: { id },
+ include: {
+ community: true,
+ builder: true,
+ homes: { where: { status: "PUBLISHED" } },
+ },
+ });
+ if (!plan) notFound();
+
+ return (
+ <div className="mx-auto max-w-4xl px-4 py-8">
+ <nav className="text-sm text-neutral-500">
+ <Link href={`/communities/${plan.community.slug}`} className="hover:text-teal-700">{plan.community.name}</Link>
+ </nav>
+ <h1 className="mt-2 text-3xl font-bold">{plan.name}</h1>
+ <p className="mt-1 text-neutral-600">{plan.builder.name} · {plan.community.name}</p>
+ <dl className="mt-6 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
+ <div><dt className="text-neutral-500">Base price</dt><dd className="font-semibold">{fmtPrice(plan.basePrice === null ? null : Number(plan.basePrice))}</dd></div>
+ <div><dt className="text-neutral-500">Beds</dt><dd className="font-semibold">{plan.beds ?? "—"}{plan.bedsMax && plan.bedsMax !== plan.beds ? `–${plan.bedsMax}` : ""}</dd></div>
+ <div><dt className="text-neutral-500">Sq ft</dt><dd className="font-semibold">{plan.sqft?.toLocaleString() ?? "—"}</dd></div>
+ <div><dt className="text-neutral-500">Stories</dt><dd className="font-semibold">{plan.stories ?? "—"}</dd></div>
+ </dl>
+ <p className="mt-2 text-xs text-neutral-400">
+ Floor-plan drawings render only when the source registry confirms media rights (rights status: {plan.planMediaRights ?? "none"}).
+ </p>
+ <section className="mt-8">
+ <h2 className="text-xl font-semibold">Available homes on this plan</h2>
+ <div className="mt-3 grid gap-4 sm:grid-cols-2">
+ {plan.homes.map((home) => (
+ <Link key={home.id} href={`/homes/${home.id}`} className="rounded-lg border border-neutral-200 p-3 text-sm hover:border-teal-400">
+ <div className="font-semibold">{fmtPrice(home.price === null ? null : Number(home.price))}</div>
+ <div className="text-neutral-600">{home.street}</div>
+ </Link>
+ ))}
+ {plan.homes.length === 0 && <p className="text-sm text-neutral-500">No inventory homes on this plan right now.</p>}
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/saved/page.tsx b/apps/web/src/app/saved/page.tsx
new file mode 100644
index 0000000..2caec18
--- /dev/null
+++ b/apps/web/src/app/saved/page.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+
+/** v1 stub: saved homes live in localStorage; accounts + alert delivery are deferred. */
+export default function SavedPage() {
+ const [ids, setIds] = useState<string[]>([]);
+ useEffect(() => {
+ setIds(JSON.parse(localStorage.getItem("spechomes.saved") ?? "[]"));
+ }, []);
+
+ return (
+ <div className="mx-auto max-w-4xl px-4 py-10">
+ <h1 className="text-3xl font-bold">Saved homes</h1>
+ {ids.length === 0 ? (
+ <p className="mt-4 text-neutral-600">
+ No saved homes yet. Save from the <Link href="/search" className="text-teal-700 underline">search page</Link>.
+ Price and availability alerts arrive with accounts (deferred in v1).
+ </p>
+ ) : (
+ <ul className="mt-4 space-y-2">
+ {ids.map((id) => (
+ <li key={id}>
+ <Link href={`/homes/${id}`} className="text-teal-700 underline">Saved home {id.slice(0, 8)}…</Link>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
new file mode 100644
index 0000000..4266c2d
--- /dev/null
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -0,0 +1,323 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import dynamic from "next/dynamic";
+import Link from "next/link";
+import { fmtPrice, VerificationBadge } from "@spechomes/shared-ui";
+
+// Leaflet needs window — client-only load.
+const MapView = dynamic(() => import("@spechomes/shared-ui").then((m) => m.MapView), { ssr: false });
+
+interface Home {
+ id: string;
+ street: string | null;
+ city: string;
+ state: string;
+ zip: string;
+ price: string | number | null;
+ beds: number | null;
+ bathsTotal: string | number | null;
+ sqft: number | null;
+ stories: number | null;
+ garageSpaces: number | null;
+ lat: string | number | null;
+ lon: string | number | null;
+ constructionStatus: string;
+ estCompletionDate: string | null;
+ verificationLabel: string;
+ lastVerifiedAt: string | null;
+ publishedAt: string;
+ community: { slug: string; name: string; schoolDistrict: string | null };
+ builder: { slug: string; name: string };
+}
+
+interface SearchResponse {
+ homes: Home[];
+ total: number;
+ page: number;
+ pageSize: number;
+ location: { label: string; lat: number; lon: number } | null;
+}
+
+interface Facets {
+ constructionStatus: { value: string; count: number }[];
+ builder: { value: string; label: string; count: number }[];
+ beds: { value: number; count: number }[];
+ homeType: { value: string; count: number }[];
+}
+
+const SORTS = [
+ { value: "newest", label: "Newest" },
+ { value: "price_asc", label: "Price ↑" },
+ { value: "price_desc", label: "Price ↓" },
+ { value: "sqft_desc", label: "Sq Ft ↓" },
+ { value: "closest", label: "Closest" },
+] as const;
+
+const STATUS_LABELS: Record<string, string> = {
+ MOVE_IN_READY: "Move-in ready",
+ UNDER_CONSTRUCTION: "Under construction",
+ PLANNED: "Planned",
+};
+
+export default function SearchClient({ initialQuery }: { initialQuery: string }) {
+ const [q, setQ] = useState(initialQuery);
+ const [filters, setFilters] = useState<Record<string, string | string[]>>({});
+ // Sort + density persist across reloads (standing grid rule).
+ const [sort, setSort] = useState<string>("newest");
+ const [cols, setCols] = useState<number>(3);
+ const [data, setData] = useState<SearchResponse | null>(null);
+ const [facets, setFacets] = useState<Facets | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [page, setPage] = useState(1);
+ const bboxRef = useRef<string | null>(null);
+
+ useEffect(() => {
+ const savedSort = localStorage.getItem("spechomes.sort");
+ const savedCols = localStorage.getItem("spechomes.cols");
+ if (savedSort) setSort(savedSort);
+ if (savedCols) setCols(Number(savedCols));
+ }, []);
+
+ const setSortPersist = (value: string) => {
+ setSort(value);
+ localStorage.setItem("spechomes.sort", value);
+ };
+ const setColsPersist = (value: number) => {
+ setCols(value);
+ localStorage.setItem("spechomes.cols", String(value));
+ };
+
+ const queryString = useMemo(() => {
+ const params = new URLSearchParams();
+ if (q) params.set("q", q);
+ if (bboxRef.current) params.set("bbox", bboxRef.current);
+ for (const [key, value] of Object.entries(filters)) {
+ if (Array.isArray(value)) value.forEach((v) => params.append(key, v));
+ else if (value !== "") params.set(key, value);
+ }
+ params.set("sort", sort);
+ params.set("page", String(page));
+ return params.toString();
+ }, [q, filters, sort, page]);
+
+ const fetchResults = useCallback(async () => {
+ setLoading(true);
+ try {
+ const [searchRes, facetsRes] = await Promise.all([
+ fetch(`/api/search?${queryString}`),
+ fetch(`/api/facets?${queryString}`),
+ ]);
+ setData(await searchRes.json());
+ setFacets(await facetsRes.json());
+ } finally {
+ setLoading(false);
+ }
+ }, [queryString]);
+
+ useEffect(() => {
+ void fetchResults();
+ }, [fetchResults]);
+
+ const toggleMulti = (key: string, value: string) => {
+ setPage(1);
+ setFilters((prev) => {
+ const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : [];
+ const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
+ return { ...prev, [key]: next };
+ });
+ };
+ const setSingle = (key: string, value: string) => {
+ setPage(1);
+ setFilters((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const markers = (data?.homes ?? [])
+ .filter((h) => h.lat !== null && h.lon !== null)
+ .map((h) => ({
+ id: h.id,
+ lat: Number(h.lat),
+ lon: Number(h.lon),
+ label: h.street ?? `${h.community.name} home`,
+ sublabel: fmtPrice(h.price),
+ href: `/homes/${h.id}`,
+ }));
+
+ const onMapMove = useCallback(
+ (bbox: { minLat: number; maxLat: number; minLon: number; maxLon: number }) => {
+ bboxRef.current = `${bbox.minLat},${bbox.minLon},${bbox.maxLat},${bbox.maxLon}`;
+ },
+ [],
+ );
+
+ const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
+
+ return (
+ <div className="mx-auto max-w-7xl px-4 py-6">
+ {/* Search bar */}
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ bboxRef.current = null;
+ setPage(1);
+ void fetchResults();
+ }}
+ className="flex gap-2"
+ >
+ <input
+ value={q}
+ onChange={(e) => setQ(e.target.value)}
+ placeholder="City, ZIP, or community"
+ className="w-full max-w-md rounded-lg border border-neutral-300 px-4 py-2"
+ data-testid="search-input"
+ />
+ <button type="submit" className="rounded-lg bg-teal-700 px-5 py-2 font-medium text-white hover:bg-teal-800">
+ Search
+ </button>
+ </form>
+
+ <div className="mt-6 grid gap-6 lg:grid-cols-[260px_1fr]">
+ {/* Filters */}
+ <aside className="space-y-5 text-sm" data-testid="filters">
+ <div>
+ <h3 className="font-semibold">Price</h3>
+ <div className="mt-2 flex gap-2">
+ <input type="number" placeholder="Min" className="w-full rounded border border-neutral-300 px-2 py-1"
+ onChange={(e) => setSingle("priceMin", e.target.value)} data-testid="price-min" />
+ <input type="number" placeholder="Max" className="w-full rounded border border-neutral-300 px-2 py-1"
+ onChange={(e) => setSingle("priceMax", e.target.value)} data-testid="price-max" />
+ </div>
+ </div>
+ <div>
+ <h3 className="font-semibold">Beds (min)</h3>
+ <div className="mt-2 flex gap-1">
+ {[1, 2, 3, 4, 5].map((n) => (
+ <button key={n} type="button" data-testid={`beds-${n}`}
+ onClick={() => setSingle("bedsMin", filters.bedsMin === String(n) ? "" : String(n))}
+ className={`rounded border px-2.5 py-1 ${filters.bedsMin === String(n) ? "border-teal-600 bg-teal-50 text-teal-800" : "border-neutral-300"}`}>
+ {n}+
+ </button>
+ ))}
+ </div>
+ </div>
+ <div>
+ <h3 className="font-semibold">Construction status</h3>
+ {facets?.constructionStatus.map((facet) => (
+ <label key={facet.value} className="mt-1 flex items-center gap-2">
+ <input type="checkbox"
+ checked={Array.isArray(filters.status) && (filters.status as string[]).includes(facet.value)}
+ onChange={() => toggleMulti("status", facet.value)} />
+ {STATUS_LABELS[facet.value] ?? facet.value}
+ <span className="text-neutral-400">({facet.count})</span>
+ </label>
+ ))}
+ </div>
+ <div>
+ <h3 className="font-semibold">Builder</h3>
+ {facets?.builder.map((facet) => (
+ <label key={facet.value} className="mt-1 flex items-center gap-2">
+ <input type="checkbox"
+ checked={Array.isArray(filters.builder) && (filters.builder as string[]).includes(facet.value)}
+ onChange={() => toggleMulti("builder", facet.value)} />
+ {facet.label}
+ <span className="text-neutral-400">({facet.count})</span>
+ </label>
+ ))}
+ </div>
+ <div>
+ <h3 className="font-semibold">More</h3>
+ <label className="mt-1 flex items-center gap-2">
+ <input type="checkbox" checked={filters.incentives === "1"}
+ onChange={() => setSingle("incentives", filters.incentives === "1" ? "" : "1")} />
+ Has incentives
+ </label>
+ <label className="mt-1 flex items-center gap-2">
+ <input type="checkbox" checked={filters.ageRestricted === "1"}
+ onChange={() => setSingle("ageRestricted", filters.ageRestricted === "1" ? "" : "1")} />
+ Age-restricted community
+ </label>
+ <select className="mt-2 w-full rounded border border-neutral-300 px-2 py-1"
+ value={(filters.moveInByMonths as string) ?? ""}
+ onChange={(e) => setSingle("moveInByMonths", e.target.value)}>
+ <option value="">Any move-in timeframe</option>
+ <option value="0">Ready now</option>
+ <option value="3">Within 3 months</option>
+ <option value="6">Within 6 months</option>
+ <option value="12">Within 12 months</option>
+ </select>
+ </div>
+ </aside>
+
+ {/* Results */}
+ <div>
+ <div className="flex flex-wrap items-center justify-between gap-3">
+ <div className="text-sm text-neutral-600" data-testid="result-count">
+ {loading ? "Searching…" : `${data?.total ?? 0} homes${data?.location ? ` near ${data.location.label}` : ""}`}
+ </div>
+ {/* Mandatory grid controls: sort select + density slider, localStorage-persisted */}
+ <div className="flex items-center gap-4">
+ <label className="flex items-center gap-2 text-sm">
+ Sort
+ <select value={sort} onChange={(e) => setSortPersist(e.target.value)}
+ className="rounded border border-neutral-300 px-2 py-1" data-testid="sort-select">
+ {SORTS.map((s) => (
+ <option key={s.value} value={s.value}>{s.label}</option>
+ ))}
+ </select>
+ </label>
+ <label className="flex items-center gap-2 text-sm">
+ Density
+ <input type="range" min={2} max={4} step={1} value={cols}
+ onChange={(e) => setColsPersist(Number(e.target.value))} data-testid="density-slider" />
+ </label>
+ </div>
+ </div>
+
+ <div className="mt-4 grid gap-4 lg:grid-cols-[1fr_380px]">
+ <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} data-testid="results-grid">
+ {(data?.homes ?? []).map((home) => (
+ <Link key={home.id} href={`/homes/${home.id}`}
+ className="rounded-xl border border-neutral-200 p-4 shadow-sm transition hover:border-teal-400 hover:shadow-md"
+ data-testid="home-card">
+ <div className="text-lg font-semibold">{fmtPrice(home.price)}</div>
+ <div className="mt-0.5 truncate text-sm text-neutral-700">
+ {home.street ?? "Address on request"} · {home.city}, {home.state}
+ </div>
+ <div className="mt-1 text-sm text-neutral-500">
+ {home.beds ?? "—"} bd · {home.bathsTotal ?? "—"} ba · {home.sqft?.toLocaleString() ?? "—"} sqft
+ </div>
+ <div className="mt-1 text-xs text-neutral-500">
+ {STATUS_LABELS[home.constructionStatus]}
+ {home.estCompletionDate ? ` · est. ${new Date(home.estCompletionDate).toLocaleDateString(undefined, { month: "short", year: "numeric" })}` : ""}
+ </div>
+ <div className="mt-2 flex items-center justify-between">
+ <span className="text-xs text-neutral-500">{home.builder.name}</span>
+ <VerificationBadge label={home.verificationLabel} />
+ </div>
+ </Link>
+ ))}
+ {!loading && (data?.homes.length ?? 0) === 0 && (
+ <p className="col-span-full py-10 text-center text-neutral-500">
+ No homes match these filters.
+ </p>
+ )}
+ </div>
+ <div className="h-[540px] overflow-hidden rounded-xl border border-neutral-200" data-testid="map">
+ <MapView markers={markers} onMoveEnd={onMapMove} />
+ </div>
+ </div>
+
+ {totalPages > 1 && (
+ <div className="mt-6 flex items-center justify-center gap-3 text-sm">
+ <button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}
+ className="rounded border border-neutral-300 px-3 py-1 disabled:opacity-40">Prev</button>
+ <span>Page {page} of {totalPages}</span>
+ <button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}
+ className="rounded border border-neutral-300 px-3 py-1 disabled:opacity-40">Next</button>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx
new file mode 100644
index 0000000..e66b296
--- /dev/null
+++ b/apps/web/src/app/search/page.tsx
@@ -0,0 +1,12 @@
+import SearchClient from "./SearchClient";
+
+export const dynamic = "force-dynamic";
+
+export default async function SearchPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ q?: string }>;
+}) {
+ const { q } = await searchParams;
+ return <SearchClient initialQuery={q ?? ""} />;
+}
diff --git a/apps/web/src/lib/parse-search.ts b/apps/web/src/lib/parse-search.ts
new file mode 100644
index 0000000..6d9cbc5
--- /dev/null
+++ b/apps/web/src/lib/parse-search.ts
@@ -0,0 +1,45 @@
+import type { SearchParams } from "@spechomes/search";
+
+function num(v: string | null): number | undefined {
+ if (v === null || v === "") return undefined;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : undefined;
+}
+
+export function parseSearchParams(searchParams: URLSearchParams): SearchParams {
+ const bboxRaw = searchParams.get("bbox");
+ let bbox: SearchParams["bbox"];
+ if (bboxRaw) {
+ const [minLat, minLon, maxLat, maxLon] = bboxRaw.split(",").map(Number);
+ if ([minLat, minLon, maxLat, maxLon].every((n) => Number.isFinite(n))) {
+ bbox = { minLat: minLat!, minLon: minLon!, maxLat: maxLat!, maxLon: maxLon! };
+ }
+ }
+ return {
+ q: searchParams.get("q") ?? undefined,
+ bbox,
+ priceMin: num(searchParams.get("priceMin")),
+ priceMax: num(searchParams.get("priceMax")),
+ bedsMin: num(searchParams.get("bedsMin")),
+ bathsMin: num(searchParams.get("bathsMin")),
+ sqftMin: num(searchParams.get("sqftMin")),
+ sqftMax: num(searchParams.get("sqftMax")),
+ homeTypes: searchParams.getAll("homeType"),
+ builderSlugs: searchParams.getAll("builder"),
+ communitySlug: searchParams.get("community") ?? undefined,
+ statuses: searchParams.getAll("status"),
+ storiesEq: num(searchParams.get("stories")),
+ garageMin: num(searchParams.get("garageMin")),
+ hasIncentives: searchParams.get("incentives") === "1" ? true : undefined,
+ ageRestricted:
+ searchParams.get("ageRestricted") === "1" ? true : searchParams.get("ageRestricted") === "0" ? false : undefined,
+ hoaMax: num(searchParams.get("hoaMax")),
+ schoolDistrict: searchParams.get("schoolDistrict") ?? undefined,
+ state: searchParams.get("state") ?? undefined,
+ city: searchParams.get("city") ?? undefined,
+ moveInByMonths: num(searchParams.get("moveInByMonths")),
+ sort: (searchParams.get("sort") as SearchParams["sort"]) ?? "newest",
+ page: num(searchParams.get("page")),
+ pageSize: num(searchParams.get("pageSize")),
+ };
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index b8eeb84..840f56f 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -5,16 +5,42 @@
"allowJs": true,
"incremental": true,
"module": "esnext",
- "plugins": [{ "name": "next" }],
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
"paths": {
- "@/*": ["./src/*"],
- "@spechomes/database": ["../../packages/database/src/index.ts"],
- "@spechomes/schemas": ["../../packages/schemas/src/index.ts"],
- "@spechomes/search": ["../../packages/search/src/index.ts"],
- "@spechomes/shared": ["../../packages/shared/src/index.ts"],
- "@spechomes/shared-ui": ["../../packages/shared-ui/src/index.ts"]
+ "@/*": [
+ "./src/*"
+ ],
+ "@spechomes/database": [
+ "../../packages/database/src/index.ts"
+ ],
+ "@spechomes/schemas": [
+ "../../packages/schemas/src/index.ts"
+ ],
+ "@spechomes/search": [
+ "../../packages/search/src/index.ts"
+ ],
+ "@spechomes/shared": [
+ "../../packages/shared/src/index.ts"
+ ],
+ "@spechomes/shared-ui": [
+ "../../packages/shared-ui/src/index.ts"
+ ],
+ "@spechomes/publisher": [
+ "../../packages/publisher/src/index.ts"
+ ]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
diff --git a/apps/workers/package.json b/apps/workers/package.json
index 5ed604b..b400501 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -16,7 +16,13 @@
"@spechomes/shared": "workspace:*",
"@spechomes/collectors-common": "workspace:*",
"@spechomes/collector-meridian-homes": "workspace:*",
- "pg-boss": "^10.1.5"
+ "pg-boss": "^10.1.5",
+ "@spechomes/publisher": "workspace:*"
},
- "devDependencies": { "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+ "devDependencies": {
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2",
+ "vitest": "^4.0.0",
+ "@types/node": "^22.10.5"
+ }
}
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index a66ca17..04679c3 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -1,6 +1,6 @@
import { prisma } from "@spechomes/database";
import { meridianHomesAdapter } from "@spechomes/collector-meridian-homes";
-import { runPipeline } from "./pipeline.js";
+import { runPipeline } from "./pipeline";
/**
* Direct pipeline runner — no queue required.
diff --git a/apps/workers/src/index.ts b/apps/workers/src/index.ts
index 44c79e4..f01f2e8 100644
--- a/apps/workers/src/index.ts
+++ b/apps/workers/src/index.ts
@@ -1,6 +1,6 @@
import { work } from "@spechomes/shared";
import { meridianHomesAdapter } from "@spechomes/collector-meridian-homes";
-import { runPipeline } from "./pipeline.js";
+import { runPipeline } from "./pipeline";
/**
* Queue-backed worker daemon (pg-boss). Stages stay pure functions; this
diff --git a/apps/workers/src/pipeline.ts b/apps/workers/src/pipeline.ts
index 61491e3..a34c5f4 100644
--- a/apps/workers/src/pipeline.ts
+++ b/apps/workers/src/pipeline.ts
@@ -7,7 +7,7 @@ import {
type SourceRegistry,
} from "@spechomes/database";
import { validatePayload, type ExtractedRecord } from "@spechomes/schemas";
-import { canonicalKey, normalizeAddress } from "@spechomes/shared";
+import { canonicalKey } from "@spechomes/shared";
import {
ALL_RULES,
runValidation,
@@ -16,6 +16,9 @@ import {
type StagedCandidate,
} from "@spechomes/validation";
import type { RawPage, SourceAdapter } from "@spechomes/collectors-common";
+import { publishStagedRecord } from "@spechomes/publisher";
+
+export { publishStagedRecord };
/**
* Pipeline stages. Each is a pure-ish function over (adapter, db) — the
@@ -241,133 +244,8 @@ function reconstructHints(
};
}
-// ── Stage 4: publish (the ONLY writer of published tables) ─────────────
-
-export async function publishStagedRecord(stagedRecordId: string) {
- const staged = await prisma.stagedRecord.findUniqueOrThrow({
- where: { id: stagedRecordId },
- include: { source: true },
- });
- if (staged.status !== "VALIDATED" && staged.status !== "APPROVED") {
- throw new Error(
- `refusing to publish staged record ${staged.id} with status ${staged.status} — only VALIDATED or human-APPROVED records publish`,
- );
- }
-
- const payload = staged.payload as Record<string, { value: unknown }>;
- const value = <T>(field: string): T | null => (payload[field]?.value ?? null) as T | null;
-
- let publishedEntityId: string | null = null;
-
- if (staged.entityType === "INVENTORY_HOME") {
- const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: "meridian-homes" } });
- const community = await prisma.community.findFirstOrThrow({
- where: { name: "Cedar Bend", builderId: builder.id },
- });
- const priorPrice = await prisma.inventoryHome.findUnique({
- where: { canonicalKey: staged.canonicalKey },
- select: { price: true },
- });
-
- const price = value<number>("price");
- const street = value<string>("street");
- const home = await prisma.inventoryHome.upsert({
- where: { canonicalKey: staged.canonicalKey },
- create: {
- canonicalKey: staged.canonicalKey,
- communityId: community.id,
- builderId: builder.id,
- builderInventoryId: value<string>("builderInventoryId"),
- street,
- city: value<string>("city") ?? community.city,
- state: value<string>("state") ?? community.state,
- zip: value<string>("zip") ?? community.zip,
- normalizedAddress: street ? normalizeAddress(street) : null,
- lotNumber: value<string>("lotNumber"),
- lat: value<number>("lat"),
- lon: value<number>("lon"),
- price,
- beds: value<number>("beds"),
- bathsTotal: value<number>("bathsTotal"),
- sqft: value<number>("sqft"),
- stories: value<number>("stories"),
- garageSpaces: value<number>("garageSpaces"),
- homeType: (value<string>("homeType") as never) ?? "SINGLE_FAMILY",
- constructionStatus: (value<string>("constructionStatus") as never) ?? "UNDER_CONSTRUCTION",
- estCompletionDate: value<string>("estCompletionDate")
- ? new Date(value<string>("estCompletionDate")!)
- : null,
- status: "PUBLISHED",
- freshness: "FRESH",
- verificationLabel: "DEMONSTRATION", // fixture source — never a production claim
- lastVerifiedAt: new Date(),
- sourceId: staged.sourceId,
- sourceUrl: payload.street?.value ? (payload.street as { sourceUrl?: string }).sourceUrl ?? null : null,
- isDemo: true,
- },
- update: {
- previousPrice: priorPrice?.price ?? undefined,
- price,
- lastVerifiedAt: new Date(),
- freshness: "FRESH",
- updatedAt: new Date(),
- },
- });
- publishedEntityId = home.id;
-
- if (priorPrice?.price && price !== null && Number(priorPrice.price) !== price) {
- await prisma.changeEvent.create({
- data: {
- entityType: "INVENTORY_HOME",
- entityId: home.id,
- field: "price",
- oldValue: String(priorPrice.price),
- newValue: String(price),
- sourceId: staged.sourceId,
- },
- });
- }
- } else if (staged.entityType === "INCENTIVE") {
- const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: "meridian-homes" } });
- const community = await prisma.community.findFirstOrThrow({
- where: { name: "Cedar Bend", builderId: builder.id },
- });
- const expiresAt = value<string>("expiresAt");
- const incentive = await prisma.incentive.create({
- data: {
- scope: "COMMUNITY",
- builderId: builder.id,
- communityId: community.id,
- title: value<string>("title") ?? "Untitled offer",
- description: value<string>("description"),
- valueUsd: value<number>("valueUsd"),
- expiresAt: expiresAt ? new Date(expiresAt) : null,
- // Spec rule: incentives need an expiration OR an explicit label.
- evergreenLabel: expiresAt ? null : "expiration not provided",
- sourceId: staged.sourceId,
- isDemo: true,
- },
- });
- publishedEntityId = incentive.id;
- } else if (staged.entityType === "COMMUNITY") {
- // Fixture communities are pre-seeded (FK target); publishing refreshes verification only.
- const community = await prisma.community.findFirst({ where: { name: value<string>("name") ?? "" } });
- publishedEntityId = community?.id ?? null;
- }
-
- await prisma.stagedRecord.update({
- where: { id: staged.id },
- data: { status: "PUBLISHED", publishedEntityId },
- });
- // Re-point evidence at the published entity so consumer pages can cite it.
- if (publishedEntityId) {
- await prisma.sourceEvidence.updateMany({
- where: { stagedRecordId: staged.id },
- data: { entityType: staged.entityType, entityId: publishedEntityId },
- });
- }
- return publishedEntityId;
-}
+// ── Stage 4: publish lives in @spechomes/publisher (the ONLY writer
+// of published tables) — re-exported here for pipeline callers. ────
// ── Orchestration: run every stage for one adapter ─────────────────────
diff --git a/collectors/common/src/index.ts b/collectors/common/src/index.ts
index e202a52..631bd0f 100644
--- a/collectors/common/src/index.ts
+++ b/collectors/common/src/index.ts
@@ -1 +1 @@
-export * from "./adapter.js";
+export * from "./adapter";
diff --git a/collectors/meridian-homes/src/extract.test.ts b/collectors/meridian-homes/src/extract.test.ts
index 11ed8d2..775caf0 100644
--- a/collectors/meridian-homes/src/extract.test.ts
+++ b/collectors/meridian-homes/src/extract.test.ts
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { sha256 } from "@spechomes/collectors-common";
-import { meridianHomesAdapter } from "./index.js";
+import { meridianHomesAdapter } from "./index";
const FIXTURE_DIR = join(import.meta.dirname, "../../../fixtures/raw-pages/meridian-homes");
const EXPECTED_PATH = join(import.meta.dirname, "../../../fixtures/expected-records/meridian-homes.json");
diff --git a/packages/publisher/package.json b/packages/publisher/package.json
new file mode 100644
index 0000000..e3723bc
--- /dev/null
+++ b/packages/publisher/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@spechomes/publisher",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": { "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" },
+ "dependencies": {
+ "@spechomes/database": "workspace:*",
+ "@spechomes/shared": "workspace:*"
+ },
+ "devDependencies": { "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+}
diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts
new file mode 100644
index 0000000..4488494
--- /dev/null
+++ b/packages/publisher/src/index.ts
@@ -0,0 +1,136 @@
+import { prisma } from "@spechomes/database";
+import { normalizeAddress } from "@spechomes/shared";
+
+/**
+ * THE ONLY WRITER OF PUBLISHED TABLES.
+ *
+ * Publishes a staged record whose status is VALIDATED (deterministic
+ * validators passed) or APPROVED (human review). Any other status throws.
+ * Collectors, extractors, and normalizers never touch published tables;
+ * they end at StagedRecord, and this function is the single bridge.
+ */
+export async function publishStagedRecord(stagedRecordId: string) {
+ const staged = await prisma.stagedRecord.findUniqueOrThrow({
+ where: { id: stagedRecordId },
+ include: { source: true },
+ });
+ if (staged.status !== "VALIDATED" && staged.status !== "APPROVED") {
+ throw new Error(
+ `refusing to publish staged record ${staged.id} with status ${staged.status} — only VALIDATED or human-APPROVED records publish`,
+ );
+ }
+
+ const payload = staged.payload as Record<string, { value: unknown; sourceUrl?: string }>;
+ const value = <T>(field: string): T | null => (payload[field]?.value ?? null) as T | null;
+
+ let publishedEntityId: string | null = null;
+
+ if (staged.entityType === "INVENTORY_HOME") {
+ const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: "meridian-homes" } });
+ const community = await prisma.community.findFirstOrThrow({
+ where: { name: "Cedar Bend", builderId: builder.id },
+ });
+ const priorPrice = await prisma.inventoryHome.findUnique({
+ where: { canonicalKey: staged.canonicalKey },
+ select: { price: true },
+ });
+
+ const price = value<number>("price");
+ const street = value<string>("street");
+ const home = await prisma.inventoryHome.upsert({
+ where: { canonicalKey: staged.canonicalKey },
+ create: {
+ canonicalKey: staged.canonicalKey,
+ communityId: community.id,
+ builderId: builder.id,
+ builderInventoryId: value<string>("builderInventoryId"),
+ street,
+ city: value<string>("city") ?? community.city,
+ state: value<string>("state") ?? community.state,
+ zip: value<string>("zip") ?? community.zip,
+ normalizedAddress: street ? normalizeAddress(street) : null,
+ lotNumber: value<string>("lotNumber"),
+ lat: value<number>("lat"),
+ lon: value<number>("lon"),
+ price,
+ beds: value<number>("beds"),
+ bathsTotal: value<number>("bathsTotal"),
+ sqft: value<number>("sqft"),
+ stories: value<number>("stories"),
+ garageSpaces: value<number>("garageSpaces"),
+ homeType: (value<string>("homeType") as never) ?? "SINGLE_FAMILY",
+ constructionStatus: (value<string>("constructionStatus") as never) ?? "UNDER_CONSTRUCTION",
+ estCompletionDate: value<string>("estCompletionDate")
+ ? new Date(value<string>("estCompletionDate")!)
+ : null,
+ status: "PUBLISHED",
+ freshness: "FRESH",
+ verificationLabel: "DEMONSTRATION", // fixture source — never a production claim
+ lastVerifiedAt: new Date(),
+ sourceId: staged.sourceId,
+ sourceUrl: payload.street?.sourceUrl ?? null,
+ isDemo: true,
+ },
+ update: {
+ previousPrice: priorPrice?.price ?? undefined,
+ price,
+ lastVerifiedAt: new Date(),
+ freshness: "FRESH",
+ updatedAt: new Date(),
+ },
+ });
+ publishedEntityId = home.id;
+
+ if (priorPrice?.price && price !== null && Number(priorPrice.price) !== price) {
+ await prisma.changeEvent.create({
+ data: {
+ entityType: "INVENTORY_HOME",
+ entityId: home.id,
+ field: "price",
+ oldValue: String(priorPrice.price),
+ newValue: String(price),
+ sourceId: staged.sourceId,
+ },
+ });
+ }
+ } else if (staged.entityType === "INCENTIVE") {
+ const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: "meridian-homes" } });
+ const community = await prisma.community.findFirstOrThrow({
+ where: { name: "Cedar Bend", builderId: builder.id },
+ });
+ const expiresAt = value<string>("expiresAt");
+ const incentive = await prisma.incentive.create({
+ data: {
+ scope: "COMMUNITY",
+ builderId: builder.id,
+ communityId: community.id,
+ title: value<string>("title") ?? "Untitled offer",
+ description: value<string>("description"),
+ valueUsd: value<number>("valueUsd"),
+ expiresAt: expiresAt ? new Date(expiresAt) : null,
+ // Spec rule: incentives need an expiration OR an explicit label.
+ evergreenLabel: expiresAt ? null : "expiration not provided",
+ sourceId: staged.sourceId,
+ isDemo: true,
+ },
+ });
+ publishedEntityId = incentive.id;
+ } else if (staged.entityType === "COMMUNITY") {
+ // Fixture communities are pre-seeded (FK target); publishing refreshes verification only.
+ const community = await prisma.community.findFirst({ where: { name: value<string>("name") ?? "" } });
+ publishedEntityId = community?.id ?? null;
+ }
+
+ await prisma.stagedRecord.update({
+ where: { id: staged.id },
+ data: { status: "PUBLISHED", publishedEntityId },
+ });
+ // Re-point evidence at the published entity so consumer pages can cite it.
+ if (publishedEntityId) {
+ await prisma.sourceEvidence.updateMany({
+ where: { stagedRecordId: staged.id },
+ data: { entityType: staged.entityType, entityId: publishedEntityId },
+ });
+ }
+ return publishedEntityId;
+}
diff --git a/packages/publisher/tsconfig.json b/packages/publisher/tsconfig.json
new file mode 100644
index 0000000..e9bfc48
--- /dev/null
+++ b/packages/publisher/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts
new file mode 100644
index 0000000..90e1366
--- /dev/null
+++ b/packages/search/src/index.ts
@@ -0,0 +1,258 @@
+import { prisma, Prisma } from "@spechomes/database";
+import { bboxAround, type BBox } from "@spechomes/shared";
+
+/**
+ * Search v1 — two steps:
+ * A) resolve free-text location (city / zip / community) → center + bbox
+ * via pg_trgm similarity against Community.searchText
+ * B) structured filtered page via Prisma findMany inside the bbox
+ * Organic order only — there is no paid placement anywhere in v1.
+ * All filters are objective (Fair Housing: no subjective neighborhood
+ * dimensions exist in the schema).
+ */
+
+export interface SearchParams {
+ q?: string;
+ bbox?: BBox; // explicit map-move bbox skips location resolution
+ priceMin?: number;
+ priceMax?: number;
+ bedsMin?: number;
+ bathsMin?: number;
+ sqftMin?: number;
+ sqftMax?: number;
+ homeTypes?: string[];
+ builderSlugs?: string[];
+ communitySlug?: string;
+ statuses?: string[]; // construction statuses
+ storiesEq?: number;
+ garageMin?: number;
+ hasIncentives?: boolean;
+ ageRestricted?: boolean;
+ hoaMax?: number;
+ schoolDistrict?: string;
+ state?: string;
+ city?: string;
+ moveInByMonths?: number; // move-in timeframe: ready or completing within N months
+ sort?: "newest" | "price_asc" | "price_desc" | "sqft_desc" | "closest";
+ page?: number;
+ pageSize?: number;
+}
+
+export interface ResolvedLocation {
+ label: string;
+ lat: number;
+ lon: number;
+ bbox: BBox;
+}
+
+const DEFAULT_RADIUS_MILES = 30;
+
+export async function resolveLocation(q: string): Promise<ResolvedLocation | null> {
+ const query = q.trim();
+ if (!query) return null;
+ const rows = await prisma.$queryRaw<
+ { id: string; name: string; city: string; state: string; lat: number | null; lon: number | null; sim: number }[]
+ >(Prisma.sql`
+ SELECT id, name, city, state, lat::float8 AS lat, lon::float8 AS lon,
+ similarity("searchText", ${query}) AS sim
+ FROM "Community"
+ WHERE "searchText" % ${query} OR zip = ${query} OR lower(city) = lower(${query})
+ ORDER BY (zip = ${query}) DESC, (lower(city) = lower(${query})) DESC, sim DESC
+ LIMIT 1
+ `);
+ const hit = rows[0];
+ if (!hit || hit.lat === null || hit.lon === null) return null;
+ return {
+ label: `${hit.city}, ${hit.state}`,
+ lat: hit.lat,
+ lon: hit.lon,
+ bbox: bboxAround(hit.lat, hit.lon, DEFAULT_RADIUS_MILES),
+ };
+}
+
+export function buildWhere(params: SearchParams, bbox?: BBox): Prisma.InventoryHomeWhereInput {
+ const now = new Date();
+ const moveInCutoff =
+ params.moveInByMonths !== undefined
+ ? new Date(now.getFullYear(), now.getMonth() + params.moveInByMonths, now.getDate())
+ : undefined;
+
+ return {
+ status: "PUBLISHED",
+ ...(bbox
+ ? {
+ lat: { gte: bbox.minLat, lte: bbox.maxLat },
+ lon: { gte: bbox.minLon, lte: bbox.maxLon },
+ }
+ : {}),
+ ...(params.priceMin !== undefined || params.priceMax !== undefined
+ ? { price: { ...(params.priceMin !== undefined ? { gte: params.priceMin } : {}), ...(params.priceMax !== undefined ? { lte: params.priceMax } : {}) } }
+ : {}),
+ ...(params.bedsMin !== undefined ? { beds: { gte: params.bedsMin } } : {}),
+ ...(params.bathsMin !== undefined ? { bathsTotal: { gte: params.bathsMin } } : {}),
+ ...(params.sqftMin !== undefined || params.sqftMax !== undefined
+ ? { sqft: { ...(params.sqftMin !== undefined ? { gte: params.sqftMin } : {}), ...(params.sqftMax !== undefined ? { lte: params.sqftMax } : {}) } }
+ : {}),
+ ...(params.homeTypes?.length ? { homeType: { in: params.homeTypes as never[] } } : {}),
+ ...(params.builderSlugs?.length ? { builder: { slug: { in: params.builderSlugs } } } : {}),
+ ...(params.communitySlug ? { community: { slug: params.communitySlug } } : {}),
+ ...(params.statuses?.length ? { constructionStatus: { in: params.statuses as never[] } } : {}),
+ ...(params.storiesEq !== undefined ? { stories: params.storiesEq } : {}),
+ ...(params.garageMin !== undefined ? { garageSpaces: { gte: params.garageMin } } : {}),
+ ...(params.state ? { state: params.state } : {}),
+ ...(params.city ? { city: { equals: params.city, mode: "insensitive" } } : {}),
+ ...(moveInCutoff
+ ? {
+ OR: [
+ { constructionStatus: "MOVE_IN_READY" },
+ { estCompletionDate: { lte: moveInCutoff } },
+ ],
+ }
+ : {}),
+ ...(params.hasIncentives
+ ? {
+ OR: [
+ { incentives: { some: {} } },
+ { community: { incentives: { some: { OR: [{ expiresAt: { gte: now } }, { evergreenLabel: { not: null } }] } } } },
+ ],
+ }
+ : {}),
+ ...(params.ageRestricted !== undefined ? { community: { ageRestricted: params.ageRestricted } } : {}),
+ ...(params.hoaMax !== undefined ? { community: { hoaFeeMonthly: { lte: params.hoaMax } } } : {}),
+ ...(params.schoolDistrict ? { community: { schoolDistrict: params.schoolDistrict } } : {}),
+ };
+}
+
+const HOME_INCLUDE = {
+ community: { select: { slug: true, name: true, schoolDistrict: true, hoaFeeMonthly: true, ageRestricted: true } },
+ builder: { select: { slug: true, name: true } },
+ floorPlan: { select: { id: true, name: true } },
+} satisfies Prisma.InventoryHomeInclude;
+
+export type SearchHome = Prisma.InventoryHomeGetPayload<{ include: typeof HOME_INCLUDE }>;
+
+export interface SearchResult {
+ homes: SearchHome[];
+ total: number;
+ page: number;
+ pageSize: number;
+ location: ResolvedLocation | null;
+}
+
+export async function runSearch(params: SearchParams): Promise<SearchResult> {
+ const page = Math.max(1, params.page ?? 1);
+ const pageSize = Math.min(60, Math.max(1, params.pageSize ?? 24));
+
+ let location: ResolvedLocation | null = null;
+ let bbox = params.bbox;
+ if (!bbox && params.q) {
+ location = await resolveLocation(params.q);
+ bbox = location?.bbox;
+ }
+
+ const where = buildWhere(params, bbox);
+
+ const orderBy: Prisma.InventoryHomeOrderByWithRelationInput[] =
+ params.sort === "price_asc"
+ ? [{ price: { sort: "asc", nulls: "last" } }]
+ : params.sort === "price_desc"
+ ? [{ price: { sort: "desc", nulls: "last" } }]
+ : params.sort === "sqft_desc"
+ ? [{ sqft: { sort: "desc", nulls: "last" } }]
+ : [{ publishedAt: "desc" }]; // newest — the organic default
+
+ // sort=closest is the one raw-SQL path (haversine); fall back to newest
+ // when there is no center point to measure from.
+ if (params.sort === "closest" && (location || params.bbox)) {
+ const centerLat = location?.lat ?? (params.bbox!.minLat + params.bbox!.maxLat) / 2;
+ const centerLon = location?.lon ?? (params.bbox!.minLon + params.bbox!.maxLon) / 2;
+ const [homesRaw, total] = await Promise.all([
+ prisma.$queryRaw<{ id: string }[]>(Prisma.sql`
+ SELECT id FROM "InventoryHome"
+ WHERE status = 'PUBLISHED'
+ AND lat BETWEEN ${bbox!.minLat} AND ${bbox!.maxLat}
+ AND lon BETWEEN ${bbox!.minLon} AND ${bbox!.maxLon}
+ ORDER BY 2 * 3958.8 * asin(sqrt(
+ power(sin(radians((lat::float8 - ${centerLat}) / 2)), 2) +
+ cos(radians(${centerLat})) * cos(radians(lat::float8)) *
+ power(sin(radians((lon::float8 - ${centerLon}) / 2)), 2)
+ ))
+ LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
+ `),
+ prisma.inventoryHome.count({ where }),
+ ]);
+ const ids = homesRaw.map((h) => h.id);
+ const unordered = await prisma.inventoryHome.findMany({ where: { id: { in: ids } }, include: HOME_INCLUDE });
+ const byId = new Map(unordered.map((h) => [h.id, h]));
+ return { homes: ids.map((id) => byId.get(id)!).filter(Boolean), total, page, pageSize, location };
+ }
+
+ const [homes, total] = await Promise.all([
+ prisma.inventoryHome.findMany({
+ where,
+ orderBy,
+ include: HOME_INCLUDE,
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ prisma.inventoryHome.count({ where }),
+ ]);
+
+ return { homes, total, page, pageSize, location };
+}
+
+/**
+ * Facet counts with the self-dimension excluded (the corkwallcovering
+ * filterProducts(spec, skip) pattern): each facet's counts are computed
+ * with every OTHER filter applied but not its own.
+ */
+export async function runFacets(params: SearchParams) {
+ let bbox = params.bbox;
+ if (!bbox && params.q) bbox = (await resolveLocation(params.q))?.bbox;
+
+ const without = (key: keyof SearchParams): Prisma.InventoryHomeWhereInput =>
+ buildWhere({ ...params, [key]: undefined }, bbox);
+
+ const [byStatus, byBuilder, byBeds, byType] = await Promise.all([
+ prisma.inventoryHome.groupBy({
+ by: ["constructionStatus"],
+ where: without("statuses"),
+ _count: { _all: true },
+ }),
+ prisma.inventoryHome.groupBy({
+ by: ["builderId"],
+ where: without("builderSlugs"),
+ _count: { _all: true },
+ }),
+ prisma.inventoryHome.groupBy({
+ by: ["beds"],
+ where: without("bedsMin"),
+ _count: { _all: true },
+ }),
+ prisma.inventoryHome.groupBy({
+ by: ["homeType"],
+ where: without("homeTypes"),
+ _count: { _all: true },
+ }),
+ ]);
+
+ const builders = await prisma.builder.findMany({
+ where: { id: { in: byBuilder.map((b) => b.builderId) } },
+ select: { id: true, slug: true, name: true },
+ });
+ const builderById = new Map(builders.map((b) => [b.id, b]));
+
+ return {
+ constructionStatus: byStatus.map((s) => ({ value: s.constructionStatus, count: s._count._all })),
+ builder: byBuilder.map((b) => ({
+ value: builderById.get(b.builderId)?.slug ?? b.builderId,
+ label: builderById.get(b.builderId)?.name ?? b.builderId,
+ count: b._count._all,
+ })),
+ beds: byBeds
+ .filter((b) => b.beds !== null)
+ .sort((a, b) => (a.beds ?? 0) - (b.beds ?? 0))
+ .map((b) => ({ value: b.beds, count: b._count._all })),
+ homeType: byType.map((t) => ({ value: t.homeType, count: t._count._all })),
+ };
+}
diff --git a/packages/shared-ui/src/MapView.tsx b/packages/shared-ui/src/MapView.tsx
new file mode 100644
index 0000000..742fd31
--- /dev/null
+++ b/packages/shared-ui/src/MapView.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useEffect } from "react";
+import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
+import "leaflet/dist/leaflet.css";
+import L from "leaflet";
+
+/**
+ * Props-only map contract — Leaflet + OSM today; a commercial provider can
+ * swap in behind this interface without touching callers. Import this
+ * component with next/dynamic ssr:false (Leaflet needs window).
+ */
+
+export interface MapMarker {
+ id: string;
+ lat: number;
+ lon: number;
+ label: string;
+ sublabel?: string;
+ href?: string;
+}
+
+export interface MapViewProps {
+ markers: MapMarker[];
+ center?: [number, number];
+ zoom?: number;
+ onMoveEnd?: (bbox: { minLat: number; maxLat: number; minLon: number; maxLon: number }) => void;
+ className?: string;
+}
+
+// Default marker sprites don't resolve under bundlers; use a divIcon dot.
+const dotIcon = L.divIcon({
+ className: "",
+ html: '<div style="width:14px;height:14px;border-radius:50%;background:#0f766e;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)"></div>',
+ iconSize: [14, 14],
+ iconAnchor: [7, 7],
+});
+
+function MoveListener({ onMoveEnd }: { onMoveEnd?: MapViewProps["onMoveEnd"] }) {
+ const map = useMap();
+ useEffect(() => {
+ if (!onMoveEnd) return;
+ const handler = () => {
+ const b = map.getBounds();
+ onMoveEnd({
+ minLat: b.getSouth(),
+ maxLat: b.getNorth(),
+ minLon: b.getWest(),
+ maxLon: b.getEast(),
+ });
+ };
+ map.on("moveend", handler);
+ return () => {
+ map.off("moveend", handler);
+ };
+ }, [map, onMoveEnd]);
+ return null;
+}
+
+function FitMarkers({ markers }: { markers: MapMarker[] }) {
+ const map = useMap();
+ useEffect(() => {
+ if (markers.length === 0) return;
+ const bounds = L.latLngBounds(markers.map((m) => [m.lat, m.lon] as [number, number]));
+ map.fitBounds(bounds.pad(0.15), { maxZoom: 13 });
+ // Only refit when the marker SET changes, not on every pan.
+ }, [map, markers.map((m) => m.id).join(",")]);
+ return null;
+}
+
+export function MapView({ markers, center = [31.5, -95.0], zoom = 5, onMoveEnd, className }: MapViewProps) {
+ return (
+ <MapContainer center={center} zoom={zoom} className={className} style={{ width: "100%", height: "100%" }}>
+ <TileLayer
+ attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+ url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
+ />
+ <MoveListener onMoveEnd={onMoveEnd} />
+ <FitMarkers markers={markers} />
+ {markers.map((m) => (
+ <Marker key={m.id} position={[m.lat, m.lon]} icon={dotIcon}>
+ <Popup>
+ {m.href ? (
+ <a href={m.href} style={{ fontWeight: 600 }}>
+ {m.label}
+ </a>
+ ) : (
+ <strong>{m.label}</strong>
+ )}
+ {m.sublabel ? <div>{m.sublabel}</div> : null}
+ </Popup>
+ </Marker>
+ ))}
+ </MapContainer>
+ );
+}
diff --git a/packages/shared-ui/src/badges.tsx b/packages/shared-ui/src/badges.tsx
new file mode 100644
index 0000000..9783bae
--- /dev/null
+++ b/packages/shared-ui/src/badges.tsx
@@ -0,0 +1,49 @@
+import type { ReactElement } from "react";
+
+/** Verification labels — the consumer-trust surface. Colors are fixed per label. */
+const LABEL_STYLES: Record<string, { text: string; classes: string }> = {
+ VERIFIED_FROM_BUILDER: { text: "Verified directly from builder", classes: "bg-emerald-100 text-emerald-900 border-emerald-300" },
+ BUILDER_WEBSITE: { text: "Verified from builder website", classes: "bg-teal-100 text-teal-900 border-teal-300" },
+ BUILDER_SUBMITTED: { text: "Builder-submitted", classes: "bg-sky-100 text-sky-900 border-sky-300" },
+ NEEDS_REVERIFICATION: { text: "Needs reverification", classes: "bg-amber-100 text-amber-900 border-amber-300" },
+ NO_LONGER_AVAILABLE: { text: "No longer available", classes: "bg-neutral-200 text-neutral-700 border-neutral-300" },
+ DEMONSTRATION: { text: "Demonstration data", classes: "bg-violet-100 text-violet-900 border-violet-300" },
+};
+
+export function VerificationBadge({ label }: { label: string }): ReactElement {
+ const style = LABEL_STYLES[label] ?? { text: label, classes: "bg-neutral-100 text-neutral-700 border-neutral-300" };
+ return (
+ <span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${style.classes}`}>
+ {style.text}
+ </span>
+ );
+}
+
+/**
+ * Created date + time chip — mandatory on every admin card (standing rule).
+ * Renders in the viewer's local timezone; full ISO in title for precision.
+ */
+export function CreatedChip({ date }: { date: Date | string }): ReactElement {
+ const d = typeof date === "string" ? new Date(date) : date;
+ return (
+ <span
+ title={d.toISOString()}
+ className="inline-flex items-center gap-1 rounded bg-neutral-100 px-2 py-0.5 text-xs text-neutral-600"
+ >
+ 🕓{" "}
+ {d.toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ })}
+ </span>
+ );
+}
+
+export function fmtPrice(price: number | string | null | undefined): string {
+ if (price === null || price === undefined) return "Price not published";
+ const n = typeof price === "string" ? Number(price) : price;
+ return n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
+}
diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts
new file mode 100644
index 0000000..9ab6296
--- /dev/null
+++ b/packages/shared-ui/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./MapView";
+export * from "./badges";
diff --git a/packages/shared/src/canonical.test.ts b/packages/shared/src/canonical.test.ts
index 4c90b59..e1db91c 100644
--- a/packages/shared/src/canonical.test.ts
+++ b/packages/shared/src/canonical.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { canonicalKey, normalizeAddress } from "./canonical.js";
+import { canonicalKey, normalizeAddress } from "./canonical";
describe("normalizeAddress", () => {
it("lowercases, strips punctuation, abbreviates street words", () => {
diff --git a/packages/shared/src/geo.test.ts b/packages/shared/src/geo.test.ts
index d7570ea..c39ac01 100644
--- a/packages/shared/src/geo.test.ts
+++ b/packages/shared/src/geo.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { bboxAround, haversineMiles, latLonInState, stateForZip } from "./geo.js";
+import { bboxAround, haversineMiles, latLonInState, stateForZip } from "./geo";
describe("bboxAround", () => {
it("produces a symmetric box around the center", () => {
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 9d7c174..3c1fe39 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,3 +1,3 @@
-export * from "./canonical.js";
-export * from "./geo.js";
-export * from "./queue.js";
+export * from "./canonical";
+export * from "./geo";
+export * from "./queue";
diff --git a/packages/validation/src/engine.ts b/packages/validation/src/engine.ts
index 7de159f..2453b14 100644
--- a/packages/validation/src/engine.ts
+++ b/packages/validation/src/engine.ts
@@ -4,7 +4,7 @@ import type {
ValidationEventInput,
ValidationOutcome,
ValidationRule,
-} from "./types.js";
+} from "./types";
/**
* Run every applicable rule and fold the results into one verdict.
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index c8dcd35..e79f3c1 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -1,3 +1,3 @@
-export * from "./types.js";
-export * from "./rules.js";
-export * from "./engine.js";
+export * from "./types";
+export * from "./rules";
+export * from "./engine";
diff --git a/packages/validation/src/rules.test.ts b/packages/validation/src/rules.test.ts
index b8057ec..3ada84b 100644
--- a/packages/validation/src/rules.test.ts
+++ b/packages/validation/src/rules.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { runValidation } from "./engine.js";
+import { runValidation } from "./engine";
import {
ALL_RULES,
bigPriceChange,
@@ -12,8 +12,8 @@ import {
priceWithinBounds,
sqftPositive,
stateZipAgreement,
-} from "./rules.js";
-import type { RuleContext, RuleDb, StagedCandidate } from "./types.js";
+} from "./rules";
+import type { RuleContext, RuleDb, StagedCandidate } from "./types";
function fv<T>(value: T | null, raw?: string): {
value: T | null; raw: string | null; evidenceText: string | null; sourceUrl: string; confidence: number;
diff --git a/packages/validation/src/rules.ts b/packages/validation/src/rules.ts
index be94713..a84a28c 100644
--- a/packages/validation/src/rules.ts
+++ b/packages/validation/src/rules.ts
@@ -1,5 +1,5 @@
import { latLonInState, stateForZip } from "@spechomes/shared";
-import { fieldValue, type StagedCandidate, type ValidationRule } from "./types.js";
+import { fieldValue, type StagedCandidate, type ValidationRule } from "./types";
export const VALIDATOR_VERSION = "1.0.0";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 391fb4a..084f4d2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@spechomes/database':
specifier: workspace:*
version: link:../../packages/database
+ '@spechomes/publisher':
+ specifier: workspace:*
+ version: link:../../packages/publisher
'@spechomes/schemas':
specifier: workspace:*
version: link:../../packages/schemas
@@ -148,6 +151,9 @@ importers:
'@spechomes/database':
specifier: workspace:*
version: link:../../packages/database
+ '@spechomes/publisher':
+ specifier: workspace:*
+ version: link:../../packages/publisher
'@spechomes/schemas':
specifier: workspace:*
version: link:../../packages/schemas
@@ -243,6 +249,25 @@ importers:
specifier: ^4.0.0
version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
+ packages/publisher:
+ dependencies:
+ '@spechomes/database':
+ specifier: workspace:*
+ version: link:../database
+ '@spechomes/shared':
+ specifier: workspace:*
+ version: link:../shared
+ devDependencies:
+ '@types/node':
+ specifier: ^22.10.5
+ version: 22.20.1
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vitest:
+ specifier: ^4.0.0
+ version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
+
packages/schemas:
dependencies:
zod:
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 0ce2d55..7a4f65a 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -3,7 +3,11 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
- "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "lib": [
+ "ES2022",
+ "DOM",
+ "DOM.Iterable"
+ ],
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
@@ -14,14 +18,33 @@
"jsx": "preserve",
"baseUrl": ".",
"paths": {
- "@spechomes/database": ["packages/database/src/index.ts"],
- "@spechomes/schemas": ["packages/schemas/src/index.ts"],
- "@spechomes/validation": ["packages/validation/src/index.ts"],
- "@spechomes/search": ["packages/search/src/index.ts"],
- "@spechomes/source-registry": ["packages/source-registry/src/index.ts"],
- "@spechomes/shared": ["packages/shared/src/index.ts"],
- "@spechomes/shared-ui": ["packages/shared-ui/src/index.ts"],
- "@spechomes/collectors-common": ["collectors/common/src/index.ts"]
+ "@spechomes/database": [
+ "packages/database/src/index.ts"
+ ],
+ "@spechomes/schemas": [
+ "packages/schemas/src/index.ts"
+ ],
+ "@spechomes/validation": [
+ "packages/validation/src/index.ts"
+ ],
+ "@spechomes/search": [
+ "packages/search/src/index.ts"
+ ],
+ "@spechomes/source-registry": [
+ "packages/source-registry/src/index.ts"
+ ],
+ "@spechomes/shared": [
+ "packages/shared/src/index.ts"
+ ],
+ "@spechomes/shared-ui": [
+ "packages/shared-ui/src/index.ts"
+ ],
+ "@spechomes/collectors-common": [
+ "collectors/common/src/index.ts"
+ ],
+ "@spechomes/publisher": [
+ "packages/publisher/src/index.ts"
+ ]
}
}
}
← 75e00bb worker pipeline (fetch/extract/validate/publish) + CLI + syn
·
back to Homesonspec
·
fix Leaflet SSR: keep MapView out of the shared-ui barrel, s 7c746cc →