← back to Homesonspec

apps/web/src/app/homes/[id]/page.tsx

462 lines

import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@homesonspec/database";
import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";
import CorrectionForm from "./CorrectionForm";
import LeadForm from "./LeadForm";
import NearbySubs from "./NearbySubs";
import { parcelLink } from "../../../lib/parcel";
import { matchSubsForHome } from "../../../lib/contractors";

export const dynamic = "force-dynamic";

const STATUS_LABELS: Record<string, string> = {
  MOVE_IN_READY: "Move-in ready",
  UNDER_CONSTRUCTION: "Under construction",
  PLANNED: "Planned",
};
const STATUS_DOT: Record<string, string> = {
  MOVE_IN_READY: "#16a34a",
  UNDER_CONSTRUCTION: "#f59e0b",
  PLANNED: "#3b82f6",
};

export default async function HomeDetailPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const home = await prisma.inventoryHome.findUnique({
    where: { id },
    include: {
      community: true,
      builder: true,
      floorPlan: true,
      source: true,
      incentives: true,
    },
  });
  if (!home || home.status !== "PUBLISHED" || home.isDemo) notFound();

  const [communityIncentives, evidence, otherHomes, subs] = await Promise.all([
    prisma.incentive.findMany({ where: { communityId: home.communityId, scope: "COMMUNITY" } }),
    prisma.sourceEvidence.findMany({
      where: { entityType: "INVENTORY_HOME", entityId: home.id },
      orderBy: { field: "asc" },
    }),
    // "Carousel of homes in the project" — other available homes in this community.
    prisma.inventoryHome.findMany({
      where: {
        communityId: home.communityId,
        status: "PUBLISHED",
        isDemo: false,
        freshness: { not: "INACTIVE" },
        id: { not: home.id },
      },
      select: {
        id: true, street: true, city: true, state: true, price: true,
        beds: true, bathsTotal: true, sqft: true, constructionStatus: true, images: true,
      },
      orderBy: { publishedAt: "desc" },
      take: 12,
    }),
    // "Find subs near this home" — nearby licensed CA subcontractors grouped by
    // trade, from the shared usre/CSLB contractor API. CA-only (the CSLB source
    // covers California); non-CA homes short-circuit to the empty-state. This
    // never throws — matchSubsForHome returns a graceful empty result on failure.
    home.state?.toUpperCase() === "CA"
      ? matchSubsForHome({ city: home.city, county: home.community.county, zip: home.zip })
      : Promise.resolve({ matches: {}, criteria: null, source_as_of: null, ok: false, error: null }),
  ]);
  const incentives = [...home.incentives, ...communityIncentives];
  // Media-rights safe default: builder photos are OFF unless BUILDER_IMAGES_ENABLED=1
  // is explicitly set (must match the homepage default — facts-only per docs/data-rights/POLICY.md).
  const showImages = process.env.BUILDER_IMAGES_ENABLED === "1";

  // Dedupe evidence to one row per field (newest retrieval). Re-crawls produce a
  // fresh snapshot each time (dynamic source content), so raw evidence accumulates
  // ~1 row per field per crawl — show only the latest so the table stays readable.
  const latestEvidence = Object.values(
    evidence.reduce<Record<string, (typeof evidence)[number]>>((acc, row) => {
      const cur = acc[row.field];
      if (!cur || row.retrievedAt > cur.retrievedAt) acc[row.field] = row;
      return acc;
    }, {}),
  ).sort((a, b) => a.field.localeCompare(b.field));

  // href-drill: builds drill URLs into the URL-addressable search grid, so every
  // fact on this detail page routes to its deeper, filtered view.
  const drill = (kv: Record<string, string>): string => {
    const params = new URLSearchParams();
    for (const [k, v] of Object.entries(kv)) if (v) params.set(k, v);
    return `/search?${params.toString()}`;
  };

  // Each fact is [label, value, href?] — href makes the fact a drill link into
  // the URL-addressable grid (href-drill rule: no dead-end data points).
  const facts: [string, string, string?][] = [
    ["Price", fmtPrice(home.price === null ? null : Number(home.price))],
    ["Status", STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus, drill({ status: home.constructionStatus })],
    ["Beds", home.beds?.toString() ?? "Not published", home.beds != null ? drill({ bedsMin: String(home.beds) }) : undefined],
    ["Baths", home.bathsTotal?.toString() ?? "Not published", home.bathsTotal != null ? drill({ bathsMin: String(home.bathsTotal) }) : undefined],
    ["Square feet", home.sqft?.toLocaleString() ?? "Not published", home.sqft != null ? drill({ sqftMin: String(Math.max(0, Math.floor((home.sqft * 0.8) / 100) * 100)), sqftMax: String(Math.ceil((home.sqft * 1.2) / 100) * 100) }) : undefined],
    ["Stories", home.stories?.toString() ?? "Not published", home.stories != null ? drill({ stories: String(home.stories) }) : undefined],
    ["Garage", home.garageSpaces ? `${home.garageSpaces}-car` : "Not published", home.garageSpaces ? drill({ garageMin: String(home.garageSpaces) }) : undefined],
    ["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",
    ],
  ];

  // Long-tail specs captured into the extensible `specs` map (per builder). Rendered
  // in their own "Additional details" block, separate from the core facts. Only
  // known keys with a label surface; unknown keys are ignored (never fabricated).
  const SPEC_LABELS: Record<string, string> = {
    estMonthlyPayment: "Est. monthly payment",
    originalPrice: "Original price",
    totalSalePrice: "Sale price",
    planCode: "Plan code",
    multiGen: "Multi-gen layout",
    allowOffer: "Accepts offers",
    qmiAvailable: "Quick move-in",
    requiresPromo: "Promo required",
  };
  const rawSpecs =
    home.specs && typeof home.specs === "object" && !Array.isArray(home.specs)
      ? (home.specs as Record<string, unknown>)
      : {};
  const specFacts: [string, string][] = Object.entries(rawSpecs)
    .filter(([k]) => SPEC_LABELS[k])
    .map(([k, v]): [string, string] => {
      if (typeof v === "boolean") return [SPEC_LABELS[k]!, v ? "Yes" : "No"];
      if (k === "estMonthlyPayment") return [SPEC_LABELS[k]!, `${fmtPrice(Number(v))}/mo`];
      if (k === "originalPrice" || k === "totalSalePrice") return [SPEC_LABELS[k]!, fmtPrice(Number(v))];
      return [SPEC_LABELS[k]!, String(v)];
    });
  const purchaseUrl = typeof rawSpecs.purchaseUrl === "string" ? rawSpecs.purchaseUrl : null;

  // Parcel / public-records drill. We have no APN column (never fabricated) — we
  // surface the honest identifiers we DO have (lot number + county) and link OUT
  // to the county's public parcel-search surface (public-records backbone).
  const parcel = parcelLink(home.community.county, home.state, home.city);

  return (
    <div className="mx-auto max-w-5xl px-4 py-8">
      <nav className="text-sm text-neutral-500">
        <Link href="/search" className="hover:text-brand-700">Search</Link> ·{" "}
        <Link href={`/communities/${home.community.slug}`} className="hover:text-brand-700">
          {home.community.name}
        </Link>
      </nav>

      {/* Branded hero header. Honest — no fabricated photography; builder imagery
          renders here once ingested (see the image-ingestion follow-up). */}
      <div className="mt-3 overflow-hidden rounded-2xl border border-brand-600/30 shadow-[var(--shadow-card)]">
        <div className="relative h-56 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500 sm:h-72">
          {showImages && home.images[0]?.startsWith("http") ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img src={home.images[0]} alt={`${home.street ?? home.community.name} exterior`}
              fetchPriority="high" className="absolute inset-0 h-full w-full object-cover" />
          ) : null}
          {/* scrim keeps the wordmark + price legible over any photo */}
          <div className="absolute inset-0 bg-gradient-to-t from-brand-950/85 via-brand-950/25 to-brand-950/10" />
          <span className="badge-ready absolute left-4 top-4 gap-1.5">
            <span className="h-2 w-2 rounded-full" style={{ background: STATUS_DOT[home.constructionStatus] ?? "#16a34a" }} />
            {STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus}
          </span>
          <div className="absolute inset-x-0 bottom-0 flex flex-wrap items-end justify-between gap-3 bg-gradient-to-t from-brand-950/80 to-transparent p-5 text-white">
            <div>
              <h1 className="font-display text-2xl font-semibold sm:text-3xl">
                {home.street ?? "Address available from builder"}
              </h1>
              <p className="mt-1 text-sm text-brand-100">
                <Link href={drill({ city: home.city, st: home.state })} className="hover:text-white hover:underline">{home.city}</Link>,{" "}
                <Link href={drill({ st: home.state })} className="hover:text-white hover:underline">{home.state}</Link> {home.zip} ·{" "}
                <Link href={`/communities/${home.community.slug}`} className="hover:text-white hover:underline">{home.community.name}</Link>
              </p>
            </div>
            <div className="text-right">
              <div className="font-display text-3xl font-semibold" data-testid="detail-price">
                {fmtPrice(home.price === null ? null : Number(home.price))}
              </div>
              {home.previousPrice && home.price && Number(home.previousPrice) !== Number(home.price) ? (
                <div className="text-sm text-brand-200 line-through">{fmtPrice(Number(home.previousPrice))}</div>
              ) : null}
            </div>
          </div>
        </div>
      </div>
      <p className="mt-2 text-sm text-neutral-600">
        Built by{" "}
        <Link href={`/builders/${home.builder.slug}`} className="font-semibold text-brand-700 hover:underline">
          {home.builder.name}
        </Link>
      </p>

      {showImages && home.images.filter((s) => s?.startsWith("http")).length > 1 ? (
        <div className="mt-3 flex gap-2 overflow-x-auto pb-1" data-testid="home-gallery">
          {home.images.filter((s) => s?.startsWith("http")).slice(0, 8).map((src, i) => (
            // eslint-disable-next-line @next/next/no-img-element
            <img key={i} src={src} alt={`${home.community.name} photo ${i + 1}`} loading="lazy"
              className="h-20 w-28 shrink-0 rounded-lg object-cover ring-1 ring-neutral-200" />
          ))}
        </div>
      ) : null}

      {/* Verification block — the trust surface. */}
      <div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-neutral-200 bg-white p-3 text-sm shadow-[var(--shadow-card)]" data-testid="verification-block">
        <VerificationBadge label={home.verificationLabel} />
        <span className="text-neutral-600">
          Last verified:{" "}
          {home.lastVerifiedAt
            ? home.lastVerifiedAt.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
            : "not yet verified"}
        </span>
        {home.source ? <span className="text-neutral-500">Source: {home.source.name}</span> : null}
        {home.sourceUrl ? (
          <a href={home.sourceUrl} rel="nofollow noopener noreferrer" className="text-brand-700 hover:underline">
            Original source
          </a>
        ) : null}
      </div>

      <div className="mt-6 grid gap-8 md:grid-cols-[1fr_320px]">
        <div>
          <h2 className="font-display text-xl font-semibold text-brand-900">Home facts</h2>
          <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
            {facts.map(([label, value, href]) => (
              <div key={label} className="border-b border-neutral-100 py-1.5">
                <dt className="text-neutral-500">{label}</dt>
                <dd className="font-medium">
                  {href ? (
                    <Link href={href} className="text-brand-800 hover:text-brand-600 hover:underline decoration-brand-300 underline-offset-2"
                      data-testid={`fact-drill-${label.toLowerCase().replace(/\s+/g, "-")}`}>
                      {value}
                    </Link>
                  ) : (
                    value
                  )}
                </dd>
              </div>
            ))}
          </dl>
          <p className="mt-2 text-xs text-neutral-400">
            “Not published” means the source did not state this fact. HomesOnSpec never fills in missing values.
          </p>

          {/* Parcel & public records — the lot/parcel identifier plus a link OUT to the
              county's public assessor / property-records search. We surface only the
              identifiers we honestly hold (lot number + county); we never fabricate an
              APN. The assessor link is public-records backbone (compliant). */}
          <section className="mt-8" data-testid="parcel-section">
            <h2 className="font-display text-xl font-semibold text-brand-900">Parcel &amp; public records</h2>
            <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
              <div className="border-b border-neutral-100 py-1.5">
                <dt className="text-neutral-500">Lot number</dt>
                <dd className="font-medium">{home.lotNumber ? `Lot ${home.lotNumber}` : "Not published"}</dd>
              </div>
              <div className="border-b border-neutral-100 py-1.5">
                <dt className="text-neutral-500">County</dt>
                <dd className="font-medium">{home.community.county ?? "Not published"}</dd>
              </div>
              <div className="border-b border-neutral-100 py-1.5">
                <dt className="text-neutral-500">City / State</dt>
                <dd className="font-medium">
                  <Link href={drill({ city: home.city, st: home.state })} className="text-brand-800 hover:text-brand-600 hover:underline">
                    {home.city}, {home.state}
                  </Link>
                </dd>
              </div>
            </dl>
            {parcel ? (
              <a href={parcel.url} target="_blank" rel="nofollow noopener noreferrer"
                className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-accent-600 hover:text-accent-700"
                data-testid="assessor-link">
                🔎 Look up this parcel on {parcel.authority} ↗
              </a>
            ) : null}
            <p className="mt-2 text-xs text-neutral-400">
              HomesOnSpec does not publish an APN. Use the county&rsquo;s public property-records
              search above to look up the official parcel record by address or lot.
            </p>
          </section>

          {/* Find subs near this home — nearby licensed CA subcontractors by trade. */}
          <NearbySubs
            matches={subs.matches}
            error={subs.error}
            sourceAsOf={subs.source_as_of}
            city={home.city}
            county={home.community.county}
            state={home.state}
          />

          {(specFacts.length > 0 || purchaseUrl) && (
            <section className="mt-8">
              <h2 className="font-display text-xl font-semibold text-brand-900">Additional details</h2>
              {specFacts.length > 0 && (
                <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
                  {specFacts.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>
              )}
              {purchaseUrl && (
                <a
                  href={purchaseUrl}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="mt-3 inline-block text-sm font-medium text-accent-600 hover:text-accent-700"
                >
                  View this home on the builder&rsquo;s site →
                </a>
              )}
            </section>
          )}

          {incentives.length > 0 && (
            <section className="mt-8">
              <h2 className="font-display text-xl font-semibold text-brand-900">Incentives</h2>
              <ul className="mt-3 space-y-3">
                {incentives.map((incentive) => (
                  <li key={incentive.id} className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm">
                    <div className="font-semibold">{incentive.title}</div>
                    {incentive.description && <p className="mt-1 text-neutral-600">{incentive.description}</p>}
                    <div className="mt-1 text-xs text-neutral-500">
                      {incentive.expiresAt
                        ? `Expires ${incentive.expiresAt.toLocaleDateString()}`
                        : incentive.evergreenLabel ?? ""}
                    </div>
                  </li>
                ))}
              </ul>
            </section>
          )}

          <section className="mt-8">
            <h2 className="font-display text-xl font-semibold text-brand-900">Data provenance</h2>
            <p className="mt-1 text-sm text-neutral-500">
              Every displayed fact traces to source evidence with a retrieval timestamp.
            </p>
            <div className="mt-3 overflow-x-auto">
              <table className="w-full text-left text-xs" data-testid="evidence-table">
                <thead>
                  <tr className="border-b border-neutral-200 text-neutral-500">
                    <th className="py-1.5 pr-3">Field</th>
                    <th className="py-1.5 pr-3">Source text</th>
                    <th className="py-1.5 pr-3">Retrieved</th>
                    <th className="py-1.5">Confidence</th>
                  </tr>
                </thead>
                <tbody>
                  {latestEvidence.map((row) => (
                    <tr key={row.id} className="border-b border-neutral-100">
                      <td className="py-1.5 pr-3 font-medium">{row.field}</td>
                      <td className="py-1.5 pr-3">{row.evidenceText ?? <em className="text-neutral-400">not stated by source</em>}</td>
                      <td className="py-1.5 pr-3">{row.retrievedAt.toLocaleDateString()}</td>
                      <td className="py-1.5">{(row.confidence * 100).toFixed(0)}%</td>
                    </tr>
                  ))}
                  {latestEvidence.length === 0 && (
                    <tr><td colSpan={4} className="py-2 text-neutral-400">No field-level evidence recorded.</td></tr>
                  )}
                </tbody>
              </table>
            </div>
          </section>

          <section className="mt-8">
            <CorrectionForm entityId={home.id} />
          </section>
        </div>

        <aside className="self-start lg:sticky lg:top-[84px]">
          <div className="card p-4">
            <h2 className="font-display text-lg font-semibold text-brand-900">Contact {home.builder.name}</h2>
            <p className="mt-1 text-sm text-neutral-600">
              This builder is actively selling this home — send a note and they&rsquo;ll follow up. No obligation.
            </p>
            {/* For spec homes the BUILDER is the firm/broker of record (sold builder-direct),
                so the contact card ties the home to the builder and links its firm site + the
                listing page. Phone falls back to the builder's own line when the community
                sales-office phone is absent. (agent-own-site layer = the drafted migration.) */}
            {(() => {
              const phone = home.community.salesPhone ?? home.builder.contactPhone;
              return phone ? (
                <>
                  <a href={`tel:${phone.replace(/[^0-9+]/g, "")}`}
                    className="btn-accent mt-3 w-full justify-center" data-testid="sales-phone">
                    📞 Call {phone}
                  </a>
                  <p className="mt-2 rounded-lg bg-brand-50 px-3 py-2 text-center text-xs font-medium text-brand-800" data-testid="mention-hos">
                    💬 When you call, mention you saw it on <span className="font-semibold">HomesOnSpec.com</span>
                  </p>
                </>
              ) : null;
            })()}
            {home.builder.websiteUrl ? (
              <a href={home.builder.websiteUrl} rel="nofollow noopener noreferrer" target="_blank"
                className="mt-2 block text-center text-sm font-medium text-brand-700 hover:underline" data-testid="builder-site">
                🏢 Visit {home.builder.name} ↗
              </a>
            ) : null}
            {home.sourceUrl ? (
              <a href={home.sourceUrl} rel="nofollow noopener noreferrer" target="_blank"
                className="mt-1 block text-center text-sm font-medium text-brand-700 hover:underline" data-testid="listing-link">
                🔗 View this listing ↗
              </a>
            ) : null}
            <div className="mt-3">
              <LeadForm homeId={home.id} communityId={home.communityId} builderName={home.builder.name} />
            </div>
          </div>
        </aside>
      </div>

      {/* Carousel of homes in the project — other available homes in this community. */}
      {otherHomes.length > 0 && (
        <section className="mt-12">
          <div className="flex items-end justify-between">
            <h2 className="font-display text-xl font-semibold text-brand-900">
              More homes in {home.community.name}
            </h2>
            <Link href={`/communities/${home.community.slug}`} className="text-sm font-semibold text-brand-700 hover:text-brand-800">
              View community →
            </Link>
          </div>
          <div className="mt-4 flex snap-x gap-4 overflow-x-auto pb-3" data-testid="community-carousel">
            {otherHomes.map((h) => (
              <Link key={h.id} href={`/homes/${h.id}`} className="card-interactive w-64 shrink-0 snap-start overflow-hidden">
                <div className="relative h-32 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500">
                  {showImages && h.images[0]?.startsWith("http") ? (
                    // eslint-disable-next-line @next/next/no-img-element
                    <img src={h.images[0]} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
                  ) : null}
                  <span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-white/95 px-2 py-0.5 text-[11px] font-medium text-neutral-700">
                    <span className="h-1.5 w-1.5 rounded-full" style={{ background: STATUS_DOT[h.constructionStatus] ?? "#16a34a" }} />
                    {STATUS_LABELS[h.constructionStatus] ?? h.constructionStatus}
                  </span>
                </div>
                <div className="p-3">
                  <div className="font-display text-lg font-semibold text-brand-900">
                    {fmtPrice(h.price === null ? null : Number(h.price))}
                  </div>
                  <div className="mt-0.5 truncate text-sm text-neutral-600">{h.street ?? "Address from builder"}</div>
                  <div className="mt-1 text-xs text-neutral-500">
                    {h.beds ?? "—"} bd · {h.bathsTotal?.toString() ?? "—"} ba · {h.sqft?.toLocaleString() ?? "—"} sqft
                  </div>
                </div>
              </Link>
            ))}
          </div>
        </section>
      )}
    </div>
  );
}