tacilytics 2.0.2 → 2.2.0

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/public/app.js CHANGED
@@ -14,6 +14,7 @@
14
14
  }
15
15
  } catch (e) { /* corrupt value → keep both visible */ }
16
16
  var lastStats = null;
17
+ function closeModal(ov, opt) { return window.TaciModal.close(ov, opt); } // one close path for all modals (account.js)
17
18
  // dashboard segmentation filters (whitelist mirrors the server's FILTER_COLS + goal)
18
19
  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
 
@@ -34,7 +35,16 @@
34
35
  // chart palette comes from CSS custom properties (single source of truth for the theme)
35
36
  var CSSV = getComputedStyle(document.documentElement);
36
37
  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
+ var COL = {};
39
+ // chart colours follow the theme: re-read the CSS variables whenever it changes
40
+ function readColors() {
41
+ CSSV = getComputedStyle(document.documentElement);
42
+ COL.v = cv('--chart-visitors', '#5b8def'); COL.p = cv('--chart-pageviews', '#8b93a3'); COL.red = cv('--red', '#f87171');
43
+ COL.dim = cv('--text-dim', '#8b93a3'); COL.line = cv('--line-strong', '#323946'); COL.bg = cv('--bg', '#071114');
44
+ COL.grid = document.documentElement.getAttribute('data-theme') === 'light' ? 'rgba(10,40,38,.08)' : 'rgba(255,255,255,.05)';
45
+ }
46
+ readColors();
47
+ new MutationObserver(function () { readColors(); if (lastStats && lastStats.series) drawChart(lastStats.series); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
38
48
  // Escape any value that originates from visitor beacons before it touches innerHTML.
39
49
  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
40
50
 
@@ -43,7 +53,8 @@
43
53
  opts.headers = Object.assign({ 'Content-Type': 'application/json', 'x-session-token': TOKEN }, opts.headers || {});
44
54
  return fetch(path, opts).then(function (r) {
45
55
  if (r.status === 401) { if (!SHARE) showLogin(); throw new Error('auth'); }
46
- return r.json().catch(function () { return {}; });
56
+ // same rule as account.js: JSON errors come back as {error}, anything that is not JSON throws (Tyto)
57
+ return window.TaciReadJson(r);
47
58
  });
48
59
  }
49
60
 
@@ -56,10 +67,13 @@
56
67
  var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
57
68
  return Math.max(r, g, b) < 90; // only near-black / deep navy marks; saturated reds stay
58
69
  }
70
+ // lookup tables keyed by visitor data (source, country, platform …): own keys only, so a source
71
+ // called 'constructor' or '__proto__' stays plain text instead of hitting Object.prototype
72
+ function own(o, k) { return Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined; }
59
73
  function ico(id, cls) { return '<svg class="ico ' + (cls || '') + '" aria-hidden="true"><use href="' + SPRITE + '#' + id + '"></use></svg>'; }
60
74
  function brand(name, fallbackUi) {
61
75
  if (!name) return ico(fallbackUi || 'i-other', 'ui');
62
- var c = BRAND_COLORS[name];
76
+ var c = own(BRAND_COLORS, name);
63
77
  if (name === 'samsung') c = '#6f8dff'; // Samsung navy is unreadable on dark — same hue, lighter
64
78
  if (c && isDark(c)) c = '#e7f3f1'; // black logos (Apple, X, GitHub …) would vanish on the dark UI
65
79
  return '<svg class="ico brand" aria-hidden="true"' + (c ? ' style="color:' + c + '"' : '') + '><use href="' + SPRITE + '#b-' + name + '"></use></svg>';
@@ -87,23 +101,16 @@
87
101
  function flag(cc) { return flagIcon(cc); } // returns HTML now (img), never a text emoji
88
102
  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
103
 
90
- // ── login ──
91
- function showLogin() { $('login').classList.remove('hide'); $('app').classList.add('hide'); }
104
+ // ── login (the form itself lives in account.js: passphrase, accounts, TOTP, passkeys) ──
105
+ function showLogin() { clearInterval(window.__rt); [].forEach.call(document.querySelectorAll('.modal-overlay:not(#login):not(.hide)'), function (o) { closeModal(o, { force: true }); }); $('login').classList.remove('hide'); $('app').classList.add('hide'); var v = $('appVersion'); if (v) v.textContent = ''; if (window.TaciAccount) window.TaciAccount.prepareLogin(); }
92
106
  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
+ function signedIn(token) { TOKEN = token; localStorage.setItem('nyx_token', TOKEN); $('loginErr').textContent = ''; boot(); }
108
+ $('logoutBtn').onclick = function () {
109
+ fetch('/auth/logout', { method: 'POST', headers: { 'x-session-token': TOKEN } }).catch(function () {});
110
+ localStorage.removeItem('nyx_token'); TOKEN = ''; showLogin();
111
+ };
112
+ // hooks for account.js
113
+ window.Taci = { api: api, esc: esc, ico: ico, signedIn: signedIn, sites: function () { return state.sites; }, reloadSites: function () { return loadSites().then(load); } };
107
114
 
108
115
  // ── sites ──
109
116
  function loadSites() {
@@ -114,15 +121,48 @@
114
121
  if (!sites.length) { sel.innerHTML = '<option>no sites yet</option>'; return; }
115
122
  if (!state.site || !sites.some(function (s) { return s.domain === state.site; })) state.site = sites[0].domain;
116
123
  sel.value = state.site;
124
+ showTz();
117
125
  });
