Skrypt GTM Event Walker – eksport eventów z Tag Assistanta do markdown

Strona główna » Skrypty » Skrypt GTM Event Walker – eksport eventów z Tag Assistanta do markdown
Instrukcja użycia

→ Zainstaluj rozszerzenie Tampermonkey w Chrome
→ Otwórz dashboard Tampermonkey → Utwórz nowy skrypt
→ Wklej poniższy kod i zapisz (Ctrl+S)
→ Wejdź na tagassistant.google.com → widget pojawi się automatycznie
→ Zaznacz eventy na liście po lewej → kliknij „Kopiuj zaznaczone”

Skrypt GTM Event Walker

// ==UserScript==
// @name         GTM Event Walker
// @namespace    https://pawelpiekarski.pl/
// @version      3.6.2
// @description  Zaznacz eventy → skrypt przechodzi po nich i kopiuje tagi/zmienne/dataLayer/consent jako jeden markdown. Presety: ecommerce / B2B / kliknięcia przed nimi.
// @author       Paweł Piekarski
// @match        https://tagassistant.google.com/*
// @icon         https://www.gstatic.com/analytics-suite/header/suite/v2/ic_tag_manager.svg
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const WID = 'ta-tb';
    const LS_COL = 'taEwCol';
    const LS_OPTS = 'taEwOpts3'; // v3.3: wszystko domyślnie zaznaczone (dedup, hideNoise) — nowy klucz, żeby stary zapis nie nadpisał defaultów
    const LS_POS = 'taEwPos';
    const LS_SETS = 'taEwSets';  // v2.9: edytowalne listy eventów dla presetów
    const POLL_MS = 1200;

    let collapsed = localStorage.getItem(LS_COL) === '1';
    let widget = null;

    /* ═══════════════════ STATE ═══════════════════ */
    let eventSearch = '';
    let eventSearchRegex = null;
    let eventSearchError = '';

    let instanceGroups = [];          // [{title, url, rows:[{num,name,key,el}]}]
    const manualOn = new Set();       // eventy zaznaczone ręcznie (tickiem)
    const manualOff = new Set();      // eventy ręcznie ODZNACZONE — nadpisują zaznaczenie z presetu
    let lastClickedKey = '';          // ostatnio kliknięty wiersz — do zaznaczania zakresów shiftem
    let renderedOrder = [];           // kolejność wierszy na liście widgetu (do shift-clicka)
    let lastInstanceKey = '';
    let walking = false;              // trwa przechodzenie po eventach
    let cancelWalk = false;           // żądanie przerwania walkera
    let lastOutput = null;            // cache ostatnio zebranego outputu — podgląd/edycja bez ponownego przeklikiwania

    const globalTags = new Set();     // globalne "Dotyczy tagów" — trafia na początek outputu
    const knownTagNames = new Set();  // podpowiedzi: WYŁĄCZNIE tagi zaczytane z Tags Fired / Tags Not Fired

    // Opcje kopiowania (checkboxy na dole) — nadpisywane z localStorage
    const opts = {
        tags: true,
        vars: true,
        dl: true,
        consent: true,         // zakładka Consent (sekcja pomijana, gdy brak danych)
        skipUndefined: true,   // pomiń zmienne undefined
        varsDiff: true,        // zmienne jako diff vs poprzedni event
        dedup: true,           // kopiuj tylko pierwszy event z każdej nazwy (duplikaty pomijane)
        filterTree: true,      // drzewko GTM pokazuje tylko zaznaczone eventy (gdy coś zaznaczono)
        hideNoise: true,       // ukryj szum (lista skip: Message/History/Consent…) na liście i w drzewku
    };

    function loadOpts() {
        try {
            const saved = JSON.parse(localStorage.getItem(LS_OPTS) || '{}');
            Object.keys(opts).forEach(k => { if (typeof saved[k] === 'boolean') opts[k] = saved[k]; });
        } catch (_) {}
    }
    function saveOpts() {
        try { localStorage.setItem(LS_OPTS, JSON.stringify(opts)); } catch (_) {}
    }
    function savePos() {
        if (!widget) return;
        const r = widget.getBoundingClientRect();
        try { localStorage.setItem(LS_POS, JSON.stringify({ left: r.left, top: r.top })); } catch (_) {}
    }
    function restorePos() {
        if (!widget) return;
        try {
            const p = JSON.parse(localStorage.getItem(LS_POS) || 'null');
            if (!p) return;
            // Nie wypuszczaj widgetu poza viewport (np. po zmianie rozdzielczości)
            const left = Math.min(Math.max(0, p.left), window.innerWidth - 60);
            const top = Math.min(Math.max(0, p.top), window.innerHeight - 60);
            widget.style.left = left + 'px';
            widget.style.top = top + 'px';
            widget.style.right = 'auto';
            widget.style.bottom = 'auto';
        } catch (_) {}
    }

    /* ═══════════════════ THEME ═══════════════════ */
    const C = {
        bg: '#111827', hover: '#1e293b',
        accent: '#3b82f6', accentDk: '#1e3a5f',
        green: '#22c55e', red: '#ef4444',
        text: '#e5e7eb', dim: '#6b7280', border: '#374151',
        orange: '#f97316',
    };
    const COLORS = ['#3b82f6','#ef4444','#22c55e','#eab308','#a855f7','#f97316','#06b6d4','#ec4899','#14b8a6','#f43f5e','#8b5cf6','#64748b','#84cc16','#0ea5e9','#d946ef'];
    const ecMap = {}; let eci = 0;
    function ec(t) { if (!ecMap[t]) ecMap[t] = COLORS[eci++ % COLORS.length]; return ecMap[t]; }

    function escHtml(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
    const sleep = ms => new Promise(r => setTimeout(r, ms));

    /* ═══════════════════ KOMPRESJA OUTPUTU ═══════════════════ */

    /** Usuwa artefakty łamania linii Tag Assistanta: `..." + \n"...` sklejane w jeden string */
    function cleanStr(s) {
        return s.replace(/"\s*\+\s*\n?\s*"/g, '');
    }

    const MAX_STR = 220;
    /** Przycina bardzo długie wartości (np. Click Element z pełnym CSS-pathem) */
    function truncateStr(s, max = MAX_STR) {
        if (typeof s !== 'string' || s.length <= max) return s;
        return s.slice(0, max) + `… [skrócono, +${s.length - max} zn.]`;
    }

    /* ═══════════════════ SEARCH (text/regex) ═══════════════════ */
    const REGEX_SIGNAL = /[|\\^$*+?.()\[\]{}]/;

    function compileEventSearch(raw) {
        eventSearch = raw; eventSearchError = ''; eventSearchRegex = null;
        if (raw && REGEX_SIGNAL.test(raw)) {
            try { eventSearchRegex = new RegExp(raw, 'i'); }
            catch (e) { eventSearchError = e.message.replace(/^Invalid regular expression: /, ''); }
        }
    }

    function matchesSearch(name) {
        if (!eventSearch || eventSearchError) return true;
        if (eventSearchRegex) return eventSearchRegex.test(name);
        return name.toLowerCase().includes(eventSearch.toLowerCase());
    }

    /* ═══════════════════ PRESETY EVENTÓW (v2.9) ═══════════════════ */
    /**
     * Cztery szybkie filtry:
     *  - ecom        → eventy e-commerce (GA4)
     *  - ecomClicks  → kliknięcia bezpośrednio POPRZEDZAJĄCE event e-commerce (fallback: purchase_click itp.)
     *  - b2b         → eventy leadowe/B2B
     *  - b2bClicks   → kliknięcia bezpośrednio poprzedzające event B2B
     * Listy edytowalne (✎), zapisywane w localStorage.
     */
    const DEFAULT_SETS = {
        ecom: [
            'view_item_list','select_item','view_item','add_to_cart','remove_from_cart',
            'view_cart','begin_checkout','add_shipping_info','add_payment_info',
            'purchase','refund','add_to_wishlist','view_promotion','select_promotion',
        ],
        b2b: [
            'generate_lead','form_start','form_submit','contact','lead','sign_up',
            'phone_call','click_phone','click_email','click_call','download',
        ],
        // Tag Assistant pokazuje nazwy wyświetlane ("Click", "Link Click"), nie surowe gtm.click
        click: ['gtm.click','gtm.linkClick','click','link click','link_click'],
        // Szum pomijany przy szukaniu "kliknięcia przed X" — eventy techniczne między klikiem a eventem docelowym
        skip: ['message','history','history change','consent','timer','scroll depth','element visibility','gtm.dom','gtm.load','gtm.js','gtm.init','gtm.init_consent','gtm.historyChange','gtm.timer','gtm.scrollDepth','gtm.elementVisibility'],
    };

    let SETS = loadSets();
    const activePresets = new Set(); // wiele presetów naraz — suma zbiorów (OR)

    /**
     * Efektywne zaznaczenie = (suma aktywnych presetów ∪ zaznaczone ręcznie) − odznaczone ręcznie.
     * Presety ZAZNACZAJĄ eventy (ticki na liście), nie filtrują widoku.
     * Liczone na żywo — nowe eventy pasujące do presetu zaznaczają się same.
     */
    function effectiveSelectedKeys() {
        const out = new Set(manualOn);
        activePresets.forEach(p => presetKeySet(p).forEach(k => out.add(k)));
        manualOff.forEach(k => out.delete(k));
        return out;
    }

    /** Widoczność wiersza na liście widgetu: wyszukiwarka + opcjonalne ukrycie szumu (zaznaczone zawsze widać) */
    function listVisible(r, eff) {
        if (opts.hideNoise && inSet(r.name, SETS.skip) && !eff.has(r.key)) return false;
        return matchesSearch(r.name);
    }

    function loadSets() {
        try {
            const s = JSON.parse(localStorage.getItem(LS_SETS) || 'null');
            if (s && Array.isArray(s.ecom) && Array.isArray(s.b2b) && Array.isArray(s.click)) {
                // Migracja z v2.9: dołóż brakującą listę skip i nowe aliasy klików
                if (!Array.isArray(s.skip)) s.skip = [...DEFAULT_SETS.skip];
                if (!s.click.some(x => x.toLowerCase() === 'link click')) s.click.push('link click');
                return s;
            }
        } catch (_) {}
        return JSON.parse(JSON.stringify(DEFAULT_SETS));
    }

    function inSet(name, arr) {
        const n = name.toLowerCase().trim();
        return arr.some(x => n === x.toLowerCase());
    }

    const CLICK_LOOKAHEAD = 15; // maks. ile eventów do przodu szukamy eventu docelowego

    /**
     * Zwraca Set kluczy eventów pasujących do presetu.
     * Dla presetów "kliknięcia przed X": klik kwalifikuje się, gdy idąc w dół osi czasu
     * (rosnące numery) pierwszy ISTOTNY event po nim należy do zbioru X.
     * Po drodze przeskakujemy: kolejne kliki (gtm.click → gtm.linkClick) oraz szum
     * z listy skip (Message, History, History Change, Consent…) — Tag Assistant
     * niemal zawsze wciska je między klik a właściwy event.
     */
    function presetKeySet(preset) {
        const keys = new Set();
        if (!preset) return keys;
        const base = preset.startsWith('ecom') ? SETS.ecom : SETS.b2b;
        const wantClicks = preset.endsWith('Clicks');

        instanceGroups.forEach(g => {
            // Kolejność chronologiczna — sortujemy po numerze eventu
            const rows = [...g.rows].sort((a, b) => (+a.num || 0) - (+b.num || 0));
            for (let i = 0; i < rows.length; i++) {
                const r = rows[i];
                if (!wantClicks) {
                    if (inSet(r.name, base)) keys.add(r.key);
                    continue;
                }
                if (!inSet(r.name, SETS.click)) continue;
                let j = i + 1;
                const limit = Math.min(rows.length, i + 1 + CLICK_LOOKAHEAD);
                while (j < limit && (inSet(rows[j].name, SETS.click) || inSet(rows[j].name, SETS.skip))) j++;
                if (j < limit && inSet(rows[j].name, base)) keys.add(r.key);
            }
        });
        return keys;
    }

    /* ═══════════════════ GTM OUTPUT TABS (Tags/Variables/Data Layer/Consent) ═══════════════════ */

    function getActiveGtmTab() {
        const sel = document.querySelector('.header__tab--selected.wd-header-tab');
        if (!sel) return '';
        const text = sel.textContent.trim().toLowerCase();
        if (text.startsWith('tag')) return 'tags';
        if (text.startsWith('variable')) return 'variables';
        if (text.startsWith('data')) return 'datalayer';
        if (text.startsWith('consent')) return 'consent';
        return text;
    }

    function findGtmTabElement(tabType) {
        const tabs = document.querySelectorAll('.header__tab.wd-header-tab');
        for (const tab of tabs) {
            const text = tab.textContent.trim().toLowerCase();
            if (tabType === 'tags' && text.startsWith('tag')) return tab;
            if (tabType === 'variables' && text.startsWith('variable')) return tab;
            if (tabType === 'datalayer' && text.startsWith('data')) return tab;
            if (tabType === 'consent' && text.startsWith('consent')) return tab;
        }
        return null;
    }

    function switchGtmTabAndWait(tabType, checkSelector, timeoutMs = 2000) {
        return new Promise((resolve) => {
            const currentTab = getActiveGtmTab();
            if (currentTab === tabType && document.querySelector(checkSelector)) return resolve(true);
            if (currentTab !== tabType) {
                const tabEl = findGtmTabElement(tabType);
                if (!tabEl) return resolve(false);
                tabEl.click();
            }
            const start = Date.now();
            const tick = () => {
                if (document.querySelector(checkSelector)) return resolve(true);
                if (Date.now() - start > timeoutMs) return resolve(false);
                requestAnimationFrame(tick);
            };
            tick();
        });
    }

    /* ═══════════════════ TAGS — kolekcja ═══════════════════ */

    function getTagCards() {
        const cards = document.querySelectorAll('.tags-tab__tag');
        if (cards.length) return [...cards];
        const fallback = document.querySelectorAll('.gtm-debug-card');
        return [...fallback].filter(el => el.querySelector('.gtm-debug-card__title'));
    }

    function nameFromCard(card) {
        const title = card.querySelector('.gtm-debug-card__title, .wd-fired-tag-name');
        return title ? title.textContent.trim() : '';
    }

    function typeFromCard(card) {
        const subtitle = card.querySelector('.gtm-debug-card__subtitle, .wd-nominated-tag-type');
        if (!subtitle) return '';
        let text = '';
        subtitle.childNodes.forEach(node => {
            if (node.nodeType === Node.TEXT_NODE) {
                text += node.textContent;
            } else if (node.nodeType === Node.ELEMENT_NODE) {
                const cls = node.className || '';
                if (!cls.includes('wd-summary-label') && !cls.includes('ng-hide') && !cls.includes('wd-event-label')) {
                    text += node.textContent;
                }
            }
        });
        return text.replace(/\s+/g, ' ').trim();
    }

    function statusFromCard(card) {
        const label = card.querySelector('.wd-event-label');
        if (label) {
            const t = label.textContent.replace(/^[\s-]+/, '').trim();
            if (t) return t;
        }
        const pausedChip = card.querySelector('.tag-details__paused-chip');
        if (pausedChip) {
            const t = pausedChip.textContent.replace(/^[\s-]+/, '').trim();
            if (t) return t;
        }
        const section = card.closest('.tags-tab__fired-tags, .tags-tab__blocked-tags');
        if (section) {
            if (section.classList.contains('tags-tab__fired-tags')) return 'Fired';
            if (section.classList.contains('tags-tab__blocked-tags')) return 'Not Fired';
        }
        return '';
    }

    function sectionOfCard(card) {
        const section = card.closest('.tags-tab__fired-tags, .tags-tab__blocked-tags');
        if (!section) return '';
        if (section.classList.contains('tags-tab__fired-tags')) return 'fired';
        if (section.classList.contains('tags-tab__blocked-tags')) return 'notFired';
        return '';
    }

    function expandNotFiredList(timeoutMs = 1500) {
        return new Promise((resolve) => {
            const toggleBtn = document.querySelector('.wd-tags-not-fired-toggle-btn');
            if (!toggleBtn) return resolve({ expanded: true, wasCollapsed: false });
            const txt = toggleBtn.textContent.trim().toLowerCase();
            const wasCollapsed = txt.includes('show');
            if (!wasCollapsed) return resolve({ expanded: true, wasCollapsed: false });
            toggleBtn.click();
            const start = Date.now();
            const tick = () => {
                const cards = document.querySelectorAll('.tags-tab__blocked-tags .tags-tab__tag');
                if (cards.length > 0) return resolve({ expanded: true, wasCollapsed: true });
                if (Date.now() - start > timeoutMs) return resolve({ expanded: false, wasCollapsed: true });
                requestAnimationFrame(tick);
            };
            tick();
        });
    }

    function collapseNotFiredList() {
        const toggleBtn = document.querySelector('.wd-tags-not-fired-toggle-btn');
        if (!toggleBtn) return;
        if (toggleBtn.textContent.trim().toLowerCase().includes('hide')) toggleBtn.click();
    }

    function collectTagsObject() {
        const cards = getTagCards();
        const fired = [];
        const notFiredByStatus = {};
        // "Unknown Tag Type" nic nie wnosi — status i tak niesie informację
        const meaningfulType = t => (t && !/^unknown tag type/i.test(t)) ? t : '';

        cards.forEach(card => {
            const name = nameFromCard(card);
            if (!name) return;
            knownTagNames.add(name); // nazwy tagów zasilają listę "Dotyczy tagów"
            const type = meaningfulType(typeFromCard(card));
            const status = statusFromCard(card);

            if (sectionOfCard(card) === 'notFired') {
                // Kompaktowo: grupujemy po statusie, same nazwy — duża oszczędność znaków
                const st = status || 'Not Fired';
                (notFiredByStatus[st] = notFiredByStatus[st] || []).push(type ? `${name} [${type}]` : name);
            } else {
                const entry = { name };
                if (type) entry.type = type;
                if (status) entry.status = status;
                fired.push(entry);
            }
        });

        const result = {};
        if (fired.length) result.fired = fired;
        if (Object.keys(notFiredByStatus).length) result.notFired = notFiredByStatus;
        return result;
    }

    /* ═══════════════════ VARIABLES — kolekcja ═══════════════════ */

    function getVarRows() {
        return [...document.querySelectorAll('.gtm-debug-variable-table-row')];
    }

    function varNameFromRow(row) {
        const chip = row.querySelector('.gtm-debug-chip');
        return chip ? chip.textContent.trim() : '';
    }

    function varValueFromRow(row) {
        const valEl = row.querySelector('.gtm-debug-variable-table-value');
        return valEl ? valEl.textContent.trim() : '';
    }

    function parseRawValue(rawValue) {
        if (rawValue === 'undefined') return undefined;
        if (rawValue === 'true') return true;
        if (rawValue === 'false') return false;
        if (/^-?\d+(\.\d+)?$/.test(rawValue)) return Number(rawValue);
        const unquoted = rawValue.replace(/^"(.*)"$/s, '$1');
        if (/^\{[\s\S]*\}$/.test(rawValue) || /^\[[\s\S]*\]$/.test(rawValue)) {
            try { return JSON.parse(rawValue); } catch (_) { return unquoted; }
        }
        return unquoted;
    }

    function collectVarsObject() {
        const result = {};
        getVarRows().forEach(row => {
            const name = varNameFromRow(row);
            if (!name) return;
            const raw = varValueFromRow(row);
            if (opts.skipUndefined && raw === 'undefined') return;
            let v = parseRawValue(cleanStr(raw));
            if (typeof v === 'string') v = truncateStr(v);
            result[name] = v === undefined ? null : v;
        });
        return result;
    }

    /**
     * Diff zmiennych między eventami: co się zmieniło / doszło / zniknęło.
     */
    function diffVars(prev, curr) {
        const changed = {};
        const removed = [];
        Object.keys(curr).forEach(k => {
            if (!(k in prev) || JSON.stringify(prev[k]) !== JSON.stringify(curr[k])) {
                changed[k] = curr[k];
            }
        });
        Object.keys(prev).forEach(k => {
            if (!(k in curr)) removed.push(k);
        });
        return { changed, removed };
    }

    /* ═══════════════════ CONSENT — kolekcja ═══════════════════ */

    function collectConsentObject() {
        // Twardy warunek: działamy TYLKO gdy zakładka Consent jest realnie aktywna.
        // Bez tego generyczne selektory łapały tabelę Variables (stąd undefined w outputcie).
        if (getActiveGtmTab() !== 'consent') return null;

        const scope = document.querySelector('[class*="consent-tab"], [class*="consent"]');
        if (!scope) return null;
        // Ochrona: jeśli w scope siedzi tabela zmiennych, to zły kontener — nie parsujemy
        if (scope.querySelector('.gtm-debug-variable-table-row')) return null;

        const result = {};
        scope.querySelectorAll('tr, .gtm-debug-table-row').forEach(row => {
            const cells = row.querySelectorAll('td, th, .gtm-debug-table-cell');
            if (cells.length < 2) return;
            const key = cells[0].textContent.replace(/\s+/g, ' ').trim();
            if (!key) return;
            // Pomiń wiersze nagłówkowe
            if (/consent type|typ zgody|^variable$/i.test(key)) return;
            const values = [...cells].slice(1)
                .map(c => truncateStr(cleanStr(c.textContent.replace(/\s+/g, ' ').trim())))
                .filter(Boolean);
            if (opts.skipUndefined && values.every(v => v === 'undefined')) return;
            if (values.length) result[key] = values.length === 1 ? values[0] : values;
        });
        if (Object.keys(result).length) return result;

        // Fallback: surowy tekst panelu consent (przycięty)
        const raw = scope.innerText.replace(/\n{3,}/g, '\n\n').trim();
        return raw ? { raw: truncateStr(raw, 800) } : null;
    }

    /* ═══════════════════ DATA LAYER — kolekcja ═══════════════════ */

    function getFullCodeMirrorText() {
        const allCM = document.querySelectorAll('.CodeMirror');
        for (const cmEl of allCM) {
            if (cmEl.CodeMirror) {
                const val = cmEl.CodeMirror.getValue();
                if (val && val.trim().startsWith('{')) return val;
            }
        }
        const container = document.querySelector('.gtm-debug-data-layer-content-container');
        if (container) {
            const cmEl = container.querySelector('.CodeMirror');
            if (cmEl && cmEl.CodeMirror) return cmEl.CodeMirror.getValue();
        }
        return null;
    }

    function parseTagAssistantObject(raw) {
        let cleaned = raw.replace(/"(\s*)\+\s*\n?\s*"/g, '');
        cleaned = cleaned.replace(/(?<=[{\[,\n]\s*)([a-zA-Z_$][\w$]*)\s*:/g, '"$1":');
        cleaned = cleaned.replace(/,(\s*[}\]])/g, '$1');
        return JSON.parse(cleaned);
    }

    function collectDataLayerPushText() {
        const raw = getFullCodeMirrorText();
        if (!raw) return null;
        try {
            const parsed = parseTagAssistantObject(raw);
            const push = {};
            if (parsed.event) push.event = parsed.event;
            if (parsed.eventModel && Object.keys(parsed.eventModel).length > 0) {
                push.ecommerce = {};
                const em = parsed.eventModel;
                const ecomFields = [
                    'transaction_id', 'value', 'shipping', 'currency', 'tax',
                    'items', 'coupon', 'affiliation', 'payment_type',
                    'shipping_tier', 'item_list_id', 'item_list_name',
                    'creative_name', 'creative_slot', 'promotion_id', 'promotion_name'
                ];
                for (const f of ecomFields) {
                    if (em[f] !== undefined) push.ecommerce[f] = em[f];
                }
                if (Object.keys(push.ecommerce).length === 0) delete push.ecommerce;
            } else if (parsed.ecommerce) {
                push.ecommerce = parsed.ecommerce;
            }
            return `dataLayer.push(${JSON.stringify(push, null, 2)});`;
        } catch (e) {
            console.warn('[Event Walker] DataLayer parse error:', e.message);
            return raw;
        }
    }

    /* ═══════════════════ SKANOWANIE EVENTÓW (grupy = strony) ═══════════════════ */

    function scanInstances() {
        const groups = [];
        document.querySelectorAll('.message-list__group').forEach((group, gi) => {
            const groupRow = group.querySelector('.message-list__row--group');
            let gTitle = '', gUrl = '';
            if (groupRow) {
                const span = groupRow.querySelector('.message-list__title span[title]') ||
                             groupRow.querySelector('span[title]');
                if (span) {
                    gTitle = span.textContent.trim();
                    const t = span.getAttribute('title') || '';
                    if (/^https?:\/\//.test(t)) gUrl = t;
                    else if (t && t !== gTitle) gUrl = t;
                } else {
                    gTitle = groupRow.textContent.replace(/\s+/g, ' ').trim();
                }
            }
            const rows = [];
            group.querySelectorAll('.message-list__row--indented').forEach(row => {
                const titleSpan = row.querySelector('.message-list__title span[title]');
                const name = titleSpan ? titleSpan.getAttribute('title').trim() : '';
                if (!name) return;
                const numMatch = row.textContent.trim().match(/^(\d+)/);
                const num = numMatch ? numMatch[1] : '';
                const key = `${gi}|${num}|${name}`;
                rows.push({ num, name, key, el: row, gTitle, gUrl });
            });
            if (rows.length) groups.push({ title: gTitle, url: gUrl, rows });
        });
        return groups;
    }

    function allRowsFlat() {
        return instanceGroups.flatMap(g => g.rows);
    }

    function selectedRowsFlat() {
        const eff = effectiveSelectedKeys();
        return allRowsFlat().filter(r => eff.has(r.key));
    }

    /**
     * Cache outputu jest ważny tylko dla zaznaczenia i opcji, przy których go zebrano.
     * Zmiana zaznaczenia lub opcji = unieważnienie (podgląd zbierze od nowa).
     */
    function invalidateOutputCache() {
        lastOutput = null;
    }

    /* ═══════════════════ FILTR NA SIDEBARZE GTM (search + preset) ═══════════════════ */

    function applySidebarFilter() {
        const eff = effectiveSelectedKeys();
        const selActive = opts.filterTree && eff.size > 0;   // zaznaczenie steruje drzewkiem
        const searchActive = eventSearch && !eventSearchError;

        if (!selActive && !searchActive && !opts.hideNoise) {
            document.querySelectorAll('.message-list__row--indented').forEach(r => { r.style.display = ''; });
            return;
        }
        allRowsFlat().forEach(r => {
            if (!r.el || !document.contains(r.el)) return;
            let vis;
            if (selActive) {
                // Tryb "tylko zaznaczone" — drzewko pokazuje dokładnie to, co ma ticki
                vis = eff.has(r.key);
            } else {
                vis = matchesSearch(r.name);
                if (vis && opts.hideNoise && inSet(r.name, SETS.skip) && !eff.has(r.key)) vis = false;
            }
            r.el.style.display = vis ? '' : 'none';
        });
    }

    /* ═══════════════════ CLIPBOARD ═══════════════════ */

    async function writeClipboard(text) {
        try {
            await navigator.clipboard.writeText(text);
            return true;
        } catch (_) {
            const ta = document.createElement('textarea');
            ta.value = text;
            ta.style.cssText = 'position:fixed;left:-9999px';
            document.body.appendChild(ta);
            ta.select();
            let ok = false;
            try { ok = document.execCommand('copy'); } catch (_) {}
            ta.remove();
            return ok;
        }
    }

    /* ═══════════════════ WERYFIKACJA ZAŁADOWANIA EVENTU ═══════════════════ */

    function getDetailArea() {
        // Obszar szczegółów (bez naszego widgetu) do fingerprintu
        return document.querySelector('.gtm-debug-pane, .content, main') || null;
    }

    function detailFingerprint() {
        const el = getDetailArea();
        if (!el) return '';
        const t = el.innerText || '';
        return t.length + '|' + t.slice(0, 400);
    }

    function getSelectedSidebarRow() {
        return document.querySelector('.message-list__row--indented[class*="selected"]') ||
               document.querySelector('.message-list__row[class*="selected"]');
    }

    /**
     * Klika event i czeka aż detale FAKTYCZNIE się przeładują
     * (fingerprint treści się zmieni albo wiersz dostanie klasę selected).
     * Fallback: timeout — nie blokujemy walkera w nieskończoność.
     */
    async function clickAndWaitForDetail(item, timeoutMs = 1800) {
        const wasAlreadySelected = (item.el.className || '').includes('selected');
        const before = detailFingerprint();

        item.el.scrollIntoView({ block: 'center' });
        item.el.click();

        if (wasAlreadySelected) {
            // Event już był otwarty — treść się nie zmieni, krótki settle wystarczy
            await sleep(250);
            return;
        }

        const start = Date.now();
        while (Date.now() - start < timeoutMs) {
            await sleep(80);
            if (detailFingerprint() !== before) break;
            const cls = item.el.className || '';
            if (cls.includes('selected') && Date.now() - start > 300) break;
        }
        // Settle — daj Angularowi dokończyć render list wewnątrz zakładek
        await sleep(200);
    }

    /* ═══════════════════ GŁÓWNY WALKER ═══════════════════ */

    /**
     * Przechodzi po zaznaczonych eventach i buduje markdown.
     * Zwraca string albo null (anulowano / błąd / nic nie zebrano).
     */
    async function walkAndBuild(progressBtn) {
        let selected = selectedRowsFlat();
        if (!selected.length) return { error: '✕ nic nie zaznaczono' };
        if (!opts.tags && !opts.vars && !opts.dl && !opts.consent) return { error: '✕ wybierz sekcje' };

        // Dedup po nazwie: zostaje pierwszy (najwcześniejszy) event z każdej nazwy
        let dedupNote = '';
        if (opts.dedup) {
            const seen = new Set();
            const kept = [];
            const skipped = [];
            selected.forEach(r => {
                const n = r.name.toLowerCase().trim();
                if (seen.has(n)) { skipped.push(`#${r.num || '?'} ${r.name}`); return; }
                seen.add(n);
                kept.push(r);
            });
            selected = kept;
            if (skipped.length) {
                dedupNote = `Dedup po nazwie — pominięto ${skipped.length} duplikatów: ${skipped.join(', ')}`;
            }
            if (!selected.length) return { error: '✕ nic po dedupie' };
        }

        walking = true;
        cancelWalk = false;
        progressBtn.classList.add('loading');

        // Zapamiętaj, który event był otwarty przed startem — przywrócimy go po walkerze
        const prevSelectedRow = getSelectedSidebarRow();

        const parts = [];
        let prevVars = null;
        let prevVarsEventLabel = '';
        let cancelled = false;

        try {
            for (let i = 0; i < selected.length; i++) {
                if (cancelWalk) { cancelled = true; break; }

                const item = selected[i];
                progressBtn.textContent = `⏳ ${i + 1}/${selected.length} · kliknij aby przerwać`;

                await clickAndWaitForDetail(item);
                if (cancelWalk) { cancelled = true; break; }

                const sec = [];
                sec.push(`## Event #${item.num || '?'}: ${item.name}`);
                if (item.gTitle) sec.push(`Tytuł: ${item.gTitle}`);
                if (item.gUrl) sec.push(`URL: ${item.gUrl}`);
                sec.push('');

                if (opts.dl && !cancelWalk) {
                    const ok = await switchGtmTabAndWait('datalayer', '.CodeMirror');
                    if (ok) {
                        await sleep(150);
                        const dl = collectDataLayerPushText();
                        sec.push('### Warstwa danych (dataLayer)');
                        sec.push('```js');
                        sec.push(dl || '// brak danych');
                        sec.push('```');
                        sec.push('');
                    }
                }

                if (opts.tags && !cancelWalk) {
                    const ok = await switchGtmTabAndWait('tags', '.tags-tab__tag, .gtm-debug-card, .tags-tab');
                    if (ok) {
                        const { wasCollapsed } = await expandNotFiredList();
                        await sleep(100);
                        const tags = collectTagsObject();
                        if (wasCollapsed) collapseNotFiredList();
                        sec.push('### Tagi');
                        sec.push('```json');
                        sec.push(JSON.stringify(tags, null, 2));
                        sec.push('```');
                        sec.push('');
                    }
                }

                if (opts.vars && !cancelWalk) {
                    const ok = await switchGtmTabAndWait('variables', '.gtm-debug-variable-table-row');
                    if (ok) {
                        await sleep(100);
                        const vars = collectVarsObject();

                        if (opts.varsDiff && prevVars) {
                            const { changed, removed } = diffVars(prevVars, vars);
                            sec.push(`### Zmienne (zmiany vs ${prevVarsEventLabel})`);
                            if (Object.keys(changed).length === 0 && removed.length === 0) {
                                sec.push('_bez zmian_');
                            } else {
                                const diffOut = {};
                                if (Object.keys(changed).length) diffOut.zmienione = changed;
                                if (removed.length) diffOut.usuniete = removed;
                                sec.push('```json');
                                sec.push(JSON.stringify(diffOut, null, 2));
                                sec.push('```');
                            }
                        } else {
                            sec.push('### Zmienne' + (opts.varsDiff ? ' (pełna lista — pierwszy event)' : ''));
                            sec.push('```json');
                            sec.push(JSON.stringify(vars, null, 2));
                            sec.push('```');
                        }
                        sec.push('');

                        prevVars = vars;
                        prevVarsEventLabel = `Event #${item.num || '?'}: ${item.name}`;
                    }
                }

                if (opts.consent && !cancelWalk) {
                    const ok = await switchGtmTabAndWait('consent', '[class*="consent"]');
                    if (ok && getActiveGtmTab() === 'consent') {
                        await sleep(150);
                        const consent = collectConsentObject();
                        // Brak danych consent = pomijamy sekcję (nie zaśmiecamy outputu)
                        if (consent && Object.keys(consent).length) {
                            sec.push('### Consent');
                            sec.push('```json');
                            sec.push(JSON.stringify(consent, null, 2));
                            sec.push('```');
                            sec.push('');
                        }
                    }
                }

                parts.push(sec.join('\n'));
            }
        } catch (err) {
            console.error('[Event Walker] Walk error:', err);
            return { error: '✕ błąd' };
        } finally {
            walking = false;
            progressBtn.classList.remove('loading');
            // Przywróć event, który był otwarty przed startem walkera
            if (prevSelectedRow && document.contains(prevSelectedRow)) {
                try { prevSelectedRow.click(); } catch (_) {}
            }
        }

        if (cancelled && !parts.length) return { error: '✕ przerwano' };

        // Nagłówek: notka o dedupie. Linia "Dotyczy tagów" jest dokładana na etapie podglądu.
        let header = '';
        if (dedupNote) header += dedupNote + '\n\n---\n\n';
        const out = header + parts.join('\n---\n\n');
        return { text: out, count: parts.length, cancelled };
    }

    async function runCopyWalk(btn) {
        if (walking) { cancelWalk = true; btn.textContent = '⏹ przerywam…'; return; }
        const origText = btn.textContent;

        const res = await walkAndBuild(btn);
        if (res.error) { flashBtn(btn, res.error, C.red, origText); return; }

        lastOutput = res.text;
        const ok = await writeClipboard(res.text);
        const label = res.cancelled
            ? `✓ skopiowano ${res.count} evt (przerwano)`
            : `✓ skopiowano ${res.count} evt`;
        flashBtn(btn, ok ? label : '✕ błąd schowka', ok ? C.green : C.red, null);
        // Po skopiowaniu od razu pokaż podgląd — widać co poszło do schowka, można doszlifować i skopiować ponownie
        openPreviewModal(res.text);
    }

    async function runPreviewWalk(btn, copyBtn) {
        if (walking) return;

        // Mamy już zebrany output (po Kopiuj albo poprzednim podglądzie)?
        // Pokaż go od razu — bez ponownego przeklikiwania eventów.
        if (lastOutput !== null) {
            openPreviewModal(lastOutput);
            return;
        }

        const origText = copyBtn.textContent;
        const res = await walkAndBuild(copyBtn);
        if (res.error) { flashBtn(copyBtn, res.error, C.red, origText); return; }

        lastOutput = res.text;
        flashBtn(copyBtn, `✓ zebrano ${res.count} evt`, C.green, null);
        openPreviewModal(res.text);
    }

    function flashBtn(btn, text, color, restoreText) {
        btn.textContent = text;
        btn.style.background = color;
        btn.style.borderColor = color;
        btn.style.color = '#fff';
        setTimeout(() => {
            btn.style.background = '';
            btn.style.borderColor = '';
            btn.style.color = '';
            btn.textContent = restoreText !== null && restoreText !== undefined
                ? restoreText
                : `Kopiuj zaznaczone (${effectiveSelectedKeys().size})`;
        }, 1800);
    }

    /* ═══════════════════ MODAL PODGLĄDU / EDYCJI ═══════════════════ */

    function fmtCount(text) {
        return `${text.length.toLocaleString('pl-PL')} znaków · ~${Math.round(text.length / 4).toLocaleString('pl-PL')} tokenów`;
    }

    function openPreviewModal(text) {
        closePreviewModal();
        const modal = document.createElement('div');
        modal.id = WID + '-modal';
        modal.innerHTML = `
<style>
#${WID}-modal{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:'Google Sans',system-ui,sans-serif}
#${WID}-modal .ta-m-box{width:min(760px,92vw);height:min(640px,86vh);background:${C.bg};border:1px solid ${C.border};border-radius:14px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.6)}
#${WID}-modal .ta-m-hdr{padding:10px 16px;display:flex;align-items:center;gap:10px;border-bottom:1px solid ${C.border};background:${C.hover}}
#${WID}-modal .ta-m-title{flex:1;font-size:13px;font-weight:600;color:#fff}
#${WID}-modal .ta-m-count{font-size:11px;color:${C.dim}}
#${WID}-modal .ta-m-close{background:none;border:none;color:${C.dim};font-size:20px;cursor:pointer;line-height:1;padding:0 4px}
#${WID}-modal .ta-m-close:hover{color:#fff}
#${WID}-modal .ta-m-tags-wrap{padding:7px 16px 8px;border-bottom:1px solid ${C.border};background:${C.bg}}
#${WID}-modal .ta-m-tags-hdr{display:flex;align-items:center;gap:8px;margin-bottom:5px}
#${WID}-modal .ta-m-tags-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap}
#${WID}-modal .ta-m-tags-cnt{font-size:10px;color:${C.dim};font-weight:700}
#${WID}-modal .ta-m-tags-filter{margin-left:auto;width:170px;background:${C.border};border:1px solid #4b5563;color:${C.text};font-size:10px;padding:3px 8px;border-radius:6px;outline:none;font-family:inherit}
#${WID}-modal .ta-m-tags-filter:focus{border-color:${C.accent}}
#${WID}-modal .ta-m-tags-filter::placeholder{color:#6b7280}
#${WID}-modal .ta-m-tags-toggle{font-size:10px;color:${C.accent};background:none;border:none;cursor:pointer;font-weight:700;white-space:nowrap;padding:2px 4px}
#${WID}-modal .ta-m-tags-toggle:hover{text-decoration:underline}
#${WID}-modal .ta-m-tags{display:flex;flex-wrap:wrap;gap:4px;max-height:56px;overflow-y:auto}
#${WID}-modal .ta-m-tags.expanded{max-height:168px}
#${WID}-modal .ta-m-tags::-webkit-scrollbar{width:5px}#${WID}-modal .ta-m-tags::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
#${WID}-modal .ta-m-tag{font-size:10px;font-weight:600;padding:3px 9px;border-radius:10px;border:1px solid ${C.border};background:${C.bg};color:${C.text};cursor:pointer;transition:all .1s;white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
#${WID}-modal .ta-m-tag:hover{border-color:${C.accent}}
#${WID}-modal .ta-m-tag.on{background:${C.accent};border-color:${C.accent};color:#fff}
#${WID}-modal .ta-m-tags-empty{font-size:10px;color:${C.dim};padding:2px 0}
#${WID}-modal textarea{flex:1;background:#0b1220;color:${C.text};border:none;outline:none;resize:none;padding:14px 16px;font-family:'Consolas','Monaco','Courier New',monospace;font-size:11px;line-height:1.5;user-select:text}
#${WID}-modal .ta-m-foot{padding:10px 16px;display:flex;gap:8px;border-top:1px solid ${C.border};background:${C.hover}}
#${WID}-modal .ta-m-btn{font-size:12px;font-weight:700;padding:7px 16px;border-radius:8px;cursor:pointer;border:1px solid ${C.border};background:${C.bg};color:${C.text};transition:all .12s}
#${WID}-modal .ta-m-btn:hover{border-color:${C.accent}}
#${WID}-modal .ta-m-btn--primary{background:${C.accent};border-color:${C.accent};color:#fff}
#${WID}-modal .ta-m-btn--primary:hover{background:#2563eb}
</style>
<div class="ta-m-box">
  <div class="ta-m-hdr">
    <span class="ta-m-title">Podgląd / edycja — GTM Event Walker</span>
    <span class="ta-m-count" id="ta-m-count">${fmtCount(text)}</span>
    <button class="ta-m-close" title="Zamknij">✕</button>
  </div>
  <div class="ta-m-tags-wrap" id="ta-m-tags-wrap" style="display:none">
    <div class="ta-m-tags-hdr">
      <span class="ta-m-tags-label">Dotyczy tagów</span>
      <span class="ta-m-tags-cnt" id="ta-m-tags-cnt"></span>
      <input class="ta-m-tags-filter" id="ta-m-tags-filter" placeholder="filtruj tagi..." style="display:none">
      <button class="ta-m-tags-toggle" id="ta-m-tags-toggle"></button>
    </div>
    <div class="ta-m-tags" id="ta-m-tags"></div>
  </div>
  <textarea spellcheck="false"></textarea>
  <div class="ta-m-foot">
    <button class="ta-m-btn ta-m-btn--primary" id="ta-m-copy">Kopiuj do schowka</button>
    <button class="ta-m-btn" id="ta-m-cancel">Zamknij</button>
  </div>
</div>`;
        const ta = modal.querySelector('textarea');
        ta.value = text;

        /* ── Dotyczy tagów: chipy nad treścią, toggle podmienia pierwszą linię outputu ── */
        let tagsExpanded = true;   // start: rozwinięte — pełna lista z filtrem, "zwiń" chowa do samych wybranych
        let tagFilter = '';
        const cntEl = () => modal.querySelector('#ta-m-count');
        function refreshCount() {
            const c = cntEl();
            if (c) c.textContent = fmtCount(ta.value);
        }
        function applyTagsHeader() {
            // Zdejmij istniejącą linię nagłówka (idempotentnie), potem dołóż aktualną
            let v = ta.value.replace(/^Dotyczy tagów:[^\n]*\n+/, '');
            if (globalTags.size) v = `Dotyczy tagów: ${[...globalTags].join(', ')}\n\n` + v;
            ta.value = v;
            lastOutput = v;
            refreshCount();
        }
        function renderModalTags() {
            const wrap = modal.querySelector('#ta-m-tags-wrap');
            const box = modal.querySelector('#ta-m-tags');
            const cnt = modal.querySelector('#ta-m-tags-cnt');
            const tgl = modal.querySelector('#ta-m-tags-toggle');
            const filt = modal.querySelector('#ta-m-tags-filter');
            const all = [...knownTagNames];
            if (!all.length) { wrap.style.display = 'none'; return; }
            wrap.style.display = 'block';

            cnt.textContent = `${globalTags.size}/${all.length}`;
            tgl.textContent = tagsExpanded
                ? 'zwiń ▴'
                : (globalTags.size ? 'pokaż wszystkie ▾' : `wybierz (${all.length}) ▾`);
            filt.style.display = tagsExpanded ? '' : 'none';
            box.classList.toggle('expanded', tagsExpanded);

            // Sort: grupowanie po vendorze (prefiks przed " - "), alfabetycznie; wybrane zawsze na początku
            const byVendor = (a, b) => {
                const pa = a.split(' - ')[0], pb = b.split(' - ')[0];
                return pa.localeCompare(pb, 'pl') || a.localeCompare(b, 'pl');
            };
            const sorted = all.sort(byVendor);
            const sel = sorted.filter(n => globalTags.has(n));
            const rest = sorted.filter(n => !globalTags.has(n));
            let names = [...sel, ...rest];

            if (!tagsExpanded) {
                names = sel; // zwinięte: tylko wybrane chipy
            } else if (tagFilter) {
                const f = tagFilter.toLowerCase();
                names = names.filter(n => n.toLowerCase().includes(f));
            }

            box.innerHTML = '';
            if (!names.length) {
                box.innerHTML = tagsExpanded
                    ? '<span class="ta-m-tags-empty">brak tagów pasujących do filtra</span>'
                    : '<span class="ta-m-tags-empty">brak wybranych — kliknij „wybierz", żeby dodać kontekst do outputu</span>';
                return;
            }
            names.forEach(name => {
                const chip = document.createElement('button');
                chip.className = 'ta-m-tag' + (globalTags.has(name) ? ' on' : '');
                chip.textContent = name;
                chip.title = name;
                chip.addEventListener('click', () => {
                    globalTags.has(name) ? globalTags.delete(name) : globalTags.add(name);
                    applyTagsHeader();
                    renderModalTags(); // re-sort: wybrane wskakują na początek, licznik się aktualizuje
                });
                box.appendChild(chip);
            });
        }
        modal.querySelector('#ta-m-tags-toggle').addEventListener('click', () => {
            tagsExpanded = !tagsExpanded;
            renderModalTags();
            if (tagsExpanded) modal.querySelector('#ta-m-tags-filter').focus();
        });
        modal.querySelector('#ta-m-tags-filter').addEventListener('input', e => {
            tagFilter = e.target.value;
            renderModalTags();
        });
        renderModalTags();
        applyTagsHeader(); // jeśli tagi były już wybrane wcześniej — linia trafia do treści od razu

        // Edycja w podglądzie — aktualizuje cache i licznik na żywo,
        // więc kolejne otwarcie podglądu i Kopiuj z modala widzą poprawki
        ta.addEventListener('input', () => {
            lastOutput = ta.value;
            refreshCount();
        });
        modal.addEventListener('click', e => { if (e.target === modal) closePreviewModal(); });
        modal.querySelector('.ta-m-close').addEventListener('click', closePreviewModal);
        modal.querySelector('#ta-m-cancel').addEventListener('click', closePreviewModal);
        modal.querySelector('#ta-m-copy').addEventListener('click', async e => {
            const b = e.currentTarget;
            const ok = await writeClipboard(ta.value); // kopiuje aktualną (edytowaną) treść
            b.textContent = ok ? '✓ skopiowano' : '✕ błąd';
            setTimeout(() => { b.textContent = 'Kopiuj do schowka'; }, 1500);
        });
        document.addEventListener('keydown', escCloseModal);
        document.body.appendChild(modal);
    }

    function escCloseModal(e) {
        if (e.key === 'Escape') closePreviewModal();
    }

    function closePreviewModal() {
        const m = document.getElementById(WID + '-modal');
        if (m) m.remove();
        document.removeEventListener('keydown', escCloseModal);
    }

    /* ═══════════════════ WIDGET ═══════════════════ */

    function buildWidget() {
        if (widget) widget.remove();
        widget = document.createElement('div');
        widget.id = WID;
        widget.innerHTML = `
<style>
#${WID}{position:fixed;bottom:16px;right:16px;z-index:2147483646;font-family:'Google Sans',system-ui,sans-serif;width:330px;border-radius:14px;overflow:hidden;box-shadow:0 10px 36px rgba(0,0,0,.55);border:1px solid ${C.border};background:${C.bg};user-select:none}
#${WID} *{box-sizing:border-box}
.ta-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}}
.ta-logo{width:22px;height:22px;flex-shrink:0}
.ta-logo svg{display:block}
.ta-title{flex:1;font-size:13px;font-weight:600;color:#fff;letter-spacing:.3px}
.ta-badge{font-size:10px;background:${C.border};color:${C.dim};padding:2px 8px;border-radius:10px}
.ta-hdr-btn{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;opacity:.6;transition:opacity .15s}
.ta-hdr-btn:hover{opacity:1}
.ta-wrap{overflow:hidden;transition:max-height .25s ease}
.ta-wrap.open{max-height:92vh}.ta-wrap.closed{max-height:0!important}
.ta-panel{padding:10px 14px;max-height:62vh;overflow-y:auto}
.ta-panel::-webkit-scrollbar{width:5px}.ta-panel::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
.ta-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.ta-settings{display:none;padding:10px 14px 12px;max-height:62vh;overflow-y:auto}
.ta-settings::-webkit-scrollbar{width:5px}.ta-settings::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
.ta-set-back{font-size:11px;color:${C.accent};font-weight:600;cursor:pointer;padding:3px 2px}
.ta-set-back:hover{text-decoration:underline}
.ta-settings .ta-set-item{display:flex;align-items:flex-start;gap:9px;padding:7px 5px;border-radius:7px;transition:background .08s}
.ta-settings .ta-set-item:hover{background:${C.hover}}
.ta-settings .ta-set-item .ta-chk{flex-shrink:0;margin-top:2px;width:15px;height:15px}
.ta-set-txt{flex:1;min-width:0}
.ta-set-name{font-size:11px;color:${C.text};font-weight:600;line-height:1.3}
.ta-set-desc{font-size:10px;color:${C.dim};line-height:1.4;margin-top:2px}
.ta-preset-row{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;align-items:center}
.ta-preset{font-size:10px;font-weight:600;padding:4px 9px;border-radius:12px;border:1px solid ${C.border};background:${C.bg};color:${C.text};cursor:pointer;transition:all .12s;font-family:inherit}
.ta-preset:hover{border-color:${C.accent}}
.ta-preset.on{background:${C.accent};border-color:${C.accent};color:#fff}
.ta-preset-cnt{opacity:.7;font-weight:800;margin-left:1px}
.ta-preset--empty{opacity:.45}
.ta-preset--empty:hover{opacity:.8}
.ta-input-wrap{position:relative;margin-bottom:8px}
.ta-input{width:100%;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:7px 10px;border-radius:8px;font-size:12px;outline:none;font-family:inherit}
.ta-input:focus{border-color:${C.accent}}
.ta-input::placeholder{color:#3a3f4a}
.ta-input.ta-input--regex{font-family:'Consolas','Monaco','Courier New',monospace;border-color:#7c3aed}
.ta-input.ta-input--error{border-color:${C.red}!important}
.ta-search-mode{position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:9px;padding:2px 6px;border-radius:4px;font-weight:700;border:none;background:${C.border};color:${C.dim};pointer-events:none}
.ta-search-mode--clear{color:${C.text};pointer-events:auto;cursor:pointer}
.ta-search-mode--regex{background:#7c3aed;color:#fff;pointer-events:auto;cursor:pointer}
.ta-search-mode--error{background:${C.red};color:#fff;pointer-events:auto;cursor:pointer}
.ta-search-hint{font-size:10px;color:${C.dim};margin:-4px 0 6px 2px;line-height:1.3}
.ta-search-error{font-size:10px;color:${C.red};margin:-4px 0 6px 2px;line-height:1.3}
.ta-ghdr{display:flex;align-items:center;gap:6px;font-size:10px;color:${C.orange};font-weight:600;padding:7px 4px 3px;border-bottom:1px dashed ${C.border};margin-bottom:2px;cursor:pointer;border-radius:4px;transition:background .08s}
.ta-ghdr:hover{background:${C.hover}}
.ta-ghdr .ta-chk{width:13px;height:13px;font-size:8px}
.ta-ghdr.on .ta-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.ta-ghdr-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ta-ghdr-cnt{font-size:9px;color:${C.dim};font-weight:700;flex-shrink:0}
.ta-frow{display:flex;align-items:center;gap:8px;padding:5px 4px;cursor:pointer;transition:background .08s;border-radius:6px}
.ta-frow:hover{background:${C.hover}}
.ta-chk{width:15px;height:15px;border-radius:4px;border:2px solid ${C.border};display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .12s;font-size:9px;color:transparent}
.ta-frow.on .ta-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.ta-dot{width:7px;height:7px;border-radius:2px;flex-shrink:0}
.ta-num{font-size:10px;color:${C.dim};min-width:16px;text-align:right}
.ta-fname{flex:1;font-size:12px;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ta-optbar{border-top:1px solid ${C.border};padding:8px 12px;background:${C.hover}}
.ta-optrow{display:flex;gap:4px 12px;flex-wrap:wrap}
.ta-opt{display:flex;align-items:center;gap:5px;cursor:pointer;padding:2px 0}
.ta-opt .ta-chk{width:14px;height:14px}
.ta-opt.on .ta-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.ta-opt-label{font-size:10px;color:${C.dim};white-space:nowrap}
.ta-opt:hover .ta-opt-label{color:${C.text}}
.ta-copybar{border-top:1px solid ${C.border};padding:8px 12px;background:${C.hover};display:flex;gap:6px}
.ta-copy-btn{flex:1;font-size:12px;color:#fff;background:${C.accent};border:1px solid ${C.accent};cursor:pointer;font-weight:700;padding:8px 4px;border-radius:8px;transition:all .12s;text-align:center}
.ta-copy-btn:hover{background:#2563eb}
.ta-copy-btn:active{transform:scale(.98)}
.ta-copy-btn.loading{opacity:.85}
.ta-eye-btn{font-size:12px;font-weight:600;color:${C.text};background:${C.bg};border:1px solid ${C.border};cursor:pointer;padding:8px 12px;border-radius:8px;transition:all .12s;text-align:center;white-space:nowrap}
.ta-eye-btn:hover{border-color:${C.accent}}
.ta-empty{font-size:11px;color:${C.dim};padding:6px 0}
</style>

<div class="ta-hdr" id="ta-hdr">
  <div class="ta-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="#0d9488"/><circle cx="82" cy="82" r="36" fill="none" stroke="#fff" stroke-width="12"/><line x1="108" y1="108" x2="148" y2="148" stroke="#fff" stroke-width="12" stroke-linecap="round"/><rect x="60" y="70" width="44" height="6" rx="3" fill="#fff" opacity=".7"/><rect x="60" y="82" width="30" height="6" rx="3" fill="#fff" opacity=".7"/></svg></div>
  <span class="ta-title">GTM Event Walker</span>
  <span class="ta-badge" id="ta-badge">—</span>
  <button class="ta-hdr-btn" id="ta-settings-btn" title="Ustawienia — opcje widoczności i kopiowania">⚙</button>
  <button class="ta-hdr-btn" id="ta-col">▾</button>
</div>

<div class="ta-wrap ${collapsed ? 'closed' : 'open'}" id="ta-wrap">

  <div class="ta-panel" id="ta-evt-panel">
    <div class="ta-label">Szukaj po nazwie eventu <span style="font-weight:400;text-transform:none;letter-spacing:0;color:#9ca3af">· regex, np. purchase|add_to_cart</span></div>
    <div class="ta-input-wrap">
      <input class="ta-input" id="ta-evt-search" placeholder="wpisz nazwę eventu..." style="padding-right:38px">
      <span class="ta-search-mode" id="ta-evt-search-mode">TEXT</span>
    </div>
    <div id="ta-evt-search-hint" class="ta-search-hint" style="display:none"></div>

    <div class="ta-label">Presety — zaznaczają pasujące eventy</div>
    <div class="ta-preset-row">
      <button class="ta-preset" data-preset="ecom" title="Zaznacza eventy e-commerce (GA4): purchase, add_to_cart, begin_checkout…">🛒 Ecommerce <span class="ta-preset-cnt"></span></button>
      <button class="ta-preset" data-preset="ecomClicks" title="Zaznacza kliknięcia (Click / Link Click) poprzedzające event e-commerce — pomija po drodze Message/History/Consent. Do fallbacków typu purchase_click">🖱→🛒 Kliknięcia przed ecom <span class="ta-preset-cnt"></span></button>
      <button class="ta-preset" data-preset="b2b" title="Zaznacza eventy leadowe/B2B: generate_lead, form_submit, contact…">📋 B2B <span class="ta-preset-cnt"></span></button>
      <button class="ta-preset" data-preset="b2bClicks" title="Zaznacza kliknięcia poprzedzające event B2B (pomija Message/History/Consent po drodze) — do fallbacków klikowych">🖱→📋 Kliknięcia przed B2B <span class="ta-preset-cnt"></span></button>
    </div>

    <div id="ta-instance-list"></div>
  </div>

  <!-- ═══ USTAWIENIA (pod zębatką) ═══ -->
  <div class="ta-settings" id="ta-settings">
    <div class="ta-set-back" id="ta-settings-back">← wróć do listy eventów</div>

    <div class="ta-label" style="margin-top:8px">Widoczność</div>
    <div class="ta-opt ta-set-item" data-opt="filterTree">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">Drzewko GTM: tylko zaznaczone</div>
        <div class="ta-set-desc">Gdy cokolwiek ma ticka (z presetu albo ręcznie), drzewko eventów w Tag Assistant pokazuje wyłącznie zaznaczone pozycje. Wyłącz, żeby zawsze widzieć pełną oś czasu.</div>
      </div>
    </div>
    <div class="ta-opt ta-set-item" data-opt="hideNoise">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">Ukryj szum</div>
        <div class="ta-set-desc">Chowa eventy techniczne (Message, History, History Change, Consent…) z listy widgetu i z drzewka. Lista „skip" jest edytowalna niżej. Zaznaczone eventy są zawsze widoczne, nawet jeśli to szum.</div>
      </div>
    </div>

    <div class="ta-label" style="margin-top:12px">Co kopiować z każdego eventu</div>
    <div class="ta-opt ta-set-item" data-opt="tags">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">Tagi</div>
        <div class="ta-set-desc">Sekcja Tags eventu: tagi odpalone (z typem i statusem, np. „Fired 1 time") oraz nieodpalone, pogrupowane po statusie.</div>
      </div>
    </div>
    <div class="ta-opt ta-set-item" data-opt="vars">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">Zmienne</div>
        <div class="ta-set-desc">Wartości wszystkich zmiennych GTM w momencie eventu (zakładka Variables). Długie wartości są przycinane do 220 znaków.</div>
      </div>
    </div>
    <div class="ta-opt ta-set-item" data-opt="dl">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">DL — warstwa danych</div>
        <div class="ta-set-desc">Zrekonstruowany dataLayer.push() eventu z polami e-commerce (transaction_id, value, items…) z zakładki Data Layer.</div>
      </div>
    </div>
    <div class="ta-opt ta-set-item" data-opt="consent">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">Consent</div>
        <div class="ta-set-desc">Stany zgód (consent mode) w momencie eventu. Sekcja jest pomijana w outputcie, gdy zakładka Consent nie ma danych.</div>
      </div>
    </div>
    <div class="ta-opt ta-set-item" data-opt="skipUndefined">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">Pomiń undefined</div>
        <div class="ta-set-desc">Zmienne o wartości undefined nie trafiają do outputu — mniej śmieci i mniejszy koszt tokenów.</div>
      </div>
    </div>
    <div class="ta-opt ta-set-item" data-opt="varsDiff">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">Zmienne jako diff</div>
        <div class="ta-set-desc">Pierwszy event ma pełną listę zmiennych, każdy kolejny tylko to, co się zmieniło lub zniknęło względem poprzedniego — dużo mniejszy output przy serii eventów.</div>
      </div>
    </div>
    <div class="ta-opt ta-set-item" data-opt="dedup">
      <div class="ta-chk">✓</div>
      <div class="ta-set-txt">
        <div class="ta-set-name">Dedup po nazwie</div>
        <div class="ta-set-desc">Z zaznaczonych eventów kopiowany jest tylko pierwszy z każdej nazwy (np. z 3× view_item zostaje jeden). Pominięte duplikaty są wylistowane w nagłówku outputu, żeby agent wiedział o pełnej sekwencji.</div>
      </div>
    </div>
  </div>

  <!-- ═══ PRZYCISKI ═══ -->
  <div class="ta-copybar">
    <button class="ta-copy-btn" id="ta-copy-walk" title="Przechodzi po zaznaczonych eventach i kopiuje wybrane sekcje jako jeden markdown. Podczas pracy: kliknij, aby przerwać.">Kopiuj zaznaczone (0)</button>
    <button class="ta-eye-btn" id="ta-preview-walk" title="Podgląd zebranych danych przed skopiowaniem (z licznikiem znaków/tokenów)">👁 Podgląd</button>
  </div>

</div>
        `;
        document.body.appendChild(widget);

        /* ── Przywróć stan checkboxów opcji z localStorage ── */
        widget.querySelectorAll('.ta-opt').forEach(el => {
            el.classList.toggle('on', !!opts[el.dataset.opt]);
        });

        /* ── Collapse ── */
        const colBtn = document.getElementById('ta-col');
        colBtn.textContent = collapsed ? '▸' : '▾';
        colBtn.addEventListener('click', () => {
            collapsed = !collapsed;
            localStorage.setItem(LS_COL, collapsed ? '1' : '0');
            updateExpandDirection();
            document.getElementById('ta-wrap').classList.toggle('open', !collapsed);
            document.getElementById('ta-wrap').classList.toggle('closed', collapsed);
            colBtn.textContent = collapsed ? '▸' : '▾';
        });

        /* ── Ustawienia (zębatka) — przełącza widok panelu ── */
        let settingsOpen = false;
        const setBtn = document.getElementById('ta-settings-btn');
        function toggleSettings(force) {
            settingsOpen = typeof force === 'boolean' ? force : !settingsOpen;
            document.getElementById('ta-settings').style.display = settingsOpen ? 'block' : 'none';
            document.getElementById('ta-evt-panel').style.display = settingsOpen ? 'none' : 'block';
            setBtn.style.opacity = settingsOpen ? '1' : '';
            setBtn.style.color = settingsOpen ? '#60a5fa' : '';
            // Rozwiń widget, jeśli był zwinięty
            if (settingsOpen && collapsed) colBtn.click();
        }
        setBtn.addEventListener('click', () => toggleSettings());
        document.getElementById('ta-settings-back').addEventListener('click', () => toggleSettings(false));

        /* ── Presety (można łączyć) — ZAZNACZAJĄ pasujące eventy tickiem ── */
        widget.querySelectorAll('.ta-preset').forEach(btn => {
            btn.addEventListener('click', () => {
                const p = btn.dataset.preset;
                activePresets.has(p) ? activePresets.delete(p) : activePresets.add(p);
                btn.classList.toggle('on', activePresets.has(p));
                invalidateOutputCache();
                renderInstanceList();
                applySidebarFilter();
            });
        });

        /* ── Search ── */
        document.getElementById('ta-evt-search').addEventListener('input', e => {
            compileEventSearch(e.target.value);
            updateSearchIndicator();
            renderInstanceList();
            applySidebarFilter();
        });
        document.getElementById('ta-evt-search-mode').addEventListener('click', () => {
            if (!eventSearch) return;
            document.getElementById('ta-evt-search').value = '';
            compileEventSearch('');
            updateSearchIndicator();
            renderInstanceList();
            applySidebarFilter();
            document.getElementById('ta-evt-search').focus();
        });

        /* ── Opcje (z zapisem do localStorage) ── */
        widget.querySelectorAll('.ta-opt').forEach(optEl => {
            optEl.addEventListener('click', () => {
                const key = optEl.dataset.opt;
                opts[key] = !opts[key];
                optEl.classList.toggle('on', opts[key]);
                saveOpts();
                if (key === 'filterTree' || key === 'hideNoise') {
                    // Opcje widoku — nie unieważniają zebranego outputu
                    renderInstanceList();
                    applySidebarFilter();
                } else {
                    invalidateOutputCache();
                }
            });
        });

        /* ── Kopiuj / Podgląd ── */
        const copyBtn = document.getElementById('ta-copy-walk');
        copyBtn.addEventListener('click', () => runCopyWalk(copyBtn));
        document.getElementById('ta-preview-walk').addEventListener('click', e => {
            runPreviewWalk(e.currentTarget, copyBtn);
        });

        makeDraggable(widget, document.getElementById('ta-hdr'));
        restorePos();
    }

    function updateSearchIndicator() {
        const input = document.getElementById('ta-evt-search');
        const badge = document.getElementById('ta-evt-search-mode');
        const hint = document.getElementById('ta-evt-search-hint');
        if (!input || !badge || !hint) return;
        input.classList.remove('ta-input--regex', 'ta-input--error');
        badge.className = 'ta-search-mode';
        hint.style.display = 'none';
        if (!eventSearch) { badge.textContent = 'TEXT'; return; }
        if (eventSearchError) {
            input.classList.add('ta-input--regex', 'ta-input--error');
            badge.classList.add('ta-search-mode--error');
            badge.textContent = '✕';
            hint.style.display = 'block'; hint.className = 'ta-search-error'; hint.textContent = eventSearchError;
        } else if (eventSearchRegex) {
            input.classList.add('ta-input--regex');
            badge.classList.add('ta-search-mode--regex');
            badge.textContent = '✕';
            hint.style.display = 'block'; hint.className = 'ta-search-hint';
            hint.textContent = 'Tryb regex · użyj | jako OR, np. purchase|add_to_cart';
        } else {
            badge.classList.add('ta-search-mode--clear');
            badge.textContent = '✕';
        }
    }

    function renderInstanceList() {
        const list = document.getElementById('ta-instance-list');
        if (!list) return;
        list.innerHTML = '';
        let shown = 0;
        const eff = effectiveSelectedKeys();
        renderedOrder = [];

        instanceGroups.forEach(group => {
            const visibleRows = group.rows.filter(r => listVisible(r, eff));
            if (!visibleRows.length) return;

            const selCount = visibleRows.filter(r => eff.has(r.key)).length;
            const allSel = selCount === visibleRows.length;
            const ghdr = document.createElement('div');
            ghdr.className = 'ta-ghdr' + (allSel ? ' on' : '');
            ghdr.title = (group.url || group.title || '') + ' · klik: zaznacz/odznacz wszystkie eventy tej strony';
            ghdr.innerHTML = `<div class="ta-chk">✓</div><span class="ta-ghdr-name">📄 ${escHtml(group.title || '(strona)')}</span><span class="ta-ghdr-cnt">${selCount}/${visibleRows.length}</span>`;
            ghdr.addEventListener('click', () => {
                const effNow = effectiveSelectedKeys();
                const all = visibleRows.every(r => effNow.has(r.key));
                visibleRows.forEach(r => {
                    if (all) { manualOff.add(r.key); manualOn.delete(r.key); }
                    else { manualOn.add(r.key); manualOff.delete(r.key); }
                });
                invalidateOutputCache();
                renderInstanceList();
                applySidebarFilter();
            });
            list.appendChild(ghdr);

            visibleRows.forEach(r => {
                shown++;
                renderedOrder.push(r);
                const row = document.createElement('div');
                row.className = 'ta-frow' + (eff.has(r.key) ? ' on' : '');
                row.innerHTML = `<div class="ta-chk">✓</div><span class="ta-num">${escHtml(r.num)}</span><div class="ta-dot" style="background:${ec(r.name)}"></div><span class="ta-fname" title="${escHtml(r.name)} · Shift+klik: zakres · Alt+klik: otwórz event w Tag Assistant">${escHtml(r.name)}</span>`;
                row.addEventListener('click', (e) => {
                    // Alt+klik: otwórz event w Tag Assistant (bez zmiany zaznaczenia)
                    if (e.altKey) {
                        if (r.el && document.contains(r.el)) {
                            r.el.scrollIntoView({ block: 'center' });
                            r.el.click();
                        }
                        return;
                    }
                    const turningOn = !effectiveSelectedKeys().has(r.key);
                    let applyTo = [r];
                    // Shift+klik: zakres od ostatnio klikniętego wiersza (w kolejności listy)
                    if (e.shiftKey && lastClickedKey) {
                        const i1 = renderedOrder.findIndex(x => x.key === lastClickedKey);
                        const i2 = renderedOrder.findIndex(x => x.key === r.key);
                        if (i1 !== -1 && i2 !== -1 && i1 !== i2) {
                            const [a, b] = i1 < i2 ? [i1, i2] : [i2, i1];
                            applyTo = renderedOrder.slice(a, b + 1);
                        }
                    }
                    applyTo.forEach(x => {
                        if (turningOn) { manualOn.add(x.key); manualOff.delete(x.key); }
                        else { manualOff.add(x.key); manualOn.delete(x.key); }
                    });
                    lastClickedKey = r.key;
                    invalidateOutputCache();
                    if (applyTo.length === 1) {
                        // Pojedynczy klik: bez pełnego re-renderu (nie skacze scroll)
                        row.classList.toggle('on', effectiveSelectedKeys().has(r.key));
                        updateCopyBtn();
                    } else {
                        renderInstanceList();
                    }
                    applySidebarFilter();
                });
                list.appendChild(row);
            });
        });

        if (!shown) {
            list.innerHTML = '<div class="ta-empty">Brak eventów do wyświetlenia</div>';
        }
        updateCopyBtn();
        updatePresetCounts();
    }

    /** Liczniki na chipach presetów: ile eventów pasuje do każdego (przy aktualnych danych) */
    function updatePresetCounts() {
        if (!widget) return;
        widget.querySelectorAll('.ta-preset').forEach(btn => {
            const p = btn.dataset.preset;
            const n = presetKeySet(p).size;
            const cnt = btn.querySelector('.ta-preset-cnt');
            if (cnt) cnt.textContent = `(${n})`;
            btn.classList.toggle('ta-preset--empty', n === 0 && !activePresets.has(p));
        });
    }

    function updateCopyBtn() {
        const btn = document.getElementById('ta-copy-walk');
        if (!btn || walking) return;
        btn.textContent = `Kopiuj zaznaczone (${effectiveSelectedKeys().size})`;
    }

    /* ═══════════════════ EXPAND DIRECTION ═══════════════════ */
    function updateExpandDirection() {
        if (!widget) return;
        const rect = widget.getBoundingClientRect();
        const hdrH = document.getElementById('ta-hdr').offsetHeight;
        const hdrMid = rect.top + hdrH / 2;
        const viewMid = window.innerHeight / 2;
        if (hdrMid > viewMid) {
            widget.style.bottom = (window.innerHeight - rect.bottom) + 'px';
            widget.style.top = 'auto';
        } else {
            widget.style.top = rect.top + 'px';
            widget.style.bottom = 'auto';
        }
    }

    /* ═══════════════════ DRAG ═══════════════════ */
    function makeDraggable(el, handle) {
        let sx, sy, sl, st;
        handle.addEventListener('mousedown', e => {
            if (e.target.closest('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();
                savePos();
            };
            document.addEventListener('mousemove', mv);
            document.addEventListener('mouseup', up);
        });
    }

    /* ═══════════════════ POLL ═══════════════════ */
    function poll() {
        if (walking) return; // nie skanuj podczas walkera — klikanie zmienia DOM

        const groups = scanInstances();
        const key = groups.map(g => g.title + '::' + g.rows.map(r => r.key).join(',')).join(';');
        const total = groups.reduce((a, g) => a + g.rows.length, 0);

        const badge = document.getElementById('ta-badge');
        if (badge) badge.textContent = total ? `${total} evt` : '—';

        // Zbieraj nazwy tagów do listy "Dotyczy tagów" (gdy zakładka Tags jest akurat otwarta)
        if (getActiveGtmTab() === 'tags') {
            getTagCards().forEach(card => { const n = nameFromCard(card); if (n) knownTagNames.add(n); });
        }

        if (key !== lastInstanceKey) {
            lastInstanceKey = key;
            instanceGroups = groups;
            // Usuń zaznaczenia eventów, których już nie ma
            const validKeys = new Set(allRowsFlat().map(r => r.key));
            [...manualOn].forEach(k => { if (!validKeys.has(k)) manualOn.delete(k); });
            [...manualOff].forEach(k => { if (!validKeys.has(k)) manualOff.delete(k); });
            renderInstanceList();
        } else {
            // Odśwież referencje do elementów DOM (Angular może przerenderować)
            instanceGroups = groups;
        }

        applySidebarFilter();
    }

    /* ═══════════════════ OBSERVER ═══════════════════ */
    let contentObserver = null;

    function watchContent() {
        if (contentObserver) contentObserver.disconnect();
        const target = document.querySelector('.message-list__groups, main, #content');
        if (!target) return;
        contentObserver = new MutationObserver(() => {
            if (walking) return;
            requestAnimationFrame(() => { poll(); });
        });
        contentObserver.observe(target, { childList: true, subtree: true });
    }

    /* ═══════════════════ INIT ═══════════════════ */
    function init() {
        loadOpts();
        buildWidget();
        updateExpandDirection();
        poll();
        watchContent();
        setInterval(() => { if (!walking) { poll(); watchContent(); } }, POLL_MS);

        let lastUrl = location.href;
        new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                setTimeout(() => { poll(); watchContent(); }, 600);
            }
        }).observe(document.body, { childList: true, subtree: true });
    }

    if (document.readyState === 'complete') setTimeout(init, 2000);
    else window.addEventListener('load', () => setTimeout(init, 2000));
})();

Dodaj komentarz

Napisz do mnie

Paweł Piekarski
Paweł Piekarski PhD, Analityk eCommerce 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.