← back to Stayclaim

src/app/layout.tsx

133 lines

import './globals.css';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import Link from 'next/link';
import { SITE_NAME, siteForHost } from '@/lib/site';
import { FREE_FOREVER } from '@/lib/flags';
import { Wordmark } from '@/components/Wordmark';
import { HeaderSearch } from '@/components/HeaderSearch';
import { GucciStripe } from '@/components/GucciStripe';

/**
 * Per-host metadata: each surface (wholivedthere, claimmyaddress, bubbesblock)
 * gets its own <title>, OG, description. The middleware sets x-pastdoor-host;
 * we read it here so social shares + SERPs see the right brand per domain.
 */
export async function generateMetadata(): Promise<Metadata> {
  const h = await headers();
  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${surface.domain}`;
  return {
    metadataBase: new URL(baseUrl),
    title: {
      default: `${surface.brandName} — ${surface.tagline}`,
      template: `%s · ${surface.brandName}`,
    },
    description: surface.description,
    alternates: { canonical: '/' },
    openGraph: {
      type: 'website',
      siteName: surface.brandName,
      title: surface.ogTitle,
      description: surface.description,
      url: baseUrl,
    },
    twitter: {
      card: 'summary_large_image',
      title: surface.brandName,
      description: surface.tagline,
    },
  };
}

// Per-surface chrome treatment — keeps GucciStripe shared (the family signal)
// but lets each site wear its own accent on the wordmark dot, header bg tint,
// and primary-CTA button. Resolves UX critique that the surfaces still read as
// "same site, three colors" despite the FilmsGrid differentiation.
const SURFACE_CHROME: Record<'flagship' | 'claim' | 'community', {
  accent: string;
  headerBg: string;
  headerBorder: string;
  ctaText: string;
}> = {
  flagship:  { accent: '#1f4d40', headerBg: 'bg-[#f3ebd9]/85', headerBorder: 'border-b border-[#1f4d40]/15',  ctaText: '#f3ebd9' },
  claim:     { accent: '#c9292e', headerBg: 'bg-[#e8dcc4]/90', headerBorder: 'border-b-2 border-[#c9292e]',    ctaText: '#fdf9ee' },
  community: { accent: '#c5a572', headerBg: 'bg-[#ede0c4]/85', headerBorder: 'border-b border-[#c5a572]/30',  ctaText: '#1a1a1a' },
};

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const h = await headers();
  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
  const chrome = SURFACE_CHROME[surface.key];

  return (
    <html lang="en">
      <body>
        {/* Per-surface GA4 — distinct property per domain, injected here so
            host-routed traffic flows to the right brand bucket. Measurement ID
            comes from env (NEXT_PUBLIC_GA_FLAGSHIP / _CLAIM / _COMMUNITY) and
            is created by the analytics skill once the service account has
            GA Admin access. Snippet stays inert until env var is set. */}
        {(() => {
          const env = {
            flagship:  process.env.NEXT_PUBLIC_GA_FLAGSHIP,
            claim:     process.env.NEXT_PUBLIC_GA_CLAIM,
            community: process.env.NEXT_PUBLIC_GA_COMMUNITY,
          }[surface.key];
          const id = env || surface.gaMeasurementId;
          // Hard-validate format before any HTML/JS interpolation — protects
          // against XSS if a misconfigured env var ever contains quotes,
          // newlines, or </script>.
          if (!id || !/^G-[A-Z0-9]+$/.test(id)) return null;
          return (
            <>
              <script async src={`https://www.googletagmanager.com/gtag/js?id=${id}`}></script>
              <script
                dangerouslySetInnerHTML={{
                  __html: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${id}',{send_page_view:true});`,
                }}
              />
            </>
          );
        })()}
        <header className={`${chrome.headerBg} ${chrome.headerBorder} backdrop-blur sticky top-0 z-20`}>
          <div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between gap-6">
            <Link href="/" aria-label={surface.brandName}>
              <Wordmark name={surface.domain} accent={chrome.accent} />
            </Link>
            <div className="flex-1 hidden md:flex justify-end">
              <HeaderSearch />
            </div>
            <nav className="flex items-center gap-6 text-sm">
              <Link href="/map" className="hidden md:inline hover:opacity-70 transition">Map</Link>
              <Link href="/films" className="hidden md:inline hover:opacity-70 transition">Films</Link>
              <Link href="/browse" className="hidden lg:inline hover:opacity-70 transition">Browse</Link>
              {!FREE_FOREVER && (
                <Link href="/hosts" className="hidden lg:inline hover:opacity-70 transition">For owners</Link>
              )}
              <Link
                href={surface.primaryCta.href}
                className="px-4 py-1.5 hover:opacity-90 transition font-medium"
                style={{ background: chrome.accent, color: chrome.ctaText }}
              >
                {surface.primaryCta.label}
              </Link>
            </nav>
          </div>
        </header>
        <GucciStripe />
        <main>{children}</main>
        <footer className="border-t border-ink/10 mt-24">
          <div className="max-w-6xl mx-auto px-6 py-8 text-xs text-ink/60 flex flex-col md:flex-row justify-between gap-2">
            <span>© {new Date().getFullYear()} {surface.brandName}</span>
            <span className="text-ink/40">
              Public record. Historical associations only. Living persons protected by design.
              {FREE_FOREVER && ' Free forever.'}
            </span>
          </div>
        </footer>
      </body>
    </html>
  );
}