Widget GTM Checker – detektor pikseli, podgląd dataLayer i symulator eventów

Strona główna » Skrypty » Widget GTM Checker – detektor pikseli, podgląd dataLayer i symulator eventów
Instrukcja użycia
Instrukcja instalacji

→ Zainstaluj rozszerzenie Tampermonkey w Chrome
→ Otwórz dashboard Tampermonkey → Utwórz nowy skrypt
→ Wklej poniższy kod i zapisz (Ctrl+S)
→ Odwiedź dowolną stronę → widget GTM Checker pojawi się w prawym dolnym rogu
→ Alt+Shift+A — ukrywa/przywraca widget na dowolnej domenie

Skrypt GTM Checker

// ==UserScript==
// @name         GTM Checker
// @namespace    https://pawelpiekarski.pl/
// @version      3.9.2
// @description  GTM injection (per domena, localStorage), live monitor dataLayer, edytowalny push dataLayer (presety GA4 w grupach Ecommerce / Leady, z wyborem liczby produktów, wczytywane do pola + wklejanie z GTM Preview / konsoli), detektor pixeli z consent mode, filtry DevTools Network per vendor i biblioteka komend — narzędzia web analityka
// @author       Paweł Piekarski
// @match        *://*/*
// @icon         https://www.gstatic.com/analytics-suite/header/suite/v2/ic_tag_manager.svg
// @run-at       document-start
// @noframes
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // Tylko top-level okno (zapas na wypadek, gdyby @noframes nie zadziałało).
    // Bez tego widget + auto-inject GTM odpalają się też w iframe'ach
    // (np. WP update.php) → dwa widgety i zdublowane hity.
    if (window.top !== window.self) return;
    // Jedna instancja na dokument (np. zdublowana instalacja skryptu w TM)
    const INST = 'data-pk-gtmc';
    const de = document.documentElement;
    if (de) {
        if (de.hasAttribute(INST)) return;
        de.setAttribute(INST, '1');
    }

    const WID = 'pk-gtmc-host';
    const SS_OPEN = 'pkGtmcOpen'; // sessionStorage — rozwinięcie per karta+domena
    const SS_TAB = 'pkGtmcTab';   // sessionStorage — ostatnia aktywna zakładka
    const LS_POS = 'pkGtmcPos';
    const LS_INJECT = 'pkGtmcInject'; // localStorage — GTM wstrzykiwany na tej domenie (origin)
    const LS_ITEMS = 'pkGtmcItems';   // localStorage — liczba produktów w presetach (per domena)

    let collapsed = true; // domyślnie zwinięty na każdej nowej stronie
    try { collapsed = sessionStorage.getItem(SS_OPEN) !== '1'; } catch (e) {}
    let manuallyHidden = false; // Alt+Shift+A — ukrycie tylko do reloadu
    let host = null, root = null;
    let activeTab = 'pixels';
    try {
        const t = sessionStorage.getItem(SS_TAB);
        if (['pixels', 'dl', 'push', 'cmds', 'net'].includes(t)) activeTab = t;
    } catch (e) {}
    let bridgeReady = false;
    let lastSnapshot = null;
    let lastConsent = null;

    const C = {
        bg: '#111827', hover: '#1e293b',
        accent: '#3b82f6', accentDk: '#1e3a5f',
        green: '#22c55e', red: '#ef4444', yellow: '#eab308', purple: '#c084fc',
        text: '#e5e7eb', dim: '#6b7280', border: '#374151',
    };

    // Hosty, na których widget NIE pokazuje się automatycznie (panele narzędzi,
    // nie "normalne" strony). Alt+Shift+A nadal działa, gdyby był potrzebny.
    const BLOCKED_HOSTS = [
        /(^|\.)tagmanager\.google\.com$/,
        /(^|\.)analytics\.google\.com$/,
        /(^|\.)tagassistant\.google\.com$/,
        /(^|\.)ads\.google\.com$/,
        /(^|\.)adsense\.google\.com$/,
        /(^|\.)merchants\.google\.com$/,
        /(^|\.)marketingplatform\.google\.com$/,
        /(^|\.)search\.google\.com$/,          // Search Console
        /(^|\.)lookerstudio\.google\.com$/,
        /(^|\.)datastudio\.google\.com$/,
        /(^|\.)optimize\.google\.com$/,
        /(^|\.)accounts\.google\.com$/,
        /(^|\.)mail\.google\.com$/,
        /(^|\.)drive\.google\.com$/,
        /(^|\.)docs\.google\.com$/,
        /(^|\.)calendar\.google\.com$/,
        /(^|\.)meet\.google\.com$/,
        /(^|\.)console\.cloud\.google\.com$/,
        /(^|\.)business\.facebook\.com$/,      // Meta Business Suite
        /(^|\.)adsmanager\.facebook\.com$/,
        /(^|\.)events\.facebook\.com$/,        // Events Manager
        /(^|\.)clarity\.microsoft\.com$/,
        /(^|\.)ads\.tiktok\.com$/,
        /(^|\.)ads\.microsoft\.com$/,
        /(^|\.)campaignmanager\.linkedin\.com$/,
        /(^|\.)analytics\.tiktok\.com$/,
        /(^|\.)app\.salesmanago\.com$/,
    ];
    function isBlockedHost() {
        const h = location.hostname;
        return BLOCKED_HOSTS.some(re => re.test(h));
    }

    function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
    function escAttr(s) { return escHtml(s).replace(/"/g, '&quot;'); }
    function fmtTime(ts) { const d = new Date(ts); return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':'); }
    function isGtmId(id) { return /^GTM-[A-Z0-9]+$/.test(id); }

    /* ═══════════════════ STORAGE (GTM per domena — localStorage) ═══════════════════ */
    // localStorage jest per origin, więc zapis "przykleja się" do domeny:
    // wchodzisz na sklep bez GTM → wpisujesz GTM-XXX → przy każdym kolejnym
    // wejściu kontener ładuje się przy document-start, jakby siedział w kodzie.
    function getDomainInject() {
        try {
            const raw = localStorage.getItem(LS_INJECT);
            if (!raw) return null;
            const o = JSON.parse(raw);
            return o && isGtmId(o.id) ? o : null;
        } catch (e) { return null; }
    }
    function setDomainInject(id) {
        try { localStorage.setItem(LS_INJECT, JSON.stringify({ id: id, ts: Date.now() })); return true; } catch (e) { return false; }
    }
    function clearDomainInject() {
        try { localStorage.removeItem(LS_INJECT); } catch (e) {}
    }

    /* ═══════════════════ BRIDGE (kod działający w kontekście strony) ═══════════════════ */
    // Wstrzykiwany jako <script>, komunikacja przez CustomEvent — działa niezależnie
    // od trybu sandboxa Tampermonkey. Monitoruje dataLayer, wykonuje pushe
    // symulatora i zwraca snapshot globali/consentu.
    //
    // Monitor dataLayer — dlaczego tak, a nie zwykły wrap dataLayer.push:
    // 1. GTM po załadowaniu NADPISUJE dataLayer.push własną funkcją — każdy wrapper
    //    założony wcześniej znika. Dlatego podstawą jest licznik przetworzonych
    //    indeksów per tablica + flush interwałem; wrap pusha jest tylko
    //    przyspieszaczem (natychmiastowy flush) i jest ponawiany, gdy GTM go zje.
    // 2. Strony potrafią zrobić `dataLayer = []` (reassignment) — wykrywane
    //    porównaniem referencji, licznik startuje od zera na nowej tablicy.
    // 3. GTM przy > ~300 wpisach robi splice starych — gdy length spadnie poniżej
    //    licznika, licznik jest przycinany do length (bez duplikatów).
    // 4. gtag() pushuje obiekty `arguments` — wykrywane i oznaczane jako komenda.
    // 5. Niestandardowe nazwy dataLayer (np. dane z GTM &l=) — okresowy skan
    //    window po /datalayer/i.
    function pageBridge() {
        if (window.__pkGtmcBridge) return;
        window.__pkGtmcBridge = true;

        function safeStringify(obj) {
            var seen = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
            return JSON.stringify(obj, function (k, v) {
                if (typeof v === 'function') return '[function]';
                try {
                    if (typeof Node !== 'undefined' && v instanceof Node) return '[DOM <' + (v.nodeName || 'node').toLowerCase() + '>]';
                    if (typeof Window !== 'undefined' && v instanceof Window) return '[window]';
                } catch (e) {}
                if (typeof v === 'object' && v !== null && seen) {
                    if (seen.has(v)) return '[circular]';
                    seen.add(v);
                }
                return v;
            });
        }

        function send(type, data) {
            try {
                document.dispatchEvent(new CustomEvent('pk-gtmc', { detail: safeStringify({ type: type, data: data, ts: Date.now() }) }));
            } catch (e) {}
        }

        // ── snapshot globali ──
        function snapshot() {
            var names = ['google_tag_manager', 'gtag', 'fbq', 'clarity', 'ttq', 'hj', '_linkedin_partner_id', 'pintrk', 'uetq', 'twq', 'snaptr', 'criteo_q', '_hsq', '_paq', 'UC_UI', 'Cookiebot', 'OneTrust', '__cmp'];
            var g = {};
            names.forEach(function (k) { try { g[k] = !!window[k]; } catch (e) { g[k] = false; } });
            var gtmKeys = []; try { gtmKeys = Object.keys(window.google_tag_manager || {}); } catch (e) {}
            var fbqIds = []; try { if (window.fbq && window.fbq.getState) (window.fbq.getState().pixels || []).forEach(function (p) { if (p.id) fbqIds.push(String(p.id)); }); } catch (e) {}
            var hjid = null; try { hjid = window._hjSettings && window._hjSettings.hjid ? String(window._hjSettings.hjid) : null; } catch (e) {}
            var liId = null; try { liId = window._linkedin_partner_id ? String(window._linkedin_partner_id) : null; } catch (e) {}
            send('snapshot', { g: g, gtmKeys: gtmKeys, fbqIds: fbqIds, hjid: hjid, liId: liId });
        }

        // ── consent mode ──
        function consent() {
            var out = null;
            try {
                var e = window.google_tag_data && window.google_tag_data.ics && window.google_tag_data.ics.entries;
                if (e) {
                    out = {};
                    Object.keys(e).forEach(function (k) {
                        out[k] = { def: e[k]['default'], upd: e[k].update };
                    });
                }
            } catch (err) {}
            send('consent', out);
        }

        /* ── MONITOR DATALAYER ── */
        var dlNames = ['dataLayer'];
        var dlRefs = [];   // [ {name, arr, seen} ] — stan per referencja tablicy
        var MAX_PAYLOAD = 50000;

        function isArguments(v) {
            try {
                return v && typeof v === 'object' && !Array.isArray(v) &&
                    typeof v.length === 'number' &&
                    Object.prototype.toString.call(v) === '[object Arguments]';
            } catch (e) { return false; }
        }

        function emit(raw, dlName, idx) {
            var kind = 'obj', entry = raw;
            try {
                if (isArguments(raw)) { kind = 'gtag'; entry = Array.prototype.slice.call(raw); }
            } catch (e) {}
            var payload = { dl: dlName, i: idx, kind: kind, e: entry };
            try {
                if (safeStringify(payload).length > MAX_PAYLOAD) {
                    payload.e = '[payload > 50 kB — pominięty; zobacz w konsoli: ' + dlName + '[' + idx + ']]';
                }
            } catch (err) {
                payload.e = '[nieserializowalny wpis — zobacz w konsoli: ' + dlName + '[' + idx + ']]';
            }
            send('dl', payload);
        }

        function stateFor(name, arr) {
            for (var i = 0; i < dlRefs.length; i++) {
                if (dlRefs[i].name === name) {
                    if (dlRefs[i].arr !== arr) { dlRefs[i].arr = arr; dlRefs[i].seen = 0; } // reassignment
                    return dlRefs[i];
                }
            }
            var st = { name: name, arr: arr, seen: 0 };
            dlRefs.push(st);
            return st;
        }

        function flush(name) {
            var arr;
            try { arr = window[name]; } catch (e) { return; }
            if (!arr || typeof arr.length !== 'number' || typeof arr.push !== 'function') return;
            var st = stateFor(name, arr);
            if (st.seen > arr.length) st.seen = arr.length; // GTM zrobił splice — bez re-emisji
            while (st.seen < arr.length) {
                emit(arr[st.seen], name, st.seen);
                st.seen++;
            }
        }

        function hookPush(name) {
            var arr;
            try { arr = window[name]; } catch (e) { return; }
            if (!arr || typeof arr.push !== 'function') return;
            if (arr.push.__pkGtmcH) return; // już owinięty (nasz wrap wciąż żyje)
            var orig = arr.push;
            var wrapped = function () {
                var r = orig.apply(this, arguments);
                try { flush(name); } catch (e) {}
                return r;
            };
            wrapped.__pkGtmcH = 1;
            try { arr.push = wrapped; } catch (e) {}
        }

        function scanCustomNames() {
            try {
                Object.keys(window).forEach(function (k) {
                    if (dlNames.indexOf(k) >= 0) return;
                    if (!/datalayer/i.test(k)) return;
                    var v; try { v = window[k]; } catch (e) { return; }
                    if (v && typeof v.length === 'number' && typeof v.push === 'function') dlNames.push(k);
                });
            } catch (e) {}
        }

        function dlTick() {
            for (var i = 0; i < dlNames.length; i++) {
                flush(dlNames[i]);     // dogania wszystko, nawet gdy GTM zjadł nasz wrap
                hookPush(dlNames[i]);  // re-hook po ewentualnym nadpisaniu przez GTM
            }
        }

        scanCustomNames();
        dlTick(); // złap wpisy istniejące już przy document-start
        setInterval(dlTick, 300);
        setInterval(scanCustomNames, 5000);

        // ── komendy z userscriptu ──
        document.addEventListener('pk-gtmc-cmd', function (ev) {
            var d; try { d = JSON.parse(ev.detail); } catch (e) { return; }
            if (d.a === 'snapshot') snapshot();
            else if (d.a === 'consent') consent();
            else if (d.a === 'push') {
                try {
                    window.dataLayer = window.dataLayer || [];
                    if (d.clear) window.dataLayer.push({ ecommerce: null });
                    window.dataLayer.push(d.data);
                } catch (e) {}
            }
        });

        send('ready', 1);
    }

    function injectBridge() {
        try {
            const s = document.createElement('script');
            s.textContent = '(' + pageBridge.toString() + ')();';
            (document.documentElement || document.head || document.body).appendChild(s);
            s.remove();
        } catch (e) {}
    }

    function sendCmd(a, extra) {
        try {
            document.dispatchEvent(new CustomEvent('pk-gtmc-cmd', { detail: JSON.stringify(Object.assign({ a: a }, extra || {})) }));
        } catch (e) {}
    }

    /* ═══════════════════ ZDARZENIA DL (bufor po stronie userscriptu) ═══════════════════ */
    const DL_MAX = 300;
    let dlEvents = [];      // chronologicznie; render od końca (najnowsze na górze)
    let dlSeq = 0;
    let dlPaused = false;
    let dlHideSys = false;  // filtr gtm.*
    let dlFilter = '';
    const dlExpanded = new Set();

    function dlEventName(d) {
        if (d.kind === 'gtag') {
            const a = d.e;
            const parts = Array.isArray(a) ? a.slice(0, 2).map(x => typeof x === 'string' ? x : (x === null ? 'null' : typeof x)) : [];
            return 'gtag(' + parts.join(', ') + (Array.isArray(a) && a.length > 2 ? ', …' : '') + ')';
        }
        const e = d.e;
        if (e && typeof e === 'object' && !Array.isArray(e)) {
            if (e.event) return String(e.event);
            const ks = Object.keys(e);
            if (ks.length) return '{' + ks.slice(0, 3).join(', ') + (ks.length > 3 ? ', …' : '') + '}';
            return '{}';
        }
        if (typeof e === 'string') return JSON.stringify(e);
        return String(e);
    }
    function dlEventClass(d) {
        if (d.kind === 'gtag') return 'gt';
        const e = d.e;
        if (e && typeof e === 'object' && e.ecommerce) return 'ec';
        if (e && typeof e === 'object' && typeof e.event === 'string' && e.event.indexOf('gtm.') === 0) return 'sys';
        return '';
    }
    function dlIsSys(d) {
        const e = d.e;
        return !!(e && typeof e === 'object' && typeof e.event === 'string' && e.event.indexOf('gtm.') === 0);
    }
    function dlMatches(rec) {
        if (dlHideSys && dlIsSys(rec.d)) return false;
        if (!dlFilter) return true;
        const q = dlFilter.toLowerCase();
        if (rec.name.toLowerCase().includes(q)) return true;
        try { return JSON.stringify(rec.d.e).toLowerCase().includes(q); } catch (e) { return false; }
    }

    function addDlEvent(d, ts) {
        const rec = { id: ++dlSeq, ts: ts || Date.now(), d: d, name: dlEventName(d), cls: dlEventClass(d) };
        dlEvents.push(rec);
        if (dlEvents.length > DL_MAX) {
            const drop = dlEvents.splice(0, dlEvents.length - DL_MAX);
            drop.forEach(r => dlExpanded.delete(r.id));
        }
        if (root && activeTab === 'dl' && !dlPaused) dlLiveInsert(rec);
        else if (root && activeTab === 'dl') updateDlFoot();
    }

    /* ═══════════════════ ODBIÓR Z BRIDGE'A ═══════════════════ */
    document.addEventListener('pk-gtmc', ev => {
        let msg; try { msg = JSON.parse(ev.detail); } catch (e) { return; }
        if (msg.type === 'ready') { bridgeReady = true; return; }
        if (msg.type === 'snapshot') { lastSnapshot = msg.data; return; }
        if (msg.type === 'consent') { lastConsent = msg.data; return; }
        if (msg.type === 'dl') { addDlEvent(msg.data, msg.ts); }
    });

    /* ═══════════════════ GTM INJECTION ═══════════════════ */
    const injectedIds = new Set();

    function injectGTM(containerId, dlName) {
        dlName = dlName || 'dataLayer';
        if (injectedIds.has(containerId) ||
            document.querySelector('script[src*="googletagmanager.com/gtm.js?id=' + containerId + '"]')) {
            console.log('%c[GTM Checker] ' + containerId + ' już istnieje — pomijam', 'color:#eab308;');
            return false;
        }
        injectedIds.add(containerId);
        const s = document.createElement('script');
        s.textContent = `
          (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
          new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
          j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
          'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
          })(window,document,'script','${dlName.replace(/[^A-Za-z0-9_$]/g, '')}','${containerId}');
        `;
        document.documentElement.appendChild(s);

        const insertNoscript = () => {
            const ns = document.createElement('noscript');
            const iframe = document.createElement('iframe');
            iframe.src = 'https://www.googletagmanager.com/ns.html?id=' + containerId;
            iframe.height = '0'; iframe.width = '0';
            iframe.style.display = 'none'; iframe.style.visibility = 'hidden';
            ns.appendChild(iframe);
            document.body.insertBefore(ns, document.body.firstChild);
        };
        if (document.body) insertNoscript();
        else document.addEventListener('DOMContentLoaded', insertNoscript);

        console.log('%c[GTM Checker] ✓ Wstrzyknięto: ' + containerId, 'color:#22c55e;font-weight:bold;');
        return true;
    }

    let autoInjectedId = null;
    if (!isBlockedHost()) injectBridge();
    (function autoInject() {
        if (isBlockedHost()) return;
        const dom = getDomainInject();
        if (dom) { injectGTM(dom.id); autoInjectedId = dom.id; }
    })();

    /* ═══════════════════ BIBLIOTEKA KOMEND ═══════════════════ */
    const COMMANDS = [
        { label: 'dataLayer', code: 'dataLayer', desc: 'Klasyk — jakie eventy i parametry są pushowane (GTM)' },
        { label: 'dataLayer — tylko eventy', code: 'dataLayer.filter(e => e.event)', desc: 'Odfiltrowane pushe bez eventu' },
        { label: 'dataLayer — tabela eventów', code: "console.table(dataLayer.filter(e => e.event).map((e,i) => ({'#': i, event: e.event})))", desc: 'Czytelna lista kolejności eventów' },
        { label: 'dataLayer — ostatni ecommerce', code: 'dataLayer.filter(e => e.ecommerce).pop()', desc: 'Ostatni push e-commerce (items, value, currency)' },
        { label: 'dataLayer — kopiuj jako JSON', code: 'copy(JSON.stringify(dataLayer, null, 2))', desc: 'Cały dataLayer do schowka — idealne do wklejenia AI' },
        { label: 'Live monitor dataLayer', code: "(function(){var p=dataLayer.push;dataLayer.push=function(){console.log('%c[DL push]','color:#22c55e;font-weight:bold',...arguments);return p.apply(this,arguments)}})()", desc: 'Loguje na żywo każdy kolejny push do dataLayer' },
        { label: 'window.google_tag_manager', code: 'window.google_tag_manager', desc: 'Czy GTM jest obecny i które kontenery działają' },
        { label: 'Google Consent Mode', code: "window.google_tag_data && google_tag_data.ics ? google_tag_data.ics.entries : 'brak google_tag_data.ics'", desc: 'Stan consent mode per storage type' },
        { label: 'document.referrer', code: 'document.referrer', desc: 'Skąd przyszedł użytkownik' },
        { label: 'Parametry UTM z URL', code: "Object.fromEntries([...new URLSearchParams(location.search)].filter(([k]) => /^(utm_|gclid|fbclid|msclkid)/.test(k)))", desc: 'utm_*, gclid, fbclid, msclkid z bieżącego URL' },
        { label: 'document.cookie', code: 'document.cookie', desc: 'Wszystkie cookies dostępne z JS' },
        { label: 'Cookies GA (_ga*)', code: "document.cookie.split('; ').filter(c => /^_ga/.test(c))", desc: 'Client ID i cookies sesji GA4' },
        { label: 'Cookies Meta (_fbp / _fbc)', code: "document.cookie.split('; ').filter(c => /^_fb[pc]/.test(c))", desc: 'Browser ID i click ID Meta — kluczowe dla CAPI' },
        { label: 'Zasoby / requesty', code: 'performance.getEntriesByType("resource")', desc: 'Co faktycznie się załadowało (w tym trackery)' },
        { label: 'Requesty trackerów', code: 'performance.getEntriesByType("resource").map(r => r.name).filter(n => /collect|gtm\\.js|gtag|facebook|clarity|hotjar|tiktok|doubleclick/i.test(n))', desc: 'Tylko hity trackingowe — walidacja czy coś odpaliło' },
        { label: 'Consent (Usercentrics)', code: "localStorage.getItem('uc_settings')", desc: 'Stan zgody, jeśli stoi Usercentrics' },
        { label: 'Iframes', code: "document.querySelectorAll('iframe')", desc: 'Osadzone narzędzia i skrypty 3rd party' },
        { label: 'Ukryte pola formularzy', code: "document.querySelectorAll('input[type=\"hidden\"]')", desc: 'Czy formularz zbiera dane trackingowe' },
        { label: 'Skrypty zewnętrzne', code: '[...document.scripts].map(s => s.src).filter(Boolean)', desc: 'Lista wszystkich zewnętrznych <script>' },
        { label: 'Timing ładowania strony', code: "(({domContentLoadedEventEnd:d,loadEventEnd:l,responseStart:r}) => ({TTFB_ms: Math.round(r), DOMContentLoaded_ms: Math.round(d), Load_ms: Math.round(l)}))(performance.getEntriesByType('navigation')[0])", desc: 'TTFB, DOMContentLoaded, Load — szybki health check' },
        { label: 'Niestandardowe dataLayer', code: "Object.keys(window).filter(k => /datalayer/i.test(k))", desc: 'Znajdź niestandardowe nazwy dataLayer w window' },
    ];

    /* ═══════════════════ FILTRY DEVTOOLS → NETWORK ═══════════════════ */
    // Składnia pola filtra w Network: zwykły tekst = substring URL, /regex/,
    // domain:*.x.com, method:POST, -term = negacja, kilka warunków po spacji = AND.
    const NET_FILTERS = [
        { vendor: 'Google Analytics 4 (GA4)', aliases: ['google analytics', 'ga4', 'ga', 'gtag', 'analytics', 'collect', 'sgtm', 'server side'], items: [
            { f: 'g/collect', d: 'Hity GA4 — działa też dla sGTM na własnej domenie' },
            { f: 'g/collect en=', d: 'Hity z nazwą eventu w URL (pojedyncze, nie batch)' },
            { f: 'g/collect en=purchase', d: 'Tylko purchase (jeśli nie poszedł w batchu POST)' },
            { f: 'g/collect _dbg=1', d: 'Hity z flagą DebugView' },
            { f: 'domain:*.google-analytics.com', d: 'Wszystko do google-analytics.com (regiony też)' },
            { f: 'g/collect -domain:*.google-analytics.com -domain:analytics.google.com', d: 'Tylko hity do sGTM / first-party (bez Google)' },
        ]},
        { vendor: 'Google Tag Manager (GTM) / gtag.js', aliases: ['google tag manager', 'gtm', 'gtag', 'tag manager', 'container', 'kontener', 'preview', 'tag assistant'], items: [
            { f: 'gtm.js', d: 'Ładowanie kontenera GTM' },
            { f: 'gtag/js', d: 'Ładowanie biblioteki gtag.js' },
            { f: 'googletagmanager.com', d: 'Wszystko z GTM (gtm.js, gtag/js, gtm/debug)' },
            { f: 'gtm/debug', d: 'Requesty trybu Preview / Tag Assistant' },
        ]},
        { vendor: 'Google Ads (AdWords)', aliases: ['google ads', 'gads', 'adwords', 'aw', 'ads', 'conversion', 'konwersje', 'remarketing', 'doubleclick', 'dc', 'floodlight'], items: [
            { f: 'pagead/conversion', d: 'Hity konwersji (googleadservices)' },
            { f: 'pagead/viewthroughconversion', d: 'Remarketing / view-through (doubleclick)' },
            { f: 'pagead/1p-conversion', d: 'Konwersje first-party (enhanced conversions)' },
            { f: '/googleadservices|doubleclick\\.net|googlesyndication/', d: 'Regex: cały ekosystem Google Ads' },
        ]},
        { vendor: 'Meta Pixel (Facebook / Instagram)', aliases: ['meta', 'facebook', 'fb', 'fbq', 'instagram', 'ig', 'pixel', 'piksel', 'capi', 'fbp', 'fbc'], items: [
            { f: 'facebook.com/tr', d: 'Hity pixela (PageView, Purchase…)' },
            { f: 'facebook.com/tr ev=Purchase', d: 'Tylko event Purchase' },
            { f: 'connect.facebook.net', d: 'Ładowanie fbevents.js' },
            { f: '/facebook\\.com\\/tr|connect\\.facebook\\.net/', d: 'Regex: biblioteka + hity' },
        ]},
        { vendor: 'TikTok Pixel', aliases: ['tiktok', 'tt', 'ttq', 'pixel'], items: [
            { f: 'analytics.tiktok.com', d: 'Biblioteka + hity' },
            { f: 'tiktok.com/api/v2/pixel', d: 'Same hity eventów' },
        ]},
        { vendor: 'Microsoft Clarity', aliases: ['clarity', 'ms clarity', 'microsoft', 'heatmap', 'heatmapa', 'nagrania', 'session recording'], items: [
            { f: 'clarity.ms', d: 'Biblioteka + collect' },
            { f: 'clarity.ms/collect', d: 'Same hity sesji' },
        ]},
        { vendor: 'Hotjar', aliases: ['hotjar', 'hj', 'heatmap', 'heatmapa', 'nagrania', 'session recording'], items: [
            { f: 'hotjar', d: 'hotjar.com + hotjar.io (biblioteka, sesje, WS)' },
        ]},
        { vendor: 'LinkedIn Insight Tag', aliases: ['linkedin', 'li', 'insight', 'licdn', 'b2b'], items: [
            { f: 'px.ads.linkedin.com', d: 'Hity (collect)' },
            { f: 'snap.licdn.com', d: 'Ładowanie insight.min.js' },
        ]},
        { vendor: 'Microsoft Ads (Bing UET)', aliases: ['microsoft ads', 'bing', 'bing ads', 'uet', 'msads', 'uetq'], items: [
            { f: 'bat.bing.com', d: 'Biblioteka + hity UET' },
            { f: 'bat.bing.com/action', d: 'Same hity eventów' },
        ]},
        { vendor: 'Pinterest Tag', aliases: ['pinterest', 'pin', 'pintrk'], items: [
            { f: 'ct.pinterest.com', d: 'Biblioteka + hity' },
        ]},
        { vendor: 'X (Twitter) Pixel', aliases: ['x', 'twitter', 'twq'], items: [
            { f: '/ads-twitter\\.com|t\\.co\\/i\\/adsct/', d: 'Regex: biblioteka + hity' },
        ]},
        { vendor: 'Snapchat Pixel', aliases: ['snap', 'snapchat', 'snaptr'], items: [
            { f: 'tr.snapchat.com', d: 'Hity pixela' },
        ]},
        { vendor: 'Criteo', aliases: ['criteo', 'retargeting'], items: [
            { f: 'criteo', d: 'static.criteo.net + hity' },
        ]},
        { vendor: 'HubSpot', aliases: ['hubspot', 'hs', 'hsq', 'crm', 'formularze'], items: [
            { f: '/hs-scripts|hs-analytics|hsforms|hubspot/', d: 'Regex: tracking + formularze' },
        ]},
        { vendor: 'Matomo (Piwik)', aliases: ['matomo', 'piwik', 'paq', 'self-hosted'], items: [
            { f: 'matomo.php', d: 'Hity (lub piwik.php w starszych)' },
        ]},
        { vendor: 'CMP / Consent (zgody)', aliases: ['cmp', 'consent', 'zgody', 'cookie', 'cookies', 'rodo', 'gdpr', 'usercentrics', 'cookiebot', 'onetrust', 'cookieyes', 'banner'], items: [
            { f: 'usercentrics', d: 'Usercentrics' },
            { f: 'consent.cookiebot.com', d: 'Cookiebot' },
            { f: 'cookielaw.org', d: 'OneTrust' },
            { f: 'cdn-cookieyes.com', d: 'CookieYes' },
        ]},
        { vendor: 'Ogólne / wszystko', aliases: ['ogólne', 'ogolne', 'all', 'wszystko', 'third party', '3rd party', 'post', 'błędy', 'bledy', 'blokada', 'adblock', 'cache'], items: [
            { f: '/collect|gtm\\.js|gtag|facebook\\.com\\/tr|clarity\\.ms|hotjar|tiktok|doubleclick|linkedin|pinterest|bing\\.com\\/action/', d: 'Regex: wszystkie hity trackerów naraz' },
            { f: '-domain:*.' + location.hostname.replace(/^www\./, '') + ' -domain:' + location.hostname, d: 'Tylko requesty poza tę domenę (3rd party)', dyn: true },
            { f: 'method:POST', d: 'Same POST-y (batch GA4, Meta, sGTM)' },
            { f: '-status-code:200 -status-code:204 -status-code:302', d: 'Wszystko, co nie wróciło OK — blokady, błędy' },
            { f: 'is:from-cache', d: 'Tylko z cache (czy biblioteka faktycznie się pobrała?)' },
        ]},
    ];

    let netQuery = '';
    function renderNet(panel) {
        panel.innerHTML = `
          <div class="at-dl-bar">
            <input class="at-input" id="at-net-q" placeholder="szukaj: ga4, meta|tiktok, ^google…" value="${escAttr(netQuery)}">
          </div>
          <div class="at-note" style="margin:0 0 6px 2px">Kliknij, aby skopiować → wklej w pole filtra ▽ w DevTools → Network. Tekst = substring URL, <b>/…/</b> = regex, <b>-</b> = negacja, spacja = AND.</div>
          <div id="at-net-list"></div>
        `;
        const list = panel.querySelector('#at-net-list');
        const draw = () => {
            list.innerHTML = '';
            const q = netQuery.toLowerCase();
            // szukajka: regex, jeśli się parsuje (np. "ga4|meta", "^goo"); inaczej substring
            let m;
            try { const re = new RegExp(netQuery, 'i'); m = str => re.test(str); }
            catch (e) { m = str => str.toLowerCase().includes(q); }
            let n = 0;
            NET_FILTERS.forEach(g => {
                const vendorHit = !q || m(g.vendor) || (g.aliases || []).some(a => m(a));
                const items = g.items.filter(it => vendorHit || m(it.f) || m(it.d));
                if (!items.length) return;
                const lab = document.createElement('div');
                lab.className = 'at-label';
                lab.textContent = g.vendor;
                list.appendChild(lab);
                items.forEach(it => {
                    n++;
                    const row = document.createElement('div');
                    row.className = 'at-cmd';
                    row.innerHTML = `<div class="at-cmd-code" style="font-size:11px;color:#bfdbfe;white-space:normal;word-break:break-all">${escHtml(it.f)} <span class="at-copied" style="display:none">✓ skopiowano</span></div>` +
                        `<div class="at-cmd-desc">${escHtml(it.d)}</div>`;
                    row.addEventListener('click', () => {
                        copyText(it.f);
                        const c = row.querySelector('.at-copied');
                        c.style.display = '';
                        setTimeout(() => { c.style.display = 'none'; }, 900);
                    });
                    list.appendChild(row);
                });
            });
            if (!n) list.innerHTML = '<div class="at-empty">Nic nie pasuje.</div>';
            root.querySelector('#at-foot-txt').textContent = `${n} filtrów Network`;
        };
        panel.querySelector('#at-net-q').addEventListener('input', ev => { netQuery = ev.target.value.trim(); draw(); });
        panel.querySelector('#at-net-q').addEventListener('keydown', e => e.stopPropagation());
        draw();
    }

    /* ═══════════════════ PRESETY PUSHY E-COMMERCE ═══════════════════ */
    // Neutralne dane testowe — łatwe do odfiltrowania w raportach (item_id TEST-*, transaction_id TEST-*).
    // Liczba produktów wybierana w zakładce Push (1 / 2 / 3 / 5) i pamiętana per domena.
    // Produkt k ma price = k, więc przy 2 produktach value = 1 + 2 = 3 — od razu widać,
    // czy GA4 / Ads / Meta liczą sumę, a nie np. tylko pierwszy item.
    const ITEM_COUNTS = [1, 2, 3, 5];
    let itemCount = 1;
    try {
        const n = parseInt(localStorage.getItem(LS_ITEMS), 10);
        if (ITEM_COUNTS.includes(n)) itemCount = n;
    } catch (e) {}
    function setItemCount(n) {
        itemCount = n;
        try { localStorage.setItem(LS_ITEMS, String(n)); } catch (e) {}
    }

    function round2(x) { return Math.round(x * 100) / 100; }
    function sampleItems(n) {
        n = Math.max(1, n || 1);
        const out = [];
        for (let i = 0; i < n; i++) {
            out.push({
                item_id: 'TEST-' + String(i + 1).padStart(3, '0'),
                item_name: 'Produkt testowy ' + (i + 1),
                item_brand: 'Noname',
                item_category: 'Testowe',
                item_list_name: 'Lista testowa',
                index: i,
                price: i + 1,
                quantity: 1,
            });
        }
        return out;
    }
    function itemsValue(items) {
        return round2(items.reduce((s, it) => s + (Number(it.price) || 0) * (Number(it.quantity) || 1), 0));
    }
    function txId() { return 'TEST-' + Date.now(); }
    function plProd(n) {
        if (n === 1) return '1 produkt';
        const d = n % 10, dd = n % 100;
        return n + (d >= 2 && d <= 4 && (dd < 12 || dd > 14) ? ' produkty' : ' produktów');
    }
    // Standardowy event ecommerce: currency, value (= suma items), parametry dodatkowe, items
    function ecomEvent(event, n, extra) {
        const items = sampleItems(n);
        return { event: event, ecommerce: Object.assign({ currency: 'PLN', value: itemsValue(items) }, extra || {}, { items: items }) };
    }

    // Grupy presetów = podzakładki w zakładce Push.
    const PRESET_GROUPS = [
        { id: 'ecom', label: '🛒 Ecommerce' },
        { id: 'lead', label: '📨 Leady' },
    ];
    const SS_PUSH_GRP = 'pkGtmcPushGrp'; // sessionStorage — ostatnio wybrana grupa presetów
    let presetGroup = 'ecom';
    try {
        const g = sessionStorage.getItem(SS_PUSH_GRP);
        if (PRESET_GROUPS.some(x => x.id === g)) presetGroup = g;
    } catch (e) {}

    // W obrębie grupy kolejność = ważność: konwersje i dół lejka na górze, mikro-eventy na dole.
    // items: true  → preset bierze liczbę produktów z przełącznika
    // items: 1     → zawsze 1 produkt (GA4 dla select_item oczekuje jednego itemu)
    // brak items   → event bez produktów
    // Leady: rekomendowane eventy GA4 dla lead gen (cykl życia leada) + konto.
    const PRESETS = [
        // ── Ecommerce ──
        { group: 'ecom', label: 'purchase', desc: 'Zakup (nowy transaction_id przy każdym wczytaniu)', items: true, build: n => {
            const items = sampleItems(n);
            const value = itemsValue(items);
            return { event: 'purchase', ecommerce: { transaction_id: txId(), value: value, tax: round2(value - value / 1.23), shipping: 0, currency: 'PLN', items: items } };
        } },
        { group: 'ecom', label: 'begin_checkout', desc: 'Rozpoczęcie checkoutu', items: true, build: n => ecomEvent('begin_checkout', n) },
        { group: 'ecom', label: 'add_payment_info', desc: 'Wybór płatności', items: true, build: n => ecomEvent('add_payment_info', n, { payment_type: 'Testowa płatność' }) },
        { group: 'ecom', label: 'add_shipping_info', desc: 'Wybór dostawy', items: true, build: n => ecomEvent('add_shipping_info', n, { shipping_tier: 'Testowa dostawa' }) },
        { group: 'ecom', label: 'add_to_cart', desc: 'Dodanie do koszyka', items: true, build: n => ecomEvent('add_to_cart', n) },
        { group: 'ecom', label: 'view_cart', desc: 'Wyświetlenie koszyka', items: true, build: n => ecomEvent('view_cart', n) },
        { group: 'ecom', label: 'view_item', desc: 'Wyświetlenie karty produktu', items: true, build: n => ecomEvent('view_item', n) },
        { group: 'ecom', label: 'refund', desc: 'Zwrot (pełny)', items: true, build: n => {
            const items = sampleItems(n);
            return { event: 'refund', ecommerce: { transaction_id: txId(), value: itemsValue(items), currency: 'PLN', items: items } };
        } },
        { group: 'ecom', label: 'select_item', desc: 'Kliknięcie produktu na liście (zawsze 1 produkt)', items: 1, build: () => ({ event: 'select_item', ecommerce: { item_list_id: 'lista_testowa', item_list_name: 'Lista testowa', items: sampleItems(1) } }) },
        { group: 'ecom', label: 'view_item_list', desc: 'Wyświetlenie listy produktów', items: true, build: n => ({ event: 'view_item_list', ecommerce: { item_list_id: 'lista_testowa', item_list_name: 'Lista testowa', items: sampleItems(n) } }) },
        { group: 'ecom', label: 'remove_from_cart', desc: 'Usunięcie z koszyka', items: true, build: n => ecomEvent('remove_from_cart', n) },
        { group: 'ecom', label: 'add_to_wishlist', desc: 'Dodanie do listy życzeń', items: true, build: n => ecomEvent('add_to_wishlist', n) },
        // ── Leady ──
        { group: 'lead', label: 'generate_lead', desc: 'Wysłanie formularza / nowy lead', build: () => ({ event: 'generate_lead', currency: 'PLN', value: 1, lead_source: 'test' }) },
        { group: 'lead', label: 'qualify_lead', desc: 'Lead zakwalifikowany (MQL / SQL)', build: () => ({ event: 'qualify_lead', currency: 'PLN', value: 1 }) },
        { group: 'lead', label: 'working_lead', desc: 'Kontakt z leadem w toku', build: () => ({ event: 'working_lead', currency: 'PLN', value: 1, lead_status: 'Rozmowa z handlowcem' }) },
        { group: 'lead', label: 'close_convert_lead', desc: 'Lead zamieniony w klienta', build: () => ({ event: 'close_convert_lead', currency: 'PLN', value: 1 }) },
        { group: 'lead', label: 'disqualify_lead', desc: 'Lead odrzucony', build: () => ({ event: 'disqualify_lead', currency: 'PLN', value: 1, disqualified_lead_reason: 'Testowy powód' }) },
        { group: 'lead', label: 'close_unconvert_lead', desc: 'Lead zamknięty bez sprzedaży', build: () => ({ event: 'close_unconvert_lead', currency: 'PLN', value: 1, unconvert_lead_reason: 'Testowy powód' }) },
        { group: 'lead', label: 'sign_up', desc: 'Rejestracja konta', build: () => ({ event: 'sign_up', method: 'email' }) },
        { group: 'lead', label: 'login', desc: 'Logowanie użytkownika', build: () => ({ event: 'login', method: 'email' }) },
    ];

    /* ═══════════════════ PARSER WŁASNEGO DATALAYER ═══════════════════ */
    // Przyjmuje to, co realnie się kopiuje:
    //  • GTM Preview → "Data Layer" / "Message" — notacja JS: klucze bez cudzysłowów,
    //    także z kropką (gtm.uniqueEventId: 25), wartości undefined itd.
    //  • zwykły JSON, tablicę pushy (np. copy(JSON.stringify(dataLayer)))
    //  • snippet z dataLayer.push({...}) — także kilka wywołań naraz
    //  • tekst z numerami linii z gutteru Preview (fallback)
    // Bez eval/new Function — CSP strony tego nie zablokuje.
    function makeLooseParser(s) {
        let i = 0;
        const UNDEF = {};
        const KEY = /[A-Za-z0-9_$.\-]+/y;
        const BARE = /[^,}\]\)\r\n]+/y;
        const NUM = /^[+-]?(?:\d+\.?\d*(?:e[+-]?\d+)?|\.\d+(?:e[+-]?\d+)?|0x[0-9a-f]+)$/i;

        function fail(msg) {
            const p = Math.min(i, s.length);
            const before = s.slice(0, p);
            const line = before.split('\n').length;
            const col = p - before.lastIndexOf('\n');
            let hint = '';
            if (s.indexOf('…') >= 0) hint = ' — w tekście jest „…” (zwinięty obiekt z konsoli?) — skopiuj pełny, np. copy(dataLayer[N])';
            throw new Error(msg + ' — linia ' + line + ', kol. ' + col + hint);
        }
        function ws() {
            for (;;) {
                const c = s[i];
                if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\u00a0' || c === '\ufeff') { i++; continue; }
                if (c === '/' && s[i + 1] === '/') { while (i < s.length && s[i] !== '\n') i++; continue; }
                if (c === '/' && s[i + 1] === '*') { const j = s.indexOf('*/', i + 2); i = j < 0 ? s.length : j + 2; continue; }
                break;
            }
        }
        function str() {
            const q = s[i++];
            let out = '';
            while (i < s.length) {
                const c = s[i++];
                if (c === q) return out;
                if (c === '\\') {
                    const n = s[i++];
                    if (n === 'u') { out += String.fromCharCode(parseInt(s.substr(i, 4), 16)); i += 4; }
                    else if (n === 'x') { out += String.fromCharCode(parseInt(s.substr(i, 2), 16)); i += 2; }
                    else if (n === '\n') { /* kontynuacja linii */ }
                    else {
                        const map = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0' };
                        out += map[n] !== undefined ? map[n] : n;
                    }
                } else out += c;
            }
            fail('Niezamknięty string');
        }
        function key() {
            ws();
            const c = s[i];
            if (c === '"' || c === "'" || c === '`') return str();
            KEY.lastIndex = i;
            const m = KEY.exec(s);
            if (!m) fail('Oczekiwano klucza');
            i += m[0].length;
            return m[0];
        }
        function obj() {
            i++;
            const o = {};
            for (;;) {
                ws();
                if (s[i] === '}') { i++; return o; }
                const k = key();
                ws();
                if (s[i] !== ':') fail('Oczekiwano „:” po kluczu „' + k + '”');
                i++;
                const v = value();
                if (v !== UNDEF) o[k] = v;
                ws();
                if (s[i] === ',') { i++; continue; }
                if (s[i] === '}') { i++; return o; }
                fail('Oczekiwano „,” lub „}”');
            }
        }
        function arr() {
            i++;
            const a = [];
            for (;;) {
                ws();
                if (s[i] === ']') { i++; return a; }
                const v = value();
                a.push(v === UNDEF ? null : v);
                ws();
                if (s[i] === ',') { i++; continue; }
                if (s[i] === ']') { i++; return a; }
                fail('Oczekiwano „,” lub „]”');
            }
        }
        function value() {
            ws();
            const c = s[i];
            if (c === '{') return obj();
            if (c === '[') return arr();
            if (c === '"' || c === "'" || c === '`') return str();
            BARE.lastIndex = i;
            const m = BARE.exec(s);
            const t = m ? m[0].trim() : '';
            if (!t) fail('Oczekiwano wartości');
            i += m[0].length;
            if (t === 'true') return true;
            if (t === 'false') return false;
            if (t === 'null') return null;
            if (t === 'undefined') return UNDEF;
            if (t === 'NaN' || t === 'Infinity' || t === '-Infinity') return null;
            if (NUM.test(t)) return Number(t);
            return t; // np. "HTMLDivElement: …" z gtm.element — zostaje jako string
        }

        return {
            // wywołanie dataLayer.push( … ) — od pozycji po "(" do ")"
            parseArgs(pos) {
                i = pos;
                const vals = [];
                ws();
                if (s[i] === ')') { i++; return { values: vals, end: i }; }
                for (;;) {
                    const v = value();
                    vals.push(v === UNDEF ? null : v);
                    ws();
                    if (s[i] === ',') { i++; continue; }
                    if (s[i] === ')') { i++; return { values: vals, end: i }; }
                    fail('Oczekiwano „,” lub „)” w dataLayer.push(…)');
                }
            },
            // jeden lub kilka obiektów/tablic jeden po drugim
            parseAll() {
                i = 0;
                const vals = [];
                ws();
                while (i < s.length) {
                    const v = value();
                    vals.push(v === UNDEF ? null : v);
                    ws();
                    while (s[i] === ';' || s[i] === ',') { i++; ws(); }
                }
                if (!vals.length) fail('Pusty tekst');
                return vals;
            },
        };
    }

    function parseLayerText(txt) {
        const s = txt.replace(/^\uFEFF/, '');
        const P = makeLooseParser(s);
        const PUSH_RE = /dataLayer\s*\.\s*push\s*\(/g;
        if (PUSH_RE.test(s)) {
            PUSH_RE.lastIndex = 0;
            const out = [];
            let m;
            while ((m = PUSH_RE.exec(s))) {
                const r = P.parseArgs(m.index + m[0].length);
                out.push(...r.values);
                PUSH_RE.lastIndex = r.end;
            }
            return out;
        }
        return P.parseAll();
    }

    // Fallback, gdy w schowku wylądowały numery linii z gutteru GTM Preview
    function stripLineNumbers(txt) {
        return txt.split(/\r?\n/)
            .filter(l => !/^\s*\d+\s*$/.test(l))
            .map(l => l.replace(/^\s*\d+(?:[ \t]+|(?=[{}\[\]A-Za-z_$"'`]))/, ''))
            .join('\n');
    }

    function parseCustomLayer(txt) {
        let vals;
        try { vals = parseLayerText(txt); }
        catch (e1) {
            const stripped = stripLineNumbers(txt);
            if (stripped === txt) throw e1;
            try { vals = parseLayerText(stripped); } catch (e2) { throw e1; }
        }
        // tablica obiektów = lista pushy (np. skopiowany cały dataLayer)
        const raw = [];
        vals.forEach(v => { if (Array.isArray(v)) raw.push(...v); else raw.push(v); });

        const entries = [];
        const dropped = new Set();
        let skipped = 0;
        raw.forEach(v => {
            if (!v || typeof v !== 'object' || Array.isArray(v)) { skipped++; return; } // gtag()/arguments, prymitywy
            const o = {};
            Object.keys(v).forEach(k => {
                // gtm / gtm.uniqueEventId / gtm.start / gtm.element… — GTM nada własne
                if (k === 'gtm' || k.indexOf('gtm.') === 0 || k === 'eventModel') { dropped.add(k); return; } // eventModel = wewnętrzna kopia GTM z Preview
                o[k] = v[k];
            });
            if (typeof o.event === 'string' && o.event.indexOf('gtm.') === 0) { skipped++; return; } // gtm.js, gtm.dom…
            const ks = Object.keys(o);
            if (!ks.length) { skipped++; return; }
            if (ks.length === 1 && ks[0] === 'ecommerce' && o.ecommerce === null) { skipped++; return; } // czyszczenie dodajemy sami
            entries.push(o);
        });
        if (!entries.length) {
            throw new Error('Brak obiektów do pushowania' + (skipped ? ' (pominięto ' + skipped + ' systemowych / pustych / gtag)' : ''));
        }
        return { entries: entries, dropped: [...dropped], skipped: skipped };
    }

    function pushEntries(entries) {
        entries.forEach(e => sendCmd('push', { data: e, clear: !!(e && e.ecommerce) }));
    }

    function entriesToSnippet(entries) {
        return 'window.dataLayer = window.dataLayer || [];\n' + entries.map(e =>
            (e && e.ecommerce ? 'dataLayer.push({ ecommerce: null });\n' : '') +
            'dataLayer.push(' + JSON.stringify(e, null, 2) + ');'
        ).join('\n');
    }


    /* ═══════════════════ DETEKTOR PIXELI ═══════════════════ */
    const TRACKERS = [
        { name: 'Google Tag Manager', re: /googletagmanager\.com\/gtm\.js/i, global: 'google_tag_manager',
          ids: (u, s) => (s ? s.gtmKeys.filter(k => /^GTM-/i.test(k)) : []) },
        { name: 'GA4 / gtag', re: /googletagmanager\.com\/gtag\/js|google-analytics\.com|analytics\.google\.com\/g\/collect/i, global: 'gtag',
          ids: (urls, s) => {
              const out = new Set(s ? s.gtmKeys.filter(k => /^G-/i.test(k)) : []);
              urls.forEach(u => {
                  let m = u.match(/gtag\/js\?id=(G-[A-Z0-9]+)/i); if (m) out.add(m[1].toUpperCase());
                  m = u.match(/\/g\/collect\?[^ ]*tid=(G-[A-Z0-9]+)/i); if (m) out.add(m[1].toUpperCase());
              });
              return [...out];
          } },
        { name: 'Google Ads / DoubleClick', re: /googleadservices\.com|doubleclick\.net|googlesyndication/i, global: null,
          ids: (u, s) => (s ? s.gtmKeys.filter(k => /^(AW|DC)-/i.test(k)) : []) },
        { name: 'Meta Pixel', re: /connect\.facebook\.net|facebook\.com\/tr/i, global: 'fbq',
          ids: (urls, s) => {
              const out = new Set(s ? s.fbqIds : []);
              urls.forEach(u => { const m = u.match(/facebook\.com\/tr[/?].*?id=(\d+)/i); if (m) out.add(m[1]); });
              return [...out];
          } },
        { name: 'Microsoft Clarity', re: /clarity\.ms/i, global: 'clarity',
          ids: (urls) => { const out = new Set(); urls.forEach(u => { const m = u.match(/clarity\.ms\/tag\/([a-z0-9]+)/i); if (m) out.add(m[1]); }); return [...out]; } },
        { name: 'TikTok Pixel', re: /analytics\.tiktok\.com/i, global: 'ttq',
          ids: (urls) => { const out = new Set(); urls.forEach(u => { const m = u.match(/sdkid=([A-Z0-9]+)/i); if (m) out.add(m[1]); }); return [...out]; } },
        { name: 'Hotjar', re: /static\.hotjar\.com|script\.hotjar\.com/i, global: 'hj',
          ids: (u, s) => (s && s.hjid ? [s.hjid] : []) },
        { name: 'LinkedIn Insight', re: /snap\.licdn\.com|px\.ads\.linkedin\.com/i, global: '_linkedin_partner_id',
          ids: (u, s) => (s && s.liId ? [s.liId] : []) },
        { name: 'Pinterest Tag', re: /ct\.pinterest\.com|s\.pinimg\.com\/ct/i, global: 'pintrk', ids: () => [] },
        { name: 'Microsoft Ads (UET)', re: /bat\.bing\.com/i, global: 'uetq', ids: () => [] },
        { name: 'X / Twitter Pixel', re: /static\.ads-twitter\.com|t\.co\/i\/adsct/i, global: 'twq', ids: () => [] },
        { name: 'Snap Pixel', re: /sc-static\.net|tr\.snapchat\.com/i, global: 'snaptr', ids: () => [] },
        { name: 'Criteo', re: /static\.criteo\.net|criteo\.com/i, global: 'criteo_q', ids: () => [] },
        { name: 'HubSpot', re: /js\.hs-scripts\.com|hs-analytics\.net|hsforms/i, global: '_hsq', ids: () => [] },
        { name: 'Matomo / Piwik', re: /matomo\.js|piwik\.js|matomo\.php/i, global: '_paq', ids: () => [] },
        { name: 'Usercentrics (CMP)', re: /usercentrics\.eu|usercentrics\.com/i, global: 'UC_UI', ids: () => [] },
        { name: 'Cookiebot (CMP)', re: /consent\.cookiebot\.com/i, global: 'Cookiebot', ids: () => [] },
        { name: 'OneTrust (CMP)', re: /cdn\.cookielaw\.org|onetrust/i, global: 'OneTrust', ids: () => [] },
        { name: 'CookieYes (CMP)', re: /cdn-cookieyes\.com/i, global: null, ids: () => [] },
        { name: 'Consentmanager (CMP)', re: /consentmanager\.net|delivery\.consentmanager/i, global: '__cmp', ids: () => [] },
    ];

    function detectTrackers() {
        sendCmd('snapshot'); // dispatch jest synchroniczny — lastSnapshot aktualny
        const s = lastSnapshot;
        let urls = [];
        try { urls = performance.getEntriesByType('resource').map(r => r.name); } catch (e) {}
        [...document.scripts].forEach(sc => { if (sc.src) urls.push(sc.src); });
        document.querySelectorAll('iframe[src]').forEach(f => urls.push(f.src));
        urls = [...new Set(urls)];

        return TRACKERS.map(t => {
            const urlHit = urls.some(u => t.re.test(u));
            const globalHit = !!(s && t.global && s.g[t.global]);
            let ids = [];
            try { ids = t.ids(urls, s) || []; } catch (e) {}
            return { name: t.name, detected: urlHit || globalHit || ids.length > 0, ids };
        });
    }

    /* ═══════════════════ CLIPBOARD ═══════════════════ */
    function copyText(txt) {
        if (navigator.clipboard && navigator.clipboard.writeText) {
            return navigator.clipboard.writeText(txt).catch(() => fallbackCopy(txt));
        }
        return Promise.resolve(fallbackCopy(txt));
    }
    function fallbackCopy(txt) {
        const ta = document.createElement('textarea');
        ta.value = txt;
        ta.style.cssText = 'position:fixed;opacity:0';
        document.body.appendChild(ta);
        ta.select();
        try { document.execCommand('copy'); } catch (e) {}
        ta.remove();
    }

    /* ═══════════════════ WIDGET (Shadow DOM) ═══════════════════ */
    const CSS = `
:host{all:initial}
*{box-sizing:border-box}
.wg{position:relative;width:360px;border-radius:14px;overflow:hidden;box-shadow:0 10px 36px rgba(0,0,0,.55);border:1px solid ${C.border};background:${C.bg};font-family:'Google Sans',system-ui,sans-serif;user-select:none;text-align:left;line-height:1.4;color:${C.text}}
.at-hdr{background:linear-gradient(135deg,${C.accentDk},${C.bg});padding:10px 14px;display:flex;align-items:center;gap:8px;cursor:move;border-bottom:1px solid ${C.border}}
.at-logo{width:22px;height:22px;flex-shrink:0}
.at-logo svg{display:block}
.at-title{flex:1;font-size:13px;font-weight:600;color:#fff;letter-spacing:.3px}
.at-badge{font-size:10px;background:${C.border};color:${C.dim};padding:2px 8px;border-radius:10px;max-width:120px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-badge.inj{background:rgba(34,197,94,.15);color:${C.green}}
.at-hdr-btn{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;opacity:.6;transition:opacity .15s;font-family:inherit}
.at-hdr-btn:hover{opacity:1}
.at-wrap{overflow:hidden;transition:max-height .25s ease}
.at-wrap.open{max-height:85vh}.at-wrap.closed{max-height:0!important}
/* ── pasek wstrzykiwania GTM (per domena) ── */
.at-inj{display:flex;align-items:center;gap:6px;padding:8px 12px;border-bottom:1px solid ${C.border};background:rgba(59,130,246,.05)}
.at-inj .at-input{flex:1;padding:5px 9px;font-size:11px;text-transform:uppercase}
.at-inj .at-input::placeholder{text-transform:none}
.at-inj-dot{width:8px;height:8px;border-radius:50%;background:${C.green};flex-shrink:0;box-shadow:0 0 6px ${C.green}}
.at-inj-dot.warn{background:${C.yellow};box-shadow:0 0 6px ${C.yellow}}
.at-inj-txt{flex:1;min-width:0;font-size:11px;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-inj-txt b{font-family:'Consolas','Monaco',monospace;color:${C.green}}
.at-inj-txt small{color:${C.dim};font-size:10px}
.at-inj-err{font-size:10px;color:${C.red};padding:0 12px 6px;background:rgba(59,130,246,.05)}
.at-tabs{display:flex;border-bottom:1px solid ${C.border}}
.at-tab{flex:1;background:none;border:none;color:${C.dim};font-size:10px;font-weight:600;padding:8px 2px;cursor:pointer;font-family:inherit;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap}
.at-tab:hover{color:${C.text};background:${C.hover}}
.at-tab.on{color:${C.accent};border-bottom-color:${C.accent}}
.at-panel{padding:10px 14px;max-height:min(440px,62vh);overflow-y:auto}
.at-panel::-webkit-scrollbar{width:5px}.at-panel::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
.at-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin:8px 0 4px}
.at-label:first-child{margin-top:0}
.at-input{width:100%;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:6px 9px;border-radius:7px;font-size:12px;outline:none;font-family:'Consolas','Monaco',monospace}
.at-input:focus{border-color:${C.accent}}
.at-input::placeholder{color:#6b7280;font-family:'Google Sans',system-ui,sans-serif}
.at-btn{border:none;border-radius:7px;padding:6px 12px;cursor:pointer;font-size:11px;font-weight:600;font-family:inherit;transition:.15s}
.at-btn-primary{background:${C.accent};color:#fff}
.at-btn-primary:hover{background:#2563eb}
.at-btn-sm{padding:3px 9px;font-size:10px;border-radius:6px}
.at-btn-ghost{background:transparent;border:1px dashed ${C.border};color:${C.dim};width:100%}
.at-btn-ghost:hover{border-color:${C.accent};color:${C.accent}}
.at-btn-sec{background:${C.border};color:${C.text}}
.at-btn-sec:hover{background:#4b5563}
.at-btn-danger{background:rgba(239,68,68,.15);color:${C.red}}
.at-btn-danger:hover{background:rgba(239,68,68,.3)}
.at-cmd{padding:7px 8px;border-radius:7px;cursor:pointer;transition:background .1s;margin-bottom:2px}
.at-cmd:hover{background:${C.hover}}
.at-cmd-label{font-size:11px;font-weight:600;color:${C.text};display:flex;align-items:center;gap:6px}
.at-copied{color:${C.green};font-size:10px;font-weight:700}
.at-cmd-code{font-family:'Consolas','Monaco',monospace;font-size:10px;color:#93c5fd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0}
.at-cmd-desc{font-size:10px;color:${C.dim}}
.at-px{display:flex;align-items:center;gap:9px;padding:5px 4px;border-radius:6px}
.at-px:hover{background:${C.hover}}
.at-px-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.at-px-name{flex:1;font-size:12px;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-px-ids{font-size:9px;font-family:'Consolas',monospace;color:${C.green};background:rgba(34,197,94,.1);padding:1px 6px;border-radius:6px;max-width:140px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
.at-px-ids:hover{background:rgba(34,197,94,.25)}
.at-empty{font-size:12px;color:${C.dim};text-align:center;padding:14px 8px;line-height:1.5}
.at-warnbox{font-size:11px;color:${C.yellow};background:rgba(234,179,8,.08);border:1px solid rgba(234,179,8,.25);border-radius:8px;padding:6px 9px;margin-bottom:8px;line-height:1.45}
.at-consent{display:grid;grid-template-columns:1fr auto auto;gap:2px 8px;font-size:10px;margin-bottom:4px}
.at-consent .h{color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.4px;font-size:9px}
.at-consent .k{color:${C.text};font-family:'Consolas',monospace}
.at-consent .g{color:${C.green};font-weight:700}
.at-consent .d{color:${C.red};font-weight:700}
.at-consent .u{color:${C.dim}}
.at-push{display:flex;align-items:center;gap:8px;padding:6px 7px;border-radius:7px;margin-bottom:2px;cursor:pointer;border:1px solid transparent}
.at-push:hover{background:${C.hover}}
.at-push.on{background:rgba(59,130,246,.12);border-color:rgba(59,130,246,.4)}
.at-push-name{flex:1;min-width:0}
.at-push-ev{font-size:11px;font-weight:600;color:${C.text};font-family:'Consolas',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-push.on .at-push-ev{color:${C.accent}}
.at-push-desc{font-size:10px;color:${C.dim};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-push-load{font-size:10px;color:${C.dim};flex-shrink:0;opacity:0;transition:.1s}
.at-push:hover .at-push-load{opacity:1}
.at-push-n{font-size:9px;color:${C.dim};font-family:'Consolas',monospace;background:${C.border};padding:1px 5px;border-radius:5px;flex-shrink:0}
.at-push.on .at-push-n{color:${C.accent};background:rgba(59,130,246,.15)}
/* ── przełącznik liczby produktów ── */
.at-qty{display:flex;align-items:center;gap:4px;margin:0 0 6px;padding:0 2px 0 7px}
.at-qty-lab{flex:1;font-size:9px;color:${C.dim};text-transform:uppercase;letter-spacing:.4px;font-weight:700;white-space:nowrap}
.at-qty .at-chip{padding:3px 8px;min-width:26px}
/* ── podzakładki grup presetów (Ecommerce / Leady) — pełna szerokość ── */
.at-seg{display:flex;width:100%;background:#0b1120;border:1px solid ${C.border};border-radius:8px;padding:2px;gap:2px;margin:12px 0 8px}
.at-seg-btn{flex:1;background:none;border:none;color:${C.dim};font-size:11px;font-weight:700;font-family:inherit;padding:6px 8px;border-radius:6px;cursor:pointer;white-space:nowrap;transition:.15s;text-align:center}
.at-seg-btn:hover{color:${C.text}}
.at-seg-btn.on{background:rgba(59,130,246,.2);color:${C.accent}}
.at-seg-n{font-family:'Consolas',monospace;font-size:9px;opacity:.7;margin-left:2px}
.at-note{font-size:10px;color:${C.dim};margin:4px 0 0 2px;line-height:1.4}
.at-note:empty{display:none}
.at-note.warn{color:${C.yellow}}
.at-note.err{color:${C.red}}
.at-note.ok{color:${C.green}}
/* ── edytowalny dataLayer ── */
.at-ta{display:block;width:100%;min-height:150px;max-height:260px;resize:vertical;background:#0b1120;border:1px solid #4b5563;color:#bfdbfe;border-radius:7px;padding:7px 9px;font-family:'Consolas','Monaco',monospace;font-size:10px;line-height:1.5;outline:none;user-select:text;-webkit-user-select:text;white-space:pre;overflow:auto;tab-size:2}
.at-ta:focus{border-color:${C.accent}}
.at-ta.err{border-color:${C.red}}
.at-paste-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;gap:6px}
.at-paste-btns{display:flex;gap:5px;flex-shrink:0}
.at-ta::placeholder{color:#4b5563}
.at-foot{border-top:1px solid ${C.border};padding:7px 10px 7px 14px;display:flex;justify-content:space-between;align-items:center;gap:6px}
.at-foot-txt{font-size:10px;color:${C.dim};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
.at-foot-btn{font-size:10px;color:${C.accent};background:none;border:none;cursor:pointer;font-weight:600;padding:2px 5px;border-radius:4px;flex-shrink:0;font-family:inherit}
.at-foot-btn:hover{background:rgba(59,130,246,.12)}
/* ── DL tab ── */
.at-dl-bar{display:flex;gap:5px;align-items:center;margin-bottom:7px}
.at-dl-bar .at-input{flex:1;padding:4px 8px;font-size:11px}
.at-chip{background:${C.border};border:none;color:${C.dim};font-size:10px;font-weight:700;font-family:'Consolas',monospace;padding:4px 8px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:.15s}
.at-chip:hover{color:${C.text}}
.at-chip.on{background:rgba(59,130,246,.2);color:${C.accent}}
.at-chip.pause-on{background:rgba(234,179,8,.2);color:${C.yellow}}
.at-dl-row{margin-bottom:1px}
.at-dl-hd{display:flex;align-items:center;gap:7px;padding:4px 6px;border-radius:6px;cursor:pointer}
.at-dl-hd:hover{background:${C.hover}}
.at-dl-t{font-size:9px;color:${C.dim};font-family:'Consolas',monospace;flex-shrink:0}
.at-dl-ev{flex:1;min-width:0;font-size:11px;font-weight:600;font-family:'Consolas',monospace;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-dl-ev.ec{color:${C.green}}
.at-dl-ev.sys{color:${C.dim};font-weight:400}
.at-dl-ev.gt{color:${C.purple}}
.at-dl-name{font-size:8px;color:${C.yellow};background:rgba(234,179,8,.12);padding:1px 5px;border-radius:5px;flex-shrink:0;font-family:'Consolas',monospace}
.at-dl-copy{background:none;border:none;color:${C.dim};font-size:11px;cursor:pointer;padding:1px 4px;border-radius:4px;flex-shrink:0;font-family:inherit;opacity:0;transition:.1s}
.at-dl-hd:hover .at-dl-copy{opacity:.8}
.at-dl-copy:hover{color:${C.accent};opacity:1}
.at-dl-pre{background:#0b1120;border:1px solid ${C.border};border-radius:7px;padding:7px 9px;margin:2px 2px 6px;font-family:'Consolas','Monaco',monospace;font-size:10px;line-height:1.5;color:#bfdbfe;white-space:pre-wrap;word-break:break-word;max-height:220px;overflow-y:auto;user-select:text;cursor:text}
.at-dl-pre::-webkit-scrollbar{width:5px}.at-dl-pre::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
`;

    function buildWidget() {
        // Usuń każdy host z DOM (też osierocony z innej instancji), nie tylko ten ze zmiennej
        document.querySelectorAll('#' + WID).forEach(n => n.remove());
        host = document.createElement('div');
        host.id = WID;
        host.style.cssText = 'position:fixed;bottom:16px;right:16px;z-index:2147483647;';
        root = host.attachShadow({ mode: 'open' });
        root.innerHTML = `
<style>${CSS}</style>
<div class="wg">
<div class="at-hdr" id="at-hdr">
  <div class="at-logo"><svg width="22" height="22" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg"><rect width="192" height="192" rx="24" fill="#3b82f6"/><path d="M96 36L140 96l-44 60-44-60L96 36z" fill="#fff" opacity=".9"/><circle cx="96" cy="96" r="20" fill="#fff"/></svg></div>
  <span class="at-title">GTM Checker</span>
  <span class="at-badge" id="at-badge" title=""></span>
  <button class="at-hdr-btn" id="at-col">▾</button>
</div>
<div class="at-wrap ${collapsed ? 'closed' : 'open'}" id="at-wrap">
  <div class="at-inj" id="at-inj"></div>
  <div class="at-inj-err" id="at-inj-err" style="display:none"></div>
  <div class="at-tabs">
    <button class="at-tab" data-tab="pixels">🔍 Pixele</button>
    <button class="at-tab" data-tab="dl">⚡ DataLayer</button>
    <button class="at-tab" data-tab="push">🛒 Push</button>
    <button class="at-tab" data-tab="cmds">📋 Cmd</button>
    <button class="at-tab" data-tab="net">🌐 Net</button>
  </div>
  <div class="at-panel" id="at-panel"></div>
  <div class="at-foot">
    <span class="at-foot-txt" id="at-foot-txt"></span>
    <button class="at-foot-btn" id="at-audit" title="Skopiuj audyt trackingu jako markdown">⤓ Audyt</button>
    <button class="at-foot-btn" id="at-refresh">↻</button>
  </div>
</div>
</div>
        `;
        document.body.appendChild(host);
        restorePosition();

        const $ = sel => root.querySelector(sel);

        const colBtn = $('#at-col');
        colBtn.textContent = collapsed ? '▸' : '▾';
        colBtn.addEventListener('click', () => {
            collapsed = !collapsed;
            try { sessionStorage.setItem(SS_OPEN, collapsed ? '0' : '1'); } catch (e) {}
            updateExpandDirection();
            $('#at-wrap').classList.toggle('open', !collapsed);
            $('#at-wrap').classList.toggle('closed', collapsed);
            colBtn.textContent = collapsed ? '▸' : '▾';
        });

        root.querySelectorAll('.at-tab').forEach(t => t.addEventListener('click', () => {
            activeTab = t.dataset.tab;
            try { sessionStorage.setItem(SS_TAB, activeTab); } catch (e) {}
            renderTabs();
        }));

        $('#at-refresh').addEventListener('click', renderTabs);
        $('#at-audit').addEventListener('click', exportAudit);

        makeDraggable(host, $('#at-hdr'));
        renderInjectBar();
        renderTabs();
        updateBadge();
        setTimeout(updateBadge, 2000);
        setTimeout(updateBadge, 5000);
    }

    /* ─────────── PASEK: WSTRZYKNIJ GTM (per domena, localStorage) ─────────── */
    function renderInjectBar() {
        const bar = root && root.querySelector('#at-inj');
        const err = root && root.querySelector('#at-inj-err');
        if (!bar) return;
        err.style.display = 'none';
        const stored = getDomainInject();

        if (stored) {
            // Sprawdź, czy kontener faktycznie działa (google_tag_manager[ID])
            sendCmd('snapshot');
            const live = !!(lastSnapshot && lastSnapshot.gtmKeys.some(k => k.toUpperCase() === stored.id));
            bar.innerHTML = `
              <span class="at-inj-dot ${live ? '' : 'warn'}" title="${live ? 'Kontener załadowany' : 'Jeszcze się ładuje albo zablokowany (CSP/adblock)'}"></span>
              <span class="at-inj-txt"><b>${escHtml(stored.id)}</b> <small>· ${escHtml(location.hostname)}</small></span>
              <button class="at-btn at-btn-sec at-btn-sm" id="at-inj-edit" title="Zmień ID">✎</button>
              <button class="at-btn at-btn-danger at-btn-sm" id="at-inj-off" title="Usuń z localStorage tej domeny i przeładuj">✕</button>
            `;
            bar.querySelector('#at-inj-off').addEventListener('click', () => {
                clearDomainInject();
                location.reload();
            });
            bar.querySelector('#at-inj-edit').addEventListener('click', () => {
                clearDomainInject();
                renderInjectBar();
                const inp = bar.querySelector('#at-inj-id');
                if (inp) { inp.value = stored.id; inp.focus(); inp.select(); }
            });
            return;
        }

        bar.innerHTML = `
          <input class="at-input" id="at-inj-id" placeholder="GTM-XXXXXX — wstrzyknij i zapamiętaj dla tej domeny" maxlength="16" spellcheck="false">
          <button class="at-btn at-btn-primary at-btn-sm" id="at-inj-go" title="Wstrzyknij teraz i zapisz w localStorage (ładuje się przy każdym wejściu)">💉 Wstrzyknij</button>
        `;
        const inp = bar.querySelector('#at-inj-id');
        const go = () => {
            const id = inp.value.trim().toUpperCase();
            if (!isGtmId(id)) {
                err.textContent = 'Nieprawidłowy format — oczekiwano GTM-XXXXXX';
                err.style.display = '';
                return;
            }
            if (!setDomainInject(id)) {
                err.textContent = 'localStorage niedostępny na tej stronie — wstrzykuję tylko jednorazowo';
                err.style.display = '';
            }
            const ok = injectGTM(id);
            autoInjectedId = id;
            if (!ok) {
                err.textContent = id + ' już jest na stronie (natywnie lub wstrzyknięty) — zapis zachowany na kolejne wejścia';
                err.style.display = '';
            }
            renderInjectBar();
            updateBadge();
            setTimeout(() => { renderInjectBar(); updateBadge(); }, 1500); // odśwież kropkę, gdy gtm.js dojedzie
            setTimeout(() => { renderInjectBar(); updateBadge(); }, 4000);
        };
        inp.addEventListener('input', () => { inp.value = inp.value.toUpperCase().replace(/[^A-Z0-9-]/g, ''); });
        inp.addEventListener('keydown', e => { if (e.key === 'Enter') go(); e.stopPropagation(); });
        bar.querySelector('#at-inj-go').addEventListener('click', go);
    }

    function updateBadge() {
        if (!root) return;
        sendCmd('snapshot');
        const b = root.querySelector('#at-badge');
        const gtm = lastSnapshot ? lastSnapshot.gtmKeys.filter(k => /^GTM-/i.test(k)) : [];
        b.classList.remove('inj');
        if (autoInjectedId) {
            b.textContent = '💉 ' + autoInjectedId;
            b.classList.add('inj');
            b.title = 'Wstrzyknięty z localStorage tej domeny';
        }
        else if (gtm.length) { b.textContent = gtm.join(', '); b.title = 'GTM na stronie: ' + gtm.join(', '); }
        else { b.textContent = 'brak GTM'; b.title = ''; }
    }

    function renderTabs() {
        if (!root) return;
        root.querySelectorAll('.at-tab').forEach(t => t.classList.toggle('on', t.dataset.tab === activeTab));
        const panel = root.querySelector('#at-panel');
        if (activeTab === 'pixels') renderPixels(panel);
        else if (activeTab === 'dl') renderDL(panel);
        else if (activeTab === 'push') renderPush(panel);
        else if (activeTab === 'cmds') renderCommands(panel);
        else renderNet(panel);
        updateBadge();
    }

    /* ─────────── CONSENT MODE (live) ─────────── */
    let lastConsentKey = '';

    function renderConsentBox() {
        const box = root && root.querySelector('#at-consent-box');
        if (!box) return;
        lastConsentKey = JSON.stringify(lastConsent);
        if (!lastConsent || !Object.keys(lastConsent).length) { box.innerHTML = ''; return; }
        const fmt = v => v === true ? '<span class="g">granted</span>' : v === false ? '<span class="d">denied</span>' : '<span class="u">—</span>';
        let html = '<div class="at-label">Consent Mode <span style="color:' + C.green + ';font-size:8px;vertical-align:middle">● live</span></div>' +
            '<div class="at-consent"><span class="h"></span><span class="h">default</span><span class="h">update</span>';
        Object.keys(lastConsent).forEach(k => {
            html += `<span class="k">${escHtml(k)}</span>${fmt(lastConsent[k].def)}${fmt(lastConsent[k].upd)}`;
        });
        box.innerHTML = html + '</div>';
    }

    function refreshConsent() {
        if (!root || activeTab !== 'pixels') return;
        sendCmd('consent'); // dispatch synchroniczny — lastConsent już zaktualizowany
        if (JSON.stringify(lastConsent) !== lastConsentKey) renderConsentBox();
    }
    setInterval(refreshConsent, 1500);

    /* ─────────── TAB: PIXELE ─────────── */
    function renderPixels(panel) {
        const found = detectTrackers().filter(r => r.detected);
        sendCmd('consent');
        panel.innerHTML = '';

        const warns = [];
        if (lastSnapshot) {
            const gtms = lastSnapshot.gtmKeys.filter(k => /^GTM-/i.test(k));
            if (gtms.length > 1) warns.push('⚠️ ' + gtms.length + ' kontenery GTM naraz: ' + gtms.join(', '));
            const ga4s = lastSnapshot.gtmKeys.filter(k => /^G-/i.test(k));
            if (ga4s.length > 1) warns.push('ℹ️ ' + ga4s.length + ' strumienie GA4: ' + ga4s.join(', ') + ' — sprawdź czy to celowe');
        }
        if (!bridgeReady) warns.push('⚠️ Bridge zablokowany (CSP?) — detekcja tylko po URL-ach, zakładki DataLayer i Push mogą nie działać.');
        if (warns.length) {
            const wb = document.createElement('div');
            wb.className = 'at-warnbox';
            wb.innerHTML = warns.map(escHtml).join('<br>');
            panel.appendChild(wb);
        }

        const consentBox = document.createElement('div');
        consentBox.id = 'at-consent-box';
        panel.appendChild(consentBox);
        renderConsentBox();

        const label = document.createElement('div');
        label.className = 'at-label';
        label.textContent = `Wykryte trackery (${found.length})`;
        panel.appendChild(label);

        if (!found.length) {
            const em = document.createElement('div');
            em.className = 'at-empty';
            em.innerHTML = 'Nie wykryto żadnych trackerów.<br>Spróbuj ↻ po pełnym załadowaniu strony.';
            panel.appendChild(em);
        }
        found.forEach(r => {
            const row = document.createElement('div');
            row.className = 'at-px';
            row.innerHTML = `<div class="at-px-dot" style="background:${C.green}"></div>` +
                `<span class="at-px-name">${escHtml(r.name)}</span>` +
                (r.ids.length ? `<span class="at-px-ids" title="Kliknij, aby skopiować">${escHtml(r.ids.join(', '))}</span>` : '');
            const idsEl = row.querySelector('.at-px-ids');
            if (idsEl) idsEl.addEventListener('click', () => {
                copyText(r.ids.join(', '));
                idsEl.textContent = '✓ skopiowano';
                setTimeout(() => { idsEl.textContent = r.ids.join(', '); }, 900);
            });
            panel.appendChild(row);
        });

        root.querySelector('#at-foot-txt').textContent = `${found.length} trackerów · ${location.hostname}`;
    }

    /* ─────────── TAB: DL (live monitor dataLayer) ─────────── */
    function dlRawJson(rec) {
        try { return JSON.stringify(rec.d.e, null, 2); } catch (e) { return String(rec.d.e); }
    }

    function makeDlRow(rec) {
        const row = document.createElement('div');
        row.className = 'at-dl-row';
        row.dataset.id = rec.id;
        const showName = rec.d.dl && rec.d.dl !== 'dataLayer';
        row.innerHTML = `
          <div class="at-dl-hd">
            <span class="at-dl-t">${fmtTime(rec.ts)}</span>
            <span class="at-dl-ev ${rec.cls}">${escHtml(rec.name)}</span>
            ${showName ? `<span class="at-dl-name" title="Niestandardowy dataLayer">${escHtml(rec.d.dl)}</span>` : ''}
            <button class="at-dl-copy" title="Kopiuj JSON">⧉</button>
          </div>
        `;
        const hd = row.querySelector('.at-dl-hd');
        const copyBtn = row.querySelector('.at-dl-copy');
        copyBtn.addEventListener('click', ev => {
            ev.stopPropagation();
            copyText(dlRawJson(rec));
            copyBtn.textContent = '✓';
            setTimeout(() => { copyBtn.textContent = '⧉'; }, 900);
        });
        hd.addEventListener('click', () => {
            const existing = row.querySelector('.at-dl-pre');
            if (existing) { existing.remove(); dlExpanded.delete(rec.id); return; }
            dlExpanded.add(rec.id);
            const pre = document.createElement('div');
            pre.className = 'at-dl-pre';
            let txt = dlRawJson(rec);
            if (txt.length > 6000) txt = txt.slice(0, 6000) + '\n… [obcięte — ⧉ kopiuje całość]';
            pre.textContent = txt;
            row.appendChild(pre);
        });
        if (dlExpanded.has(rec.id)) {
            const pre = document.createElement('div');
            pre.className = 'at-dl-pre';
            let txt = dlRawJson(rec);
            if (txt.length > 6000) txt = txt.slice(0, 6000) + '\n… [obcięte — ⧉ kopiuje całość]';
            pre.textContent = txt;
            row.appendChild(pre);
        }
        return row;
    }

    function updateDlFoot() {
        if (!root || activeTab !== 'dl') return;
        const shown = dlEvents.filter(dlMatches).length;
        root.querySelector('#at-foot-txt').textContent =
            `${shown}/${dlEvents.length} pushy` + (dlPaused ? ' · ⏸ pauza' : ' · ● live') + (dlHideSys ? ' · bez gtm.*' : '');
    }

    function dlLiveInsert(rec) {
        const list = root && root.querySelector('#at-dl-list');
        if (!list) return;
        const empty = list.querySelector('.at-empty');
        if (empty) empty.remove();
        if (dlMatches(rec)) list.insertBefore(makeDlRow(rec), list.firstChild);
        // przytnij DOM do limitu bufora
        while (list.children.length > DL_MAX) list.removeChild(list.lastChild);
        updateDlFoot();
    }

    function renderDlList() {
        const list = root && root.querySelector('#at-dl-list');
        if (!list) return;
        list.innerHTML = '';
        const filtered = dlEvents.filter(dlMatches);
        if (!filtered.length) {
            const em = document.createElement('div');
            em.className = 'at-empty';
            em.innerHTML = dlEvents.length
                ? 'Brak pushy pasujących do filtra.'
                : (bridgeReady
                    ? 'Jeszcze żadnych pushy do dataLayer.<br>Poklikaj po stronie albo zrób push z zakładki 🛒.'
                    : '⚠️ Bridge zablokowany (CSP?) — monitor dataLayer nie działa na tej stronie.');
            list.appendChild(em);
        } else {
            // od najnowszych
            for (let i = filtered.length - 1; i >= 0; i--) list.appendChild(makeDlRow(filtered[i]));
        }
        updateDlFoot();
    }

    function renderDL(panel) {
        panel.innerHTML = `
          <div class="at-dl-bar">
            <input class="at-input" id="at-dl-filter" placeholder="filtruj: event / treść JSON…" value="${escAttr(dlFilter)}">
            <button class="at-chip ${dlHideSys ? 'on' : ''}" id="at-dl-sys" title="Ukryj systemowe eventy gtm.js / gtm.dom / gtm.load itd.">gtm.*</button>
            <button class="at-chip ${dlPaused ? 'pause-on' : ''}" id="at-dl-pause" title="Wstrzymaj dopisywanie do listy (bufor dalej zbiera)">${dlPaused ? '▶' : '⏸'}</button>
            <button class="at-chip" id="at-dl-clear" title="Wyczyść listę">🧹</button>
            <button class="at-chip" id="at-dl-copyall" title="Kopiuj widoczne pushe jako JSON">⧉</button>
          </div>
          <div id="at-dl-list"></div>
        `;
        panel.querySelector('#at-dl-filter').addEventListener('input', ev => {
            dlFilter = ev.target.value.trim();
            renderDlList();
        });
        panel.querySelector('#at-dl-sys').addEventListener('click', ev => {
            dlHideSys = !dlHideSys;
            ev.target.classList.toggle('on', dlHideSys);
            renderDlList();
        });
        panel.querySelector('#at-dl-pause').addEventListener('click', ev => {
            dlPaused = !dlPaused;
            ev.target.classList.toggle('pause-on', dlPaused);
            ev.target.textContent = dlPaused ? '▶' : '⏸';
            if (!dlPaused) renderDlList(); // dogoń bufor z okresu pauzy
            else updateDlFoot();
        });
        panel.querySelector('#at-dl-clear').addEventListener('click', () => {
            dlEvents = [];
            dlExpanded.clear();
            renderDlList();
        });
        panel.querySelector('#at-dl-copyall').addEventListener('click', ev => {
            const filtered = dlEvents.filter(dlMatches);
            copyText(JSON.stringify(filtered.map(r => r.d.e), null, 2));
            ev.target.textContent = '✓';
            setTimeout(() => { ev.target.textContent = '⧉'; }, 900);
        });
        renderDlList();
    }

    /* ─────────── TAB: PUSH (edytowalny dataLayer + presety GA4) ─────────── */
    // Jedno źródło prawdy: pole tekstowe. Preset tylko WCZYTUJE dane do pola,
    // ▶ Push wysyła dokładnie to, co w polu jest (po ewentualnej edycji).
    let customDraft = '';      // treść pola — przeżywa przełączanie zakładek
    let activePreset = null;   // ostatnio wczytany preset (podświetlenie)
    let presetDirty = false;   // czy pole edytowano po wczytaniu presetu

    function flashBtn(btn, txt, ms) {
        const old = btn.textContent;
        btn.textContent = txt;
        setTimeout(() => { btn.textContent = old; }, ms || 900);
    }

    function renderPush(panel) {
        panel.innerHTML = `
          <div class="at-paste-hd">
            <span class="at-label" style="margin:0">Warstwa danych do wysłania</span>
            <span class="at-paste-btns">
              <button class="at-btn at-btn-sec at-btn-sm" id="at-paste-copy" title="Kopiuj zawartość pola jako snippet dataLayer.push">⧉</button>
              <button class="at-btn at-btn-sec at-btn-sm" id="at-paste-clear" title="Wyczyść pole">🧹</button>
              <button class="at-btn at-btn-primary at-btn-sm" id="at-paste-push" title="Ctrl+Enter">▶ Push</button>
            </span>
          </div>
          <textarea class="at-ta" id="at-paste-ta" spellcheck="false" placeholder="Kliknij preset poniżej albo wklej własny dataLayer, np.
{
  event: &quot;purchase&quot;,
  ecommerce: {
    transaction_id: &quot;TEST-123&quot;,
    value: 1,
    currency: &quot;PLN&quot;,
    items: [{ item_id: &quot;TEST-001&quot;, item_name: &quot;Produkt testowy&quot;, price: 1, quantity: 1 }]
  }
}"></textarea>
          <div class="at-note" id="at-paste-msg"></div>
          <div class="at-seg" id="at-grp" title="Presety GA4 — kliknij event, aby wczytać go do pola">
            ${PRESET_GROUPS.map(g => `<button class="at-seg-btn ${g.id === presetGroup ? 'on' : ''}" data-g="${g.id}">${g.label} <span class="at-seg-n">${PRESETS.filter(p => p.group === g.id).length}</span></button>`).join('')}
          </div>
          <div class="at-qty" id="at-qty" title="Ile produktów w items[] (value = suma price × quantity)"${presetGroup === 'ecom' ? '' : ' style="display:none"'}>
            <span class="at-qty-lab">Liczba produktów w items[]</span>
            ${ITEM_COUNTS.map(n => `<button class="at-chip ${n === itemCount ? 'on' : ''}" data-n="${n}">${n}</button>`).join('')}
          </div>
          <div id="at-push-list"></div>`;

        const ta = panel.querySelector('#at-paste-ta');
        const pushBtn = panel.querySelector('#at-paste-push');
        const copyBtn = panel.querySelector('#at-paste-copy');
        const clearBtn = panel.querySelector('#at-paste-clear');
        const msg = panel.querySelector('#at-paste-msg');
        const list = panel.querySelector('#at-push-list');
        const qty = panel.querySelector('#at-qty');
        const grp = panel.querySelector('#at-grp');

        // Bez stałego komunikatu — linia pojawia się tylko przy statusie (wczytano / wysłano / błąd)
        const setMsg = (txt, cls) => { msg.className = 'at-note' + (cls ? ' ' + cls : ''); msg.textContent = txt || ''; };
        const showErr = e => { ta.classList.add('err'); setMsg('✗ ' + e.message, 'err'); };
        const updateFoot = () => {
            const g = PRESET_GROUPS.find(x => x.id === presetGroup);
            const cnt = PRESETS.filter(p => p.group === presetGroup).length;
            root.querySelector('#at-foot-txt').textContent =
                `${g ? g.label.replace(/^\S+\s/, '') : ''}: ${cnt} presetów · ` + (presetGroup === 'ecom' ? `${itemCount} prod. · ` : '') + (activePreset ?'w polu: ' + activePreset + (presetDirty ? ' (edytowany)' : '') : (customDraft.trim() ? 'w polu: własny dataLayer' : 'pole puste'));
        };
        const presetN = p => (p.items === true ? itemCount : (p.items === 1 ? 1 : 0));
        setMsg();

        ta.value = customDraft;
        ta.addEventListener('input', () => {
            customDraft = ta.value;
            if (activePreset) presetDirty = true;
            if (ta.classList.contains('err')) { ta.classList.remove('err'); setMsg(); }
            updateFoot();
        });
        // Nie oddawaj klawiszy stronie (skróty sklepu, Alt+Shift+A itd.)
        ['keydown', 'keyup', 'keypress'].forEach(t => ta.addEventListener(t, e => {
            e.stopPropagation();
            if (t === 'keydown' && e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); pushBtn.click(); }
        }));

        pushBtn.addEventListener('click', () => {
            if (!ta.value.trim()) { ta.focus(); setMsg('Pole jest puste — kliknij preset albo wklej dataLayer.', 'warn'); return; }
            try {
                const r = parseCustomLayer(ta.value);
                pushEntries(r.entries);
                ta.classList.remove('err');
                const names = r.entries.map(e => e.event || '{bez event}').join(', ');
                setMsg('✓ Wysłano ' + r.entries.length + '× — ' + names +
                    (r.dropped.length ? ' · pominięte klucze: ' + r.dropped.join(', ') : ''), 'ok');
                flashBtn(pushBtn, '✓ ' + r.entries.length + '×');
            } catch (e) {
                showErr(e);
                flashBtn(pushBtn, '✗', 1200);
            }
        });

        copyBtn.addEventListener('click', () => {
            if (!ta.value.trim()) { ta.focus(); return; }
            try {
                const r = parseCustomLayer(ta.value);
                copyText(entriesToSnippet(r.entries));
                flashBtn(copyBtn, '✓');
            } catch (e) {
                showErr(e);
                flashBtn(copyBtn, '✗', 1200);
            }
        });

        clearBtn.addEventListener('click', () => {
            ta.value = customDraft = '';
            activePreset = null;
            presetDirty = false;
            ta.classList.remove('err');
            list.querySelectorAll('.at-push').forEach(r => r.classList.remove('on'));
            setMsg();
            updateFoot();
            ta.focus();
        });

        /* ── presety: klik = wczytaj do pola ── */
        const loadPreset = p => {
            const n = presetN(p);
            const data = p.build(n || 1);
            ta.value = customDraft = JSON.stringify(data, null, 2);
            activePreset = p.label;
            presetDirty = false;
            ta.classList.remove('err');
            list.querySelectorAll('.at-push').forEach(r => r.classList.toggle('on', r.dataset.label === p.label));
            let info = '';
            if (n) {
                const v = data.ecommerce && typeof data.ecommerce.value === 'number' ? ', value ' + data.ecommerce.value : '';
                info = ' (' + plProd(n) + v + ')';
            }
            setMsg('Wczytano ' + p.label + info + ' — popraw w polu i ▶ Push (Ctrl+Enter).');
            updateFoot();
            ta.scrollTop = 0;
            ta.focus();
            ta.setSelectionRange(0, 0);
        };

        const refreshBadges = () => {
            list.querySelectorAll('.at-push').forEach(r => {
                const p = PRESETS.find(x => x.label === r.dataset.label);
                const b = r.querySelector('.at-push-n');
                if (p && b) b.textContent = '×' + presetN(p);
            });
        };

        const drawList = () => {
            list.innerHTML = '';
            PRESETS.filter(p => p.group === presetGroup).forEach(p => {
                const row = document.createElement('div');
                row.className = 'at-push' + (activePreset === p.label ? ' on' : '');
                row.dataset.label = p.label;
                row.title = 'Wczytaj do pola (nadpisuje obecną treść)';
                const n = presetN(p);
                row.innerHTML = `
                  <div class="at-push-name">
                    <div class="at-push-ev">${escHtml(p.label)}</div>
                    <div class="at-push-desc">${escHtml(p.desc)}</div>
                  </div>
                  <span class="at-push-load">✎ wczytaj</span>
                  ${n ? `<span class="at-push-n" title="Liczba produktów w items[]">×${n}</span>` : ''}
                `;
                row.addEventListener('click', () => loadPreset(p));
                list.appendChild(row);
            });
        };
        drawList();

        /* ── podzakładki grup presetów ── */
        grp.querySelectorAll('.at-seg-btn').forEach(btn => btn.addEventListener('click', () => {
            const g = btn.dataset.g;
            if (g === presetGroup) return;
            presetGroup = g;
            try { sessionStorage.setItem(SS_PUSH_GRP, g); } catch (e) {}
            grp.querySelectorAll('.at-seg-btn').forEach(b => b.classList.toggle('on', b === btn));
            qty.style.display = g === 'ecom' ? '' : 'none'; // liczba produktów dotyczy tylko ecommerce
            drawList();
            updateFoot();
        }));

        /* ── przełącznik liczby produktów ── */
        qty.querySelectorAll('.at-chip').forEach(btn => btn.addEventListener('click', () => {
            const n = parseInt(btn.dataset.n, 10);
            if (n === itemCount) return;
            setItemCount(n);
            qty.querySelectorAll('.at-chip').forEach(b => b.classList.toggle('on', b === btn));
            refreshBadges();
            const cur = activePreset && PRESETS.find(x => x.label === activePreset);
            if (cur && cur.items === true && !presetDirty) {
                loadPreset(cur); // pole niezmienione → przeładuj od razu z nową liczbą produktów
            } else if (cur && cur.items === true && presetDirty) {
                setMsg('Ustawiono ' + plProd(n) + ' — pole ma Twoje zmiany, kliknij ' + cur.label + ', aby wczytać ponownie.', 'warn');
                updateFoot();
            } else {
                setMsg('Presety z produktami będą wczytywane z: ' + plProd(n) + '.');
                updateFoot();
            }
        }));

        updateFoot();
    }

    /* ─────────── TAB: KOMENDY ─────────── */
    function renderCommands(panel) {
        panel.innerHTML = `<div class="at-label">Kliknij, aby skopiować do schowka</div>`;
        COMMANDS.forEach(cmd => {
            const row = document.createElement('div');
            row.className = 'at-cmd';
            row.innerHTML = `<div class="at-cmd-label">${escHtml(cmd.label)} <span class="at-copied" style="display:none">✓ skopiowano</span></div>` +
                `<div class="at-cmd-code">${escHtml(cmd.code)}</div>` +
                `<div class="at-cmd-desc">${escHtml(cmd.desc)}</div>`;
            row.addEventListener('click', () => {
                copyText(cmd.code);
                const c = row.querySelector('.at-copied');
                c.style.display = '';
                setTimeout(() => { c.style.display = 'none'; }, 900);
            });
            panel.appendChild(row);
        });
        root.querySelector('#at-foot-txt').textContent = `${COMMANDS.length} komend`;
    }

    /* ═══════════════════ AUDYT (markdown do schowka) ═══════════════════ */
    function exportAudit() {
        sendCmd('snapshot'); sendCmd('consent');
        const found = detectTrackers().filter(r => r.detected);
        const lines = [];
        lines.push('# Audyt trackingu — ' + location.hostname);
        lines.push('URL: ' + location.href);
        lines.push('Data: ' + new Date().toLocaleString('pl-PL'));
        if (autoInjectedId) lines.push('Wstrzyknięty przez GTM Checker: ' + autoInjectedId + ' (nie ma go w kodzie strony)');
        lines.push('');
        lines.push('## Wykryte trackery (' + found.length + ')');
        found.forEach(r => lines.push('- ' + r.name + (r.ids.length ? ' — `' + r.ids.join(', ') + '`' : '')));
        if (lastConsent && Object.keys(lastConsent).length) {
            lines.push('');
            lines.push('## Consent Mode');
            lines.push('| typ | default | update |');
            lines.push('|---|---|---|');
            const f = v => v === true ? 'granted' : v === false ? 'denied' : '—';
            Object.keys(lastConsent).forEach(k => lines.push('| ' + k + ' | ' + f(lastConsent[k].def) + ' | ' + f(lastConsent[k].upd) + ' |'));
        }
        if (dlEvents.length) {
            lines.push('');
            lines.push('## Eventy dataLayer (' + dlEvents.length + ' pushy w buforze)');
            const counts = {};
            dlEvents.forEach(r => { counts[r.name] = (counts[r.name] || 0) + 1; });
            Object.keys(counts).forEach(n => lines.push('- `' + n + '` × ' + counts[n]));
        }
        copyText(lines.join('\n'));
        const btn = root.querySelector('#at-audit');
        const old = btn.textContent;
        btn.textContent = '✓ skopiowano';
        setTimeout(() => { btn.textContent = old; }, 1200);
    }

    /* ═══════════════════ POZYCJA / KIERUNEK / DRAG ═══════════════════ */
    const WIDGET_W = 360;
    function clampToViewport() {
        if (!host) return;
        const r = host.getBoundingClientRect();
        // pozycjonowany przez left/top?
        if (host.style.left && host.style.left !== 'auto') {
            const l = Math.max(0, Math.min(r.left, window.innerWidth - WIDGET_W));
            const t = Math.max(0, Math.min(r.top, window.innerHeight - 56));
            host.style.left = l + 'px'; host.style.top = t + 'px';
            host.style.right = 'auto'; host.style.bottom = 'auto';
        }
    }
    window.addEventListener('resize', () => { clampToViewport(); });

    function restorePosition() {
        try {
            const pos = JSON.parse(localStorage.getItem(LS_POS) || 'null');
            if (pos && typeof pos.l === 'number' && typeof pos.t === 'number') {
                const l = Math.max(0, Math.min(pos.l, window.innerWidth - WIDGET_W));
                const t = Math.max(0, Math.min(pos.t, window.innerHeight - 56));
                host.style.left = l + 'px'; host.style.top = t + 'px';
                host.style.right = 'auto'; host.style.bottom = 'auto';
            }
        } catch (e) {}
    }
    function savePosition() {
        try {
            const r = host.getBoundingClientRect();
            const l = Math.max(0, Math.min(r.left, window.innerWidth - WIDGET_W));
            const t = Math.max(0, Math.min(r.top, window.innerHeight - 56));
            localStorage.setItem(LS_POS, JSON.stringify({ l, t }));
        } catch (e) {}
    }

    function updateExpandDirection() {
        if (!host || !root) return;
        const rect = host.getBoundingClientRect();
        const hdrH = root.querySelector('#at-hdr').offsetHeight;
        const hdrMid = rect.top + hdrH / 2;
        if (hdrMid > window.innerHeight / 2) {
            host.style.bottom = (window.innerHeight - rect.bottom) + 'px';
            host.style.top = 'auto';
        } else {
            host.style.top = rect.top + 'px';
            host.style.bottom = 'auto';
        }
    }

    function makeDraggable(el, handle) {
        let sx, sy, sl, st;
        handle.addEventListener('mousedown', e => {
            if (e.composedPath().some(n => n.tagName === 'BUTTON')) return;
            e.preventDefault();
            sx = e.clientX; sy = e.clientY; const r = el.getBoundingClientRect(); sl = r.left; st = r.top;
            const mv = ev => { el.style.left = (sl + ev.clientX - sx) + 'px'; el.style.top = (st + ev.clientY - sy) + 'px'; el.style.right = 'auto'; el.style.bottom = 'auto'; };
            const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); updateExpandDirection(); savePosition(); };
            document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up);
        });
    }

    /* ═══════════════════ SPA / TOGGLE / MENU / INIT ═══════════════════ */
    function toggleWidget() {
        if (host) {
            manuallyHidden = true; // tylko do reloadu — bez zapisu w storage
            host.remove(); host = null; root = null;
        } else {
            manuallyHidden = false;
            injectBridge(); // na zablokowanym hoście bridge mógł nie wejść przy starcie
            buildWidget(); updateExpandDirection();
        }
    }

    document.addEventListener('keydown', e => {
        if (e.altKey && e.shiftKey && e.code === 'KeyA') toggleWidget();
    });

    // SPA: zmiana URL bez reloadu → odśwież aktywną zakładkę
    // Watchdog: frameworki potrafią podmienić <body> i wywalić hosta z DOM —
    // jeśli widget powinien być widoczny, a zniknął, odbuduj go.
    let lastUrl = location.href;
    setInterval(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            // DL nie resetujemy (historia SPA zostaje), Push też nie (żeby nie zgubić fokusu w polu)
            if (root && activeTab !== 'dl' && activeTab !== 'push') setTimeout(renderTabs, 800);
        }
        if (!manuallyHidden && !isBlockedHost() && document.body) {
            if (!host || !host.isConnected) {
                buildWidget();
                updateExpandDirection();
            }
        }
    }, 1200);

    function init() {
        if (isBlockedHost()) return; // panele narzędzi — nie pokazuj (Alt+Shift+A wymusza)
        buildWidget();
        updateExpandDirection();
    }
    if (document.readyState === 'complete') setTimeout(init, 800);
    else window.addEventListener('load', () => setTimeout(init, 800));
})();

Dodaj komentarz

Paweł Piekarski

Paweł Piekarski

Doktorat z nauk technicznych, ponad 10 lat analizy danych na uczelni i ponad 3 lata w analityce marketingowej.

Z nauki wyniosłem jedną zasadę: wnioski są tyle warte, co dane, na których się opierają. Dlatego najpierw porządkuję dane, a potem pomagam przełożyć je na decyzje, które przynoszą zyski.

Pracuję jako freelancer. Specjalizuję się w GTM, GA4 i BigQuery. Przeprowadzam audyty, wdrożenia od zera (albo po kimś) i opiekę analityczną.

Prowadzę kanał YouTube z ponad 50 filmami o analityce internetowej i biznesowej. Dzielę się darmowymi treściami, żeby ogarnąć analitykę samodzielnie.

Napisz do mnie

Paweł Piekarski
Paweł Piekarski Digital Analyst Marketing oparty na danych

Dane kontaktowe

+48 725 473 745

poczta@pawelpiekarski.pl

linkedin.com/in/pawelpiekarskipl

    Wysyłając wiadomość, zgadzasz się na kontakt i przetwarzanie danych zgodnie z polityką prywatności.