1// === ClaudeTuner Announcement System === 2// ëìë³´ë 4ê° íì´ì§ìì ê³µì 3 4var _ctAnnouncementList = []; 5var _ctAnnouncementContainerId = null; 6var _ctAnnouncementLang = null; 7// Per-page-load campaignâvariant picks. In-memory only (not persisted), so each page load 8// re-randomizes which variant shows, while staying stable across re-renders within one load. 9var _ctVariantChoices = {}; 10 11var _ctNoticeStylesLight = { 12 info: { bg: '#EFF6FF', border: '#BFDBFE', icon: '\u2139\uFE0F', color: '#1E40AF' }, 13 warning: { bg: '#FFFBEB', border: '#FDE68A', icon: '\u26A0\uFE0F', color: '#92400E' }, 14 critical: { bg: '#FEF2F2', border: '#FECACA', icon: '\uD83D\uDEA8', color: '#991B1B' }, 15 promo: { bg: '#F5F3FF', border: '#DDD6FE', icon: '\u2728', color: '#5B21B6' } 16}; 17var _ctNoticeStylesDark = { 18 info: { bg: '#1e3a5f', border: '#2d4a6f', icon: '\u2139\uFE0F', color: '#93c5fd' }, 19 warning: { bg: '#422006', border: '#5a3010', icon: '\u26A0\uFE0F', color: '#fde68a' }, 20 critical: { bg: '#450a0a', border: '#5a1a1a', icon: '\uD83D\uDEA8', color: '#fca5a5' }, 21 promo: { bg: '#2e1065', border: '#4c1d95', icon: '\u2728', color: '#c4b5fd' } 22}; 23function _ctGetNoticeStyles() { 24 var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; 25 return isDark ? _ctNoticeStylesDark : _ctNoticeStylesLight; 26} 27 28// Allow only http(s) or same-origin (/path) URLs; block javascript:/data: etc. 29// Returns '' for unsafe values so callers can skip rendering the link/image. 30function _ctSafeUrl(u) { 31 if (!u) return ''; 32 var s = String(u).trim(); 33 return (/^https?:\/\//i.test(s) || s.charAt(0) === '/') ? s : ''; 34} 35 36// Show a banner only to users in the target IANA timezone (e.g. 'Asia/Seoul'). 37// null/empty target = show to everyone. Catches Korea-resident users regardless of UI language. 38function _ctMatchesTz(tzTarget) { 39 if (!tzTarget) return true; 40 try { 41 return Intl.DateTimeFormat().resolvedOptions().timeZone === tzTarget; 42 } catch (e) { 43 return true; // fail open if the browser can't report a timezone 44 } 45} 46 47function _escNoticeHtml(s) { 48 return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 49} 50 51function sanitizeNoticeHtml(html) { 52 if (!html) return ''; 53 // DOM ê¸°ë° sanitizer: a, br íê·¸ë§ íì©, ëë¨¸ì§ ì ê±° 54 var doc = new DOMParser().parseFromString(html, 'text/html'); 55 function walk(node) { 56 var out = ''; 57 for (var i = 0; i < node.childNodes.length; i++) { 58 var child = node.childNodes[i]; 59 if (child.nodeType === 3) { // TEXT_NODE 60 out += _escNoticeHtml(child.textContent); 61 } else if (child.nodeType === 1) { // ELEMENT_NODE 62 var tag = child.tagName.toLowerCase(); 63 if (tag === 'br') { 64 out += '<br>'; 65 } else if (tag === 'a') { 66 var href = child.getAttribute('href') || '#'; 67 if (href.toLowerCase().indexOf('javascript:') === 0) href = '#'; 68 out += '<a href="' + _escNoticeHtml(href) + '" target="_blank" rel="noopener">' + walk(child) + '</a>'; 69 } else { 70 out += walk(child); 71 } 72 } 73 } 74 return out; 75 } 76 return walk(doc.body); 77} 78 79function formatNoticeDate(dateStr) { 80 if (!dateStr) return ''; 81 try { 82 var d = new Date(dateStr); 83 return (d.getMonth() + 1) + '/' + d.getDate(); 84 } catch(e) { return ''; } 85} 86 87function getDismissedNotices() { 88 try { 89 return JSON.parse(localStorage.getItem('ct_dismissed_notices') || '[]'); 90 } catch(e) { return []; } 91} 92 93function setDismissedNotices(arr) { 94 localStorage.setItem('ct_dismissed_notices', JSON.stringify(arr)); 95} 96 97// Dismissals for A/B campaigns are tracked by campaign key (not variant id), so dismissing 98// any variant hides the whole campaign â it can't reappear as a different variant. 99function getDismissedCampaigns() { 100 try { return JSON.parse(localStorage.getItem('ct_dismissed_campaigns') || '[]'); } catch (e) { return []; } 101} 102function setDismissedCampaigns(arr) { 103 localStorage.setItem('ct_dismissed_campaigns', JSON.stringify(arr)); 104} 105 106function dismissNotice(id) { 107 var n = _ctAnnouncementList.filter(function(x) { return x.id === id; })[0]; 108 if (n && n.campaign) { 109 var camps = getDismissedCampaigns(); 110 if (camps.indexOf(n.campaign) === -1) camps.push(n.campaign); 111 setDismissedCampaigns(camps); 112 } else { 113 var arr = getDismissedNotices(); 114 if (arr.indexOf(id) === -1) arr.push(id); 115 setDismissedNotices(arr); 116 } 117 // ë°°ë DOM ì ê±° 118 var el = document.getElementById('notice-' + id); 119 if (el) el.remove(); 120 // í¸í° ì ë°ì´í¸ 121 updateNoticeFooter(); 122} 123 124function showAllNotices() { 125 setDismissedNotices([]); 126 setDismissedCampaigns([]); 127 renderAnnouncements(_ctAnnouncementContainerId, _ctAnnouncementList); 128} 129 130function updateNoticeFooter() { 131 var footer = document.getElementById('ct-notice-footer'); 132 if (!footer) return; 133 var dismissed = getDismissedNotices(); 134 var dismissedCamps = getDismissedCampaigns(); 135 var lang = _ctAnnouncementLang; 136 var seenCamp = {}; 137 var hiddenCount = 0; 138 _ctAnnouncementList.forEach(function(n) { 139 if (!n.active) return; 140 if (n.lang && lang && n.lang !== lang) return; 141 if (!_ctMatchesTz(n.tz)) return; // don't count banners not targeted at this user 142 if (n.campaign) { 143 // count each dismissed campaign once 144 if (dismissedCamps.indexOf(n.campaign) !== -1 && !seenCamp[n.campaign]) { 145 seenCamp[n.campaign] = 1; 146 hiddenCount++; 147 } 148 } else if (dismissed.indexOf(n.id) !== -1) { 149 hiddenCount++; 150 } 151 }); 152 if (hiddenCount > 0) { 153 var footerText = _ctAnnouncementLang === 'ko' 154 ? '\uACF5\uC9C0\uC0AC\uD56D ' + hiddenCount + '\uAC74' 155 : hiddenCount + ' hidden notice' + (hiddenCount > 1 ? 's' : ''); 156 footer.innerHTML = '<a href="#" style="color:#6B7280;font-size:12px;text-decoration:none;display:flex;align-items:center;justify-content:center;gap:4px;padding:8px 0">' 157 + '<span>\uD83D\uDCE2</span> ' + footerText + '</a>'; 158 footer.querySelector('a').addEventListener('click', function(e) { e.preventDefault(); showAllNotices(); }); 159 footer.style.display = 'block'; 160 } else { 161 footer.style.display = 'none'; 162 } 163} 164 165var _CT_API_BASE = 'https://api.claudetuner.com'; 166 167// Fire-and-forget impression/click beacon for CTR. Both kinds deduped per page session. 168function _ctTrackEvent(id, kind) { 169 if (!id) return; 170 var key = 'ct_evt_' + kind + '_' + id; 171 try { if (sessionStorage.getItem(key)) return; sessionStorage.setItem(key, '1'); } catch (e) {} 172 try { 173 fetch(_CT_API_BASE + '/api/announcements/event', { 174 method: 'POST', 175 headers: { 'Content-Type': 'application/json' }, 176 body: JSON.stringify({ id: id, kind: kind }), 177 keepalive: true 178 }).catch(function () {}); 179 } catch (e) {} 180} 181 182// Variant selection: within each campaign, show one variant chosen at random. 183// choices: { campaign -> chosen id } for this page load (in-memory; keeps the pick stable across
184// re-renders within a load). dismissed: array of dismissed ids. Returns { selected, updated }. 185function _ctSelectVariants(eligible, choices, dismissed, dismissedCampaigns) { 186 var byCampaign = {}, singles = [], updated = null; 187 eligible.forEach(function (n) { 188 if (n.campaign) (byCampaign[n.campaign] = byCampaign[n.campaign] || []).push(n); 189 else singles.push(n); 190 }); 191 var out = singles.filter(function (n) { return dismissed.indexOf(n.id) === -1; }); 192 Object.keys(byCampaign).forEach(function (camp) { 193 if (dismissedCampaigns.indexOf(camp) !== -1) return; // whole campaign dismissed 194 var grp = byCampaign[camp]; 195 var ids = grp.map(function (g) { return g.id; }).sort(); 196 var chosen = choices[camp]; 197 if (!chosen || ids.indexOf(chosen) === -1) { 198 chosen = ids[Math.floor(Math.random() * ids.length)]; 199 updated = updated || {}; 200 updated[camp] = chosen; 201 } 202 for (var i = 0; i < grp.length; i++) { if (grp[i].id === chosen) { out.push(grp[i]); break; } } 203 }); 204 return { selected: out, updated: updated }; 205} 206 207// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 208// In-house ad server â Phase 1 SERVING (design docs/DESIGN-sidebar-ad-server.md) 209// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 210// The dashboard is a SEPARATE Cloudflare Pages deploy and can't import the 211// extension's canonical ad core (usage-shared.js). This is a mechanical SYNC COPY 212// of that logic, adapted to localStorage (no chrome.storage). The PLACEMENTS map 213// and banner markup are kept identical to the extension's, guarded by 214// test/ads-dry-guard.mjs (single-source-of-truth rule). 215 216// SYNC COPY of __ctUsageCore.PLACEMENTS â keep values identical to usage-shared.js. 217var _CT_PLACEMENTS = { 218 CLAUDE_SIDEBAR: 'claude_sidebar', 219 CHATGPT_SIDEBAR: 'chatgpt_sidebar', 220 GEMINI_SIDEBAR: 'gemini_sidebar', 221 POPUP: 'popup', 222 DASHBOARD: 'dashboard' 223}; 224 225var _CT_ADS_URL = 'https://cdn.claudetuner.com/ads.json'; 226var _CT_ADS_CACHE_KEY = 'ct_ads_cache'; // { at, list } (localStorage) 227var _CT_ADS_TTL_MS = 5 * 60 * 1000; // 5min â match CDN max-age=300 so a 228 // pulled/edited creative propagates fast 229var _CT_AD_STICKY_KEY = 'ct_ad_sticky'; // { [campaign_id]: content_id } 230var _CT_AD_CAP_KEY = 'ct_ad_cap'; // { "campaign|placement": {day,count} } 231var _CT_AD_INQUIRY_URL = 'https://tally.so/r/q4dyQk?source=ad_inquiry'; // advertiser-inquiry Tally form (opened from the "Ad" label) 232var _ctAds = []; // selected ad banners for this page load 233var _ctAdServedSession = {}; // per-load dedup for the daily cap counter 234var _ctAdsInFlight = false; // an ads.json selection is being awaited (dedup only) 235var _ctAdsSettled = false; // picks for THIS account state are rendered (or none servable) 236 237// === Premium ad gate (#1530) â a subscriber (`/api/me billing.plan === 'pro'`) sees no ads. === 238// The pages that fetch /api/me hand the WHOLE payload over via _ctCachePlan(meData) (next to 239// _ctCacheCountry). loadAnnouncements runs BEFORE that fetch resolves (dashboard onLogin calls it 240// first), so the decision at ad-fetch time is the localStorage copy of the LAST answer FOR THIS 241// ACCOUNT, and the live answer then corrects it in either direction, any number of times: 242// cached free / nothing â ads fetched+rendered â /api/me says pro â banners removed (re-render) 243// cached pro â ad fetch skipped â /api/me says free â ads fetched now 244// Identity (Codex 1R #1-#3): the cache is keyed by the signed-in email (window._ctUserEmail, set by 245// every dashboard page's onLogin before loadAnnouncements) and a payload is accepted ONLY when its 246// user.email is that principal â so a demo fixture (no user/billing), an admin `?as=` view (the 247// viewed user's billing), an error body, or a delayed response from the previous account can never 248// write this account's plan. No answer for this account = free = ads (fail open, as before the gate). 249// Pages that never fetch /api/me (insights, admin) resolve it ONCE per account per browser via 250// _ctResolvePlanFallback. ads.json carries no plan targeting (privacy notice §9) â client-side only. 251var _CT_PLAN_KEY = 'ct_billing_plan'; // { email, plan:'pro'|'free' } â last accepted /api/me answer 252var _ctAdFreeLive = null; // this page load's confirmed answer; null until /api/me 253var _ctAdFreeLiveFor = null; // â¦and the principal it was confirmed for (same-tab account switch) 254var _CT_PLAN_FALLBACK_MS = 4000; // grace for the page's own /api/me before the fallback asks 255 256// The signed-in principal, lower-cased. Preferred source is the dashboard session token's `email` 257// claim: the server resolves a verified Google alias to the primary account there (routes/auth.ts) 258// and /api/me answers with that primary email, whereas window._ctUserEmail (set from the Google 259// ID token) is the alias â comparing against it would reject the subscriber's own answer (Codex 260// 2R #2). Fallback to window._ctUserEmail (demo pages, no session yet). Read-only: never deletes. 261function _ctPrincipalEmail() { 262 var tok = null; 263 try { tok = localStorage.getItem('ct_session_token'); } catch (e) {} 264 if (tok) { 265 try { 266 var p = JSON.parse(atob(tok.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); 267 if (p && p.iss === 'claudetuner' && typeof p.email === 'string' && p.email) return p.email.toLowerCase(); 268 } catch (e) {} 269 } 270 var e = typeof window !== 'undefined' && window._ctUserEmail; 271 return (typeof e === 'string' && e) ? e.toLowerCase() : null; 272} 273// The cached answer for the CURRENT principal only: 'pro' | 'free' | null (unknown / other account).
274function _ctCachedPlan() { 275 var me = _ctPrincipalEmail(); 276 var c = _ctLsGet(_CT_PLAN_KEY); 277 if (!me || !c || typeof c !== 'object' || c.email !== me) return null; 278 return c.plan === 'pro' ? 'pro' : (c.plan === 'free' ? 'free' : null); 279} 280// The live answer counts only for the principal it was given for â a same-tab logout/login to 281// another account (no reload) must not inherit the previous account's verdict (Codex 2R #1). 282function _ctLiveAdFree() { 283 if (_ctAdFreeLive === null) return null; 284 var me = _ctPrincipalEmail(); 285 return (me && _ctAdFreeLiveFor === me) ? _ctAdFreeLive : null; 286} 287function _ctIsAdFree() { 288 var live = _ctLiveAdFree(); 289 if (live !== null) return live; 290 return _ctCachedPlan() === 'pro'; 291} 292 293// Call from every /api/me consumer with the full payload. Returns true when the answer was accepted. 294// Accepted = { user: { email: <the signed-in principal> }, billing: { plan } }; anything else is not an 295// answer about this account and changes nothing (neither the cache nor this load's decision). 296function _ctCachePlan(me) { 297 var principal = _ctPrincipalEmail(); 298 if (!principal || !me || typeof me !== 'object') return false; 299 var email = me.user && typeof me.user.email === 'string' ? me.user.email.toLowerCase() : null; 300 var billing = me.billing; 301 if (email !== principal || !billing || typeof billing !== 'object') return false; 302 var adFree = billing.plan === 'pro'; 303 _ctLsSet(_CT_PLAN_KEY, { email: principal, plan: adFree ? 'pro' : 'free' }); 304 _ctAdFreeLive = adFree; 305 _ctAdFreeLiveFor = principal; 306 if (!_ctAnnouncementContainerId) return true; // announcements not rendered yet â _ctRequestAds reads the live answer 307 if (adFree) { 308 // Drop whatever is painted; a later free answer (account state flips within the load) re-requests. 309 _ctAdsSettled = false; 310 var painted = _ctAds.length > 0 || _ctAnnouncementList.some(function (n) { return n && n._ad; }); 311 _ctAds = []; 312 if (painted) renderAnnouncements(_ctAnnouncementContainerId, _ctAnnouncementList); // banners + promo-feed rows go 313 } else { 314 _ctRequestAds(_ctAnnouncementContainerId); 315 } 316 return true; 317} 318 319// The ONLY entry to the dashboard ad fetch â gated on _ctIsAdFree at request time AND at 320// resolve time (the live /api/me answer can land while ads.json is in flight). In-flight dedup 321// and "already settled" are separate flags so free â pro â free recovers (Codex 1R #4). 322function _ctRequestAds(containerId) { 323 if (_ctAdsInFlight || _ctAdsSettled || _ctIsAdFree()) return; 324 _ctAdsInFlight = true; 325 _ctSelectDashboardAds(_ctAnnouncementLang).then(function (ads) { 326 _ctAdsInFlight = false; 327 if (_ctIsAdFree()) return; // Premium confirmed while fetching â drop the picks unrendered 328 _ctAdsSettled = true; 329 _ctAds = ads || []; 330 // Re-render when there is anything ad-shaped to (re)paint: banners, or partner-promo rows that a 331 // previous pro verdict hid â an empty banner pick must not leave those hidden (Codex 2R #3). 332 var adRows = _ctAnnouncementList.some(function (n) { return n && n._ad; }); 333 if (_ctAds.length || adRows) renderAnnouncements(containerId, _ctAnnouncementList); 334 }, function () { _ctAdsInFlight = false; }); 335} 336 337// Pages without their own /api/me (insights, admin): one fetch per account per browser, only when 338// nothing is cached for this account and the page's own fetch (if any) has not answered within the 339// grace period. Needs the dashboard session helper (shared/auth.js) â absent = stay unknown = ads. 340function _ctResolvePlanFallback() { 341 if (_ctLiveAdFree() !== null || _ctCachedPlan() !== null || !_ctPrincipalEmail()) return; 342 if (typeof authHeaders !== 'function') return; 343 var tok = null; 344 try { tok = localStorage.getItem('ct_session_token'); } catch (e) {} 345 if (!tok) return; 346 fetch(_CT_API_BASE + '/api/me', { headers: authHeaders() }) 347 .then(function (r) { return r.ok ? r.json() : null; }) 348 .then(function (d) { if (d) _ctCachePlan(d); }) 349 .catch(function () {}); 350} 351 352function _ctLsGet(key) { 353 try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; } 354} 355function _ctLsSet(key, val) { 356 try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} 357} 358
359// Fetch the nested ads.json with a 5min localStorage cache (mirrors the extension's 360// chrome.storage cache). Returns a Promise of the raw campaign array ([] on error). 361function _ctFetchAds() { 362 var cached = _ctLsGet(_CT_ADS_CACHE_KEY); 363 if (cached && Array.isArray(cached.list) && (Date.now() - (cached.at || 0)) < _CT_ADS_TTL_MS) { 364 return Promise.resolve(cached.list); 365 } 366 return fetch(_CT_ADS_URL) 367 .then(function (r) { return r.ok ? r.json() : []; }) 368 .then(function (list) { 369 if (!Array.isArray(list)) return []; 370 _ctLsSet(_CT_ADS_CACHE_KEY, { at: Date.now(), list: list }); 371 return list; 372 }) 373 .catch(function () { return []; }); 374} 375 376// Absent/empty/null list = match all; a null signal value also passes (unknown â 377// don't suppress). But a PRESENT non-array list is malformed data â fail closed 378// (NO match) so a bad upload can never leak to every user. 379function _ctAdInList(list, val) { 380 if (list == null) return true; 381 if (!Array.isArray(list)) return false; 382 if (list.length === 0) return true; 383 if (val == null) return true; 384 return list.indexOf(val) !== -1; 385} 386 387function _ctAdCampaignMatches(c, ctx) { 388 if (!c || !c.campaign_id) return false; 389 var t = c.targeting || {}; 390 var s = c.schedule || {}; 391 if (s.start_at && ctx.now < s.start_at) return false; 392 if (s.end_at && ctx.now > s.end_at) return false; 393 if (!_ctAdInList(t.placements, ctx.placement)) return false; 394 if (!_ctAdInList(t.lang, ctx.lang)) return false; 395 if (!_ctAdInList(t.country, ctx.country)) return false; 396 // Targeting is limited to placement + language + country, matching the privacy notice 397 // (site/privacy §9: "targeted only by ... country and interface language"). Legacy 398 // plan/provider keys in stored campaign JSON are ignored here, as in the extension core. 399 return true; 400} 401 402function _ctWeightedPickContent(contents) { 403 var total = 0, i; 404 for (i = 0; i < contents.length; i++) total += (Number(contents[i].weight) > 0 ? Number(contents[i].weight) : 1); 405 var r = Math.random() * total; 406 for (i = 0; i < contents.length; i++) { 407 r -= (Number(contents[i].weight) > 0 ? Number(contents[i].weight) : 1); 408 if (r <= 0) return contents[i]; 409 } 410 return contents[contents.length - 1]; 411} 412 413// Pick ONE content (sticky default persists (user,campaign)âcontent for fair A/B; 414// weighted re-rolls per load). Returns { content, assigned } (assigned = sticky id to persist). 415function _ctSelectAdContent(campaign, sticky) { 416 var contents = (campaign.contents || []).filter(function (c) { 417 return c && c.content_id && (c.active == null || c.active); 418 }); 419 if (!contents.length) return { content: null, assigned: null }; 420 if (campaign.rotation === 'weighted') return { content: _ctWeightedPickContent(contents), assigned: null }; 421 var prev = sticky[campaign.campaign_id]; 422 var found = prev && contents.filter(function (c) { return c.content_id === prev; })[0]; 423 if (found) return { content: found, assigned: null }; 424 var chosen = _ctWeightedPickContent(contents); 425 return { content: chosen, assigned: chosen.content_id }; 426} 427 428function _ctAdDayKey(now) { 429 var d = new Date(now); 430 return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); 431} 432function _ctAdCapId(campaignId, placement) { return campaignId + '|' + placement; } 433function _ctAdCapReached(capStore, campaignId, placement, perDay, today) { 434 if (!perDay || perDay <= 0) return false; 435 var e = capStore[_ctAdCapId(campaignId, placement)]; 436 return !!(e && e.day === today && e.count >= perDay); 437} 438 439// Cache the country the worker piggybacks on the /api/me response (symmetric to the 440// extension, which caches cf.country from snapshot POST responses). This is the only 441// country signal the dashboard has, so country-targeted campaigns fail open until it 442// lands. Call from the /api/me consumer with meData.country. 443function _ctCacheCountry(country) { 444 if (typeof country === 'string' && country) { 445 try { localStorage.setItem('ct_country', country.toUpperCase()); } catch (e) {} 446 } 447} 448 449// Fetch â target-filter â cap-check â pick ONE campaign at random â content-select for 450// the dashboard placement. Returns a Promise of an array holding a single 451// { campaign, content, placement } (or []) â one ad slot per fetch, chosen uniformly at 452// random among servable campaigns so the banner rotates across advertisers instead of 453// stacking them. Cap is only CHECKED here. 454function _ctSelectDashboardAds(lang) { 455 return _ctFetchAds().then(function (campaigns) { 456 if (!Array.isArray(campaigns) || !campaigns.length) return []; 457 var now = Date.now(); 458 var country = null; 459 var stored = _ctLsGet('ct_country'); 460 if (typeof stored === 'string' && stored) country = stored.toUpperCase(); 461 var ctx = { placement: _CT_PLACEMENTS.DASHBOARD, lang: lang || null, country: country, now: now }; 462 var eligible = campaigns.filter(function (c) { return _ctAdCampaignMatches(c, ctx); }); 463 if (!eligible.length) return []; 464 var sticky = _ctLsGet(_CT_AD_STICKY_KEY) || {}; 465 var cap = _ctLsGet(_CT_AD_CAP_KEY) || {}; 466 var today = _ctAdDayKey(now); 467 // Keep only campaigns whose daily cap is not yet reached. 468 var servable = eligible.filter(function (c) { 469 var perDay = c.cap && Number(c.cap.per_day); 470 return !_ctAdCapReached(cap, c.campaign_id, ctx.placement, perDay, today); 471 }); 472 if (!servable.length) return []; 473 // TWO slots on the dashboard (the sidebars keep one). The dashboard container is ~912px 474 // wide, so a single full-width banner stretches a 34px logo and one headline across the 475 // whole page and reads as mostly empty. Two side by side land at ~450px each. 476 // 477 // Distinct campaigns only â drawing the same advertiser into both slots would look like 478 // a bug and burn the frequency cap twice on one impression pair. Sampling WITHOUT 479 // replacement also fixes a latent hole in the single-slot version: it picked one campaign 480 // and gave up entirely (`return []`) if that campaign had no usable content, so one bad 481 // campaign could blank the whole slot. Here a campaign with no content is skipped and the 482 // next one is drawn. 483 var SLOTS = 2; 484 var pool = servable.slice(); 485 var picks = []; 486 var assignedAny = false;
487 while (pool.length && picks.length < SLOTS) { 488 var c = pool.splice(Math.floor(Math.random() * pool.length), 1)[0]; 489 var picked = _ctSelectAdContent(c, sticky); 490 if (!picked.content) continue; // no usable creative â try another advertiser 491 if (picked.assigned) { sticky[c.campaign_id] = picked.assigned; assignedAny = true; } 492 picks.push({ campaign: c, content: picked.content, placement: ctx.placement }); 493 } 494 if (assignedAny) _ctLsSet(_CT_AD_STICKY_KEY, sticky); 495 return picks; 496 }); 497} 498 499// Record a serve toward the daily frequency cap (serving-side, deduped per page load). 500function _ctNoteAdServed(campaignId, placement) { 501 if (!campaignId || !placement) return; 502 var key = _ctAdCapId(campaignId, placement); 503 if (_ctAdServedSession[key]) return; 504 _ctAdServedSession[key] = 1; 505 var cap = _ctLsGet(_CT_AD_CAP_KEY) || {}; 506 var today = _ctAdDayKey(Date.now()); 507 var e = cap[key]; 508 if (e && e.day === today) e.count += 1; else cap[key] = { day: today, count: 1 }; 509 _ctLsSet(_CT_AD_CAP_KEY, cap); 510} 511 512// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 513// Ad measurement (Phase 2, design §5) â DASHBOARD twin of the extension core 514// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 515// SYNC COPY of the extension's viewability/click/flush logic 516// (claude-tuner-extension/usage-shared.js trackAdViewability/trackAdClick + 517// background.js _adEnqueue/incrementAdCounter/flushAdCounters). Kept behavior- 518// identical so the two runtimes stay parity-consistent, guarded by 519// test/ads-dry-guard.mjs. The difference: there is NO service worker here â a web 520// page owns BOTH the increments AND the flush, and self-flushes on a fixed timer + 521// tab-hide/pagehide (no server-tunable alarm cadence). Counters live in 522// localStorage instead of chrome.storage.local. 523var _CT_AD_COUNTERS_KEY = 'ct_ad_counters'; // { "campaign|content|placement": {imp,clk} } 524var AD_IMPRESSION_DEDUP_MS = 10 * 60 * 1000; // â¤1 impression per creative / 10min 525var AD_CLICK_DEDUP_MS = 1000; // collapse click bursts / double-fires 526// The worker /api/event caps the body at 4096 bytes AND 50 rows. campaign/content IDs 527// can be up to 128 chars each, so a fixed row count can exceed the byte cap â the worker 528// drops the body but returns 204 while the client would subtract = silent count loss. 529// The flush builds its batch by BYTE BUDGET (AD_FLUSH_MAX_BYTES, headroom under 4096) 530// with a 50-row hard cap; excess keys drain over subsequent flush ticks. 531var AD_FLUSH_MAX_ROWS = 50; // hard row cap (matches the worker's row slice) 532var AD_FLUSH_MAX_BYTES = 3600; // serialized-body byte budget (headroom under the worker's 4096) 533 534// Byte-accurate size of a string: Blob is exact for any encoding; str.length is an 535// ASCII-only fallback (ad IDs are ASCII, so char â byte) when Blob is unavailable. 536function _ctAdByteSize(str) { 537 try { return new Blob([str]).size; } catch (e) { return str.length; } 538} 539// Fixed self-flush cadence. Unlike the extension (server-tunable via getCadence), a 540// page has no alarm/cadence channel; 2min is a sane fixed default that also drains 541// promptly given the tab-hide/pagehide tail flushes below. 542var DASH_FLUSH_MS = 2 * 60 * 1000; 543 544// Per-key impression/click dedup windows (Date.now() ms), keyed by creative identity 545// (campaign|content|placement) so a re-render (new element every announcements refresh) 546// can't re-count the SAME creative. Scope is one page load (module-level Map). 547var _ctAdImpressionSeen = {}; // key â lastFiredMs 548var _ctAdClickSeen = {}; // key â lastFiredMs 549function _ctAdKey(ad) { 550 return ad.campaign.campaign_id + '|' + ad.content.content_id + '|' + ad.placement; 551} 552 553// Single async op-chain (mutex): every counter read-modify-write â increment AND 554// flush â is appended here so they can never interleave within this page. Failures 555// are swallowed so one bad op never wedges the chain. The in-page chain only orders 556// THIS page's ops; the counters live in shared localStorage, so two dashboard tabs 557// could otherwise flush the same batch (duplicate AE counts) or clobber each other's 558// read-modify-write (undercount). Each op therefore also acquires a CROSS-TAB lock via 559// the Web Locks API ('ct_ad_ctr') so counter mutations are serialized across tabs too. 560// Fallback: browsers without navigator.locks run the fn directly (prior behavior) â 561// never throw. The extension SW is the single owner and needs no such lock. 562var _ctAdOpChain = Promise.resolve(); 563 564// Cross-tab fallback for browsers WITHOUT navigator.locks. The dangerous op is the flush: 565// two tabs that snapshot the same counters both POST the same rows and the server counts 566// BOTH â OVER-count, i.e. impressions/clicks that never happened. So the flush â and only 567// the flush â takes a lease. Increments stay lock-free: they are a synchronous 568// read-modify-write, so the worst ordinary outcome is a DROPPED count (under-count), and a 569// missed impression is not corruption. 570// The lease is a localStorage claim with write-then-read-back: writes are serialized per 571// origin, so when two claims interleave as write/write/read/read both tabs read back the 572// same last-written token and only one matches. The loser skips this tick â its counters 573// stay in localStorage and go out on the next flush. The lease expires so a tab killed 574// mid-flush cannot wedge flushing forever. 575// 576// This NARROWS the over-count window, it does not close it. Two known residual races, 577// both requiring an exact cross-process interleaving of adjacent localStorage ops:
578// 1. lease claim: A.claim â A.read-back â B.claim â B.read-back lets BOTH tabs win. 579// 2. increment vs flush: the flush re-reads and SUBTRACTS what it sent, so an increment 580// that read the pre-subtract value and writes after it can resurrect already-sent 581// counts â over-count. (Bounded: the increment's read and write are adjacent 582// synchronous statements, so the window is a single localStorage op wide.) 583// Closing either properly needs a real mutex â which is exactly what navigator.locks is, 584// and every browser that can run the extension has it. This whole path is a last-resort 585// fallback for a browser we do not expect to see; a rare miscount there beats the certain 586// double-count of having no lease at all. If it ever needs to be correct, serialize the 587// increment under the same lease rather than bolting more heuristics onto this one. 588var _CT_AD_LOCK_KEY = 'ct_ad_ctr_lock'; // { token, exp } 589var AD_LOCK_TTL_MS = 30 * 1000; // > worst-case flush POST; self-heals a dead holder 590 591function _ctAdTryLease() { 592 var now = Date.now(); 593 var cur = _ctLsGet(_CT_AD_LOCK_KEY); 594 if (cur && Number(cur.exp) > now) return null; // another tab is mid-flush 595 var token = String(now) + '-' + Math.random().toString(36).slice(2); 596 _ctLsSet(_CT_AD_LOCK_KEY, { token: token, exp: now + AD_LOCK_TTL_MS }); 597 var back = _ctLsGet(_CT_AD_LOCK_KEY); // last writer wins â only one token survives 598 return (back && back.token === token) ? token : null; 599} 600function _ctAdReleaseLease(token) { 601 var cur = _ctLsGet(_CT_AD_LOCK_KEY); 602 if (cur && cur.token === token) { 603 try { localStorage.removeItem(_CT_AD_LOCK_KEY); } catch (e) {} 604 } 605} 606 607// exclusive=true â must not run concurrently in ANOTHER TAB (the flush). Everything else 608// is only ordered within this page, by the op-chain. 609function _ctAdEnqueue(fn, exclusive) { 610 var run = function () { 611 if (navigator.locks && navigator.locks.request) return navigator.locks.request('ct_ad_ctr', fn); 612 if (!exclusive) return fn(); 613 var token = _ctAdTryLease(); 614 if (!token) return; // lease held elsewhere â skip; counters survive for the next tick 615 var release = function () { _ctAdReleaseLease(token); }; 616 return Promise.resolve().then(fn).then(release, release); 617 }; 618 _ctAdOpChain = _ctAdOpChain.then(run).catch(function () {}); 619 return _ctAdOpChain; 620} 621 622// Increment one counter cell. Mirrors incrementAdCounter (SW side). 623function _ctIncrementAdCounter(kind, ad) { 624 var campaign = ad.campaign.campaign_id, content = ad.content.content_id, placement = ad.placement; 625 if (!campaign || !content || !placement) return; 626 if (kind !== 'impression' && kind !== 'click') return; 627 _ctAdEnqueue(function () { 628 var store = _ctLsGet(_CT_AD_COUNTERS_KEY) || {}; 629 var key = campaign + '|' + content + '|' + placement; 630 var e = store[key] || { imp: 0, clk: 0 }; 631 if (kind === 'impression') e.imp++; else e.clk++; 632 store[key] = e; 633 _ctLsSet(_CT_AD_COUNTERS_KEY, store); 634 }); 635} 636 637// Viewability-gated impression (design §5.2) â EXACT mirror of trackAdViewability: 638// count ONE impression per creative when it stays >=50% visible in a visible tab for 639// 1s. Deduped per element (__ctViz) AND per creative key (10-min window). The 1s 640// timer's fire path REVALIDATES visibility+intersection at fire time and only 641// disconnects/dedups on a SUCCESSFUL fire, otherwise it re-arms when viewable again. 642// On a successful fire it INCREMENTS the localStorage counter (no SW to message). 643function _ctTrackAdViewability(el, ad) { 644 if (!el || el.__ctViz || !ad || !ad.campaign || !ad.content) return; 645 if (typeof IntersectionObserver !== 'function') return; 646 _ctAdMeasureInit(); // lazily arm the flush timer + tail-flush listeners (once) 647 var key = _ctAdKey(ad); 648 var seen = _ctAdImpressionSeen[key]; 649 if (seen && (Date.now() - seen) < AD_IMPRESSION_DEDUP_MS) return; // already counted this window â don't even observe 650 el.__ctViz = true; 651 var intersecting = false; // live viewability, updated on every IO callback 652 var timer = null; 653 var obs; 654 var clear = function () { if (timer) { clearTimeout(timer); timer = null; } }; 655 // Clear a pending timer the moment the tab is hidden mid-wait so it can't fire 656 // against a hidden tab; removed on disconnect to avoid a listener leak. 657 var onVis = function () { if (document.visibilityState !== 'visible') clear(); }; 658 var disconnect = function () { 659 clear(); 660 obs.disconnect(); 661 document.removeEventListener('visibilitychange', onVis); 662 }; 663 var fire = function () { 664 timer = null; 665 // Revalidate at fire time: only a still-viewable ad in a visible tab counts. 666 if (document.visibilityState !== 'visible' || intersecting !== true) return; // not viewable now â keep observing, re-arm on re-entry 667 disconnect(); 668 _ctAdImpressionSeen[key] = Date.now(); 669 _ctIncrementAdCounter('impression', ad); 670 }; 671 obs = new IntersectionObserver(function (entries) { 672 var entry = entries[entries.length - 1]; 673 intersecting = !!(entry && entry.isIntersecting && entry.intersectionRatio >= 0.5); 674 var viewable = intersecting && document.visibilityState === 'visible'; 675 if (viewable) { if (!timer) timer = setTimeout(fire, 1000); } 676 else { clear(); } // left viewport / tab hidden before 1s â restart on re-entry 677 }, { threshold: [0, 0.5] }); 678 document.addEventListener('visibilitychange', onVis); 679 obs.observe(el); 680} 681 682// Click counter (design §5.3) â EXACT mirror of trackAdClick. Ignores synthetic 683// clicks (isTrusted === false) and collapses same-creative bursts within 684// AD_CLICK_DEDUP_MS (double-fire / click fraud). A genuine repeat click after the 685// window still counts. Increments the localStorage counter (no SW to message). 686function _ctTrackAdClick(ad, e) { 687 if (!ad || !ad.campaign || !ad.content) return; 688 if (e && e.isTrusted === false) return; // synthetic click â ignore 689 var key = _ctAdKey(ad); 690 var seen = _ctAdClickSeen[key]; 691 var now = Date.now(); 692 if (seen && (now - seen) < AD_CLICK_DEDUP_MS) return; // dup within window 693 _ctAdClickSeen[key] = now; 694 _ctIncrementAdCounter('click', ad); 695} 696 697// Flush batched counters to /api/event â mirror of flushAdCounters. Runs through the 698// op-chain so it never races an increment. Sends a snapshot of the current non-empty 699// cells (â¤30 rows), then SUBTRACTS exactly what was sent from a FRESH re-read (never 700// zeroes) so increments that arrived during the in-flight POST are preserved. No-op 701// when nothing to send; on POST failure the counters are left intact for next flush. 702function _ctFlushAdCounters() { 703 // exclusive: a concurrent flush in another tab would re-send the same rows (over-count). 704 return _ctAdEnqueue(function () { 705 var store = _ctLsGet(_CT_AD_COUNTERS_KEY) || {}; 706 var keys = Object.keys(store).filter(function (k) { return store[k].imp > 0 || store[k].clk > 0; }); 707 if (!keys.length) return; 708 // Accumulate rows by BYTE BUDGET: add one at a time and stop before the serialized 709 // body would exceed AD_FLUSH_MAX_BYTES OR AD_FLUSH_MAX_ROWS is reached (whichever
710 // first). Keys not included stay in the store and drain next flush (only the keys in 711 // `batch` are subtracted below). A single row that alone exceeds the budget (only 712 // possible with corrupted keys â a valid 128-char-ID row is ~375B) is SKIPPED, never 713 // force-sent, so the worker can't drop it and cause subtract-on-204 to lose counts. 714 var batch = []; 715 var rows = []; 716 for (var bi = 0; bi < keys.length; bi++) { 717 var bk = keys[bi]; 718 var parts = bk.split('|'); 719 var row = { campaign: parts[0], content: parts[1], placement: parts[2], imp: store[bk].imp, clk: store[bk].clk }; 720 var candidate = JSON.stringify({ type: 'banner_batch', ver: 'dash', rows: rows.concat([row]) }); 721 if (_ctAdByteSize(candidate) > AD_FLUSH_MAX_BYTES) { 722 if (rows.length) break; // batch full â send the rest next flush 723 continue; // lone oversized row â skip (don't force-send + lose it) 724 } 725 rows.push(row); 726 batch.push(bk); 727 if (batch.length >= AD_FLUSH_MAX_ROWS) break; // hard row cap 728 } 729 if (!rows.length) return; // nothing sendable this tick 730 // text/plain keeps this a CORS simple request (no preflight). The server always returns 731 // 204. ver = 'dash' (no dashboard manifest version). 732 // 733 // `_auth` carries the session token ONLY so the server can count DISTINCT users reached 734 // (privacy §9: hashed with a server secret, held <=24h as a de-duplication key, never 735 // written to analytics). The dashboard MUST send it â it is currently the only ad surface 736 // live to real users, so extension-only reach would report zero until the next CWS 737 // release. Signed out â no token â anonymous beacon; impressions still count, no reach. 738 var _payload = { type: 'banner_batch', ver: 'dash', rows: rows }; 739 try { 740 var _tok = localStorage.getItem('ct_session_token'); 741 if (_tok) _payload._auth = _tok; 742 } catch (e) { /* storage blocked â stay anonymous */ } 743 return fetch(_CT_API_BASE + '/api/event', { 744 method: 'POST', 745 headers: { 'Content-Type': 'text/plain' }, 746 body: JSON.stringify(_payload), 747 keepalive: true 748 }).then(function (r) { 749 if (!(r.ok || r.status === 204)) return; // leave counters intact; retry on next flush 750 // Re-read and SUBTRACT what was actually sent (preserve concurrent increments). 751 var cur = _ctLsGet(_CT_AD_COUNTERS_KEY) || {}; 752 for (var i = 0; i < batch.length; i++) { 753 var k = batch[i]; 754 if (!cur[k]) continue; 755 cur[k].imp -= store[k].imp; 756 cur[k].clk -= store[k].clk; 757 if (cur[k].imp <= 0 && cur[k].clk <= 0) delete cur[k]; 758 } 759 _ctLsSet(_CT_AD_COUNTERS_KEY, cur); 760 }).catch(function () {}); // network failure â leave counters intact 761 }, true); 762} 763 764// Arm the flush triggers ONCE per page (a page has no SW/alarm). Guarded by a module 765// flag so repeated renders / viewability calls don't stack intervals or listeners. 766var _ctAdMeasureInited = false; 767function _ctAdMeasureInit() { 768 if (_ctAdMeasureInited) return; 769 _ctAdMeasureInited = true; 770 try { setInterval(_ctFlushAdCounters, DASH_FLUSH_MS); } catch (e) {} 771 // Tail flush so short sessions don't wait for the periodic tick. 772 document.addEventListener('visibilitychange', function () { 773 if (document.visibilityState === 'hidden') _ctFlushAdCounters(); 774 }); 775 window.addEventListener('pagehide', function () { _ctFlushAdCounters(); }); 776} 777 778// Build one ad banner â SYNC COPY of __ctUsageCore.buildAdBannerHtml (theme-neutral 779// inline styles, "Ad"/"ê´ê³ " source label for brand safety, design §12). 780function _ctBuildAdBannerHtml(ad, lang) { 781 var c = ad.content || {}; 782 var img = _ctSafeUrl(c.image_url); 783 var url = _ctSafeUrl(c.url); 784 var key = _escNoticeHtml(ad.campaign.campaign_id + '|' + c.content_id + '|' + ad.placement); 785 var tooltip = lang === 'ko' 786 ? 'Claude Tunerê° ê²ì¬íë ê´ê³ ì ëë¤. ê´ê³ 문ìíë ¤ë©´ í´ë¦íì¸ì.' 787 : 'Ad shown by Claude Tuner. Click to advertise with us.'; 788 // "Corner badge" layout: the disclosure mark is taken OUT OF FLOW (absolute, pinned to 789 // the banner's top-left) instead of occupying its own leading line. The leading-line 790 // version cost a full row of vertical space in a 230px-wide sidebar for a 9px chip. 791 // Out of flow, the logo + headline row starts at the top and the banner is ~14px shorter.
792 var h = '<div class="ct-ad-banner" data-ad-key="' + key + '"' 793 + (url ? ' data-ad-url="' + _escNoticeHtml(url) + '"' : '') 794 + ' style="position:relative;width:100%;box-sizing:border-box;' 795 + 'border:1px solid rgba(128,128,128,0.28);border-radius:10px;padding:8px 10px;margin:6px 0;' 796 + 'box-shadow:0 1px 2px rgba(0,0,0,0.04);font-family:inherit;' 797 + (url ? 'cursor:pointer;' : '') + '">'; 798 // Disclosure badge: out of flow, pinned to the banner's top-RIGHT corner. Top-left 799 // would land on the creative's logo (34px, vertically centred) and read as dirty, 800 // especially over a light logo tile on a dark theme. Right-aligned it collides with 801 // nothing â only the headline's first line, which reserves room for it below. 802 // Deliberately low-contrast (outline only, no fill) so it recedes â but the outline 803 // STAYS: it is what reads the mark as a disclosure rather than part of the ad copy. 804 // Don't fade it further; an ad label has to remain plainly recognisable. 805 // It stays an advertiser-inquiry link: hover discloses it's an ad, click opens the 806 // inquiry form. The banner click handler ignores clicks inside .ct-ad-label so the 807 // ad URL isn't opened too. 808 h += '<a class="ct-ad-label" href="' + _escNoticeHtml(_CT_AD_INQUIRY_URL) + '" target="_blank" rel="noopener noreferrer"' 809 + ' title="' + _escNoticeHtml(tooltip) + '"' 810 + ' style="position:absolute;top:2px;right:3px;z-index:1;' 811 + 'font-size:7.5px;font-weight:600;letter-spacing:0.03em;line-height:11px;padding:0 2px;' 812 + 'border:1px solid rgba(128,128,128,0.22);border-radius:3px;' 813 + 'background:none;opacity:0.45;' 814 + 'text-decoration:none;color:inherit;cursor:pointer;white-space:nowrap">AD</a>'; 815 // Content row: logo (only if present) + full-width headline/subtitle. No leading chip 816 // above it any more â the badge floats over this row's top-right corner. 817 h += '<div style="display:flex;align-items:center;gap:8px">'; 818 if (img) { 819 h += '<img src="' + _escNoticeHtml(img) + '" alt="" style="width:34px;height:34px;' 820 + 'border-radius:9px;object-fit:cover;flex-shrink:0" />'; 821 } 822 h += '<div style="flex:1;min-width:0">'; 823 // padding-right reserves exactly the strip the badge floats over, so the headline can 824 // never run underneath it. 12px is measured, not guessed: the badge is 17px wide and 825 // overhangs the text column by ~10px, and anything wider (a 20px badge, or 16px+ of 826 // padding) pushes this headline onto a SECOND line and gives back all the height we 827 // just saved. Only the headline needs it â the body sits below the badge. 828 // No bold: the headline inherits the surface's normal weight so the banner reads as 829 // part of the panel rather than shouting over it. 830 h += '<div style="font-size:13px;line-height:1.25;padding-right:12px">' 831 + _escNoticeHtml(c.title || '') + '</div>'; 832 if (c.body) { 833 h += '<div style="font-size:10.5px;opacity:0.6;margin-top:1px;line-height:1.3;' 834 + 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + _escNoticeHtml(c.body) + '</div>'; 835 } 836 h += '</div>'; 837 h += '</div>'; 838 h += '</div>'; 839 return h; 840} 841 842function renderAnnouncements(containerId, list) { 843 var container = document.getElementById(containerId); 844 if (!container) return; 845 846 _ctAnnouncementContainerId = containerId; 847 _ctAnnouncementList = list; 848 if (_ctIsAdFree()) { _ctAds = []; _ctAdsSettled = false; } // Premium: nothing below may paint a banner (#1530) 849 850 var lang = _ctAnnouncementLang; 851 var dismissed = getDismissedNotices(); 852 // Eligible = passes version/lang/tz (NOT dismiss): keeps A/B assignment stable across dismiss. 853 var adFree = _ctIsAdFree(); 854 var eligible = list.filter(function(n) { 855 if (!n.active) return false; 856 if (n._ad && adFree) return false; // Premium: partner promos (/api/promos) are ads too (#1530) 857 // lang íí°: null/undefined â 모ë ì¸ì´ì íì, ìëë©´ ì¼ì¹íë ì¸ì´ë§ 858 if (n.lang && lang && n.lang !== lang) return false; 859 // tz íí°: null â ì ì²´, ìëë©´ í´ë¹ íìì¡´ ì¬ì©ììê²ë§ (ì: íêµ ê±°ì£¼ì) 860 if (!_ctMatchesTz(n.tz)) return false;
861 return true; 862 }); 863 864 // Collapse each campaign to one variant (random per page load), then drop dismissed. 865 var choices = _ctVariantChoices; 866 var picked = _ctSelectVariants(eligible, choices, dismissed, getDismissedCampaigns()); 867 if (picked.updated) { 868 for (var k in picked.updated) choices[k] = picked.updated[k]; 869 } 870 var active = picked.selected; 871 872 // In-house ad banners render above the notices (design §5, first placement = all). 873 var adHtml = ''; 874 for (var ai = 0; ai < _ctAds.length; ai++) adHtml += _ctBuildAdBannerHtml(_ctAds[ai], lang); 875 // Side-by-side slots. auto-fit + minmax collapses to ONE column once a slot would be 876 // narrower than 320px, so a narrow window / phone stacks them with no media query. The 877 // banners keep their own inline width:100%, so they just fill whatever column they land in. 878 if (_ctAds.length > 1) { 879 // column-gap only: the vertical gap when slots collapse to a single stacked 880 // column is supplied by each banner's own margin:6px 0 (grid item margins do 881 // NOT collapse, so 6+6 = 12px), keeping the stacked gap equal to the 12px 882 // side-by-side gap instead of doubling to 24px. align-items:start prevents a 883 // banner whose title wraps to two lines from stretching its shorter sibling. 884 adHtml = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));' 885 + 'column-gap:12px;row-gap:0;align-items:start">' + adHtml + '</div>'; 886 } 887 888 if (active.length === 0 && !adHtml) { 889 container.innerHTML = ''; 890 updateNoticeFooter(); 891 return; 892 } 893 894 // ìµì ì ì ë ¬ 895 active.sort(function(a, b) { return (b.date || '').localeCompare(a.date || ''); }); 896 897 var html = adHtml; 898 for (var i = 0; i < active.length; i++) { 899 var n = active[i]; 900 var _styles = _ctGetNoticeStyles(); 901 var s = _styles[n.type] || _styles.info; 902 var dateStr = formatNoticeDate(n.date); 903 904 var safeId = _escNoticeHtml(n.id); 905 906 // Badge promo (e.g. Product Hunt launch): render the official Product Hunt embed badge 907 // instead of the logo+copy+CTA layout. Keyed on campaign so the same promo row also 908 // degrades cleanly on older extension popups (which ignore campaign and show logo+title). 909 // The embed shows the live vote count; theme picks the light/dark variant and the `t` 910 // cache-buster keeps the count fresh. Must stay in sync with the extension popup 911 // (claude-tuner-extension/ui/notices.js) â same post_id + themed embed. 912 if (n.type === 'promo' && n.campaign === 'producthunt') { 913 var bRaw = _ctSafeUrl(n.url); 914 var bUrl = bRaw ? (bRaw + (bRaw.indexOf('?') >= 0 ? '&' : '?') + 'utm_source=dashboard') : ''; 915 var bDark = document.documentElement.getAttribute('data-theme') === 'dark'; 916 var PH_POST_ID = '1186963'; 917 var bSrc = 'https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=' + PH_POST_ID + '&theme=' + (bDark ? 'dark' : 'light') + '&t=' + Date.now(); 918 var bImg = '<img src="' + bSrc + '" width="250" height="54" alt="' + _escNoticeHtml(n.title || 'Product Hunt') + '" style="display:inline-block;max-width:100%;height:auto;vertical-align:middle" />'; 919 html += '<div id="notice-' + safeId + '" style="margin-bottom:8px;text-align:center">'; 920 html += bUrl 921 ? '<a href="' + _escNoticeHtml(bUrl) + '" target="_blank" rel="noopener" data-track-click="' + safeId + '">' + bImg + '</a>' 922 : bImg; 923 html += '</div>'; 924 continue; 925 } 926 927 // Promo banner: logo + copy + CTA button (reusable for any partner project). 928 // Ads are non-dismissible by design â no close button. 929 if (n.type === 'promo') { 930 var pRaw = _ctSafeUrl(n.url); 931 var pUrl = pRaw ? (pRaw + (pRaw.indexOf('?') >= 0 ? '&' : '?') + 'utm_source=dashboard') : ''; 932 var pImg = _ctSafeUrl(n.image_url); 933 var ctaLabel = _ctAnnouncementLang === 'en' ? 'Open â' : 'ë°ë¡ê°ê¸° â'; 934 html += '<div id="notice-' + safeId + '" style="background:' + s.bg + ';border:1px solid ' + s.border + ';border-radius:10px;padding:12px 14px;margin-bottom:8px;position:relative">'; 935 html += '<div style="display:flex;align-items:center;gap:12px">'; 936 if (pImg) { 937 html += '<img src="' + _escNoticeHtml(pImg) + '" alt="" style="width:40px;height:40px;border-radius:9px;flex-shrink:0;object-fit:cover" />'; 938 } else { 939 html += '<span style="font-size:24px;flex-shrink:0">' + s.icon + '</span>'; 940 } 941 html += '<div style="flex:1;min-width:0;padding-right:14px">'; 942 html += '<div style="font-size:13px;font-weight:700;color:' + s.color + ';line-height:18px">' + _escNoticeHtml(n.title || '') + '</div>'; 943 if (n.body) html += '<div style="font-size:12px;color:var(--gray-500,#4B5563);margin-top:2px;line-height:17px">' + sanitizeNoticeHtml(n.body) + '</div>'; 944 html += '</div>'; 945 if (pUrl) { 946 html += '<a href="' + _escNoticeHtml(pUrl) + '" target="_blank" rel="noopener" data-track-click="' + safeId + '" style="flex-shrink:0;align-self:center;background:' + s.color + ';color:#fff;font-size:12px;font-weight:600;padding:7px 12px;border-radius:8px;text-decoration:none;white-space:nowrap">' + ctaLabel + '</a>'; 947 } 948 html += '</div></div>'; 949 continue; 950 } 951 952 html += '<div id="notice-' + safeId + '" style="background:' + s.bg + ';border:1px solid ' + s.border + ';border-radius:10px;padding:12px 16px;margin-bottom:8px;position:relative">'; 953 html += '<div style="display:flex;align-items:flex-start;gap:8px">'; 954 html += '<span style="font-size:14px;flex-shrink:0;line-height:20px">' + s.icon + '</span>'; 955 html += '<div style="flex:1;min-width:0">'; 956 var nSafeUrl = _ctSafeUrl(n.url); 957 if (nSafeUrl) { 958 var utmUrl = nSafeUrl + (nSafeUrl.indexOf('?') >= 0 ? '&' : '?') + 'utm_source=dashboard'; 959 html += '<a href="' + _escNoticeHtml(utmUrl) + '" target="_blank" rel="noopener" data-track-click="' + safeId + '" style="font-size:13px;font-weight:600;color:' + s.color + ';line-height:20px;text-decoration:none">
959' + _escNoticeHtml(n.title || '') + ' <span style="font-size:11px;opacity:0.6">â</span></a>'; 960 } else { 961 html += '<div style="font-size:13px;font-weight:600;color:' + s.color + ';line-height:20px">' + _escNoticeHtml(n.title || '') + '</div>'; 962 if (n.body) { 963 html += '<div style="font-size:12px;color:var(--gray-500,#4B5563);margin-top:4px;line-height:18px">' + sanitizeNoticeHtml(n.body) + '</div>'; 964 } 965 } 966 html += '</div>'; 967 html += '<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">'; 968 if (dateStr) html += '<span style="font-size:11px;color:#9CA3AF">' + dateStr + '</span>'; 969 html += '<button data-dismiss="' + safeId + '" style="background:none;border:none;cursor:pointer;font-size:16px;color:#9CA3AF;padding:0;line-height:1" title="' + (_ctAnnouncementLang === 'en' ? 'Close' : '\uB2EB\uAE30') + '">\u00D7</button>'; 970 html += '</div>'; 971 html += '</div></div>'; 972 } 973 974 container.innerHTML = html; 975 // Wire ad banners: count the serve (frequency cap, serving-side) + arm the 976 // viewability-gated impression counter (Phase 2), and open the URL on click. 977 container.querySelectorAll('.ct-ad-banner').forEach(function (el, i) { 978 var ad = _ctAds[i]; 979 if (!ad) return; 980 _ctNoteAdServed(ad.campaign.campaign_id, ad.placement); 981 _ctTrackAdViewability(el, ad); 982 var url = el.getAttribute('data-ad-url'); 983 if (url) el.addEventListener('click', function (e) { 984 // Label chip is an advertiser-inquiry link (its own target=_blank nav) â not an ad click. 985 if (e.target.closest && e.target.closest('.ct-ad-label')) return; 986 _ctTrackAdClick(ad, e); 987 window.open(url + (url.indexOf('?') >= 0 ? '&' : '?') + 'utm_source=dashboard', '_blank'); 988 }); 989 }); 990 container.querySelectorAll('button[data-dismiss]').forEach(function(btn) { 991 btn.addEventListener('click', function() { dismissNotice(btn.getAttribute('data-dismiss')); }); 992 }); 993 // Click tracking (link follows default navigation; beacon is fire-and-forget). 994 // auxclick catches middle-click "open in new tab". 995 container.querySelectorAll('[data-track-click]').forEach(function(el) { 996 var onClick = function() { _ctTrackEvent(el.getAttribute('data-track-click'), 'click'); }; 997 el.addEventListener('click', onClick); 998 el.addEventListener('auxclick', function(e) { if (e.button === 1) onClick(); }); 999 }); 1000 // Impression tracking for the variants actually shown (deduped per session) 1001 for (var j = 0; j < active.length; j++) _ctTrackEvent(active[j].id, 'impression'); 1002 updateNoticeFooter(); 1003} 1004 1005function loadAnnouncements(containerId, lang) { 1006 _ctAnnouncementLang = lang || null; 1007 var cacheBuster = Math.floor(Date.now() / 3600000); 1008 // ê³µì§(/api/announcements)ì ê´ê³ (/api/promos)ë ë³ë ìëí¬ì¸í¸ â ë ë¤ ë°ì ë³í© ë ë. 1009 // (ê´ê³ 를 ê³µì§ í¼ëì í©ì¹ë©´ ì¬ì´ëë°Â·ëë© ë± ë¤ë¥¸ ìë¹ìì ìë¯ë¡ ë¶ë¦¬ ì ì§) 1010 // Absolute URLs (the static CDN copy of announcements) are fetched as-is; relative 1011 // paths (e.g. /api/promos, still Worker-served) get the API base + cache-buster. 1012 function _fetchJson(path) { 1013 var url = /^https?:/.test(path) ? path : (_CT_API_BASE + path + '?t=' + cacheBuster); 1014 return fetch(url) 1015 .then(function(r) { return r.json(); }) 1016 .then(function(list) { return Array.isArray(list) ? list : []; }) 1017 .catch(function() { return []; }); 1018 } 1019 // Announcements are served straight from the CDN (cdn.claudetuner.com) â no Worker 1020 // invocation. Promos stay on the Worker route (own table, low volume). 1021 Promise.all([_fetchJson('https://cdn.claudetuner.com/announcements.json'), _fetchJson('/api/promos')]) 1022 .then(function(res) { 1023 // /api/promos is the partner-ad feed (worker: "ê´ê³ ë°°ë") â tag its rows so the Premium gate can 1024 // drop them at render time while ordinary notices (announcements.json) stay (Codex 1R #5). 1025 res[1].forEach(function(n) { if (n && typeof n === 'object') n._ad = true; }); 1026 var list = res[0].concat(res[1]); 1027 list.forEach(function(n) { n.active = true; }); 1028 renderAnnouncements(containerId, list); 1029 // In-house ads (design §3.2) â independent of announcements; fetched by the 1030 // synced ad core and re-rendered into the same container above the notices. 1031 // Skipped entirely for Premium (_ctRequestAds â _ctIsAdFree). 1032 _ctRequestAds(containerId); 1033 setTimeout(_ctResolvePlanFallback, _CT_PLAN_FALLBACK_MS); 1034 }) 1035 .catch(function() {}); 1036} 1037 1038// Re-render with current theme colors (call on theme change)
1039function refreshAnnouncementTheme() { 1040 if (_ctAnnouncementContainerId && _ctAnnouncementList.length) { 1041 renderAnnouncements(_ctAnnouncementContainerId, _ctAnnouncementList); 1042 } 1043}
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.