118
126
  }
119
- $('siteSelect').onchange = function () { state.site = this.value; localStorage.setItem('nyx_site', state.site); load(); if (typeof loadFunnels === 'function') loadFunnels(); };
127
+ $('siteSelect').onchange = function () { state.site = this.value; showTz(); localStorage.setItem('nyx_site', state.site); load(); if (typeof loadFunnels === 'function') loadFunnels(); };
128
+
129
+ // ── time zones: the browser's zone is the default for a new site; the list comes from Intl ──
130
+ var BROWSER_TZ = (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })();
131
+ var TZ_LIST = (function () {
132
+ var l = []; try { l = Intl.supportedValuesOf('timeZone'); } catch (e) { /* old browser: the two below */ }
133
+ if (l.indexOf('UTC') < 0) l = ['UTC'].concat(l);
134
+ if (l.indexOf(BROWSER_TZ) < 0) l.push(BROWSER_TZ);
135
+ return l;
136
+ })();
137
+ function fillTz(sel, value) {
138
+ var list = TZ_LIST.indexOf(value) < 0 ? TZ_LIST.concat([value]) : TZ_LIST;
139
+ sel.innerHTML = list.map(function (z) { return '<option value="' + esc(z) + '">' + esc(z.replace(/_/g, ' ')) + '</option>'; }).join('');
140
+ sel.value = value;
141
+ }
142
+ // today in the site's time zone (en-CA formats as YYYY-MM-DD)
143
+ function localToday() { var s = currentSite(); try { return new Intl.DateTimeFormat('en-CA', { timeZone: (s && s.timezone) || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); } catch (e) { return new Date().toISOString().slice(0, 10); } }
144
+ function currentSite() { return (state.sites || []).filter(function (s) { return s.domain === state.site; })[0]; }
145
+ function showTz() { var s = currentSite(); $('tzName').textContent = s ? String(s.timezone || 'UTC').replace(/_/g, ' ') : 'UTC'; $('tzBtn').classList.toggle('hide', !s); }
146
+ $('tzBtn').onclick = function () {
147
+ var s = currentSite(); if (!s) return;
148
+ $('tzSite').textContent = s.domain; $('tzErr').textContent = ''; fillTz($('tzSelect'), s.timezone || 'UTC');
149
+ $('tzModal').classList.remove('hide'); $('tzSelect').focus();
150
+ };
151
+ $('cancelTz').onclick = function () { closeModal($('tzModal'), { force: true }); };
152
+ $('saveTz').onclick = function () {
153
+ var s = currentSite(); if (!s) return;
154
+ api('/api/sites/' + s.id, { method: 'PATCH', body: JSON.stringify({ timezone: $('tzSelect').value }) }).then(function (r) {
155
+ if (r.error) { $('tzErr').textContent = r.error; return; }
156
+ closeModal($('tzModal'), { force: true });
157
+ loadSites().then(load);
158
+ });
159
+ };
120
160
 
121
- $('addSiteBtn').onclick = function () { $('addModal').classList.remove('hide'); $('newDomain').value = ''; $('addErr').textContent = ''; $('newDomain').focus(); };
122
- $('cancelAdd').onclick = function () { $('addModal').classList.add('hide'); };
161
+ $('addSiteBtn').onclick = function () { $('addModal').classList.remove('hide'); $('newDomain').value = ''; fillTz($('newTz'), BROWSER_TZ); $('addErr').textContent = ''; $('newDomain').focus(); };
162
+ $('cancelAdd').onclick = function () { closeModal($('addModal'), { force: true }); };
123
163
  $('createSiteBtn').onclick = function () {
124
164
  var d = $('newDomain').value.trim();
125
- api('/api/sites', { method: 'POST', body: JSON.stringify({ domain: d }) }).then(function (s) {
165
+ api('/api/sites', { method: 'POST', body: JSON.stringify({ domain: d, timezone: $('newTz').value }) }).then(function (s) {
126
166
  if (s.error) { $('addErr').textContent = s.error; return; }
127
167
  $('addModal').classList.add('hide');
128
168
  state.site = s.domain; localStorage.setItem('nyx_site', state.site);
@@ -155,7 +195,7 @@
155
195
  return {
156
196
  s1: 'Paste this once into the <code>&lt;head&gt;</code> of your site:',
157
197
  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>'
198
+ 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> · <code>data-track-downloads</code></span>'
159
199
  };
160
200
  }
161
201
  function renderSnippet() {
@@ -200,14 +240,15 @@
200
240
  };
201
241
 
202
242
  $('snippetBtn').onclick = function () { if (state.site) showSnippet(state.site); };
203
- $('closeSnippet').onclick = function () { $('snippetModal').classList.add('hide'); };
243
+ $('closeSnippet').onclick = function () { closeModal($('snippetModal')); };
204
244
  $('copySnippet').onclick = function () { copyFrom('copySnippet', 'snippetCode'); };
205
245
  $('copySnippet2').onclick = function () { copyFrom('copySnippet2', 'snippetCode2', 'Copy step 2'); };
206
246
 
207
247
  // ── live visitors (last 30 min) ──
208
248
  var liveTimer = null;
209
249
  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; }
250
+ function closeLive() { closeModal($('liveModal')); }
251
+ $('liveModal').addEventListener('taci:close', function () { clearInterval(liveTimer); liveTimer = null; }); // every exit stops polling
211
252
  function fetchLive() {
212
253
  if (document.hidden) return;
213
254
  api('/api/realtime?site=' + encodeURIComponent(state.site)).then(function (r) {
@@ -330,8 +371,8 @@
330
371
  el.classList.remove('hide');
331
372
  var chips = keys.map(function (k) {
332
373
  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 +
374
+ var disp = k === 'country' ? (flagIcon(v) + ' ' + esc(own(NAMES, v) || v)) : k === 'platform' ? (platformIcon(v) + ' ' + esc(platformLabel(v))) : esc(v);
375
+ return '<span class="chip"><span class="k">' + esc(own(FILTER_LABELS, k) || k) + '</span>' + disp +
335
376
  '<button data-rm="' + esc(k) + '" title="Remove" aria-label="Remove filter">' + ico('i-x', 'ui') + '</button></span>';
336
377
  }).join('');
337
378
  el.innerHTML = '<span class="flabel">Filtered by</span>' + chips + '<span class="chip clear" id="clearFilters">Clear all</span>';
@@ -367,6 +408,23 @@
367
408
  '<path d="' + line + '" fill="none" stroke="' + color + '" stroke-width="1.6" vector-effect="non-scaling-stroke" opacity=".9"/></svg>';
368
409
  card.insertAdjacentHTML('beforeend', svg);
369
410
  }
411
+ // ── chart annotations: a thin coral line + diamond per annotated day (daily views only) ──
412
+ function annotationsFor(d) {
413
+ var list = (lastStats && lastStats.annotations) || [];
414
+ return d && d.day ? list.filter(function (a) { return a.day === d.day; }) : [];
415
+ }
416
+ function annotationMarks(series, x, H) {
417
+ var list = (lastStats && lastStats.annotations) || [];
418
+ if (!list.length) return '';
419
+ var out = '';
420
+ series.forEach(function (d, i) {
421
+ if (!annotationsFor(d).length) return;
422
+ var cx = x(i).toFixed(1);
423
+ out += '<line x1="' + cx + '" x2="' + cx + '" y1="14" y2="' + H + '" stroke="var(--accent-2, #ff7a5c)" stroke-width="1.2" stroke-dasharray="3 4" opacity=".75" vector-effect="non-scaling-stroke"/>' +
424
+ '<path d="M' + cx + ' 3 l5 6 l-5 6 l-5 -6 z" fill="var(--accent-2, #ff7a5c)"/>';
425
+ });
426
+ return out;
427
+ }
370
428
  function drawChart(series) {
371
429
  var svg = $('chart'); var W = 800, H = 220, pad = 8;
372
430
  var showV = state.show.visitors, showP = state.show.pageviews;
@@ -388,25 +446,146 @@
388
446
  '<defs><linearGradient id="ag" x1="0" y1="0" x2="0" y2="1">' +
389
447
  '<stop offset="0%" stop-color="' + COL.v + '" stop-opacity="0.32"/><stop offset="100%" stop-color="' + COL.v + '" stop-opacity="0"/>' +
390
448
  '</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('') +
449
+ [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="' + COL.grid + '" stroke-dasharray="2 6" vector-effect="non-scaling-stroke"/>'; }).join('') +
392
450
  (showV ? '<path d="' + areaP + '" fill="url(#ag)"/>' : '') +
393
451
  (showP ? '<path d="' + path('pageviews') + '" fill="none" stroke="' + COL.p + '" stroke-width="1.6" stroke-dasharray="4 4" opacity="0.85"/>' : '') +
394
452
  (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('') : '') +
453
+ (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="' + COL.bg + '" stroke-width="1.5"/>'; }).join('') : '') +
454
+ annotationMarks(series, x, H) +
396
455
  hovers;
397
456
  var tip = $('tip');
398
457
  Array.prototype.forEach.call(svg.querySelectorAll('rect'), function (rect) {
399
458
  rect.addEventListener('mousemove', function (e) {
400
459
  var d = series[+rect.dataset.i];
460
+ var notes = annotationsFor(d);
401
461
  tip.innerHTML = '<strong>' + esc(d.label) + '</strong>' +
402
462
  (showV ? '<br>' + d.visitors + ' visitors' : '') +
403
- (showP ? '<br>' + d.pageviews + ' views' : '');
463
+ (showP ? '<br>' + d.pageviews + ' views' : '') +
464
+ notes.map(function (a) { return '<br><span class="tip-note" data-no-i18n>◆ ' + esc(a.text) + '</span>'; }).join('');
404
465
  tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 10) + 'px'; tip.style.opacity = 1;
405
466
  });
406
467
  rect.addEventListener('mouseleave', function () { tip.style.opacity = 0; });
407
468
  });
408
469
  }
409
470
 
471
+ // ── import history (Plausible / GA4) ──
472
+ var impSrc = 'plausible';
473
+ var IMP_HOWTO = {
474
+ plausible: 'In Plausible: <strong>Site settings → Imports &amp; Exports → Export data</strong>. Upload the ZIP you get (or the CSV files in it).',
475
+ ga: 'In Google Analytics 4: open a report (e.g. <strong>Pages and screens</strong>, <strong>Traffic acquisition</strong>, <strong>Demographic details</strong>), add <strong>Date</strong> as a secondary dimension, then <strong>Share → Download file → CSV</strong>. One file per report; pick several at once.',
476
+ };
477
+ function openImport() {
478
+ $('importModal').classList.remove('hide'); $('impErr').textContent = ''; $('impStatus').textContent = ''; $('impSite').textContent = state.site;
479
+ setImpSrc(impSrc); loadImports();
480
+ }
481
+ function setImpSrc(src) {
482
+ impSrc = src;
483
+ Array.prototype.forEach.call($('impTabs').children, function (b) { b.classList.toggle('active', b.dataset.src === src); });
484
+ $('impHowto').innerHTML = IMP_HOWTO[src];
485
+ }
486
+ function loadImports() {
487
+ api('/api/imports?site=' + encodeURIComponent(state.site)).then(function (r) {
488
+ if (r.error) return;
489
+ $('impList').innerHTML = r.imports.length ? '<div class="set-card"><h3>Imported</h3>' + r.imports.map(function (i) {
490
+ return '<div class="set-row"><span><strong>' + (i.source === 'ga' ? 'Google Analytics' : 'Plausible') + '</strong> ' + esc(i.first_day || '?') + ' – ' + esc(i.last_day || '?') +
491
+ ' <span class="muted small">· ' + Number(i.pageviews).toLocaleString() + ' views' + (i.created_by ? ' · by ' + esc(i.created_by) : '') + '</span></span>' +
492
+ '<button class="btn ghost danger mini" data-delimp="' + i.id + '">Remove</button></div>';
493
+ }).join('') + (r.countsUntil ? '<p class="muted small" style="margin:8px 0 0">Own data starts ' + esc(r.countsUntil) + '; imported days from then on are ignored.</p>' : '') + '</div>' : '';
494
+ $('impList').querySelectorAll('[data-delimp]').forEach(function (b) {
495
+ b.onclick = function () { if (confirm('Remove this import? The imported numbers disappear from all charts.')) api('/api/imports/' + b.dataset.delimp, { method: 'DELETE' }).then(function () { loadImports(); load(); }); };
496
+ });
497
+ });
498
+ }
499
+ $('importBtn').onclick = openImport;
500
+ $('closeImport').onclick = function () { closeModal($('importModal')); };
501
+ $('impTabs').onclick = function (e) { var b = e.target.closest('button'); if (b) setImpSrc(b.dataset.src); };
502
+ $('impGo').onclick = function () {
503
+ var files = Array.prototype.slice.call($('impFile').files || []);
504
+ $('impErr').textContent = '';
505
+ if (!files.length) { $('impErr').textContent = 'Choose a file first'; return; }
506
+ var done = 0, views = 0;
507
+ $('impStatus').textContent = 'Uploading…';
508
+ // one request per file: each file becomes its own import (and can be removed on its own)
509
+ files.reduce(function (p, f) {
510
+ return p.then(function () {
511
+ return fetch('/api/imports?site=' + encodeURIComponent(state.site) + '&source=' + impSrc + '&name=' + encodeURIComponent(f.name), {
512
+ method: 'POST', headers: { 'x-session-token': localStorage.getItem('nyx_token') || '', 'Content-Type': /\.zip$/i.test(f.name) ? 'application/zip' : 'text/csv' }, body: f,
513
+ }).then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(f.name + ': ' + (j.error || r.status)); done++; views += j.pageviews || 0; if (j.note) $('impStatus').textContent = j.note; }); });
514
+ });
515
+ }, Promise.resolve()).then(function () {
516
+ $('impStatus').textContent = done + ' file' + (done > 1 ? 's' : '') + ' imported (' + views.toLocaleString() + ' views). ' + ($('impStatus').textContent.indexOf('Days from') === 0 ? $('impStatus').textContent : '');
517
+ $('impFile').value = ''; loadImports(); load();
518
+ }).catch(function (e) { $('impErr').textContent = e.message; $('impStatus').textContent = ''; loadImports(); load(); });
519
+ };
520
+
521
+ // ── alerts ──
522
+ var KIND_LABEL = { spike: 'Traffic spike', drop: 'Traffic drop', goal: 'Goal' };
523
+ function openAlerts() {
524
+ // a fresh form only when the modal opens — openAlerts also re-renders the list while it is open
525
+ if ($('alertsModal').classList.contains('hide')) { $('alKind').value = 'spike'; $('alGoalRow').classList.add('hide'); $('alGoal').value = ''; $('alThreshold').value = '10'; $('alWindow').value = '24'; $('alWebhook').value = ''; }
526
+ $('alertsModal').classList.remove('hide'); $('alertsErr').textContent = ''; $('alertsSite').textContent = state.site;
527
+ var goals = ((lastStats && lastStats.goals) || []).map(function (g) { return g.name; });
528
+ $('alGoalList').innerHTML = goals.map(function (g) { return '<option value="' + esc(g) + '">'; }).join('');
529
+ api('/api/alerts?site=' + encodeURIComponent(state.site)).then(function (r) {
530
+ if (r.error) { $('alertsErr').textContent = r.error; return; }
531
+ $('alHint').textContent = r.fallbackWebhook ? 'Leave the webhook empty to use the server default (DIGEST_WEBHOOK_URL).' : 'Discord: channel settings → Integrations → Webhooks → Copy URL.';
532
+ $('alWebhook').placeholder = r.fallbackWebhook ? 'Webhook URL (optional — server default)' : 'Webhook URL (https://…)';
533
+ $('alertsList').innerHTML = r.alerts.length ? '<div class="set-card">' + r.alerts.map(function (a) {
534
+ var what = a.kind === 'goal' ? a.threshold + '× <span data-no-i18n>' + esc(a.goal) + '</span> in ' + (a.window_hours === 1 ? '1 h' : a.window_hours === 24 ? '24 h' : '7 days') : KIND_LABEL[a.kind];
535
+ return '<div class="set-row"><span>' + ico(a.kind === 'goal' ? 'i-goal' : a.kind === 'drop' ? 'i-down' : 'i-up', 'ui') + '<strong>' + what + '</strong>' +
536
+ ' <span class="muted small">→ ' + (a.webhook ? '<span data-no-i18n>' + esc(a.webhook) + '</span>' : 'server default') + (a.last_fired ? ' · last sent ' + new Date(a.last_fired).toLocaleString() : '') + '</span></span>' +
537
+ '<label class="small muted" style="display:inline-flex;gap:5px;align-items:center"><input type="checkbox" data-al-on="' + a.id + '"' + (a.enabled ? ' checked' : '') + ' style="width:auto;margin:0"> on</label>' +
538
+ '<button class="btn ghost mini" data-al-test="' + a.id + '">Test</button>' +
539
+ '<button class="btn ghost danger mini" data-al-del="' + a.id + '">Remove</button></div>';
540
+ }).join('') + '</div>' : '<p class="muted small">No alerts for this site yet.</p>';
541
+ var L = $('alertsList');
542
+ L.querySelectorAll('[data-al-on]').forEach(function (c) { c.onchange = function () { api('/api/alerts/' + c.dataset.alOn, { method: 'PATCH', body: JSON.stringify({ enabled: c.checked }) }); }; });
543
+ L.querySelectorAll('[data-al-del]').forEach(function (b) { b.onclick = function () { api('/api/alerts/' + b.dataset.alDel, { method: 'DELETE' }).then(openAlerts); }; });
544
+ L.querySelectorAll('[data-al-test]').forEach(function (b) {
545
+ b.onclick = function () {
546
+ b.textContent = 'Sending…';
547
+ api('/api/alerts/' + b.dataset.alTest + '/test', { method: 'POST' }).then(function (r) { b.textContent = r.error ? 'Failed' : 'Sent ✓'; if (r.error) $('alertsErr').textContent = r.error; });
548
+ };
549
+ });
550
+ });
551
+ }
552
+ $('alertsBtn').onclick = openAlerts;
553
+ $('closeAlerts').onclick = function () { closeModal($('alertsModal'), { force: true }); };
554
+ $('alKind').onchange = function () { $('alGoalRow').classList.toggle('hide', this.value !== 'goal'); };
555
+ $('alAdd').onclick = function () {
556
+ $('alertsErr').textContent = '';
557
+ var body = { site: state.site, kind: $('alKind').value, webhook: $('alWebhook').value.trim() || undefined };
558
+ if (body.kind === 'goal') { body.goal = $('alGoal').value.trim(); body.threshold = Number($('alThreshold').value); body.window_hours = Number($('alWindow').value); }
559
+ api('/api/alerts', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
560
+ if (r.error) { $('alertsErr').textContent = r.error; return; }
561
+ $('alWebhook').value = ''; window.TaciModal.snapshot($('alertsModal')); openAlerts();
562
+ });
563
+ };
564
+
565
+ // ── add / list annotations ──
566
+ function openAnnotations() {
567
+ $('annModal').classList.remove('hide'); $('annErr').textContent = '';
568
+ $('annDay').value = localToday(); $('annText').value = ''; $('annText').focus(); window.TaciModal.snapshot($('annModal')); // also when re-opened after a save
569
+ api('/api/annotations?site=' + encodeURIComponent(state.site)).then(function (list) {
570
+ $('annList').innerHTML = (list || []).length ? list.map(function (a) {
571
+ return '<div class="set-row"><span><strong>' + esc(a.day) + '</strong> <span data-no-i18n>' + esc(a.text) + '</span>' + (a.created_by ? ' <span class="muted small">· ' + esc(a.created_by) + '</span>' : '') + '</span>' +
572
+ '<button class="btn ghost danger mini" data-delann="' + a.id + '">Remove</button></div>';
573
+ }).join('') : '<p class="muted small">No notes yet.</p>';
574
+ $('annList').querySelectorAll('[data-delann]').forEach(function (b) {
575
+ b.onclick = function () { api('/api/annotations/' + b.dataset.delann, { method: 'DELETE' }).then(function () { openAnnotations(); load(); }); };
576
+ });
577
+ });
578
+ }
579
+ $('annBtn').onclick = openAnnotations;
580
+ $('closeAnn').onclick = function () { closeModal($('annModal'), { force: true }); };
581
+ $('saveAnn').onclick = function () {
582
+ api('/api/annotations', { method: 'POST', body: JSON.stringify({ site: state.site, day: $('annDay').value, text: $('annText').value }) }).then(function (r) {
583
+ if (r.error) { $('annErr').textContent = r.error; return; }
584
+ openAnnotations(); load();
585
+ });
586
+ };
587
+ $('annText').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('saveAnn').click(); });
588
+
410
589
  // ── tab-aware panels (Pages: Top/Entry/Exit · Sources: Source/Medium/Campaign) ──
