# Dealer Scripts Next.js App Router Fix

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Replace broken `useEffect` DOM injection with `next/script` using `afterInteractive` strategy for dealer analytics/GTM scripts.

**Architecture:** Parse HTML script strings from dealer database into individual script objects, then render each via Next.js `<Script>` component with `afterInteractive` strategy. This is the official recommended approach for third-party scripts in Next.js App Router, avoiding RSC serialization issues while maintaining proper script lifecycle management.

**Tech Stack:** Next.js 16, React 19, next/script component, TypeScript

---

## Background

The current implementation uses `useEffect` to inject scripts via DOM manipulation. This approach:
- Executes scripts too late (after hydration)
- Has no cleanup (memory leak risk)
- Doesn't integrate with Next.js script optimization
- Makes `<noscript>` tags pointless (they run client-side where JS is always enabled)

The correct approach uses `next/script` with `strategy="afterInteractive"` which:
- Integrates with Next.js script loading optimization
- Provides proper lifecycle callbacks (onLoad, onError)
- Works correctly with React Server Components
- Is explicitly recommended by Next.js documentation for analytics/GTM

---

## Task 1: Write Tests for Script Parser Utility

**Files:**
- Create: `lib/__tests__/parse-dealer-scripts.test.ts`

**Step 1: Write the failing tests**

```typescript
// lib/__tests__/parse-dealer-scripts.test.ts
import { parseDealerScripts, ParsedScript } from '../parse-dealer-scripts';

describe('parseDealerScripts', () => {
  describe('basic parsing', () => {
    it('returns empty array for null input', () => {
      expect(parseDealerScripts(null)).toEqual([]);
    });

    it('returns empty array for undefined input', () => {
      expect(parseDealerScripts(undefined)).toEqual([]);
    });

    it('returns empty array for empty string', () => {
      expect(parseDealerScripts('')).toEqual([]);
    });

    it('returns empty array for whitespace-only string', () => {
      expect(parseDealerScripts('   \n\t  ')).toEqual([]);
    });
  });

  describe('external scripts', () => {
    it('parses external script with src attribute', () => {
      const html = '<script src="https://example.com/script.js"></script>';
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
      expect(result[0]).toMatchObject({
        src: 'https://example.com/script.js',
        content: undefined,
      });
    });

    it('parses script with async attribute', () => {
      const html = '<script async src="https://example.com/script.js"></script>';
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
      expect(result[0].async).toBe(true);
    });

    it('parses script with defer attribute', () => {
      const html = '<script defer src="https://example.com/script.js"></script>';
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
      expect(result[0].defer).toBe(true);
    });

    it('parses script with data attributes', () => {
      const html = '<script src="https://example.com/script.js" data-api-key="abc123" data-domain="test.com"></script>';
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
      expect(result[0].dataAttributes).toEqual({
        'data-api-key': 'abc123',
        'data-domain': 'test.com',
      });
    });
  });

  describe('inline scripts', () => {
    it('parses inline script content', () => {
      const html = '<script>console.log("hello");</script>';
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
      expect(result[0]).toMatchObject({
        src: undefined,
        content: 'console.log("hello");',
      });
    });

    it('parses GTM dataLayer initialization', () => {
      const html = `<script>
        window.dataLayer = window.dataLayer || [];
        window.dataLayer.push({'gtm.start': new Date().getTime()});
      </script>`;
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
      expect(result[0].content).toContain('window.dataLayer');
    });
  });

  describe('multiple scripts', () => {
    it('parses multiple scripts in order', () => {
      const html = `
        <script src="https://first.com/a.js"></script>
        <script>var x = 1;</script>
        <script src="https://second.com/b.js" async></script>
      `;
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(3);
      expect(result[0].src).toBe('https://first.com/a.js');
      expect(result[1].content).toBe('var x = 1;');
      expect(result[2].src).toBe('https://second.com/b.js');
      expect(result[2].async).toBe(true);
    });
  });

  describe('GTM pattern', () => {
    it('parses standard GTM head snippet', () => {
      const html = `<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXX');</script>
