[object Object]

← back to Homesonspec

Builder images: capture per-home photos from Lennar feed (elevationImage/plan/community, verified 58/58 homes), images[] schema+migration, thread through publisher, render in detail hero+gallery+carousel with gradient fallback

13997e36159d339702ce5f9be3632b169ac0f957 · 2026-07-22 18:33:11 -0700 · Steve

Files touched

Diff

commit 13997e36159d339702ce5f9be3632b169ac0f957
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 18:33:11 2026 -0700

    Builder images: capture per-home photos from Lennar feed (elevationImage/plan/community, verified 58/58 homes), images[] schema+migration, thread through publisher, render in detail hero+gallery+carousel with gradient fallback
---
 apps/web/src/app/homes/[id]/page.tsx               | 25 +++++++++++--
 collectors/lennar/src/index.ts                     | 42 ++++++++++++++++++++++
 .../20260723012950_home_images/migration.sql       |  2 ++
 packages/database/prisma/schema.prisma             |  1 +
 packages/publisher/src/index.ts                    |  2 ++
 packages/schemas/src/index.ts                      |  3 ++
 6 files changed, 73 insertions(+), 2 deletions(-)

diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
index f8e3abd..b7196ff 100644
--- a/apps/web/src/app/homes/[id]/page.tsx
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -49,7 +49,7 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
       },
       select: {
         id: true, street: true, city: true, state: true, price: true,
-        beds: true, bathsTotal: true, sqft: true, constructionStatus: true,
+        beds: true, bathsTotal: true, sqft: true, constructionStatus: true, images: true,
       },
       orderBy: { publishedAt: "desc" },
       take: 12,
@@ -88,7 +88,14 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
       {/* 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-64">
+        <div className="relative h-56 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500 sm:h-72">
+          {home.images[0] ? (
+            // eslint-disable-next-line @next/next/no-img-element
+            <img src={home.images[0]} alt={`${home.street ?? home.community.name} exterior`}
+              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}
@@ -120,6 +127,16 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
         </Link>
       </p>
 
+      {home.images.length > 1 ? (
+        <div className="mt-3 flex gap-2 overflow-x-auto pb-1" data-testid="home-gallery">
+          {home.images.slice(0, 8).map((src, i) => (
+            // eslint-disable-next-line @next/next/no-img-element
+            <img key={i} src={src} alt={`${home.community.name} photo ${i + 1}`}
+              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} />
@@ -236,6 +253,10 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
             {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">
+                  {h.images[0] ? (
+                    // eslint-disable-next-line @next/next/no-img-element
+                    <img src={h.images[0]} alt="" 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}
diff --git a/collectors/lennar/src/index.ts b/collectors/lennar/src/index.ts
index e924766..f356737 100644
--- a/collectors/lennar/src/index.ts
+++ b/collectors/lennar/src/index.ts
@@ -57,6 +57,47 @@ function str(value: unknown): string | null {
   return typeof value === "string" && value.trim() ? value.trim() : null;
 }
 
+/**
+ * Pull a clean https image URL from a Lennar Apollo image node. Handles both
+ * ImageType ({ url }) and TitleImageType ({ image: { url } }), deref-ing refs,
+ * and drops the ?d=…&w=… CDN sizing query so the largest asset is stored.
+ */
+function imgUrl(apollo: Apollo, node: unknown): string | null {
+  const n = deref(apollo, node) ?? (node as Record<string, unknown> | null);
+  if (!n || typeof n !== "object") return null;
+  const inner = (deref(apollo, n.image) ?? n.image ?? n) as Record<string, unknown>;
+  const u = inner?.url;
+  return typeof u === "string" && /^https:\/\//.test(u) ? u.split("?")[0]! : null;
+}
+
+/**
+ * Builder listing photos for a home, best-effort and deduped: the homesite's own
+ * elevation photo first, then its plan's hero + elevation, then the community
+ * hero + gallery as fallback. Facts-only sources yield []; never guessed.
+ */
+function collectImages(
+  apollo: Apollo,
+  home: Record<string, unknown>,
+  plan: Record<string, unknown> | null,
+  rollup: Record<string, unknown> | undefined,
+): string[] {
+  const out: string[] = [];
+  const push = (v: unknown) => {
+    const u = imgUrl(apollo, v);
+    if (u) out.push(u);
+  };
+  push(home.elevationImage);
+  if (plan) {
+    push(plan.heroImage);
+    const el = plan['elevationImages({"first":1})'];
+    if (Array.isArray(el)) el.forEach(push);
+  }
+  push(rollup?.heroImage);
+  const gallery = rollup?.heroGalleryImages;
+  if (Array.isArray(gallery)) gallery.slice(0, 4).forEach(push);
+  return [...new Set(out)].slice(0, 6);
+}
+
 function numOrNull(value: unknown): number | null {
   const n = Number(value);
   return Number.isFinite(n) && n !== 0 ? n : null; // Lennar uses 0 for "no value"
@@ -193,6 +234,7 @@ export const lennarAdapter: SourceAdapter = {
             lon: fv(numOrNull(home.longitude), home.longitude === undefined ? null : String(home.longitude), page.url),
             planName: fv(planName, planName, page.url),
             availabilityStatus: fv(status, status, page.url),
+            images: fv<string[]>(collectImages(apollo, home, plan, rollup), null, page.url, "builder listing photos"),
           },
         });
       }
