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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 & 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">↻ 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…' : '⏸ 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)) + ' · 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.