# Apex Stub Pages + Dealer Directory Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give each of the 4 production apex domains a distinct consumer-first stub homepage and a crawlable `/dealers` directory (hub + state pages) so every active dealer gets a followed inbound link and apexes stop reading as AIMCLEAR-branded doorway clones.

**Architecture:** Internal dealer-site routes move from `app/dealers/*` to `app/sites/*` (proxy rewrite targets follow). Three new host-encoded ISR route families follow the existing dealer-page pattern (`{domainPrefix}-{domain}` slug in the path, proxy rewrites public URLs to them): stubs at `app/home/[domainSlug]`, directory hub at `app/directory/[domainSlug]`, region pages at `app/directory/[domainSlug]/[region]`. Public URLs stay clean (`myamsoil.com/`, `myamsoil.com/dealers`, `myamsoil.com/dealers/texas`). All content/config lives in `lib/` modules with unit tests.

**Tech Stack:** Next.js 16 App Router (ISR, `force-static`), React 19, Prisma 7, Tailwind + Inter, Jest (+ RTL available), TypeScript.

**Spec:** `docs/superpowers/specs/2026-06-09-apex-stubs-dealer-directory-design.md`

---

## Working agreements & environment notes

- **TDD: every task writes its failing test first.** Run the test, see it fail, implement, see it pass, commit.
- **Sandbox quirks (sbx):** run `DATABASE_URL="postgresql://x:x@localhost:5432/x" npx prisma generate` once before the first `tsc`/`jest` run (fresh sandboxes have no generated client). Never `git add -A` (CRLF hazard) - always `git add <explicit paths>`. Commit with `--no-verify` (husky is unreliable in the sandbox).
- **Branch:** `amsoil-seo-work` (already isolated; no new worktree needed in sbx). The `amsoil-robots-txt` branch merges first or we rebase on it - our robots edits (Task 6/9) are small swaps that re-apply cleanly either way; re-check them at rebase time.
- **Baseline check before Task 1:** `npm test` green, `npx tsc --noEmit` clean.
- Commands below use `npx jest <path> -t '<name>'` for single tests; full suite is `npm test`.

---

### Task 1: Apex config module (`lib/apex-config.ts`)

The single source of truth for the four dealer-facing apex domains: identity, copy, metadata, hreflang sibling. Slug convention matches dealer routes: `{domainPrefix}-{domain}` (e.g. `myamsoil-com`).

**Files:**
- Create: `lib/apex-config.ts`
- Test: `lib/__tests__/apex-config.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// lib/__tests__/apex-config.test.ts
import {
  APEX_CONFIGS,
  getApexBySlug,
  getApexFromHost,
  type ApexConfig,
} from '../apex-config';

describe('apex-config', () => {
  const slugs = Object.keys(APEX_CONFIGS);

  it('defines exactly the four dealer-facing apexes', () => {
    expect(slugs.sort()).toEqual([
      'myamsoil-ca',
      'myamsoil-com',
      'shopamsoil-ca',
      'shopamsoil-com',
    ]);
  });

  it.each(slugs)('%s has every required field populated', (slug) => {
    const c = APEX_CONFIGS[slug];
    expect(c.hostname).toMatch(/^(my|shop)amsoil\.(com|ca)$/);
    expect(c.domainPrefix).toMatch(/^(myamsoil|shopamsoil)$/);
    expect(c.domain).toMatch(/^(com|ca)$/);
    expect(c.country).toMatch(/^(US|CA)$/);
    expect(c.siteName.length).toBeGreaterThan(3);
    expect(c.meta.title.length).toBeGreaterThan(10);
    expect(c.meta.description.length).toBeGreaterThan(50);
    expect(c.hero.title.length).toBeGreaterThan(10);
    expect(c.hero.subtitle.length).toBeGreaterThan(10);
    expect(c.trustCards).toHaveLength(3);
    for (const card of c.trustCards) {
      expect(card.title.length).toBeGreaterThan(3);
      expect(card.body.length).toBeGreaterThan(20);
    }
    expect(c.signupBand.title.length).toBeGreaterThan(5);
    expect(c.hreflangSiblingSlug in APEX_CONFIGS).toBe(true);
    expect(c.hreflangSiblingSlug).not.toBe(slug);
  });

  it('enforces four genuinely distinct copy sets (anti-doorway)', () => {
    const fields: Array<(c: ApexConfig) => string> = [
      (c) => c.hero.title,
      (c) => c.hero.subtitle,
      (c) => c.meta.description,
      (c) => c.trustCards.map((t) => t.body).join('|'),
    ];
    for (const pick of fields) {
      const values = slugs.map((s) => pick(APEX_CONFIGS[s]));
      expect(new Set(values).size).toBe(values.length); // all unique
    }
  });

  it('hreflang siblings pair com<->ca within the same prefix', () => {
    expect(APEX_CONFIGS['myamsoil-com'].hreflangSiblingSlug).toBe('myamsoil-ca');
    expect(APEX_CONFIGS['myamsoil-ca'].hreflangSiblingSlug).toBe('myamsoil-com');
    expect(APEX_CONFIGS['shopamsoil-com'].hreflangSiblingSlug).toBe('shopamsoil-ca');
    expect(APEX_CONFIGS['shopamsoil-ca'].hreflangSiblingSlug).toBe('shopamsoil-com');
  });

  describe('getApexFromHost', () => {
    it('matches bare apex hosts (with and without port)', () => {
      expect(getApexFromHost('myamsoil.com')?.slug).toBe('myamsoil-com');
      expect(getApexFromHost('shopamsoil.ca:443')?.slug).toBe('shopamsoil-ca');
      expect(getApexFromHost('MYAMSOIL.COM')?.slug).toBe('myamsoil-com');
    });
    it('matches www variants', () => {
      expect(getApexFromHost('www.shopamsoil.com')?.slug).toBe('shopamsoil-com');
    });
    it('rejects dealer subdomains, aimclear host, dev hosts', () => {
      expect(getApexFromHost('bobsoil.myamsoil.com')).toBeNull();
      expect(getApexFromHost('amsoil.aimclear.com')).toBeNull();
      expect(getApexFromHost('amsoil-dlp.acdev3.com')).toBeNull();
      expect(getApexFromHost('localhost:3000')).toBeNull();
    });
  });

  it('getApexBySlug returns null for unknown slugs', () => {
    expect(getApexBySlug('myamsoil-com')).not.toBeNull();
    expect(getApexBySlug('minnesota')).toBeNull();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest lib/__tests__/apex-config.test.ts`
Expected: FAIL - `Cannot find module '../apex-config'`

- [ ] **Step 3: Implement `lib/apex-config.ts`**

Write the module with the full copy decks (this copy shipped from the approved design; marketing can tune via PR later). The structure:

```typescript
// lib/apex-config.ts
/**
 * Apex domain configuration — single source of truth for the four
 * dealer-facing apex domains' identity, positioning copy, and metadata.
 *
 * Slug convention matches dealer routes: `${domainPrefix}-${domain}`.
 * amsoil.aimclear.com is NOT here: it keeps public/index.html (dealer
 * acquisition, AIMCLEAR-branded) and never gets a stub.
 *
 * The four copy decks MUST stay genuinely distinct (Google doorway policy:
 * "multiple domain names with slight variations"). Enforced by tests.
 */
import { type ValidPrefix, type ValidDomain } from './domain-config';

export interface ApexConfig {
  slug: string;
  hostname: string;
  domainPrefix: ValidPrefix;
  domain: ValidDomain;
  country: 'US' | 'CA';
  siteName: string;
  meta: { title: string; description: string };
  hero: { title: string; subtitle: string; cta: string };
  trustCards: Array<{ title: string; body: string }>;
  regionBandTitle: string;
  signupBand: { title: string; body: string; cta: string };
  hreflangSiblingSlug: string;
}

export const APEX_CONFIGS: Record<string, ApexConfig> = {
  'myamsoil-com': {
    slug: 'myamsoil-com',
    hostname: 'myamsoil.com',
    domainPrefix: 'myamsoil',
    domain: 'com',
    country: 'US',
    siteName: 'MyAMSOIL',
    meta: {
      title: 'Find Your Local Independent AMSOIL Dealer | MyAMSOIL.com',
      description:
        'Connect with an independent AMSOIL dealer near you. Browse hundreds of dealers across the United States, see who serves your area, and buy AMSOIL synthetic oil with personal service.',
    },
    hero: {
      title: 'Your Local Source for AMSOIL Synthetic Oil',
      subtitle:
        'Independent AMSOIL dealers across the United States, ready to help you choose the right products for your vehicles and equipment.',
      cta: 'Find a dealer near you',
    },
    trustCards: [
      {
        title: 'Personal service',
        body: 'Independent dealers answer your questions directly — real people who know the product line, not a call center queue.',
      },
      {
        title: 'Local expertise',
        body: 'Your dealer knows what works in your area, from daily commuters to farm equipment, and can point you to exactly the right spec.',
      },
      {
        title: 'A dealer relationship',
        body: 'Buy through the same dealer every time and they learn your vehicles, remind you of intervals, and handle the reordering.',
      },
    ],
    regionBandTitle: 'Find dealers by state',
    signupBand: {
      title: 'Are you an AMSOIL dealer?',
      body: 'Launch your own professional landing page in minutes.',
      cta: 'Get started',
    },
    hreflangSiblingSlug: 'myamsoil-ca',
  },
  'shopamsoil-com': {
    slug: 'shopamsoil-com',
    hostname: 'shopamsoil.com',
    domainPrefix: 'shopamsoil',
    domain: 'com',
    country: 'US',
    siteName: 'ShopAMSOIL',
    meta: {
      title: 'Where to Buy AMSOIL Synthetic Oil | ShopAMSOIL.com',
      description:
        'Shop AMSOIL synthetic motor oil, diesel oil, and powersports lubricants through independent dealers. Compare dealers by specialty and location and order factory-direct.',
    },
    hero: {
      title: 'Where to Buy AMSOIL — Direct from Independent Dealers',
      subtitle:
        'From full-synthetic motor oil to diesel, racing, and powersports lubricants — find a dealer who specializes in what you drive.',
      cta: 'Shop by dealer',
    },
    trustCards: [
      {
        title: 'Every product line',
        body: 'Motor oil, diesel, transmission fluid, filtration, powersports — dealers carry or source the full AMSOIL catalog.',
      },
      {
        title: 'Specialty matching',
        body: 'Racing? Marine? Fleet? Many dealers focus on a niche and can spec complete product packages for your use case.',
      },
      {
        title: 'Factory-direct ordering',
        body: 'Dealers set you up with the account type that gets you the best pricing, shipped straight from AMSOIL distribution centers.',
      },
    ],
    regionBandTitle: 'Browse dealers by state',
    signupBand: {
      title: 'Sell AMSOIL online',
      body: 'Get a storefront landing page that sends commissionable orders your way.',
      cta: 'Create your page',
    },
    hreflangSiblingSlug: 'shopamsoil-ca',
  },
  'myamsoil-ca': {
    slug: 'myamsoil-ca',
    hostname: 'myamsoil.ca',
    domainPrefix: 'myamsoil',
    domain: 'ca',
    country: 'CA',
    siteName: 'MyAMSOIL Canada',
    meta: {
      title: 'Find a Canadian Independent AMSOIL Dealer | MyAMSOIL.ca',
      description:
        'Find independent AMSOIL dealers across Canada. Get cold-weather lubrication advice from a local dealer and buy AMSOIL synthetic oil built for Canadian winters.',
    },
    hero: {
      title: 'AMSOIL Dealers Across Canada',
      subtitle:
        'Local independent dealers from BC to the Maritimes who know what Canadian winters demand from an engine.',
      cta: 'Find a dealer in your province',
    },
    trustCards: [
      {
        title: 'Cold-weather proven',
        body: 'AMSOIL synthetic oils stay fluid at -40°. Canadian dealers live with the same cold starts you do and spec accordingly.',
      },
      {
        title: 'Dealers in every region',
        body: 'From Vancouver Island to St. John’s, find a dealer who ships within Canada and understands provincial availability.',
      },
      {
        title: 'Advice you can phone',
        body: 'Your dealer is a person, not a portal — ask about snowmobile oil in October and block heaters in January.',
      },
    ],
    regionBandTitle: 'Find dealers by province',
    signupBand: {
      title: 'Are you a Canadian AMSOIL dealer?',
      body: 'Stand up your own landing page for your customers.',
      cta: 'Get started',
    },
    hreflangSiblingSlug: 'myamsoil-com',
  },
  'shopamsoil-ca': {
    slug: 'shopamsoil-ca',
    hostname: 'shopamsoil.ca',
    domainPrefix: 'shopamsoil',
    domain: 'ca',
    country: 'CA',
    siteName: 'ShopAMSOIL Canada',
    meta: {
      title: 'Buy AMSOIL in Canada Through Independent Dealers | ShopAMSOIL.ca',
      description:
        'Buy AMSOIL synthetic lubricants in Canada through independent dealers. Compare dealers by specialty — diesel, powersports, marine — with Canadian shipping and pricing.',
    },
    hero: {
      title: 'Buy AMSOIL in Canada, Dealer-Direct',
      subtitle:
        'Synthetic oils and lubricants for trucks, sleds, bikes, and boats — ordered through Canadian independent dealers.',
      cta: 'Browse Canadian dealers',
    },
    trustCards: [
      {
        title: 'Canadian availability',
        body: 'Dealers know which products stock in Canadian distribution centres and what ships fastest to your postal code.',
      },
      {
        title: 'Powersports country',
        body: 'Snowmobiles, ATVs, outboards — many Canadian dealers specialize in the machines that fill Canadian garages.',
      },
      {
        title: 'Order with guidance',
        body: 'Skip guessing at product codes: a dealer builds your order, applies the right account pricing, and tracks it to your door.',
      },
    ],
    regionBandTitle: 'Browse dealers by province',
    signupBand: {
      title: 'Sell AMSOIL in Canada',
      body: 'Launch a landing page that brings Canadian customers to you.',
      cta: 'Create your page',
    },
    hreflangSiblingSlug: 'shopamsoil-com',
  },
};

export function getApexBySlug(slug: string): ApexConfig | null {
  return APEX_CONFIGS[slug] ?? null;
}

/**
 * Resolve a request Host header to an apex config.
 * Accepts bare apex and www. variant; anything else (dealer subdomains,
 * amsoil.aimclear.com, dev hosts, custom domains) returns null.
 */
export function getApexFromHost(hostname: string): ApexConfig | null {
  const host = hostname.split(':')[0].toLowerCase();
  const bare = host.startsWith('www.') ? host.slice(4) : host;
  for (const config of Object.values(APEX_CONFIGS)) {
    if (config.hostname === bare) return config;
  }
  return null;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx jest lib/__tests__/apex-config.test.ts`