411
590
  function renderPagesPanel() {
412
591
  var s = lastStats; if (!s) return;
@@ -420,8 +599,8 @@
420
599
  'android-app://com.google.android.googlequicksearchbox': 'Google App (Android)', 't.co': 'X / Twitter',
421
600
  };
422
601
  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; }
602
+ function platformIcon(n) { return n === 'web' ? ico('i-web', 'ui') : n === 'windows' ? ico('i-desktop', 'ui') : brand(own(PLATFORM_ICON, n), 'i-mobile'); }
603
+ function platformLabel(n) { return own(PLATFORM_NAMES, n) || n; }
425
604
  state.pfTab = 'platforms';
426
605
  function renderPlatformsPanel() {
427
606
  var s = lastStats; if (!s) return;
@@ -433,7 +612,7 @@
433
612
  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
613
  else bars('p-platforms', s.platforms || [], { filterKey: 'platform', icon: function (r) { return platformIcon(r.name); }, label: function (r) { return platformLabel(r.name); } });
435
614
  }
436
- function sourceLabel(n) { if (n === 'Direct') return 'Direct / none'; var nice = SOURCE_NAMES[n]; return nice ? nice + ' · ' + n : n; }
615
+ function sourceLabel(n) { if (n === 'Direct') return 'Direct / none'; var nice = own(SOURCE_NAMES, n); return nice ? nice + ' · ' + n : n; }
437
616
  function renderSourcesPanel() {
438
617
  var s = lastStats; if (!s) return;
439
618
  var tab = state.srcTab, fk = tab === 'mediums' ? 'medium' : tab === 'campaigns' ? 'campaign' : 'source';
@@ -545,6 +724,10 @@
545
724
  api('/api/stats?' + statsQuery()).then(function (s) {
546
725
  if (s.error) return;
547
726
  lastStats = s;
727
+ var imn = $('importedNote');
728
+ if (s.imported && s.imported.included) { imn.textContent = 'Includes imported history' + (s.imported.until ? ' (until ' + s.imported.until + ')' : '') + '.'; imn.title = 'Visitors on imported days are summed per day, not unique across days.'; imn.classList.remove('hide'); }
729
+ else if (s.imported && s.imported.excludedByFilter) { imn.textContent = 'Imported history is hidden while a filter is active.'; imn.classList.remove('hide'); }
730
+ else imn.classList.add('hide');
548
731
  $('s-visitors').textContent = fmt(s.totals.visitors);
549
732
  $('s-pageviews').textContent = fmt(s.totals.pageviews);
550
733
  $('s-bounce').textContent = s.totals.bounce + '%';
@@ -558,9 +741,9 @@
558
741
  sparkline('s-pageviews', (s.series || []).map(function (d) { return d.pageviews; }), COL.p);
559
742
  renderPagesPanel();
560
743
  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'); } });
744
+ bars('p-countries', s.countries, { filterKey: 'country', icon: function (r) { return flagIcon(r.name); }, label: function (r) { return own(NAMES, r.name) || r.name; } });
745
+ 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(own(BROWSER_ICON, r.name), 'i-browser'); } });
746
+ bars('p-os', s.os, { filterKey: 'os', icon: function (r) { return r.name === 'Windows' ? ico('i-desktop', 'ui') : brand(own(OS_ICON, r.name), 'i-os'); } });
564
747
  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
