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