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