tacilytics 0.0.0-stage → 2.0.1
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.
- package/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +253 -2
- package/cli.js +173 -0
- package/day-salt.js +55 -0
- package/package.json +56 -4
- package/public/app.js +788 -0
- package/public/assets/DISCLAIMER-simple-icons.md +51 -0
- package/public/assets/LICENSE-lucide.txt +43 -0
- package/public/assets/LICENSE-simple-icons.md +30 -0
- package/public/assets/cosmic-bg.webp +0 -0
- package/public/assets/flags/LICENSE-flag-icons.txt +21 -0
- package/public/assets/flags/ad.svg +150 -0
- package/public/assets/flags/ae.svg +6 -0
- package/public/assets/flags/af.svg +81 -0
- package/public/assets/flags/ag.svg +14 -0
- package/public/assets/flags/ai.svg +29 -0
- package/public/assets/flags/al.svg +5 -0
- package/public/assets/flags/am.svg +5 -0
- package/public/assets/flags/ao.svg +13 -0
- package/public/assets/flags/aq.svg +5 -0
- package/public/assets/flags/ar.svg +32 -0
- package/public/assets/flags/arab.svg +109 -0
- package/public/assets/flags/as.svg +72 -0
- package/public/assets/flags/asean.svg +13 -0
- package/public/assets/flags/at.svg +4 -0
- package/public/assets/flags/au.svg +8 -0
- package/public/assets/flags/aw.svg +186 -0
- package/public/assets/flags/ax.svg +18 -0
- package/public/assets/flags/az.svg +8 -0
- package/public/assets/flags/ba.svg +12 -0
- package/public/assets/flags/bb.svg +6 -0
- package/public/assets/flags/bd.svg +4 -0
- package/public/assets/flags/be.svg +7 -0
- package/public/assets/flags/bf.svg +7 -0
- package/public/assets/flags/bg.svg +5 -0
- package/public/assets/flags/bh.svg +4 -0
- package/public/assets/flags/bi.svg +15 -0
- package/public/assets/flags/bj.svg +14 -0
- package/public/assets/flags/bl.svg +5 -0
- package/public/assets/flags/bm.svg +97 -0
- package/public/assets/flags/bn.svg +36 -0
- package/public/assets/flags/bo.svg +673 -0
- package/public/assets/flags/bq.svg +5 -0
- package/public/assets/flags/br.svg +45 -0
- package/public/assets/flags/bs.svg +13 -0
- package/public/assets/flags/bt.svg +89 -0
- package/public/assets/flags/bv.svg +13 -0
- package/public/assets/flags/bw.svg +7 -0
- package/public/assets/flags/by.svg +18 -0
- package/public/assets/flags/bz.svg +145 -0
- package/public/assets/flags/ca.svg +4 -0
- package/public/assets/flags/cc.svg +19 -0
- package/public/assets/flags/cd.svg +5 -0
- package/public/assets/flags/cefta.svg +13 -0
- package/public/assets/flags/cf.svg +15 -0
- package/public/assets/flags/cg.svg +12 -0
- package/public/assets/flags/ch.svg +9 -0
- package/public/assets/flags/ci.svg +7 -0
- package/public/assets/flags/ck.svg +9 -0
- package/public/assets/flags/cl.svg +13 -0
- package/public/assets/flags/cm.svg +15 -0
- package/public/assets/flags/cn.svg +11 -0
- package/public/assets/flags/co.svg +7 -0
- package/public/assets/flags/cp.svg +7 -0
- package/public/assets/flags/cr.svg +7 -0
- package/public/assets/flags/cu.svg +13 -0
- package/public/assets/flags/cv.svg +13 -0
- package/public/assets/flags/cw.svg +14 -0
- package/public/assets/flags/cx.svg +15 -0
- package/public/assets/flags/cy.svg +6 -0
- package/public/assets/flags/cz.svg +5 -0
- package/public/assets/flags/de.svg +5 -0
- package/public/assets/flags/dg.svg +130 -0
- package/public/assets/flags/dj.svg +13 -0
- package/public/assets/flags/dk.svg +5 -0
- package/public/assets/flags/dm.svg +152 -0
- package/public/assets/flags/do.svg +121 -0
- package/public/assets/flags/dz.svg +5 -0
- package/public/assets/flags/eac.svg +48 -0
- package/public/assets/flags/ec.svg +138 -0
- package/public/assets/flags/ee.svg +5 -0
- package/public/assets/flags/eg.svg +38 -0
- package/public/assets/flags/eh.svg +16 -0
- package/public/assets/flags/er.svg +8 -0
- package/public/assets/flags/es-ct.svg +4 -0
- package/public/assets/flags/es-ga.svg +187 -0
- package/public/assets/flags/es-pv.svg +5 -0
- package/public/assets/flags/es.svg +544 -0
- package/public/assets/flags/et.svg +14 -0
- package/public/assets/flags/eu.svg +28 -0
- package/public/assets/flags/fi.svg +5 -0
- package/public/assets/flags/fj.svg +120 -0
- package/public/assets/flags/fk.svg +90 -0
- package/public/assets/flags/fm.svg +11 -0
- package/public/assets/flags/fo.svg +12 -0
- package/public/assets/flags/fr.svg +5 -0
- package/public/assets/flags/ga.svg +7 -0
- package/public/assets/flags/gb-eng.svg +5 -0
- package/public/assets/flags/gb-nir.svg +132 -0
- package/public/assets/flags/gb-sct.svg +4 -0
- package/public/assets/flags/gb-wls.svg +9 -0
- package/public/assets/flags/gb.svg +7 -0
- package/public/assets/flags/gd.svg +27 -0
- package/public/assets/flags/ge.svg +6 -0
- package/public/assets/flags/gf.svg +5 -0
- package/public/assets/flags/gg.svg +9 -0
- package/public/assets/flags/gh.svg +6 -0
- package/public/assets/flags/gi.svg +32 -0
- package/public/assets/flags/gl.svg +4 -0
- package/public/assets/flags/gm.svg +14 -0
- package/public/assets/flags/gn.svg +7 -0
- package/public/assets/flags/gp.svg +5 -0
- package/public/assets/flags/gq.svg +23 -0
- package/public/assets/flags/gr.svg +16 -0
- package/public/assets/flags/gs.svg +133 -0
- package/public/assets/flags/gt.svg +204 -0
- package/public/assets/flags/gu.svg +19 -0
- package/public/assets/flags/gw.svg +13 -0
- package/public/assets/flags/gy.svg +9 -0
- package/public/assets/flags/hk.svg +8 -0
- package/public/assets/flags/hm.svg +8 -0
- package/public/assets/flags/hn.svg +18 -0
- package/public/assets/flags/hr.svg +58 -0
- package/public/assets/flags/ht.svg +116 -0
- package/public/assets/flags/hu.svg +7 -0
- package/public/assets/flags/ic.svg +7 -0
- package/public/assets/flags/id.svg +4 -0
- package/public/assets/flags/ie.svg +7 -0
- package/public/assets/flags/il.svg +14 -0
- package/public/assets/flags/im.svg +36 -0
- package/public/assets/flags/in.svg +25 -0
- package/public/assets/flags/io.svg +130 -0
- package/public/assets/flags/iq.svg +10 -0
- package/public/assets/flags/ir.svg +219 -0
- package/public/assets/flags/is.svg +12 -0
- package/public/assets/flags/it.svg +7 -0
- package/public/assets/flags/je.svg +62 -0
- package/public/assets/flags/jm.svg +8 -0
- package/public/assets/flags/jo.svg +16 -0
- package/public/assets/flags/jp.svg +11 -0
- package/public/assets/flags/ke.svg +23 -0
- package/public/assets/flags/kg.svg +4 -0
- package/public/assets/flags/kh.svg +61 -0
- package/public/assets/flags/ki.svg +36 -0
- package/public/assets/flags/km.svg +16 -0
- package/public/assets/flags/kn.svg +14 -0
- package/public/assets/flags/kp.svg +15 -0
- package/public/assets/flags/kr.svg +24 -0
- package/public/assets/flags/kw.svg +13 -0
- package/public/assets/flags/ky.svg +103 -0
- package/public/assets/flags/kz.svg +36 -0
- package/public/assets/flags/la.svg +12 -0
- package/public/assets/flags/lb.svg +15 -0
- package/public/assets/flags/lc.svg +8 -0
- package/public/assets/flags/li.svg +43 -0
- package/public/assets/flags/lk.svg +22 -0
- package/public/assets/flags/lr.svg +14 -0
- package/public/assets/flags/ls.svg +8 -0
- package/public/assets/flags/lt.svg +7 -0
- package/public/assets/flags/lu.svg +5 -0
- package/public/assets/flags/lv.svg +6 -0
- package/public/assets/flags/ly.svg +13 -0
- package/public/assets/flags/ma.svg +4 -0
- package/public/assets/flags/mc.svg +6 -0
- package/public/assets/flags/md.svg +70 -0
- package/public/assets/flags/me.svg +116 -0
- package/public/assets/flags/mf.svg +5 -0
- package/public/assets/flags/mg.svg +7 -0
- package/public/assets/flags/mh.svg +7 -0
- package/public/assets/flags/mk.svg +5 -0
- package/public/assets/flags/ml.svg +7 -0
- package/public/assets/flags/mm.svg +12 -0
- package/public/assets/flags/mn.svg +14 -0
- package/public/assets/flags/mo.svg +9 -0
- package/public/assets/flags/mp.svg +86 -0
- package/public/assets/flags/mq.svg +5 -0
- package/public/assets/flags/mr.svg +6 -0
- package/public/assets/flags/ms.svg +29 -0
- package/public/assets/flags/mt.svg +58 -0
- package/public/assets/flags/mu.svg +8 -0
- package/public/assets/flags/mv.svg +6 -0
- package/public/assets/flags/mw.svg +10 -0
- package/public/assets/flags/mx.svg +382 -0
- package/public/assets/flags/my.svg +26 -0
- package/public/assets/flags/mz.svg +21 -0
- package/public/assets/flags/na.svg +16 -0
- package/public/assets/flags/nc.svg +13 -0
- package/public/assets/flags/ne.svg +6 -0
- package/public/assets/flags/nf.svg +9 -0
- package/public/assets/flags/ng.svg +6 -0
- package/public/assets/flags/ni.svg +129 -0
- package/public/assets/flags/nl.svg +5 -0
- package/public/assets/flags/no.svg +7 -0
- package/public/assets/flags/np.svg +13 -0
- package/public/assets/flags/nr.svg +12 -0
- package/public/assets/flags/nu.svg +10 -0
- package/public/assets/flags/nz.svg +36 -0
- package/public/assets/flags/om.svg +115 -0
- package/public/assets/flags/pa.svg +14 -0
- package/public/assets/flags/pc.svg +33 -0
- package/public/assets/flags/pe.svg +4 -0
- package/public/assets/flags/pf.svg +19 -0
- package/public/assets/flags/pg.svg +9 -0
- package/public/assets/flags/ph.svg +6 -0
- package/public/assets/flags/pk.svg +15 -0
- package/public/assets/flags/pl.svg +6 -0
- package/public/assets/flags/pm.svg +5 -0
- package/public/assets/flags/pn.svg +53 -0
- package/public/assets/flags/pr.svg +13 -0
- package/public/assets/flags/ps.svg +6 -0
- package/public/assets/flags/pt.svg +57 -0
- package/public/assets/flags/pw.svg +11 -0
- package/public/assets/flags/py.svg +157 -0
- package/public/assets/flags/qa.svg +4 -0
- package/public/assets/flags/re.svg +5 -0
- package/public/assets/flags/ro.svg +7 -0
- package/public/assets/flags/rs.svg +292 -0
- package/public/assets/flags/ru.svg +5 -0
- package/public/assets/flags/rw.svg +13 -0
- package/public/assets/flags/sa.svg +25 -0
- package/public/assets/flags/sb.svg +13 -0
- package/public/assets/flags/sc.svg +7 -0
- package/public/assets/flags/sd.svg +13 -0
- package/public/assets/flags/se.svg +4 -0
- package/public/assets/flags/sg.svg +13 -0
- package/public/assets/flags/sh-ac.svg +689 -0
- package/public/assets/flags/sh-hl.svg +164 -0
- package/public/assets/flags/sh-ta.svg +76 -0
- package/public/assets/flags/sh.svg +7 -0
- package/public/assets/flags/si.svg +18 -0
- package/public/assets/flags/sj.svg +7 -0
- package/public/assets/flags/sk.svg +9 -0
- package/public/assets/flags/sl.svg +7 -0
- package/public/assets/flags/sm.svg +75 -0
- package/public/assets/flags/sn.svg +8 -0
- package/public/assets/flags/so.svg +11 -0
- package/public/assets/flags/sr.svg +6 -0
- package/public/assets/flags/ss.svg +8 -0
- package/public/assets/flags/st.svg +16 -0
- package/public/assets/flags/sv.svg +593 -0
- package/public/assets/flags/sx.svg +56 -0
- package/public/assets/flags/sy.svg +6 -0
- package/public/assets/flags/sz.svg +34 -0
- package/public/assets/flags/tc.svg +50 -0
- package/public/assets/flags/td.svg +7 -0
- package/public/assets/flags/tf.svg +15 -0
- package/public/assets/flags/tg.svg +14 -0
- package/public/assets/flags/th.svg +7 -0
- package/public/assets/flags/tj.svg +22 -0
- package/public/assets/flags/tk.svg +5 -0
- package/public/assets/flags/tl.svg +13 -0
- package/public/assets/flags/tm.svg +204 -0
- package/public/assets/flags/tn.svg +4 -0
- package/public/assets/flags/to.svg +10 -0
- package/public/assets/flags/tr.svg +8 -0
- package/public/assets/flags/tt.svg +5 -0
- package/public/assets/flags/tv.svg +9 -0
- package/public/assets/flags/tw.svg +34 -0
- package/public/assets/flags/tz.svg +13 -0
- package/public/assets/flags/ua.svg +6 -0
- package/public/assets/flags/ug.svg +30 -0
- package/public/assets/flags/um.svg +9 -0
- package/public/assets/flags/un.svg +16 -0
- package/public/assets/flags/us.svg +9 -0
- package/public/assets/flags/uy.svg +28 -0
- package/public/assets/flags/uz.svg +30 -0
- package/public/assets/flags/va.svg +190 -0
- package/public/assets/flags/vc.svg +8 -0
- package/public/assets/flags/ve.svg +26 -0
- package/public/assets/flags/vg.svg +59 -0
- package/public/assets/flags/vi.svg +28 -0
- package/public/assets/flags/vn.svg +11 -0
- package/public/assets/flags/vu.svg +21 -0
- package/public/assets/flags/wf.svg +5 -0
- package/public/assets/flags/ws.svg +7 -0
- package/public/assets/flags/xk.svg +5 -0
- package/public/assets/flags/xx.svg +4 -0
- package/public/assets/flags/ye.svg +7 -0
- package/public/assets/flags/yt.svg +5 -0
- package/public/assets/flags/za.svg +17 -0
- package/public/assets/flags/zm.svg +27 -0
- package/public/assets/flags/zw.svg +21 -0
- package/public/assets/fonts/Inter-OFL.txt +93 -0
- package/public/assets/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- package/public/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/public/assets/icons.svg +200 -0
- package/public/assets/nyx-logo-256.png +0 -0
- package/public/assets/tacilytics-mark.svg +16 -0
- package/public/index.html +239 -0
- package/public/style.css +411 -0
- package/public/tacilytics.js +184 -0
- package/sdk/flutter/CHANGELOG.md +6 -0
- package/sdk/flutter/LICENSE +21 -0
- package/sdk/flutter/README.md +41 -0
- package/sdk/flutter/dart_test.yaml +3 -0
- package/sdk/flutter/lib/nyx_analytics.dart +13 -0
- package/sdk/flutter/lib/src/beacon.dart +87 -0
- package/sdk/flutter/lib/src/client.dart +177 -0
- package/sdk/flutter/lib/tacilytics.dart +13 -0
- package/sdk/flutter/pubspec.lock +221 -0
- package/sdk/flutter/pubspec.yaml +18 -0
- package/sdk/flutter/test/alias_test.dart +13 -0
- package/sdk/flutter/test/beacon_test.dart +46 -0
- package/sdk/flutter/test/e2e_test.dart +18 -0
- package/server.js +893 -0
- 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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><head></code> of your site:',
|
|
157
|
+
c1: snippetFor(domain), s2: '', c2: '',
|
|
158
|
+
hint: 'Track goals & 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
|
+
})();
|