Expected: PASS (all)

- [ ] **Step 5: Commit**

```bash
git add lib/apex-config.ts lib/__tests__/apex-config.test.ts
git commit --no-verify -m "feat(apex): apex domain config with per-domain positioning copy"
```

---

### Task 2: Region slug table (`lib/region-slugs.ts`)

Fixed US-state + CA-province table. Region pages only ever mint from this table - a typo'd `Dealer.state` value can never create a URL. Handles both code (`MN`) and full-name (`Minnesota`) values in `Dealer.state`.

**Files:**
- Create: `lib/region-slugs.ts`
- Test: `lib/__tests__/region-slugs.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// lib/__tests__/region-slugs.test.ts
import { REGIONS, getRegionBySlug, resolveRegionForDealer } from '../region-slugs';

describe('region-slugs', () => {
  it('contains 50 US states + DC and 13 CA provinces/territories', () => {
    expect(REGIONS.filter((r) => r.country === 'US')).toHaveLength(51);
    expect(REGIONS.filter((r) => r.country === 'CA')).toHaveLength(13);
  });

  it('slugs are unique, lowercase, kebab-case', () => {
    const slugs = REGIONS.map((r) => r.slug);
    expect(new Set(slugs).size).toBe(slugs.length);
    for (const slug of slugs) expect(slug).toMatch(/^[a-z]+(-[a-z]+)*$/);
  });

  it('getRegionBySlug resolves and rejects', () => {
    expect(getRegionBySlug('texas')?.code).toBe('TX');
    expect(getRegionBySlug('british-columbia')?.code).toBe('BC');
    expect(getRegionBySlug('minnesota')?.name).toBe('Minnesota');
    expect(getRegionBySlug('ann-arbor')).toBeNull();
  });

  describe('resolveRegionForDealer', () => {
    it('matches 2-letter codes case-insensitively', () => {
      expect(resolveRegionForDealer('MN', 'US')?.slug).toBe('minnesota');
      expect(resolveRegionForDealer('mn', 'USA')?.slug).toBe('minnesota');
      expect(resolveRegionForDealer('ON', 'CA')?.slug).toBe('ontario');
    });
    it('matches full names case-insensitively', () => {
      expect(resolveRegionForDealer('Minnesota', 'US')?.slug).toBe('minnesota');
      expect(resolveRegionForDealer('british columbia', 'Canada')?.slug).toBe(
        'british-columbia'
      );
    });
    it('normalizes country variants', () => {
      expect(resolveRegionForDealer('TX', 'United States')?.country).toBe('US');
      expect(resolveRegionForDealer('QC', 'canada')?.country).toBe('CA');
    });
    it('returns null for junk', () => {
      expect(resolveRegionForDealer('Narnia', 'US')).toBeNull();
      expect(resolveRegionForDealer('', 'US')).toBeNull();
      // a US code with CA country mismatches -> null (data error surfaces as "Other")
      expect(resolveRegionForDealer('TX', 'CA')).toBeNull();
    });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest lib/__tests__/region-slugs.test.ts`
Expected: FAIL - module not found

- [ ] **Step 3: Implement**

```typescript
// lib/region-slugs.ts
/**
 * Fixed region (US state / CA province) table for the dealer directory.
 * Region page URLs mint ONLY from this table — never from Dealer.state
 * free text (contrast: amsoil.com serves Michigan cities at /us/minnesota/).
 */
export interface Region {
  slug: string; // 'minnesota', 'british-columbia'
  code: string; // 'MN', 'BC'
  name: string; // 'Minnesota', 'British Columbia'
  country: 'US' | 'CA';
}

const US: Array<[string, string]> = [
  ['AL', 'Alabama'], ['AK', 'Alaska'], ['AZ', 'Arizona'], ['AR', 'Arkansas'],
  ['CA', 'California'], ['CO', 'Colorado'], ['CT', 'Connecticut'], ['DE', 'Delaware'],
  ['DC', 'District of Columbia'], ['FL', 'Florida'], ['GA', 'Georgia'], ['HI', 'Hawaii'],
  ['ID', 'Idaho'], ['IL', 'Illinois'], ['IN', 'Indiana'], ['IA', 'Iowa'],
  ['KS', 'Kansas'], ['KY', 'Kentucky'], ['LA', 'Louisiana'], ['ME', 'Maine'],
  ['MD', 'Maryland'], ['MA', 'Massachusetts'], ['MI', 'Michigan'], ['MN', 'Minnesota'],
  ['MS', 'Mississippi'], ['MO', 'Missouri'], ['MT', 'Montana'], ['NE', 'Nebraska'],
  ['NV', 'Nevada'], ['NH', 'New Hampshire'], ['NJ', 'New Jersey'], ['NM', 'New Mexico'],
  ['NY', 'New York'], ['NC', 'North Carolina'], ['ND', 'North Dakota'], ['OH', 'Ohio'],
  ['OK', 'Oklahoma'], ['OR', 'Oregon'], ['PA', 'Pennsylvania'], ['RI', 'Rhode Island'],
  ['SC', 'South Carolina'], ['SD', 'South Dakota'], ['TN', 'Tennessee'], ['TX', 'Texas'],
  ['UT', 'Utah'], ['VT', 'Vermont'], ['VA', 'Virginia'], ['WA', 'Washington'],
  ['WV', 'West Virginia'], ['WI', 'Wisconsin'], ['WY', 'Wyoming'],
];

const CA: Array<[string, string]> = [
  ['AB', 'Alberta'], ['BC', 'British Columbia'], ['MB', 'Manitoba'],
  ['NB', 'New Brunswick'], ['NL', 'Newfoundland and Labrador'],
  ['NS', 'Nova Scotia'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut'],
  ['ON', 'Ontario'], ['PE', 'Prince Edward Island'], ['QC', 'Quebec'],
  ['SK', 'Saskatchewan'], ['YT', 'Yukon'],
];

function slugify(name: string): string {
  return name.toLowerCase().replace(/[^a-z]+/g, '-').replace(/(^-|-$)/g, '');
}

export const REGIONS: Region[] = [
  ...US.map(([code, name]) => ({ slug: slugify(name), code, name, country: 'US' as const })),
  ...CA.map(([code, name]) => ({ slug: slugify(name), code, name, country: 'CA' as const })),
];

const BY_SLUG = new Map(REGIONS.map((r) => [r.slug, r]));

export function getRegionBySlug(slug: string): Region | null {
  return BY_SLUG.get(slug) ?? null;
}

function normalizeCountry(country: string): 'US' | 'CA' | null {
  const c = country.trim().toLowerCase();
  if (['us', 'usa', 'united states', 'united states of america'].includes(c)) return 'US';
  if (['ca', 'can', 'canada'].includes(c)) return 'CA';
  return null;
}

/**
 * Map a dealer's free-text state/country to a Region, or null when the
 * values don't resolve (caller groups those under "Other locations").
 */
export function resolveRegionForDealer(state: string, country: string): Region | null {
  const normCountry = normalizeCountry(country);
  if (!normCountry || !state) return null;
  const s = state.trim();
  return (
    REGIONS.find(
      (r) =>
        r.country === normCountry &&
        (r.code === s.toUpperCase() || r.name.toLowerCase() === s.toLowerCase())
    ) ?? null
  );
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx jest lib/__tests__/region-slugs.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add lib/region-slugs.ts lib/__tests__/region-slugs.test.ts
git commit --no-verify -m "feat(directory): fixed US/CA region slug table"
```

---

### Task 3: Shared directory exclusion list (`lib/directory-exclusions.ts`) + sitemap filter

Test/demo/reserved subdomains currently pollute the apex sitemap (and would pollute the directory). One shared predicate, used by both.

**Files:**
- Create: `lib/directory-exclusions.ts`
- Modify: `app/sitemap.ts:190-213` (`buildGlobalSitemap` dealer loop)
- Test: `lib/__tests__/directory-exclusions.test.ts`

- [ ] **Step 1: Check for an existing reserved list to reuse**

Run: `grep -n "RESERVED" lib/subdomain-validation.ts lib/subdomain-validation-format.ts lib/dealer-validation.ts | head -20`

If a reserved-subdomain array already exists, import and extend it rather than duplicating values. Adjust the implementation in Step 4 accordingly (the test stays the same).

- [ ] **Step 2: Write the failing test**

```typescript
// lib/__tests__/directory-exclusions.test.ts
import { isExcludedSubdomain } from '../directory-exclusions';

describe('directory-exclusions', () => {
  it('excludes known test/demo/reserved subdomains', () => {
    for (const s of ['test123', 'joewarnertest', 'dealer', 'http', 'search', 'go', 'demo-x', 'demo-anything']) {
      expect(isExcludedSubdomain(s)).toBe(true);
    }
  });
  it('excludes test-* QA seed accounts', () => {
    expect(isExcludedSubdomain('test-starter')).toBe(true);
    expect(isExcludedSubdomain('test-professional')).toBe(true);
  });
  it('keeps real dealer subdomains', () => {
    for (const s of ['bobsoil', 'alexhammonds', 'desertpinesynthetics', 'gotest']) {
      expect(isExcludedSubdomain(s)).toBe(false);
    }
  });
  it('is case-insensitive', () => {
    expect(isExcludedSubdomain('Test123')).toBe(true);
  });
});
```

- [ ] **Step 3: Run test to verify it fails**

Run: `npx jest lib/__tests__/directory-exclusions.test.ts`
Expected: FAIL - module not found

- [ ] **Step 4: Implement**

```typescript
// lib/directory-exclusions.ts
/**
 * Test/demo/reserved subdomains excluded from ALL public SEO surfaces:
 * apex sitemap, dealer directory, spotlight. One list, one predicate.
 * (joe@'s SerpApi monitor reads the sitemap — these were polluting it.)
 */
const EXCLUDED_EXACT = new Set([
  'test123',
  'joewarnertest',
  'dealer',
  'http',
  'search',
  'go',
]);

const EXCLUDED_PATTERNS: RegExp[] = [
  /^demo-/, // demo-* sandbox dealers
  /^test-/, // test-* QA seed accounts (test-starter@claude.dev etc.)
];

export function isExcludedSubdomain(subdomain: string): boolean {
  const s = subdomain.toLowerCase();
  if (EXCLUDED_EXACT.has(s)) return true;
  return EXCLUDED_PATTERNS.some((re) => re.test(s));
}
```

- [ ] **Step 5: Run test to verify it passes**

Run: `npx jest lib/__tests__/directory-exclusions.test.ts`
Expected: PASS

- [ ] **Step 6: Wire into the global sitemap**

In `app/sitemap.ts`, add the import and filter inside `buildGlobalSitemap`'s dealer loop:

```typescript
import { isExcludedSubdomain } from '@/lib/directory-exclusions';
// ... inside buildGlobalSitemap's `for (const dealer of dealers)` loop:
    for (const dealer of dealers) {
      if (!dealer.subdomain) continue;
      if (isExcludedSubdomain(dealer.subdomain)) continue;
```

- [ ] **Step 7: Run the existing sitemap tests + full suite for regressions**

