← back to Stayclaim
src/app/restaurants/page.tsx
186 lines
/**
* /restaurants — Map of celebrity restaurants
*
* Curated list of LA restaurants with documented celebrity sightings, sourced
* from Wikipedia + LA Times + restaurant histories. Per PLAN.md privacy
* guardrail: each sighting carries source_tier + source_url + date/era.
* Sightings are CLAIMS, not eternal truths.
*
* Restaurant pin → click → /restaurants/[slug] for the patron list.
*/
import Link from 'next/link';
import type { Metadata } from 'next';
import { pool } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
export const metadata: Metadata = {
title: 'Celebrity restaurants',
description:
'A map of Los Angeles restaurants where movie stars actually ate — Musso & Frank to Dan Tana\'s to The Ivy. Each sighting is sourced and dated.',
alternates: { canonical: '/restaurants' },
};
type RestRow = {
id: string;
slug: string;
name: string;
cuisine: string | null;
address: string | null;
city: string | null;
phone: string | null;
lat: number | null;
lng: number | null;
opened_year: number | null;
closed_year: number | null;
blurb: string | null;
sighting_count: number;
top_patrons: string[];
};
function formatPhone(p: string): string {
// E.164-ish "+1-323-467-7788" → "(323) 467-7788" for US numbers; otherwise return as-is.
const m = p.match(/^\+1[-\s]?(\d{3})[-\s]?(\d{3})[-\s]?(\d{4})$/);
return m ? `(${m[1]}) ${m[2]}-${m[3]}` : p;
}
async function getRestaurants(): Promise<RestRow[]> {
try {
const { rows } = await pool.query<RestRow>(`
SELECT r.id, r.slug, r.name, r.cuisine, r.address, r.city, r.phone,
r.lat::float AS lat, r.lng::float AS lng,
r.opened_year, r.closed_year, r.blurb,
COUNT(s.id)::int AS sighting_count,
COALESCE(
(SELECT array_agg(person_name)
FROM (
SELECT DISTINCT person_name
FROM restaurant_sighting
WHERE restaurant_id = r.id AND public_visible = true
ORDER BY person_name
LIMIT 4
) t),
ARRAY[]::text[]
) AS top_patrons
FROM restaurant r
LEFT JOIN restaurant_sighting s
ON s.restaurant_id = r.id AND s.public_visible = true
GROUP BY r.id
ORDER BY r.opened_year ASC NULLS LAST, r.name
LIMIT 500
`);
return rows;
} catch (e) {
console.error('[restaurants/page] DB error:', e);
return [];
}
}
export default async function RestaurantsPage() {
const rests = await getRestaurants();
const pins: MapPin[] = rests
.flatMap(r => {
const lat = Number(r.lat);
const lng = Number(r.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return [];
if (lat === 0 && lng === 0) return [];
return [{
slug: r.slug,
title: r.name,
subtitle: `${r.city ?? ''} · ${r.sighting_count} sightings`,
lat,
lng,
href: `/restaurants/${r.slug}`,
}];
});
return (
<>
<div className="max-w-6xl mx-auto px-6 pt-6">
<BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Restaurants' }]} />
</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">
Where they ate · sourced sightings
</p>
<h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
Celebrity restaurants.
</h1>
<p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
Musso & Frank to The Ivy — the rooms where movie stars actually
showed up. Each sighting is dated and cited.
</p>
<p className="mt-3 text-sm text-ink/55 max-w-prose">
{rests.length} restaurants on the map · {' '}
{rests.reduce((n, r) => n + r.sighting_count, 0)} documented sightings.
Every claim links to its source — Wikipedia, LATimes, restaurant
histories. We don’t do tabloid rumor.
</p>
</header>
{pins.length > 0 && (
<section className="max-w-6xl mx-auto px-6 py-8">
<InteractiveMapMount pins={pins} height={520} variant="positron" />
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
Click any pin for the patron list and source citations
</p>
</section>
)}
<section className="max-w-3xl mx-auto px-6 py-10">
<ol className="divide-y divide-ink/10">
{rests.map(r => (
<li key={r.slug}>
<div className="group grid grid-cols-[1fr_auto] gap-4 py-5 items-baseline hover:bg-[#fdf9ee] -mx-3 px-3 transition">
<div>
<Link href={`/restaurants/${r.slug}`} className="block">
<h2 className="font-display text-3xl text-ink tracking-[-0.01em] group-hover:text-coral transition">
{r.name}
</h2>
</Link>
<p className="mt-1 text-sm text-ink/70">
{r.cuisine ? <span>{r.cuisine} · </span> : null}
{[r.address, r.city].filter(Boolean).join(', ')}
</p>
{r.phone && (
<p className="mt-1 text-[12px] font-mono text-ink/65 tabular-nums">
<a
href={`tel:${r.phone}`}
className="hover:text-coral underline-offset-2 hover:underline"
aria-label={`Call ${r.name}`}
>
{formatPhone(r.phone)}
</a>
</p>
)}
{r.top_patrons.length > 0 && (
<p className="mt-1 text-[12px] font-mono text-ink/55 italic">
seen: {r.top_patrons.join(' · ')}
{r.sighting_count > r.top_patrons.length ? ` +${r.sighting_count - r.top_patrons.length} more` : ''}
</p>
)}
</div>
<Link href={`/restaurants/${r.slug}`} className="text-right block">
<p className="font-mono text-xs text-ink/60 tabular-nums">
{r.opened_year ?? '?'}{r.closed_year ? `–${r.closed_year}` : '–present'}
</p>
<p className="font-mono text-[10px] text-ink/40 mt-1">
{r.sighting_count} sighting{r.sighting_count === 1 ? '' : 's'}
</p>
</Link>
</div>
</li>
))}
</ol>
{rests.length === 0 && (
<p className="font-display italic text-2xl text-ink/55 text-center py-12">
The list is loading. Restaurants are added as sightings are sourced.
</p>
)}
</section>
</>
);
}