← back to Stayclaim

src/app/neighborhood/[slug]/page.tsx

112 lines

import type { Metadata } from 'next';
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { searchListings } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { NodeCard } from '@/components/NodeCard';

export const runtime = 'nodejs';

const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

const CITY_FOR_SLUG: Record<string, string> = {
  'beverly-hills': 'Beverly Hills',
  'los-angeles': 'Los Angeles',
  'malibu': 'Malibu',
  'big-bear-lake': 'Big Bear Lake',
  'joshua-tree': 'Joshua Tree',
};

function cityNameFor(slug: string): string | null {
  if (!SLUG_RE.test(slug)) return null;
  if (Object.prototype.hasOwnProperty.call(CITY_FOR_SLUG, slug)) {
    return CITY_FOR_SLUG[slug]!;
  }
  return slug
    .split('-')
    .filter(Boolean)
    .map(w => (w[0] ?? '').toUpperCase() + w.slice(1))
    .join(' ');
}

// Single dedupe layer: metadata + page reuse the same DB call per request.
const getListingsForCity = cache(async (cityName: string) =>
  searchListings({ city: cityName, limit: 24 })
);

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const cityName = cityNameFor(params.slug);
  if (!cityName) {
    return { title: 'Not found', robots: { index: false, follow: false } };
  }
  const listings = await getListingsForCity(cityName);
  // Unknown slug AND no DB rows → noindex (caller will 404 in the page).
  const known = Object.prototype.hasOwnProperty.call(CITY_FOR_SLUG, params.slug);
  if (!known && listings.length === 0) {
    return { title: cityName, robots: { index: false, follow: false } };
  }
  return {
    title: cityName,
    description: `${cityName} addresses in the archive — permits, sales, architects, historical associations.`,
    alternates: { canonical: `/neighborhood/${encodeURIComponent(params.slug)}` },
    // Threshold: ≥3 listings before we index the neighborhood index page.
    robots: { index: listings.length >= 3, follow: true },
  };
}

export default async function NeighborhoodPage({ params }: { params: { slug: string } }) {
  const cityName = cityNameFor(params.slug);
  if (!cityName) notFound();
  const listings = await getListingsForCity(cityName);
  // Reject doorway pages: unknown slug with zero matching listings.
  const known = Object.prototype.hasOwnProperty.call(CITY_FOR_SLUG, params.slug);
  if (!known && listings.length === 0) notFound();

  return (
    <>
      <div className="max-w-6xl mx-auto px-6 pt-6">
        <BreadcrumbArchive
          items={[
            { label: 'Archive', href: '/browse' },
            { label: 'Neighborhoods', href: '/neighborhoods' },
            { label: cityName },
          ]}
        />
      </div>

      <header className="max-w-6xl mx-auto px-6 py-12 border-b border-ink/10">
        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Neighborhood</p>
        <h1 className="font-display text-6xl md:text-7xl text-ink tracking-[-0.02em] leading-[0.95]">
          {cityName}
        </h1>
        <p className="mt-5 font-display italic text-xl text-ink/70 max-w-prose">
          Every address a record. Every record a story.
        </p>
      </header>

      <section className="max-w-6xl mx-auto px-6 py-12">
        <h2 className="font-display text-3xl text-ink mb-6 tracking-[-0.01em]">Addresses in the archive</h2>
        {listings.length === 0 ? (
          <p className="text-sm text-ink/50 italic">
            No addresses yet in this neighborhood. Ingest pipeline pending.
          </p>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
            {listings.map(l => (
              <NodeCard
                key={l.id}
                kind="address"
                href={`/address/${l.slug}`}
                title={l.title}
                subtitle={l.address_line1 ?? undefined}
                imageUrl={l.hero_image ?? undefined}
                eyebrow={l.property_type ?? 'Address'}
              />
            ))}
          </div>
        )}
      </section>
    </>
  );
}