Run: `npx jest app --silent && npx jest lib --silent`
Expected: PASS (if a sitemap test asserts on a fixture dealer named like an excluded value, update the fixture name - it's asserting incidental data, not behavior)

- [ ] **Step 8: Commit**

```bash
git add lib/directory-exclusions.ts lib/__tests__/directory-exclusions.test.ts app/sitemap.ts
git commit --no-verify -m "feat(seo): shared test/demo subdomain exclusion list, applied to apex sitemap"
```

---

### Task 4: Directory data layer (`lib/dealer-directory.ts`)

Query + grouping. Cached per apex with a `directory-{slug}` tag for on-demand revalidation.

**Files:**
- Create: `lib/dealer-directory.ts`
- Test: `lib/__tests__/dealer-directory.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// lib/__tests__/dealer-directory.test.ts
/** @jest-environment node */
const mockFindMany = jest.fn();
jest.mock('../prisma', () => ({
  prisma: { dealer: { findMany: (...a: unknown[]) => mockFindMany(...a) } },
}));
// unstable_cache passthrough: execute the wrapped fn directly in tests
jest.mock('next/cache', () => ({
  unstable_cache: (fn: (...a: unknown[]) => unknown) => fn,
}));

import {
  getDealersForApex,
  groupDealersByRegion,
  MIN_DEALERS_FOR_REGION_PAGE,
  type DirectoryDealer,
} from '../dealer-directory';

const dealer = (over: Partial<DirectoryDealer> = {}): DirectoryDealer => ({
  subdomain: 'bobsoil',
  businessName: "Bob's Synthetic Oil",
  city: 'Austin',
  state: 'TX',
  country: 'US',
  description: 'Diesel & fleet specialist.',
  logoUrl: null,
  customDomain: null,
  customDomainStatus: 'none',
  subscriptionTier: 'Starter',
  pageContent: null,
  ...over,
});

describe('getDealersForApex', () => {
  beforeEach(() => mockFindMany.mockReset());

  it('queries active statuses scoped to the apex tuple and filters exclusions', async () => {
    mockFindMany.mockResolvedValue([
      dealer(),
      dealer({ subdomain: 'test123' }),
      dealer({ subdomain: 'demo-thing' }),
    ]);
    const result = await getDealersForApex('myamsoil', 'com');
    expect(mockFindMany).toHaveBeenCalledWith(
      expect.objectContaining({
        where: expect.objectContaining({
          status: { in: ['active', 'cancelled_pending'] },
          subdomain: { not: null },
          domainPrefix: 'myamsoil',
          domain: 'com',
        }),
      })
    );
    expect(result.map((d) => d.subdomain)).toEqual(['bobsoil']);
  });
});

describe('groupDealersByRegion', () => {
  const make = (n: number, state: string, city = 'Springfield') =>
    Array.from({ length: n }, (_, i) =>
      dealer({ subdomain: `d-${state}-${i}`, state, city: `${city}${i % 3}` })
    );

  it('splits regions into paged (>= threshold) and inline (< threshold)', () => {
    const grouped = groupDealersByRegion(
      [...make(MIN_DEALERS_FOR_REGION_PAGE, 'TX'), ...make(2, 'VT')],
      'US'
    );
    expect(grouped.paged.map((g) => g.region.slug)).toEqual(['texas']);
    expect(grouped.inline.map((g) => g.region.slug)).toEqual(['vermont']);
  });

  it('groups dealers by city within a region, cities sorted, dealers alphabetical', () => {
    const grouped = groupDealersByRegion(make(6, 'TX'), 'US');
    const tx = grouped.paged[0];
    const cityNames = tx.cities.map((c) => c.city);
    expect(cityNames).toEqual([...cityNames].sort());
    for (const c of tx.cities) {
      const names = c.dealers.map((d) => d.businessName ?? '');
      expect(names).toEqual([...names].sort());
    }
  });

  it('cross-border dealers group under foreign bucket, domestic regions first', () => {
    const grouped = groupDealersByRegion(
      [...make(6, 'TX'), ...make(1, 'ON')],
      'US' // apex country US; ON dealer has country US in fixture -> unresolvable
    );
    // ON with country US doesn't resolve -> "other" bucket
    expect(grouped.other).toHaveLength(1);
  });

  it('foreign-resolvable dealers land in the foreign bucket', () => {
    const dealers = [
      ...make(6, 'TX'),
      dealer({ subdomain: 'canuck', state: 'ON', country: 'CA', city: 'Ottawa' }),
    ];
    const grouped = groupDealersByRegion(dealers, 'US');
    expect(grouped.foreign.map((g) => g.region.slug)).toEqual(['ontario']);
  });

  it('unresolvable states land in other, never mint a region', () => {
    const grouped = groupDealersByRegion([dealer({ state: 'Narnia' })], 'US');
    expect(grouped.paged).toHaveLength(0);
    expect(grouped.other).toHaveLength(1);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest lib/__tests__/dealer-directory.test.ts`
Expected: FAIL - module not found

- [ ] **Step 3: Implement**

```typescript
// lib/dealer-directory.ts
/**
 * Data layer for the apex dealer directory (/dealers hub + region pages).
 * Cached per apex with tag `directory-{prefix}-{domain}` — invalidated
 * on dealer lifecycle changes (see lib/directory-revalidation.ts).
 */
import { unstable_cache } from 'next/cache';
import { prisma } from './prisma';
import { isExcludedSubdomain } from './directory-exclusions';
import { resolveRegionForDealer, type Region } from './region-slugs';
import type { ValidPrefix, ValidDomain } from './domain-config';

export const MIN_DEALERS_FOR_REGION_PAGE = 5;

export interface DirectoryDealer {
  subdomain: string | null;
  businessName: string | null;
  city: string;
  state: string;
  country: string;
  description: string | null;
  logoUrl: string | null;
  customDomain: string | null;
  customDomainStatus: string | null;
  subscriptionTier: string;
  pageContent: unknown;
}

const DIRECTORY_SELECT = {
  subdomain: true,
  businessName: true,
  city: true,
  state: true,
  country: true,
  description: true,
  logoUrl: true,
  customDomain: true,
  customDomainStatus: true,
  subscriptionTier: true,
  pageContent: true,
} as const;

export function directoryTag(prefix: ValidPrefix, domain: ValidDomain): string {
  return `directory-${prefix}-${domain}`;
}

async function fetchDealersForApex(
  prefix: ValidPrefix,
  domain: ValidDomain
): Promise<DirectoryDealer[]> {
  const dealers = await prisma.dealer.findMany({
    where: {
      status: { in: ['active', 'cancelled_pending'] },
      subdomain: { not: null },
      domainPrefix: prefix,
      domain,
    },
    select: DIRECTORY_SELECT,
    orderBy: { businessName: 'asc' },
  });
  return dealers.filter((d) => d.subdomain && !isExcludedSubdomain(d.subdomain));
}

export async function getDealersForApex(
  prefix: ValidPrefix,
  domain: ValidDomain
): Promise<DirectoryDealer[]> {
  const cached = unstable_cache(
    () => fetchDealersForApex(prefix, domain),
    ['dealers-for-apex', prefix, domain],
    { tags: [directoryTag(prefix, domain)], revalidate: 3600 }
  );
  return cached();
}

export interface CityGroup {
  city: string;
  dealers: DirectoryDealer[];
}
export interface RegionGroup {
  region: Region;
  count: number;
  cities: CityGroup[];
}
export interface GroupedDirectory {
  paged: RegionGroup[]; // >= threshold: gets /dealers/[region] page
  inline: RegionGroup[]; // < threshold: listed inline on the hub
  foreign: RegionGroup[]; // resolvable region in the other country
  other: DirectoryDealer[]; // unresolvable state/country data
}

export function groupDealersByRegion(
  dealers: DirectoryDealer[],
  apexCountry: 'US' | 'CA'
): GroupedDirectory {
  const byRegion = new Map<string, { region: Region; dealers: DirectoryDealer[] }>();
  const other: DirectoryDealer[] = [];

  for (const d of dealers) {
    const region = resolveRegionForDealer(d.state, d.country);
    if (!region) {
      other.push(d);
      continue;
    }
    const entry = byRegion.get(region.slug) ?? { region, dealers: [] };
    entry.dealers.push(d);
    byRegion.set(region.slug, entry);
  }

  const toGroup = ({ region, dealers: ds }: { region: Region; dealers: DirectoryDealer[] }): RegionGroup => {
    const cityMap = new Map<string, DirectoryDealer[]>();
    for (const d of ds) {
      const city = d.city.trim() || 'Other';
      cityMap.set(city, [...(cityMap.get(city) ?? []), d]);
    }
    const cities = [...cityMap.entries()]
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([city, cityDealers]) => ({
        city,
        dealers: [...cityDealers].sort((a, b) =>
          (a.businessName ?? '').localeCompare(b.businessName ?? '')
        ),
      }));
    return { region, count: ds.length, cities };
  };

  const domestic = [...byRegion.values()].filter((e) => e.region.country === apexCountry);
  const foreign = [...byRegion.values()].filter((e) => e.region.country !== apexCountry);
  const sortByName = (a: RegionGroup, b: RegionGroup) =>
    a.region.name.localeCompare(b.region.name);

  return {
    paged: domestic
      .filter((e) => e.dealers.length >= MIN_DEALERS_FOR_REGION_PAGE)
      .map(toGroup)
      .sort(sortByName),
    inline: domestic
      .filter((e) => e.dealers.length < MIN_DEALERS_FOR_REGION_PAGE)
      .map(toGroup)
      .sort(sortByName),
    foreign: foreign.map(toGroup).sort(sortByName),
    other,
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx jest lib/__tests__/dealer-directory.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add lib/dealer-directory.ts lib/__tests__/dealer-directory.test.ts
git commit --no-verify -m "feat(directory): apex-scoped dealer query and region/city grouping"
```

---

### Task 5: Spotlight selection (`lib/spotlight.ts`)

Quality gate (logo + description + customized pageContent) → tier-weighted, date-seeded deterministic pick of 3.

**Files:**
- Create: `lib/spotlight.ts`
- Test: `lib/__tests__/spotlight.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// lib/__tests__/spotlight.test.ts
import { selectSpotlight, isSpotlightEligible } from '../spotlight';
import type { DirectoryDealer } from '../dealer-directory';

const dealer = (over: Partial<DirectoryDealer> = {}): DirectoryDealer => ({
  subdomain: 'bobsoil',
  businessName: "Bob's Synthetic Oil",
  city: 'Austin',
  state: 'TX',
  country: 'US',
  description: 'Diesel & fleet specialist.',
  logoUrl: 'https://cdn.example.com/logo.png',
  customDomain: null,
  customDomainStatus: 'none',
  subscriptionTier: 'Starter',
  pageContent: { content: [{ type: 'Hero' }] },
  ...over,
});

describe('isSpotlightEligible', () => {
  it('requires logo, description, and customized pageContent', () => {
    expect(isSpotlightEligible(dealer())).toBe(true);
    expect(isSpotlightEligible(dealer({ logoUrl: null }))).toBe(false);
    expect(isSpotlightEligible(dealer({ description: null }))).toBe(false);
    expect(isSpotlightEligible(dealer({ description: '  ' }))).toBe(false);
    expect(isSpotlightEligible(dealer({ pageContent: null }))).toBe(false);
    expect(isSpotlightEligible(dealer({ pageContent: {} }))).toBe(false);
  });
});

describe('selectSpotlight', () => {
  const pool = Array.from({ length: 20 }, (_, i) =>
    dealer({
      subdomain: `dealer-${i}`,
      subscriptionTier: ['Starter', 'Growth', 'Enhanced', 'Professional'][i % 4],
    })
  );

  it('is deterministic for the same date seed', () => {
    const a = selectSpotlight(pool, '2026-06-09');
    const b = selectSpotlight(pool, '2026-06-09');
    expect(a.map((d) => d.subdomain)).toEqual(b.map((d) => d.subdomain));
  });

  it('rotates across dates', () => {
    const days = ['2026-06-09', '2026-06-10', '2026-06-11', '2026-06-12'];
    const picks = days.map((d) => selectSpotlight(pool, d).map((x) => x.subdomain).join(','));
    expect(new Set(picks).size).toBeGreaterThan(1);
  });

  it('returns 3 distinct eligible dealers', () => {
    const picked = selectSpotlight(pool, '2026-06-09');
    expect(picked).toHaveLength(3);
    expect(new Set(picked.map((d) => d.subdomain)).size).toBe(3);
  });

  it('filters ineligible dealers before selection', () => {
    const mixed = [dealer({ subdomain: 'good' }), dealer({ subdomain: 'no-logo', logoUrl: null })];
    const picked = selectSpotlight(mixed, '2026-06-09');
    expect(picked.map((d) => d.subdomain)).toEqual(['good']);
  });

  it('returns [] when nobody qualifies (hub hides the strip)', () => {
    expect(selectSpotlight([dealer({ logoUrl: null })], '2026-06-09')).toEqual([]);
  });

  it('weights higher tiers: Professional appears more often across many seeds', () => {
    const counts = { Starter: 0, Professional: 0 };
    for (let day = 1; day <= 60; day++) {
      const seed = `2026-07-${String(day % 28 + 1).padStart(2, '0')}-${day}`;
      for (const d of selectSpotlight(pool, seed)) {
        if (d.subscriptionTier === 'Starter') counts.Starter++;
        if (d.subscriptionTier === 'Professional') counts.Professional++;
      }
    }
    expect(counts.Professional).toBeGreaterThan(counts.Starter);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest lib/__tests__/spotlight.test.ts`
Expected: FAIL - module not found

- [ ] **Step 3: Implement**

```typescript
// lib/spotlight.ts
/**
 * "Featured dealers" spotlight selection.
 * Pool: quality-gated (logo + description + customized page) so the strip
 * always showcases configured pages. Selection: tier-weighted random
 * WITHOUT replacement, seeded by date string -> deterministic per ISR
 * render, rotates daily. No Math.random() (RSC determinism).
 */
import type { DirectoryDealer } from './dealer-directory';

export const SPOTLIGHT_SIZE = 3;

const TIER_WEIGHTS: Record<string, number> = {
  professional: 4,
  enhanced: 3,
  growth: 2,
  starter: 1,
};

export function isSpotlightEligible(d: DirectoryDealer): boolean {
  if (!d.logoUrl) return false;
  if (!d.description || !d.description.trim()) return false;
  // "customized" v1 predicate: non-empty pageContent JSON object
  if (!d.pageContent || typeof d.pageContent !== 'object') return false;
  if (Object.keys(d.pageContent as Record<string, unknown>).length === 0) return false;
  return true;
}

/** mulberry32 PRNG from a string seed — tiny, deterministic, no deps */
function seededRandom(seed: string): () => number {
  let h = 1779033703 ^ seed.length;
  for (let i = 0; i < seed.length; i++) {
    h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  let a = h >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function selectSpotlight(
  dealers: DirectoryDealer[],
  dateSeed: string,
  size: number = SPOTLIGHT_SIZE
): DirectoryDealer[] {
  const pool = dealers.filter(isSpotlightEligible);
  if (pool.length <= size) return pool;

  const rand = seededRandom(dateSeed);
  const remaining = pool.map((d) => ({
    dealer: d,
    weight: TIER_WEIGHTS[d.subscriptionTier?.toLowerCase() ?? ''] ?? 1,
  }));
  const picked: DirectoryDealer[] = [];

  while (picked.length < size && remaining.length > 0) {
    const total = remaining.reduce((sum, e) => sum + e.weight, 0);
    let roll = rand() * total;
    let idx = 0;
    for (let i = 0; i < remaining.length; i++) {
      roll -= remaining[i].weight;
      if (roll <= 0) {
        idx = i;
        break;
      }
    }
    picked.push(remaining[idx].dealer);
    remaining.splice(idx, 1);
  }
  return picked;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx jest lib/__tests__/spotlight.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add lib/spotlight.ts lib/__tests__/spotlight.test.ts
git commit --no-verify -m "feat(directory): quality-gated tier-weighted spotlight selection"
```

---

### Task 6: Rename internal dealer routes `app/dealers/*` → `app/sites/*`

The riskiest task. The proxy's `/dealers/` interception block must move to `/sites/` and the `/dealers/` prefix must fall through to normal routing (it becomes the public directory's URL space in Task 9). TDD: change the proxy test expectations FIRST.

