← back to Stayclaim
src/app/restaurants/[slug]/page.tsx
312 lines
/**
* /restaurants/[slug] — Restaurant detail with sightings list
*
* Per PLAN.md privacy guardrail: each sighting carries source_tier + source_url
* + date_range or era. Sightings are CLAIMS, not eternal truths. Public
* sources only — no tabloid rumor without an A/B/C tier citation.
*/
import Link from 'next/link';
import { cache } from 'react';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { pool } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { MiniMap } from '@/components/MiniMap';
import { safeHttpUrlUnknown as safeHttpHref } from '@/lib/url-guards';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const SLUG_RE = /^[a-z0-9-]+$/i;
type Restaurant = {
id: string;
slug: string;
name: string;
cuisine: string | null;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
lat: number | null;
lng: number | null;
opened_year: number | null;
closed_year: number | null;
blurb: string | null;
source_url: string | null;
hero_image: string | null;
hero_image_credit: string | null;
hero_image_license: string | null;
hero_image_source_url: string | null;
};
function formatPhone(p: string): string {
const m = p.match(/^\+1[-\s]?(\d{3})[-\s]?(\d{3})[-\s]?(\d{4})$/);
return m ? `(${m[1]}) ${m[2]}-${m[3]}` : p;
}
// Strip everything except + and digits before interpolating into a tel: URI.
function telUri(p: string): string {
return `tel:${p.replace(/[^+0-9]/g, '')}`;
}
type Sighting = {
id: string;
person_name: string;
entity_slug: string | null;
entity_kind: string | null;
era: string | null;
seen_date: string | null;
notes: string | null;
source_tier: 'A' | 'B' | 'C' | 'D';
source_url: string | null;
source_label: string | null;
};
// Wrapped in React cache() so generateMetadata + the page component share
// one DB hit per request rather than firing two identical queries.
const getRestaurant = cache(async function getRestaurant(slug: string): Promise<Restaurant | null> {
const { rows } = await pool.query<Restaurant>(
`SELECT id, slug, name, cuisine, address, city, state, zip, phone,
lat::float AS lat, lng::float AS lng,
opened_year, closed_year, blurb, source_url, hero_image,
hero_image_credit, hero_image_license, hero_image_source_url
FROM restaurant WHERE slug = $1 LIMIT 1`,
[slug]
);
return rows[0] ?? null;
});
const getSightings = cache(async function getSightings(restaurantId: string): Promise<Sighting[]> {
const { rows } = await pool.query<Sighting>(
`SELECT s.id, s.person_name,
e.slug AS entity_slug, e.kind AS entity_kind,
s.era, s.seen_date::text, s.notes,
s.source_tier, s.source_url, s.source_label
FROM restaurant_sighting s
LEFT JOIN entity e ON e.id = s.entity_id
WHERE s.restaurant_id = $1 AND s.public_visible = true
ORDER BY s.person_name
LIMIT 200`,
[restaurantId]
);
return rows;
});
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const r = await getRestaurant(slug);
if (!r) return { title: 'Restaurant not found' };
return {
title: `${r.name} — celebrity sightings`,
description: r.blurb ?? `${r.name} in ${r.city ?? 'Los Angeles'}: documented celebrity patrons, sourced and dated.`,
alternates: { canonical: `/restaurants/${r.slug}` },
};
}
const TIER_LABEL: Record<Sighting['source_tier'], string> = {
A: 'Government',
B: 'Archive',
C: 'Verified',
D: 'Tabloid',
};
const TIER_COLOR: Record<Sighting['source_tier'], string> = {
A: '#1f4d40',
B: '#1f4d40',
C: '#7a6a3f',
D: '#999',
};
const tierColor = (t: Sighting['source_tier']): string => TIER_COLOR[t] ?? '#999';
const tierLabel = (t: Sighting['source_tier']): string => TIER_LABEL[t] ?? 'Unverified';
export default async function RestaurantPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const r = await getRestaurant(slug);
if (!r) notFound();
const sightings = await getSightings(r.id);
const yearLine = `${r.opened_year ?? '?'}${r.closed_year ? `–${r.closed_year} (closed)` : '–present'}`;
return (
<>
<div className="max-w-3xl mx-auto px-6 pt-6">
<BreadcrumbArchive items={[
{ label: 'Archive', href: '/' },
{ label: 'Restaurants', href: '/restaurants' },
{ label: r.name },
]} />
</div>
<header className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
<p className="text-[10px] uppercase tracking-[0.18em] text-ink/55 mb-3 font-mono">
{r.cuisine ? `${r.cuisine} · ` : ''}{yearLine}
</p>
<h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
{r.name}
</h1>
<p className="mt-3 text-ink/70 text-lg">
{r.address}{r.city ? `, ${r.city}` : ''}{r.state ? `, ${r.state}` : ''}
</p>
{r.phone && (
<p className="mt-1.5 font-mono text-sm text-ink/65 tabular-nums">
<a
href={telUri(r.phone)}
className="hover:text-coral underline-offset-2 hover:underline"
aria-label={`Call ${r.name}`}
>
{formatPhone(r.phone)}
</a>
</p>
)}
{r.blurb && (
<p className="mt-5 font-display italic text-xl text-ink/75 max-w-prose leading-snug">
{r.blurb}
</p>
)}
{safeHttpHref(r.source_url) && (
<p className="mt-3 text-[12px] font-mono text-ink/50">
<a href={safeHttpHref(r.source_url)!} target="_blank" rel="noopener noreferrer" className="underline hover:text-coral">
Restaurant source
</a>
</p>
)}
</header>
{safeHttpHref(r.hero_image) && (
<figure className="max-w-3xl mx-auto px-6 pt-8">
{/* Use a plain <img> rather than next/image: archive thumbs come from
many domains (LAPL, Wikimedia, USC, Calisphere, LoC) and we don't
want to maintain a remotePatterns allowlist for the long tail.
src is scheme-validated above (http/https only). */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={safeHttpHref(r.hero_image)!}
alt={`${r.name} — historical photo`}
className="w-full h-auto block"
loading="lazy"
/>
<figcaption className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/55 leading-relaxed">
{r.hero_image_credit && <span>{r.hero_image_credit}</span>}
{r.hero_image_license && (
<span className="text-ink/45"> · {r.hero_image_license}</span>
)}
{safeHttpHref(r.hero_image_source_url) && (
<>
{' '}·{' '}
<a
href={safeHttpHref(r.hero_image_source_url)!}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-coral"
>
archive record
</a>
</>
)}
</figcaption>
</figure>
)}
{(() => {
const lat = Number(r.lat ?? NaN);
const lng = Number(r.lng ?? NaN);
const ok = Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0);
if (!ok) return null;
return (
<section className="max-w-3xl mx-auto px-6 py-8">
<MiniMap
pins={[{ lat, lng, label: r.name, featured: true }]}
height={280}
caption={`${r.address ?? ''}${r.city ? ', ' + r.city : ''}`}
/>
</section>
);
})()}
<section className="max-w-3xl mx-auto px-6 py-10">
<h2 className="font-mono text-[11px] uppercase tracking-[0.18em] text-ink/55 mb-6">
Documented sightings · {sightings.length}
</h2>
{sightings.length === 0 ? (
<p className="font-display italic text-xl text-ink/55 py-8">
No sourced sightings yet. Add one with a citation.
</p>
) : (
<ol className="divide-y divide-ink/10">
{sightings.map(s => {
const inner = (
<div className="grid grid-cols-[1fr_auto] gap-4 py-5 items-baseline">
<div>
<h3 className="font-display text-2xl text-ink group-hover:text-coral transition">
{s.person_name}
</h3>
{s.notes && (
<p className="mt-1 text-sm text-ink/70 leading-snug">{s.notes}</p>
)}
<p className="mt-1.5 flex flex-wrap items-center gap-2 text-[11px] font-mono">
<span
className="px-1.5 py-0.5 text-white uppercase tracking-[0.15em] text-[9px] font-bold"
style={{ background: tierColor(s.source_tier) }}
title={`Source tier ${s.source_tier} — ${tierLabel(s.source_tier)}`}
>
{tierLabel(s.source_tier)}
</span>
{safeHttpHref(s.source_url) ? (
<a
href={safeHttpHref(s.source_url)!}
target="_blank"
rel="noopener noreferrer"
className="text-ink/55 italic underline hover:text-coral"
>
{s.source_label ?? 'source'}
</a>
) : (
<span className="text-ink/45 italic">{s.source_label ?? 'unsourced'}</span>
)}
</p>
</div>
<div className="text-right">
<p className="font-mono text-xs text-ink/60 tabular-nums">
{s.seen_date ? s.seen_date.slice(0, 10) : (s.era ?? '—')}
</p>
{s.entity_slug && (
<p className="font-mono text-[10px] text-ink/40 mt-1 uppercase tracking-[0.15em]">
{s.entity_kind ?? 'person'}
</p>
)}
</div>
</div>
);
const safeEntitySlug =
typeof s.entity_slug === 'string' && SLUG_RE.test(s.entity_slug)
? s.entity_slug
: null;
return (
<li key={s.id}>
{safeEntitySlug ? (
<Link
href={`/entity/${encodeURIComponent(safeEntitySlug)}`}
className="group block hover:bg-[#fdf9ee] -mx-3 px-3 transition"
>
{inner}
</Link>
) : (
/* Non-interactive: drop `group` so inner h3's group-hover doesn't mislead */
<div className="block">{inner}</div>
)}
</li>
);
})}
</ol>
)}
<p className="mt-8 text-[11px] font-mono text-ink/40 leading-relaxed border-t border-ink/10 pt-6">
Sightings are claims, not eternal truths. Each carries a source tier
(A=govt, B=archive, C=verified secondary, D=tabloid) and a citation.
Spot something wrong? <Link href="/submit" className="underline">Submit a correction</Link>.
</p>
</section>
</>
);
}