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