**Files:**
- Rename: `app/dealers/` → `app/sites/` (whole tree: `[subdomain]/[domainSlug]/{page.tsx,layout.tsx,og/,[...slug]/}`)
- Modify: `proxy.ts` (rewrite strings ~L233-277, interception block L513-571, custom-domain rewrite ~L677, subdomain rewrite ~L851; comment refs)
- Modify: `app/robots.ts:57` (platform disallow)
- Modify: `__tests__/proxy.test.ts` (rewrite expectations)
- Modify: any other `/dealers/` string refs found by grep

- [ ] **Step 1: Inventory every reference**

Run: `grep -rn "dealers/" --include="*.ts" --include="*.tsx" app lib proxy.ts __tests__ | grep -v "app/dealers/" | grep -vE "directory|/dealers \(public" | sort`

Record the hit list. Expect at minimum: `proxy.ts` (rewrites + interception + comments), `app/robots.ts`, `__tests__/proxy.test.ts`, possibly `app/api/revalidate/route.ts` (revalidatePath strings), `lib/seo-utils.ts` comments, OG-related paths. Every code hit gets updated in this task; comment-only hits too (stale comments are bugs waiting to happen).

- [ ] **Step 2: Update proxy test expectations (failing tests first)**

In `__tests__/proxy.test.ts`, update every assertion that expects a rewrite to `/dealers/...` to expect `/sites/...`. Example pattern:

```typescript
// BEFORE
expect(response.headers.get('x-middleware-rewrite')).toContain('/dealers/test123');
// AFTER
expect(response.headers.get('x-middleware-rewrite')).toContain('/sites/test123');
```

Then ADD new tests for the moved interception and the freed-up `/dealers/` prefix:

```typescript
describe('/sites/ internal path protection (moved from /dealers/)', () => {
  it('301-redirects direct /sites/[subdomain] hits on production to canonical host', async () => {
    mockLookupDealerCanonicalHost.mockResolvedValueOnce('bobsoil.myamsoil.com');
    const request = new NextRequest('https://myamsoil.com/sites/bobsoil/myamsoil-com', {
      headers: { host: 'myamsoil.com' },
    });
    const response = await proxy(request);
    expect(response.status).toBe(301);
    expect(response.headers.get('location')).toBe('https://bobsoil.myamsoil.com/');
  });

  it('blocks non-GET methods on /sites/*', async () => {
    const request = new NextRequest('https://myamsoil.com/sites/bobsoil', {
      method: 'POST',
      headers: { host: 'myamsoil.com' },
    });
    const response = await proxy(request);
    expect(response.status).toBe(405);
  });

  it('404s bot probes on /sites/*', async () => {
    const request = new NextRequest('https://myamsoil.com/sites/wp-admin', {
      headers: { host: 'myamsoil.com' },
    });
    const response = await proxy(request);
    expect(response.status).toBe(404);
  });
});

describe('/dealers/ prefix is no longer intercepted as a dealer-site path', () => {
  it('does NOT treat /dealers/texas as a subdomain lookup', async () => {
    const { countActiveDealersBySubdomain } = jest.requireMock('../lib/subdomain-lookup');
    countActiveDealersBySubdomain.mockClear();
    const request = new NextRequest('https://myamsoil.com/dealers/texas', {
      headers: { host: 'myamsoil.com' },
    });
    await proxy(request);
    expect(countActiveDealersBySubdomain).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 3: Run proxy tests to verify they fail**

Run: `npx jest __tests__/proxy.test.ts`
Expected: FAIL - rewrites still target `/dealers/`, interception tests fail

- [ ] **Step 4: Perform the rename and proxy edits**

```bash
git mv app/dealers app/sites
```

Then in `proxy.ts`:
1. `handleDealerSlugPath` (~L233-277): rewrite target `/dealers/${subdomain}/...` → `/sites/${subdomain}/...` (and the doc comment).
2. Interception block (L513): `if (pathname.startsWith('/dealers/'))` → `if (pathname.startsWith('/sites/'))`. Inner comments updated. The 301/ambiguity/probe logic is otherwise unchanged.
3. Custom-domain rewrite (~L677): `/dealers/${customDomainDealer.subdomain}/...` → `/sites/...`.
4. Subdomain rewrite (~L851): `/dealers/${subdomain}/${domainSlug}${pathname}` → `/sites/...`.
5. OG comment (~L245) and any other comment refs.

In `app/robots.ts:57`: `'/dealers/'` → `'/sites/'` in the platform disallow array. (If the `amsoil-robots-txt` branch landed first, the array also contains `/dev-tools/` and no `/_next/` - only swap the one element; do not reintroduce `/_next/`.)

In `app/sites/[subdomain]/[domainSlug]/page.tsx` and `[...slug]/page.tsx`: update any self-referential comments and - critical - check `revalidatePath` / OG-cache code paths found in Step 1 for hardcoded `/dealers/` strings (e.g. `app/api/revalidate/route.ts` builds `revalidatePath('/dealers/...')` paths - update to `/sites/...`).

- [ ] **Step 5: Run the full test suite**

Run: `npm test`
Expected: PASS. Triage every failure - a missed `/dealers/` string shows up here, fix it in this task, not later.

- [ ] **Step 6: Typecheck + grep for stragglers**

Run: `npx tsc --noEmit && grep -rn "'/dealers/\|\"/dealers/\|\`/dealers/" --include="*.ts" --include="*.tsx" app lib proxy.ts | grep -v __tests__`
Expected: tsc clean; grep returns ZERO hits (public-directory code doesn't exist yet).

- [ ] **Step 7: Commit**

```bash
git add -u
git add app/sites
git commit --no-verify -m "refactor(routing): rename internal dealer routes /dealers/* -> /sites/*

Frees the public /dealers URL space for the directory. Proxy rewrite
targets, the protective interception block (method guard, bot probes,
301-to-canonical), robots.txt disallow, and revalidation paths all move."
```

(Note: `git add -u` stages tracked-file changes only - safe re CRLF since we only touched LF files; verify with `git diff --cached --stat` before committing.)

---

### Task 7: Proxy routes apex `/` to the stub and protects `/home/*`

Apex hosts (the 4 dealer-facing domains, www included) get `/` rewritten to `/home/{slug}` for unauthenticated users. `amsoil.aimclear.com`, dev hosts, and everything else keep today's behavior (`public/index.html`). Direct `/home/*` hits on production 308 to `/` (mirrors `/sites/` protection); on non-production they render (that's how we preview stubs on dev/staging).

**Files:**
- Modify: `proxy.ts` (root-path block ~L869-893 + new `/home/` guard near the `/sites/` block)
- Modify: `__tests__/proxy.test.ts`

- [ ] **Step 1: Write the failing tests**

```typescript
describe('apex stub routing', () => {
  it.each([
    ['myamsoil.com', 'myamsoil-com'],
    ['shopamsoil.com', 'shopamsoil-com'],
    ['myamsoil.ca', 'myamsoil-ca'],
    ['www.shopamsoil.ca', 'shopamsoil-ca'],
  ])('rewrites unauthenticated / on %s to /home/%s', async (host, slug) => {
    const { getToken } = jest.requireMock('next-auth/jwt');
    getToken.mockResolvedValueOnce(null);
    const request = new NextRequest(`https://${host}/`, { headers: { host } });
    const response = await proxy(request);
    expect(response.headers.get('x-middleware-rewrite')).toContain(`/home/${slug}`);
  });

  it('still redirects authenticated users on an apex to /dashboard', async () => {
    const { getToken } = jest.requireMock('next-auth/jwt');
    getToken.mockResolvedValueOnce({ sub: 'user-1' });
    const request = new NextRequest('https://myamsoil.com/', {
      headers: { host: 'myamsoil.com' },
    });
    const response = await proxy(request);
    expect(response.status).toBe(307);
    expect(response.headers.get('location')).toContain('/dashboard');
  });

  it('amsoil.aimclear.com keeps the static landing (no rewrite)', async () => {
    const { getToken } = jest.requireMock('next-auth/jwt');
    getToken.mockResolvedValueOnce(null);
    const request = new NextRequest('https://amsoil.aimclear.com/', {
      headers: { host: 'amsoil.aimclear.com' },
    });
    const response = await proxy(request);
    expect(response.headers.get('x-middleware-rewrite')).toBeNull();
  });

  it('308-redirects direct /home/* hits on production apexes to /', async () => {
    const request = new NextRequest('https://myamsoil.com/home/myamsoil-com', {
      headers: { host: 'myamsoil.com' },
    });
    const response = await proxy(request);
    expect(response.status).toBe(308);
    expect(new URL(response.headers.get('location')!).pathname).toBe('/');
  });

  it('serves /home/* directly on non-production hosts (dev preview)', async () => {
    const request = new NextRequest('http://localhost:3000/home/myamsoil-com', {
      headers: { host: 'localhost:3000' },
    });
    const response = await proxy(request);
    expect(response.status).not.toBe(308);
  });
});
```

- [ ] **Step 2: Run to verify failures**

Run: `npx jest __tests__/proxy.test.ts -t 'apex stub routing'`
Expected: FAIL - no rewrite happens yet

- [ ] **Step 3: Implement in `proxy.ts`**

Add import:

```typescript
import { getApexFromHost } from './lib/apex-config';
```

Add the `/home/` guard adjacent to the `/sites/` interception block:

```typescript
  // /home/* is the internal rewrite target for apex stub pages. Direct hits
  // on production are redirected to / (mirrors /sites/ canonical protection);
  // non-production serves them directly so dev/staging can preview stubs.
  if (pathname.startsWith('/home/')) {
    const hostWithoutPort = hostname.split(':')[0].toLowerCase();
    if (isProductionDomain(hostWithoutPort) && !request.nextUrl.searchParams.has('noredirect')) {
      return NextResponse.redirect(new URL('/', request.url), 308);
    }
  }
```

Modify the root-path block (the existing `if (pathname === '/')`): after the unauthenticated determination (both the `catch` path and the `!token` path currently `return nextWithoutDealerHeader(request)`), route apex hosts to the stub instead:

```typescript
    // Unauthenticated: apex domains get the host-aware stub; everything else
    // (amsoil.aimclear.com, dev hosts) keeps the static public/index.html.
    const apex = getApexFromHost(hostname);
    if (apex) {
      return NextResponse.rewrite(new URL(`/home/${apex.slug}`, request.url));
    }
    return nextWithoutDealerHeader(request); // Serves public/index.html
```

Apply the same apex branch in BOTH unauthenticated exits of the root block (the JWT-error catch path must also serve the stub - copy the two lines there, keeping the cookie-clearing behavior).

Note: `hostname` is already in scope in the proxy function (used by the subdomain logic). Confirm the variable name at the insertion point matches (it's `hostname` from `request.headers.get('host')` parsing near the top of `proxy()`).

- [ ] **Step 4: Run tests**

Run: `npx jest __tests__/proxy.test.ts`
Expected: PASS (including all pre-existing root-path tests)

- [ ] **Step 5: Commit**

```bash
git add proxy.ts __tests__/proxy.test.ts
git commit --no-verify -m "feat(apex): proxy routes apex / to host-aware stub at /home/{slug}"
```

---

### Task 8: Stub page route (`app/home/[domainSlug]/`)

Renders the approved layout-B stub from `ApexConfig`. ISR force-static, daily revalidate. Includes metadata (canonical, OG, hreflang), `WebSite` + `Organization` JSON-LD, and GA4.

**Files:**
- Create: `app/home/[domainSlug]/page.tsx`
- Create: `app/home/[domainSlug]/layout.tsx`
- Create: `components/apex/ApexAnalytics.tsx`
- Create: `components/apex/ApexHeader.tsx`, `components/apex/ApexFooter.tsx` (shared with directory pages in Tasks 10-11)
- Test: `app/home/__tests__/page.test.tsx`

- [ ] **Step 1: Write the failing tests** (metadata + JSON-LD are the SEO-load-bearing parts - test those as data, render-test the page shell)

```tsx
// app/home/__tests__/page.test.tsx
/** @jest-environment node */
import { generateMetadata, generateStaticParams } from '../[domainSlug]/page';

describe('stub page generateStaticParams', () => {
  it('pre-generates exactly the four apex slugs', async () => {
    const params = await generateStaticParams();
    expect(params.map((p) => p.domainSlug).sort()).toEqual([
      'myamsoil-ca',
      'myamsoil-com',
      'shopamsoil-ca',
      'shopamsoil-com',
    ]);
  });
});

