← 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} />
    </>
  );
}