# Jeep SA Dealer Finder — 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:** Build a single self-contained `index.html` Jeep-branded dealer finder widget (iframe body only) for jeep.com/za, with search, province filter, geolocation sort, and a Leaflet/OpenStreetMap map — all dealer data pre-embedded from the CSV.

**Architecture:** One HTML file with all CSS, JS, and dealer data inline. A one-time `build-data.js` Node.js script parses `Jeep_south_Africa_Dealer.csv` and writes `dealers-data.js` (the `const DEALERS` array) to paste into `index.html`. No build tool, no server, no dependencies beyond Leaflet CDN.

**Tech Stack:** HTML5, CSS3, Vanilla JS, Leaflet 1.9.4, OpenStreetMap tiles, Node.js v22 (build script only)

---

## CSV Column Map (verified)

The CSV has a multi-line header (one record spanning 3 text lines due to quoted newlines). Data starts at record index 1. Column indices (0-based):

| Index | Field |
|---|---|
| 4 | Dealer Name |
| 8 | Address |
| 9 | City/Prefecture |
| 10 | State/Region (province) |
| 14 | General Phone |
| 15–21 | Mon – Sun hours |
| 28 | Latitude |
| 29 | Longitude |
| 40 | DealerShip Email |
| 48 | Whatsapp Number |

---

## Task 1: Initialise git and parse CSV → DEALERS array

**Files:**
- Create: `build-data.js`
- Generated: `dealers-data.js`

- [ ] **Step 1: Initialise git repo**

```bash
cd /Volumes/Exutate/CLIENTS/STELLANTIS/2026/Jeep-dealer-locator
git init
echo ".superpowers/" >> .gitignore
echo "dealers-data.js" >> .gitignore
git add .gitignore
git commit -m "chore: init repo"
```

- [ ] **Step 2: Write build-data.js**

```javascript
// build-data.js — run once: node build-data.js
'use strict';
const fs = require('fs');

// Full CSV parser — handles quoted fields containing commas and newlines
function parseCSV(text) {
  const records = [];
  let field = '', inQuotes = false, record = [];
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (ch === '"') {
      if (inQuotes && text[i + 1] === '"') { field += '"'; i++; }
      else inQuotes = !inQuotes;
    } else if (ch === ',' && !inQuotes) {
      record.push(field.trim()); field = '';
    } else if ((ch === '\n' || ch === '\r') && !inQuotes) {
      if (ch === '\r' && text[i + 1] === '\n') i++;
      record.push(field.trim()); field = '';
      if (record.some(f => f)) records.push(record);
      record = [];
    } else {
      field += ch;
    }
  }
  if (record.length) {
    record.push(field.trim());
    if (record.some(f => f)) records.push(record);
  }
  return records;
}

const content = fs.readFileSync('Jeep_south_Africa_Dealer.csv', 'utf8');
const records = parseCSV(content);
// records[0] = header (multi-line quoted field makes it one record); data from records[1]

const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];

const dealers = records.slice(1)
  .filter(r => r[4] && r[28] && r[29])
  .map(r => ({
    name:     r[4],
    address:  r[8],
    city:     r[9],
    province: r[10],
    phone:    r[14],
    email:    r[40],
    whatsapp: r[48],
    lat:      parseFloat(r[28]),
    lng:      parseFloat(r[29]),
    hours:    Object.fromEntries(DAY_KEYS.map((d, i) => [d, r[15 + i] || '']))
  }))
  .filter(d => !isNaN(d.lat) && !isNaN(d.lng));

const output = `const DEALERS = ${JSON.stringify(dealers, null, 2)};`;
fs.writeFileSync('dealers-data.js', output);
console.log(`Wrote ${dealers.length} dealers to dealers-data.js`);
```

- [ ] **Step 3: Run it**

```bash
node build-data.js
```

Expected output: `Wrote 32 dealers to dealers-data.js` (number may vary slightly)

- [ ] **Step 4: Inspect output**

```bash
node -e "
const fs = require('fs');
eval(fs.readFileSync('dealers-data.js','utf8'));
console.log('Total:', DEALERS.length);
console.log('First:', JSON.stringify(DEALERS[0], null, 2));
const provinces = [...new Set(DEALERS.map(d=>d.province))].sort();
console.log('Provinces:', provinces);
"
```