describe('stub page generateMetadata', () => {
  const meta = (slug: string) =>
    generateMetadata({ params: Promise.resolve({ domainSlug: slug }) });

  it('emits host-correct canonical with no trailing slash issues', async () => {
    const m = await meta('myamsoil-com');
    expect(m.alternates?.canonical).toBe('https://myamsoil.com');
    expect(m.alternates?.canonical).not.toMatch(/\/$/);
  });

  it('emits per-domain og:site_name (no AIMCLEAR anywhere)', async () => {
    for (const slug of ['myamsoil-com', 'shopamsoil-com', 'myamsoil-ca', 'shopamsoil-ca']) {
      const m = await meta(slug);
      expect(JSON.stringify(m)).not.toMatch(/aimclear/i);
      expect(m.openGraph?.siteName?.length).toBeGreaterThan(3);
    }
  });

  it('emits hreflang pairs between com and ca siblings', async () => {
    const m = await meta('myamsoil-com');
    expect(m.alternates?.languages?.['en-US']).toBe('https://myamsoil.com');
    expect(m.alternates?.languages?.['en-CA']).toBe('https://myamsoil.ca');
  });

  it('four domains produce four distinct titles and descriptions', async () => {
    const all = await Promise.all(
      ['myamsoil-com', 'shopamsoil-com', 'myamsoil-ca', 'shopamsoil-ca'].map(meta)
    );
    expect(new Set(all.map((m) => m.title)).size).toBe(4);
    expect(new Set(all.map((m) => m.description)).size).toBe(4);
  });
});
```

- [ ] **Step 2: Run to verify failure**

Run: `npx jest app/home`
Expected: FAIL - module not found

- [ ] **Step 3: Implement the page**

```tsx
// app/home/[domainSlug]/page.tsx
/**
 * Apex stub homepage — one per dealer-facing apex domain.
 * Public URL is the apex root (/); proxy rewrites to /home/[domainSlug].
 * Direct /home/* hits on production 308 to / (proxy).
 */
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { APEX_CONFIGS, getApexBySlug } from '@/lib/apex-config';
import { getDealersForApex, groupDealersByRegion } from '@/lib/dealer-directory';
import { ApexHeader } from '@/components/apex/ApexHeader';
import { ApexFooter } from '@/components/apex/ApexFooter';

export const revalidate = 86400; // daily
export const dynamic = 'force-static';
export const dynamicParams = false; // only the four configured apexes exist

export async function generateStaticParams() {
  return Object.keys(APEX_CONFIGS).map((domainSlug) => ({ domainSlug }));
}

interface Props {
  params: Promise<{ domainSlug: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { domainSlug } = await params;
  const apex = getApexBySlug(domainSlug);
  if (!apex) return {};
  const sibling = APEX_CONFIGS[apex.hreflangSiblingSlug];
  const origin = `https://${apex.hostname}`;
  const [usUrl, caUrl] =
    apex.country === 'US'
      ? [origin, `https://${sibling.hostname}`]
      : [`https://${sibling.hostname}`, origin];
  return {
    title: apex.meta.title,
    description: apex.meta.description,
    alternates: {
      canonical: origin,
      languages: { 'en-US': usUrl, 'en-CA': caUrl },
    },
    openGraph: {
      type: 'website',
      url: origin,
      siteName: apex.siteName,
      title: apex.meta.title,
      description: apex.meta.description,
      images: [`${origin}/media/amsoil-logo.png`],
    },
    twitter: {
      card: 'summary_large_image',
      title: apex.meta.title,
      description: apex.meta.description,
    },
  };
}

function buildJsonLd(apex: NonNullable<ReturnType<typeof getApexBySlug>>) {
  const origin = `https://${apex.hostname}`;
  return {
    '@context': 'https://schema.org',
    '@graph': [
      {
        '@type': 'WebSite',
        '@id': `${origin}/#website`,
        url: origin,
        name: apex.siteName,
        description: apex.meta.description,
      },
      {
        '@type': 'Organization',
        '@id': `${origin}/#organization`,
        name: apex.siteName,
        url: origin,
      },
    ],
  };
}

export default async function ApexStubPage({ params }: Props) {
  const { domainSlug } = await params;
  const apex = getApexBySlug(domainSlug);
  if (!apex) notFound();

  const dealers = await getDealersForApex(apex.domainPrefix, apex.domain);
  const grouped = groupDealersByRegion(dealers, apex.country);
  const topRegions = [...grouped.paged].sort((a, b) => b.count - a.count).slice(0, 8);

  return (
    <div className="min-h-screen bg-slate-900 text-slate-200">
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(buildJsonLd(apex)) }}
      />
      <ApexHeader apex={apex} />
      <main id="main">
        {/* Hero */}
        <section className="px-4 py-16 md:py-24 text-center">
          <div className="mx-auto max-w-3xl rounded-3xl border border-slate-400/25 bg-slate-900/45 p-8 md:p-12 backdrop-blur">
            <h1 className="text-3xl md:text-5xl font-extrabold text-slate-50">
              {apex.hero.title}
            </h1>
            <p className="mt-4 text-base md:text-lg text-slate-400">{apex.hero.subtitle}</p>
            <Link
              href="/dealers"
              className="mt-8 inline-block rounded-lg bg-orange-500 px-6 py-3 font-semibold text-white hover:bg-orange-600"
            >
              {apex.hero.cta} →
            </Link>
          </div>
        </section>

        {/* Trust band */}
        <section className="px-4 pb-12">
          <div className="mx-auto grid max-w-5xl grid-cols-1 gap-4 md:grid-cols-3">
            {apex.trustCards.map((card) => (
              <div key={card.title} className="rounded-xl border border-slate-700 bg-slate-800 p-6">
                <h2 className="font-bold text-slate-50">{card.title}</h2>
                <p className="mt-2 text-sm text-slate-300">{card.body}</p>
              </div>
            ))}
          </div>
        </section>

        {/* Region band */}
        <section className="px-4 pb-12">
          <div className="mx-auto max-w-5xl">
            <h2 className="mb-4 font-semibold text-slate-50">{apex.regionBandTitle}</h2>
            <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
              {topRegions.map((g) => (
                <Link
                  key={g.region.slug}
                  href={`/dealers/${g.region.slug}`}
                  className="rounded-lg border border-slate-700 bg-slate-800 p-3 text-center text-blue-400 hover:border-slate-500"
                >
                  {g.region.name}
                  <span className="block text-xs text-slate-400">{g.count} dealers</span>
                </Link>
              ))}
              <Link
                href="/dealers"
                className="rounded-lg border border-slate-700 bg-slate-800 p-3 text-center text-slate-400 hover:border-slate-500"
              >
                All {apex.country === 'US' ? 'states' : 'provinces'} →
              </Link>
            </div>
          </div>
        </section>

        {/* Dealer signup band */}
        <section className="px-4 pb-16">
          <div className="mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 rounded-xl bg-gradient-to-r from-slate-800 to-slate-700 p-6 md:flex-row">
            <p>
              <strong className="text-slate-50">{apex.signupBand.title}</strong>{' '}
              <span className="text-slate-300">{apex.signupBand.body}</span>
            </p>
            <Link
              href="/register"
              className="rounded-lg border border-slate-500 bg-slate-900 px-5 py-2 text-slate-200 hover:border-slate-300"
            >
              {apex.signupBand.cta}
            </Link>
          </div>
        </section>
      </main>
      <ApexFooter apex={apex} />
    </div>
  );
}
```

- [ ] **Step 4: Implement layout + shared components**

```tsx
// app/home/[domainSlug]/layout.tsx
import { ApexAnalytics } from '@/components/apex/ApexAnalytics';

export default function ApexHomeLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <ApexAnalytics />
    </>
  );
}
```

```tsx
// components/apex/ApexAnalytics.tsx
'use client';
/**
 * GA4 loader for apex stub + directory pages.
 * Same measurement ID and production-domain guard as public/index.html
 * (which keeps its own inline copy for amsoil.aimclear.com).
 */
import Script from 'next/script';
import { PRODUCTION_DOMAINS } from '@/lib/production-domains';

const GA_ID = 'G-NR1G252PS2';

export function ApexAnalytics() {
  if (typeof window !== 'undefined') {
    const host = window.location.hostname.toLowerCase();
    const isProd = PRODUCTION_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));
    if (!isProd) return null;
  }
  return (
    <>
      <Script src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`} strategy="afterInteractive" />
      <Script id="ga4-init" strategy="afterInteractive">
        {`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', '${GA_ID}');`}
      </Script>
    </>
  );
}
```

```tsx
// components/apex/ApexHeader.tsx
import Link from 'next/link';
import type { ApexConfig } from '@/lib/apex-config';

export function ApexHeader({ apex }: { apex: ApexConfig }) {
  return (
    <header className="border-b border-slate-800 px-4 py-4">
      <div className="mx-auto flex max-w-5xl items-center justify-between">
        <Link href="/" className="text-lg font-bold text-slate-50">
          {apex.siteName}
        </Link>
        <nav className="flex gap-5 text-sm text-slate-400">
          <Link href="/dealers" className="hover:text-slate-200">
            Find a Dealer
          </Link>
          <Link href="/register" className="hover:text-slate-200">
            For Dealers
          </Link>
        </nav>
      </div>
    </header>
  );
}
```

```tsx
// components/apex/ApexFooter.tsx
import Link from 'next/link';
import type { ApexConfig } from '@/lib/apex-config';

export function ApexFooter({ apex }: { apex: ApexConfig }) {
  return (
    <footer className="border-t border-slate-800 px-4 py-8 text-center text-sm text-slate-500">
      <nav className="mb-2 flex justify-center gap-4">
        <Link href="/dealers" className="hover:text-slate-300">Dealer Directory</Link>
        <Link href="/register" className="hover:text-slate-300">Become a Dealer</Link>
        <Link href="/terms" className="hover:text-slate-300">Terms</Link>
      </nav>
      <p>
        {apex.siteName} — independent AMSOIL dealer landing pages. AMSOIL and the AMSOIL logo
        are registered trademarks of AMSOIL INC.
      </p>
    </footer>
  );
}
```

- [ ] **Step 5: Run tests + typecheck**

Run: `npx jest app/home && npx tsc --noEmit`
Expected: PASS / clean

- [ ] **Step 6: Visual smoke check on dev**

Run: `npm run dev` (or use the running dev server) then `curl -s http://localhost:3000/home/myamsoil-com | grep -c "Your Local Source"`
Expected: `1`. Repeat for the other three slugs (each should show its own hero copy).

- [ ] **Step 7: Commit**

```bash
git add app/home components/apex app/home/__tests__
git commit --no-verify -m "feat(apex): host-aware stub homepage with per-domain copy, JSON-LD, hreflang, GA4"
```

---

### Task 9: Proxy routes `/dealers` + `/dealers/[region]` to the directory

Apex hosts: `/dealers` → `/directory/{slug}`, `/dealers/{region}` → `/directory/{slug}/{region}`. Direct `/directory/*` hits 308 back to the public URL on production. Robots: disallow the two internal prefixes.

**Files:**
- Modify: `proxy.ts`
- Modify: `app/robots.ts` (add `/home/`, `/directory/` to platform disallow)
- Modify: `__tests__/proxy.test.ts`
- Test (robots): `app/__tests__/robots.test.ts` (extend existing if present - check `ls app/__tests__/` and `grep -rln "robots" __tests__ app` first; create if absent)

- [ ] **Step 1: Write the failing proxy tests**

```typescript
describe('public /dealers directory routing', () => {
  const { getToken } = jest.requireMock('next-auth/jwt');
  beforeEach(() => getToken.mockResolvedValue(null));

  it('rewrites /dealers on an apex to /directory/{slug}', async () => {
    const request = new NextRequest('https://myamsoil.com/dealers', {
      headers: { host: 'myamsoil.com' },
    });
    const response = await proxy(request);
    expect(response.headers.get('x-middleware-rewrite')).toContain('/directory/myamsoil-com');
  });

  it('rewrites /dealers/texas on an apex to /directory/{slug}/texas', async () => {
    const request = new NextRequest('https://shopamsoil.ca/dealers/ontario', {
      headers: { host: 'shopamsoil.ca' },
    });
    const response = await proxy(request);
    expect(response.headers.get('x-middleware-rewrite')).toContain(
      '/directory/shopamsoil-ca/ontario'
    );
  });

  it('does not rewrite /dealers on non-apex hosts', async () => {
    const request = new NextRequest('https://amsoil.aimclear.com/dealers', {
      headers: { host: 'amsoil.aimclear.com' },
    });
    const response = await proxy(request);
    expect(response.headers.get('x-middleware-rewrite') ?? '').not.toContain('/directory/');
  });

  it('308-redirects direct /directory/* hits on production to the public URL', async () => {
    const request = new NextRequest('https://myamsoil.com/directory/myamsoil-com/texas', {
      headers: { host: 'myamsoil.com' },
    });
    const response = await proxy(request);
    expect(response.status).toBe(308);
    expect(new URL(response.headers.get('location')!).pathname).toBe('/dealers/texas');
  });
});
```

- [ ] **Step 2: Run to verify failure**

Run: `npx jest __tests__/proxy.test.ts -t 'public /dealers directory routing'`
Expected: FAIL

- [ ] **Step 3: Implement in `proxy.ts`**

Add near the `/home/` guard:

```typescript
  // /directory/* is the internal rewrite target for the public /dealers
  // directory. Direct production hits redirect to the public URL.
  if (pathname.startsWith('/directory/')) {
    const hostWithoutPort = hostname.split(':')[0].toLowerCase();
    if (isProductionDomain(hostWithoutPort) && !request.nextUrl.searchParams.has('noredirect')) {
      const segments = pathname.split('/').filter(Boolean); // ['directory', slug, region?]
      const publicPath = segments.length >= 3 ? `/dealers/${segments[2]}` : '/dealers';
      return NextResponse.redirect(new URL(publicPath, request.url), 308);
    }
  }

  // Public dealer directory on apex hosts: /dealers and /dealers/{region}
  if (pathname === '/dealers' || /^\/dealers\/[a-z-]+$/.test(pathname)) {
    const apex = getApexFromHost(hostname);
    if (apex) {
      const region = pathname === '/dealers' ? '' : pathname.slice('/dealers/'.length);
      return NextResponse.rewrite(
        new URL(`/directory/${apex.slug}${region ? `/${region}` : ''}`, request.url)
      );
    }
  }
```

