← back to Stayclaim

src/lib/site.ts

127 lines

/**
 * Single source of truth for the public-facing brand.
 *
 * Three-domain ecosystem (one Next.js process, host-routed via middleware.ts):
 *   - wholivedthere.com  → flagship / archive (default)
 *   - claimmyaddress.com → onboarding / claim flow
 *   - bubbesblock.com    → community / neighborhoods
 *
 * `SITE_NAME` etc. remain pastdoor as the umbrella brand;
 * per-host overrides come from siteForHost(headers().get('host')) in server components.
 */
import { headers } from 'next/headers';

export const SITE_NAME = 'pastdoor';
export const SITE_DOMAIN = 'pastdoor.com';
export const SITE_TAGLINE = 'Nextdoor, but for then.';
export const SITE_DESCRIPTION =
  'Every address has a story. Look up the full history of a house — who lived there, what was built and when, what the archival maps show. The opposite of a real-estate listing.';

export type SurfaceKey = 'flagship' | 'claim' | 'community';

export interface SurfaceConfig {
  key: SurfaceKey;
  domain: string;
  brandName: string;
  tagline: string;
  description: string;
  primaryCta: { label: string; href: string };
  themeAccent: 'green' | 'red' | 'gold';   // matches Gucci palette decision in PLAN.md
  ogTitle: string;
  /**
   * GA4 measurement ID per surface — populated once the analytics skill creates
   * the properties (pending Steve granting the service account GA Admin access).
   * Layout.tsx reads NEXT_PUBLIC_GA_<SURFACE_KEY> env vars first; falls back here.
   */
  gaMeasurementId?: string;
}

const SURFACES: Record<string, SurfaceConfig> = {
  // Flagship — full archive
  'wholivedthere.com': {
    key: 'flagship',
    domain: 'wholivedthere.com',
    brandName: 'WhoLivedThere',
    tagline: 'Every address has a story.',
    description:
      'Look up the full history of any house — who lived there, when it was built, what the archives say. Maps, deeds, permits, photos, year-by-year Street View.',
    primaryCta: { label: 'Search any address', href: '/' },
    themeAccent: 'green',
    ogTitle: 'WhoLivedThere — every address has a story',
  },
  // Onboarding — claim flow
  'claimmyaddress.com': {
    key: 'claim',
    domain: 'claimmyaddress.com',
    brandName: 'ClaimMyAddress',
    tagline: 'Your home. Your file. Forever.',
    description:
      'Claim your address. Upload deeds, bills, photos, and the receipts that prove your home is yours. A free, permanent home file backed by public records.',
    primaryCta: { label: 'Claim your home', href: '/submit' },
    themeAccent: 'red',
    ogTitle: 'ClaimMyAddress — your home, your file, forever',
  },
  // Community — neighborhoods
  'bubbesblock.com': {
    key: 'community',
    domain: 'bubbesblock.com',
    brandName: "Bubbe's Block",
    tagline: 'The block, then and now.',
    description:
      "What's happening on your block — and what was happening here a hundred years ago. Neighborhood posts, cup-of-sugar requests, history grounded in the archive.",
    primaryCta: { label: 'See your block', href: '/neighborhoods' },
    themeAccent: 'gold',
    ogTitle: "Bubbe's Block — the block, then and now",
  },
};

const DEFAULT_SURFACE: SurfaceConfig = (() => {
  const s = SURFACES['wholivedthere.com'];
  if (!s) throw new Error('lib/site.ts: default surface (wholivedthere.com) missing from SURFACES');
  return s;
})();

/**
 * Resolve the surface config for a given host. Strips `www.` and port.
 * Falls back to the wholivedthere flagship when host is unknown (dev, preview, etc.)
 */
export function siteForHost(rawHost: string | null | undefined): SurfaceConfig {
  if (!rawHost) return DEFAULT_SURFACE;
  let host = rawHost.trim().toLowerCase().replace(/^www\./, '');
  // IPv6 bracket-aware port strip: `[::1]:3000` → `[::1]`; IPv4/hostname `host:port` → `host`.
  if (host.startsWith('[')) {
    const end = host.indexOf(']');
    if (end !== -1) host = host.slice(0, end + 1);
  } else {
    host = host.replace(/:\d+$/, '');
  }
  // Use Object.hasOwn to defeat prototype-pollution lookups (`__proto__`, `constructor`).
  return Object.prototype.hasOwnProperty.call(SURFACES, host) ? SURFACES[host] : DEFAULT_SURFACE;
}

/**
 * Convenience for server components — reads x-pastdoor-host (set by middleware)
 * or the literal Host header. Async because Next 15's `headers()` is async.
 *
 * `x-pastdoor-host` is validated against known SURFACES keys to prevent client
 * spoofing (the header would otherwise be attacker-controlled if a request
 * bypasses middleware or the edge fails to strip inbound x-* headers).
 */
export async function currentSurface(): Promise<SurfaceConfig> {
  const h = await headers();
  const forwarded = h.get('x-pastdoor-host');
  const safeForwarded =
    forwarded && Object.prototype.hasOwnProperty.call(SURFACES, forwarded.trim().toLowerCase())
      ? forwarded
      : null;
  return siteForHost(safeForwarded || h.get('host'));
}

/**
 * Sister-domain links shown via the cross-promo CTA component.
 * Returns the *other two* surfaces, in the order they should appear.
 */
export function sisterSurfaces(current: SurfaceKey): SurfaceConfig[] {
  return Object.values(SURFACES).filter(s => s.key !== current);
}