tacilytics 0.0.0-stage → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/.env.example +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +253 -2
  4. package/cli.js +173 -0
  5. package/day-salt.js +55 -0
  6. package/package.json +56 -4
  7. package/public/app.js +788 -0
  8. package/public/assets/DISCLAIMER-simple-icons.md +51 -0
  9. package/public/assets/LICENSE-lucide.txt +43 -0
  10. package/public/assets/LICENSE-simple-icons.md +30 -0
  11. package/public/assets/cosmic-bg.webp +0 -0
  12. package/public/assets/flags/LICENSE-flag-icons.txt +21 -0
  13. package/public/assets/flags/ad.svg +150 -0
  14. package/public/assets/flags/ae.svg +6 -0
  15. package/public/assets/flags/af.svg +81 -0
  16. package/public/assets/flags/ag.svg +14 -0
  17. package/public/assets/flags/ai.svg +29 -0
  18. package/public/assets/flags/al.svg +5 -0
  19. package/public/assets/flags/am.svg +5 -0
  20. package/public/assets/flags/ao.svg +13 -0
  21. package/public/assets/flags/aq.svg +5 -0
  22. package/public/assets/flags/ar.svg +32 -0
  23. package/public/assets/flags/arab.svg +109 -0
  24. package/public/assets/flags/as.svg +72 -0
  25. package/public/assets/flags/asean.svg +13 -0
  26. package/public/assets/flags/at.svg +4 -0
  27. package/public/assets/flags/au.svg +8 -0
  28. package/public/assets/flags/aw.svg +186 -0
  29. package/public/assets/flags/ax.svg +18 -0
  30. package/public/assets/flags/az.svg +8 -0
  31. package/public/assets/flags/ba.svg +12 -0
  32. package/public/assets/flags/bb.svg +6 -0
  33. package/public/assets/flags/bd.svg +4 -0
  34. package/public/assets/flags/be.svg +7 -0
  35. package/public/assets/flags/bf.svg +7 -0
  36. package/public/assets/flags/bg.svg +5 -0
  37. package/public/assets/flags/bh.svg +4 -0
  38. package/public/assets/flags/bi.svg +15 -0
  39. package/public/assets/flags/bj.svg +14 -0
  40. package/public/assets/flags/bl.svg +5 -0
  41. package/public/assets/flags/bm.svg +97 -0
  42. package/public/assets/flags/bn.svg +36 -0
  43. package/public/assets/flags/bo.svg +673 -0
  44. package/public/assets/flags/bq.svg +5 -0
  45. package/public/assets/flags/br.svg +45 -0
  46. package/public/assets/flags/bs.svg +13 -0
  47. package/public/assets/flags/bt.svg +89 -0
  48. package/public/assets/flags/bv.svg +13 -0
  49. package/public/assets/flags/bw.svg +7 -0
  50. package/public/assets/flags/by.svg +18 -0
  51. package/public/assets/flags/bz.svg +145 -0
  52. package/public/assets/flags/ca.svg +4 -0
  53. package/public/assets/flags/cc.svg +19 -0
  54. package/public/assets/flags/cd.svg +5 -0
  55. package/public/assets/flags/cefta.svg +13 -0
  56. package/public/assets/flags/cf.svg +15 -0
  57. package/public/assets/flags/cg.svg +12 -0
  58. package/public/assets/flags/ch.svg +9 -0
  59. package/public/assets/flags/ci.svg +7 -0
  60. package/public/assets/flags/ck.svg +9 -0
  61. package/public/assets/flags/cl.svg +13 -0
  62. package/public/assets/flags/cm.svg +15 -0
  63. package/public/assets/flags/cn.svg +11 -0
  64. package/public/assets/flags/co.svg +7 -0
  65. package/public/assets/flags/cp.svg +7 -0
  66. package/public/assets/flags/cr.svg +7 -0
  67. package/public/assets/flags/cu.svg +13 -0
  68. package/public/assets/flags/cv.svg +13 -0
  69. package/public/assets/flags/cw.svg +14 -0
  70. package/public/assets/flags/cx.svg +15 -0
  71. package/public/assets/flags/cy.svg +6 -0
  72. package/public/assets/flags/cz.svg +5 -0
  73. package/public/assets/flags/de.svg +5 -0
  74. package/public/assets/flags/dg.svg +130 -0
  75. package/public/assets/flags/dj.svg +13 -0
  76. package/public/assets/flags/dk.svg +5 -0
  77. package/public/assets/flags/dm.svg +152 -0
  78. package/public/assets/flags/do.svg +121 -0
  79. package/public/assets/flags/dz.svg +5 -0
  80. package/public/assets/flags/eac.svg +48 -0
  81. package/public/assets/flags/ec.svg +138 -0
  82. package/public/assets/flags/ee.svg +5 -0
  83. package/public/assets/flags/eg.svg +38 -0
  84. package/public/assets/flags/eh.svg +16 -0
  85. package/public/assets/flags/er.svg +8 -0
  86. package/public/assets/flags/es-ct.svg +4 -0
  87. package/public/assets/flags/es-ga.svg +187 -0
  88. package/public/assets/flags/es-pv.svg +5 -0
  89. package/public/assets/flags/es.svg +544 -0
  90. package/public/assets/flags/et.svg +14 -0
  91. package/public/assets/flags/eu.svg +28 -0
  92. package/public/assets/flags/fi.svg +5 -0
  93. package/public/assets/flags/fj.svg +120 -0
  94. package/public/assets/flags/fk.svg +90 -0
  95. package/public/assets/flags/fm.svg +11 -0
  96. package/public/assets/flags/fo.svg +12 -0
  97. package/public/assets/flags/fr.svg +5 -0
  98. package/public/assets/flags/ga.svg +7 -0
  99. package/public/assets/flags/gb-eng.svg +5 -0
  100. package/public/assets/flags/gb-nir.svg +132 -0
  101. package/public/assets/flags/gb-sct.svg +4 -0
  102. package/public/assets/flags/gb-wls.svg +9 -0
  103. package/public/assets/flags/gb.svg +7 -0
  104. package/public/assets/flags/gd.svg +27 -0
  105. package/public/assets/flags/ge.svg +6 -0
  106. package/public/assets/flags/gf.svg +5 -0
  107. package/public/assets/flags/gg.svg +9 -0
  108. package/public/assets/flags/gh.svg +6 -0
  109. package/public/assets/flags/gi.svg +32 -0
  110. package/public/assets/flags/gl.svg +4 -0
  111. package/public/assets/flags/gm.svg +14 -0
  112. package/public/assets/flags/gn.svg +7 -0
  113. package/public/assets/flags/gp.svg +5 -0
  114. package/public/assets/flags/gq.svg +23 -0
  115. package/public/assets/flags/gr.svg +16 -0
  116. package/public/assets/flags/gs.svg +133 -0
  117. package/public/assets/flags/gt.svg +204 -0
  118. package/public/assets/flags/gu.svg +19 -0
  119. package/public/assets/flags/gw.svg +13 -0
  120. package/public/assets/flags/gy.svg +9 -0
  121. package/public/assets/flags/hk.svg +8 -0
  122. package/public/assets/flags/hm.svg +8 -0
  123. package/public/assets/flags/hn.svg +18 -0
  124. package/public/assets/flags/hr.svg +58 -0
  125. package/public/assets/flags/ht.svg +116 -0
  126. package/public/assets/flags/hu.svg +7 -0
  127. package/public/assets/flags/ic.svg +7 -0
  128. package/public/assets/flags/id.svg +4 -0
  129. package/public/assets/flags/ie.svg +7 -0
  130. package/public/assets/flags/il.svg +14 -0
  131. package/public/assets/flags/im.svg +36 -0
  132. package/public/assets/flags/in.svg +25 -0
  133. package/public/assets/flags/io.svg +130 -0
  134. package/public/assets/flags/iq.svg +10 -0
  135. package/public/assets/flags/ir.svg +219 -0
  136. package/public/assets/flags/is.svg +12 -0
  137. package/public/assets/flags/it.svg +7 -0
  138. package/public/assets/flags/je.svg +62 -0
  139. package/public/assets/flags/jm.svg +8 -0
  140. package/public/assets/flags/jo.svg +16 -0
  141. package/public/assets/flags/jp.svg +11 -0
  142. package/public/assets/flags/ke.svg +23 -0
  143. package/public/assets/flags/kg.svg +4 -0
  144. package/public/assets/flags/kh.svg +61 -0
  145. package/public/assets/flags/ki.svg +36 -0
  146. package/public/assets/flags/km.svg +16 -0
  147. package/public/assets/flags/kn.svg +14 -0
  148. package/public/assets/flags/kp.svg +15 -0
  149. package/public/assets/flags/kr.svg +24 -0
  150. package/public/assets/flags/kw.svg +13 -0
  151. package/public/assets/flags/ky.svg +103 -0
  152. package/public/assets/flags/kz.svg +36 -0
  153. package/public/assets/flags/la.svg +12 -0
  154. package/public/assets/flags/lb.svg +15 -0
  155. package/public/assets/flags/lc.svg +8 -0
  156. package/public/assets/flags/li.svg +43 -0
  157. package/public/assets/flags/lk.svg +22 -0
  158. package/public/assets/flags/lr.svg +14 -0
  159. package/public/assets/flags/ls.svg +8 -0
  160. package/public/assets/flags/lt.svg +7 -0
  161. package/public/assets/flags/lu.svg +5 -0
  162. package/public/assets/flags/lv.svg +6 -0
  163. package/public/assets/flags/ly.svg +13 -0
  164. package/public/assets/flags/ma.svg +4 -0
  165. package/public/assets/flags/mc.svg +6 -0
  166. package/public/assets/flags/md.svg +70 -0
  167. package/public/assets/flags/me.svg +116 -0
  168. package/public/assets/flags/mf.svg +5 -0
  169. package/public/assets/flags/mg.svg +7 -0
  170. package/public/assets/flags/mh.svg +7 -0
  171. package/public/assets/flags/mk.svg +5 -0
  172. package/public/assets/flags/ml.svg +7 -0
  173. package/public/assets/flags/mm.svg +12 -0
  174. package/public/assets/flags/mn.svg +14 -0
  175. package/public/assets/flags/mo.svg +9 -0
  176. package/public/assets/flags/mp.svg +86 -0
  177. package/public/assets/flags/mq.svg +5 -0
  178. package/public/assets/flags/mr.svg +6 -0
  179. package/public/assets/flags/ms.svg +29 -0
  180. package/public/assets/flags/mt.svg +58 -0
  181. package/public/assets/flags/mu.svg +8 -0
  182. package/public/assets/flags/mv.svg +6 -0
  183. package/public/assets/flags/mw.svg +10 -0
  184. package/public/assets/flags/mx.svg +382 -0
  185. package/public/assets/flags/my.svg +26 -0
  186. package/public/assets/flags/mz.svg +21 -0
  187. package/public/assets/flags/na.svg +16 -0
  188. package/public/assets/flags/nc.svg +13 -0
  189. package/public/assets/flags/ne.svg +6 -0
  190. package/public/assets/flags/nf.svg +9 -0
  191. package/public/assets/flags/ng.svg +6 -0
  192. package/public/assets/flags/ni.svg +129 -0
  193. package/public/assets/flags/nl.svg +5 -0
  194. package/public/assets/flags/no.svg +7 -0
  195. package/public/assets/flags/np.svg +13 -0
  196. package/public/assets/flags/nr.svg +12 -0
  197. package/public/assets/flags/nu.svg +10 -0
  198. package/public/assets/flags/nz.svg +36 -0
  199. package/public/assets/flags/om.svg +115 -0
  200. package/public/assets/flags/pa.svg +14 -0
  201. package/public/assets/flags/pc.svg +33 -0
  202. package/public/assets/flags/pe.svg +4 -0
  203. package/public/assets/flags/pf.svg +19 -0
  204. package/public/assets/flags/pg.svg +9 -0
  205. package/public/assets/flags/ph.svg +6 -0
  206. package/public/assets/flags/pk.svg +15 -0
  207. package/public/assets/flags/pl.svg +6 -0
  208. package/public/assets/flags/pm.svg +5 -0
  209. package/public/assets/flags/pn.svg +53 -0
  210. package/public/assets/flags/pr.svg +13 -0
  211. package/public/assets/flags/ps.svg +6 -0
  212. package/public/assets/flags/pt.svg +57 -0
  213. package/public/assets/flags/pw.svg +11 -0
  214. package/public/assets/flags/py.svg +157 -0
  215. package/public/assets/flags/qa.svg +4 -0
  216. package/public/assets/flags/re.svg +5 -0
  217. package/public/assets/flags/ro.svg +7 -0
  218. package/public/assets/flags/rs.svg +292 -0
  219. package/public/assets/flags/ru.svg +5 -0
  220. package/public/assets/flags/rw.svg +13 -0
  221. package/public/assets/flags/sa.svg +25 -0
  222. package/public/assets/flags/sb.svg +13 -0
  223. package/public/assets/flags/sc.svg +7 -0
  224. package/public/assets/flags/sd.svg +13 -0
  225. package/public/assets/flags/se.svg +4 -0
  226. package/public/assets/flags/sg.svg +13 -0
  227. package/public/assets/flags/sh-ac.svg +689 -0
  228. package/public/assets/flags/sh-hl.svg +164 -0
  229. package/public/assets/flags/sh-ta.svg +76 -0
  230. package/public/assets/flags/sh.svg +7 -0
  231. package/public/assets/flags/si.svg +18 -0
  232. package/public/assets/flags/sj.svg +7 -0
  233. package/public/assets/flags/sk.svg +9 -0
  234. package/public/assets/flags/sl.svg +7 -0
  235. package/public/assets/flags/sm.svg +75 -0
  236. package/public/assets/flags/sn.svg +8 -0
  237. package/public/assets/flags/so.svg +11 -0
  238. package/public/assets/flags/sr.svg +6 -0
  239. package/public/assets/flags/ss.svg +8 -0
  240. package/public/assets/flags/st.svg +16 -0
  241. package/public/assets/flags/sv.svg +593 -0
  242. package/public/assets/flags/sx.svg +56 -0
  243. package/public/assets/flags/sy.svg +6 -0
  244. package/public/assets/flags/sz.svg +34 -0
  245. package/public/assets/flags/tc.svg +50 -0
  246. package/public/assets/flags/td.svg +7 -0
  247. package/public/assets/flags/tf.svg +15 -0
  248. package/public/assets/flags/tg.svg +14 -0
  249. package/public/assets/flags/th.svg +7 -0
  250. package/public/assets/flags/tj.svg +22 -0
  251. package/public/assets/flags/tk.svg +5 -0
  252. package/public/assets/flags/tl.svg +13 -0
  253. package/public/assets/flags/tm.svg +204 -0
  254. package/public/assets/flags/tn.svg +4 -0
  255. package/public/assets/flags/to.svg +10 -0
  256. package/public/assets/flags/tr.svg +8 -0
  257. package/public/assets/flags/tt.svg +5 -0
  258. package/public/assets/flags/tv.svg +9 -0
  259. package/public/assets/flags/tw.svg +34 -0
  260. package/public/assets/flags/tz.svg +13 -0
  261. package/public/assets/flags/ua.svg +6 -0
  262. package/public/assets/flags/ug.svg +30 -0
  263. package/public/assets/flags/um.svg +9 -0
  264. package/public/assets/flags/un.svg +16 -0
  265. package/public/assets/flags/us.svg +9 -0
  266. package/public/assets/flags/uy.svg +28 -0
  267. package/public/assets/flags/uz.svg +30 -0
  268. package/public/assets/flags/va.svg +190 -0
  269. package/public/assets/flags/vc.svg +8 -0
  270. package/public/assets/flags/ve.svg +26 -0
  271. package/public/assets/flags/vg.svg +59 -0
  272. package/public/assets/flags/vi.svg +28 -0
  273. package/public/assets/flags/vn.svg +11 -0
  274. package/public/assets/flags/vu.svg +21 -0
  275. package/public/assets/flags/wf.svg +5 -0
  276. package/public/assets/flags/ws.svg +7 -0
  277. package/public/assets/flags/xk.svg +5 -0
  278. package/public/assets/flags/xx.svg +4 -0
  279. package/public/assets/flags/ye.svg +7 -0
  280. package/public/assets/flags/yt.svg +5 -0
  281. package/public/assets/flags/za.svg +17 -0
  282. package/public/assets/flags/zm.svg +27 -0
  283. package/public/assets/flags/zw.svg +21 -0
  284. package/public/assets/fonts/Inter-OFL.txt +93 -0
  285. package/public/assets/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  286. package/public/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
  287. package/public/assets/icons.svg +200 -0
  288. package/public/assets/nyx-logo-256.png +0 -0
  289. package/public/assets/tacilytics-mark.svg +16 -0
  290. package/public/index.html +239 -0
  291. package/public/style.css +415 -0
  292. package/public/tacilytics.js +184 -0
  293. package/sdk/flutter/CHANGELOG.md +6 -0
  294. package/sdk/flutter/LICENSE +21 -0
  295. package/sdk/flutter/README.md +41 -0
  296. package/sdk/flutter/dart_test.yaml +3 -0
  297. package/sdk/flutter/lib/nyx_analytics.dart +13 -0
  298. package/sdk/flutter/lib/src/beacon.dart +87 -0
  299. package/sdk/flutter/lib/src/client.dart +177 -0
  300. package/sdk/flutter/lib/tacilytics.dart +13 -0
  301. package/sdk/flutter/pubspec.lock +221 -0
  302. package/sdk/flutter/pubspec.yaml +18 -0
  303. package/sdk/flutter/test/alias_test.dart +13 -0
  304. package/sdk/flutter/test/beacon_test.dart +46 -0
  305. package/sdk/flutter/test/e2e_test.dart +18 -0
  306. package/server.js +896 -0
  307. package/tz-country.js +32 -0