Placement: BEFORE the auth/protected-path checks and before subdomain routing's fall-through, in the same region as the `/sites/` guard. Dealer hosts never match (their `getApexFromHost` is null), so a dealer-site CMS page named `/dealers` on a subdomain still routes to the dealer's own slug handler.

In `app/robots.ts`, extend the platform disallow array:

```typescript
disallow: ['/api/', '/dashboard/', '/auth/', '/admin/', '/dev/', '/sites/', '/home/', '/directory/'],
```

(After rebase on `amsoil-robots-txt`, the array also includes `/dev-tools/` and excludes `/_next/` - keep both of those properties; just ensure `/sites/`, `/home/`, `/directory/` are present and `/dealers/` is absent.)

- [ ] **Step 4: Run tests**

Run: `npx jest __tests__/proxy.test.ts && npx jest app`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add proxy.ts app/robots.ts __tests__/proxy.test.ts
git commit --no-verify -m "feat(directory): proxy routes public /dealers URLs to internal /directory routes"
```

---

### Task 10: Directory hub page (`app/directory/[domainSlug]/page.tsx`)

Locator hub (approved option C): search island, spotlight strip, region grid, inline small regions. ISR 1h with `directory-{slug}` tag.

**Files:**
- Create: `app/directory/[domainSlug]/page.tsx`
- Create: `components/directory/DealerSearch.tsx` (client island)
- Create: `components/directory/DealerRow.tsx`
- Create: `components/directory/SpotlightStrip.tsx`
- Create: `lib/directory-jsonld.ts`
- Test: `lib/__tests__/directory-jsonld.test.ts`, `app/directory/__tests__/page.test.tsx`

- [ ] **Step 1: Write the failing JSON-LD tests**

```typescript
// lib/__tests__/directory-jsonld.test.ts
import { buildHubJsonLd, buildRegionJsonLd } from '../directory-jsonld';
import { getApexBySlug } from '../apex-config';
import { getRegionBySlug } from '../region-slugs';

const apex = getApexBySlug('myamsoil-com')!;
const dealers = [
  {
    businessName: "Bob's Synthetic Oil",
    city: 'Austin',
    state: 'TX',
    url: 'https://bobsoil.myamsoil.com',
  },
];

describe('buildHubJsonLd', () => {
  const ld = buildHubJsonLd(apex, 612);
  it('is a CollectionPage about the apex with correct canonical URL', () => {
    expect(ld['@type']).toBe('CollectionPage');
    expect(ld.url).toBe('https://myamsoil.com/dealers');
    expect(ld.url).not.toMatch(/\/$/);
  });
  it('carries the dealer count in the description', () => {
    expect(ld.description).toContain('612');
  });
});

describe('buildRegionJsonLd', () => {
  const region = getRegionBySlug('texas')!;
  const ld = buildRegionJsonLd(apex, region, dealers);
  it('includes BreadcrumbList back to the hub', () => {
    const crumbs = ld['@graph'].find((n: { '@type': string }) => n['@type'] === 'BreadcrumbList');
    expect(crumbs.itemListElement).toHaveLength(3);
    expect(crumbs.itemListElement[1].item).toBe('https://myamsoil.com/dealers');
  });
  it('includes an ItemList naming each dealer with their canonical site URL', () => {
    const list = ld['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList');
    expect(list.itemListElement[0].item.name).toBe("Bob's Synthetic Oil");
    expect(list.itemListElement[0].item.url).toBe('https://bobsoil.myamsoil.com');
  });
});
```

- [ ] **Step 2: Run to verify failure**

Run: `npx jest lib/__tests__/directory-jsonld.test.ts`
Expected: FAIL - module not found

- [ ] **Step 3: Implement `lib/directory-jsonld.ts`**

```typescript
// lib/directory-jsonld.ts
/**
 * JSON-LD builders for directory pages (hub: CollectionPage,
 * region: CollectionPage + BreadcrumbList + ItemList of dealers).
 * URLs follow the no-trailing-slash canonical convention (PR #903).
 */
import type { ApexConfig } from './apex-config';
import type { Region } from './region-slugs';

export interface JsonLdDealer {
  businessName: string | null;
  city: string;
  state: string;
  url: string;
}

export function buildHubJsonLd(apex: ApexConfig, dealerCount: number) {
  const origin = `https://${apex.hostname}`;
  return {
    '@context': 'https://schema.org',
    '@type': 'CollectionPage',
    url: `${origin}/dealers`,
    name: `Independent AMSOIL Dealer Directory | ${apex.siteName}`,
    description: `Browse ${dealerCount} independent AMSOIL dealers. Search by name, city, or ${
      apex.country === 'US' ? 'state' : 'province'
    } and visit each dealer's own site.`,
    isPartOf: { '@id': `${origin}/#website` },
  };
}

export function buildRegionJsonLd(apex: ApexConfig, region: Region, dealers: JsonLdDealer[]) {
  const origin = `https://${apex.hostname}`;
  const pageUrl = `${origin}/dealers/${region.slug}`;
  return {
    '@context': 'https://schema.org',
    '@graph': [
      {
        '@type': 'CollectionPage',
        url: pageUrl,
        name: `AMSOIL Dealers in ${region.name} | ${apex.siteName}`,
      },
      {
        '@type': 'BreadcrumbList',
        itemListElement: [
          { '@type': 'ListItem', position: 1, name: apex.siteName, item: origin },
          { '@type': 'ListItem', position: 2, name: 'Dealers', item: `${origin}/dealers` },
          { '@type': 'ListItem', position: 3, name: region.name, item: pageUrl },
        ],
      },
      {
        '@type': 'ItemList',
        itemListElement: dealers.map((d, i) => ({
          '@type': 'ListItem',
          position: i + 1,
          item: {
            '@type': 'LocalBusiness',
            name: d.businessName ?? 'AMSOIL Dealer',
            url: d.url,
            address: {
              '@type': 'PostalAddress',
              addressLocality: d.city,
              addressRegion: d.state,
              addressCountry: region.country,
            },
          },
        })),
      },
    ],
  };
}
```

Run: `npx jest lib/__tests__/directory-jsonld.test.ts` → PASS, then commit:

```bash
git add lib/directory-jsonld.ts lib/__tests__/directory-jsonld.test.ts
git commit --no-verify -m "feat(directory): CollectionPage/BreadcrumbList/ItemList JSON-LD builders"
```

- [ ] **Step 4: Write the failing hub render test**

```tsx
// app/directory/__tests__/page.test.tsx
/** @jest-environment node */
const mockGetDealers = jest.fn();
jest.mock('@/lib/dealer-directory', () => ({
  ...jest.requireActual('@/lib/dealer-directory'),
  getDealersForApex: (...a: unknown[]) => mockGetDealers(...a),
}));

import { generateMetadata, generateStaticParams } from '../[domainSlug]/page';

describe('directory hub', () => {
  it('pre-generates the four apex hubs', async () => {
    const params = await generateStaticParams();
    expect(params).toHaveLength(4);
  });

  it('metadata: canonical is the public /dealers URL, not /directory', async () => {
    const m = await generateMetadata({
      params: Promise.resolve({ domainSlug: 'myamsoil-com' }),
    });
    expect(m.alternates?.canonical).toBe('https://myamsoil.com/dealers');
    expect(JSON.stringify(m)).not.toContain('/directory/');
  });
});
```

- [ ] **Step 5: Run to verify failure, then implement the hub page**

Run: `npx jest app/directory` → FAIL

```tsx
// app/directory/[domainSlug]/page.tsx
/**
 * Dealer directory hub. Public URL: {apex}/dealers (proxy rewrite).
 * Locator pattern: search island + spotlight + region grid + inline
 * sub-threshold regions. Every dealer on the apex is reachable from
 * here (region page at depth 2, or inline at depth 1).
 */
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { APEX_CONFIGS, getApexBySlug } from '@/lib/apex-config';
import { getDealersForApex, groupDealersByRegion } from '@/lib/dealer-directory';
import { selectSpotlight } from '@/lib/spotlight';
import { buildHubJsonLd } from '@/lib/directory-jsonld';
import { getCanonicalUrl } from '@/lib/seo-utils';
import { ApexHeader } from '@/components/apex/ApexHeader';
import { ApexFooter } from '@/components/apex/ApexFooter';
import { ApexAnalytics } from '@/components/apex/ApexAnalytics';
import { DealerSearch } from '@/components/directory/DealerSearch';
import { DealerRow } from '@/components/directory/DealerRow';
import { SpotlightStrip } from '@/components/directory/SpotlightStrip';

export const revalidate = 3600;
export const dynamic = 'force-static';
export const dynamicParams = false;

export async function generateStaticParams() {
  return Object.keys(APEX_CONFIGS).map((domainSlug) => ({ domainSlug }));
}

interface Props {
  params: Promise<{ domainSlug: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { domainSlug } = await params;
  const apex = getApexBySlug(domainSlug);
  if (!apex) return {};
  const url = `https://${apex.hostname}/dealers`;
  const title = `Find an Independent AMSOIL Dealer | ${apex.siteName}`;
  const description = `Browse independent AMSOIL dealers by ${
    apex.country === 'US' ? 'state' : 'province'
  }, search by name or city, and visit each dealer's own site.`;
  return {
    title,
    description,
    alternates: { canonical: url },
    openGraph: { type: 'website', url, siteName: apex.siteName, title, description },
  };
}

export default async function DirectoryHubPage({ params }: Props) {
  const { domainSlug } = await params;
  const apex = getApexBySlug(domainSlug);
  if (!apex) notFound();

  const dealers = await getDealersForApex(apex.domainPrefix, apex.domain);
  const grouped = groupDealersByRegion(dealers, apex.country);
  const dateSeed = new Date().toISOString().slice(0, 10); // resolves at ISR render time
  const spotlight = selectSpotlight(dealers, `${dateSeed}-${apex.slug}`);

  const searchIndex = dealers.map((d) => ({
    name: d.businessName ?? d.subdomain ?? '',
    city: d.city,
    state: d.state,
    url: getCanonicalUrl(d),
  }));

  const regionNoun = apex.country === 'US' ? 'state' : 'province';

  return (
    <div className="min-h-screen bg-slate-900 text-slate-200">
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(buildHubJsonLd(apex, dealers.length)) }}
      />
      <ApexHeader apex={apex} />
      <main id="main" className="mx-auto max-w-5xl px-4 pb-16">
        <section className="py-12 text-center">
          <h1 className="text-3xl font-extrabold text-slate-50 md:text-4xl">
            Find an Independent AMSOIL Dealer
          </h1>
          <p className="mt-2 text-slate-400">
            {dealers.length} dealers across {grouped.paged.length + grouped.inline.length}{' '}
            {regionNoun}s
          </p>
          <DealerSearch dealers={searchIndex} />
        </section>

        {spotlight.length > 0 && <SpotlightStrip dealers={spotlight} />}

        <section className="mt-10">
          <h2 className="mb-4 font-semibold text-slate-50">Browse by {regionNoun}</h2>
          <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
            {grouped.paged.map((g) => (
              <Link
                key={g.region.slug}
                href={`/dealers/${g.region.slug}`}
                className="rounded-lg border border-slate-700 bg-slate-800 p-3 text-center text-blue-400 hover:border-slate-500"
              >
                {g.region.name}
                <span className="block text-xs text-slate-400">{g.count}</span>
              </Link>
            ))}
          </div>
        </section>

        {grouped.inline.length > 0 && (
          <section className="mt-10">
            <h2 className="mb-4 font-semibold text-slate-50">
              More {regionNoun}s with dealers
            </h2>
            {grouped.inline.map((g) => (
              <div key={g.region.slug} id={g.region.slug} className="mb-6">
                <h3 className="mb-2 border-b border-slate-700 pb-1 font-semibold text-slate-50">
                  {g.region.name} <span className="font-normal text-slate-400">({g.count})</span>
                </h3>
                {g.cities.flatMap((c) => c.dealers).map((d) => (
                  <DealerRow key={`${d.subdomain}`} dealer={d} />
                ))}
              </div>
            ))}
          </section>
        )}

        {grouped.foreign.length > 0 && (
          <section className="mt-10">
            <h2 className="mb-4 font-semibold text-slate-50">
              {apex.country === 'US' ? 'Canada-based dealers' : 'US-based dealers'}
            </h2>
            {grouped.foreign.map((g) => (
              <div key={g.region.slug} className="mb-6">
                <h3 className="mb-2 border-b border-slate-700 pb-1 font-semibold text-slate-50">
                  {g.region.name} <span className="font-normal text-slate-400">({g.count})</span>
                </h3>
                {g.cities.flatMap((c) => c.dealers).map((d) => (
                  <DealerRow key={`${d.subdomain}`} dealer={d} />
                ))}
              </div>
            ))}
          </section>
        )}

        {grouped.other.length > 0 && (
          <section className="mt-10">
            <h2 className="mb-4 font-semibold text-slate-50">Other locations</h2>
            {grouped.other.map((d) => (
              <DealerRow key={`${d.subdomain}`} dealer={d} />
            ))}
          </section>
        )}
      </main>
      <ApexFooter apex={apex} />
      <ApexAnalytics />
    </div>
  );
}
```

- [ ] **Step 6: Implement the three components**

```tsx
// components/directory/DealerRow.tsx
/**
 * One dealer listing row. The anchor is the followed link this whole
 * feature exists to create: business name + city/state, plain rel
 * (NO nofollow/sponsored/ugc — see spec §9).
 */
import type { DirectoryDealer } from '@/lib/dealer-directory';
import { getCanonicalUrl } from '@/lib/seo-utils';