<!-- End Google Tag Manager -->`;
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
      expect(result[0].content).toContain('gtm.start');
      expect(result[0].content).toContain('GTM-XXXX');
    });
  });

  describe('unique IDs', () => {
    it('generates unique IDs for each script', () => {
      const html = `
        <script src="https://a.com/1.js"></script>
        <script src="https://b.com/2.js"></script>
      `;
      const result = parseDealerScripts(html);

      expect(result[0].id).not.toBe(result[1].id);
      expect(result[0].id).toMatch(/^dealer-script-/);
    });
  });

  describe('ignores non-script elements', () => {
    it('ignores noscript elements', () => {
      const html = `
        <script src="https://example.com/script.js"></script>
        <noscript><iframe src="https://example.com/fallback"></iframe></noscript>
      `;
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
      expect(result[0].src).toBe('https://example.com/script.js');
    });

    it('ignores HTML comments', () => {
      const html = `
        <!-- This is a comment -->
        <script src="https://example.com/script.js"></script>
      `;
      const result = parseDealerScripts(html);

      expect(result).toHaveLength(1);
    });
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npm test -- --testPathPatterns="parse-dealer-scripts" --passWithNoTests`

Expected: FAIL with "Cannot find module '../parse-dealer-scripts'"

**Step 3: Commit test file**

```bash
git add lib/__tests__/parse-dealer-scripts.test.ts
git commit -m "test: add tests for dealer script parser utility"
```

---

## Task 2: Implement Script Parser Utility

**Files:**
- Create: `lib/parse-dealer-scripts.ts`

**Step 1: Create the parser implementation**

```typescript
// lib/parse-dealer-scripts.ts

/**
 * Parsed script object ready for next/script component
 */
export interface ParsedScript {
  /** Unique ID for React key and next/script id prop */
  id: string;
  /** External script URL (mutually exclusive with content) */
  src?: string;
  /** Inline script content (mutually exclusive with src) */
  content?: string;
  /** Script should load asynchronously */
  async?: boolean;
  /** Script should defer execution */
  defer?: boolean;
  /** Data attributes to pass through */
  dataAttributes?: Record<string, string>;
}

let scriptCounter = 0;

/**
 * Parse HTML string containing script tags into structured objects
 * for rendering with next/script component.
 *
 * Extracts:
 * - External scripts (src attribute)
 * - Inline scripts (text content)
 * - Script attributes (async, defer, data-*)
 *
 * Ignores:
 * - <noscript> elements (handled separately or not at all in client-side context)
 * - HTML comments
 * - Other HTML elements
 *
 * @param html - Raw HTML string containing script tags
 * @returns Array of parsed script objects
 */
export function parseDealerScripts(html: string | null | undefined): ParsedScript[] {
  if (!html || !html.trim()) {
    return [];
  }

  // Use DOMParser if available (browser), otherwise return empty
  // Server-side parsing would need a different approach, but this runs client-side
  if (typeof DOMParser === 'undefined') {
    return [];
  }

  const scripts: ParsedScript[] = [];
  const parser = new DOMParser();

  // Wrap in div to handle multiple root elements
  const doc = parser.parseFromString(`<div>${html}</div>`, 'text/html');
  const scriptElements = doc.querySelectorAll('script');

  scriptElements.forEach((el) => {
    const script: ParsedScript = {
      id: `dealer-script-${++scriptCounter}-${Date.now()}`,
    };

    // External script
    const src = el.getAttribute('src');
    if (src) {
      script.src = src;
    }

    // Inline script content
    const content = el.textContent?.trim();
    if (content && !src) {
      script.content = content;
    }

    // Boolean attributes
    if (el.hasAttribute('async')) {
      script.async = true;
    }
    if (el.hasAttribute('defer')) {
      script.defer = true;
    }

    // Data attributes
    const dataAttributes: Record<string, string> = {};
    Array.from(el.attributes).forEach((attr) => {
      if (attr.name.startsWith('data-')) {
        dataAttributes[attr.name] = attr.value;
      }
    });
    if (Object.keys(dataAttributes).length > 0) {
      script.dataAttributes = dataAttributes;
    }

    // Only add if we have something to render
    if (script.src || script.content) {
      scripts.push(script);
    }
  });

  return scripts;
}
```

**Step 2: Run tests to verify they pass**

Run: `npm test -- --testPathPatterns="parse-dealer-scripts"`

Expected: All tests PASS

**Step 3: Commit implementation**

```bash
git add lib/parse-dealer-scripts.ts
git commit -m "feat: add dealer script parser utility for next/script"
```

---

## Task 3: Write Tests for DealerScripts Component