diff --git a/packages/database/prisma/migrations/20260723012950_home_images/migration.sql b/packages/database/prisma/migrations/20260723012950_home_images/migration.sql
new file mode 100644
index 0000000..59b932c
--- /dev/null
+++ b/packages/database/prisma/migrations/20260723012950_home_images/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "InventoryHome" ADD COLUMN     "images" TEXT[] DEFAULT ARRAY[]::TEXT[];
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 411fd5d..815beb5 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -194,6 +194,7 @@ model InventoryHome {
   sqft               Int?
   stories            Int?
   garageSpaces       Int?
+  images             String[] @default([]) // builder-supplied listing photo URLs (largest variant)
   homeType           HomeType @default(SINGLE_FAMILY)
   constructionStatus ConstructionStatus @default(UNDER_CONSTRUCTION)
   estCompletionDate  DateTime?
diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts
index 95d70b7..2502f84 100644
--- a/packages/publisher/src/index.ts
+++ b/packages/publisher/src/index.ts
@@ -142,6 +142,7 @@ export async function publishStagedRecord(stagedRecordId: string) {
         sqft: value<number>("sqft"),
         stories: value<number>("stories"),
         garageSpaces: value<number>("garageSpaces"),
+        images: value<string[]>("images") ?? [],
         homeType: (value<string>("homeType") as never) ?? "SINGLE_FAMILY",
         constructionStatus: (value<string>("constructionStatus") as never) ?? "UNDER_CONSTRUCTION",
         estCompletionDate: value<string>("estCompletionDate")
@@ -161,6 +162,7 @@ export async function publishStagedRecord(stagedRecordId: string) {
         beds: value<number>("beds"),
         bathsTotal: value<number>("bathsTotal"),
         sqft: value<number>("sqft"),
+        images: value<string[]>("images") ?? undefined,
         constructionStatus: (value<string>("constructionStatus") as never) ?? undefined,
         estCompletionDate: value<string>("estCompletionDate")
           ? new Date(value<string>("estCompletionDate")!)
diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts
index 57522c6..6812f6e 100644
--- a/packages/schemas/src/index.ts
+++ b/packages/schemas/src/index.ts
@@ -77,6 +77,9 @@ export const inventoryHomePayloadSchema = z.object({
   lat: fieldValueSchema(z.number()),
   lon: fieldValueSchema(z.number()),
   planName: fieldValueSchema(z.string()),
+  // Builder-supplied listing photos (largest srcset variant per asset). Optional
+  // so adapters that don't yet capture media still validate.
+  images: fieldValueSchema(z.array(z.string())).optional(),
 });
 export type InventoryHomePayload = z.infer<typeof inventoryHomePayloadSchema>;
 

← 77b81eb Home detail: branded hero header + status badge, 'more homes  ·  back to Homesonspec  ·  Pipeline: FORCE_REEXTRACT env to re-extract already-snapshot ba895b7 →