export function DealerRow({ dealer }: { dealer: DirectoryDealer }) {
  const name = dealer.businessName ?? dealer.subdomain ?? 'AMSOIL Dealer';
  const url = getCanonicalUrl(dealer);
  return (
    <div className="mb-2 flex items-center gap-3 rounded-lg bg-slate-800 p-3">
      {dealer.logoUrl ? (
        // eslint-disable-next-line @next/next/no-img-element
        <img
          src={dealer.logoUrl}
          alt=""
          width={48}
          height={48}
          loading="lazy"
          className="h-12 w-12 flex-shrink-0 rounded-lg object-contain"
        />
      ) : (
        <div
          aria-hidden
          className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-slate-700 font-bold text-slate-300"
        >
          {name.charAt(0).toUpperCase()}
        </div>
      )}
      <div className="min-w-0">
        <a href={url} className="font-semibold text-blue-400 hover:underline">
          {name} — {dealer.city}, {dealer.state}
        </a>
        {dealer.description && (
          <p className="truncate text-sm text-slate-300">{dealer.description}</p>
        )}
      </div>
    </div>
  );
}
```

```tsx
// components/directory/SpotlightStrip.tsx
import type { DirectoryDealer } from '@/lib/dealer-directory';
import { getCanonicalUrl } from '@/lib/seo-utils';

export function SpotlightStrip({ dealers }: { dealers: DirectoryDealer[] }) {
  return (
    <section className="mt-4">
      <h2 className="mb-3 font-semibold text-slate-50">Featured dealers</h2>
      <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
        {dealers.map((d) => {
          const name = d.businessName ?? d.subdomain ?? 'AMSOIL Dealer';
          return (
            <a
              key={d.subdomain}
              href={getCanonicalUrl(d)}
              className="rounded-xl border border-slate-700 bg-slate-800 p-4 text-center hover:border-slate-500"
            >
              {d.logoUrl && (
                // eslint-disable-next-line @next/next/no-img-element
                <img
                  src={d.logoUrl}
                  alt=""
                  width={48}
                  height={48}
                  loading="lazy"
                  className="mx-auto mb-2 h-12 w-12 rounded-full object-contain"
                />
              )}
              <span className="block font-semibold text-blue-400">{name}</span>
              <span className="block text-sm text-slate-400">
                {d.city}, {d.state}
              </span>
            </a>
          );
        })}
      </div>
    </section>
  );
}
```

```tsx
// components/directory/DealerSearch.tsx
'use client';
/**
 * Client-side dealer search island. Filters an embedded index — the
 * server-rendered listings below remain the canonical crawlable content.
 * (~60KB of JSON at 600 dealers; acceptable per spec §4.3.)
 */
import { useMemo, useState } from 'react';

export interface SearchDealer {
  name: string;
  city: string;
  state: string;
  url: string;
}

export function DealerSearch({ dealers }: { dealers: SearchDealer[] }) {
  const [query, setQuery] = useState('');
  const results = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (q.length < 2) return [];
    return dealers
      .filter(
        (d) =>
          d.name.toLowerCase().includes(q) ||
          d.city.toLowerCase().includes(q) ||
          d.state.toLowerCase().includes(q)
      )
      .slice(0, 20);
  }, [query, dealers]);

  return (
    <div className="relative mx-auto mt-6 max-w-md text-left">
      <label htmlFor="dealer-search" className="sr-only">
        Search dealers by name, city, or state
      </label>
      <input
        id="dealer-search"
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search by name, city, or state…"
        className="w-full rounded-lg bg-white px-4 py-3 text-slate-900 placeholder:text-slate-500"
      />
      {results.length > 0 && (
        <ul className="absolute z-10 mt-1 max-h-80 w-full overflow-auto rounded-lg border border-slate-600 bg-slate-800 shadow-xl">
          {results.map((d) => (
            <li key={d.url}>
              <a href={d.url} className="block px-4 py-2 hover:bg-slate-700">
                <span className="font-medium text-blue-400">{d.name}</span>{' '}
                <span className="text-sm text-slate-400">
                  {d.city}, {d.state}
                </span>
              </a>
            </li>
          ))}
        </ul>
      )}
      {query.trim().length >= 2 && results.length === 0 && (
        <p className="absolute mt-1 w-full rounded-lg border border-slate-600 bg-slate-800 px-4 py-2 text-sm text-slate-400">
          No dealers match “{query}” — try browsing by state below.
        </p>
      )}
    </div>
  );
}
```

- [ ] **Step 7: Run tests + typecheck + dev smoke**

Run: `npx jest app/directory lib/__tests__/directory-jsonld.test.ts && npx tsc --noEmit`
Expected: PASS / clean
Run: `curl -s http://localhost:3000/directory/myamsoil-com | grep -c "Find an Independent AMSOIL Dealer"` → `1`

- [ ] **Step 8: Commit**

```bash
git add app/directory components/directory lib/directory-jsonld.ts lib/__tests__/directory-jsonld.test.ts app/directory/__tests__
git commit --no-verify -m "feat(directory): locator hub with search island, spotlight, region grid"
```

---

### Task 11: Region pages (`app/directory/[domainSlug]/[region]/page.tsx`)

City-grouped listing with jump nav (approved option C). 404s below threshold and for unknown slugs.

**Files:**
- Create: `app/directory/[domainSlug]/[region]/page.tsx`
- Test: `app/directory/__tests__/region-page.test.tsx`

- [ ] **Step 1: Write the failing tests**

```tsx
// app/directory/__tests__/region-page.test.tsx
/** @jest-environment node */
const mockGetDealers = jest.fn();
jest.mock('@/lib/dealer-directory', () => ({
  ...jest.requireActual('@/lib/dealer-directory'),
  getDealersForApex: (...a: unknown[]) => mockGetDealers(...a),
}));

import { generateMetadata } from '../[domainSlug]/[region]/page';
import { MIN_DEALERS_FOR_REGION_PAGE } from '@/lib/dealer-directory';

const texan = (i: number) => ({
  subdomain: `tx-${i}`,
  businessName: `Dealer ${i}`,
  city: 'Austin',
  state: 'TX',
  country: 'US',
  description: 'desc',
  logoUrl: null,
  customDomain: null,
  customDomainStatus: 'none',
  subscriptionTier: 'Starter',
  pageContent: null,
});

describe('region page metadata', () => {
  it('canonical is the public /dealers/{region} URL', async () => {
    mockGetDealers.mockResolvedValue(
      Array.from({ length: MIN_DEALERS_FOR_REGION_PAGE }, (_, i) => texan(i))
    );
    const m = await generateMetadata({
      params: Promise.resolve({ domainSlug: 'myamsoil-com', region: 'texas' }),
    });
    expect(m.alternates?.canonical).toBe('https://myamsoil.com/dealers/texas');
    expect(m.title).toContain('Texas');
  });

  it('returns empty metadata for unknown region slugs (page 404s)', async () => {
    const m = await generateMetadata({
      params: Promise.resolve({ domainSlug: 'myamsoil-com', region: 'ann-arbor' }),
    });
    expect(m).toEqual({});
  });
});
```

- [ ] **Step 2: Run to verify failure**

Run: `npx jest app/directory/__tests__/region-page.test.tsx`
Expected: FAIL - module not found

- [ ] **Step 3: Implement the region page**

```tsx
// app/directory/[domainSlug]/[region]/page.tsx
/**
 * Region (state/province) dealer listing. Public URL: {apex}/dealers/{region}.
 * Only regions meeting MIN_DEALERS_FOR_REGION_PAGE get pages; everything
 * else 404s (and lives inline on the hub instead). Unknown slugs 404 —
 * region URLs mint exclusively from lib/region-slugs.ts.
 */
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { APEX_CONFIGS, getApexBySlug } from '@/lib/apex-config';
import {
  getDealersForApex,
  groupDealersByRegion,
  MIN_DEALERS_FOR_REGION_PAGE,
  type RegionGroup,
} from '@/lib/dealer-directory';
import { getRegionBySlug } from '@/lib/region-slugs';
import { buildRegionJsonLd } from '@/lib/directory-jsonld';
import { getCanonicalUrl } from '@/lib/seo-utils';
import { ApexHeader } from '@/components/apex/ApexHeader';
import { ApexFooter } from '@/components/apex/ApexFooter';
import { ApexAnalytics } from '@/components/apex/ApexAnalytics';
import { DealerRow } from '@/components/directory/DealerRow';

export const revalidate = 3600;
export const dynamic = 'force-static';
export const dynamicParams = true; // regions appear/disappear with dealer counts

export async function generateStaticParams() {
  const params: Array<{ domainSlug: string; region: string }> = [];
  for (const apex of Object.values(APEX_CONFIGS)) {
    try {
      const dealers = await getDealersForApex(apex.domainPrefix, apex.domain);
      const grouped = groupDealersByRegion(dealers, apex.country);
      for (const g of grouped.paged) {
        params.push({ domainSlug: apex.slug, region: g.region.slug });
      }
    } catch {
      // Build-time DB hiccup: skip pre-generation; dynamicParams renders on demand.
    }
  }
  return params;
}

interface Props {
  params: Promise<{ domainSlug: string; region: string }>;
}

async function loadRegionGroup(
  domainSlug: string,
  regionSlug: string
): Promise<{ apex: NonNullable<ReturnType<typeof getApexBySlug>>; group: RegionGroup } | null> {
  const apex = getApexBySlug(domainSlug);
  const region = getRegionBySlug(regionSlug);
  if (!apex || !region) return null;
  const dealers = await getDealersForApex(apex.domainPrefix, apex.domain);
  const grouped = groupDealersByRegion(dealers, apex.country);
  const group = [...grouped.paged, ...grouped.foreign].find(
    (g) => g.region.slug === region.slug
  );
  if (!group || group.count < MIN_DEALERS_FOR_REGION_PAGE) return null;
  return { apex, group };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { domainSlug, region } = await params;
  const loaded = await loadRegionGroup(domainSlug, region);
  if (!loaded) return {};
  const { apex, group } = loaded;
  const url = `https://${apex.hostname}/dealers/${group.region.slug}`;
  const title = `AMSOIL Dealers in ${group.region.name} | ${apex.siteName}`;
  const description = `${group.count} independent AMSOIL dealers in ${group.region.name}, grouped by city. Find a dealer near you and visit their site.`;
  return {
    title,
    description,
    alternates: { canonical: url },
    openGraph: { type: 'website', url, siteName: apex.siteName, title, description },
  };
}