**Files:**
- Create: `components/__tests__/DealerScripts.test.tsx`

**Step 1: Write the failing tests**

```typescript
// components/__tests__/DealerScripts.test.tsx
import { render } from '@testing-library/react';
import { DealerScripts } from '../DealerScripts';

// Mock next/script since it has special behavior in tests
jest.mock('next/script', () => {
  return function MockScript({
    id,
    src,
    strategy,
    onLoad,
    onError,
    dangerouslySetInnerHTML,
    ...props
  }: {
    id: string;
    src?: string;
    strategy?: string;
    onLoad?: () => void;
    onError?: () => void;
    dangerouslySetInnerHTML?: { __html: string };
    [key: string]: unknown;
  }) {
    return (
      <script
        data-testid={`mock-script-${id}`}
        data-src={src}
        data-strategy={strategy}
        data-has-inline={dangerouslySetInnerHTML ? 'true' : 'false'}
        {...props}
      />
    );
  };
});

describe('DealerScripts', () => {
  it('renders nothing when no scripts provided', () => {
    const { container } = render(<DealerScripts />);
    expect(container.querySelectorAll('script')).toHaveLength(0);
  });

  it('renders nothing for null scripts', () => {
    const { container } = render(
      <DealerScripts headScripts={null} bodyScripts={null} />
    );
    expect(container.querySelectorAll('script')).toHaveLength(0);
  });

  it('renders external script with afterInteractive strategy', () => {
    const { container } = render(
      <DealerScripts
        headScripts='<script src="https://example.com/script.js"></script>'
      />
    );

    const script = container.querySelector('script');
    expect(script).toBeTruthy();
    expect(script?.getAttribute('data-src')).toBe('https://example.com/script.js');
    expect(script?.getAttribute('data-strategy')).toBe('afterInteractive');
  });

  it('renders inline script with dangerouslySetInnerHTML', () => {
    const { container } = render(
      <DealerScripts
        headScripts='<script>console.log("test");</script>'
      />
    );

    const script = container.querySelector('script');
    expect(script).toBeTruthy();
    expect(script?.getAttribute('data-has-inline')).toBe('true');
  });

  it('renders multiple scripts from both head and body', () => {
    const { container } = render(
      <DealerScripts
        headScripts='<script src="https://head.com/a.js"></script>'
        bodyScripts='<script src="https://body.com/b.js"></script>'
      />
    );

    const scripts = container.querySelectorAll('script');
    expect(scripts).toHaveLength(2);
  });

  it('passes data attributes through to script', () => {
    const { container } = render(
      <DealerScripts
        headScripts='<script src="https://example.com/script.js" data-api-key="abc123"></script>'
      />
    );

    const script = container.querySelector('script');
    expect(script?.getAttribute('data-api-key')).toBe('abc123');
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npm test -- --testPathPatterns="DealerScripts.test"`

