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