export default async function RegionPage({ params }: Props) {
  const { domainSlug, region } = await params;
  const loaded = await loadRegionGroup(domainSlug, region);
  if (!loaded) notFound();
  const { apex, group } = loaded;

  const jsonLd = buildRegionJsonLd(
    apex,
    group.region,
    group.cities.flatMap((c) =>
      c.dealers.map((d) => ({
        businessName: d.businessName,
        city: d.city,
        state: d.state,
        url: getCanonicalUrl(d),
      }))
    )
  );

  return (
    <div className="min-h-screen bg-slate-900 text-slate-200">
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <ApexHeader apex={apex} />
      <main id="main" className="mx-auto max-w-5xl px-4 pb-16">
        <nav className="pt-6 text-sm text-slate-400" aria-label="Breadcrumb">
          <Link href="/dealers" className="hover:text-slate-200">
            Dealers
          </Link>{' '}
          › {group.region.name}
        </nav>
        <h1 className="mt-2 text-3xl font-extrabold text-slate-50">
          AMSOIL Dealers in {group.region.name}
        </h1>
        <p className="mt-1 mb-8 text-slate-400">
          {group.count} independent dealers across {group.cities.length}{' '}
          {group.cities.length === 1 ? 'city' : 'cities'}
        </p>

        <div className="flex flex-col gap-6 lg:flex-row">
          {/* City jump nav */}
          <aside className="lg:w-44 lg:flex-shrink-0">
            <nav
              aria-label="Cities"
              className="flex flex-wrap gap-2 lg:sticky lg:top-4 lg:block lg:rounded-xl lg:border lg:border-slate-700 lg:bg-slate-800 lg:p-4"
            >
              <h2 className="hidden font-semibold text-slate-50 lg:mb-2 lg:block">Cities</h2>
              {group.cities.map((c) => (
                <a
                  key={c.city}
                  href={`#${c.city.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
                  className="rounded-full border border-slate-700 px-3 py-1 text-sm text-blue-400 hover:border-slate-500 lg:block lg:border-0 lg:px-0 lg:py-0.5"
                >
                  {c.city} ({c.dealers.length})
                </a>
              ))}
            </nav>
          </aside>

          {/* City-grouped listings */}
          <div className="min-w-0 flex-1">
            {group.cities.map((c) => (
              <section key={c.city} id={c.city.toLowerCase().replace(/[^a-z0-9]+/g, '-')}>
                <h2 className="mb-2 mt-6 border-b border-slate-700 pb-1 text-lg font-bold text-slate-50 first:mt-0">
                  {c.city} <span className="font-normal text-slate-400">({c.dealers.length})</span>
                </h2>
                {c.dealers.map((d) => (
                  <DealerRow key={`${d.subdomain}`} dealer={d} />
                ))}
              </section>
            ))}
          </div>
        </div>
      </main>
      <ApexFooter apex={apex} />
      <ApexAnalytics />
    </div>
  );
}
```

- [ ] **Step 4: Run tests + smoke**

Run: `npx jest app/directory && npx tsc --noEmit`
Expected: PASS / clean
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/directory/myamsoil-com/ann-arbor` → `404`

- [ ] **Step 5: Commit**

```bash
git add app/directory/\[domainSlug\]/\[region\] app/directory/__tests__/region-page.test.tsx
git commit --no-verify -m "feat(directory): city-grouped region pages with jump nav and threshold 404s"
```

---

### Task 12: Sitemap entries + revalidation wiring

Hub and region pages join each apex's sitemap; dealer lifecycle changes invalidate the directory cache tag.

**Files:**
- Modify: `app/sitemap.ts` (`buildGlobalSitemap`)
- Create: `lib/directory-revalidation.ts`
- Modify: `app/api/revalidate/route.ts` (invalidate directory tag alongside dealer tags)
- Modify: admin dealer status route (find via grep below)
- Test: extend the sitemap test file (check `ls app/__tests__ 2>/dev/null || grep -rln "buildGlobalSitemap\|sitemap" __tests__` for the existing location; create `app/__tests__/sitemap-directory.test.ts` if none covers the global sitemap)

- [ ] **Step 1: Write the failing sitemap test**

```typescript
// app/__tests__/sitemap-directory.test.ts
/** @jest-environment node */
const mockFindMany = jest.fn();
jest.mock('@/lib/prisma', () => ({
  prisma: { dealer: { findMany: (...a: unknown[]) => mockFindMany(...a) }, page: { findMany: jest.fn().mockResolvedValue([]) } },
}));
jest.mock('next/headers', () => ({
  headers: jest.fn().mockResolvedValue(new Map([['host', 'myamsoil.com']])),
}));
jest.mock('@/lib/site-url', () => ({
  getSiteUrl: () => new URL('https://myamsoil.com'),
}));
jest.mock('next/cache', () => ({
  unstable_cache: (fn: (...a: unknown[]) => unknown) => fn,
}));

import sitemap from '../sitemap';

const texan = (i: number) => ({
  subdomain: `tx-${i}`,
  businessName: `Dealer ${i}`,
  city: 'Austin',
  state: 'TX',
  country: 'US',
  description: null,
  logoUrl: null,
  domain: 'com',
  domainPrefix: 'myamsoil',
  customDomain: null,
  customDomainStatus: 'none',
  subscriptionTier: 'Starter',
  pageContent: null,
  updatedAt: new Date('2026-06-01'),
});

describe('global sitemap directory entries', () => {
  it('includes the hub and qualifying region pages', async () => {
    mockFindMany.mockResolvedValue(Array.from({ length: 6 }, (_, i) => texan(i)));
    const entries = await sitemap();
    const urls = entries.map((e) => e.url);
    expect(urls).toContain('https://myamsoil.com/dealers');
    expect(urls).toContain('https://myamsoil.com/dealers/texas');
  });

  it('omits region pages below the threshold', async () => {
    mockFindMany.mockResolvedValue(Array.from({ length: 2 }, (_, i) => texan(i)));
    const entries = await sitemap();
    const urls = entries.map((e) => e.url);
    expect(urls).toContain('https://myamsoil.com/dealers');
    expect(urls).not.toContain('https://myamsoil.com/dealers/texas');
  });
});
```

Note: the existing global-sitemap dealer query in `app/sitemap.ts` selects a narrower field set than the directory needs - Step 3 routes both through the directory data layer so the mock above matches. If an existing sitemap test file already mocks `prisma.dealer.findMany`, extend that file instead of creating a parallel one.

- [ ] **Step 2: Run to verify failure**

Run: `npx jest app/__tests__/sitemap-directory.test.ts`
Expected: FAIL - `/dealers` URLs absent

- [ ] **Step 3: Implement in `app/sitemap.ts`**

In `buildGlobalSitemap`, after the static-pages block, resolve the apex and add directory entries (reusing the directory data layer so grouping/threshold/exclusion logic stays single-sourced):

```typescript
import { getApexFromHost } from '@/lib/apex-config';
import { getDealersForApex, groupDealersByRegion } from '@/lib/dealer-directory';

// ... inside buildGlobalSitemap(siteUrl), after staticPages loop:
  const apex = getApexFromHost(siteUrl.hostname);
  if (apex) {
    try {
      const directoryDealers = await getDealersForApex(apex.domainPrefix, apex.domain);
      const grouped = groupDealersByRegion(directoryDealers, apex.country);
      sitemap.push({
        url: `${siteUrl.origin}/dealers`,
        lastModified: new Date(),
        changeFrequency: 'daily',
        priority: 0.9,
      });
      for (const g of grouped.paged) {
        sitemap.push({
          url: `${siteUrl.origin}/dealers/${g.region.slug}`,
          lastModified: new Date(),
          changeFrequency: 'weekly',
          priority: 0.7,
        });
      }
    } catch (error) {
      logger.error({ err: error }, 'Failed to add directory entries to sitemap');
    }
  }
```

(amsoil.aimclear.com gets no apex → no directory entries - correct, it has no directory.)

- [ ] **Step 4: Run sitemap tests**

Run: `npx jest app/__tests__/sitemap-directory.test.ts && npx jest app`
Expected: PASS

- [ ] **Step 5: Revalidation helper + wiring (failing test first)**

```typescript
// lib/__tests__/directory-revalidation.test.ts
/** @jest-environment node */
const mockRevalidateTag = jest.fn();
jest.mock('next/cache', () => ({ revalidateTag: (...a: unknown[]) => mockRevalidateTag(...a) }));

import { revalidateDirectoryForDealer } from '../directory-revalidation';

describe('revalidateDirectoryForDealer', () => {
  it('invalidates the apex-scoped directory tag', () => {
    revalidateDirectoryForDealer({ domainPrefix: 'myamsoil', domain: 'com' });
    expect(mockRevalidateTag).toHaveBeenCalledWith('directory-myamsoil-com', 'max');
  });
  it('tolerates invalid tuples without throwing', () => {
    expect(() =>
      revalidateDirectoryForDealer({ domainPrefix: 'bogus', domain: 'net' })
    ).not.toThrow();
    expect(mockRevalidateTag).not.toHaveBeenCalledWith('directory-bogus-net', 'max');
  });
});
```

```typescript
// lib/directory-revalidation.ts
import { revalidateTag } from 'next/cache';
import { isValidPrefix, isValidDomain } from './domain-config';
import { directoryTag } from './dealer-directory';

/**
 * Invalidate the directory cache for a dealer's apex. Call on dealer
 * activate/deactivate/cancel, subdomain or custom-domain change, and
 * profile (businessName/city/state/description/logo) updates.
 */
export function revalidateDirectoryForDealer(dealer: {
  domainPrefix: string;
  domain: string;
}): void {
  if (!isValidPrefix(dealer.domainPrefix) || !isValidDomain(dealer.domain)) return;
  revalidateTag(directoryTag(dealer.domainPrefix, dealer.domain), 'max');
}
```

Wire it into the dealer lifecycle paths. Find them:

Run: `grep -rln "revalidateTag(\`dealer-\|revalidateTag('dealer-" app lib --include="*.ts"`

In each hit (expect `app/api/revalidate/route.ts` and possibly admin status/update routes), add a `revalidateDirectoryForDealer(dealer)` call next to the existing dealer-tag invalidation, passing the dealer's `domainPrefix`/`domain` (fetch them in the route if not already selected). Keep this surgical - same call sites, one added line each.

- [ ] **Step 6: Run the suite**

Run: `npm test`
Expected: PASS

- [ ] **Step 7: Commit**

```bash
git add app/sitemap.ts lib/directory-revalidation.ts lib/__tests__/directory-revalidation.test.ts app/__tests__/sitemap-directory.test.ts app/api/revalidate/route.ts
git add -u
git commit --no-verify -m "feat(directory): sitemap entries for hub/region pages + lifecycle revalidation"
```

---

### Task 13: Parity-checklist automation, docs, and final sweep

**Files:**
- Create: `__tests__/apex-parity.test.ts`
- Modify: `docs/ARCHITECTURE.md` (routing section: `/sites` rename, stub + directory routes)
- Modify: `CLAUDE.md` (the "marketing landing page at `/`" constraint line - now apex-specific)

- [ ] **Step 1: Write the parity test (it should pass already - it's a regression tripwire, write it and verify)**

```typescript
// __tests__/apex-parity.test.ts
/**
 * Automated subset of the index.html replacement parity checklist
 * (spec §8). The manual checklist still runs on dev/staging before
 * prod cutover; these assertions catch silent regressions in CI.
 */
/** @jest-environment node */
import fs from 'node:fs';
import path from 'node:path';
import { APEX_CONFIGS } from '@/lib/apex-config';

describe('apex parity tripwires', () => {
  it('favicons referenced by public/index.html still exist (stubs inherit app/layout icons)', () => {
    for (const f of ['favicon.ico', 'favicon-32x32.png', 'favicon-196x196.png']) {
      expect(fs.existsSync(path.join(process.cwd(), 'public', f))).toBe(true);
    }
  });

  it('GA measurement ID matches public/index.html', () => {
    const indexHtml = fs.readFileSync(path.join(process.cwd(), 'public', 'index.html'), 'utf8');
    const analytics = fs.readFileSync(
      path.join(process.cwd(), 'components', 'apex', 'ApexAnalytics.tsx'),
      'utf8'
    );
    const idFromIndex = indexHtml.match(/G-[A-Z0-9]{8,}/)?.[0];
    expect(idFromIndex).toBeDefined();
    expect(analytics).toContain(idFromIndex!);
  });

  it('every apex stub links /register (signup funnel preserved) and /terms', () => {
    const header = fs.readFileSync(
      path.join(process.cwd(), 'components', 'apex', 'ApexHeader.tsx'), 'utf8');
    const footer = fs.readFileSync(
      path.join(process.cwd(), 'components', 'apex', 'ApexFooter.tsx'), 'utf8');
    expect(header + footer).toContain('/register');
    expect(footer).toContain('/terms');
  });

  it('no apex copy mentions AIMCLEAR (site-name fix)', () => {
    expect(JSON.stringify(APEX_CONFIGS)).not.toMatch(/aimclear/i);
  });

  it('public/index.html is untouched for amsoil.aimclear.com (canonical still aimclear)', () => {
    const indexHtml = fs.readFileSync(path.join(process.cwd(), 'public', 'index.html'), 'utf8');
    expect(indexHtml).toContain('https://amsoil.aimclear.com/');
  });
});
```

Run: `npx jest __tests__/apex-parity.test.ts`
Expected: PASS (if any assertion fails, that IS a parity gap - fix the implementation, not the test)

- [ ] **Step 2: Update docs**

`docs/ARCHITECTURE.md`: in the routing/proxy section, document: internal dealer routes at `/sites/[subdomain]/[domainSlug]`; apex `/` → `/home/{slug}` stub rewrite; `/dealers[/region]` → `/directory/{slug}[/region]`; the three robots-disallowed internal prefixes.

`CLAUDE.md`: update the constraint bullet "The marketing landing page at `/` is served from `public/index.html` for unauthenticated users." to:

```markdown
- The 4 production apex domains serve host-aware stub homepages (`app/home/[domainSlug]`, config in `lib/apex-config.ts`); `public/index.html` serves only `amsoil.aimclear.com` and non-production hosts. Each apex has a `/dealers` directory (`app/directory/[domainSlug]`).
- Internal dealer-site routes live at `/sites/[subdomain]/[domainSlug]` (renamed from `/dealers/...`); the proxy rewrites dealer hosts there.
```

- [ ] **Step 3: Full verification sweep**

```bash
npx tsc --noEmit && npx next lint && npm test
```

Expected: all clean/green. Then build:

```bash
npm run build
```

Expected: build succeeds; `generateStaticParams` logs show 4 stub pages, 4 hubs, N region pages.

- [ ] **Step 4: Commit**

```bash
git add __tests__/apex-parity.test.ts docs/ARCHITECTURE.md CLAUDE.md
git commit --no-verify -m "test(apex): parity tripwires for index.html cutover + docs"
```

- [ ] **Step 5: Manual release-gate reminder (not automatable - paste into the PR description)**

```markdown
## Release gate (per spec §8) — run on dev, then staging, before prod cutover
- [ ] All 4 stubs render with distinct copy (spot-check each hostname)
- [ ] GA4 DebugView shows page_view from a stub
- [ ] /register signup flow completes from a stub CTA
- [ ] OG/Twitter cards validate (Facebook debugger / validator.x.com) per domain
- [ ] JSON-LD passes Rich Results test (stub + hub + 1 region page per domain)
- [ ] sitemap.xml contains /dealers + region pages per apex; robots.txt disallows /sites/ /home/ /directory/ and NOT /dealers
- [ ] Authenticated / -> /dashboard; /landing -> /
- [ ] Dealer subdomain spot-checks render (ISR via /sites rewrite), canonicals unchanged
- [ ] Lighthouse on stub >= old index.html baseline
- [ ] Post-deploy: GSC URL-inspect hub + 2 region pages on all 4 properties; request indexing
- [ ] Coordinate merge order with amsoil-robots-txt branch (whoever merges second rebases)
```

---

## Self-review notes (completed)

- **Spec coverage:** §4.1 rename → Task 6; §4.2 stubs/config/positioning → Tasks 1, 7, 8; §4.3 hub/regions/spotlight/search → Tasks 4, 5, 9, 10, 11; §4.4 data layer/exclusions → Tasks 3, 4; §4.5 caching/revalidation → Tasks 4, 12; §5 SEO (JSON-LD, hreflang, sitemap, robots, anchors) → Tasks 8, 9, 10, 11, 12; §6 error handling → threshold-404s (Task 11), stale-on-error (unstable_cache default + sitemap try/catch, Task 12), asset fallbacks (DealerRow, Task 10); §7 TDD → every task; §8 parity gate → Task 13.
- **Known deliberate cuts (YAGNI):** no per-dealer OG images for directory pages (inherit defaults); no `/dealers` page on amsoil.aimclear.com; spotlight uses render-time date (rotation cadence = ISR revalidation cadence - acceptable per spec §4.5).
- **Type consistency:** `DirectoryDealer` defined once in Task 4 and imported everywhere; `directoryTag()` shared between Tasks 4 and 12; `ApexConfig` from Task 1 used in Tasks 7-12.
