← back to Stayclaim
src/app/address/[slug]/page.tsx
483 lines
import { notFound } from 'next/navigation';
import { cache } from 'react';
import type { Metadata } from 'next';
import {
getListingBySlug as _getListingBySlug,
getListingAssociations,
getNearbyListings,
getEventsForListing as _getEventsForListing,
getMediaForListing,
getStructuresForListing,
getParcelForListing,
getProductionsForListing,
getNearbyFilming,
yearOf,
} from '@/lib/db';
// Per-request memoization: generateMetadata + page body each call these once;
// React `cache()` dedupes within a single render so we hit PG only once each.
const getListingBySlug = cache(_getListingBySlug);
const getEventsForListing = cache(_getEventsForListing);
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const listing = await getListingBySlug(params.slug);
if (!listing) return { robots: { index: false, follow: false } };
// Only index pages with substantive content. Stub pages get noindex.
const events = await getEventsForListing(listing.id);
const indexable = events.length > 0;
return {
title: listing.title,
description: listing.headline ?? `History of ${listing.title} — permits, sales, archival media.`,
alternates: { canonical: `/address/${listing.slug}` },
robots: indexable ? { index: true, follow: true } : { index: false, follow: true },
openGraph: {
title: listing.title,
description: listing.headline ?? '',
url: `/address/${listing.slug}`,
type: 'article',
},
};
}
import { NodeCard } from '@/components/NodeCard';
import { AddressMasthead } from '@/components/AddressMasthead';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { Timeline, type TimelineEvent } from '@/components/Timeline';
import { FactRow } from '@/components/FactRow';
import { Projection } from '@/components/Projection';
import { ClaimCTA } from '@/components/ClaimCTA';
import { SponsoredCard } from '@/components/sponsored/SponsoredCard';
import { projectMonthlyFromNightly } from '@/lib/multiplier';
import { CurveSparkline } from '@/components/CurveSparkline';
import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
import { StreetView } from '@/components/StreetView';
import { StreetViewHistory } from '@/components/StreetViewHistory';
import { MediaWall } from '@/components/MediaWall';
import { JsonLd } from '@/components/JsonLd';
import { ProvenanceFooter } from '@/components/ProvenanceFooter';
import { CrossPromo } from '@/components/CrossPromo';
import { FilmsGridServer } from '@/components/FilmsGridServer';
import { AddressNarrative } from '@/components/AddressNarrative';
import { listPdFilmImages } from '@/lib/db';
import { getOrFetchParcelForAddress } from '@/lib/la-arcgis';
import { getPermitsForApn } from '@/lib/la-permits';
import { SITE_DOMAIN, currentSurface } from '@/lib/site';
import Link from 'next/link';
export default async function AddressPage({ params }: { params: { slug: string } }) {
const listing = await getListingBySlug(params.slug);
if (!listing) notFound();
const surface = await currentSurface();
// Kick off the LA-arcgis fetch concurrently with the Promise.all below.
// It doesn't depend on any of those queries so there's no reason to wait.
const laParcelPromise = (listing.address_line1 || listing.title)
? getOrFetchParcelForAddress(
`${listing.address_line1 ?? listing.title} ${listing.city ?? ''}`.trim(),
listing.id,
).catch((err) => {
console.error('[address] getOrFetchParcelForAddress failed', { slug: listing.slug, err });
return null;
})
: Promise.resolve(null);
const [associations, nearby, placeEvents, media, structures, parcel, productions, nearbyFilming] = await Promise.all([
getListingAssociations(listing.id),
listing.city ? getNearbyListings(listing.city, listing.id, 3) : Promise.resolve([]),
getEventsForListing(listing.id),
getMediaForListing(listing.id),
getStructuresForListing(listing.id),
getParcelForListing(listing.id),
getProductionsForListing(listing.id),
listing.city ? getNearbyFilming(listing.city, listing.id, 5) : Promise.resolve([]),
]);
// For the films-at-this-address grid: only render if PD images actually exist
// for the linked productions. Avoids the empty placeholder strip seen on most
// listings (where productions exist but no PD image has been matched yet).
// Resolve laParcel + filmsHere in parallel — both need to settle before render.
const [filmsHere, laParcel] = await Promise.all([
productions.length
? listPdFilmImages({ productionIds: productions.map(p => p.production_id), limit: 36 })
: Promise.resolve([]),
laParcelPromise,
]);
// LA City permits — only resolves if parcel is in City of LA (not BH/SM/etc).
// Cache-first via la_permit table.
const laPermits = laParcel?.apn
? await getPermitsForApn(laParcel.apn, { limit: 50 }).catch((err) => {
console.error('[address] getPermitsForApn failed', { slug: listing.slug, apn: laParcel.apn, err });
return [];
})
: [];
const events: TimelineEvent[] = placeEvents.map(pe => ({
date: pe.valid_from ? yearOf(pe.valid_from) : 'undated',
type: pe.kind,
summary: pe.summary,
tier: pe.source_tier as 'A' | 'B' | 'C',
sourceLabel: pe.source_label ?? undefined,
sourceUrl: pe.source_url ?? undefined,
}));
const projection = projectMonthlyFromNightly(
listing.nightly_price != null ? Number(listing.nightly_price) : null
);
// schema.org/Place — only the fields actually visible on the page.
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${SITE_DOMAIN}`;
const jsonLd: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Place',
name: listing.title,
url: `${baseUrl}/address/${listing.slug}`,
address: {
'@type': 'PostalAddress',
streetAddress: listing.address_line1 ?? undefined,
addressLocality: listing.city ?? undefined,
addressRegion: listing.state ?? undefined,
postalCode: listing.postal_code ?? undefined,
addressCountry: listing.country ?? 'US',
},
};
if (listing.latitude != null && listing.longitude != null) {
jsonLd.geo = {
'@type': 'GeoCoordinates',
latitude: Number(listing.latitude),
longitude: Number(listing.longitude),
};
}
// schema.org has no canonical "year built"; use additionalProperty.
// Build unconditionally so APN survives even when year_built is missing.
const additionalProperty: Array<{ '@type': string; name: string; value: unknown }> = [];
if (structures[0]?.year_built) {
additionalProperty.push({ '@type': 'PropertyValue', name: 'Year built', value: structures[0].year_built });
}
if (parcel) {
additionalProperty.push({ '@type': 'PropertyValue', name: 'APN', value: parcel.apn });
}
if (additionalProperty.length > 0) {
jsonLd.additionalProperty = additionalProperty;
}
if (listing.description) jsonLd.description = listing.description;
// Roll up source-tier counts for the provenance footer.
const tierCounts = { A: 0, B: 0, C: 0 } as { A: number; B: number; C: number };
for (const e of placeEvents) {
if (e.source_tier === 'A' || e.source_tier === 'B' || e.source_tier === 'C') tierCounts[e.source_tier]++;
}
for (const a of associations) {
if (a.source_tier === 'A' || a.source_tier === 'B' || a.source_tier === 'C') tierCounts[a.source_tier]++;
}
for (const m of media) {
if (m.source_tier === 'A' || m.source_tier === 'B' || m.source_tier === 'C') tierCounts[m.source_tier]++;
}
for (const p of productions) {
if (p.source_tier === 'A' || p.source_tier === 'B' || p.source_tier === 'C') tierCounts[p.source_tier]++;
}
return (
<>
<div className="max-w-6xl mx-auto px-6 pt-6">
<BreadcrumbArchive
items={[
{ label: 'Archive', href: '/browse' },
listing.city
? { label: listing.city, href: `/neighborhood/${listing.city.toLowerCase().replace(/\s+/g, '-')}` }
: { label: '—' },
{ label: listing.postal_code ?? '—' },
{ label: listing.title },
]}
/>
</div>
<JsonLd data={jsonLd} />
<AddressMasthead listing={listing} />
{/* Storytelling layer — narrative paragraphs above the data tables.
The story comes first; the citations live in the timeline + facts below. */}
<AddressNarrative
listing={listing}
structures={structures}
events={placeEvents}
productions={productions}
parcel={laParcel}
laPermits={laPermits}
/>
{listing.latitude != null && listing.longitude != null && (
<div className="max-w-6xl mx-auto px-6">
<StreetView
lat={Number(listing.latitude)}
lng={Number(listing.longitude)}
addressLabel={listing.address_line1 ?? listing.title}
/>
<StreetViewHistory
lat={Number(listing.latitude)}
lng={Number(listing.longitude)}
/>
</div>
)}
<div className="max-w-6xl mx-auto px-6 py-12 grid md:grid-cols-[minmax(0,1fr)_320px] gap-10">
{/* LEFT — editorial */}
<div>
<section className="mb-10">
<h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">History</h2>
<Timeline events={events} />
</section>
{associations.length > 0 && (
<section className="mb-10">
<h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">Associated (historical)</h2>
<ul className="space-y-4">
{associations.map(a => (
<li key={a.id} className="border-b border-ink/5 pb-4">
<div className="flex items-baseline gap-3 flex-wrap">
<Link
href={`/entity/${a.entity_slug}`}
className="font-display text-xl text-ink hover:text-coral transition"
>
{a.entity_name}
</Link>
<span className="text-xs uppercase tracking-wider text-ink/50">{a.relation}</span>
{(a.date_from || a.date_to) && (
<span className="font-mono text-xs text-ink/60">
{yearOf(a.date_from)}–{yearOf(a.date_to)}
</span>
)}
</div>
{a.summary && <p className="mt-2 text-sm text-ink/70">{a.summary}</p>}
</li>
))}
</ul>
</section>
)}
<section className="mb-10">
<h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">Property</h2>
<dl>
{parcel && (
<FactRow
label="APN"
value={<span className="font-mono">{parcel.apn}</span>}
tier="A"
sourceLabel={parcel.jurisdiction}
/>
)}
{structures[0]?.year_built && (
<FactRow
label="Year built"
value={structures[0].year_built}
tier="A"
sourceLabel="assessor"
/>
)}
{listing.property_type && (
<FactRow label="Type" value={listing.property_type} tier="A" sourceLabel="assessor" />
)}
{listing.bedrooms != null && (
<FactRow label="Bedrooms" value={listing.bedrooms} tier="A" sourceLabel="assessor" />
)}
{listing.bathrooms != null && (
<FactRow label="Bathrooms" value={listing.bathrooms} tier="A" sourceLabel="assessor" />
)}
{listing.sleeps != null && (
<FactRow label="Sleeps" value={listing.sleeps} tier="C" sourceLabel="owner" />
)}
{listing.amenities.length > 0 && (
<FactRow
label="Amenities"
value={listing.amenities.join(' · ')}
tier="C"
sourceLabel="owner"
/>
)}
</dl>
</section>
{media.length > 0 && <MediaWall assets={media} />}
{productions.length > 0 && (
<section className="mb-10">
<h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">Used as filming location</h2>
<ul className="space-y-4">
{productions.map(p => (
<li key={p.id} className="border-b border-ink/5 pb-4">
<div className="flex items-baseline gap-3 flex-wrap">
<Link
href={`/film/${p.production_slug}`}
className="font-display text-xl text-ink hover:text-coral transition"
>
{p.production_title}
</Link>
<span className="text-xs uppercase tracking-wider text-ink/50">
{p.production_kind.replace('_', ' ')}
</span>
{p.production_year && (
<span className="font-mono text-xs text-ink/60">{p.production_year}</span>
)}
{p.role && (
<span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">
{p.role.replace('_', ' ')}
</span>
)}
</div>
{p.scene_note && (
<p className="mt-1 text-sm text-ink/70 leading-relaxed">{p.scene_note}</p>
)}
{p.permit_number && (
<p className="mt-1 font-mono text-[10px] text-ink/45 uppercase tracking-[0.15em]">
FilmLA permit · {p.permit_number}
</p>
)}
</li>
))}
</ul>
</section>
)}
{projection.monthly && (
<section className="mb-10">
<h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">Rental projection</h2>
<div className="grid md:grid-cols-[1fr_320px] gap-6 items-start">
<Projection
label="Projected monthly"
value={`$${projection.monthly.toLocaleString()}`}
unit="/ mo"
confidence={projection.confidence}
anchorWindow={projection.anchorWindow}
note={projection.note}
/>
<CurveSparkline />
</div>
</section>
)}
{nearby.length > 0 && (
<section className="mb-10">
<h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">
Nearby in {listing.city}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{nearby.map(n => (
<NodeCard
key={n.id}
kind="address"
href={`/address/${n.slug}`}
title={n.title}
subtitle={n.address_line1 ?? undefined}
imageUrl={n.hero_image ?? undefined}
eyebrow={n.property_type ?? 'Address'}
/>
))}
</div>
</section>
)}
{nearbyFilming.length > 0 && (
<section className="mb-10">
<h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">
Filmed nearby
</h2>
<ul className="divide-y divide-ink/5 border-t border-ink/10">
{nearbyFilming.map(f => (
<li key={`${f.production_slug}-${f.listing_slug}`} className="py-3 flex items-baseline justify-between gap-3 flex-wrap">
<div className="flex items-baseline gap-3 flex-wrap">
<Link href={`/film/${f.production_slug}`} className="font-display text-lg text-ink hover:text-coral transition">
{f.production_title}
</Link>
<span className="text-ink/30">·</span>
<Link href={`/address/${f.listing_slug}`} className="text-sm text-ink/70 hover:text-coral transition">
{f.listing_title}
</Link>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
{f.shoot_year ?? '?'} · {f.production_kind.replace('_', ' ')}
</span>
</li>
))}
</ul>
</section>
)}
</div>
{/* RIGHT — sidebar */}
<aside className="flex flex-col gap-6">
{listing.latitude != null && listing.longitude != null && (() => {
const sidebarPins: MapPin[] = [
{
slug: listing.slug,
title: listing.title,
subtitle: listing.address_line1 ?? listing.city ?? undefined,
lat: Number(listing.latitude),
lng: Number(listing.longitude),
featured: true,
},
...nearby
.filter(n => n.latitude != null && n.longitude != null)
.map(n => ({
slug: n.slug,
title: n.title,
subtitle: n.address_line1 ?? undefined,
lat: Number(n.latitude),
lng: Number(n.longitude),
})),
];
return (
<div>
<InteractiveMapMount pins={sidebarPins} height={260} variant="positron" />
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
{listing.address_line1 ?? listing.city}
</p>
</div>
);
})()}
<ClaimCTA slug={listing.slug} />
{/* --- WALLED OFF SPONSORED MODULE --- */}
{/* Only allow http(s) URLs; block javascript:, data:, vbscript:, etc. */}
{listing.source_url && /^https?:\/\//i.test(listing.source_url) && (
<SponsoredCard
source={
listing.source === 'zillow'
? 'zillow'
: listing.source === 'redfin'
? 'redfin'
: listing.source === 'airbnb'
? 'airbnb'
: 'other'
}
title={`View ${listing.title} on ${listing.source}`}
priceLabel={
listing.nightly_price
? `From $${Number(listing.nightly_price).toLocaleString()} /night`
: undefined
}
url={listing.source_url}
imageUrl={listing.hero_image ?? undefined}
/>
)}
</aside>
</div>
{/* Only show the films grid if we actually have PD images for these
productions. Otherwise the empty grid renders awkward placeholders. */}
{filmsHere.length > 0 && (
<FilmsGridServer
heading="Scenes filmed at this address"
kicker="Public-domain stills · provenance per image"
productionIds={productions.map(p => p.production_id)}
limit={36}
/>
)}
<ProvenanceFooter counts={tierCounts} lastUpdated={listing.updated_at as unknown as Date | string | null} />
<CrossPromo current={surface.key} />
</>
);
}