Expected:
- Total ≥ 30
- First dealer has `name`, `address`, `city`, `province`, `phone`, `lat` (negative number ~-22 to -35), `lng` (positive ~17–33), `hours.mon` through `hours.sun`
- Provinces list includes: `Gauteng`, `Western Cape`, `KZN` or `KwaZulu-Natal`, etc.

- [ ] **Step 5: Commit build script**

```bash
git add build-data.js
git commit -m "feat: add CSV-to-JSON build script"
```

---

## Task 2: HTML skeleton with layout and full CSS

**Files:**
- Modify: `index.html` (overwrite existing Leapmotor content entirely)

- [ ] **Step 1: Write index.html**

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Find a Jeep Dealer</title>
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
  <style>
    :root {
      --black:  #000000;
      --red:    #BA0C2F;
      --white:  #ffffff;
      --border: #eeeeee;
      --bg-light: #fafafa;
      --muted:  #666666;
      --panel-width: 380px;
    }
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    html, body {
      height: 100%;
      font-family: Arial, Helvetica, sans-serif;
      background: var(--white);
      color: #111;
    }
    .wrap {
      display: grid;
      grid-template-columns: var(--panel-width) 1fr;
      height: 100vh;
      overflow: hidden;
    }

    /* ── Left panel ── */
    .panel {
      display: flex;
      flex-direction: column;
      height: 100vh;
      background: var(--white);
      border-right: 1px solid var(--border);
      overflow: hidden;
    }
    .panel-header {
      background: var(--black);
      padding: 16px 18px 14px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      flex-shrink: 0;
    }
    .panel-header h1 {
      font-size: 15px;
      font-weight: 700;
      letter-spacing: 2px;
      text-transform: uppercase;
      color: var(--white);
      line-height: 1;
    }
    .count-badge {
      background: var(--red);
      color: var(--white);
      font-size: 11px;
      font-weight: 700;
      padding: 3px 9px;
      border-radius: 20px;
      letter-spacing: 0.5px;
      white-space: nowrap;
    }

    /* Controls */
    .panel-controls {
      padding: 12px 14px;
      border-bottom: 1px solid var(--border);
      background: var(--bg-light);
      display: flex;
      flex-direction: column;
      gap: 8px;
      flex-shrink: 0;
    }
    .search-row { display: flex; gap: 8px; }
    .search-input {
      flex: 1;
      min-width: 0;
      height: 38px;
      border: 1.5px solid #ccc;
      padding: 0 12px;
      font-size: 13px;
      color: #333;
      background: var(--white);
      outline: none;
      font-family: inherit;
      border-radius: 0;
    }
    .search-input:focus { border-color: var(--black); }
    .search-input::placeholder { color: #aaa; }
    .btn-locate {
      height: 38px;
      padding: 0 12px;
      background: var(--black);
      color: var(--white);
      border: none;
      font-size: 11px;
      font-weight: 700;
      letter-spacing: 1px;
      text-transform: uppercase;
      cursor: pointer;
      white-space: nowrap;
      font-family: inherit;
    }
    .btn-locate:hover { background: #333; }
    .btn-locate:disabled { background: #555; cursor: default; }
    .province-select {
      height: 38px;
      border: 1.5px solid #ccc;
      padding: 0 32px 0 10px;
      font-size: 13px;
      color: #333;
      background: var(--white);
      cursor: pointer;
      width: 100%;
      appearance: none;
      -webkit-appearance: none;
      font-family: inherit;
      border-radius: 0;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23333' d='M5 6L0 0h10z'/%3E%3C/svg%3E");
      background-repeat: no-repeat;
      background-position: right 12px center;
    }
    .province-select:focus { outline: none; border-color: var(--black); }

    /* Results bar */
    .results-bar {
      padding: 7px 14px;
      background: #f5f5f5;
      border-bottom: 1px solid var(--border);
      font-size: 11px;
      color: #777;
      letter-spacing: 0.5px;
      text-transform: uppercase;
      font-weight: 600;
      display: flex;
      align-items: center;
      justify-content: space-between;
      flex-shrink: 0;
    }
    .clear-btn {
      font-size: 11px;
      color: var(--red);
      background: none;
      border: none;
      cursor: pointer;
      font-weight: 700;
      letter-spacing: 0.5px;
      text-transform: uppercase;
      text-decoration: underline;
      font-family: inherit;
    }

    /* Dealer list */
    .dealer-list { flex: 1; overflow-y: auto; }
    .dealer-list::-webkit-scrollbar { width: 4px; }
    .dealer-list::-webkit-scrollbar-track { background: #f5f5f5; }
    .dealer-list::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; }

    /* Dealer cards */
    .dealer-card {
      padding: 14px 16px;
      border-bottom: 1px solid var(--border);
      cursor: pointer;
      transition: background 0.12s;
      border-left: 3px solid transparent;
    }
    .dealer-card:hover { background: #f9f9f9; }
    .dealer-card.active { background: #fff8f8; border-left-color: var(--red); }
    .dealer-name {
      font-size: 13px;
      font-weight: 700;
      letter-spacing: 0.5px;
      text-transform: uppercase;
      color: #111;
      margin-bottom: 3px;
    }
    .dealer-city {
      font-size: 11px;
      color: var(--red);
      font-weight: 700;
      letter-spacing: 0.5px;
      text-transform: uppercase;
      margin-bottom: 5px;
    }
    .dealer-address {
      font-size: 12px;
      color: var(--muted);
      line-height: 1.45;
      margin-bottom: 8px;
    }
    .dealer-actions {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
      margin-bottom: 8px;
    }
    .action-btn {
      display: inline-flex;
      align-items: center;
      gap: 4px;
      padding: 5px 10px;
      font-size: 11px;
      font-weight: 700;
      letter-spacing: 0.5px;
      text-transform: uppercase;
      text-decoration: none;
      border: 1.5px solid;
      cursor: pointer;
      font-family: inherit;
      line-height: 1;
      border-radius: 0;
    }
    .action-btn.primary { background: var(--red); color: var(--white); border-color: var(--red); }
    .action-btn.primary:hover { background: #9a0a26; border-color: #9a0a26; }
    .action-btn.secondary { background: var(--white); color: #333; border-color: #ccc; }
    .action-btn.secondary:hover { border-color: #333; }
    .action-btn.whatsapp { background: var(--white); color: #25D366; border-color: #25D366; }
    .action-btn.whatsapp:hover { background: #25D366; color: var(--white); }

    /* Hours */
    .hours-toggle {
      display: inline-flex;
      align-items: center;
      gap: 4px;
      font-size: 11px;
      color: #888;
      background: none;
      border: none;
      cursor: pointer;
      text-transform: uppercase;
      letter-spacing: 0.5px;
      font-weight: 600;
      font-family: inherit;
      padding: 0;
    }
    .hours-toggle:hover { color: #333; }
    .hours-table { display: none; margin-top: 8px; border-top: 1px solid var(--border); padding-top: 8px; }
    .hours-table.open { display: block; }
    .hours-row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 11px; color: #555; }
    .hours-row .day { color: #888; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; min-width: 30px; }
    .hours-row.today .day { color: var(--red); }
    .hours-row.today .time { color: var(--red); font-weight: 700; }

    /* Map */
    #map { height: 100vh; }

    /* Responsive */
    @media (max-width: 980px) {
      .wrap { grid-template-columns: 1fr; grid-template-rows: auto 50vh; height: auto; }
      .panel { height: auto; max-height: 50vh; }
      .dealer-list { max-height: calc(50vh - 190px); }
      #map { height: 50vh; }
    }
  </style>
</head>
<body>
  <div class="wrap">
    <aside class="panel" id="panel">
      <div class="panel-header">
        <h1>Find a Dealer</h1>
        <span class="count-badge" id="count">0 Dealers</span>
      </div>
      <div class="panel-controls">
        <div class="search-row">
          <input id="search" class="search-input" type="text" placeholder="Search by city, suburb or dealer…"/>
          <button id="locateBtn" class="btn-locate">Near Me</button>
        </div>
        <select id="province" class="province-select">
          <option value="">All Provinces</option>
        </select>
      </div>
      <div class="results-bar">
        <span id="resultsLabel">Loading…</span>
        <button class="clear-btn" id="clearBtn">Clear</button>
      </div>
      <div class="dealer-list" id="dealerList"></div>
    </aside>
    <section id="map"></section>
  </div>

  <!-- DEALER DATA — replaced in Task 7 with output of: node build-data.js -->
  <script>const DEALERS = [];</script>

  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
  <script>
    /* App logic added in Tasks 3–6 */
  </script>
</body>
</html>
```

- [ ] **Step 2: Open in browser**

Open `index.html` directly (double-click or `open index.html`). Expected:
- Black header "FIND A DEALER" with red "0 Dealers" badge
- Search input + "Near Me" button + "All Provinces" dropdown
- Results bar reading "Loading…" with a "Clear" link
- Empty scrollable area
- OpenStreetMap tiles loading on the right (may show world map at default zoom)
- Zero console errors

- [ ] **Step 3: Commit**

```bash
git add index.html
git commit -m "feat: add Jeep dealer finder HTML skeleton with full CSS"
```

---

## Task 3: Map init, custom pin marker, province dropdown

**Files:**
- Modify: `index.html` — replace `/* App logic added in Tasks 3–6 */` comment

- [ ] **Step 1: Replace the app script comment with map init code**

```javascript
// ── Custom Jeep pin icons ──
const PIN_RED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 34" width="24" height="34"><path d="M12 0C5.373 0 0 5.373 0 12c0 9.5 12 22 12 22S24 21.5 24 12C24 5.373 18.627 0 12 0z" fill="#BA0C2F"/><circle cx="12" cy="12" r="5" fill="white"/></svg>`;
const PIN_BLACK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 34" width="24" height="34"><path d="M12 0C5.373 0 0 5.373 0 12c0 9.5 12 22 12 22S24 21.5 24 12C24 5.373 18.627 0 12 0z" fill="#000000"/><circle cx="12" cy="12" r="5" fill="white"/></svg>`;

function makeIcon(active) {
  return L.divIcon({
    html: active ? PIN_BLACK : PIN_RED,
    className: '',
    iconSize: [24, 34],
    iconAnchor: [12, 34],
    popupAnchor: [0, -36]
  });
}

// ── Map init ──
const map = L.map('map', { zoomControl: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 19,
  attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);

let markers = [];     // array of { marker: L.Marker, dealer: Object }
let activeIdx = null; // index in current rendered set

function fitAll(items) {
  if (!items.length) { map.setView([-28.5, 24.7], 5); return; }
  map.fitBounds(L.featureGroup(items.map(i => i.marker)).getBounds().pad(0.12));
}

// ── Province dropdown ──
function populateProvinces() {
  const sel = document.getElementById('province');
  const provinces = [...new Set(DEALERS.map(d => d.province).filter(Boolean))].sort();
  provinces.forEach(p => {
    const opt = document.createElement('option');
    opt.value = p;
    opt.textContent = p;
    sel.appendChild(opt);
  });
}
populateProvinces();
```

- [ ] **Step 2: Reload browser**

Expected:
- OpenStreetMap tiles load on the right
- Map centred on world default (no markers yet — DEALERS is empty)
- Province dropdown still shows "All Provinces" (populated from empty DEALERS, correct)
- Zero console errors

- [ ] **Step 3: Commit**

```bash
git add index.html
git commit -m "feat: init Leaflet map with custom Jeep pin icons and province dropdown"
```

---

## Task 4: Render dealer cards and map markers

**Files:**
- Modify: `index.html` — append inside app `<script>`, after `populateProvinces()`

- [ ] **Step 1: Append hours helpers and card builder**

```javascript
// ── Hours helpers ──
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const DAY_KEYS  = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];

function formatHours(val) {
  if (!val || val.toLowerCase() === 'closed') return 'Closed';
  return val.replace('-', ' – ');
}

function hoursHTML(dealer) {
  const todayIdx = new Date().getDay(); // 0 = Sunday
  return DAY_KEYS.map((key, i) =>
    `<div class="hours-row${i === todayIdx ? ' today' : ''}">
      <span class="day">${DAY_NAMES[i]}${i === todayIdx ? ' ●' : ''}</span>
      <span class="time">${formatHours(dealer.hours[key])}</span>
    </div>`
  ).join('');
}

// ── Build one dealer card element ──
function buildCard(dealer, idx) {
  const el = document.createElement('div');
  el.className = 'dealer-card';
  el.dataset.idx = idx;

  const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${dealer.lat},${dealer.lng}`;
  const phoneClean = dealer.phone ? dealer.phone.replace(/\s+/g, '') : '';
  const waNum = dealer.whatsapp ? dealer.whatsapp.replace(/\D/g, '') : '';
  const waBtn = waNum
    ? `<a class="action-btn whatsapp" href="https://wa.me/${waNum}" target="_blank" rel="noopener">
         <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
         WhatsApp
       </a>`
    : '';

  el.innerHTML = `
    <div class="dealer-name">${dealer.name}</div>
    <div class="dealer-city">${dealer.city}${dealer.province ? ' · ' + dealer.province : ''}</div>
    <div class="dealer-address">${dealer.address}</div>
    <div class="dealer-actions">
      <a class="action-btn primary" href="${directionsUrl}" target="_blank" rel="noopener">Directions</a>
      ${phoneClean ? `<a class="action-btn secondary" href="tel:${phoneClean}">${dealer.phone}</a>` : ''}
      ${waBtn}
    </div>
    <button class="hours-toggle" type="button">
      <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
      Opening Hours <span class="arrow">▼</span>
    </button>
    <div class="hours-table">${hoursHTML(dealer)}</div>
  `;

  el.querySelector('.hours-toggle').addEventListener('click', e => {
    e.stopPropagation();
    const table = el.querySelector('.hours-table');
    table.classList.toggle('open');
    el.querySelector('.arrow').textContent = table.classList.contains('open') ? '▲' : '▼';
  });

  return el;
}
```

- [ ] **Step 2: Append render and activateDealer functions**

```javascript
// ── Render a filtered dealer array ──
function render(items) {
  const list     = document.getElementById('dealerList');
  const badge    = document.getElementById('count');
  const label    = document.getElementById('resultsLabel');

  // Clear previous markers
  markers.forEach(m => m.marker.remove());
  markers = [];
  activeIdx = null;
  list.innerHTML = '';

  const n = items.length;
  badge.textContent = `${n} Dealer${n !== 1 ? 's' : ''}`;
  label.textContent = `Showing ${n} dealer${n !== 1 ? 's' : ''}`;

  items.forEach((dealer, i) => {
    // Card
    const el = buildCard(dealer, i);
    el.addEventListener('click', () => activateDealer(i));
    list.appendChild(el);

    // Marker
    const m = L.marker([dealer.lat, dealer.lng], { icon: makeIcon(false) }).addTo(map);
    m.bindPopup(`
      <div style="font-family:Arial,sans-serif;min-width:180px">
        <div style="font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px;border-top:3px solid #BA0C2F;padding-top:6px">${dealer.name}</div>
        <div style="font-size:11px;color:#666;margin-bottom:8px;line-height:1.4">${dealer.address}</div>
        <a href="https://www.google.com/maps/dir/?api=1&destination=${dealer.lat},${dealer.lng}" target="_blank" rel="noopener"
           style="display:inline-block;background:#BA0C2F;color:#fff;font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:5px 10px;text-decoration:none">
          Directions
        </a>
      </div>
    `);
    m.on('click', () => activateDealer(i));
    markers.push({ marker: m, dealer });
  });

  fitAll(markers);
}

// ── Activate a dealer: highlight card + black pin + fly map ──
function activateDealer(idx) {
  if (activeIdx !== null) {
    document.querySelector(`.dealer-card[data-idx="${activeIdx}"]`)?.classList.remove('active');
    markers[activeIdx]?.marker.setIcon(makeIcon(false));
  }
  activeIdx = idx;

  const card = document.querySelector(`.dealer-card[data-idx="${idx}"]`);
  card?.classList.add('active');
  card?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });

  if (markers[idx]) {
    markers[idx].marker.setIcon(makeIcon(true));
    map.flyTo([markers[idx].dealer.lat, markers[idx].dealer.lng], 13, { duration: 0.8 });
    markers[idx].marker.openPopup();
  }
}

// ── Initial render ──
render(DEALERS);
```

- [ ] **Step 3: Reload and manual test with console**

Open `index.html`. DEALERS is still empty — count shows "0 Dealers". Now paste this into the browser DevTools console:

```javascript
render([{
  name: 'Test Dealer', address: '1 Main Road', city: 'Cape Town',
  province: 'Western Cape', phone: '021 555 0000', email: '', whatsapp: '27821234567',
  lat: -33.92, lng: 18.42,
  hours: { sun:'Closed', mon:'8:00-17:00', tue:'8:00-17:00', wed:'8:00-17:00',
           thu:'8:00-17:00', fri:'8:00-17:00', sat:'8:00-13:00' }
}]);
```

Expected:
- One dealer card appears in the list
- Red pin appears on map at Cape Town
- Count badge shows "1 Dealer"

Now verify interactions:
- Click the card → map flies to Cape Town, pin turns black, popup opens, card gets red left border
- Click the red pin (reset first via `render([...])`) → same result
- Click "Opening Hours ▼" → Mon–Sun table expands; today's row is red with dot ●; click again → collapses

- [ ] **Step 4: Commit**

```bash
git add index.html
git commit -m "feat: add dealer card renderer, map markers, and bidirectional activate interaction"
```

---

## Task 5: Search and province filter

**Files:**
- Modify: `index.html` — append to app script, after `render(DEALERS)`

- [ ] **Step 1: Append filter wiring**

```javascript
// ── Filter ──
function getFiltered() {
  const q    = document.getElementById('search').value.toLowerCase().trim();
  const prov = document.getElementById('province').value;
  return DEALERS.filter(d => {
    const matchQ = !q || [d.name, d.city, d.address, d.province, d.phone]
      .filter(Boolean).some(v => v.toLowerCase().includes(q));
    const matchP = !prov || d.province === prov;
    return matchQ && matchP;
  });
}

function applyFilters() { render(getFiltered()); }

document.getElementById('search').addEventListener('input', applyFilters);
document.getElementById('province').addEventListener('change', applyFilters);
document.getElementById('clearBtn').addEventListener('click', () => {
  document.getElementById('search').value = '';
  document.getElementById('province').value = '';
  render(DEALERS);
});
```

- [ ] **Step 2: Commit**

```bash
git add index.html
git commit -m "feat: add text search and province filter with clear button"
```

---

## Task 6: Geolocation — Near Me

**Files:**
- Modify: `index.html` — append to app script, after filter wiring

- [ ] **Step 1: Append haversine and Near Me handler**

```javascript
// ── Haversine distance in km ──
function haversine(lat1, lng1, lat2, lng2) {
  const R = 6371;
  const dLat = (lat2 - lat1) * Math.PI / 180;
  const dLng = (lng2 - lng1) * Math.PI / 180;
  const a = Math.sin(dLat / 2) ** 2
    + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180)
    * Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

// ── Near Me button ──
document.getElementById('locateBtn').addEventListener('click', () => {
  if (!navigator.geolocation) {
    alert('Geolocation is not supported by your browser.');
    return;
  }
  const btn = document.getElementById('locateBtn');
  btn.textContent = 'Locating…';
  btn.disabled = true;

  navigator.geolocation.getCurrentPosition(
    ({ coords }) => {
      btn.textContent = 'Near Me';
      btn.disabled = false;
      document.getElementById('search').value = '';
      document.getElementById('province').value = '';
      const sorted = DEALERS
        .map(d => ({ ...d, _dist: haversine(coords.latitude, coords.longitude, d.lat, d.lng) }))
        .sort((a, b) => a._dist - b._dist);
      render(sorted);
      if (sorted.length) activateDealer(0);
    },
    () => {
      btn.textContent = 'Near Me';
      btn.disabled = false;
      alert('Unable to retrieve your location. Please allow location access and try again.');
    }
  );
});
```

- [ ] **Step 2: Commit**

```bash
git add index.html
git commit -m "feat: add Near Me geolocation with distance sort"
```

---

## Task 7: Embed real dealer data + full integration test

**Files:**
- Modify: `index.html` — replace placeholder DEALERS script block

- [ ] **Step 1: Read dealers-data.js content**

```bash
cat dealers-data.js
```

- [ ] **Step 2: Replace placeholder in index.html**

Find this block in `index.html`:
```html
<!-- DEALER DATA — replaced in Task 7 with output of: node build-data.js -->
<script>const DEALERS = [];</script>
```

Replace with:
```html
<script>
[PASTE ENTIRE CONTENT OF dealers-data.js HERE]
</script>
```

- [ ] **Step 3: Full integration test in browser**

Open `index.html`. Run through every feature:

| # | Test | Expected |
|---|---|---|
| 1 | Initial load | All dealers shown; map fits bounds across South Africa; badge shows ≥30 |
| 2 | Province dropdown | Options populated from CSV (Gauteng, Western Cape, KZN, etc.) |
| 3 | Type "pretoria" in search | Filters to Pretoria dealers only; map re-fits |
| 4 | Type "westvaal" in search | Shows all Westvaal branches |
| 5 | Select "Western Cape" from dropdown | Shows only WC dealers |
| 6 | Type "cape" + select "Western Cape" | Intersected results |
| 7 | Click "Clear" | Both fields reset; all dealers shown |
| 8 | Click a dealer card | Map flies to that pin; pin turns black; popup opens; card gets red border |
| 9 | Click a red map pin | Card scrolls into view; gets red border; pin turns black |
| 10 | Click "Opening Hours ▼" on any card | Mon–Sun table expands; today's row is red with ● dot |
| 11 | Click "Opening Hours ▲" | Table collapses |
| 12 | Click "Directions" on any card | New tab: Google Maps directions to dealer |
| 13 | Click a phone number | `tel:` link fires |
| 14 | WhatsApp button | Appears only for dealers with a WhatsApp number; opens `wa.me/...` |
| 15 | Resize to <980px | Panel stacks above map; both scroll independently |
| 16 | Click "Near Me" | Browser asks for location; on allow: list re-sorts by distance, closest dealer activated |

- [ ] **Step 4: Commit**

```bash
git add index.html
git commit -m "feat: embed full Jeep SA dealer data — complete dealer finder"
```

---

## Self-Review

### Spec coverage

| Spec requirement | Task |
|---|---|
| Option A layout (left panel + right map) | Task 2 |
| Black header, "FIND A DEALER", count badge | Task 2 |
| Brand palette (#000, #BA0C2F, #fff) | Task 2 |
| Text search (name, city, suburb, phone) | Task 5 |
| "Near Me" geolocation sort | Task 6 |
| Province dropdown from CSV data | Task 3 |
| Dealer card: name, city·province, address | Task 4 |
| Directions → Google Maps | Task 4 |
| Phone → tel: link | Task 4 |
| WhatsApp → wa.me (conditional on field) | Task 4 |
| Expandable hours Mon–Sun | Task 4 |
| Today's hours row highlighted red | Task 4 |
| Active card: red left border + bg | Task 4 |
| Leaflet + OpenStreetMap | Task 3 |
| Red pin markers | Task 3 |
| Active pin: black | Task 3+4 |
| Popup: red top border, name, address, Directions | Task 4 |
| Card click → fly + popup + scroll | Task 4 |
| Pin click → card scroll + active | Task 4 |
| fitBounds on visible markers | Task 4 |
| Single-file output | All tasks |
| CSV pre-parsed to embedded JS | Task 1+7 |
| Responsive 980px breakpoint | Task 2 |
| Clear button resets all filters | Task 5 |

All spec requirements covered. No gaps.

### Placeholder scan
No TBDs, no "implement later", no "handle edge cases" — all code blocks are complete and runnable.

### Type consistency
- `makeIcon(active: boolean)` → defined Task 3, used Tasks 3+4 ✓
- `markers` → `{ marker: L.Marker, dealer: Object }[]` → consistent across Tasks 3+4+6 ✓
- `activeIdx` → `number | null` → consistent across Tasks 3+4 ✓
- `render(items: dealer[])` → defined Task 4, called Tasks 5+6 ✓
- `activateDealer(idx: number)` → defined Task 4, called Tasks 4+6 ✓
- `fitAll(items: { marker: L.Marker }[])` → defined Task 3, called Task 4 ✓
- `haversine(lat1, lng1, lat2, lng2)` → defined+used Task 6 ✓
- `getFiltered()` → defined+used Task 5 ✓
- `DAY_KEYS` array → defined Task 4, matches `hours` object keys from `build-data.js` Task 1 ✓
