← 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
M apps/web/src/app/homes/[id]/page.tsxM collectors/lennar/src/index.tsA packages/database/prisma/migrations/20260723012950_home_images/migration.sqlM packages/database/prisma/schema.prismaM packages/publisher/src/index.tsM packages/schemas/src/index.ts
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 →