748
  renderPlatformsPanel();
566
749
  bars('p-languages', s.languages, { filterKey: 'lang' });
@@ -696,7 +879,7 @@
696
879
  };
697
880
  $('copyShare').onclick = function () { navigator.clipboard.writeText($('shareUrl').textContent); $('copyShare').textContent = 'Copied'; setTimeout(function () { $('copyShare').textContent = 'Copy link'; }, 1500); };
698
881
  $('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'); };
882
+ $('closeShare').onclick = function () { closeModal($('shareModal')); };
700
883
 
701
884
  // ── funnels ──
702
885
  function fmtPct(p) { return (p == null ? '—' : p + '%'); }
@@ -744,7 +927,7 @@
744
927
  $('funnelModal').classList.remove('hide'); $('funnelName').focus();
745
928
  };
746
929
  $('addStepBtn').onclick = function () { if ($('funnelSteps').children.length < 8) $('funnelSteps').appendChild(stepRow()); };
747
- $('cancelFunnel').onclick = function () { $('funnelModal').classList.add('hide'); };
930
+ $('cancelFunnel').onclick = function () { closeModal($('funnelModal'), { force: true }); };
748
931
  $('createFunnelBtn').onclick = function () {
749
932
  var steps = Array.prototype.map.call($('funnelSteps').children, function (row) {
750
933
  return { type: row.querySelector('.step-type').value, value: row.querySelector('.step-value').value.trim() };
@@ -758,6 +941,7 @@
758
941
 
759
942
  function boot() {
760
943
  hideLogin();
944
+ if (window.TaciAccount) window.TaciAccount.afterBoot();
761
945
  syncLegend();
762
946
  loadSites().then(function () { load(); loadFunnels(); });
763
947
  // live dashboard: refresh every 15s while the tab is visible
@@ -776,7 +960,7 @@
776
960
  state.site = info.domain;
777
961
  var sel = $('siteSelect');
778
962
  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'); });
963
+ ['addSiteBtn', 'tzBtn', 'logoutBtn', 'settingsBtn', 'shareBtn', 'snippetBtn', 'addFunnelBtn', 'annBtn', 'alertsBtn', 'importBtn'].forEach(function (id) { var el = $(id); if (el) el.classList.add('hide'); });
780
964
  syncLegend();
781
965
  load();
782
966
  clearInterval(window.__rt);
@@ -784,5 +968,9 @@
784
968
  });
785
969
  }
786
970
 
787
- if (SHARE) bootShare(); else if (TOKEN) boot(); else showLogin();
971
+ var INVITE = (location.pathname.match(/^\/invite\/([a-f0-9]{32,64})$/) || [])[1] || '';
972
+ if (SHARE) bootShare();
973
+ else if (INVITE) { $('login').classList.add('hide'); window.TaciAccount.showInvite(INVITE); }
974
+ else if (/^\/reset\/[a-f0-9]{32,64}$/.test(location.pathname)) { $('login').classList.add('hide'); window.TaciAccount.showReset(location.pathname.split('/').pop()); }
975
+ else if (TOKEN) boot(); else showLogin();
788
976
  })();
@@ -197,4 +197,10 @@
197
197
  <symbol id="b-bluesky" viewBox="0 0 24 24"><path fill="currentColor" d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></symbol>
198
198
  <symbol id="b-mastodon" viewBox="0 0 24 24"><path fill="currentColor" d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></symbol>
199
199
  <symbol id="b-signal" viewBox="0 0 24 24"><path fill="currentColor" d="M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125"/></symbol>
200
+ <symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /><circle cx="16.5" cy="7.5" r=".5" fill="currentColor" /></symbol>
201
+ <symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></symbol>
202
+ <symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></symbol>
203
+ <symbol id="i-user-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><line x1="19" x2="19" y1="8" y2="14" /><line x1="22" x2="16" y1="11" y2="11" /></symbol>
204
+ <symbol id="i-smartphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2" /><path d="M12 18h.01" /></symbol>
205
+ <symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
200
206
  </svg>