Expected: FAIL (current implementation doesn't use next/script)

**Step 3: Commit test file**

```bash
git add components/__tests__/DealerScripts.test.tsx
git commit -m "test: add tests for DealerScripts component with next/script"
```

---

## Task 4: Rewrite DealerScripts Component

**Files:**
- Modify: `components/DealerScripts.tsx`

**Step 1: Rewrite using next/script**

```typescript
// components/DealerScripts.tsx
'use client';

import Script from 'next/script';
import { useMemo } from 'react';
import { parseDealerScripts } from '@/lib/parse-dealer-scripts';

interface DealerScriptsProps {
  headScripts?: string | null;
  bodyScripts?: string | null;
}

/**
 * Client component that renders dealer scripts using next/script.
 *
 * Uses `afterInteractive` strategy which loads scripts after Next.js
 * hydration completes. This is the recommended approach for analytics
 * and tracking scripts (GTM, GA4, Facebook Pixel, etc.).
 *
 * Scripts are parsed from HTML strings stored in the dealer database
 * and rendered as individual next/script components for proper
 * lifecycle management and optimization.
 *
 * Note: <noscript> fallbacks are NOT supported because this component
 * runs client-side where JavaScript is always enabled. For no-JS support,
 * scripts would need to be rendered server-side in the layout.
 */
export function DealerScripts({ headScripts, bodyScripts }: DealerScriptsProps) {
  // Parse all scripts from HTML strings
  const allScripts = useMemo(() => {
    const head = parseDealerScripts(headScripts);
    const body = parseDealerScripts(bodyScripts);
    return [...head, ...body];
  }, [headScripts, bodyScripts]);

  // Don't render anything if no scripts
  if (allScripts.length === 0) {
    return null;
  }

  return (
    <>
      {allScripts.map((script) => (
        <Script
          key={script.id}
          id={script.id}
          strategy="afterInteractive"
          src={script.src}
          async={script.async}
          defer={script.defer}
          {...(script.content && !script.src
            ? { dangerouslySetInnerHTML: { __html: script.content } }
            : {})}
          {...(script.dataAttributes || {})}
          onError={(e) => {
            console.error(`[DealerScripts] Failed to load script:`, script.src || script.id, e);
          }}
        />
      ))}
    </>
  );
}
```

**Step 2: Run tests to verify they pass**

Run: `npm test -- --testPathPatterns="DealerScripts.test|parse-dealer-scripts"`

Expected: All tests PASS

**Step 3: Commit implementation**

```bash
git add components/DealerScripts.tsx
git commit -m "feat: rewrite DealerScripts to use next/script afterInteractive"
```

---

## Task 5: Run Full Test Suite and Build

**Files:**
- None (validation only)

**Step 1: Run all tests**

Run: `npm test`

Expected: All tests PASS (2387+ tests)

**Step 2: Run TypeScript check**

Run: `npx tsc --noEmit`

Expected: No errors

**Step 3: Run production build**

Run: `npm run build`

Expected: Build succeeds

**Step 4: Commit if any fixes were needed**

If any fixes were required, commit them:
```bash
git add -A
git commit -m "fix: address test/build issues for DealerScripts"
```

---

## Task 6: Manual Browser Verification

**Files:**
- None (manual testing)

**Step 1: Start dev server**

Run: `npm run dev`

**Step 2: Add test scripts to a dealer**

Using the Basic Editor, add these test scripts:

**Head Scripts:**
```html
<script>
  console.log('[DealerScripts] Head script executed at:', new Date().toISOString());
  window.__DEALER_HEAD_LOADED__ = true;
</script>
```

**Body Scripts:**
```html
<script>
  console.log('[DealerScripts] Body script executed at:', new Date().toISOString());
  window.__DEALER_BODY_LOADED__ = true;
</script>
```

**Step 3: Visit dealer page and verify**

1. Open browser DevTools Console
2. Visit the dealer page
3. Verify you see the console.log messages
4. Verify `window.__DEALER_HEAD_LOADED__` and `window.__DEALER_BODY_LOADED__` are `true`

Expected: Both scripts execute after page hydration

**Step 4: Test with real GTM snippet (optional)**

If a GTM container ID is available, test with real GTM:
1. Add GTM head snippet to Head Scripts field
2. Verify GTM container loads (check Network tab for gtm.js request)
3. Verify dataLayer events fire

---

## Task 7: Create PR

**Step 1: Review all changes**

Run: `git diff origin/dev...HEAD --stat`

**Step 2: Push branch**

Run: `git push -u origin feature/split-head-body-scripts`

**Step 3: Create PR**

```bash
gh pr create --base dev --title "fix(scripts): Use next/script afterInteractive for dealer analytics" --body "$(cat <<'EOF'
## Summary
- Replaces broken `useEffect` DOM injection with `next/script` + `afterInteractive`
- Adds script parser utility to extract scripts from HTML strings
- Proper error handling and lifecycle management via Next.js

## Why
The previous implementation had issues:
- `useEffect` executes too late (after hydration)
- No cleanup function (memory leak risk)
- `<noscript>` handling was pointless client-side
- Not integrated with Next.js optimization

## Test plan
- [ ] Unit tests for parser utility
- [ ] Unit tests for component
- [ ] Full test suite passes
- [ ] Manual browser verification
- [ ] GTM test with real container (if available)
EOF
)"
```

---

## Summary

| Task | Description | Est. Time |
|------|-------------|-----------|
| 1 | Write parser tests | 3 min |
| 2 | Implement parser | 3 min |
| 3 | Write component tests | 3 min |
| 4 | Rewrite component | 3 min |
| 5 | Full test suite + build | 5 min |
| 6 | Manual browser testing | 5 min |
| 7 | Create PR | 2 min |

**Total: ~25 minutes**