package/public/app.js ADDED
@@ -0,0 +1,788 @@
1
+ /* Tacilytics dashboard */
2
+ (function () {
3
+ var TOKEN = localStorage.getItem('nyx_token') || '';
4
+ // share mode: /share/<token> renders a read-only dashboard without login
5
+ var SHARE = (location.pathname.match(/^\/share\/([a-f0-9]{32,64})$/) || [])[1] || '';
6
+ var state = { site: localStorage.getItem('nyx_site') || '', period: '7d', from: '', to: '', sites: [], filters: {}, srcTab: 'sources', pageTab: 'pages' };
7
+ // which chart series are visible (toggled via the legend)
8
+ state.show = { visitors: true, pageviews: true };
9
+ try {
10
+ var savedShow = JSON.parse(localStorage.getItem('nyx_show') || 'null');
11
+ // Never allow both off — an empty chart looks like broken data, not like a choice.
12
+ if (savedShow && (savedShow.visitors || savedShow.pageviews)) {
13
+ state.show = { visitors: !!savedShow.visitors, pageviews: !!savedShow.pageviews };
14
+ }
15
+ } catch (e) { /* corrupt value → keep both visible */ }
16
+ var lastStats = null;
17
+ // dashboard segmentation filters (whitelist mirrors the server's FILTER_COLS + goal)
18
+ var FILTER_LABELS = { country: 'Country', source: 'Source', page: 'Page', browser: 'Browser', os: 'OS', device: 'Device', lang: 'Language', medium: 'Medium', campaign: 'Campaign', platform: 'Platform', version: 'App version', goal: 'Goal' };
19
+
20
+ // build the stats/realtime query string from the current site, period/range and active filters
21
+ function statsQuery() {
22
+ var q = 'site=' + encodeURIComponent(state.site);
23
+ if (SHARE) q += '&share=' + SHARE;
24
+ if (state.from && state.to) q += '&from=' + state.from + '&to=' + state.to;
25
+ else q += '&period=' + state.period;
26
+ Object.keys(state.filters).forEach(function (k) { q += '&' + k + '=' + encodeURIComponent(state.filters[k]); });
27
+ return q;
28
+ }
29
+ function setFilter(key, value) { if (!value || value === 'Direct') return; state.filters[key] = value; load(); }
30
+ function removeFilter(key) { delete state.filters[key]; load(); }
31
+ function clearFilters() { state.filters = {}; load(); }
32
+ var $ = function (id) { return document.getElementById(id); };
33
+ var ORIGIN = location.origin;
34
+ // chart palette comes from CSS custom properties (single source of truth for the theme)
35
+ var CSSV = getComputedStyle(document.documentElement);
36
+ function cv(name, fb) { return (CSSV.getPropertyValue(name) || '').trim() || fb; }
37
+ var COL = { v: cv('--chart-visitors', '#5b8def'), p: cv('--chart-pageviews', '#8b93a3'), red: cv('--red', '#f87171'), dim: cv('--text-dim', '#8b93a3'), line: cv('--line-strong', '#323946') };
38
+ // Escape any value that originates from visitor beacons before it touches innerHTML.
39
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
40
+
41
+ function api(path, opts) {
42
+ opts = opts || {};
43
+ opts.headers = Object.assign({ 'Content-Type': 'application/json', 'x-session-token': TOKEN }, opts.headers || {});
44
+ return fetch(path, opts).then(function (r) {
45
+ if (r.status === 401) { if (!SHARE) showLogin(); throw new Error('auth'); }
46
+ return r.json().catch(function () { return {}; });
47
+ });
48
+ }
49
+
50
+ // ── flags / names ──
51
+ // ── icons: UI = Lucide, brands = Simple Icons (sprite /assets/icons.svg), flags = flag-icons ──
52
+ var SPRITE = '/assets/icons.svg?v=1';
53
+ var BRAND_COLORS = {"apple": "#000000", "android": "#3DDC84", "linux": "#FCC624", "chrome": "#4285F4", "safari": "#006CFF", "firefox": "#FF7139", "samsung": "#1428A0", "opera": "#FF1B2D", "google": "#4285F4", "facebook": "#0866FF", "instagram": "#FF0069", "youtube": "#FF0000", "tiktok": "#000000", "x": "#000000", "duckduckgo": "#DE5833", "gmail": "#EA4335", "whatsapp": "#25D366", "pinterest": "#BD081C", "reddit": "#FF4500", "klarna": "#FFB3C7", "paypal": "#002991", "flutter": "#02569B", "swift": "#F05138", "kotlin": "#7F52FF", "react": "#61DAFB", "ecosia": "#008009", "brave": "#FB542B", "vivaldi": "#EF3939", "yandex": "#5282FF", "telegram": "#26A5E4", "ios": "#000000", "macos": "#000000", "ubuntu": "#E95420", "chromeos": "#4285F4", "twitch": "#9146FF", "github": "#181717", "discord": "#5865F2", "snapchat": "#FFFC00", "threads": "#000000", "bluesky": "#1185FE", "mastodon": "#6364FF", "signal": "#3B45FD"};
54
+ function isDark(hex) {
55
+ var h = hex.replace('#', ''); if (h.length !== 6) return false;
56
+ var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
57
+ return Math.max(r, g, b) < 90; // only near-black / deep navy marks; saturated reds stay
58
+ }
59
+ function ico(id, cls) { return '<svg class="ico ' + (cls || '') + '" aria-hidden="true"><use href="' + SPRITE + '#' + id + '"></use></svg>'; }
60
+ function brand(name, fallbackUi) {
61
+ if (!name) return ico(fallbackUi || 'i-other', 'ui');
62
+ var c = BRAND_COLORS[name];
63
+ if (name === 'samsung') c = '#6f8dff'; // Samsung navy is unreadable on dark — same hue, lighter
64
+ if (c && isDark(c)) c = '#e7f3f1'; // black logos (Apple, X, GitHub …) would vanish on the dark UI
65
+ return '<svg class="ico brand" aria-hidden="true"' + (c ? ' style="color:' + c + '"' : '') + '><use href="' + SPRITE + '#b-' + name + '"></use></svg>';
66
+ }
67
+ function flagIcon(cc) {
68
+ if (!cc || !/^[A-Za-z]{2}$/.test(cc)) return ico('i-globe', 'ui');
69
+ return '<img class="flag" src="/assets/flags/' + cc.toLowerCase() + '.svg" alt="" loading="lazy" width="18" height="14">';
70
+ }
71
+ var BROWSER_ICON = { Chrome: 'chrome', Safari: 'safari', Firefox: 'firefox', Samsung: 'samsung', Opera: 'opera', Vivaldi: 'vivaldi', Brave: 'brave', Yandex: 'yandex' };
72
+ var OS_ICON = { Android: 'android', iOS: 'apple', macOS: 'apple', Linux: 'linux', ChromeOS: 'chrome' };
73
+ var PLATFORM_ICON = { android: 'android', ios: 'apple', macos: 'apple', linux: 'linux' };
74
+ function sourceBrand(n) {
75
+ var h = String(n || '').toLowerCase();
76
+ var map = [['google', 'google'], ['gmail', 'gmail'], ['.gm', 'gmail'], ['facebook', 'facebook'], ['fb', 'facebook'], ['instagram', 'instagram'], ['ig', 'instagram'],
77
+ ['youtube', 'youtube'], ['tiktok', 'tiktok'], ['t.co', 'x'], ['twitter', 'x'], ['x.com', 'x'], ['duckduckgo', 'duckduckgo'], ['ecosia', 'ecosia'], ['reddit', 'reddit'],
78
+ ['pinterest', 'pinterest'], ['whatsapp', 'whatsapp'], ['telegram', 'telegram'], ['github', 'github'], ['paypal', 'paypal'], ['klarna', 'klarna'], ['discord', 'discord'],
79
+ ['twitch', 'twitch'], ['snapchat', 'snapchat'], ['threads', 'threads'], ['bsky', 'bluesky'], ['mastodon', 'mastodon']];
80
+ for (var i = 0; i < map.length; i++) {
81
+ var k = map[i][0];
82
+ if (h === k || h.indexOf(k + '.') === 0 || h.indexOf('.' + k + '.') >= 0 || h.indexOf(k) >= 0 && k.length > 3) return map[i][1];
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function flag(cc) { return flagIcon(cc); } // returns HTML now (img), never a text emoji
88
+ var NAMES = { DE: 'Germany', AT: 'Austria', CH: 'Switzerland', GB: 'United Kingdom', US: 'United States', FR: 'France', NL: 'Netherlands', ES: 'Spain', IT: 'Italy', SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland', CZ: 'Czechia', BE: 'Belgium', IE: 'Ireland', PT: 'Portugal', CA: 'Canada', AU: 'Australia', BR: 'Brazil', IN: 'India', JP: 'Japan', CN: 'China', RU: 'Russia', TR: 'Turkey', MX: 'Mexico', HU: 'Hungary', RO: 'Romania', GR: 'Greece', UA: 'Ukraine', HR: 'Croatia', NZ: 'New Zealand', SG: 'Singapore', KR: 'South Korea', IL: 'Israel', AE: 'UAE', ZA: 'South Africa' };
89
+
90
+ // ── login ──
91
+ function showLogin() { $('login').classList.remove('hide'); $('app').classList.add('hide'); }
92
+ function hideLogin() { $('login').classList.add('hide'); $('app').classList.remove('hide'); }
93
+ function login() {
94
+ var pw = $('pw').value;
95
+ fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) })
96
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
97
+ .then(function (res) {
98
+ if (!res.ok) { $('loginErr').textContent = res.j.error || 'Wrong password'; return; }
99
+ TOKEN = res.j.token; localStorage.setItem('nyx_token', TOKEN); $('loginErr').textContent = '';
100
+ boot();
101
+ })
102
+ .catch(function () { $('loginErr').textContent = 'Connection error'; });
103
+ }
104
+ $('loginBtn').onclick = login;
105
+ $('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') login(); });
106
+ $('logoutBtn').onclick = function () { localStorage.removeItem('nyx_token'); TOKEN = ''; showLogin(); };
107
+
108
+ // ── sites ──
109
+ function loadSites() {
110
+ return api('/api/sites').then(function (sites) {
111
+ state.sites = sites;
112
+ var sel = $('siteSelect');
113
+ sel.innerHTML = sites.map(function (s) { return '<option value="' + esc(s.domain) + '">' + esc(s.domain) + '</option>'; }).join('');
114
+ if (!sites.length) { sel.innerHTML = '<option>no sites yet</option>'; return; }
115
+ if (!state.site || !sites.some(function (s) { return s.domain === state.site; })) state.site = sites[0].domain;
116
+ sel.value = state.site;
117
+ });
118
+ }
119
+ $('siteSelect').onchange = function () { state.site = this.value; localStorage.setItem('nyx_site', state.site); load(); if (typeof loadFunnels === 'function') loadFunnels(); };
120
+
121
+ $('addSiteBtn').onclick = function () { $('addModal').classList.remove('hide'); $('newDomain').value = ''; $('addErr').textContent = ''; $('newDomain').focus(); };
122
+ $('cancelAdd').onclick = function () { $('addModal').classList.add('hide'); };
123
+ $('createSiteBtn').onclick = function () {
124
+ var d = $('newDomain').value.trim();
125
+ api('/api/sites', { method: 'POST', body: JSON.stringify({ domain: d }) }).then(function (s) {
126
+ if (s.error) { $('addErr').textContent = s.error; return; }
127
+ $('addModal').classList.add('hide');
128
+ state.site = s.domain; localStorage.setItem('nyx_site', state.site);
129
+ loadSites().then(function () { $('siteSelect').value = state.site; load(); showSnippet(s.domain); });
130
+ });
131
+ };
132
+ $('newDomain').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('createSiteBtn').click(); });
133
+
134
+ // ── snippet: website, Flutter app, any other app (same site, platform tells them apart) ──
135
+ var SNIP_REPO = 'https://github.com/Open-Cosmic-Software/tacilytics';
136
+ state.snipTab = 'web';
137
+ function snippetFor(domain) {
138
+ return '<script defer data-domain="' + domain + '" src="' + ORIGIN + '/tacilytics.js"><\/script>';
139
+ }
140
+ function snipParts(domain, tab) {
141
+ if (tab === 'flutter') return {
142
+ s1: '1 · Add the package to <code>pubspec.yaml</code>, then run <code>flutter pub get</code>:',
143
+ c1: 'dependencies:\n tacilytics:\n git:\n url: ' + SNIP_REPO + '\n path: sdk/flutter',
144
+ s2: '2 · Start it in <code>main()</code> — screens are tracked automatically:',
145
+ c2: "import 'package:tacilytics/tacilytics.dart';\n\nvoid main() {\n Tacilytics.init(\n endpoint: '" + ORIGIN + "',\n domain: '" + domain + "',\n appVersion: '1.0.0',\n enabled: true,\n );\n runApp(MaterialApp(\n navigatorObservers: [TacilyticsRouteObserver()],\n home: const MyHome(),\n ));\n}",
146
+ hint: 'Goals &amp; revenue anywhere in your code:<br><code>Tacilytics.instance.event(\'Signup\')</code> · <code>.search(\'term\', 6)</code> · <code>.purchase(value: 49.90)</code><br><span style="opacity:.7">No cookies, no device id. Want it only after consent? Leave out <code>enabled: true</code> and call <code>setEnabled(true)</code> later.</span>'
147
+ };
148
+ if (tab === 'other') return {
149
+ s1: 'Swift, Kotlin, React Native, Unity … — send one JSON <code>POST</code> per screen or event:',
150
+ c1: 'POST ' + ORIGIN + '/api/collect\nContent-Type: application/json\nUser-Agent: MyApp/1.0 (android)\n\n{\n "d": "' + domain + '",\n "p": "/product/42",\n "pf": "android",\n "sid": "<random id, new per app launch>",\n "av": "1.0.0"\n}',
151
+ s2: 'Event with properties (e.g. a purchase) — add a name and props:',
152
+ c2: '{\n "d": "' + domain + '", "p": "/checkout", "pf": "ios", "sid": "…",\n "n": "Purchase",\n "pr": { "value": "49.90", "currency": "EUR" }\n}',
153
+ hint: '<code>pf</code>: android · ios · macos · windows · linux. <code>sid</code>: 16–64 random chars, kept in memory only — never a device id. All fields: <a href="' + SNIP_REPO + '#mobile--desktop-apps" target="_blank" rel="noopener">README</a>.'
154
+ };
155
+ return {
156
+ s1: 'Paste this once into the <code>&lt;head&gt;</code> of your site:',
157
+ c1: snippetFor(domain), s2: '', c2: '',
158
+ hint: 'Track goals &amp; properties from your code:<br><code>tacilytics(\'Signup\')</code> · <code>tacilytics(\'Signup\', { plan: \'pro\' })</code><br><span style="opacity:.7">Privacy flags on the tag: <code>data-honor-dnt</code> · <code>data-track-outbound</code></span>'
159
+ };
160
+ }
161
+ function renderSnippet() {
162
+ var d = $('snipDomain').textContent, p = snipParts(d, state.snipTab);
163
+ $('snipStep1').innerHTML = p.s1;
164
+ $('snippetCode').textContent = p.c1;
165
+ $('snipStep2').innerHTML = p.s2;
166
+ $('snippetCode2').textContent = p.c2;
167
+ $('snippetCode2').classList.toggle('hide', !p.c2);
168
+ $('copySnippet2').classList.toggle('hide', !p.c2);
169
+ $('copySnippet').textContent = p.c2 ? 'Copy step 1' : 'Copy';
170
+ $('snipHint').innerHTML = p.hint;
171
+ }
172
+ function showSnippet(domain) {
173
+ $('snipDomain').textContent = domain;
174
+ renderSnippet();
175
+ $('snippetModal').classList.remove('hide');
176
+ }
177
+ function copyFrom(btnId, codeId, label) {
178
+ navigator.clipboard.writeText($(codeId).textContent).then(function () {
179
+ $(btnId).textContent = 'Copied'; setTimeout(function () { renderSnippet(); if (label) $(btnId).textContent = label; }, 1500);
180
+ });
181
+ }
182
+ // ── CSV export of the current view ──
183
+ $('exportBtn').onclick = function () {
184
+ if (!lastStats) return;
185
+ var s = lastStats, rows = [['metric', 'name', 'visitors', 'pageviews']];
186
+ rows.push(['summary', 'visitors', s.totals.visitors, '']);
187
+ rows.push(['summary', 'pageviews', '', s.totals.pageviews]);
188
+ rows.push(['summary', 'bounce_rate', s.totals.bounce + '%', '']);
189
+ rows.push(['summary', 'avg_visit_seconds', s.totals.duration == null ? '' : s.totals.duration, '']);
190
+ [['page', s.pages], ['entry_page', s.entryPages], ['exit_page', s.exitPages], ['source', s.sources], ['medium', s.mediums], ['campaign', s.campaigns], ['country', s.countries], ['browser', s.browsers], ['os', s.os], ['device', s.devices], ['language', s.languages], ['goal', s.goals]].forEach(function (pair) {
191
+ (pair[1] || []).forEach(function (r) { rows.push([pair[0], r.name, r.visitors, r.pageviews]); });
192
+ });
193
+ var csv = rows.map(function (r) { return r.map(function (c) { return '"' + String(c == null ? '' : c).replace(/"/g, '""') + '"'; }).join(','); }).join('\n');
194
+ var blob = new Blob([csv], { type: 'text/csv' });
195
+ var a = document.createElement('a');
196
+ a.href = URL.createObjectURL(blob);
197
+ a.download = 'tacilytics-' + state.site + '-' + state.period + '.csv';
198
+ a.click();
199
+ URL.revokeObjectURL(a.href);
200
+ };
201
+
202
+ $('snippetBtn').onclick = function () { if (state.site) showSnippet(state.site); };
203
+ $('closeSnippet').onclick = function () { $('snippetModal').classList.add('hide'); };
204
+ $('copySnippet').onclick = function () { copyFrom('copySnippet', 'snippetCode'); };
205
+ $('copySnippet2').onclick = function () { copyFrom('copySnippet2', 'snippetCode2', 'Copy step 2'); };
206
+
207
+ // ── live visitors (last 30 min) ──
208
+ var liveTimer = null;
209
+ function openLive() { if (!state.site || state.site === 'no sites yet') return; $('liveModal').classList.remove('hide'); fetchLive(); clearInterval(liveTimer); liveTimer = setInterval(fetchLive, 8000); }
210
+ function closeLive() { $('liveModal').classList.add('hide'); clearInterval(liveTimer); liveTimer = null; }
211
+ function fetchLive() {
212
+ if (document.hidden) return;
213
+ api('/api/realtime?site=' + encodeURIComponent(state.site)).then(function (r) {
214
+ if (r.error) return;
215
+ $('live-online').textContent = r.online;
216
+ renderSpark(r.minutes);
217
+ renderFeed(r.recent, r.now);
218
+ }).catch(function () {});
219
+ }
220
+ function renderSpark(minutes) {
221
+ var svg = $('live-spark'), W = 600, H = 70, n = minutes.length;
222
+ var max = Math.max.apply(null, minutes.map(function (m) { return m.pageviews; }).concat([1]));
223
+ var bw = W / n;
224
+ svg.innerHTML = minutes.map(function (m, i) {
225
+ var h = m.pageviews ? Math.max(3, Math.round((m.pageviews / max) * (H - 6))) : 0;
226
+ return '<rect x="' + (i * bw + 1).toFixed(1) + '" y="' + (H - h) + '" width="' + (bw - 2).toFixed(1) + '" height="' + h + '" rx="2" fill="' + (i >= n - 5 ? COL.v : COL.line) + '"></rect>';
227
+ }).join('');
228
+ }
229
+ function timeAgo(ts, now) {
230
+ var s = Math.max(0, Math.round((now - ts) / 1000));
231
+ if (s < 8) return 'just now';
232
+ if (s < 60) return s + 's ago';
233
+ return Math.round(s / 60) + 'm ago';
234
+ }
235
+ function renderFeed(recent, now) {
236
+ var el = $('live-feed');
237
+ if (!recent || !recent.length) { el.innerHTML = '<div class="empty">No visitors in the last 30 minutes</div>'; return; }
238
+ el.innerHTML = recent.map(function (e) {
239
+ var loc = e.country ? flagIcon(e.country) : ico('i-globe', 'ui');
240
+ var what = e.name ? '<span class="goal">' + ico('i-goal', 'ui') + ' ' + esc(e.name) + '</span>' : '<span class="path">' + esc(e.path) + '</span>';
241
+ var src = (e.source && e.source !== 'Direct') ? 'via ' + e.source : '';
242
+ var meta = [e.platform ? platformLabel(e.platform) : e.browser, e.device, src].filter(Boolean).map(esc).join(' · ');
243
+ return '<div class="feed-row"><span class="fl" title="' + esc(e.country || e.tz || '') + '">' + loc + '</span>' +
244
+ '<div class="fw">' + what + '<span class="fm">' + meta + '</span></div>' +
245
+ '<span class="ft">' + timeAgo(e.ts, now) + '</span></div>';
246
+ }).join('');
247
+ }
248
+ $('realtimeBtn').onclick = openLive;
249
+ $('closeLive').onclick = closeLive;
250
+
251
+ // ── periods ──
252
+ Array.prototype.forEach.call(document.querySelectorAll('#periods button'), function (b) {
253
+ b.onclick = function () {
254
+ document.querySelectorAll('#periods button').forEach(function (x) { x.classList.remove('active'); });
255
+ b.classList.add('active'); state.period = b.dataset.p;
256
+ state.from = ''; state.to = ''; $('fromDate').value = ''; $('toDate').value = ''; // clear custom range
257
+ load();
258
+ };
259
+ });
260
+
261
+ // ── custom date range ──
262
+ $('applyRange').onclick = function () {
263
+ var f = $('fromDate').value, t = $('toDate').value;
264
+ if (!f || !t) return;
265
+ if (f > t) { var tmp = f; f = t; t = tmp; $('fromDate').value = f; $('toDate').value = t; }
266
+ state.from = f; state.to = t;
267
+ document.querySelectorAll('#periods button').forEach(function (x) { x.classList.remove('active'); });
268
+ load();
269
+ };
270
+
271
+ // ── panel tabs (Pages + Sources) ──
272
+ function wireTabs(id, key, rerender) {
273
+ Array.prototype.forEach.call(document.querySelectorAll('#' + id + ' button'), function (b) {
274
+ b.onclick = function () {
275
+ document.querySelectorAll('#' + id + ' button').forEach(function (x) { x.classList.remove('active'); });
276
+ b.classList.add('active'); state[key] = b.dataset.t; rerender();
277
+ };
278
+ });
279
+ }
280
+ wireTabs('pageTabs', 'pageTab', renderPagesPanel);
281
+ wireTabs('srcTabs', 'srcTab', renderSourcesPanel);
282
+ wireTabs('searchTabs', 'searchTab', function () { renderSearch(); });
283
+ wireTabs('pfTabs', 'pfTab', function () { renderPlatformsPanel(); });
284
+ wireTabs('snipTabs', 'snipTab', renderSnippet);
285
+
286
+ // ── rendering ──
287
+ function fmt(n) { n = n || 0; if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; return String(n); }
288
+ function trend(id, p) {
289
+ var el = $(id);
290
+ if (p == null) { el.textContent = ''; el.className = 'trend'; return; }
291
+ var up = p >= 0;
292
+ el.textContent = (up ? '↑ ' : '↓ ') + Math.abs(p) + '%';
293
+ el.className = 'trend ' + (up ? 'up' : 'down');
294
+ }
295
+
296
+ function bars(elId, rows, opts) {
297
+ opts = opts || {};
298
+ var el = $(elId);
299
+ if (!rows || !rows.length) { el.innerHTML = '<div class="empty">No data yet</div>'; return; }
300
+ var max = Math.max.apply(null, rows.map(function (r) { return r.visitors || 0; })) || 1;
301
+ var fk = opts.filterKey; // when set, clicking a row drills into that segment
302
+ el.innerHTML = rows.map(function (r) {
303
+ var label = opts.label ? opts.label(r) : (r.name || '—');
304
+ var w = Math.max(3, Math.round(((r.visitors || 0) / max) * 100));
305
+ var val = opts.value ? opts.value(r) : r.name;
306
+ var clickable = fk && val && val !== 'Direct';
307
+ var cr = opts.cr && r.cr != null ? '<span class="cr">' + r.cr + '%</span>' : '';
308
+ return '<div class="bar' + (clickable ? ' clickable' : '') + '"' + (clickable ? ' data-fk="' + esc(fk) + '" data-fv="' + esc(val) + '"' : '') + ' title="' + esc(label) + '">' +
309
+ '<div class="fill" style="width:' + w + '%"></div>' +
310
+ '<span class="name">' + (opts.icon ? opts.icon(r) : '') + '<span class="txt">' + esc(label) + '</span></span><span class="val">' + fmt(r.visitors) + cr + '</span></div>';
311
+ }).join('');
312
+ if (fk) Array.prototype.forEach.call(el.querySelectorAll('.bar.clickable'), function (b) {
313
+ b.onclick = function () { setFilter(b.getAttribute('data-fk'), b.getAttribute('data-fv')); };
314
+ });
315
+ }
316
+
317
+ // seconds → "1m 23s" / "45s"
318
+ function fmtDur(s) {
319
+ if (s == null) return '—';
320
+ if (s < 60) return s + 's';
321
+ var m = Math.floor(s / 60), r = s % 60;
322
+ return m + 'm' + (r ? ' ' + r + 's' : '');
323
+ }
324
+
325
+ // ── segmentation filter chips ──
326
+ function renderFilters() {
327
+ var el = $('filters');
328
+ var keys = Object.keys(state.filters);
329
+ if (!keys.length) { el.classList.add('hide'); el.innerHTML = ''; return; }
330
+ el.classList.remove('hide');
331
+ var chips = keys.map(function (k) {
332
+ var v = state.filters[k];
333
+ var disp = k === 'country' ? (flagIcon(v) + ' ' + esc(NAMES[v] || v)) : k === 'platform' ? (platformIcon(v) + ' ' + esc(platformLabel(v))) : esc(v);
334
+ return '<span class="chip"><span class="k">' + esc(FILTER_LABELS[k] || k) + '</span>' + disp +
335
+ '<button data-rm="' + esc(k) + '" title="Remove" aria-label="Remove filter">' + ico('i-x', 'ui') + '</button></span>';
336
+ }).join('');
337
+ el.innerHTML = '<span class="flabel">Filtered by</span>' + chips + '<span class="chip clear" id="clearFilters">Clear all</span>';
338
+ Array.prototype.forEach.call(el.querySelectorAll('[data-rm]'), function (b) { b.onclick = function () { removeFilter(b.getAttribute('data-rm')); }; });
339
+ var ce = $('clearFilters'); if (ce) ce.onclick = clearFilters;
340
+ }
341
+
342
+ // Smooth line through points (Catmull-Rom → cubic Bézier), clamped so it never dips below the baseline.
343
+ function smoothPath(pts, floorY) {
344
+ if (!pts.length) return '';
345
+ if (pts.length < 3) return pts.map(function (p, i) { return (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1); }).join(' ');
346
+ var d = 'M' + pts[0][0].toFixed(1) + ' ' + pts[0][1].toFixed(1);
347
+ for (var i = 0; i < pts.length - 1; i++) {
348
+ var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
349
+ var c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = Math.min(floorY, p1[1] + (p2[1] - p0[1]) / 6);
350
+ var c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = Math.min(floorY, p2[1] - (p3[1] - p1[1]) / 6);
351
+ d += ' C' + c1x.toFixed(1) + ' ' + c1y.toFixed(1) + ' ' + c2x.toFixed(1) + ' ' + c2y.toFixed(1) + ' ' + p2[0].toFixed(1) + ' ' + p2[1].toFixed(1);
352
+ }
353
+ return d;
354
+ }
355
+ // Mini sparkline at the bottom of a KPI card.
356
+ function sparkline(cardValueId, values, color) {
357
+ var card = $(cardValueId) && $(cardValueId).parentNode; if (!card) return;
358
+ var old = card.querySelector('svg.spark'); if (old) old.remove();
359
+ if (!values || values.length < 2) return;
360
+ var W = 200, H = 38, mx = Math.max.apply(null, values) || 1;
361
+ var pts = values.map(function (v, i) { return [(i * W) / (values.length - 1), H - 3 - (v / mx) * (H - 8)]; });
362
+ var line = smoothPath(pts, H - 1);
363
+ var gid = 'sg-' + cardValueId;
364
+ var svg = '<svg class="spark" viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" aria-hidden="true"><defs><linearGradient id="' + gid + '" x1="0" y1="0" x2="0" y2="1">' +
365
+ '<stop offset="0%" stop-color="' + color + '" stop-opacity=".28"/><stop offset="100%" stop-color="' + color + '" stop-opacity="0"/></linearGradient></defs>' +
366
+ '<path d="' + line + ' L' + W + ' ' + H + ' L0 ' + H + ' Z" fill="url(#' + gid + ')"/>' +
367
+ '<path d="' + line + '" fill="none" stroke="' + color + '" stroke-width="1.6" vector-effect="non-scaling-stroke" opacity=".9"/></svg>';
368
+ card.insertAdjacentHTML('beforeend', svg);
369
+ }
370
+ function drawChart(series) {
371
+ var svg = $('chart'); var W = 800, H = 220, pad = 8;
372
+ var showV = state.show.visitors, showP = state.show.pageviews;
373
+ // Only visible series define the scale — otherwise hiding a line would not zoom in.
374
+ var max = Math.max(1, Math.max.apply(null, series.map(function (d) {
375
+ return Math.max(showV ? d.visitors : 0, showP ? d.pageviews : 0);
376
+ })));
377
+ var n = series.length;
378
+ var x = function (i) { return pad + (i * (W - 2 * pad)) / Math.max(1, n - 1); };
379
+ var y = function (v) { return H - pad - (v / max) * (H - 2 * pad); };
380
+ function path(key) { return smoothPath(series.map(function (d, i) { return [x(i), y(d[key])]; }), H - pad); }
381
+ var areaP = path('visitors') + ' L' + x(n - 1).toFixed(1) + ' ' + (H - pad) + ' L' + x(0).toFixed(1) + ' ' + (H - pad) + ' Z';
382
+ var hovers = series.map(function (d, i) {
383
+ var w = (W - 2 * pad) / Math.max(1, n);
384
+ return '<rect x="' + (x(i) - w / 2).toFixed(1) + '" y="0" width="' + w.toFixed(1) + '" height="' + H + '" fill="transparent" ' +
385
+ 'data-i="' + i + '"></rect>';
386
+ }).join('');
387
+ svg.innerHTML =
388
+ '<defs><linearGradient id="ag" x1="0" y1="0" x2="0" y2="1">' +
389
+ '<stop offset="0%" stop-color="' + COL.v + '" stop-opacity="0.32"/><stop offset="100%" stop-color="' + COL.v + '" stop-opacity="0"/>' +
390
+ '</linearGradient></defs>' +
391
+ [0.25, 0.5, 0.75].map(function (f) { var gy = (pad + f * (H - 2 * pad)).toFixed(1); return '<line x1="0" x2="' + W + '" y1="' + gy + '" y2="' + gy + '" stroke="rgba(255,255,255,.05)" stroke-dasharray="2 6" vector-effect="non-scaling-stroke"/>'; }).join('') +
392
+ (showV ? '<path d="' + areaP + '" fill="url(#ag)"/>' : '') +
393
+ (showP ? '<path d="' + path('pageviews') + '" fill="none" stroke="' + COL.p + '" stroke-width="1.6" stroke-dasharray="4 4" opacity="0.85"/>' : '') +
394
+ (showV ? '<path d="' + path('visitors') + '" fill="none" stroke="' + COL.v + '" stroke-width="2.4" stroke-linecap="round"/>' : '') +
395
+ (showV && n <= 40 ? series.map(function (d, i) { return '<circle cx="' + x(i).toFixed(1) + '" cy="' + y(d.visitors).toFixed(1) + '" r="2.4" fill="' + COL.v + '" stroke="#071114" stroke-width="1.5"/>'; }).join('') : '') +
396
+ hovers;
397
+ var tip = $('tip');
398
+ Array.prototype.forEach.call(svg.querySelectorAll('rect'), function (rect) {
399
+ rect.addEventListener('mousemove', function (e) {
400
+ var d = series[+rect.dataset.i];
401
+ tip.innerHTML = '<strong>' + esc(d.label) + '</strong>' +
402
+ (showV ? '<br>' + d.visitors + ' visitors' : '') +
403
+ (showP ? '<br>' + d.pageviews + ' views' : '');
404
+ tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 10) + 'px'; tip.style.opacity = 1;
405
+ });
406
+ rect.addEventListener('mouseleave', function () { tip.style.opacity = 0; });
407
+ });
408
+ }
409
+
410
+ // ── tab-aware panels (Pages: Top/Entry/Exit · Sources: Source/Medium/Campaign) ──
411
+ function renderPagesPanel() {
412
+ var s = lastStats; if (!s) return;
413
+ bars('p-pages', s[state.pageTab] || s.pages, { filterKey: 'page', label: function (r) { return r.name; } });
414
+ }
415
+ // readable names for app/short referrers — the raw host stays in the data and the tooltip
416
+ var SOURCE_NAMES = {
417
+ 'com.google.android.googlequicksearchbox': 'Google App (Android)', 'com.google.android.gm': 'Gmail App',
418
+ 'fb': 'Facebook', 'm.facebook.com': 'Facebook (mobile)', 'l.facebook.com': 'Facebook', 'lm.facebook.com': 'Facebook',
419
+ 'ig': 'Instagram', 'l.instagram.com': 'Instagram', 'homescreen': 'Homescreen (app shortcut)',
420
+ 'android-app://com.google.android.googlequicksearchbox': 'Google App (Android)', 't.co': 'X / Twitter',
421
+ };
422
+ var PLATFORM_NAMES = { web: 'Web', android: 'Android app', ios: 'iOS app', macos: 'macOS app', windows: 'Windows app', linux: 'Linux app' };
423
+ function platformIcon(n) { return n === 'web' ? ico('i-web', 'ui') : n === 'windows' ? ico('i-desktop', 'ui') : brand(PLATFORM_ICON[n], 'i-mobile'); }
424
+ function platformLabel(n) { return PLATFORM_NAMES[n] || n; }
425
+ state.pfTab = 'platforms';
426
+ function renderPlatformsPanel() {
427
+ var s = lastStats; if (!s) return;
428
+ var w = $('w-platforms');
429
+ // only interesting once an app reports — a site that is web-only keeps its old layout
430
+ var hasApp = (s.platforms || []).some(function (r) { return r.name !== 'web'; }) || state.filters.platform;
431
+ if (!hasApp) { w.classList.add('hide'); return; }
432
+ w.classList.remove('hide');
433
+ if (state.pfTab === 'appVersions') bars('p-platforms', s.appVersions || [], { filterKey: 'version', icon: function () { return ico('i-layers', 'ui'); }, label: function (r) { return 'v' + r.name; } });
434
+ else bars('p-platforms', s.platforms || [], { filterKey: 'platform', icon: function (r) { return platformIcon(r.name); }, label: function (r) { return platformLabel(r.name); } });
435
+ }
436
+ function sourceLabel(n) { if (n === 'Direct') return 'Direct / none'; var nice = SOURCE_NAMES[n]; return nice ? nice + ' · ' + n : n; }
437
+ function renderSourcesPanel() {
438
+ var s = lastStats; if (!s) return;
439
+ var tab = state.srcTab, fk = tab === 'mediums' ? 'medium' : tab === 'campaigns' ? 'campaign' : 'source';
440
+ bars('p-sources', s[tab] || [], { filterKey: fk, icon: function (r) { if (tab !== 'sources') return ico(tab === 'campaigns' ? 'i-sparkles' : 'i-sources', 'ui'); if (r.name === 'Direct') return ico('i-direct', 'ui'); if (r.name === 'homescreen') return ico('i-mobile', 'ui'); return brand(sourceBrand(r.name), 'i-sources'); }, label: function (r) { return tab === 'sources' ? sourceLabel(r.name) : r.name; } });
441
+ var el = $('p-sources');
442
+ if (tab === 'sources' && s.paymentReturns && s.paymentReturns.length) {
443
+ var n = s.paymentReturns.reduce(function (a, r) { return a + (r.visitors || 0); }, 0);
444
+ var who = s.paymentReturns.map(function (r) { return r.name; }).join(', ');
445
+ el.insertAdjacentHTML('beforeend', '<p class="pay-note" title="' + esc(who) + '">' + ico('i-pay', 'ui') + ' ' + fmt(n) + ' returning from payment (' + esc(who) + ') — not counted as a source</p>');
446
+ }
447
+ }
448
+
449
+ function money(v, cur) {
450
+ try { return new Intl.NumberFormat(navigator.language || 'de-DE', { style: 'currency', currency: cur || 'EUR', maximumFractionDigits: v >= 1000 ? 0 : 2 }).format(v); }
451
+ catch (e) { return (Math.round(v * 100) / 100) + ' ' + (cur || ''); }
452
+ }
453
+ function pctTxt(p) { return p == null ? '—' : (p >= 0 ? '↑ ' : '↓ ') + Math.abs(p) + '%'; }
454
+ function pctCls(p) { return p == null ? '' : (p >= 0 ? 'up' : 'down'); }
455
+
456
+ // previous-period trend already sits in the stat cards; last year only appears once data reaches back that far
457
+ function renderCompare(s) {
458
+ ['visitors', 'pageviews'].forEach(function (k) {
459
+ var el = $('y-' + k); if (!el) return;
460
+ if (!s.yoy || s.yoy[k] == null) { el.classList.add('hide'); el.textContent = ''; return; }
461
+ el.classList.remove('hide');
462
+ el.className = 'yoy ' + pctCls(s.yoy[k]);
463
+ el.textContent = pctTxt(s.yoy[k]) + ' vs last year';
464
+ });
465
+ }
466
+
467
+ function renderInsights(s) {
468
+ renderCompare(s);
469
+ // commerce cards + revenue by source
470
+ var c = s.commerce, cw = $('commerce');
471
+ if (c) {
472
+ cw.classList.remove('hide');
473
+ $('c-revenue').textContent = money(c.revenue);
474
+ $('c-orders').textContent = fmt(c.orders);
475
+ $('c-aov').textContent = money(c.aov);
476
+ $('c-cr').textContent = c.cr + '%';
477
+ trend('tc-revenue', c.trend && c.trend.revenue);
478
+ trend('tc-orders', c.trend && c.trend.orders);
479
+ $('w-revsrc').classList.remove('hide');
480
+ var pfs = c.byPlatform || [];
481
+ if (pfs.some(function (r) { return r.name !== 'web'; })) {
482
+ $('w-revpf').classList.remove('hide');
483
+ var maxP = Math.max.apply(null, pfs.map(function (r) { return r.revenue; }).concat([1]));
484
+ $('p-revpf').innerHTML = pfs.map(function (r) {
485
+ var w = Math.max(3, Math.round((r.revenue / maxP) * 100));
486
+ return '<div class="bar clickable" data-fk="platform" data-fv="' + esc(r.name) + '"><div class="fill" style="width:' + w + '%"></div><span class="name">' + esc(platformLabel(r.name)) + '</span><span class="val">' + money(r.revenue) + '<span class="cr">' + r.orders + '×</span></span></div>';
487
+ }).join('');
488
+ Array.prototype.forEach.call($('p-revpf').querySelectorAll('.bar.clickable'), function (b) { b.onclick = function () { setFilter('platform', b.getAttribute('data-fv')); }; });
489
+ } else $('w-revpf').classList.add('hide');
490
+ var maxR = Math.max.apply(null, c.bySource.map(function (r) { return r.revenue; }).concat([1]));
491
+ $('p-revsrc').innerHTML = c.bySource.map(function (r) {
492
+ var w = Math.max(3, Math.round((r.revenue / maxR) * 100));
493
+ return '<div class="bar" title="' + esc(r.name) + '"><div class="fill" style="width:' + w + '%"></div><span class="name">' + esc(sourceLabel(r.name)) + '</span><span class="val">' + money(r.revenue) + '<span class="cr">' + r.orders + '×</span></span></div>';
494
+ }).join('') || '<div class="empty">No data yet</div>';
495
+ } else { cw.classList.add('hide'); $('w-revsrc').classList.add('hide'); $('w-revpf').classList.add('hide'); }
496
+ // site search
497
+ if (s.search) {
498
+ $('w-search').classList.remove('hide');
499
+ $('searchMeta').textContent = fmt(s.search.total) + ' searches · ' + s.search.zeroShare + '% without results';
500
+ renderSearch();
501
+ } else $('w-search').classList.add('hide');
502
+ // 404
503
+ if (s.notFound && s.notFound.length) {
504
+ $('w-404').classList.remove('hide');
505
+ bars('p-404', s.notFound, { label: function (r) { return r.name + (r.fromPage ? ' ← ' + r.fromPage : ''); } });
506
+ } else $('w-404').classList.add('hide');
507
+ // scroll depth
508
+ if (s.scrollPages && s.scrollPages.length) {
509
+ $('w-scroll').classList.remove('hide');
510
+ $('p-scroll').innerHTML = s.scrollPages.map(function (r) {
511
+ return '<div class="bar" title="' + esc(r.name) + ' — ' + r.pageviews + ' measured views"><div class="fill" style="width:' + Math.max(3, r.avg) + '%"></div><span class="name">' + esc(r.name) + '</span><span class="val">' + r.avg + '%<span class="cr">' + r.deep + '% deep</span></span></div>';
512
+ }).join('');
513
+ } else $('w-scroll').classList.add('hide');
514
+ }
515
+ state.searchTab = 'top';
516
+ function renderSearch() {
517
+ var s = lastStats; if (!s || !s.search) return;
518
+ var rows = s.search[state.searchTab] || [];
519
+ bars('p-search', rows, { label: function (r) { return '“' + r.name + '”' + (state.searchTab === 'top' && r.zero ? ' · ' + r.zero + '× no results' : ''); } });
520
+ }
521
+ function renderProps(s) {
522
+ var wrap = $('propsWrap');
523
+ if (!s.properties || !s.properties.length) { wrap.classList.add('hide'); wrap.innerHTML = ''; return; }
524
+ wrap.classList.remove('hide');
525
+ wrap.innerHTML = s.properties.map(function (p) {
526
+ var rows = p.values.map(function (v) {
527
+ var max = Math.max.apply(null, p.values.map(function (x) { return x.visitors || 0; })) || 1;
528
+ var w = Math.max(3, Math.round(((v.visitors || 0) / max) * 100));
529
+ return '<div class="bar"><div class="fill" style="width:' + w + '%"></div><span class="name">' + esc(v.name) + '</span><span class="val">' + fmt(v.visitors) + '</span></div>';
530
+ }).join('');
531
+ return '<div class="card panel"><h3>' + ico('i-goal', 'h') + ' ' + esc(p.key) + '</h3><div class="bars">' + rows + '</div></div>';
532
+ }).join('');
533
+ }
534
+
535
+ function load() {
536
+ var emptyMetrics = ['s-visitors', 's-pageviews', 's-bounce', 's-vpv', 's-dur'];
537
+ var emptyPanels = ['p-pages', 'p-sources', 'p-countries', 'p-browsers', 'p-os', 'p-devices', 'p-goals', 'p-languages'];
538
+ if (!state.site || state.site === 'no sites yet') {
539
+ emptyMetrics.forEach(function (id) { $(id).textContent = '—'; });
540
+ emptyPanels.forEach(function (id) { $(id).innerHTML = '<div class="empty">Add a site to begin</div>'; });
541
+ $('chart').innerHTML = '';
542
+ return;
543
+ }
544
+ renderFilters();
545
+ api('/api/stats?' + statsQuery()).then(function (s) {
546
+ if (s.error) return;
547
+ lastStats = s;
548
+ $('s-visitors').textContent = fmt(s.totals.visitors);
549
+ $('s-pageviews').textContent = fmt(s.totals.pageviews);
550
+ $('s-bounce').textContent = s.totals.bounce + '%';
551
+ $('s-vpv').textContent = s.totals.viewsPerVisitor;
552
+ $('s-dur').textContent = fmtDur(s.totals.duration);
553
+ $('realtime').textContent = s.realtime;
554
+ trend('t-visitors', s.trend && s.trend.visitors);
555
+ trend('t-pageviews', s.trend && s.trend.pageviews);
556
+ drawChart(s.series);
557
+ sparkline('s-visitors', (s.series || []).map(function (d) { return d.visitors; }), COL.v);
558
+ sparkline('s-pageviews', (s.series || []).map(function (d) { return d.pageviews; }), COL.p);
559
+ renderPagesPanel();
560
+ renderSourcesPanel();
561
+ bars('p-countries', s.countries, { filterKey: 'country', icon: function (r) { return flagIcon(r.name); }, label: function (r) { return NAMES[r.name] || r.name; } });
562
+ bars('p-browsers', s.browsers, { filterKey: 'browser', icon: function (r) { return r.name === 'App' ? ico('i-mobile', 'ui') : r.name === 'Edge' ? ico('i-browser', 'ui') : brand(BROWSER_ICON[r.name], 'i-browser'); } });
563
+ bars('p-os', s.os, { filterKey: 'os', icon: function (r) { return r.name === 'Windows' ? ico('i-desktop', 'ui') : brand(OS_ICON[r.name], 'i-os'); } });
564
+ bars('p-devices', s.devices, { filterKey: 'device', icon: function (r) { return ico(r.name === 'Desktop' ? 'i-desktop' : r.name === 'Tablet' ? 'i-tablet' : 'i-mobile', 'ui'); } });
565
+ renderPlatformsPanel();
566
+ bars('p-languages', s.languages, { filterKey: 'lang' });
567
+ bars('p-goals', s.goals, { filterKey: 'goal', cr: true, icon: function (r) { return ico(r.name === 'Purchase' ? 'i-cart' : r.name === 'Search' ? 'i-search' : r.name === '404' ? 'i-404' : 'i-goal', 'ui'); }, label: function (r) { return r.name; } });
568
+ renderProps(s);
569
+ renderInsights(s);
570
+ loadJourneys();
571
+ loadFunnels();
572
+ });
573
+ }
574
+
575
+ // ── series toggle (click legend) ──
576
+ function syncLegend() {
577
+ ['visitors', 'pageviews'].forEach(function (k) {
578
+ var b = $('leg-' + k);
579
+ if (b) b.setAttribute('aria-pressed', state.show[k] ? 'true' : 'false');
580
+ });
581
+ }
582
+ Array.prototype.forEach.call(document.querySelectorAll('.leg'), function (btn) {
583
+ btn.addEventListener('click', function () {
584
+ var k = btn.dataset.key, other = k === 'visitors' ? 'pageviews' : 'visitors';
585
+ // Turning off the last visible line would empty the chart → flip instead of blanking.
586
+ if (state.show[k] && !state.show[other]) { state.show[k] = false; state.show[other] = true; }
587
+ else { state.show[k] = !state.show[k]; }
588
+ localStorage.setItem('nyx_show', JSON.stringify(state.show));
589
+ syncLegend();
590
+ if (lastStats && lastStats.series) drawChart(lastStats.series);
591
+ });
592
+ });
593
+
594
+ // ── visitor journeys: hand-rolled sankey (no lib) ──
595
+ function loadJourneys() {
596
+ if (!state.site) return;
597
+ var q = statsQuery();
598
+ api('/api/journeys?' + q).then(function (j) {
599
+ if (j.error || !j.links) return;
600
+ drawSankey(j);
601
+ $('journeyMeta').textContent = '· ' + fmt(j.sessions) + ' sessions, top paths, 30-min session window';
602
+ }).catch(function (e) { if (window.console) console.error('journeys:', e); });
603
+ }
604
+ function drawSankey(j) {
605
+ var svg = $('journey');
606
+ svg.innerHTML = '';
607
+ if (!j.links.length) { svg.innerHTML = '<text x="20" y="40" fill="#888" font-size="13">Not enough data yet</text>'; return; }
608
+ var W = Math.max(svg.clientWidth || 760, 640), H = 340, PAD = 24, colW = 150;
609
+ var depths = j.maxDepth;
610
+ var colX = function (d) { return PAD + d * ((W - 2 * PAD - colW) / Math.max(1, depths - 1)); };
611
+ // node totals per column: sum of outgoing (or incoming for last col)
612
+ var nodes = {}; // "d|path" → {in,out}
613
+ j.links.forEach(function (l) {
614
+ var a = nodes[l.depth + '|' + l.from] = nodes[l.depth + '|' + l.from] || { v: 0 };
615
+ a.v += l.count;
616
+ var b = nodes[(l.depth + 1) + '|' + l.to] = nodes[(l.depth + 1) + '|' + l.to] || { v: 0 };
617
+ b.v = Math.max(b.v, 0) + l.count;
618
+ });
619
+ // layout: stack nodes per column by size
620
+ var cols = {};
621
+ Object.keys(nodes).forEach(function (k) {
622
+ var d = +k.split('|')[0];
623
+ (cols[d] = cols[d] || []).push({ key: k, path: k.slice(k.indexOf('|') + 1), v: nodes[k].v });
624
+ });
625
+ var maxTotal = 0;
626
+ Object.keys(cols).forEach(function (d) {
627
+ cols[d].sort(function (a, b) { return b.v - a.v; });
628
+ var t = cols[d].reduce(function (s, n) { return s + n.v; }, 0);
629
+ if (t > maxTotal) maxTotal = t;
630
+ });
631
+ var scale = (H - 2 * PAD - 40) / Math.max(1, maxTotal);
632
+ var pos = {}; // key → {x,y,h}
633
+ Object.keys(cols).forEach(function (d) {
634
+ var y = PAD;
635
+ cols[d].forEach(function (n) {
636
+ var h = Math.max(4, n.v * scale);
637
+ pos[n.key] = { x: colX(+d), y: y, h: h, v: n.v, path: n.path };
638
+ y += h + 10;
639
+ });
640
+ });
641
+ var ns = 'http://www.w3.org/2000/svg';
642
+ var used = {}; // per-node offsets so ribbons stack instead of overlap
643
+ j.links.forEach(function (l) {
644
+ var a = pos[l.depth + '|' + l.from], b = pos[(l.depth + 1) + '|' + l.to];
645
+ if (!a || !b) return;
646
+ var h = Math.max(2, l.count * scale);
647
+ var ao = used[l.depth + '|' + l.from] = used[l.depth + '|' + l.from] || { o: 0 };
648
+ var bo = used['in' + (l.depth + 1) + '|' + l.to] = used['in' + (l.depth + 1) + '|' + l.to] || { o: 0 };
649
+ var y1 = a.y + ao.o + h / 2, y2 = b.y + bo.o + h / 2;
650
+ ao.o += h; bo.o += h;
651
+ var x1 = a.x + 8, x2 = b.x;
652
+ var mx = (x1 + x2) / 2;
653
+ var p = document.createElementNS(ns, 'path');
654
+ p.setAttribute('d', 'M' + x1 + ',' + y1 + ' C' + mx + ',' + y1 + ' ' + mx + ',' + y2 + ' ' + x2 + ',' + y2);
655
+ p.setAttribute('stroke', l.to === '✖ exit' ? 'rgba(138,165,162,.13)' : 'rgba(45,212,191,.26)');
656
+ p.setAttribute('stroke-width', h);
657
+ p.setAttribute('fill', 'none');
658
+ p.style.transition = 'stroke .2s';
659
+ var tip = l.from + ' → ' + (l.to === '✖ exit' ? 'left the site' : l.to) + ' · ' + l.count;
660
+ p.addEventListener('mouseenter', function (e) { p.setAttribute('stroke', l.to === '✖ exit' ? 'rgba(138,165,162,.32)' : 'rgba(45,212,191,.6)'); showTip(e, tip); });
661
+ p.addEventListener('mousemove', function (e) { showTip(e, tip); });
662
+ p.addEventListener('mouseleave', function () { p.setAttribute('stroke', l.to === '✖ exit' ? 'rgba(138,165,162,.13)' : 'rgba(45,212,191,.26)'); hideTip(); });
663
+ svg.appendChild(p);
664
+ });
665
+ Object.keys(pos).forEach(function (k) {
666
+ var n = pos[k];
667
+ var r = document.createElementNS(ns, 'rect');
668
+ r.setAttribute('x', n.x); r.setAttribute('y', n.y);
669
+ r.setAttribute('width', 8); r.setAttribute('height', n.h);
670
+ r.setAttribute('rx', 2);
671
+ r.setAttribute('fill', n.path === '✖ exit' ? COL.line : n.path === '…' ? COL.dim : COL.v);
672
+ svg.appendChild(r);
673
+ var t = document.createElementNS(ns, 'text');
674
+ t.setAttribute('x', n.x + 12); t.setAttribute('y', n.y + Math.min(n.h / 2 + 4, 14));
675
+ t.setAttribute('fill', COL.dim); t.setAttribute('font-size', '11');
676
+ var shown = n.path === '✖ exit' ? 'Left the site' : n.path;
677
+ var label = shown.length > 22 ? shown.slice(0, 21) + '…' : shown;
678
+ t.textContent = label + ' (' + fmt(n.v) + ')';
679
+ svg.appendChild(t);
680
+ });
681
+ }
682
+ function showTip(e, text) { var tip = $('tip'); tip.textContent = text; tip.style.opacity = 1; tip.style.left = (e.pageX + 12) + 'px'; tip.style.top = (e.pageY - 10) + 'px'; }
683
+ function hideTip() { $('tip').style.opacity = 0; }
684
+
685
+ // ── share links ──
686
+ var shareToken = null;
687
+ $('shareBtn').onclick = function () {
688
+ if (!state.site) return;
689
+ api('/api/shares', { method: 'POST', body: JSON.stringify({ site: state.site }) }).then(function (s) {
690
+ if (s.error) return;
691
+ shareToken = s.token;
692
+ $('shareDomain').textContent = s.domain;
693
+ $('shareUrl').textContent = ORIGIN + '/share/' + s.token;
694
+ $('shareModal').classList.remove('hide');
695
+ });
696
+ };
697
+ $('copyShare').onclick = function () { navigator.clipboard.writeText($('shareUrl').textContent); $('copyShare').textContent = 'Copied'; setTimeout(function () { $('copyShare').textContent = 'Copy link'; }, 1500); };
698
+ $('revokeShare').onclick = function () { if (shareToken) api('/api/shares/' + shareToken, { method: 'DELETE' }).then(function () { $('shareModal').classList.add('hide'); }); };
699
+ $('closeShare').onclick = function () { $('shareModal').classList.add('hide'); };
700
+
701
+ // ── funnels ──
702
+ function fmtPct(p) { return (p == null ? '—' : p + '%'); }
703
+ function loadFunnels() {
704
+ if (SHARE) { $('funnels').innerHTML = ''; $('addFunnelBtn').classList.add('hide'); return; }
705
+ if (!state.site) return;
706
+ api('/api/funnels?site=' + encodeURIComponent(state.site)).then(function (fs) {
707
+ if (!Array.isArray(fs)) return;
708
+ if (!fs.length) { $('funnels').innerHTML = '<div class="empty">No funnels yet — define one to see where visitors drop off.</div>'; return; }
709
+ $('funnels').innerHTML = '';
710
+ fs.forEach(function (f) {
711
+ var q = state.from && state.to ? 'from=' + state.from + '&to=' + state.to : 'period=' + state.period;
712
+ api('/api/funnels/' + f.id + '/stats?' + q).then(function (st) {
713
+ if (st.error) return;
714
+ var max = st.steps.length ? st.steps[0].visitors : 0;
715
+ var html = '<div class="funnel"><div class="funnel-head"><strong>' + esc(st.name) + '</strong>' +
716
+ '<button class="funnel-del" data-id="' + f.id + '" title="Delete funnel">×</button></div>';
717
+ st.steps.forEach(function (s, i) {
718
+ var w = max ? Math.max(2, (s.visitors / max) * 100) : 2;
719
+ html += '<div class="funnel-step"><div class="funnel-label">' + (i + 1) + '. ' + (s.type === 'goal' ? ico('i-goal', 'ui') : ico('i-pages', 'ui')) + ' ' + esc(s.label) + '</div>' +
720
+ '<div class="funnel-bar-wrap"><div class="funnel-bar" style="width:' + w + '%"></div>' +
721
+ '<span class="funnel-nums">' + fmt(s.visitors) + ' · ' + s.pctOfFirst + '%' + (i > 0 ? ' <span class="drop">−' + s.dropFromPrev + '%</span>' : '') + '</span></div></div>';
722
+ });
723
+ html += '</div>';
724
+ $('funnels').insertAdjacentHTML('beforeend', html);
725
+ $('funnels').querySelectorAll('.funnel-del').forEach(function (btn) {
726
+ btn.onclick = function () { api('/api/funnels/' + btn.dataset.id, { method: 'DELETE' }).then(loadFunnels); };
727
+ });
728
+ });
729
+ });
730
+ });
731
+ }
732
+
733
+ function stepRow() {
734
+ var div = document.createElement('div');
735
+ div.className = 'step-row';
736
+ div.innerHTML = '<select class="step-type"><option value="page">Page</option><option value="goal">Goal</option></select>' +
737
+ '<input class="step-value" type="text" placeholder="/pricing or Signup" autocomplete="off">';
738
+ return div;
739
+ }
740
+ $('addFunnelBtn').onclick = function () {
741
+ $('funnelErr').textContent = ''; $('funnelName').value = '';
742
+ var wrap = $('funnelSteps'); wrap.innerHTML = '';
743
+ wrap.appendChild(stepRow()); wrap.appendChild(stepRow());
744
+ $('funnelModal').classList.remove('hide'); $('funnelName').focus();
745
+ };
746
+ $('addStepBtn').onclick = function () { if ($('funnelSteps').children.length < 8) $('funnelSteps').appendChild(stepRow()); };
747
+ $('cancelFunnel').onclick = function () { $('funnelModal').classList.add('hide'); };
748
+ $('createFunnelBtn').onclick = function () {
749
+ var steps = Array.prototype.map.call($('funnelSteps').children, function (row) {
750
+ return { type: row.querySelector('.step-type').value, value: row.querySelector('.step-value').value.trim() };
751
+ }).filter(function (s) { return s.value; });
752
+ api('/api/funnels', { method: 'POST', body: JSON.stringify({ site: state.site, name: $('funnelName').value, steps: steps }) }).then(function (r) {
753
+ if (r.error) { $('funnelErr').textContent = r.error; return; }
754
+ $('funnelModal').classList.add('hide');
755
+ loadFunnels();
756
+ });
757
+ };
758
+
759
+ function boot() {
760
+ hideLogin();
761
+ syncLegend();
762
+ loadSites().then(function () { load(); loadFunnels(); });
763
+ // live dashboard: refresh every 15s while the tab is visible
764
+ clearInterval(window.__rt);
765
+ window.__rt = setInterval(function () {
766
+ if (!state.site || document.hidden) return;
767
+ load();
768
+ }, 15000);
769
+ }
770
+
771
+ // share mode: resolve the token, lock the UI down to one site, skip login entirely
772
+ function bootShare() {
773
+ fetch('/api/share-info/' + SHARE).then(function (r) { return r.json(); }).then(function (info) {
774
+ if (info.error) { document.body.innerHTML = '<div style="display:grid;place-items:center;height:100vh;color:#e6e8ec;background:#0f1115;font-family:sans-serif"><div><h2>Link expired</h2><p>This share link was revoked or never existed.</p></div></div>'; return; }
775
+ hideLogin();
776
+ state.site = info.domain;
777
+ var sel = $('siteSelect');
778
+ sel.innerHTML = '<option>' + esc(info.domain) + '</option>'; sel.disabled = true;
779
+ ['addSiteBtn', 'logoutBtn', 'shareBtn', 'snippetBtn', 'addFunnelBtn'].forEach(function (id) { var el = $(id); if (el) el.classList.add('hide'); });
780
+ syncLegend();
781
+ load();
782
+ clearInterval(window.__rt);
783
+ window.__rt = setInterval(function () { if (!document.hidden) load(); }, 15000);
784
+ });
785
+ }
786
+
787
+ if (SHARE) bootShare(); else if (TOKEN) boot(); else showLogin();
788
+ })();