PageSourceSearch

https://app.replicants.ai/js/ads-tests.js?v=20260927r5

js replicants.ai collected 2026-10-05 12:32:20 UTC 150,836 bytes, 2,698 lines download raw bytes

1// src/public/js/ads-tests.js — Ad Testing UI (Phase 4 v1 + Google OAuth
2// connect).
3//
4// Spec: docs/projects/ad-conversions/phase-3-4-mcp-and-dashboard.md (v2.0)
5// §"Phase 4 — Testing UI" + §"OAuth connect — the product mechanism". Read +
6// approve/dismiss + Google-only connect this phase — no campaign creation,
7// no platform test/read API calls, no scaling UI, no Meta connect (stays
8// "coming soon"). Consumes src/api/ads-routes.js:
9//   GET  /api/ads/tests            (?status=)
10//   GET  /api/ads/tests/:id
11//   POST /api/ads/tests/:id/approve   (409 if the test already moved on)
12//   POST /api/ads/tests/:id/dismiss   (409 if the test already moved on)
13//   GET  /api/ads/connections
14//   GET  /api/ads/health
15//   GET  /api/ads/oauth/google/start             { url } to navigate to
16//   GET  /api/ads/oauth/google/accounts          this tenant's accessible accounts
17//                                                 (503 { error: 'dev_token_missing' |
18//                                                 'mcc_id_missing' } if not yet
19//                                                 configured on this environment)
20//   POST /api/ads/connections/google/account      { ad_account_id } → "Connect =
21//                                                 Provision": persists the pick AND
22//                                                 provisions tag/labels/config
23//                                                 server-side in one call (409
24//                                                 not_accessible if stale)
25//   POST /api/ads/connections/google/disconnect  revoke + soft-disconnect
26// The OAuth *callback* (`/api/ads/oauth/google/callback`) is never fetched
27// by this file — Google redirects the browser there directly, and that
28// route redirects back to `/app.html?page=ads&connected=google` /
29// `&connect_error=1`, which consumeReturnFlag() below picks up on load.
30//
31// Account picker (Settings, "Connect = Provision" step 2 — spec
32// §"Connect = Provision"): once connected, if the connection has no
33// ad_account_id yet, Settings fetches the accessible-accounts list and
34// shows a radio picker + one "Use this account" button. Picking an account
35// both selects it AND provisions everything (conversion actions, tag id,
36// labels, config) in the same call — the tenant never sees a pixel/tag/
37// label. On success a confirmation banner renders straight from that
38// call's response (tagId/labels) — GET /connections deliberately never
39// returns config VALUES (only presence, via /health), so this is the one
40// moment those values are ever shown, and only client-side/session-local.
41//
42// Mount: mirrors src/public/js/sales-pipeline.js's shape exactly — an empty
43// `<div class="page" id="page-ads">` in app.html, rendered entirely by this
44// file into `#ads-root`. window.showAds() (ui-core.js) mounts the module at
45// its default route ('board'); the shell subnav (sidebar-nav.js's
46// _adsGroups()) calls window.showAdsRoute(route) for 'board'/'settings';
47// in-page navigation (Review / back links) also calls showAdsRoute so a
48// fresh visitor and a mid-session click behave identically.
49//
50// Styling: reuses ui-kit.css's shared components directly (rk-btn, rk-card,
51// rk-badge, rk-input, rk-breadcrumbs) and adds only the structure ui-kit.css
52// doesn't have via css/ads-tests.css (.ads-* classes, values entirely
53// var(--rk-*) — CODING-STANDARDS §"UI Kit" + §"Design System & UX
54// Principles").
55//
56// Pure/testable helpers (fmtMoney, spendPercent, daysSince, statusChip,
57// humanizeKey, esc, buildResultsTable data shaping) are kept side-effect-free
58// and exported via module.exports (Node-testable) alongside the browser
59// window.AdsTests global — mirrors videographer-gates.js's dual-export
60// pattern so this file is unit-testable without a DOM.
61(function () {
62  'use strict';
63
64  var STATE = {
65    route: 'board',       // 'board' | 'detail' | 'settings'
66    testId: null,
67    tests: [],             // this tenant's ad_tests (board data)
68    health: null,          // GET /ads/health response
69    connections: [],       // GET /ads/connections response
70    detailTest: null,      // full row for the active detail view
71    loadedOnce: false,
72    busy: false,
73    googleAccounts: null,       // null = not loaded yet; [] = loaded, empty/error
74    googleAccountsLoading: false,
75    googleAccountsError: null,  // err.code from a failed GET /oauth/google/accounts
76    selectedGoogleAccountId: null,
77    provisionResult: null,      // last POST /connections/google/account response
78    agentControls: null,        // GET /agent-controls response (null = not loaded)
79    agentControlsLoading: false,
80    agentSaving: false,
81    // ── Meta (Facebook) — same three-stage shape as Google above:
82    // connect → pick an account → provisioned.
83    metaAccounts: null,         // null = not loaded yet; [] = loaded, empty/error
84    metaAccountsLoading: false,
85    metaAccountsError: null,
86    selectedMetaAccountId: null,
87    metaProvisionResult: null,  // last POST /connections/meta/account response
88    // The verification screen's data: GET /connections/meta/events.
89    metaEvents: null,           // { range_days, tracking, events[] }
90    metaEventsLoading: false,
91    // D5 — the staleness banner and the one-button republish.
92    staleness: null,            // { pages_needing_republish, by_surface[], … }
93    stalenessLoading: false,
94    republish: null,            // { job, items[], done, partial, failed, total }
95    republishStarting: false,
96    republishError: null,       // a failed START says so on the card
97    // D6 — the tracking screen. The range lives HERE rather than on the
98    // control, because renderRoute() replaces the whole route's markup and a
99    // control's own state dies with the element it was attached to.
100    trackingTab: 'firing',
101    trackingRange: { win: '30d', from: null, to: null },
102    trackingTest: false,
103    trackingEvents: null,
104    trackingEventsLoading: false,
105    trackingPages: null,
106    trackingPagesLoading: false,
107    trackingAllPages: false,    // the per-page list is one tap, not the default
108    republishTimer: null,
109    // Test Events: the code is typed in each time and never stored — Meta's
110    // code is per-session, so a remembered one silently sends nowhere.
111    metaTestCode: '',
112    metaTestSending: false,
113    metaTestResult: null,
114    // ── The Facebook screen: a READ mirror of Ads Manager ────────────────
115    // Alex, 2026-08-24: *"Once they're created I want to see them in my
116    // replicants' UI and I want to know if they're on or off."*
117    // One level at a time, Meta's own three, so a coach can hold this and
118    // Ads Manager side by side and see the same thing.
119    fbLevel: 'campaigns',       // 'campaigns' | 'adsets' | 'ads'
120    fbCampaigns: null,          // null = not loaded; [] = loaded, empty
121    fbAdSets: null,
122    fbAds: null,
123    fbLoading: false,
124    fbError: null,              // err.code — rendered as a sentence, not a code
125    fbCampaign: null,           // { id, name } we drilled into
126    fbAdSet: null,
127    fbHealth: null,             // GET /meta/health
128    // The date filter for the Facebook screen (T6). A Meta preset, or a custom
129    // {since,until}. Lives in STATE because renderRoute() replaces the markup.
130    fbRange: { preset: 'last_7d', since: null, until: null },
131    fbFetchedAt: null,          // when the numbers were last pulled (the "last updated")
132    fbPausing: null,            // id currently being paused, so its button can show progress
133    fbCustomOpen: false,        // whether the custom-range inputs are shown
134  };
135  var _lastNavRoute = null;
136
137  // ⚠️ ONE list of routes, read by BOTH the internal router and the public
138  // entry point. It was two copies, and on 2026-08-24 adding 'facebook' to
139  // one of them produced a screen that silently fell back to the board:
140  // showAdsRoute() rejected the route before go() ever saw it. Two mechanisms
141  // for one job — CODING-STANDARDS §4 — and the failure mode was a route that
142  // looked wired and wasn't.
143  var ROUTES = ['board', 'detail', 'settings', 'facebook', 'tracking'];
144
145  // ── Pure helpers (exported — no DOM, no fetch) ──────────────────────────
146
147  function esc(s) {
148    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
149      return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
150    });
151  }
152
153  // Money is always rendered via this one helper — minor units in, a
154  // currency-formatted string out. Falls back to a plain "$" + 2dp string if
155  // Intl/currency lookup throws (unknown currency code etc).
156  function fmtMoney(minor, currency) {
157    var n = (Number(minor) || 0) / 100;
158    try {
159      return new Intl.NumberFormat('en', { style: 'currency', currency: String(currency || 'usd').toUpperCase() }).format(n);
160    } catch (_) {
161      return '$' + n.toFixed(2);
162    }
163  }
164
165  // 0–100, rounded, clamped. cap<=0 (shouldn't happen — budget_cap_minor is
166  // NOT NULL) reads as 0 rather than throwing/NaN-ing the progress bar.
167  function spendPercent(spendMinor, capMinor) {
168    var spend = Number(spendMinor) || 0;
169    var cap = Number(capMinor) || 0;
170    if (cap <= 0) return 0;
171    var pct = Math.round((spend / cap) * 100);
172    if (!Number.isFinite(pct) || pct < 0) return 0;
173    return Math.min(100, pct);
174  }
175
176  // Whole days between an ISO timestamp and `now` (default: current time).
177  // `now` is an explicit param so this is deterministic/testable — not read
178  // from Date.now() implicitly inside the render path.
179  function daysSince(iso, now) {
180    if (!iso) return null;
181    var start = new Date(iso).getTime();
182    if (!Number.isFinite(start)) return null;
183    var n = now ? new Date(now).getTime() : Date.now();
184    var days = Math.floor((n - start) / 86400000);
185    return days < 0 ? 0 : days;
186  }
187
188  function humanizeKey(k) {
189    return String(k || '').replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
190  }
191
192  function humanizeValue(v) {
193    if (v == null || v === '') return '—';
194    if (Array.isArray(v)) return v.map(function (x) { return (x && typeof x === 'object') ? JSON.stringify(x) : String(x); }).join(', ');
195    if (typeof v === 'object') return JSON.stringify(v);
196    return String(v);
197  }
198
199  function platformLabel(p) {
200    return p === 'google' ? 'Google' : (p === 'meta' ? 'Meta' : (p || 'Unknown platform'));
201  }
202
203  // Display an ad-account id the way the ad platform itself formats it, so it's
204  // instantly recognisable. Google customer IDs are 10 digits shown as
205  // XXX-XXX-XXXX (e.g. 818-143-7091). Anything else passes through untouched
206  // (Meta uses an act_<id> form — add when Meta ships).
207  function fmtAcct(id, platform) {
208    var s = String(id == null ? '' : id).trim();
209    if (!platform || platform === 'google') {
210      var d = s.replace(/\D/g, '');
211      if (d.length === 10) return d.slice(0, 3) + '-' + d.slice(3, 6) + '-' + d.slice(6);
212    }
213    return s;
214  }
215
216  // Design-system rule: colour is never the only signal — every chip pairs
217  // an icon + text (CODING-STANDARDS §"Design System & UX Principles",
218  // "Status Colours"). `cls` is one of ui-kit.css's rk-badge-* variants (or
219  // '' for the plain neutral rk-badge).
220  //
221  // Note on scope: the spec's example chip set for a running test includes
222  // "Leading: V2" (phase-3-4-mcp-and-dashboard.md §"Screen 1"). That requires
223  // picking a leading variant out of `results`, but `results` has no defined
224  // shape yet — migration 291 ships it as an empty `{}` stub and the Phase 3
225  // MCP rail (not built) is what will decide what goes in it. Inventing a
226  // parsing contract here would be a guess, not a read of an actual shape
227  // (CLAUDE.md "No Assumptions"), so this deliberately renders "Learning" for
228  // every in-progress running test and reserves "Ready to conclude" for the
229  // one signal that IS well-defined in code today: spend has hit the cap.
230  function statusChip(test) {
231    var t = test || {};
232    var status = t.status;
233    if (status === 'recommended') return { icon: 'ℹ️', text: 'Needs your review', cls: 'rk-badge-info' };
234    if (status === 'approved') return { icon: '⏳', text: 'Approved — starting soon', cls: 'rk-badge-info' };
235    if (status === 'paused') return { icon: '⏸', text: 'Paused', cls: 'rk-badge-warning' };
236    if (status === 'running') {
237      if (spendPercent(t.spend_minor, t.budget_cap_minor) >= 100) {
238        return { icon: '🏁', text: 'Ready to conclude', cls: 'rk-badge-warning' };
239      }
240      return { icon: '●', text: 'Learning', cls: 'rk-badge-info' };
241    }
242    if (status === 'concluded') {
243      var v = String(t.verdict || '').toLowerCase();
244      if (v.indexOf('win') !== -1) return { icon: '🏆', text: 'Winner', cls: 'rk-badge-success' };
245      return { icon: '🏁', text: t.verdict ? ('Concluded — ' + t.verdict) : 'Concluded', cls: '' };
246    }
247    if (status === 'dismissed') return { icon: '🚫', text: 'Dismissed', cls: '' };
248    return { icon: 'ℹ️', text: status || 'Unknown', cls: '' };
249  }
250
251  // "will spend at most $200" — the approval card's one-liner (spec §Screen 1).
252  function capLine(test) {
253    return 'will spend at most ' + fmtMoney(test && test.budget_cap_minor, test && test.currency);
254  }
255
256  function variantCount(test) {
257    return Array.isArray(test && test.variants) ? test.variants.length : 0;
258  }
259
260  // audience_spec is an arbitrary JSON object (spec: "targeting + seeded-
261  // audience references") — no fixed schema exists to key off, so this
262  // renders every top-level key generically as a humanised label + value
263  // rather than guessing at specific field names.
264  function audienceSpecRows(spec) {
265    var obj = (spec && typeof spec === 'object' && !Array.isArray(spec)) ? spec : {};
266    return Object.keys(obj).map(function (k) { return { label: humanizeKey(k), value: humanizeValue(obj[k]) }; });
267  }
268
269  // Reads whatever shape a variant object happens to have (copy + asset refs
270  // per the spec's "ad_tests" table sketch — no fixed field list) using a
271  // small set of common aliases, so the preview degrades gracefully instead
272  // of guessing at a contract the schema doesn't define.
273  function variantPreview(v, i) {
274    v = v || {};
275    return {
276      label: 'Variant ' + (i + 1),
277      headline: v.headline || v.title || v.name || null,
278      description: v.description || v.body || v.copy || null,
279      image: v.image_url || v.image || v.asset_url || null,
280    };
281  }
282
283  // `results` is a per-variant metrics blob with no fixed schema yet
284  // (migration 291: empty `{}` until the Phase 3 build/read tools exist).
285  // When it DOES carry `{ variants: [...] }`, render every numeric/text field
286  // present as a column — dynamic, not a guessed fixed column list — so this
287  // doesn't silently mis-render once the real shape lands. Returns null when
288  // there's nothing to show (caller renders the "collecting data…" state).
289  function resultsTableData(results) {
290    var obj = (results && typeof results === 'object') ? results : {};
291    var variants = Array.isArray(obj.variants) ? obj.variants : [];
292    if (!variants.length) return null;
293    var labelKey = ['label', 'name', 'id'].filter(function (k) {
294      return variants.some(function (v) { return v && v[k] != null; });
295    })[0] || null;
296    var metricKeys = [];
297    variants.forEach(function (v) {
298      if (!v || typeof v !== 'object') return;
299      Object.keys(v).forEach(function (k) {
300        if (k === labelKey) return;
301        if (metricKeys.indexOf(k) === -1) metricKeys.push(k);
302      });
303    });
304    var rows = variants.map(function (v, i) {
305      v = v || {};
306      var label = labelKey && v[labelKey] != null ? String(v[labelKey]) : ('Variant ' + (i + 1));
307      var cells = metricKeys.map(function (k) { return v[k] == null ? '—' : String(v[k]); });
308      return { label: label, cells: cells };
309    });
310    return { columns: metricKeys.map(humanizeKey), rows: rows };
311  }
312
313  // ── Connect onboarding + persistent "how it works" reference (Settings) ──
314  // Pure function of the Google connection row (or null) so it's Node-testable
315  // like the other helpers above. The guide shows in BOTH states — being
316  // connected shouldn't mean you understand how it all works; people should
317  // always be able to refer back:
318  //   • not connected            → the full step-by-step walkthrough + CTA +
319  //                                spend promise (primary onboarding, expanded)
320  //   • connected, no account    → a short actionable "one more step" nudge
321  //                                (the account picker renders right below it),
322  //                                PLUS the collapsible reference under it
323  //   • connected, account picked → the collapsible reference on its own
324  // The reference is a native <details> (collapsed by default), so it needs no
325  // JS wiring, survives the module's re-renders, and is keyboard-accessible.
326  // Its copy is the same explanatory content, reframed from imperative
327  // ("Click Connect") to descriptive ("When you connected, we created…") — the
328  // "click Connect" step and first-timer signup line are dropped (they're done).
329  // Copy mirrors the plain-English, reassuring tone of ads-onboarding.js.
330  var GA_SIGNUP_URL = 'https://ads.google.com/home/';
331
332  // Collapsible "how it works" reference — shown once connected (both sub-states).
333  function connectReference() {
334    return '<details class="rk-card ads-guide ads-guide-details">' +
335      '<summary class="ads-guide-summary">' +
336        '<span class="ads-guide-summary-ic" aria-hidden="true">📖</span>' +
337        '<span class="ads-guide-summary-txt">' +
338          '<span class="ads-guide-summary-t">How your Google Ads setup works</span>' +
339          '<span class="ads-guide-summary-sub">Tracking, campaigns, audiences &amp; spend safety — how it all fits together</span>' +
340        '</span>' +
341        '<span class="ads-guide-summary-chev" aria-hidden="true">▾</span>' +
342      '</summary>' +
343      '<div class="ads-guide-details-body">' +
344        '<p class="ads-guide-lede">Here’s what’s running behind the scenes now that you’re connected — ' +
345        'you can refer back to this any time.</p>' +
346        '<ul class="ads-ref-list">' +
347          '<li class="ads-ref-item">' +
348            '<span class="ads-ref-ic" aria-hidden="true">🔌</span>' +
349            '<div class="ads-ref-body">' +
350              '<div class="ads-ref-t">We handle all the Google plumbing</div>' +
351              '<div class="ads-ref-d">When you connected and approved on Google, you gave the platform permission to ' +
352              'manage ads and audiences on your behalf. You never create pixels, tags, developer tokens, or touch any code.</div>' +
353            '</div>' +
354          '</li>' +
355          '<li class="ads-ref-item">' +
356            '<span class="ads-ref-ic" aria-hidden="true">🎯</span>' +
357            '<div class="ads-ref-body">' +
358              '<div class="ads-ref-t">Your conversion tracking is already live</div>' +
359              '<div class="ads-ref-d">When you connected, we created your Lead and Purchase conversion tracking, wrote ' +
360              'your tag, and published it to your pages — automatically. No manual tracking setup, ever.</div>' +
361            '</div>' +
362          '</li>' +
363        '</ul>' +
364        '<div class="ads-guide-next">' +
365          '<div class="ads-guide-next-t">✨ What you can do</div>' +
366          '<ul class="ads-guide-next-list">' +
367            '<li>Ask your agent to build an AI campaign — it’s created <strong>paused</strong>, for your approval.</li>' +
368            '<li>Seed a Customer Match audience from your existing contacts.</li>' +
369          '</ul>' +
370        '</div>' +
371        '<div class="ads-guide-guard">' +
372          '<span class="ads-guide-guard-ic" aria-hidden="true">🛡️</span>' +
373          '<span><strong>You’re always in control of spend.</strong> Your agent recommends and prepares — it never spends ' +
374          'your money or raises a budget on its own. Going live and raising budgets are always your click.</span>' +
375        '</div>' +
376      '</div>' +
377    '</details>';
378  }
379
380  function connectGuide(g) {
381    if (g && g.connected) {
382      var reference = connectReference();
383      if (!g.ad_account_id) {
384        // Connected but no account chosen yet — actionable nudge (immediate next
385        // step) followed by the persistent reference.
386        return '<div class="rk-card ads-guide ads-guide-nudge">' +
387          '<div class="ads-guide-nudge-row">' +
388            '<span class="ads-guide-nudge-ic" aria-hidden="true">✅</span>' +
389            '<div>' +
390              '<div class="rk-card-title">One more step — pick your ad account</div>' +
391              '<p class="ads-guide-lede">You’re connected to Google. Choose which Google Ads account to use ' +
392              'just below, and we’ll set up your conversion tracking automatically — nothing else to do.</p>' +
393            '</div>' +
394          '</div></div>' + reference;
395      }
396      // Fully set up — the collapsible reference on its own.
397      return reference;
398    }
399    // Not connected — the full first-timer walkthrough (primary onboarding + CTA).
400    var steps = [
401      { t: 'Click “Connect Google Ads”',
402        d: 'Use the Connect button just below. We’ll send you to Google to sign in and approve — nothing spends and nothing is charged.' },
403      { t: 'Approve on Google',
404        d: 'You’re giving the platform permission to manage ads and audiences on your behalf. We handle all the technical Google setup for you — you never create pixels, tags, developer tokens, or touch any code.' },
405      { t: 'Pick your ad account',
406        d: 'Choose which Google Ads account to use. If you manage several, pick the one for this business.' },
407      { t: 'That’s it — we set the rest up automatically',
408        d: 'We create your Lead and Purchase conversion tracking, write your tag, and publish it to your pages. No manual tracking setup, ever.' },
409    ];
410    var stepHtml = steps.map(function (s, i) {
411      return '<li class="ads-guide-step">' +
412        '<span class="ads-guide-num" aria-hidden="true">' + (i + 1) + '</span>' +
413        '<div class="ads-guide-step-body">' +
414          '<div class="ads-guide-step-t">' + esc(s.t) + '</div>' +
415          '<div class="ads-guide-step-d">' + esc(s.d) + '</div>' +
416        '</div></li>';
417    }).join('');
418    return '<div class="rk-card ads-guide">' +
419      '<div class="ads-guide-eyebrow">👋 New here? Here’s how connecting works</div>' +
420      '<div class="rk-card-title">Connect Google Ads in a few clicks</div>' +
421      '<p class="ads-guide-lede">Connecting lets your agent build and run ad campaigns for you. It takes about a minute, ' +
422      'and <strong>we handle all the technical Google plumbing</strong> — you don’t set up pixels, tags, developer tokens, or any code.</p>' +
423      '<ol class="ads-guide-steps">' + stepHtml + '</ol>' +
424      '<div class="ads-guide-next">' +
425        '<div class="ads-guide-next-t">✨ What you can do once you’re connected</div>' +
426        '<ul class="ads-guide-next-list">' +
427          '<li>Ask your agent to build an AI campaign — it’s created <strong>paused</strong>, for your approval.</li>' +
428          '<li>Seed a Customer Match audience from your existing contacts.</li>' +
429        '</ul>' +
430      '</div>' +
431      '<div class="ads-guide-guard">' +
432        '<span class="ads-guide-guard-ic" aria-hidden="true">🛡️</span>' +
433        '<span><strong>You’re always in control of spend.</strong> Your agent recommends and prepares — it never spends ' +
434        'your money or raises a budget on its own. Going live and raising budgets are always your click.</span>' +
435      '</div>' +
436      '<div class="ads-guide-help">Don’t have a Google Ads account yet? ' +
437        '<a href="' + GA_SIGNUP_URL + '" target="_blank" rel="noopener noreferrer">Create one free at Google Ads</a>, ' +
438        'then come back here and connect it.</div>' +
439      '</div>';
440  }
441
442  // Board (unconnected) — a single clear call-to-action pointing at Settings,
443  // NOT a duplicate of the full guide. Returns '' once connected.
444  function boardConnectCta(g) {
445    if (g && g.connected) return '';
446    return '<div class="rk-card ads-board-cta">' +
447      '<div class="ads-board-cta-main">' +
448        '<div class="ads-board-cta-t">🔌 Connect Google Ads to get started</div>' +
449        '<div class="ads-board-cta-d">Link your Google Ads account so your agent can build and run ad tests for you. ' +
450        'We handle the technical setup, and nothing spends without your approval.</div>' +
451      '</div>' +
452      '<button type="button" class="rk-btn ads-btn-touch" data-act="goto-settings">Get started</button>' +
453      '</div>';
454  }
455
456  // ── Facebook formatters ────────────────────────────────────────────────
457  // These live OUT here, with the other pure helpers, for one reason: out
458  // here a test can call them, and inside the DOM closure a test can only
459  // grep the source for the string it hopes is there. Every one of them turns
460  // a Meta constant into words a coach reads, so getting them wrong is a copy
461  // bug on the screen — exactly the class of thing a source pin misses.
462  //
463  // The money formatters deliberately did NOT come with them: they fall back
464  // to the loaded account's currency, so they depend on STATE and are not pure.
465
466  // Meta's three levels, and the ONE status that tells the truth.
467  //
468  // 🚨 `effective_status`, NEVER `status`. `status` is what the tenant set;
469  // `effective_status` is what Facebook is actually DOING. They diverge
470  // exactly when something is wrong — a paused parent, a rejected ad, a dead
471  // payment method — so rendering `status` would be a reassuring lie on
472  // precisely the screen someone opens to find out why nothing is happening.
473  function fbStatus(effective, fallback) {
474    var e = String(effective || fallback || '').toUpperCase();
475    if (e === 'ACTIVE') return { icon: '●', label: 'Active', cls: 'fb-on' };
476    if (e === 'PAUSED') return { icon: '⏸', label: 'Paused', cls: 'fb-off' };
477    if (e === 'DISAPPROVED') return { icon: '⚠️', label: 'Rejected', cls: 'fb-bad' };
478    if (e === 'WITH_ISSUES') return { icon: '⚠️', label: 'Has issues', cls: 'fb-bad' };
479    if (e === 'PENDING_REVIEW') return { icon: '⏳', label: 'In review', cls: 'fb-wait' };
480    if (e === 'CAMPAIGN_PAUSED') return { icon: '⏸', label: 'Campaign paused', cls: 'fb-off' };
481    if (e === 'ADSET_PAUSED') return { icon: '⏸', label: 'Ad set paused', cls: 'fb-off' };
482    if (e === 'ARCHIVED') return { icon: '🗄', label: 'Archived', cls: 'fb-off' };
483    if (e === 'ACCOUNT_PROBLEM') return { icon: '🚫', label: 'Account problem', cls: 'fb-bad' };
484    // Unknown rather than guessed. A wrong label here is worse than none:
485    // the whole value of this column is that it can be trusted.
486    return { icon: '•', label: e ? e.replace(/_/g, ' ').toLowerCase() : 'Unknown', cls: 'fb-off' };
487  }
488
489  function fbPill(effective, fallback) {
490    var s = fbStatus(effective, fallback);
491    // Colour + icon + TEXT, never colour alone (design-system rule).
492    return '<span class="fb-pill ' + s.cls + '">' + s.icon + ' ' + esc(s.label) + '</span>';
493  }
494
495  // ── The Facebook date filter (T6). Ads Manager's own windows, so a coach
496  // comparing the two sees the same names. `maximum` is the account's whole
497  // history; the rest map 1:1 to Meta presets the backend whitelists. ──
498  var FB_DATE_PRESETS = [
499    { key: 'today', label: 'Today' },
500    { key: 'yesterday', label: 'Yesterday' },
501    { key: 'last_7d', label: 'Last 7 days' },
502    { key: 'last_30d', label: 'Last 30 days' },
503    { key: 'maximum', label: 'Maximum' },
504  ];
505
506  // The query string the meta routes want for a given range — a validated
507  // custom window wins, else the preset. Pure, so it is unit-tested.
508  function fbRangeQuery(range) {
509    var r = range || {};
510    if (r.since && r.until) {
511      return '?since=' + encodeURIComponent(r.since) + '&until=' + encodeURIComponent(r.until);
512    }
513    return '?date=' + encodeURIComponent(r.preset || 'last_7d');
514  }
515
516  // The human label for the active window, for the header line.
517  function fbRangeLabel(range) {
518    var r = range || {};
519    if (r.since && r.until) return r.since + ' → ' + r.until;
520    for (var i = 0; i < FB_DATE_PRESETS.length; i++) {
521      if (FB_DATE_PRESETS[i].key === (r.preset || 'last_7d')) return FB_DATE_PRESETS[i].label;
522    }
523    return 'Last 7 days';
524  }
525
526  // "just now", "3 min ago", "2 h ago" — the age of the numbers, for the
527  // "last updated" line. Pure (takes now), so it is testable and deterministic.
528  function fbAgo(iso, now) {
529    if (!iso) return '';
530    var then = new Date(iso).getTime();
531    if (!isFinite(then)) return '';
532    var secs = Math.max(0, Math.round(((now || Date.now()) - then) / 1000));
533    if (secs < 45) return 'just now';
534    var mins = Math.round(secs / 60);
535    if (mins < 60) return mins + ' min ago';
536    var hrs = Math.round(mins / 60);
537    if (hrs < 24) return hrs + ' h ago';
538    return Math.round(hrs / 24) + ' d ago';
539  }
540
541  // The metric set for one row, formatted for display (T9/T11). Units are the
542  // point: money in major units with the account currency, counts as counts,
543  // CTR as a percent, CPC and cost-per-result as money. Pure — currency passed
544  // in — so it is unit-tested against the units bug that started this work.
545  function fbMetricsData(perf, currency) {
546    if (!perf) return null;
547    var num = function (v) { var n = Number(v); return isFinite(n) ? n : 0; };
548    var money = function (v) { return fbCur(currency) + num(v).toFixed(2); };
549    // The first conversion a coach cares about, and its cost, mirroring fbResults.
550    var conv = perf.conversions || {};
551    var costs = perf.costPerConversion || {};
552    var primaryKey = ('lead' in conv) ? 'lead'
553      : ('offsite_conversion_fb_pixel_lead' in conv) ? 'offsite_conversion_fb_pixel_lead'
554      : ('purchase' in conv) ? 'purchase'
555      : Object.keys(conv)[0] || null;
556    var results = primaryKey ? num(conv[primaryKey]) : 0;
557    var costPer = primaryKey && costs[primaryKey] != null ? money(costs[primaryKey]) : '—';
558    return {
559      spend: money(perf.spend),
560      impressions: String(Math.round(num(perf.impressions))),
561      reach: String(Math.round(num(perf.reach))),
562      clicks: String(Math.round(num(perf.clicks))),
563      cpc: money(perf.cpc),
564      ctr: num(perf.ctr).toFixed(2) + '%',
565      results: results ? String(results) : '0',
566      costPerResult: results ? costPer : '—',
567    };
568  }
569
570  // fbCur needs the account currency; on the browser it falls back to
571  // STATE.fbHealth, but the pure helpers above take it explicitly so a test
572  // never depends on STATE. This shim keeps the render sites terse.
573  function fbCur(currency) {
574    var c = String(currency || (typeof STATE !== 'undefined' && STATE.fbHealth && STATE.fbHealth.currency) || '').toUpperCase();
575    if (c === 'GBP') return '£';
576    if (c === 'USD') return '$';
577    if (c === 'EUR') return '€';
578    return c ? c + ' ' : '';
579  }
580
581  // The conversions map is keyed by Meta's action_type. `lead` is the one a
582  // coach cares about first, so it leads; anything else is summed behind it
583  // rather than dropped, because a number that silently disappears is worse
584  // than one that is grouped.
585  function fbResults(perf) {
586    if (!perf || !perf.conversions) return '—';
587    var c = perf.conversions;
588    var keys = Object.keys(c);
589    if (!keys.length) return '—';
590    var lead = c.lead || c.offsite_conversion_fb_pixel_lead;
591    if (lead) return fbPlural(lead, 'lead', 'leads');
592    var purchase = c.purchase || c.offsite_conversion_fb_pixel_purchase;
593    if (purchase) return fbPlural(purchase, 'sale', 'sales');
594    var total = keys.reduce(function (a, k) { return a + (Number(c[k]) || 0); }, 0);
595    return fbPlural(total, 'result', 'results');
596  }
597
598  // The first conversion is the one a coach stares at, and "1 sales" is
599  // exactly the wrong moment for the screen to look unfinished.
600  function fbPlural(n, one, many) {
601    return String(n) + ' ' + (Number(n) === 1 ? one : many);
602  }
603
604  // Meta's objective constants read like shouting. Say them the way a coach
605  // would, and leave anything unmapped alone rather than mangling it.
606  function fbObjective(o) {
607    var map = {
608      OUTCOME_TRAFFIC: 'Traffic', OUTCOME_LEADS: 'Leads', OUTCOME_SALES: 'Sales',
609      OUTCOME_AWARENESS: 'Awareness', OUTCOME_ENGAGEMENT: 'Engagement',
610    };
611    return map[String(o || '').toUpperCase()] || (o ? String(o).replace(/^OUTCOME_/, '').toLowerCase() : '—');
612  }
613
614  // Meta's learning_stage_info is an internal constant, and rendering it raw
615  // put the word "success" on a row on its own — true, and meaningless to a
616  // coach. Say the two states that mean something and say nothing at all for
617  // the rest, rather than showing a constant nobody can act on.
618  function fbLearning(stage) {
619    var s = String(stage || '').toUpperCase();
620    if (s === 'SUCCESS') return '<span>Finished learning</span>';
621    if (s === 'LEARNING') return '<span>Still learning</span>';
622    return '';
623  }
624
625  // The conversion event an ad set optimises for. Meta's constants are
626  // singular and shouted (LEAD); a coach reads "Optimising for leads".
627  function fbEvent(e) {
628    var map = {
629      LEAD: 'Optimising for leads',
630      PURCHASE: 'Optimising for sales',
631      COMPLETE_REGISTRATION: 'Optimising for sign-ups',
632      INITIATE_CHECKOUT: 'Optimising for checkouts',
633      ADD_TO_CART: 'Optimising for added carts',
634      VIEW_CONTENT: 'Optimising for page views',
635    };
636    return map[String(e || '').toUpperCase()]
637      || ('Optimising for ' + String(e || '').replace(/_/g, ' ').toLowerCase());
638  }
639
640  function fbGoal(g) {
641    var map = {
642      LANDING_PAGE_VIEWS: 'Optimising for page views',
643      OFFSITE_CONVERSIONS: 'Optimising for conversions',
644      LINK_CLICKS: 'Optimising for clicks',
645      IMPRESSIONS: 'Optimising for reach',
646      VALUE: 'Optimising for value',
647    };
648    return map[String(g || '').toUpperCase()] || (g ? String(g).replace(/_/g, ' ').toLowerCase() : '—');
649  }
650
651  var HELPERS = {
652    esc: esc,
653    fbStatus: fbStatus,
654    fbPill: fbPill,
655    fbResults: fbResults,
656    fbPlural: fbPlural,
657    fbObjective: fbObjective,
658    fbLearning: fbLearning,
659    fbEvent: fbEvent,
660    fbGoal: fbGoal,
661    fmtMoney: fmtMoney,
662    spendPercent: spendPercent,
663    daysSince: daysSince,
664    humanizeKey: humanizeKey,
665    humanizeValue: humanizeValue,
666    platformLabel: platformLabel,
667    statusChip: statusChip,
668    capLine: capLine,
669    variantCount: variantCount,
670    audienceSpecRows: audienceSpecRows,
671    variantPreview: variantPreview,
672    resultsTableData: resultsTableData,
673    connectGuide: connectGuide,
674    boardConnectCta: boardConnectCta,
675    // Facebook dashboard date filter + metrics (T6/T9)
676    FB_DATE_PRESETS: FB_DATE_PRESETS,
677    fbRangeQuery: fbRangeQuery,
678    fbRangeLabel: fbRangeLabel,
679    fbAgo: fbAgo,
680    fbMetricsData: fbMetricsData,
681    fbResults: fbResults,
682  };
683
684  // Browser-only from here — DOM/fetch/global wiring. Exits early (after
685  // exporting HELPERS below) when there's no `window` (Node/Jest).
686  var hasWindow = (typeof window !== 'undefined');
687
688  if (hasWindow) {
689    (function browserModule() {
690      // ── Fetch ──────────────────────────────────────────────────────────
691      // Reads (`api()`, js/api.js) get the centralised 401→session-expired
692      // handling. Mutations (approve/dismiss) need the raw HTTP status to
693      // tell a 409 ("already moved on") apart from any other failure — `api()`
694      // only special-cases 401 and otherwise just returns the parsed body —
695      // so those go through a small local POST that mirrors sales-pipeline.js's
696      // send() helper (same getToken()/API_BASE globals, same shape).
697      function getJSON(path) {
698        return api('/ads' + path).catch(function () { return null; });
699      }
700      function postAction(path, body) {
701        var base = (typeof API_BASE !== 'undefined') ? API_BASE : '/api';
702        var token = (typeof getToken === 'function') ? getToken() : null;
703        var headers = token ? { 'Authorization': 'Bearer ' + token } : {};
704        var opts = { method: 'POST', headers: headers };
705        if (body !== undefined) {
706          headers['Content-Type'] = 'application/json';
707          opts.body = JSON.stringify(body);
708        }
709        return fetch(base + '/ads' + path, opts).then(function (r) {
710          return r.json().catch(function () { return {}; }).then(function (j) { return { status: r.status, body: j }; });
711        }).catch(function () { return { status: 0, body: {} }; });
712      }
713
714      // ── Data load ──────────────────────────────────────────────────────
715      function rootEl() { return document.getElementById('ads-root'); }
716
717      function load() {
718        var root = rootEl();
719        if (!root) return;
720        consumeReturnFlag();
721        if (!STATE.loadedOnce) root.innerHTML = '<div class="ads-empty-inline">Loading your ad tests…</div>';
722        return Promise.all([getJSON('/tests'), getJSON('/health'), getJSON('/connections')])
723          .then(function (res) {
724            STATE.tests = (res[0] && res[0].tests) || [];
725            STATE.health = res[1] || null;
726            STATE.connections = (res[2] && res[2].connections) || [];
727            STATE.loadedOnce = true;
728            renderRoute();
729          })
730          .catch(function () {
731            root.innerHTML = '<div class="ads-empty-inline">Could not load the Ads dashboard. Refresh to try again.</div>';
732          });
733      }
734
735      function loadDetail(id) {
736        return getJSON('/tests/' + encodeURIComponent(id)).then(function (res) {
737          STATE.detailTest = (res && res.test) || null;
738          renderRoute();
739        });
740      }
741
742      // ── Router (mirrors sales-pipeline.js's go()/syncNav() shape) ───────
743      function go(route, opts) {
744        if (ROUTES.indexOf(route) === -1) route = 'board';
745        STATE.route = route;
746        if (opts && opts.testId != null) STATE.testId = opts.testId;
747        if (route !== 'detail') { STATE.testId = null; STATE.detailTest = null; }
748        if (route === 'detail' && STATE.testId != null) {
749          loadDetail(STATE.testId);
750        } else {
751          if (route === 'facebook') ensureFacebookLoaded();
752          // Kicked off here as well as from the range control's first tab
753          // change, so the screen still loads if analytics-controls.js is not
754          // on the page for any reason.
755          if (route === 'tracking') ensureTrackingLoaded();
756          renderRoute();
757        }
758        syncNav();
759        var page = document.getElementById('page-ads');
760        if (page && page.scrollIntoView) { try { page.scrollIntoView({ block: 'start' }); } catch (e) {} }
761      }
762
763      function syncNav() {
764        try {
765          currentModule = 'ads';
766          currentPage = 'ads-' + STATE.route;
767          if (typeof renderSidebar === 'function' && STATE.route !== _lastNavRoute) {
768            _lastNavRoute = STATE.route;
769            renderSidebar();
770          } else if (typeof renderSubnav === 'function') {
771            renderSubnav();
772          }
773        } catch (e) { /* nav shell not present — module still renders standalone */ }
774      }
775
776      function renderRoute() {
777        var root = rootEl();
778        if (!root) return;
779        if (STATE.route === 'settings') root.innerHTML = renderSettings();
780        else if (STATE.route === 'facebook') root.innerHTML = renderFacebook();
781        else if (STATE.route === 'tracking') root.innerHTML = renderTracking();
782        else if (STATE.route === 'detail') root.innerHTML = renderDetail();
783        else root.innerHTML = renderBoard();
784        wire(root);
785        // The shared range control binds to elements this render just created,
786        // so it is mounted after the markup rather than inside it.
787        if (STATE.route === 'tracking') attachTrackingControls();
788      }
789
790      // ── Shared bits ───────────────────────────────────────────────────
791      function pageHead(crumbHtml, title, sub, actionHtml) {
792        return '<div class="ads-head"><div>' +
793          '<div class="rk-breadcrumbs">' + crumbHtml + '</div>' +
794          '<h1>' + esc(title) + '</h1>' +
795          (sub ? '<div class="ads-sub">' + esc(sub) + '</div>' : '') +
796          '</div>' + (actionHtml || '') + '</div>';
797      }
798
799      function healthItem(label, ok, text) {
800        return '<div class="ads-health-item">' +
801          '<div class="ads-health-label">' + esc(label) + '</div>' +
802          '<div class="ads-health-value ' + (ok ? 'ads-health-ok' : 'ads-health-bad') + '">' +
803          (ok ? '✅' : '❌') + ' ' + esc(text) + '</div></div>';
804      }
805
806      function renderHealthStrip() {
807        var h = STATE.health || {};
808        var conns = STATE.connections || [];
809        var connItems = conns.map(function (c) {
810          return healthItem(platformLabel(c.platform) + ' Ads', !!c.connected, c.connected ? 'Connected' : 'Not connected');
811        }).join('');
812        var trackingOk = !!(h.googleTagConfigured || h.metaPixelConfigured);
813        var trackingItem = healthItem('Tracking config', trackingOk, trackingOk ? 'Present' : 'Not set up');
814        var lastEvents = '<div class="ads-health-item"><div class="ads-health-label">Last events</div>' +
815          '<div class="ads-health-value ads-health-dash">—</div></div>';
816        return '<div class="ads-health">' + connItems + trackingItem + lastEvents + '</div>';
817      }
818
819      // ── Board (Screen 1) ──────────────────────────────────────────────
820      function renderBoard() {
821        var head = pageHead('<span class="sep">Ads</span>', 'Ads', 'Tests your agent has recommended, tests running now, and what you’ve learned.');
822        // Unconnected → a single clear CTA to Settings so a first-timer knows the
823        // first move is connecting (the full walkthrough lives in Settings).
824        var cta = boardConnectCta(googleConnection());
825        var health = renderHealthStrip();
826        if (!STATE.tests.length) {
827          return head + cta + health +
828            '<div class="ads-empty"><div class="ads-empty-icon">📢</div>' +
829            '<h3>No campaigns yet</h3>' +
830            '<p>No campaigns yet — talk to your agent about testing an ad angle for one of your funnels.</p></div>';
831        }
832        var needsApproval = STATE.tests.filter(function (t) { return t.status === 'recommended'; });
833        var running = STATE.tests.filter(function (t) { return t.status === 'running' || t.status === 'paused'; });
834        var concluded = STATE.tests.filter(function (t) { return t.status === 'concluded' || t.status === 'dismissed'; });
835        return head + cta + health +
836          renderApprovalRow(needsApproval) +
837          renderRunningRow(running) +
838          renderConcludedRow(concluded);
839      }
840
841      function renderApprovalRow(list) {
842        if (!list.length) return '';
843        return '<div class="ads-section"><div class="ads-section-head"><h2>Needs your approval</h2></div>' +
844          '<div class="ads-cards">' + list.map(renderApprovalCard).join('') + '</div></div>';
845      }
846      function renderApprovalCard(t) {
847        return '<div class="rk-card">' +
848          '<div class="ads-card-hypothesis">' + esc(t.hypothesis) + '</div>' +
849          '<div class="ads-card-meta">' +
850            '<span>' + esc(platformLabel(t.platform)) + (t.channel ? ' · ' + esc(t.channel) : '') + '</span>' +
851            '<span>' + variantCount(t) + ' variant' + (variantCount(t) === 1 ? '' : 's') + '</span>' +
852          '</div>' +
853          '<div class="ads-card-cap">' + esc(capLine(t)) + '</div>' +
854          '<div class="ads-card-actions">' +
855            '<button type="button" class="rk-btn ads-btn-touch" data-act="review" data-id="' + esc(t.id) + '">Review</button>' +
856          '</div></div>';
857      }
858
859      function renderRunningRow(list) {
860        if (!list.length) return '';
861        return '<div class="ads-section"><div class="ads-section-head"><h2>Running</h2></div>' +
862          '<div class="ads-cards">' + list.map(renderRunningCard).join('') + '</div></div>';
863      }
864      function renderRunningCard(t) {
865        var pct = spendPercent(t.spend_minor, t.budget_cap_minor);
866        var chip = statusChip(t);
867        var days = daysSince(t.approved_at);
868        return '<div class="rk-card" role="button" tabindex="0" data-act="open" data-id="' + esc(t.id) + '">' +
869          '<div class="ads-card-head"><span class="rk-badge ' + chip.cls + '">' + chip.icon + ' ' + esc(chip.text) + '</span></div>' +
870          '<div class="ads-card-hypothesis">' + esc(t.hypothesis) + '</div>' +
871          '<div class="ads-progress-row"><span>' + fmtMoney(t.spend_minor, t.currency) + ' of ' + fmtMoney(t.budget_cap_minor, t.currency) + '</span>' +
872          (days != null ? '<span>' + days + ' day' + (days === 1 ? '' : 's') + ' running</span>' : '') + '</div>' +
873          '<div class="ads-progress"><div class="ads-progress-fill' + (pct >= 100 ? ' ads-progress-full' : '') + '" style="width:' + pct + '%"></div></div>' +
874          '</div>';
875      }
876
877      function renderConcludedRow(list) {
878        if (!list.length) return '';
879        var rows = list.map(function (t) {
880          var chip = statusChip(t);
881          return '<div class="ads-concluded-row" role="button" tabindex="0" data-act="open" data-id="' + esc(t.id) + '">' +
882            '<span class="rk-badge ' + chip.cls + '">' + chip.icon + ' ' + esc(chip.text) + '</span>' +
883            '<span class="ads-concluded-hyp">' + esc(t.hypothesis) + '</span>' +
884            '<span class="ads-concluded-learning">' + esc(t.learnings || 'No learnings recorded.') + '</span>' +
885            '</div>';
886        }).join('');
887        return '<div class="ads-section"><div class="ads-section-head"><h2>Concluded</h2></div>' +
888          '<div class="ads-concluded-list">' + rows + '</div></div>';
889      }
890
891      // ── Detail (Screen 2) ─────────────────────────────────────────────
892      function backCrumb() {
893        return '<a href="#" data-act="back">Ads</a><span class="sep">/</span><span>Test</span>';
894      }
895
896      function renderDetail() {
897        var t = STATE.detailTest;
898        if (!t) return pageHead(backCrumb(), 'Loading…', '') + '<div class="ads-empty-inline">Loading this test…</div>';
899        var head = pageHead(backCrumb(), t.hypothesis, platformLabel(t.platform) + (t.channel ? ' · ' + t.channel : ''));
900        if (t.status === 'recommended') return head + renderRecommendedDetail(t);
901        if (t.status === 'approved') return head + renderApprovedDetail(t);
902        if (t.status === 'running' || t.status === 'paused') return head + renderRunningDetail(t);
903        if (t.status === 'concluded') return head + renderConcludedDetail(t);
904        if (t.status === 'dismissed') return head + '<div class="ads-empty-inline">This recommendation was dismissed.</div>';
905        return head + renderRecommendedDetail(t);
906      }
907
908      function renderAudienceCard(t) {
909        var rows = audienceSpecRows(t.audience_spec);
910        var body = rows.length
911          ? '<dl class="ads-kv">' + rows.map(function (r) { return '<div class="ads-kv-row"><dt>' + esc(r.label) + '</dt><dd>' + esc(r.value) + '</dd></div>'; }).join('') + '</dl>'
912          : '<div class="ads-empty-inline">No audience details recorded yet.</div>';
913        return '<div class="rk-card"><div class="rk-card-title">Audience</div>' + body + '</div>';
914      }
915
916      function renderVariantsGrid(t) {
917        var variants = Array.isArray(t.variants) ? t.variants : [];
918        if (!variants.length) return '<div class="ads-empty-inline">No creative variants recorded yet.</div>';
919        return '<div class="ads-variant-grid">' + variants.map(function (v, i) {
920          var p = variantPreview(v, i);
921          return '<div class="ads-variant-card">' +
922            (p.image ? '<div class="ads-variant-img"><img src="' + esc(p.image) + '" alt=""></div>' : '') +
923            '<div class="ads-variant-body">' +
924              '<div class="ads-variant-label">' + esc(p.label) + '</div>' +
925              '<div class="ads-variant-headline">' + esc(p.headline || '(no headline yet)') + '</div>' +
926              (p.description ? '<div class="ads-variant-desc">' + esc(p.description) + '</div>' : '') +
927            '</div></div>';
928        }).join('') + '</div>';
929      }
930
931      function renderRecommendedDetail(t) {
932        return renderAudienceCard(t) +
933          '<div class="rk-card rk-mt-4"><div class="rk-card-title">Creative variants</div>' + renderVariantsGrid(t) + '</div>' +
934          '<div class="rk-card rk-mt-4"><div class="rk-card-title">What success means</div>' +
935            '<div class="ads-kv"><div class="ads-kv-row"><dt>Primary metric</dt><dd>' + esc(t.primary_metric || '—') + '</dd></div>' +
936            '<div class="ads-kv-row"><dt>Spend cap</dt><dd>' + esc(capLine(t)) + '</dd></div></div></div>' +
937          '<div class="ads-card-actions rk-mt-6">' +
938            '<button type="button" class="rk-btn ads-btn-touch" data-act="approve" data-id="' + esc(t.id) + '">Approve — spend up to ' + fmtMoney(t.budget_cap_minor, t.currency) + '</button>' +
939            '<button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="dismiss" data-id="' + esc(t.id) + '">Dismiss</button>' +
940          '</div>';
941      }
942
943      // The tenant-only "Switch on" card — surfaces POST /tests/:id/activate,
944      // which was previously reachable only by API. Shown ONLY when the test
945      // has a BUILT campaign that is still paused (platform_refs.campaign set,
946      // paused not yet flipped to false by an activation). This is the sole
947      // spend-switch-on control in the UI; the agent can't press it (it's a
948      // tenant route), and it double-confirms before spending the first dollar.
949      function renderActivateCard(t) {
950        var refs = (t && t.platform_refs && typeof t.platform_refs === 'object') ? t.platform_refs : {};
951        if (!refs.campaign || refs.paused === false) return '';
952        return '<div class="rk-card rk-mt-4"><div class="rk-card-title">Ready to go live</div>' +
953          '<p class="rk-mt-2">This campaign is built on ' + esc(platformLabel(t.platform)) + ' and is <strong>paused</strong> — it never spends until you switch it on. When you do, it starts spending up to its daily budget (capped at ' + esc(fmtMoney(t.budget_cap_minor, t.currency)) + ' for this test).</p>' +
954          '<div class="ads-card-actions rk-mt-3">' +
955            '<button type="button" class="rk-btn ads-btn-touch" data-act="activate" data-id="' + esc(t.id) + '"' + (STATE.busy ? ' disabled' : '') + '>Switch on — start spending</button>' +
956          '</div></div>';
957      }
958
959      function renderApprovedDetail(t) {
960        return '<div class="rk-card"><div class="rk-card-title">Approved — starting soon</div>' +
961          '<p>You’ve approved this test. We’ll set it up on ' + esc(platformLabel(t.platform)) + ' and begin spending, capped at ' + esc(fmtMoney(t.budget_cap_minor, t.currency)) + '.</p></div>' +
962          renderActivateCard(t) +
963          renderAudienceCard(t) +
964          '<div class="rk-card rk-mt-4"><div class="rk-card-title">Creative variants</div>' + renderVariantsGrid(t) + '</div>';
965      }
966
967      function renderRunningDetail(t) {
968        var pct = spendPercent(t.spend_minor, t.budget_cap_minor);
969        var chip = statusChip(t);
970        var days = daysSince(t.approved_at);
971        var table = resultsTableData(t.results);
972        return '<div class="rk-card">' +
973          '<div class="ads-card-head"><span class="rk-badge ' + chip.cls + '">' + chip.icon + ' ' + esc(chip.text) + '</span></div>' +
974          '<div class="ads-progress-row"><span><strong>' + fmtMoney(t.spend_minor, t.currency) + '</strong> of ' + fmtMoney(t.budget_cap_minor, t.currency) + '</span>' +
975          (days != null ? '<span>' + days + ' day' + (days === 1 ? '' : 's') + ' running</span>' : '') + '</div>' +
976          '<div class="ads-progress"><div class="ads-progress-fill' + (pct >= 100 ? ' ads-progress-full' : '') + '" style="width:' + pct + '%"></div></div>' +
977          '</div>' +
978          renderActivateCard(t) +
979          '<div class="rk-card rk-mt-4"><div class="rk-card-title">Results</div>' + renderResultsTableHtml(table) + '</div>';
980      }
981
982      function renderResultsTableHtml(table) {
983        if (!table) return '<div class="ads-empty-inline">Collecting data — check back once the test has been running a little while.</div>';
984        var head = '<tr><th>Variant</th>' + table.columns.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr>';
985        var rows = table.rows.map(function (r) {
986          return '<tr><td>' + esc(r.label) + '</td>' + r.cells.map(function (c) { return '<td>' + esc(c) + '</td>'; }).join('') + '</tr>';
987        }).join('');
988        return '<div class="ads-table-wrap"><table class="ads-table">' + head + rows + '</table></div>';
989      }
990
991      function renderConcludedDetail(t) {
992        var chip = statusChip(t);
993        var table = resultsTableData(t.results);
994        return '<div class="rk-card">' +
995          '<div class="ads-card-head"><span class="rk-badge ' + chip.cls + '">' + chip.icon + ' ' + esc(chip.text) + '</span></div>' +
996          '<p>' + esc(t.learnings || 'No learnings recorded.') + '</p></div>' +
997          '<div class="rk-card rk-mt-4"><div class="rk-card-title">Results</div>' + renderResultsTableHtml(table) + '</div>';
998      }
999
1000      // ── Settings (Screen 3) ───────────────────────────────────────────
1001      // ══ THE FACEBOOK SCREEN ════════════════════════════════════════════
1002      //
1003      // Alex, 2026-08-24: *"basic reporting mirroring what is in your Facebook
1004      // ads account… they can see that it's matched between what's in their
1005      // Facebook account and what's there… at least click through, see from
1006      // your campaign to your ad sets to your ads, and see what's actually
1007      // running."*
1008      //
1009      // ⚠️ IT MIRRORS ADS MANAGER ON PURPOSE. Meta's three levels, Meta's own
1010      // words, Meta's own numbers, so a coach can hold the two side by side and
1011      // recognise both. Inventing our own vocabulary here would mean learning
1012      // two systems for one account — the opposite of the rule that a screen
1013      // should ask a coach to talk about THEIR subject, not understand OURS.
1014      //
1015      // ⚠️ AND IT IS READ-ONLY. Nothing is created here (that is the agent's
1016      // job, and a second way to do it is two mechanisms for one job), and
1017      // nothing can be switched ON here — the same line the service and the
1018      // MCP tools hold.
1019
1020      // Minor units → what a person reads. Meta gives budgets in minor units
1021      // and spend in major ones, which is a trap; each is converted at its own
1022      // call site and never by a shared helper that could meet the wrong one.
1023      function fbMoneyMinor(minor, currency) {
1024        if (minor == null) return '—';
1025        var n = Number(minor);
1026        if (!isFinite(n)) return '—';
1027        return fbCur(currency) + (n / 100).toFixed(2);
1028      }
1029      function fbMoneyMajor(major, currency) {
1030        var n = Number(major);
1031        if (!isFinite(n)) return '—';
1032        return fbCur(currency) + n.toFixed(2);
1033      }
1034      function ensureFacebookLoaded() {
1035        if (STATE.fbCampaigns !== null || STATE.fbLoading) return;
1036        loadFbCampaigns();
1037      }
1038
1039      // Fetch (or re-fetch) the campaign level for the current date window, and
1040      // the account health alongside. Used on first open, on Refresh, and when
1041      // the date filter changes. Stamps fetched_at (the "last updated").
1042      function loadFbCampaigns() {
1043        STATE.fbLoading = true;
1044        STATE.fbError = null;
1045        Promise.all([
1046          getJSON('/meta/campaigns' + fbRangeQuery(STATE.fbRange)),
1047          getJSON('/meta/health').catch(function () { return null; }),
1048        ]).then(function (r) {
1049          STATE.fbLoading = false;
1050          var res = r[0];
1051          if (res && res.campaigns) {
1052            STATE.fbCampaigns = res.campaigns;
1053            STATE.fbFetchedAt = res.fetched_at || new Date().toISOString();
1054          } else {
1055            STATE.fbCampaigns = [];
1056            STATE.fbError = (res && res.error) || 'load_failed';
1057          }
1058          STATE.fbHealth = (r[1] && r[1].health) || null;
1059          if (STATE.route === 'facebook') renderRoute();
1060        }).catch(function () {
1061          STATE.fbLoading = false;
1062          STATE.fbCampaigns = [];
1063          STATE.fbError = 'load_failed';
1064          if (STATE.route === 'facebook') renderRoute();
1065        });
1066      }
1067
1068      function loadFbAdSets(id) {
1069        getJSON('/meta/campaigns/' + encodeURIComponent(id) + '/adsets' + fbRangeQuery(STATE.fbRange)).then(function (res) {
1070          STATE.fbAdSets = (res && res.adSets) || [];
1071          if (res && res.fetched_at) STATE.fbFetchedAt = res.fetched_at;
1072          if (STATE.route === 'facebook') renderRoute();
1073        }).catch(function () {
1074          STATE.fbAdSets = [];
1075          if (STATE.route === 'facebook') renderRoute();
1076        });
1077      }
1078
1079      function loadFbAds(id) {
1080        getJSON('/meta/adsets/' + encodeURIComponent(id) + '/ads' + fbRangeQuery(STATE.fbRange)).then(function (res) {
1081          STATE.fbAds = (res && res.ads) || [];
1082          if (res && res.fetched_at) STATE.fbFetchedAt = res.fetched_at;
1083          if (STATE.route === 'facebook') renderRoute();
1084        }).catch(function () {
1085          STATE.fbAds = [];
1086          if (STATE.route === 'facebook') renderRoute();
1087        });
1088      }
1089
1090      // Re-fetch whatever level is on screen — for Refresh, a date change, and
1091      // after a pause so the status pill reflects the new state.
1092      function reloadFbCurrentLevel() {
1093        if (STATE.fbLevel === 'ads' && STATE.fbAdSet) { STATE.fbAds = null; renderRoute(); loadFbAds(STATE.fbAdSet.id); }
1094        else if (STATE.fbLevel === 'adsets' && STATE.fbCampaign) { STATE.fbAdSets = null; renderRoute(); loadFbAdSets(STATE.fbCampaign.id); }
1095        else { loadFbCampaigns(); }
1096      }
1097
1098      // Apply a new date window and reload the level in view.
1099      function changeFbRange(next) {
1100        STATE.fbRange = next;
1101        if (next.since && next.until) STATE.fbCustomOpen = false;
1102        reloadFbCurrentLevel();
1103      }
1104
1105      // Pause — the one tenant-facing mutation (T10). Spend-SAFE: the route is
1106      // pause-only, so this can never switch anything on. After it lands the
1107      // level is reloaded so the status pill tells the truth.
1108      function pauseFbObject(level, id) {
1109        if (STATE.fbPausing || !id) return;
1110        STATE.fbPausing = id;
1111        renderRoute();
1112        var base = level === 'campaign' ? '/meta/campaigns/' : level === 'adset' ? '/meta/adsets/' : '/meta/ads/';
1113        postAction(base + encodeURIComponent(id) + '/pause').then(function (res) {
1114          STATE.fbPausing = null;
1115          if (res && res.status && res.status < 400) {
1116            if (typeof showToast === 'function') showToast('Paused. Switching it back on is done in Ads Manager.');
1117            reloadFbCurrentLevel();
1118          } else {
1119            if (typeof showToast === 'function') showToast('Could not pause that just now. Please try again.', 'error');
1120            renderRoute();
1121          }
1122        });
1123      }
1124
1125      // Errors a coach can act on. ⚠️ An expired token is the single most
1126      // likely failure months from now — the long-lived Meta token lasts about
1127      // 60 days — and "something went wrong" would send someone debugging our
1128      // code instead of clicking Reconnect.
1129      function fbErrorText(code) {
1130        if (code === 'not_connected') return 'Facebook isn’t connected yet. Connect it in Ads settings.';
1131        if (code === 'no_ad_account') return 'No Facebook ad account is chosen yet. Pick one in Ads settings.';
1132        if (code === 'meta_token_expired') return 'Your Facebook connection has expired. Reconnect it in Ads settings.';
1133        if (code === 'meta_api_error' || code === 'meta_network_error') return 'Facebook didn’t answer just now. Try again in a moment.';
1134        return 'Couldn’t load your Facebook campaigns just now.';
1135      }
1136
1137
1138      // ══ The tracking screen (D6) ═══════════════════════════════════════
1139      //
1140      // 🔻 ALEX, 2026-08-24: *"a permanent tracking UI showing what's tracked
1141      // and what events fire … take the design from the community analytics,
1142      // where you can put in the date range and you got the filters, and use
1143      // the same naming conventions that Facebook ads have. It's a direct
1144      // mirroring of their naming conventions so there's no kind of learning
1145      // curve for this."*
1146      //
1147      // So: the SAME range control the community and per-course analytics
1148      // screens use (js/analytics-controls.js — this is its third host), the
1149      // same `?test_traffic=1` contract the funnel analytics uses, and
1150      // Facebook's own event names in the column a coach reads first.
1151
1152      // What each not-sent reason means, in the words of somebody who has not
1153      // read the code. The reasons themselves are stable keys minted by the
1154      // replay (meta-capi-replay NOT_SENT) — the wording belongs here.
1155      function notSentSentence(reason, n) {
1156        var many = String(n);
1157        switch (reason) {
1158          case 'below_scroll_depth': return many + ' didn’t read far enough down the page';
1159          case 'below_dwell': return many + ' stayed less than 15 seconds';
1160          case 'passive_beacon': return many + ' were an early ping rather than a real visit';
1161          case 'unidentifiable': return many + ' had nobody Facebook could match them to';
1162          case 'missing_shared_id': return many + ' arrived without the id that stops it being counted twice';
1163          case 'no_mapping': return 'Facebook has no event that means this';
1164          default: return many + ' — ' + String(reason).replace(/_/g, ' ');
1165        }
1166      }
1167
1168      function trackingQS() {
1169        var r = STATE.trackingRange;
1170        var qs = (r.from && r.to)
1171          ? 'from=' + encodeURIComponent(r.from) + '&to=' + encodeURIComponent(r.to)
1172          : 'window=' + encodeURIComponent(r.win);
1173        if (STATE.trackingTest) qs += '&test_traffic=1';
1174        return qs;
1175      }
1176
1177      function ensureTrackingLoaded() {
1178        if (STATE.trackingEvents === null && !STATE.trackingEventsLoading) {
1179          STATE.trackingEventsLoading = true;
1180          getJSON('/connections/meta/tracking/events?' + trackingQS()).then(function (res) {
1181            STATE.trackingEventsLoading = false;
1182            STATE.trackingEvents = res || { events: [] };
1183            if (STATE.route === 'tracking') renderRoute();
1184          }).catch(function () {
1185            STATE.trackingEventsLoading = false;
1186            STATE.trackingEvents = { events: [] };
1187            if (STATE.route === 'tracking') renderRoute();
1188          });
1189        }
1190        if (STATE.trackingPages === null && !STATE.trackingPagesLoading) {
1191          STATE.trackingPagesLoading = true;
1192          // The page list does NOT take the range — a page is tracked or it is
1193          // not, and dating that question would be inventing a number.
1194          getJSON('/connections/meta/tracking/pages').then(function (res) {
1195            STATE.trackingPagesLoading = false;
1196            STATE.trackingPages = res || { pages: [] };
1197            if (STATE.route === 'tracking') renderRoute();
1198          }).catch(function () {
1199            STATE.trackingPagesLoading = false;
1200            STATE.trackingPages = { pages: [] };
1201            if (STATE.route === 'tracking') renderRoute();
1202          });
1203        }
1204      }
1205
1206      function renderTracking() {
1207        var AC = window.App && window.App.AnalyticsControls;
1208        var head = pageHead(
1209          '<span class="sep">Ads</span><span class="sep">/</span><span>Tracking</span>',
1210          'Tracking',
1211          'What is being tracked, and what Facebook is being told.');
1212        return head +
1213          '<div id="ads-tracking-host">' +
1214            '<div class="ca-tabbar trk-tabs" data-ca-tabbar>' +
1215              '<button type="button" class="trk-tab" data-ca-tab="firing">What’s firing</button>' +
1216              '<button type="button" class="trk-tab" data-ca-tab="pages">What’s tracked</button>' +
1217            '</div>' +
1218            (AC ? AC.rangeRowHtml() : '') +
1219            '<label class="trk-test"><input type="checkbox" data-act="tracking-test"' +
1220              (STATE.trackingTest ? ' checked' : '') + '> Show test traffic instead of real visitors</label>' +
1221            '<div data-ca-panel="firing">' + renderFiringPanel() + '</div>' +
1222            '<div data-ca-panel="pages">' + renderPagesPanel() + '</div>' +
1223          '</div>';
1224      }
1225
1226      function renderFiringPanel() {
1227        if (STATE.trackingEvents === null) return '<div class="ads-empty-inline">Checking…</div>';
1228        var evs = (STATE.trackingEvents && STATE.trackingEvents.events) || [];
1229        var live = evs.filter(function (e) { return e.total > 0; });
1230        if (!live.length) {
1231          return '<div class="rk-card rk-mt-4"><div class="rk-card-title">Nothing recorded in this period</div>' +
1232            '<p class="rk-mt-2">No visits or actions were recorded on your pages in the range you picked. ' +
1233            'If that is a surprise, check the What’s tracked tab — a page with no beacon reports nothing.</p></div>';
1234        }
1235        var trunc = STATE.trackingEvents.delivery_truncated
1236          ? '<p class="ads-ads-ads-rp-note-block">This period holds more events than can be checked one by one, so the ' +
1237            'sent column covers the most recent ' + STATE.trackingEvents.delivery_rows + ' of them.</p>'
1238          : '';
1239        var rows = live.map(firingRow).join('');
1240        // 🚨 THE SILENT ONES ARE A FINDING, NOT AN ABSENCE. The first render of
1241        // this screen filtered every zero-count event out, so a coach whose
1242        // community was recording nothing at all saw a table that looked
1243        // healthy. They are listed — quietly, below the table, so they do not
1244        // pad it — because "your community events are not firing" is the most
1245        // useful thing this screen can tell somebody.
1246        var silent = evs.filter(function (e) { return !e.total; })
1247          .map(function (e) { return esc(e.label); });
1248        var silentBlock = silent.length
1249          ? '<p class="ads-ads-ads-rp-note-block">Nothing recorded in this period: ' + silent.join(' · ') + '.</p>'
1250          : '';
1251        return '<div class="rk-card rk-mt-4">' +
1252          '<div class="rk-card-title">What’s firing</div>' +
1253          '<div class="trk-scroll"><table class="trk-table"><thead><tr>' +
1254            '<th>What happened</th><th>Facebook event</th><th class="trk-num">Times</th>' +
1255            '<th class="trk-num">Sent to Facebook</th><th>Not sent, and why</th>' +
1256          '</tr></thead><tbody>' + rows + '</tbody></table></div>' + trunc + silentBlock + '</div>';
1257      }
1258
1259      function firingRow(e) {
1260        // 🚨 A browser-sent event has NO server count, and showing 0 there
1261        // would read as a failure of something that never had a part to play.
1262        // A 'local' event (form_submit / checkout_started) is not a Facebook
1263        // event at all — its real Meta counterpart is the sibling ads_* row —
1264        // so it must NOT claim to have been "sent by the page", which is the
1265        // exact lie this screen used to tell about a form submit.
1266        var sent;
1267        if (e.sent === null || e.sent === undefined) {
1268          sent = (e.via === 'local')
1269            ? '<span class="trk-muted">recorded here</span>'
1270            : '<span class="trk-muted">sent by the page</span>';
1271        } else {
1272          sent = String(e.sent);
1273        }
1274        var why = (e.not_sent || []).length
1275          ? (e.not_sent || []).map(function (r) { return esc(notSentSentence(r.reason, r.count)); }).join('<br>')
1276          : '<span class="trk-muted">—</span>';
1277        // The raw name is only worth showing when it adds something the label
1278        // does not already say — "capture submitted / capture_submitted" is
1279        // one line of noise repeated.
1280        var slug = e.label.toLowerCase().replace(/[^a-z]+/g, '') === e.event.replace(/[^a-z]+/g, '')
1281          ? '' : '<span class="trk-slug">' + esc(e.event) + '</span>';
1282        return '<tr>' +
1283          '<td data-l="What happened"><span class="trk-label">' + esc(e.label) + '</span>' + slug + '</td>' +
1284          '<td data-l="Facebook event">' + (e.meta_event
1285            ? '<span class="trk-fb">' + esc(e.meta_event) + '</span>' +
1286              // A custom event builds audiences and reports fine, but an ad set
1287              // cannot always optimise for one. Saying so here stops a coach
1288              // picking it as a goal and finding out later.
1289              (e.meta_custom ? '<span class="trk-slug">custom event</span>' : '')
1290            : '<span class="trk-muted">not sent</span>') + '</td>' +
1291          '<td class="trk-num" data-l="Times">' + String(e.total) + '</td>' +
1292          '<td class="trk-num" data-l="Sent to Facebook">' + sent + '</td>' +
1293          '<td class="trk-why" data-l="Not sent, and why">' + why + '</td>' +
1294        '</tr>';
1295      }
1296
1297      /**
1298       * One row per SITE by default, every page behind a toggle.
1299       *
1300       * ⚠️ Measured at 390px on 2026-08-24: the per-page list for a tenant with
1301       * 89 published pages rendered a **20,802px** column on a phone. Every row
1302       * was true and the whole thing was unusable — and the question a coach
1303       * actually arrives with is "is my courses site tracked?", not "tell me
1304       * about page 47". The detail is still one tap away, because when the
1305       * answer is "no" the next question is always "which ones".
1306       */
1307      function pagesBySite(pages) {
1308        var order = [];
1309        var by = {};
1310        pages.forEach(function (p) {
1311          if (!by[p.site_id]) {
1312            by[p.site_id] = { site_id: p.site_id, site_name: p.site_name,
1313              surface_label: p.surface_label, pages: 0, beacon: 0, pixel: 0,
1314              pixel_known: 0, current: 0 };
1315            order.push(p.site_id);
1316          }
1317          var g = by[p.site_id];
1318          g.pages += 1;
1319          if (p.beacon) g.beacon += 1;
1320          if (p.pixel !== null) { g.pixel_known += 1; if (p.pixel) g.pixel += 1; }
1321          if (p.current) g.current += 1;
1322        });
1323        return order.map(function (id) { return by[id]; });
1324      }
1325
1326      function siteSummaryRow(g) {
1327        var of = function (n) { return String(n) + ' of ' + String(g.pages); };
1328        return '<tr>' +
1329          '<td data-l="Site"><span class="trk-label">' + esc(g.site_name) + '</span>' +
1330            '<span class="trk-slug">' + esc(g.surface_label) + '</span></td>' +
1331          '<td class="trk-num" data-l="Pages">' + String(g.pages) + '</td>' +
1332          '<td data-l="Beacon">' + (g.beacon === g.pages
1333            ? '<span class="trk-yes">All</span>' : '<span class="trk-no">' + of(g.beacon) + '</span>') + '</td>' +
1334          '<td data-l="Facebook pixel">' + (!g.pixel_known
1335            ? '<span class="trk-muted">no pixel set</span>'
1336            : g.pixel === g.pages ? '<span class="trk-yes">All</span>'
1337              : '<span class="trk-old">' + of(g.pixel) + '</span>') + '</td>' +
1338          '<td data-l="Tracking">' + (g.current === g.pages
1339            ? '<span class="trk-yes">Current</span>'
1340            : '<span class="trk-old">' + of(g.current) + ' current</span>') + '</td>' +
1341        '</tr>';
1342      }
1343
1344      function renderPagesPanel() {
1345        if (STATE.trackingPages === null) return '<div class="ads-empty-inline">Checking…</div>';
1346        var inv = STATE.trackingPages || {};
1347        var pages = inv.pages || [];
1348        if (!pages.length) {
1349          return '<div class="rk-card rk-mt-4"><div class="rk-card-title">No published pages yet</div>' +
1350            '<p class="rk-mt-2">Tracking goes on every page you publish — funnels, websites, your community and your courses.</p></div>';
1351        }
1352        var head = '<p class="rk-mt-2">' + String(inv.tracked) + ' of ' + String(inv.total) +
1353          ' published pages carry your tracking' +
1354          (inv.current === inv.total ? ', and every one is on the current version.'
1355            : ', and ' + String(inv.total - inv.current) + ' are still on an older version.') + '</p>';
1356        var rows = pages.map(function (p) {
1357          return '<tr>' +
1358            '<td data-l="Page"><span class="trk-label">' + esc(p.page_name) + '</span>' +
1359              '<span class="trk-slug">/' + esc(p.slug) + '</span></td>' +
1360            '<td data-l="Where"><span class="trk-label">' + esc(p.site_name) + '</span>' +
1361              '<span class="trk-slug">' + esc(p.surface_label) + '</span></td>' +
1362            '<td data-l="Beacon">' + yesNo(p.beacon) + '</td>' +
1363            '<td data-l="Facebook pixel">' +
1364              (p.pixel === null ? '<span class="trk-muted">no pixel set</span>' : yesNo(p.pixel)) + '</td>' +
1365            '<td data-l="Tracking">' + (p.current ? '<span class="trk-yes">Current</span>'
1366              : p.stamped ? '<span class="trk-old">Older version</span>'
1367              : '<span class="trk-old">Not yet updated</span>') + '</td>' +
1368          '</tr>';
1369        }).join('');
1370        var trunc = inv.truncated
1371          ? '<p class="ads-ads-ads-rp-note-block">Showing the first ' + String(inv.cap) + ' pages.</p>' : '';
1372        // Measured on dev, 2026-08-24: the community and course-site SPA shells
1373        // carry our beacon but not the Facebook pixel, because the pixel is
1374        // only baked onto pages that have the cookie-consent layer and those
1375        // shells are built by their own regenerators. Left as a bare "No" it
1376        // reads as a fault; said plainly it is a decision a coach can see.
1377        var ours = Number(inv.ours_only || 0);
1378        var oursNote = ours
1379          ? '<p class="ads-ads-ads-rp-note-block">' + String(ours) + ' of these carry our own tracking but not the Facebook ' +
1380            'pixel: your community and course member areas are built as one app rather than as pages, and the ' +
1381            'pixel only goes on pages that ask visitors about cookies. What members do there is still recorded here.</p>'
1382          : '';
1383        // Above the table, not below it: a note explaining a column of "No" is
1384        // no use 89 rows further down than the first one.
1385        var table = STATE.trackingAllPages
1386          ? '<div class="trk-scroll"><table class="trk-table"><thead><tr>' +
1387              '<th>Page</th><th>Where</th><th>Beacon</th><th>Facebook pixel</th><th>Tracking</th>' +
1388            '</tr></thead><tbody>' + rows + '</tbody></table></div>'
1389          : '<div class="trk-scroll"><table class="trk-table"><thead><tr>' +
1390              '<th>Site</th><th>Pages</th><th>Beacon</th><th>Facebook pixel</th><th>Tracking</th>' +
1391            '</tr></thead><tbody>' + pagesBySite(pages).map(siteSummaryRow).join('') + '</tbody></table></div>';
1392        var toggle = '<div class="ads-card-actions rk-mt-4">' +
1393          '<button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="tracking-all-pages">' +
1394          (STATE.trackingAllPages ? 'Group by site' : 'Show every page (' + String(pages.length) + ')') +
1395          '</button></div>';
1396        return '<div class="rk-card rk-mt-4"><div class="rk-card-title">What’s tracked</div>' + head + oursNote +
1397          table + toggle + trunc + '</div>';
1398      }
1399
1400      function yesNo(v) {
1401        return v ? '<span class="trk-yes">Yes</span>' : '<span class="trk-no">No</span>';
1402      }
1403
1404      /**
1405       * Mount the shared range control on this route's host.
1406       *
1407       * ⚠️ Called after EVERY render, because renderRoute() replaces the
1408       * route's markup wholesale and the control hangs its state off the
1409       * element it was attached to. STATE is therefore the memory, and the
1410       * fresh control is hydrated from it — otherwise every reload would snap
1411       * the range back to 30 days while the data on screen was something else.
1412       */
1413      function attachTrackingControls() {
1414        var AC = window.App && window.App.AnalyticsControls;
1415        var host = document.getElementById('ads-tracking-host');
1416        if (!AC || !host) return;
1417        var h = AC.attach(host, {
1418          defaultTab: STATE.trackingTab,
1419          defaultWindow: STATE.trackingRange.win,
1420          onReload: function () {
1421            var hh = AC.get(host);
1422            if (!hh) return;
1423            STATE.trackingTab = hh.state.tab;
1424            var changed = hh.state.win !== STATE.trackingRange.win
1425              || hh.state.from !== STATE.trackingRange.from
1426              || hh.state.to !== STATE.trackingRange.to;
1427            STATE.trackingRange = { win: hh.state.win, from: hh.state.from, to: hh.state.to };
1428            if (changed) STATE.trackingEvents = null;   // the page list has no range
1429            ensureTrackingLoaded();
1430          },
1431        });
1432        if (!h) return;
1433        h.state.win = STATE.trackingRange.win;
1434        h.state.from = STATE.trackingRange.from;
1435        h.state.to = STATE.trackingRange.to;
1436        var custom = !!(STATE.trackingRange.from && STATE.trackingRange.to);
1437        host.querySelectorAll('[data-ca-range] button').forEach(function (b) {
1438          b.classList.toggle('active', !custom && b.getAttribute('data-win') === STATE.trackingRange.win);
1439        });
1440        var f = host.querySelector('[data-ca-from]');
1441        var t = host.querySelector('[data-ca-to]');
1442        if (f) f.value = STATE.trackingRange.from || '';
1443        if (t) t.value = STATE.trackingRange.to || '';
1444        h.syncUi();
1445        h.showTab(STATE.trackingTab);
1446      }
1447
1448      function renderFacebook() {
1449        var crumb = '<span class="sep">Ads</span><span class="sep">/</span><span>Facebook</span>';
1450        var head = pageHead(crumb, 'Facebook',
1451          'What’s running in your Facebook ad account, exactly as Facebook sees it.');
1452
1453        if (STATE.fbLoading && STATE.fbCampaigns === null) {
1454          return head + '<div class="ads-empty-inline">Loading your Facebook campaigns…</div>';
1455        }
1456        if (STATE.fbError) {
1457          return head + '<div class="rk-card"><div class="ads-empty-inline">' + esc(fbErrorText(STATE.fbError)) + '</div>' +
1458            '<div class="ads-card-actions rk-mt-4"><button type="button" class="rk-btn ads-btn-touch" data-act="goto-settings">Go to Ads settings</button></div></div>';
1459        }
1460
1461        var banner = renderFbAccountBanner();
1462        var controls = renderFbControls();
1463        if (STATE.fbLevel === 'ads') return head + banner + controls + renderFbAds();
1464        if (STATE.fbLevel === 'adsets') return head + banner + controls + renderFbAdSets();
1465        return head + banner + controls + renderFbCampaigns();
1466      }
1467
1468      // ── The date filter + refresh + last-updated (T6/T7). Ads Manager's own
1469      // windows so a coach compares like with like; a custom range behind a
1470      // "Custom…" toggle; and when the numbers were last pulled, with a button
1471      // to pull them again (they come from Meta's Graph, so it is a fetch). ──
1472      function renderFbControls() {
1473        var r = STATE.fbRange;
1474        var custom = !!(r.since && r.until);
1475        var chips = FB_DATE_PRESETS.map(function (p) {
1476          var on = !custom && (r.preset || 'last_7d') === p.key;
1477          return '<button type="button" class="fb-range-chip ads-btn-touch' + (on ? ' active' : '') +
1478            '" data-act="fb-range" data-preset="' + esc(p.key) + '">' + esc(p.label) + '</button>';
1479        }).join('');
1480        var customChip = '<button type="button" class="fb-range-chip ads-btn-touch' + (custom ? ' active' : '') +
1481          '" data-act="fb-range-custom">Custom…</button>';
1482        var customRow = (custom || STATE.fbCustomOpen)
1483          ? '<div class="fb-custom-row">' +
1484              '<input type="date" data-fb-since value="' + esc(r.since || '') + '" aria-label="From date">' +
1485              '<span class="fb-custom-sep">→</span>' +
1486              '<input type="date" data-fb-until value="' + esc(r.until || '') + '" aria-label="To date">' +
1487              '<button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="fb-range-apply">Apply</button>' +
1488            '</div>'
1489          : '';
1490        var updated = STATE.fbFetchedAt
1491          ? '<span class="fb-updated">Updated ' + esc(fbAgo(STATE.fbFetchedAt, Date.now())) + '</span>'
1492          : '<span class="fb-updated"></span>';
1493        return '<div class="rk-card fb-controls">' +
1494          '<div class="fb-range-row">' + chips + customChip + '</div>' +
1495          customRow +
1496          '<div class="fb-controls-foot">' + updated +
1497            '<button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="fb-refresh">&#8635; Refresh</button>' +
1498          '</div></div>';
1499      }
1500
1501      // One row's numbers (T9/T11), correct units throughout — money in major
1502      // units with the account currency, counts as counts, CTR as a percent.
1503      function renderFbMetrics(perf) {
1504        var m = fbMetricsData(perf, STATE.fbHealth && STATE.fbHealth.currency);
1505        if (!m) return '<div class="fb-metrics fb-metrics-empty">No spend recorded in this window.</div>';
1506        var cell = function (label, val, strong) {
1507          return '<div class="fb-metric' + (strong ? ' fb-metric-strong' : '') + '">' +
1508            '<span class="fb-metric-v">' + esc(val) + '</span>' +
1509            '<span class="fb-metric-l">' + esc(label) + '</span></div>';
1510        };
1511        return '<div class="fb-metrics">' +
1512          cell('Spent', m.spend, true) + cell('Results', m.results, true) + cell('Cost/result', m.costPerResult, true) +
1513          cell('Impressions', m.impressions) + cell('Reach', m.reach) + cell('Clicks', m.clicks) +
1514          cell('CPC', m.cpc) + cell('CTR', m.ctr) + '</div>';
1515      }
1516
1517      // The inline Pause (T10). Offered ONLY when the object is actually running
1518      // — there is nothing to pause on something already off — and it can never
1519      // switch anything on (the route is pause-only, spend-safe).
1520      function fbPauseBtn(level, id, effective, fallback) {
1521        var st = fbStatus(effective, fallback);
1522        if (st.cls !== 'fb-on') return '';
1523        var busy = STATE.fbPausing === id;
1524        return '<button type="button" class="rk-btn rk-btn-secondary fb-pause ads-btn-touch" ' +
1525          'data-act="fb-pause" data-level="' + esc(level) + '" data-id="' + esc(id) + '"' +
1526          (busy ? ' disabled' : '') + '>' + (busy ? 'Pausing&hellip;' : '&#9208; Pause') + '</button>';
1527      }
1528
1529      // The account line, and the ONE thing that stops ads dead regardless of
1530      // how correct the campaigns are: a disabled account or no payment method.
1531      // Facebook accepts campaigns happily in that state and never runs them.
1532      function renderFbAccountBanner() {
1533        var h = STATE.fbHealth;
1534        if (!h) return '';
1535        var problems = [];
1536        if (h.active === false) problems.push('This ad account is not active, so nothing will run.');
1537        if (h.hasFunding === false) problems.push('No payment method is attached, so nothing will run.');
1538        var acct = '<div class="fb-acct"><span class="fb-acct-name">' + esc(h.adAccountId ? ('Account ' + h.adAccountId) : 'Facebook account') + '</span>' +
1539          (h.currency ? '<span class="fb-acct-meta">' + esc(h.currency) + (h.timezone ? ' · ' + esc(h.timezone) : '') + '</span>' : '') +
1540          '<span class="fb-acct-meta">Spent ' + fbMoneyMajor(h.amountSpent, h.currency) + '</span></div>';
1541        if (!problems.length) return '<div class="rk-card fb-banner">' + acct + '</div>';
1542        return '<div class="rk-card fb-banner fb-banner-bad">' + acct +
1543          '<div class="fb-problems">' + problems.map(function (p) { return '<div>⚠️ ' + esc(p) + '</div>'; }).join('') + '</div></div>';
1544      }
1545
1546      function renderFbCampaigns() {
1547        var rows = STATE.fbCampaigns || [];
1548        if (!rows.length) {
1549          // ⚠️ This is the FIRST thing Alex sees, not an edge case — his
1550          // account has no campaigns yet. So it says what happens next in his
1551          // words rather than stating a fact about emptiness.
1552          return '<div class="ads-empty"><div class="ads-empty-icon">📘</div>' +
1553            '<h3>No Facebook campaigns yet</h3>' +
1554            '<p>Ask your agent to build one and it will appear here — with whether it’s on or off, and what it’s doing.</p></div>';
1555        }
1556        var body = rows.map(function (c) {
1557          var p = c.performance || null;
1558          return '<div class="fb-rowwrap">' +
1559            '<button type="button" class="fb-row" data-act="fb-open-campaign" data-id="' + esc(c.campaignId) + '" data-name="' + esc(c.name || '') + '">' +
1560              '<div class="fb-row-main"><span class="fb-row-name">' + esc(c.name || 'Untitled campaign') + '</span>' +
1561              fbPill(c.effectiveStatus, c.status) + '</div>' +
1562              '<div class="fb-row-sub">' +
1563                '<span>' + esc(fbObjective(c.objective)) + '</span>' +
1564                '<span>' + fbMoneyMinor(c.dailyBudgetMinor, null) + (c.dailyBudgetMinor != null ? '/day' : '') + '</span>' +
1565              '</div><span class="fb-row-go">›</span></button>' +
1566            renderFbMetrics(p) +
1567            '<div class="fb-row-actions">' + fbPauseBtn('campaign', c.campaignId, c.effectiveStatus, c.status) + '</div>' +
1568          '</div>';
1569        }).join('');
1570        return '<div class="rk-card"><div class="rk-card-title">Campaigns</div>' +
1571          '<p class="fb-note">' + esc(fbRangeLabel(STATE.fbRange)) + ' &middot; the same windows Facebook shows.</p>' +
1572          '<div class="fb-list">' + body + '</div></div>';
1573      }
1574
1575      function renderFbAdSets() {
1576        var back = '<button type="button" class="fb-back" data-act="fb-back-campaigns">‹ All campaigns</button>';
1577        var title = (STATE.fbCampaign && STATE.fbCampaign.name) || 'Campaign';
1578        var rows = STATE.fbAdSets;
1579        if (rows === null) return '<div class="rk-card">' + back + '<div class="ads-empty-inline">Loading ad sets…</div></div>';
1580        if (!rows.length) {
1581          return '<div class="rk-card">' + back + '<div class="rk-card-title">' + esc(title) + '</div>' +
1582            '<div class="ads-empty-inline">This campaign has no ad sets yet.</div></div>';
1583        }
1584        var body = rows.map(function (a) {
1585          // Learning-limited is the state where an ad set is spending and NOT
1586          // learning. It is invisible in Ads Manager's default columns and it
1587          // is the commonest quiet waste, so it is surfaced here as a warning.
1588          //
1589          // ⚠️ It sits OUTSIDE .fb-row-meta, after the chevron, and that is
1590          // load-bearing rather than fussy. Inside the meta column it fought
1591          // the right-aligned desktop layout: it forced its own line within a
1592          // content-width block, which made the row two lines tall and left
1593          // the name floating at the vertical centre against it. As a direct
1594          // child of the wrapping row it gets a full-width line of its own,
1595          // left-aligned under the name — the same shape the ads level already
1596          // uses for a rejected ad.
1597          var warn = a.learningLimited
1598            ? '<div class="fb-warn">⚠️ Not enough conversions to learn from</div>'
1599            : '';
1600          return '<div class="fb-rowwrap">' +
1601            '<button type="button" class="fb-row" data-act="fb-open-adset" data-id="' + esc(a.adSetId) + '" data-name="' + esc(a.name || '') + '">' +
1602              '<div class="fb-row-main"><span class="fb-row-name">' + esc(a.name || 'Untitled ad set') + '</span>' +
1603              fbPill(a.effectiveStatus, a.status) + '</div>' +
1604              '<div class="fb-row-sub">' +
1605                '<span>' + esc(a.conversionEvent ? fbEvent(a.conversionEvent) : fbGoal(a.optimizationGoal)) + '</span>' +
1606                '<span>' + fbMoneyMinor(a.dailyBudgetMinor, null) + (a.dailyBudgetMinor != null ? '/day' : '') + '</span>' +
1607                // '' when the ad set IS learning-limited — that c
1607onstant maps to
1608                // nothing, so the warning below is never doubled by a label.
1609                fbLearning(a.learningStage) +
1610              '</div><span class="fb-row-go">›</span></button>' +
1611            renderFbMetrics(a.performance || null) +
1612            warn +
1613            '<div class="fb-row-actions">' + fbPauseBtn('adset', a.adSetId, a.effectiveStatus, a.status) + '</div>' +
1614          '</div>';
1615        }).join('');
1616        return '<div class="rk-card">' + back + '<div class="rk-card-title">' + esc(title) + '</div>' +
1617          '<div class="fb-list">' + body + '</div></div>';
1618      }
1619
1620      function renderFbAds() {
1621        var back = '<button type="button" class="fb-back" data-act="fb-back-adsets">‹ ' + esc((STATE.fbCampaign && STATE.fbCampaign.name) || 'Campaign') + '</button>';
1622        var title = (STATE.fbAdSet && STATE.fbAdSet.name) || 'Ad set';
1623        var rows = STATE.fbAds;
1624        if (rows === null) return '<div class="rk-card">' + back + '<div class="ads-empty-inline">Loading ads…</div></div>';
1625        if (!rows.length) {
1626          return '<div class="rk-card">' + back + '<div class="rk-card-title">' + esc(title) + '</div>' +
1627            '<div class="ads-empty-inline">This ad set has no ads yet.</div></div>';
1628        }
1629        var body = rows.map(function (a) {
1630          // 🚨 A rejected ad looks completely normal in every other list. This
1631          // is the one place it says so, which is why it gets its own line
1632          // rather than living inside the status pill alone.
1633          var flag = a.disapproved
1634            ? '<div class="fb-warn">⚠️ Facebook rejected this ad, so it will not run. Check it in Ads Manager.</div>'
1635            : '';
1636          return '<div class="fb-rowwrap">' +
1637            '<div class="fb-row fb-row-leaf">' +
1638              '<div class="fb-row-main"><span class="fb-row-name">' + esc(a.name || 'Untitled ad') + '</span>' +
1639              fbPill(a.effectiveStatus, a.status) + '</div>' + flag + '</div>' +
1640            renderFbMetrics(a.performance || null) +
1641            '<div class="fb-row-actions">' + fbPauseBtn('ad', a.id || a.adId, a.effectiveStatus, a.status) + '</div>' +
1642          '</div>';
1643        }).join('');
1644        return '<div class="rk-card">' + back + '<div class="rk-card-title">' + esc(title) + '</div>' +
1645          '<div class="fb-list">' + body + '</div></div>';
1646      }
1647
1648      function openFbCampaign(id, name) {
1649        STATE.fbCampaign = { id: id, name: name };
1650        STATE.fbLevel = 'adsets';
1651        STATE.fbAdSets = null;
1652        renderRoute();
1653        loadFbAdSets(id);
1654      }
1655
1656      function openFbAdSet(id, name) {
1657        STATE.fbAdSet = { id: id, name: name };
1658        STATE.fbLevel = 'ads';
1659        STATE.fbAds = null;
1660        renderRoute();
1661        loadFbAds(id);
1662      }
1663
1664      function renderSettings() {
1665        var head = pageHead('<span class="sep">Ads</span><span class="sep">/</span><span>Settings</span>', 'Ads settings', 'Connections and tracking status for your ad accounts.');
1666        // First-timer onboarding guide at the very top — full walkthrough when
1667        // unconnected, a short "pick your account" nudge when connected but no
1668        // account is chosen, and nothing once fully set up. Pure: connectGuide()
1669        // decides which (or none) from the Google connection row.
1670        return head + renderConnectGuide() + renderConnectionsCard()
1671          + renderAccountPickerCard() + renderMetaAccountPickerCard()
1672          + renderProvisionedBanner() + renderTagStatusCard()
1673          + renderRepublishCard() + renderFacebookTrackingCard() + renderAgentControlsCard() + renderCapCard();
1674      }
1675
1676      // Thin wrapper over the pure connectGuide() helper — reads the current
1677      // Google connection so the guide/nudge/hidden state stays in sync with STATE.
1678      function renderConnectGuide() {
1679        return connectGuide(googleConnection());
1680      }
1681
1682      // ── Agent Controls card ─────────────────────────────────────────────
1683      // The human-set autonomy kill-switch + budget ceilings (GET/POST
1684      // /api/ads/agent-controls). Lazy-loads on first settings render (same
1685      // fire-and-forget + re-render pattern as ensureGoogleAccountsLoaded).
1686      function ensureAgentControlsLoaded() {
1687        if (STATE.agentControls !== null || STATE.agentControlsLoading) return;
1688        STATE.agentControlsLoading = true;
1689        getJSON('/agent-controls').then(function (res) {
1690          STATE.agentControlsLoading = false;
1691          STATE.agentControls = (res && typeof res === 'object')
1692            ? res
1693            : { autonomy: false, max_daily_budget_minor: 0, max_total_spend_minor: 0 };
1694          if (STATE.route === 'settings') renderRoute();
1695        });
1696      }
1697
1698      // minor units (int) → a major-unit string for a currency <input>. 0 shows
1699      // as an empty field so the placeholder reads as "not set".
1700      function minorToInput(minor) {
1701        var n = Number(minor) || 0;
1702        if (n <= 0) return '';
1703        return String(n / 100);
1704      }
1705
1706      // A major-unit input string → non-negative minor units. '' → 0 (unset).
1707      // Returns null for an invalid (negative / non-numeric) entry.
1708      function majorToMinor(v) {
1709        if (v == null || String(v).trim() === '') return 0;
1710        var n = Number(v);
1711        if (!Number.isFinite(n) || n < 0) return null;
1712        return Math.round(n * 100);
1713      }
1714
1715      function renderAgentControlsCard() {
1716        ensureAgentControlsLoaded();
1717        var c = STATE.agentControls;
1718        if (!c) {
1719          return '<div class="rk-card rk-mt-4"><div class="rk-card-title">Agent Controls</div>' +
1720            '<div class="ads-empty-inline">Loading…</div></div>';
1721        }
1722        var on = !!c.autonomy;
1723        var help = 'font-size:13px;color:var(--rk-slate,#64748b);margin-top:4px';
1724        return '<div class="rk-card rk-mt-4"><div class="rk-card-title">Agent Controls</div>' +
1725          '<p class="rk-mt-2">Decide how much your agent can do on its own. Only two things ever raise your spend — a budget increase, or switching a campaign on — and each has its own switch below. Everything else the agent does only changes how a fixed budget is spent.</p>' +
1726          // Budget-autonomy toggle
1727          '<label class="rk-mt-3" style="display:flex;align-items:center;gap:10px;cursor:pointer">' +
1728            '<input type="checkbox" data-act="agent-autonomy"' + (on ? ' checked' : '') + '>' +
1729            '<span><strong>Let the agent adjust budgets on its own</strong></span>' +
1730          '</label>' +
1731          '<p style="' + help + '">' + (on
1732            ? 'ON — your agent can raise or lower budgets by itself, but only up to the ceilings below. Anything higher still comes to you.'
1733            : 'OFF (safe default) — your agent can always LOWER a budget, but it will ALWAYS ask you before raising one.') + '</p>' +
1734          /* ⚠️ THE ACTIVATION TOGGLE IS GONE, NOT HIDDEN. Alex, 2026-08-29:
1735             *"The only thing that they shouldn't be able to do is activate the
1736             ads … I think we should make it so that they can't."*
1737
1738             Switching spend on is now a wall on both platforms rather than a
1739             gate on one (google-ads-manage-service's enforceActivation refuses
1740             unconditionally; Meta always did). Leaving a checkbox here that no
1741             longer changes anything would be worse than removing it — a control
1742             that lies about what it controls is how somebody ends up believing
1743             their agent is allowed to spend. The sentence below states the rule
1744             instead, because a coach reading this page needs to know where the
1745             line is, not just that a switch vanished. */
1746          '<p style="' + help + '"><strong>Switching a campaign on is always yours.</strong> ' +
1747            'Your agent can build campaigns, pause anything, lower a budget and change targeting — ' +
1748            'but spending your first dollar on a campaign is a decision only you can make, on Facebook and Google alike.</p>' +
1749          // Max daily
1750          '<div class="rk-mt-3"><label><strong>Max daily budget per campaign</strong>' +
1751            '<div style="display:flex;align-items:center;gap:6px;margin-top:4px">' +
1752              '<span>$</span><input type="number" min="0" step="0.01" class="rk-input" data-act="agent-max-daily" placeholder="Not set" value="' + esc(minorToInput(c.max_daily_budget_minor)) + '" style="max-width:180px">' +
1753            '</div></label>' +
1754            '<p style="' + help + '">The agent can raise budgets on its own up to this much per day, per campaign. Above this, it asks you.</p>' +
1755          '</div>' +
1756          // Max total
1757          '<div class="rk-mt-3"><label><strong>Max total across agent-managed campaigns</strong>' +
1758            '<div style="display:flex;align-items:center;gap:6px;margin-top:4px">' +
1759              '<span>$</span><input type="number" min="0" step="0.01" class="rk-input" data-act="agent-max-total" placeholder="Not set" value="' + esc(minorToInput(c.max_total_spend_minor)) + '" style="max-width:180px">' +
1760            '</div></label>' +
1761            '<p style="' + help + '">The most combined daily budget the agent can commit across every campaign it manages. Above this, it asks you.</p>' +
1762          '</div>' +
1763          '<div class="ads-card-actions rk-mt-4"><button type="button" class="rk-btn ads-btn-touch" data-act="agent-save"' + (STATE.agentSaving ? ' disabled' : '') + '>
1763' + (STATE.agentSaving ? 'Saving…' : 'Save agent controls') + '</button></div>' +
1764          '<p style="' + help + '">With autonomy off, your agent always asks before any increase. Lowering a budget is always allowed. These limits apply only to this business.</p>' +
1765          '</div>';
1766      }
1767
1768      function renderConnectionsCard() {
1769        var conns = STATE.connections || [];
1770        var rows = conns.length ? conns.map(function (c) {
1771          // Meta (Facebook) — a real connect flow since 2026-08-23; this row
1772          // used to be a disabled "coming soon" button.
1773          if (c.platform === 'meta') {
1774            var mMeta = c.connected
1775              ? '<div style="width:100%;font-size:var(--rk-text-sm,13px);color:var(--rk-slate)">' +
1776                  esc(c.ad_account_id ? ('Ad account ' + c.ad_account_id) : 'Account connected, no ad account chosen yet') +
1777                  (c.connected_at ? ' · connected ' + esc(((window.App && window.App.Dates) ? window.App.Dates.short(c.connected_at) : new Date(c.connected_at).toDateString())) : '') +
1778                '</div>'
1779              : '';
1780            var mBtn = c.connected
1781              ? '<button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="disconnect-meta">Disconnect</button>'
1782              : '<button type="button" class="rk-btn ads-btn-touch" data-act="connect-meta">Connect</button>';
1783            return '<div class="ads-conn-row"><div class="ads-conn-main">' +
1784              '<span class="ads-conn-name">Facebook Ads</span>' +
1785              '<span class="rk-badge">' + (c.connected ? '✅ Connected' : '⬜ Not connected') + '</span>' +
1786              mMeta + '</div>' + mBtn + '</div>';
1787          }
1788          if (c.platform !== 'google') {
1789            return '<div class="ads-conn-row"><div class="ads-conn-main">' +
1790              '<span class="ads-conn-name">' + esc(platformLabel(c.platform)) + ' Ads</span>' +
1791              '<span class="rk-badge">⬜ Not connected</span>' +
1792              '</div>' +
1793              '<button type="button" class="rk-btn rk-btn-secondary" disabled title="Coming soon">Connect</button></div>';
1794          }
1795          var meta = c.connected
1796            ? '<div style="width:100%;font-size:var(--rk-text-sm,13px);color:var(--rk-slate)">' +
1797                esc(c.ad_account_id ? fmtAcct(c.ad_account_id, c.platform) : 'Account connected') +
1798                (c.connected_at ? ' · connected ' + esc(((window.App && window.App.Dates) ? window.App.Dates.short(c.connected_at) : new Date(c.connected_at).toDateString())) : '') +
1799              '</div>'
1800            : '';
1801          var btn = c.connected
1802            ? '<button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="disconnect-google">Disconnect</button>'
1803            : '<button type="button" class="rk-btn ads-btn-touch" data-act="connect-google">Connect</button>';
1804          return '<div class="ads-conn-row"><div class="ads-conn-main">' +
1805            '<span class="ads-conn-name">Google Ads</span>' +
1806            '<span class="rk-badge ' + (c.connected ? 'rk-badge-success' : '') + '">' + (c.connected ? '✅ Connected' : '⬜ Not connected') + '</span>' +
1807            meta +
1808            '</div>' + btn + '</div>';
1809        }).join('') : '<div class="ads-empty-inline">No ad platforms configured yet.</div>';
1810        return '<div class="rk-card"><div class="rk-card-title">Ad accounts</div><div class="ads-conn-list">' + rows + '</div></div>';
1811      }
1812
1813      // Config presence across BOTH platforms — is a tag/pixel id stored at
1814      // all. Distinct from renderFacebookTrackingCard below, which answers
1815      // the different question "is it actually firing?".
1816      function renderTagStatusCard() {
1817        var h = STATE.health || {};
1818        return '<div class="rk-card rk-mt-4"><div class="rk-card-title">Tracking setup</div>' +
1819          '<div class="ads-kv">' +
1820          '<div class="ads-kv-row"><dt>Google tag</dt><dd>' + (h.googleTagConfigured ? '✅ Present' : '⬜ Not set up') + '</dd></div>' +
1821          '<div class="ads-kv-row"><dt>Google conversion labels</dt><dd>' + (h.googleConversionLabelsConfigured ? '✅ Present' : '⬜ Not set up') + '</dd></div>' +
1822          '<div class="ads-kv-row"><dt>Meta pixel</dt><dd>' + (h.metaPixelConfigured ? '✅ Present' : '⬜ Not set up') + '</dd></div>' +
1823          '</div></div>';
1824      }
1825
1826      function renderCapCard() {
1827        return '<div class="rk-card rk-mt-4"><div class="rk-card-title">Spend cap</div>' +
1828          '<p class="rk-mt-2">New tests are recommended with a default cap of <strong>$200</strong> — the most your agent will spend to get a signal before asking you to approve more. Ask your agent to raise it for a specific test.</p></div>';
1829      }
1830
1831      // ── Google account picker ("Connect = Provision" step 2) ────────────
1832      // Renders once the tenant is connected but hasn't picked an account
1833      // yet (STATE.connections has no ad_account_id for google). Kicks off
1834      // the accounts fetch itself (fire-and-forget, re-renders when it
1835      // resolves) — same lazy-load-on-render pattern as loadDetail/go().
1836      function googleConnection() {
1837        return (STATE.connections || []).filter(function (c) { return c.platform === 'google'; })[0] || null;
1838      }
1839
1840      function ensureGoogleAccountsLoaded() {
1841        var g = googleConnection();
1842        if (!g || !g.connected || g.ad_account_id) return;
1843        if (STATE.googleAccounts !== null || STATE.googleAccountsLoading) return;
1844        STATE.googleAccountsLoading = true;
1845        getJSON('/oauth/google/accounts').then(function (res) {
1846          STATE.googleAccountsLoading = false;
1847          if (res && Array.isArray(res.accounts)) {
1848            STATE.googleAccounts = res.accounts;
1849            STATE.googleAccountsError = null;
1850          } else {
1851            STATE.googleAccounts = [];
1852            STATE.googleAccountsError = (res && res.error) || 'load_failed';
1853          }
1854          if (STATE.route === 'settings') renderRoute();
1855        });
1856      }
1857
1858      function friendlyAccountsError(code) {
1859        if (code === 'dev_token_missing' || code === 'mcc_id_missing') {
1860          return 'Account setup isn’t switched on here yet — check back shortly.';
1861        }
1862        return 'Could not load your Google Ads accounts. Please try again.';
1863      }
1864
1865      function renderAccountPickerCard() {
1866        var g = googleConnection();
1867        if (!g || !g.connected || g.ad_account_id) return '';
1868        ensureGoogleAccountsLoaded();
1869        var body;
1870        if (STATE.googleAccountsLoading || STATE.googleAccounts === null) {
1871          body = '<div class="ads-empty-inline">Loading your Google Ads accounts…</div>';
1872        } else if (STATE.googleAccountsError) {
1873          body = '<div class="ads-empty-inline">' + esc(friendlyAccountsError(STATE.googleAccountsError)) + '</div>';
1874        } else if (!STATE.googleAccounts.length) {
1875          body = '<div class="ads-empty-inline">No accessible Google Ads accounts found for this login.</div>';
1876        } else {
1877          var rows = STATE.googleAccounts.map(function (a) {
1878            var name = a.descriptiveName || ('Account ' + fmtAcct(a.id, 'google'));
1879            var detail = fmtAcct(a.id, 'google') + (a.currencyCode ? ' · ' + a.currencyCode : '');
1880            var checked = STATE.selectedGoogleAccountId === a.id ? ' checked' : '';
1881            return '<label class="ads-acct-row"><input type="radio" name="ads-google-account" value="' + esc(a.id) + '"' + checked + '>' +
1882              '<span>' + esc(name) + ' <span class="ads-acct-id">(' + esc(detail) + ')</span></span></label>';
1883          }).join('');
1884          body = '<div class="ads-acct-list">' + rows + '</div>' +
1885            '<div class="ads-card-actions rk-mt-4"><button type="button" class="rk-btn ads-btn-touch" data-act="use-google-account"' +
1886            (STATE.selectedGoogleAccountId ? '' : ' disabled') + '>Use this account</button></div>';
1887        }
1888        return '<div class="rk-card rk-mt-4"><div class="rk-card-title">Choose your Google Ads account</div>' +
1889          '<p class="rk-mt-2">We’ll set up conversion tracking automatically — no pixel IDs or tags to copy.</p>' + body + '</div>';
1890      }
1891
1892      // ── Meta: connect → pick an account → verify ──────────────────────
1893
1894      function metaConnection() {
1895        return (STATE.connections || []).filter(function (c) { return c.platform === 'meta'; })[0] || null;
1896      }
1897
1898      function startMetaConnect() {
1899        if (STATE.busy) return;
1900        STATE.busy = true;
1901        getJSON('/oauth/meta/start').then(function (res) {
1902          STATE.busy = false;
1903          if (res && res.url) { window.location.href = res.url; return; }
1904          if (typeof showToast === 'function') showToast('Could not start the Facebook connection. Please try again.', 'error');
1905        });
1906      }
1907
1908      function ensureMetaAccountsLoaded() {
1909        var m = metaConnection();
1910        if (!m || !m.connected || m.ad_account_id) return;
1911        if (STATE.metaAccounts !== null || STATE.metaAccountsLoading) return;
1912        STATE.metaAccountsLoading = true;
1913        getJSON('/oauth/meta/accounts').then(function (res) {
1914          STATE.metaAccountsLoading = false;
1915          if (res && Array.isArray(res.accounts)) {
1916            STATE.metaAccounts = res.accounts;
1917            STATE.metaAccountsError = null;
1918          } else {
1919            STATE.metaAccounts = [];
1920            STATE.metaAccountsError = (res && res.error) || 'load_failed';
1921          }
1922          if (STATE.route === 'settings') renderRoute();
1923        });
1924      }
1925
1926      function friendlyMetaError(code) {
1927        if (code === 'oauth_client_id_missing' || code === 'oauth_client_secret_missing') {
1928          return 'Facebook isn’t switched on here yet — check back shortly.';
1929        }
1930        if (code === 'meta_token_expired' || code === 'meta_token_unreadable') {
1931          return 'Your Facebook connection has expired. Disconnect and connect again.';
1932        }
1933        return 'Could not load your Facebook ad accounts. Please try again.';
1934      }
1935
1936      function renderMetaAccountPickerCard() {
1937        var m = metaConnection();
1938        if (!m || !m.connected || m.ad_account_id) return '';
1939        ensureMetaAccountsLoaded();
1940        var body;
1941        if (STATE.metaAccountsLoading || STATE.metaAccounts === null) {
1942          body = '<div class="ads-empty-inline">Loading your Facebook ad accounts…</div>';
1943        } else if (STATE.metaAccountsError) {
1944          body = '<div class="ads-empty-inline">' + esc(friendlyMetaError(STATE.metaAccountsError)) + '</div>';
1945        } else if (!STATE.metaAccounts.length) {
1946          body = '<div class="ads-empty-inline">No Facebook ad accounts found for this login.</div>';
1947        } else {
1948          var rows = STATE.metaAccounts.map(function (a) {
1949            var name = a.name || ('Account ' + a.accountId);
1950            var bits = [a.accountId];
1951            if (a.currency) bits.push(a.currency);
1952            // A brand-new account is often not yet active. Saying so beats a
1953            // silent failure later when Meta refuses to run anything.
1954            if (a.status && a.status !== 'active') bits.push('status: ' + a.status);
1955            var checked = STATE.selectedMetaAccountId === a.accountId ? ' 
1955checked' : '';
1956            return '<label class="ads-acct-row"><input type="radio" name="ads-meta-account" value="' + esc(a.accountId) + '"' + checked + '>' +
1957              '<span>' + esc(name) + ' <span class="ads-acct-id">(' + esc(bits.join(' · ')) + ')</span></span></label>';
1958          }).join('');
1959          body = '<div class="ads-acct-list">' + rows + '</div>' +
1960            '<div class="ads-card-actions rk-mt-4"><button type="button" class="rk-btn ads-btn-touch" data-act="use-meta-account"' +
1961            (STATE.selectedMetaAccountId ? '' : ' disabled') + '>Use this account</button></div>';
1962        }
1963        return '<div class="rk-card rk-mt-4"><div class="rk-card-title">Choose your Facebook ad account</div>' +
1964          '<p class="rk-mt-2">We’ll find or create your pixel and set up tracking automatically — nothing to copy and paste.</p>' + body + '</div>';
1965      }
1966
1967      function selectMetaAccount(accountId) {
1968        if (STATE.busy || !accountId) return;
1969        STATE.busy = true;
1970        postAction('/connections/meta/account', { ad_account_id: accountId }).then(function (res) {
1971          STATE.busy = false;
1972          if (res.status === 409) {
1973            if (typeof showToast === 'function') showToast('That account is no longer available — refresh and try again.', 'error');
1974            STATE.metaAccounts = null;
1975            renderRoute();
1976            return;
1977          }
1978          if (!res.status || res.status >= 400) {
1979            if (typeof showToast === 'function') showToast('Could not set up tracking for this account. Please try again.', 'error');
1980            return;
1981          }
1982          STATE.metaProvisionResult = res.body;
1983          STATE.metaAccounts = null;
1984          STATE.selectedMetaAccountId = null;
1985          STATE.metaEvents = null;    // re-read the verification screen
1986          if (typeof showToast === 'function') showToast('Facebook connected — pixel and tracking set up automatically.');
1987          load();
1988        });
1989      }
1990
1991      // ── The verification screen (Alex, 2026-08-23): "so that I can check
1992      // that everything is firing into Facebook into the right Facebook ads
1993      // account". Shows OUR side per event, and says plainly which half
1994      // delivers it, because the events that are happening and NOT reaching
1995      // Meta are the whole point of looking.
1996      function ensureMetaEventsLoaded() {
1997        if (STATE.metaEvents !== null || STATE.metaEventsLoading) return;
1998        STATE.metaEventsLoading = true;
1999        getJSON('/connections/meta/events?days=7').then(function (res) {
2000          STATE.metaEventsLoading = false;
2001          STATE.metaEvents = res || { events: [], tracking: {} };
2002          if (STATE.route === 'settings') renderRoute();
2003        });
2004      }
2005
2006      function viaLabel(via) {
2007        if (via === 'server') return 'From our server';
2008        if (via === 'browser') return 'From the page';
2009        if (via === 'local') return 'Recorded here';
2010        return 'Both, matched';
2011      }
2012
2013      // ── The staleness banner + the one-button republish (D5) ────────────
2014      //
2015      // 🔻 ALEX, 2026-08-24: *"You've now updated this. We need to re-publish
2016      // this across your funnels, you're this, you're that … They have one
2017      // button which then does it for them. The UX needs to show, as each
2018      // one's being done, like a tick, tick, tick."*
2019      function ensureStalenessLoaded() {
2020        if (STATE.staleness !== null || STATE.stalenessLoading) return;
2021        STATE.stalenessLoading = true;
2022        getJSON('/connections/meta/staleness').then(function (res) {
2023          STATE.stalenessLoading = false;
2024          STATE.staleness = res || { pages_needing_republish: 0, by_surface: [] };
2025          if (STATE.route === 'settings') renderRoute();
2026        }).catch(function () {
2027          STATE.stalenessLoading = false;
2028          STATE.staleness = { pages_needing_republish: 0, by_surface: [] };
2029        });
2030        // A job already RUNNING (a reload mid-run) must show as running, not
2031        // as an idle button.
2032        //
2033        // 🚨 Only a running one. `GET /republish` returns the latest job
2034        // whatever its state, and adopting a finished one replaces the banner
2035        // with yesterday's checklist — measured on dev 2026-08-24, where a job
2036        // that had ended `partial` hid the "Update all" button while 36 pages
2037        // were still on the old tracking, with no way back to it. The finished
2038        // checklist belongs to the page view that watched the job run.
2039        getJSON('/connections/meta/republish').then(function (res) {
2040          if (res && res.job && res.job.status === 'running') {
2041            STATE.republish = res;
2042            _pollRepublish();
2043            if (STATE.route === 'settings') renderRoute();
2044          }
2045        }).catch(function () {});
2046      }
2047
2048      function _pollRepublish() {
2049        if (STATE.republishTimer) return;
2050        STATE.republishTimer = setInterval(function () {
2051          var j = STATE.republish && STATE.republish.job;
2052          if (!j) { clearInterval(STATE.republishTimer); STATE.republishTimer = null; return; }
2053          getJSON('/connections/meta/republish/' + j.id).then(function (res) {
2054            if (!res) return;
2055            STATE.republish = res;
2056            if (res.job.status !== 'running') {
2057              clearInterval(STATE.republishTimer); STATE.republishTimer = null;
2058              // The banner's numbers are stale the moment the job finishes.
2059              STATE.staleness = null; STATE.stalenessLoading = false;
2060              ensureStalenessLoaded();
2061            }
2062            if (STATE.route === 'settings') renderRoute();
2063          }).catch(function () {});
2064        }, 3000);
2065      }
2066
2067      // 🚨 `postAction` resolves { status, body } — NOT the parsed body. Reading
2068      // res.job_id off the envelope made the button a no-op: the POST went out,
2069      // came back 200 with a real job id, and the screen did nothing at all.
2070      // Measured by clicking it on dev, 2026-08-24 — nothing in the source
2071      // reads wrong, which is why this needed a click rather than a review.
2072      function startRepublish() {
2073        STATE.republishStarting = true;
2074        STATE.republishError = null;
2075        renderRoute();
2076        postAction('/connections/meta/republish').then(function (res) {
2077          STATE.republishStarting = false;
2078          var body = (res && res.body) || {};
2079          if (!res || !res.status || res.status >= 400) {
2080            STATE.republishError = body.message || 'Could not start the update. Please try again.';
2081          } else if (body.job_id) {
2082            STATE.republish = {
2083              job: { id: body.job_id, status: 'running' },
2084              items: [], done: 0, failed: 0, partial: 0, total: body.items,
2085            };
2086            _pollRepublish();
2087          } else {
2088            // started:false — nothing was stale after all. Re-read so the
2089            // banner clears instead of sitting there with a dead button.
2090            STATE.staleness = null; STATE.stalenessLoading = false;
2091            ensureStalenessLoaded();
2092          }
2093          renderRoute();
2094        }).catch(function () {
2095          STATE.republishStarting = false;
2096          STATE.republishError = 'Could not start the update. Please try again.';
2097          renderRoute();
2098        });
2099      }
2100
2101      // One row per site, ticking as it lands. A failure and a half-finish
2102      // both SAY so — a site quietly missing from a count is the whole reason
2103      // this list exists rather than a spinner.
2104      function republishItemRow(it) {
2105        var mark = it.status === 'done' ? '<span class="ads-ads-rp-tick">✓</span>'
2106          : it.status === 'partial' ? '<span class="ads-ads-rp-part">◑</span>'
2107          : it.status === 'failed' ? '<span class="ads-ads-rp-fail">✕</span>'
2108          : it.status === 'running' ? '<span class="ads-ads-rp-run">•</span>'
2109          : '<span class="ads-ads-rp-wait">○</span>';
2110        return '<div class="ads-ads-rp-row"><span class="ads-ads-rp-mark">' + mark + '</span>' +
2111          '<span class="ads-ads-rp-name">' + esc(it.site_name || 'Untitled') + '</span>' +
2112          '<span class="ads-ads-rp-type">' + esc(surfaceLabel(it.site_type)) + '</span>' +
2113          (it.error ? '<span class="ads-ads-rp-note">' + esc(it.error) + '</span>' : '') +
2114          '</div>';
2115      }
2116
2117      function surfaceLabel(t) {
2118        return t === 'funnel' ? 'Funnel' : t === 'website' ? 'Website'
2119          : t === 'community' ? 'Community' : t === 'course_site' ? 'Courses' : String(t || '');
2120      }
2121
2122      function renderRepublishCard() {
2123        ensureStalenessLoaded();
2124        var st = STATE.staleness;
2125        var job = STATE.republish && STATE.republish.job;
2126        var running = job && job.status === 'running';
2127
2128        // A run this page view watched keeps its checklist on screen after it
2129        // ends — otherwise a clean run ticks through and then the whole card
2130        // vanishes, which reads as "did that work?" rather than "done".
2131        var haveResult = !!(job && STATE.republish.items && STATE.republish.items.length);
2132        if (!running && !haveResult && (!st || !st.pages_needing_republish)) return '';
2133
2134        var head = '<div class="rk-card rk-mt-4 ads-rp-card">';
2135        // A start that failed says so above the button. Silence here is how a
2136        // coach presses it three times and concludes the feature is broken.
2137        var startErr = STATE.republishError
2138          ? '<p class="ads-ads-rp-error">' + esc(STATE.republishError) + '</p>' : '';
2139        if (running || haveResult) {
2140          var r = STATE.republish;
2141          var title = running
2142            ? 'Updating your pages… ' + (r.done + r.failed + (r.partial || 0)) + ' of ' + r.total
2143            : 'Finished — ' + r.done + ' updated' +
2144              ((r.partial || 0) ? ', ' + r.partial + ' partly done' : '') +
2145              (r.failed ? ', ' + r.failed + ' had a problem' : '');
2146          // A finished run that left pages behind keeps the button, because
2147          // "2 partly done" with nothing to press is a dead end.
2148          var again = (!running && st && st.pages_needing_republish)
2149            ? '<p class="ads-ads-ads-rp-note-block">' + st.pages_needing_republish + ' page' +
2150                (st.pages_needing_republish === 1 ? ' is' : 's are') +
2151                ' still on the old version.</p>' + startErr +
2152              '<div class="ads-card-actions rk-mt-4">' +
2153                '<button type="button" class="rk-btn rk-btn-primary ads-btn-touch" data-act="republish-all"' +
2154                (STATE.republishStarting ? ' disabled' : '') + '>
2154' +
2155                (STATE.republishStarting ? 'Starting…' : 'Try again') + '</button></div>'
2156            : '';
2157          return head + '<div class="rk-card-title">' + esc(title) + '</div>' +
2158            '<div class="ads-ads-rp-list">' + (r.items || []).map(republishItemRow).join('') + '</div>' +
2159            again + '</div>';
2160        }
2161
2162        // The banner, in Alex's own shape: the count, then the surfaces.
2163        var groups = (st.by_surface || []).map(function (g) {
2164          return esc(g.label) + ' (' + g.sites + ')';
2165        }).join(' · ');
2166        var selfHealing = (st.by_surface || []).filter(function (g) { return g.self_heals; })
2167          .map(function (g) { return g.label.toLowerCase(); });
2168        return head +
2169          '<div class="rk-card-title">Your tracking changed</div>' +
2170          '<p class="rk-mt-2">' + st.pages_needing_republish + ' page' +
2171            (st.pages_needing_republish === 1 ? '' : 's') +
2172            ' ' + (st.pages_needing_republish === 1 ? 'is' : 'are') +
2173            ' still running the old version.</p>' +
2174          (groups ? '<p class="ads-ads-rp-groups">' + groups + '</p>' : '') +
2175          (selfHealing.length
2176            ? '<p class="ads-ads-ads-rp-note-block">Your ' + esc(selfHealing.join(' and ')) +
2177              ' also refresh on their own after any update — this just does it now.</p>'
2178            : '') + startErr +
2179          '<div class="ads-card-actions rk-mt-4">' +
2180            '<button type="button" class="rk-btn rk-btn-primary ads-btn-touch" data-act="republish-all"' +
2181            (STATE.republishStarting ? ' disabled' : '') + '>' +
2182            (STATE.republishStarting ? 'Starting…' : 'Update all') + '</button></div></div>';
2183      }
2184
2185      function renderFacebookTrackingCard() {
2186        ensureMetaEventsLoaded();
2187        var head = '<div class="rk-card rk-mt-4"><div class="rk-card-title">Is Facebook tracking working?</div>';
2188        if (STATE.metaEventsLoading || STATE.metaEvents === null) {
2189          return head + '<div class="ads-empty-inline">Checking…</div></div>';
2190        }
2191        var t = (STATE.metaEvents && STATE.metaEvents.tracking) || {};
2192        var evs = (STATE.metaEvents && STATE.metaEvents.events) || [];
2193
2194        // The setup line first — a table of events means nothing if the
2195        // pixel is not connected to anything.
2196        var setup;
2197        if (!t.connected) {
2198          setup = '<p class="rk-mt-2">No Facebook ad account is connected yet, so nothing is being sent. Connect one above and we set the rest up.</p>';
2199        } else if (!t.ready) {
2200          setup = '<p class="rk-mt-2">Connected to ad account <b>' + esc(t.adAccountId || '—') + '</b>, but tracking isn’t finished: ' +
2201            (t.pixelId ? '' : 'no pixel yet. ') + (t.capiConfigured ? '' : 'server-side tracking isn’t switched on. ') +
2202            'Choosing your ad account again will finish the setup.</p>';
2203        } else {
2204          setup = '<p class="rk-mt-2">Sending to pixel <b>' + esc(t.pixelId) + '</b> on ad account <b>' + esc(t.adAccountId) + '</b>.' +
2205            (t.capiTokenSource === 'connection'
2206              // Honest about the expiry, because a silently-lapsed token
2207              // looks exactly like "Facebook stopped working".
2208              ? ' Server-side tracking uses your Facebook connection, so it needs reconnecting about every two months.'
2209              : ' Server-side tracking uses your own access token.') +
2210            '</p>';
2211        }
2212
2213        var anyEvents = evs.some(function (e) { return e.total > 0; });
2214        var body;
2215        if (!anyEvents) {
2216          body = '<div class="ads-empty-inline">Nothing tracked in the last 7 days yet. Visits to your funnels show up here.</div>';
2217        } else {
2218          var rows = evs.filter(function (e) { return e.total > 0; }).map(function (e) {
2219            return '<tr>' +
2220              '<td>' + esc(e.metaEvent || humanizeKey(e.event)) + '</td>' +
2221              '<td>' + esc(viaLabel(e.via)) + '</td>' +
2222              '<td style="text-align:right">' + (Number(e.total) || 0) + '</td>' +
2223              '<td style="text-align:right">' + (Number(e.identified) || 0) + '</td>' +
2224              '<td style="text-align:right">' + (Number(e.fromFacebookClick) || 0) + '</td>' +
2225              '</tr>';
2226          }).join('');
2227          body = '<div style="overflow-x:auto"><table class="rk-table rk-mt-3" style="width:100%;min-width:520px">' +
2228            '<thead><tr><th style="text-align:left">What happened</th><th style="text-align:left">Sent by</th>' +
2229            '<th style="text-align:right">Times</th><th style="text-align:right">We know who</th>' +
2230            '<th style="text-align:right">From a Facebook ad</th></tr></thead>' +
2231            '<tbody>' + rows + '</tbody></table></div>' +
2232            '<p class="rk-mt-2" style="font-size:var(--rk-text-sm,13px);color:var(--rk-slate)">' +
2233            '“We know who” counts the people we can name. “From a Facebook ad” counts visits that arrived on a Facebook ad click — ' +
2234            'that is what lets Facebook credit the right ad.</p>';
2235        }
2236        return head + setup + body + renderTestEventsBlock(t) +
2237          '<div class="ads-card-actions rk-mt-4"><button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="refresh-tracking">Check again</button></div>' +
2238          '</div>';
2239      }
2240
2241      // ── Fire test events and watch them land in Facebook ───────────────
2242      // The proof that tracking works end to end, without waiting for real
2243      // traffic. Only shown once there IS a pixel — offering it before then
2244      // is offering a button that cannot work.
2245      function renderTestEventsBlock(t) {
2246        if (!t || !t.pixelId || !t.capiConfigured) return '';
2247        var url = 'https://business.facebook.com/events_manager2/list/dataset/' +
2248          encodeURIComponent(t.pixelId) + '/test_events';
2249        var res = STATE.metaTestResult;
2250        var out = '';
2251        if (res && res.ok) {
2252          out = '<div class="ads-empty-inline rk-mt-3" style="text-align:left">' +
2253            '✅ Sent ' + (Number(res.sent) || 0) + ' test events. They show up in the tab above within about 30 seconds. ' +
2254            'They never enter your real reporting.</div>';
2255        } else if (res && res.error) {
2256          out = '<div class="ads-empty-inline rk-mt-3" style="text-align:left">' + esc(res.error) + '</div>';
2257        }
2258        return '<div class="rk-mt-4" style="border-top:1px solid var(--rk-gray-light,#e5e7eb);padding-top:16px">' +
2259          '<div style="font-weight:600;margin-bottom:6px">Send test events to check it end to end</div>' +
2260          '<p style="font-size:var(--rk-text-sm,13px);color:var(--rk-slate)">' +
2261            '1. Open <a href="' + url + '" target="_blank" rel="noopener">your Test Events tab in Facebook</a>. ' +
2262            '2. Copy the test code it shows you. 3. Paste it here and send.' +
2263          '</p>' +
2264          '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:8px">' +
2265            '<input id="fb-test-code" type="text" placeholder="TEST12345" ' +
2266              'style="padding:10px 12px;border:1px solid var(--rk-gray-light,#e5e7eb);border-radius:8px;font-size:15px;min-width:160px" ' +
2267              'value="' + esc(STATE.metaTestCode || '') + '">' +
2268            '<button type="button" class="rk-btn ads-btn-touch" data-act="send-test-events"' +
2269              (STATE.metaTestSending ? ' disabled' : '') + '>
2269' +
2270              (STATE.metaTestSending ? 'Sending…' : 'Send test events') + '</button>' +
2271          '</div>' + out +
2272        '</div>';
2273      }
2274
2275      function sendTestEvents() {
2276        var input = document.getElementById('fb-test-code');
2277        var code = (input && input.value || '').trim();
2278        STATE.metaTestCode = code;
2279        if (!code) {
2280          STATE.metaTestResult = { error: 'Paste the test code from Facebook first — without it these would count as real events.' };
2281          renderRoute();
2282          return;
2283        }
2284        STATE.metaTestSending = true;
2285        STATE.metaTestResult = null;
2286        renderRoute();
2287        postAction('/connections/meta/test-events', { test_event_code: code }).then(function (res) {
2288          STATE.metaTestSending = false;
2289          if (!res.status || res.status >= 400) {
2290            STATE.metaTestResult = { error: (res.body && res.body.message) || 'Could not send the test events. Please try again.' };
2291          } else {
2292            STATE.metaTestResult = { ok: true, sent: res.body && res.body.sent };
2293          }
2294          renderRoute();
2295        });
2296      }
2297
2298      function selectGoogleAccount(accountId) {
2299        if (STATE.busy || !accountId) return;
2300        STATE.busy = true;
2301        postAction('/connections/google/account', { ad_account_id: accountId }).then(function (res) {
2302          STATE.busy = false;
2303          if (res.status === 503) {
2304            if (typeof showToast === 'function') showToast(friendlyAccountsError(res.body && res.body.error), 'error');
2305            return;
2306          }
2307          if (res.status === 409) {
2308            if (typeof showToast === 'function') showToast('That account is no longer accessible — refresh and try again.', 'error');
2309            STATE.googleAccounts = null; // force a fresh list next render
2310            renderRoute();
2311            return;
2312          }
2313          if (!res.status || res.status >= 400) {
2314            if (typeof showToast === 'function') showToast('Could not set up tracking for this account. Please try again.', 'error');
2315            return;
2316          }
2317          STATE.provisionResult = res.body;
2318          STATE.googleAccounts = null;
2319          STATE.selectedGoogleAccountId = null;
2320          if (typeof showToast === 'function') showToast('Google Ads connected — tracking configured automatically.');
2321          load();
2322        });
2323      }
2324
2325      // "Tracking configured automatically ✓" (spec §"Connect = Provision")
2326      // — rendered straight from the provisioning response, since GET
2327      // /connections/health never return config VALUES, only presence.
2328      function renderProvisionedBanner() {
2329        var r = STATE.provisionResult;
2330        if (!r) return '';
2331        var labels = r.labels || {};
2332        var kinds = [];
2333        if (labels.purchase) kinds.push('Purchase');
2334        if (labels.lead) kinds.push('Lead');
2335        var convText = kinds.length ? kinds.join(' + ') + ' conversions' : 'conversions';
2336        return '<div class="rk-card rk-mt-4 ads-provisioned-card">' +
2337          '<div class="ads-provisioned-head">✅ Tracking configured automatically</div>' +
2338          '<p>Tag <strong>' + esc(r.tagId || '') + '</strong> — ' + esc(convText) + ' — is live on this Google Ads account. ' +
2339          'Your pages pick up the new tag automatically the next time you publish.</p>' +
2340          '<div class="ads-card-actions"><button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="dismiss-provisioned">Got it</button></div>' +
2341          '</div>';
2342      }
2343
2344      // ── Confirm dialog (scoped — see ads-tests.css comment) ─────────────
2345      function showConfirm(opts) {
2346        var overlay = document.getElementById('ads-confirm-overlay');
2347        if (!overlay) {
2348          overlay = document.createElement('div');
2349          overlay.id = 'ads-confirm-overlay';
2350          document.body.appendChild(overlay);
2351        }
2352        overlay.className = 'ads-confirm-overlay';
2353        overlay.innerHTML = '<div class="ads-confirm-modal">' +
2354          '<h3>' + esc(opts.title) + '</h3>' +
2355          '<p>' + esc(opts.message) + '</p>' +
2356          '<div class="ads-confirm-actions">' +
2357            '<button type="button" class="rk-btn rk-btn-secondary ads-btn-touch" data-act="confirm-cancel">Cancel</button>' +
2358            '<button type="button" class="rk-btn ads-btn-touch" data-act="confirm-ok">' + esc(opts.confirmLabel) + '</button>' +
2359          '</div></div>';
2360        overlay.style.display = 'flex';
2361        overlay.querySelector('[data-act="confirm-cancel"]').onclick = function () { overlay.style.display = 'none'; };
2362        overlay.onclick = function (e) { if (e.target === overlay) overlay.style.display = 'none'; };
2363        overlay.querySelector('[data-act="confirm-ok"]').onclick = function () {
2364          overlay.style.display = 'none';
2365          opts.onConfirm();
2366        };
2367      }
2368
2369      // ── Actions ───────────────────────────────────────────────────────
2370      function findTest(id) {
2371        var sid = String(id);
2372        if (STATE.detailTest && String(STATE.detailTest.id) === sid) return STATE.detailTest;
2373        return (STATE.tests || []).filter(function (t) { return String(t.id) === sid; })[0] || null;
2374      }
2375
2376      function reviewTest(id) {
2377        window.showAdsRoute('detail', id);
2378      }
2379
2380      function confirmApprove(id) {
2381        var t = findTest(id);
2382        if (!t) return;
2383        var cap = fmtMoney(t.budget_cap_minor, t.currency);
2384        showConfirm({
2385          title: 'Approve this test?',
2386          message: 'This gives permission to spend up to ' + cap + ' on ' + platformLabel(t.platform) + '. We’ll set it up now and it stays paused on the platform until it’s ready to go — spend never exceeds the cap.',
2387          confirmLabel: 'Approve — spend up to ' + cap,
2388          onConfirm: function () { runAction(id, 'approve'); },
2389        });
2390      }
2391
2392      function confirmDismiss(id) {
2393        var t = findTest(id);
2394        if (!t) return;
2395        showConfirm({
2396          title: 'Dismiss this recommendation?',
2397          message: 'Your agent won’t run this test. It stays on record so future recommendations can learn from it. You can ask for a new test any time.',
2398          confirmLabel: 'Dismiss',
2399          onConfirm: function () { runAction(id, 'dismiss'); },
2400        });
2401      }
2402
2403      function runAction(id, action) {
2404        if (STATE.busy) return;
2405        STATE.busy = true;
2406        postAction('/tests/' + encodeURIComponent(id) + '/' + action).then(function (res) {
2407          STATE.busy = false;
2408          if (res.status === 409) {
2409            if (typeof showToast === 'function') showToast('This test has already moved on — refresh.', 'error');
2410            return;
2411          }
2412          if (!res.status || res.status >= 400) {
2413            if (typeof showToast === 'function') showToast('Couldn’t ' + action + ' this test. Please try again.', 'error');
2414            return;
2415          }
2416          if (typeof showToast === 'function') showToast(action === 'approve' ? 'Test approved.' : 'Test dismissed.');
2417          STATE.detailTest = (res.body && res.body.test) || STATE.detailTest;
2418          if (STATE.route === 'detail') renderRoute();
2419          load();
2420        });
2421      }
2422
2423      // ── Google Ads connect / disconnect (Settings screen) ───────────────
2424      // Mirrors settings-payments.js's startConnect()/disconnect() shape.
2425      function startGoogleConnect() {
2426        if (STATE.busy) return;
2427        STATE.busy = true;
2428        getJSON('/oauth/google/start').then(function (res) {
2429          STATE.busy = false;
2430          if (res && res.url) { window.location.href = res.url; return; }
2431          if (typeof showToast === 'function') showToast('Could not start the Google connection. Please try again.', 'error');
2432        });
2433      }
2434
2435      // ── Switch-on (activate) — the tenant's own spend-on click ──────────
2436      function confirmActivate(id) {
2437        showConfirm({
2438          title: 'Switch this campaign on?',
2439          message: 'It will go live on Google Ads and start spending up to its daily budget. You can pause it again at any time.',
2440          confirmLabel: 'Switch on',
2441          onConfirm: function () { runActivate(id); },
2442        });
2443      }
2444
2445      function runActivate(id) {
2446        if (STATE.busy) return;
2447        STATE.busy = true;
2448        renderRoute();
2449        postAction('/tests/' + encodeURIComponent(id) + '/activate', { confirm: true }).then(function (res) {
2450          STATE.busy = false;
2451          if (!res.status || res.status >= 400) {
2452            var msg = (res.body && (res.body.message || res.body.error)) || 'Could not switch the campaign on. Please try again.';
2453            if (typeof showToast === 'function') showToast(msg, 'error');
2454            renderRoute();
2455            return;
2456          }
2457          if (typeof showToast === 'function') showToast('Campaign is live — it will start spending up to its daily budget.');
2458          // Re-fetch the test so platform_refs.paused reflects the activation
2459          // and the Switch-on card disappears.
2460          reviewTest(id);
2461          load();
2462        });
2463      }
2464
2465      function confirmDisconnectGoogle() {
2466        showConfirm({
2467          title: 'Disconnect Google Ads?',
2468          message: 'Your agent won’t be able to run or read Google Ads tests until you reconnect.',
2469          confirmLabel: 'Disconnect',
2470          onConfirm: runDisconnectGoogle,
2471        });
2472      }
2473
2474      // Names what it costs, per the design system's confirmation rule — a
2475      // bare "Are you sure?" is a speed bump, not a confirmation.
2476      function confirmDisconnectMeta() {
2477        showConfirm({
2478          title: 'Disconnect Facebook Ads?',
2479          message: 'Tracking stops going to Facebook and your agent can’t run or read Facebook ads until you reconnect. Your pixel and its history stay on your Facebook account.',
2480          confirmLabel: 'Disconnect',
2481          onConfirm: runDisconnectMeta,
2482        });
2483      }
2484
2485      function runDisconnectMeta() {
2486        if (STATE.busy) return;
2487        STATE.busy = true;
2488        postAction('/connections/meta/disconnect').then(function (res) {
2489          STATE.busy = false;
2490          if (!res.status || res.status >= 400) {
2491            if (typeof showToast === 'function') showToast('Could not disconnect Facebook. Please try again.', 'error');
2492            return;
2493          }
2494          STATE.metaAccounts = null;
2495          STATE.selectedMetaAccountId = null;
2496          STATE.metaProvisionResult = null;
2497          STATE.metaEvents = null;
2498          if (typeof showToast === 'function') showToast('Facebook Ads disconnected.');
2499          load();
2500        });
2501      }
2502
2503      function runDisconnectGoogle() {
2504        if (STATE.busy) return;
2505        STATE.busy = true;
2506        postAction('/connections/google/disconnect').then(function (res) {
2507          STATE.busy = false;
2508          if (!res.status || res.status >= 400) {
2509            if (typeof showToast === 'function') showToast('Could not disconnect Google Ads. Please try again.', 'error');
2510            return;
2511          }
2512          if (typeof showToast === 'function') showToast('Google Ads disconnected.');
2513          STATE.googleAccounts = null;
2514          STATE.googleAccountsError = null;
2515          STATE.selectedGoogleAccountId = null;
2516          STATE.provisionResult = null;
2517          load();
2518        });
2519      }
2520
2521      // ── Agent Controls save (Settings screen) ───────────────────────────
2522      function saveAgentControls() {
2523        if (STATE.agentSaving) return;
2524        var root = rootEl();
2525        if (!root) return;
2526        var toggle = root.querySelector('[data-act="agent-autonomy"]');
2527        // The activation toggle is gone (see renderAgentControlsCard) — activation
2528        // is a wall now, not a setting. Nothing to read.
2529        var dailyEl = root.querySelector('[data-act="agent-max-daily"]');
2530        var totalEl = root.querySelector('[data-act="agent-max-total"]');
2531        var autonomy = !!(toggle && toggle.checked);
2532        var daily = majorToMinor(dailyEl && dailyEl.value);
2533        var total = majorToMinor(totalEl && totalEl.value);
2534        if (daily === null || total === null) {
2535          if (typeof showToast === 'function') showToast('Enter valid, non-negative amounts for both ceilings.', 'error');
2536          return;
2537        }
2538        STATE.agentSaving = true;
2539        renderRoute();
2540        postAction('/agent-controls', { autonomy: autonomy, max_daily_budget_minor: daily, max_total_spend_minor: total }).then(function (res) {
2541          STATE.agentSaving = false;
2542          if (res.status === 400) {
2543            if (typeof showToast === 'function') showToast((res.body && res.body.message) || 'Please check the amounts and try again.', 'error');
2544            renderRoute();
2545            return;
2546          }
2547          if (!res.status || res.status >= 400) {
2548            if (typeof showToast === 'function') showToast('Could not save your agent controls. Please try again.', 'error');
2549            renderRoute();
2550            return;
2551          }
2552          STATE.agentControls = res.body || STATE.agentControls;
2553          if (typeof showToast === 'function') showToast('Agent controls saved.');
2554          renderRoute();
2555        });
2556      }
2557
2558      // Surface the OAuth-callback return flag (?connected=google /
2559      // ?connect_error=1, set by src/api/ads-routes.js's oauthCallbackRouter)
2560      // as a toast, then strip it from the URL so a refresh doesn't re-fire
2561      // it. Mirrors settings-payments.js's consumeReturnFlag, but query
2562      // -string based (this route's own brief) rather than a hash fragment.
2563      function consumeReturnFlag() {
2564        var params = new URLSearchParams(window.location.search);
2565        var connected = params.get('connected');
2566        var connectError = params.get('connect_error');
2567        if (!connected && !connectError) return;
2568        params.delete('connected');
2569        params.delete('connect_error');
2570        var search = params.toString();
2571        var newUrl = window.location.pathname + (search ? '?' + search : '') + (window.location.hash || '');
2572        try { history.replaceState(null, '', newUrl); } catch (_) {}
2573        if (connected === 'google') {
2574          STATE.googleAccounts = null; // force a fresh accounts fetch for the picker
2575          if (typeof showToast === 'function') showToast('Google Ads connected — choose your ad account below.');
2576        } else if (connectError) {
2577          if (typeof showToast === 'function') showToast('Google Ads connection failed — please try again.', 'error');
2578        }
2579      }
2580
2581      // ── Event delegation ─────────────────────────────────────────────
2582      function wire(root) {
2583        root.querySelectorAll('[data-act]').forEach(function (el) {
2584          if (el.__adsWired) return;
2585          el.__adsWired = true;
2586          var act = el.getAttribute('data-act');
2587          var id = el.getAttribute('data-id');
2588          if (act === 'review' || act === 'open') el.addEventListener('click', function () { reviewTest(id); });
2589          else if (act === 'approve') el.addEventListener('click', function () { confirmApprove(id); });
2590          else if (act === 'dismiss') el.addEventListener('click', function () { confirmDismiss(id); });
2591          else if (act === 'activate') el.addEventListener('click', function () { confirmActivate(id); });
2592          else if (act === 'back') el.addEventListener('click', function (e) { e.preventDefault(); window.showAdsRoute('board'); });
2593          else if (act === 'connect-google') el.addEventListener('click', function () { startGoogleConnect(); });
2594          else if (act === 'disconnect-google') el.addEventListener('click', function () { confirmDisconnectGoogle(); });
2595          else if (act === 'use-google-account') el.addEventListener('click', function () { selectGoogleAccount(STATE.selectedGoogleAccountId); });
2596          else if (act === 'connect-meta') el.addEventListener('click', function () { startMetaConnect(); });
2597          else if (act === 'disconnect-meta') el.addEventListener('click', function () { confirmDisconnectMeta(); });
2598          else if (act === 'use-meta-account') el.addEventListener('click', function () { selectMetaAccount(STATE.selectedMetaAccountId); });
2599          else if (act === 'refresh-tracking') el.addEventListener('click', function () { STATE.metaEvents = null; renderRoute(); });
2600          else if (act === 'send-test-events') el.addEventListener('click', function () { sendTestEvents(); });
2601          else if (act === 'dismiss-provisioned') el.addEventListener('click', function () { STATE.provisionResult = null; renderRoute(); });
2602          else if (act === 'agent-save') el.addEventListener('click', function () { saveAgentControls(); });
2603          else if (act === 'goto-settings') el.addEventListener('click', function () { window.showAdsRoute('settings'); });
2604          else if (act === 'republish-all') el.addEventListener('click', function () { startRepublish(); });
2605          else if (act === 'tracking-all-pages') el.addEventListener('click', function () {
2606            STATE.trackingAllPages = !STATE.trackingAllPages;
2607            renderRoute();
2608          });
2609          else if (act === 'tracking-test') el.addEventListener('change', function () {
2610            STATE.trackingTest = !!el.checked;
2611            STATE.trackingEvents = null;
2612            ensureTrackingLoaded();
2613            renderRoute();
2614          });
2615          // ── The Facebook drill-down ──────────────────────────────────────
2616          else if (act === 'fb-open-campaign') el.addEventListener('click', function () {
2617            openFbCampaign(el.getAttribute('data-id'), el.getAttribute('data-name'));
2618          });
2619          else if (act === 'fb-open-adset') el.addEventListener('click', function () {
2620            openFbAdSet(el.getAttribute('data-id'), el.getAttribute('data-name'));
2621          });
2622          else if (act === 'fb-back-campaigns') el.addEventListener('click', function () {
2623            // Clear the levels BELOW as well as the one being left. Leaving
2624            // stale ad-set rows behind means the next campaign opened flashes
2625            // the previous one's children before its own arrive.
2626            STATE.fbLevel = 'campaigns'; STATE.fbCampaign = null; STATE.fbAdSet = null;
2627            STATE.fbAdSets = null; STATE.fbAds = null;
2628            renderRoute();
2629          });
2630          else if (act === 'fb-back-adsets') el.addEventListener('click', function () {
2631            STATE.fbLevel = 'adsets'; STATE.fbAdSet = null; STATE.fbAds = null;
2632            renderRoute();
2633          });
2634          // ── Facebook date filter / refresh / pause ──────────────────────
2635          else if (act === 'fb-range') el.addEventListener('click', function () {
2636            changeFbRange({ preset: el.getAttribute('data-preset') || 'last_7d', since: null, until: null });
2637          });
2638          else if (act === 'fb-range-custom') el.addEventListener('click', function () {
2639            STATE.fbCustomOpen = !STATE.fbCustomOpen; renderRoute();
2640          });
2641          else if (act === 'fb-range-apply') el.addEventListener('click', function () {
2642            var root = rootEl(); if (!root) return;
2643            var f = root.querySelector('[data-fb-since]'); var t = root.querySelector('[data-fb-until]');
2644            var since = f && f.value; var until = t && t.value;
2645            if (!since || !until) { if (typeof showToast === 'function') showToast('Pick both a start and an end date.', 'error'); return; }
2646            if (since > until) { if (typeof showToast === 'function') showToast('The start date is after the end date.', 'error'); return; }
2647            changeFbRange({ preset: null, since: since, until: until });
2648          });
2649          else if (act === 'fb-refresh') el.addEventListener('click', function () { reloadFbCurrentLevel(); });
2650          else if (act === 'fb-pause') el.addEventListener('click', function () {
2651            pauseFbObject(el.getAttribute('data-level'), el.getAttribute('data-id'));
2652          });
2653          if ((act === 'review' || act === 'open') && el.tagName !== 'BUTTON') {
2654            el.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); reviewTest(id); } });
2655          }
2656        });
2657        root.querySelectorAll('input[name="ads-google-account"]').forEach(function (r) {
2658          if (r.__adsWired) return;
2659          r.__adsWired = true;
2660          r.addEventListener('change', function () {
2661            STATE.selectedGoogleAccountId = r.value;
2662            renderRoute();
2663          });
2664        });
2665        root.querySelectorAll('input[name="ads-meta-account"]').forEach(function (r) {
2666          if (r.__adsWired) return;
2667          r.__adsWired = true;
2668          r.addEventListener('change', function () {
2669            STATE.selectedMetaAccountId = r.value;
2670            renderRoute();
2671          });
2672        });
2673      }
2674
2675      // ── Public entry points ──────────────────────────────────────────
2676      window.AdsTests = { load: load, go: function (route, opts) { go(route, opts); }, _helpers: HELPERS };
2677
2678      // Shell subnav entry point (sidebar-nav.js's _adsGroups) + in-page
2679      // navigation (Review / back links). Mirrors
2680      // sales-pipeline.js's window.showSalesPipelineRoute exactly: ensures
2681      // the page is mounted + loaded, then routes.
2682      window.showAdsRoute = function (route, testId) {
2683        if (ROUTES.indexOf(route) === -1) route = 'board';
2684        if (typeof showPage === 'function') showPage('ads');
2685        currentModule = 'ads';
2686        if (!STATE.loadedOnce) {
2687          var p = load();
2688          if (p && p.then) p.then(function () { go(route, { testId: testId }); });
2689          else go(route, { testId: testId });
2690        } else {
2691          go(route, { testId: testId });
2692        }
2693      };
2694    })();
2695  }
2696
2697  if (typeof module !== 'undefined' && module.exports) module.exports = HELPERS;
2698})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.