PageSourceSearch

https://probiwaem.ru/static/shorts/feed.js?v=0.31.27

js probiwaem.ru collected 2026-10-02 02:07:51 UTC 66,286 bytes, 1,325 lines download raw bytes

1/* ============================================================================
2 * shorts/feed.js v0.25.4 — TikTok/Reels-grade feed entry point.
3 *
4 * Architecture:
5 *   1. Bootstrap из <script id="pb-shorts-bootstrap" type="application/json">.
6 *   2. Mount Embla Carousel vertical на #pbShortsViewport.
7 *   3. Virtualize: render PbShort instance current ± 2; destroy за окном.
8 *   4. Pre-fetch следующий batch когда user в ≤3 items от конца.
9 *   5. Hint-overlay first-visit (localStorage).
10 *   6. Right-side action stack:
11 *      - 👍 Like (localStorage state + animated heart burst on double-tap)
12 *      - 👎 Dislike
13 *      - ↗ Share (Web Share API + share_card.js)
14 *      - 🔖 Save (localStorage bookmarks)
15 *      - 📋 Полный отчёт → /p/{shortId}
16 *      - Marketplace «avatar» → /?mp=...
17 *   7. Double-tap stage = like + animated burst.
18 *   8. Long-press = pause; quick-tap = pause/resume; explicit ⏸ button в top-nav.
19 *   9. Keyboard nav: ↑/↓ = scroll prev/next; Esc = close; PgUp/PgDn = 5x jump.
20 *  10. Mouse-tracking 3D parallax (desktop only, fine pointer).
21 *  11. Auto-hide top progress 1500ms после settle.
22 *  12. Yandex.Metrika events on каждый action.
23 * ========================================================================== */
24(function () {
25    "use strict";
26
27    var $ = function (id) { return document.getElementById(id); };
28
29    function readBootstrap() {
30        var node = document.getElementById("pb-shorts-bootstrap");
31        if (!node) return { items: [], next_cursor: null, verdict_counts: {} };
32        try { return JSON.parse(node.textContent || "{}"); }
33        catch (e) { console.warn("[shorts] bootstrap parse failed", e); return { items: [], next_cursor: null }; }
34    }
35
36    /* v0.25.17: trackGoal раньше делал sync fetch POST на каждый Embla
37       select+settle event = 2 network calls per swipe blocking swipe-settle paint.
38       Now: Yandex.Metrika reachGoal остаётся inline (он сам буферизует), а fetch
39       deferred через setTimeout(0) — UI responsive, network call happens off
40       critical path. sendBeacon prefer'им если доступен (browser-managed,
41       0 main-thread cost). */
42    function trackGoal(name, props) {
43        try {
44            if (window.ym && window.METRIKA_ID) {
45                window.ym(window.METRIKA_ID, "reachGoal", name, props || {});
46            }
47        } catch (_) {}
48        // Defer the server POST off the critical path
49        setTimeout(function () {
50            try {
51                var body = JSON.stringify(Object.assign({ type: name, ts: Date.now() }, props || {}));
52                if (navigator.sendBeacon) {
53                    var blob = new Blob([body], { type: "application/json" });
54                    navigator.sendBeacon("/api/shorts/event", blob);
55                } else if (window.fetch) {
56                    window.fetch("/api/shorts/event", {
57                        method: "POST",
58                        headers: { "Content-Type": "application/json" },
59                        body: body,
60                        keepalive: true,
61                    }).catch(function () {});
62                }
63            } catch (_) {}
64        }, 0);
65    }
66
67    /* ── localStorage state ─────────────────────────────────────────────── */
68    function getList(key) {
69        try {
70            var raw = window.localStorage.getItem(key);
71            if (!raw) return [];
72            var arr = JSON.parse(raw);
73            return Array.isArray(arr) ? arr.filter(function (n) { return Number.isInteger(n); }) : [];
74        } catch (_) { return []; }
75    }
76    function setList(key, arr, cap) {
77        try {
78            arr = arr.slice(-(cap || 500));
79            window.localStorage.setItem(key, JSON.stringify(arr));
80        } catch (_) {}
81    }
82    function inList(key, id) { return getList(key).indexOf(id) !== -1; }
83    function addToList(key, id, cap) {
84        var arr = getList(key);
85        if (arr.indexOf(id) !== -1) return;
86        arr.push(id);
87        setList(key, arr, cap);
88    }
89    function removeFromList(key, id) {
90        var arr = getList(key);
91        var idx = arr.indexOf(id);
92        if (idx === -1) return;
93        arr.splice(idx, 1);
94        setList(key, arr);
95    }
96
97    var SEEN_KEY = "pb_seen_short_ids";
98    var LIKED_KEY = "pb_liked_short_ids";
99    var SAVED_KEY = "pb_saved_short_ids";
100    var DISLIKED_KEY = "pb_disliked_short_ids"; // v0.25.11
101
102    /* v0.25.11: server-sync для логиненных юзеров. Anon → 401 silent fail (UI
103       остаётся optimistic из localStorage). Logged → POST идёт, response с
104       likes_count обновляет counter. Используем keepalive для надёжности. */
105    function postLikeRemote(aid, liked) {
106        try {
107            return window.fetch("/api/shorts/" + aid + "/like", {
108                method: "POST",
109                headers: { "Content-Type": "application/json" },
110                body: JSON.stringify({ liked: !!liked }),
111                credentials: "same-origin",
112                keepalive: true,
113            }).then(function (r) {
114                if (!r.ok) return null;
115                return r.json().catch(function () { return null; });
116            }).catch(function () { return null; });
117        } catch (_) { return Promise.resolve(null); }
118    }
119    function postSaveRemote(aid, saved) {
120        try {
121            return window.fetch("/api/shorts/" + aid + "/save", {
122                method: "POST",
123                headers: { "Content-Type": "application/json" },
124                body: JSON.stringify({ saved: !!saved }),
125                credentials: "same-origin",
126                keepalive: true,
127            }).catch(function () {});
128        } catch (_) {}
129    }
130
131    function isOnboarded() {
132        try { return window.localStorage.getItem("pb_shorts_onboarded") === "1"; }
133        catch (_) { return false; }
134    }
135    function markOnboarded() {
136        try { window.localStorage.setItem("pb_shorts_onboarded", "1"); }
137        catch (_) {}
138    }
139
140    /* ── Low-end device detection ───────────────────────────────────────── */
141    function isLowEnd() {
142        try {
143            var mem = navigator.deviceMemory;
144            var cores = navigator.hardwareConcurrency;
145            var conn = navigator.connection && navigator.connection.effectiveType;
146            return (typeof mem === "number" && mem <= 2) ||
147                   (typeof cores === "number" && cores < 4) ||
148                   (typeof conn === "string" && /^(slow-2g|2g|3g)$/.test(conn));
149        } catch (_) { return false; }
150    }
151    var PB_LOW_END = isLowEnd();
152
153    /* ── Coarse pointer (touch) detection ───────────────────────────────── */
154    function isCoarsePointer() {
155        try {
156            return window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
157        } catch (_) { return false; }
158    }
159    function prefersReducedMotion() {
160        try {
161            return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
162        } catch (_) { return false; }
163    }
164    var PB_COARSE = isCoarsePointer();
165    var PB_REDUCED = prefersReducedMotion();
166
167    /* ── State ──────────────────────────────────────────────────────────── */
168    var state = {
169        items: [],
170        cursor: null,
171        loading: false,
172        exhausted: false,
173        currentIdx: 0,
174        shorts: {},
175        embla: null,
176        muted: true,
177        userPaused: false,
178        sessionShortsViewed: 0,
179        sessionStarted: Date.now(),
180        viewedTimers: {},
181        autoHideTimer: null,
182    };
183
184    /* ── DOM ────────────────────────────────────────────────────────────── */
185    var trackEl = null, viewportEl = null, progressEl = null;
186    var hintEl = null, muteBtn = null, pauseBtn = null, topnavEl = null;
187
188    /* ── Helpers ────────────────────────────────────────────────────────── */
189    function mpLabel(mp) {
190        var labels = {
191            wildberries: "Wildberries", wb: "Wildberries",
192            ozon: "Ozon",
193            yandex_market: "Я.Маркет", ym: "Я.Маркет",
194            megamarket: "Мегамаркет", mm: "Мегамаркет", sbermegamarket: "Мегамаркет",
195        };
196        var key = (mp || "").toLowerCase();
197        return labels[key] || (key ? key.charAt(0).toUpperCase() + key.slice(1) : "Маркетплейс");
198    }
199    function mpAvatar(mp) {
200        // v0.25.69: полные названия (как везде); CSS делает pill compact.
201        var k = (mp || "").toLowerCase();
202        if (k === "wildberries" || k === "wb") return "Wildberries";
203        if (k === "ozon") return "Ozon";
204        if (k === "yandex_market" || k === "ym") return "Я.Маркет";
205        if (k === "megamarket" || k === "mm" || k === "sbermegamarket") return "Мегамаркет";
206        return "—";
207    }
208    function verdictLine(verdict, suspPct) {
209        var sp = (typeof suspPct === "number") ? Math.round(suspPct) : 0;
210        if (verdict === "avoid") return "🛑 Лучше не брать · " + sp + "% накрутка";
211        if (verdict === "caution") return "⚠ С осторожностью · " + sp + "% накрутка";
212        if (verdict === "trusted") return "✓ Можно брать · " + sp + "% подозр.";
213        return "· Анализ";
214    }
215    /* v0.25.11: BIG verdict badge — большие % в круглом badge + verdict text рядом.
216       Юзер: «проценты сделать явно виднее, а то иначе не видно их». */
217    function renderVerdictBadge(verdict, suspPct) {
218        var sp = (typeof suspPct === "number") ? Math.round(suspPct) : 0;
219        var icon = verdict === "avoid" ? "🛑" : verdict === "caution" ? "⚠" : verdict === "trusted" ? "✓" : "·";
220        var text = verdict === "avoid" ? "Лучше не брать"
221                 : verdict === "caution" ? "С осторожностью"
222                 : verdict === "trusted" ? "Можно брать"
223                 : "Анализ";
224        return '<div class="pb-short-caption-verdict-wrapper">' +
225                 '<div class="pb-short-caption-verdict-badge" aria-label="Вердикт ' + sp + '%">' +
226                   '<span class="pb-short-caption-pct">' + sp + '<span class="pb-short-caption-pct-unit">%</span></span>' +
227                 '</div>' +
228                 '<div class="pb-short-caption-verdict-text">' +
229                   '<span class="pb-short-caption-verdict-icon">' + icon + '</span>' +
230                   escapeHtml(text) +
231                 '</div>' +
232               '</div>';
233    }
234    /* v0.31: цена из result_json.product — для факт-строки в caption */
235    function productPrice(item) {
236        try {
237            var rj = item.result_json || {};
238            var prod = rj.product || {};
239            var p = prod.price_rub || prod.price || rj.price_rub || 0;
240            p = parseFloat(p);
241            return (isFinite(p) && p > 0) ? p : 0;
242        } catch (_) { return 0; }
243    }
244    function fmtPrice(p) {
245        try { return Math.round(p).toLocaleString("ru-RU") + " ₽"; }
246        catch (_) { return Math.round(p) + " ₽"; }
247    }
248    function fmtCount(n) {
249        if (typeof n !== "number" || n <= 0) return "·";
250        if (n < 1000) return String(n);
251        if (n < 10000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K";
252        if (n < 1000000) return Math.round(n / 1000) + "K";
253        return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
254    }
255
256    /* ── Article DOM builder (TikTok-style layout) ──────────────────────── */
257    function buildArticleEl(item, isFirst) {
258        var article = document.createElement("article");
259        article.className = "pb-short";
260        article.setAttribute("data-aid", String(item.id));
261        article.setAttribute("data-short-id", item.short_id);
262        article.setAttribute("data-verdict", item.verdict || "unknown");
263        article.setAttribute("data-marketplace", item.marketplace || "");
264        if (isFirst) article.setAttribute("data-initial", "1");
265        // v0.25.25: Instagram-style blurred backdrop — set CSS variable с image url.
266        // CSS .pb-short-stage::before рендерит этот image как backdrop (blur + dim).
267        if (item.image_url) {
268            article.style.setProperty("--pb-short-bg", "url('" + item.image_url.replace(/'/g, "\\'") + "')");
269        }
270
271        // 1. Stage (full-bleed Investigation mount-point)
272        var stage = document.createElement("div");
273        stage.className = "pb-short-stage";
274        stage.setAttribute("aria-label", "Расследование товара");
275        // v0.25.23: pre-show product image — мгновенная видимость при свайпе.
276        // Investigation overlay поверх когда mountится; preview fade-out.
277        if (item.image_url) {
278            var preview = document.createElement("img");
279            preview.className = "pb-short-preview-img";
280            preview.src = item.image_url;
281            preview.alt = "";
282            preview.loading = "lazy";
283            preview.decoding = "async";
284            preview.referrerPolicy = "no-referrer";
285            preview.onerror = function () { this.style.display = "none"; };
286            stage.appendChild(preview);
287        }
288        article.appendChild(stage);
289
290        // v0.31: верхняя вердикт-плашка словами + маркетплейс (слой 1)
291        var topbar = document.createElement("div");
292        topbar.className = "pb-short-topbar";
293        topbar.setAttribute("aria-hidden", "true");
294        var vIcon = item.verdict === "avoid" ? "🛑" : item.verdict === "caution" ? "⚠" : item.verdict === "trusted" ? "✓" : "·";
295        var vText = item.verdict === "avoid" ? "Лучше не брать"
296                  : item.verdict === "caution" ? "С осторожностью"
297                  : item.verdict === "trusted" ? "Можно брать" : "Анализ";
298        topbar.innerHTML =
299            '<span class="pb-short-topbar-verdict">' + vIcon + " " + escapeHtml(vText) + '</span>' +
300            '<span class="pb-short-topbar-mp">' + escapeHtml(mpLabel(item.marketplace)) + '</span>';
301        article.appendChild(topbar);
302
303        // 2. Bottom-left caption (mp + name + verdict)
304        var caption = document.createElement("div");
305        caption.className = "pb-short-caption";
306        // v0.31: name + цена/отзывы + CTA «Полный разбор» (verdict-badge скрыт CSS'ом v0.25.18)
307        var _price = productPrice(item);
308        caption.innerHTML =
309            '<div class="pb-short-caption-mp">' + escapeHtml(mpLabel(item.marketplace).toLowerCase()) + '</div>' +
310            '<div class="pb-short-caption-name">' + escapeHtml(item.name || "Товар") + '</div>' +
311            '<div class="pb-short-caption-facts">' +
312              (_price ? '<span class="pb-short-caption-price">' + fmtPrice(_price) + '</span>' : '') +
313              (item.reviews_count ? '<span class="pb-short-caption-reviews">' + fmtCount(item.reviews_count) + ' отзывов проверено</span>' : '') +
314            '</div>' +
315            renderVerdictBadge(item.verdict, item.suspicion_pct) +
316            '<a class="pb-short-caption-cta" data-act="report" href="/p/' + escapeHtml(item.short_id) + '">Полный разбор →</a>';
317        article.appendChild(caption);
318
319        // 3. Right-side action stack (TikTok pattern)
320        var actions = document.createElement("div");
321        actions.className = "pb-short-actions-stack";
322        actions.setAttribute("aria-label", "Действия");
323        // v0.25.39: data-embla-no-drag — Embla skipит touch interception на этом
324        // элементе. Раньше на slow tap или micro-movement Embla detected drag →
325        // preventDefault на touchmove → button click blocked. Юзер: «иногда
326        // тупо лайки не ставятся».
327        actions.setAttribute("data-embla-no-drag", "");
328
329        // Like
330        var likeBtn = document.createElement("button");
331        likeBtn.type = "button";
332        likeBtn.className = "pb-short-act-btn";
333        likeBtn.setAttribute("data-act", "like");
334        likeBtn.setAttribute("aria-label", "Нравится");
335        likeBtn.innerHTML =
336            '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' +
337            '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>' +
338            '</svg></span>' +
339            '<span class="pb-short-act-label" data-counter="likes">' + fmtCount(item.likes_count || 0) + '</span>';
340        actions.appendChild(likeBtn);
341
342        // Dislike
343        var dislikeBtn = document.createElement("button");
344        dislikeBtn.type = "button";
345        dislikeBtn.className = "pb-short-act-btn";
346        dislikeBtn.setAttribute("data-act", "dislike");
347        dislikeBtn.setAttribute("aria-label", "Не нравится");
348        dislikeBtn.innerHTML =
349            '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' +
350            '<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/>' +
351            '</svg></span>' +
352            '<span class="pb-short-act-label">·</span>';
353        actions.appendChild(dislikeBtn);
354
355        // Share
356        var shareBtn = document.createElement("button");
357        shareBtn.type = "button";
358        shareBtn.className = "pb-short-act-btn";
359        shareBtn.setAttribute("data-act", "share");
360        shareBtn.setAttribute("aria-label", "Поделиться");
361        shareBtn.innerHTML =
362            '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' +
363            '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>' +
364            '<polyline points="16 6 12 2 8 6"/>' +
365            '<line x1="12" y1="2" x2="12" y2="15"/></svg></span>' +
366            '<span class="pb-short-act-label">Поделиться</span>';
367        actions.appendChild(shareBtn);
368
369        // Save
370        var saveBtn = document.createElement("button");
371        saveBtn.type = "button";
372        saveBtn.className = "pb-short-act-btn";
373        saveBtn.setAttribute("data-act", "save");
374        saveBtn.setAttribute("aria-label", "Сохранить");
375        saveBtn.innerHTML =
376            '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' +
377            '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>' +
378            '</svg></span>' +
379            '<span class="pb-short-act-label">Сохранить</span>';
380        actions.appendChild(saveBtn);
381
382        // Report
383        var reportBtn = document.createElement("a");
384        reportBtn.className = "pb-short-act-btn";
385        reportBtn.href = "/p/" + item.short_id;
386        reportBtn.setAttribute("data-act", "report");
387        reportBtn.setAttribute("aria-label", "Полный отчёт");
388        reportBtn.innerHTML =
389            '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' +
390            '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>' +
391            '<polyline points="14 2 14 8 20 8"/></svg></span>' +
392            '<span class="pb-short-act-label">Разбор</span>';
393        actions.appendChild(reportBtn);
394
395        // v0.25.23: «В магазин» — quick link на marketplace product page
396        if (item.product_url) {
397            var shopBtn = document.createElement("a");
398            shopBtn.className = "pb-short-act-btn";
399            shopBtn.href = item.product_url;
400            shopBtn.target = "_blank";
401            shopBtn.rel = "noopener noreferrer nofollow";
402            shopBtn.setAttribute("data-act", "shop");
403            shopBtn.setAttribute("aria-label", "Открыть в магазине");
404            shopBtn.innerHTML =
405                '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' +
406                '<path d="M6 2l-4 6h20L18 2H6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>' +
407                '<path d="M2 8v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>' +
408                '<path d="M8 12a4 4 0 0 0 8 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>' +
409                '<span class="pb-short-act-label">В магазин</span>';
410            shopBtn.addEventListener("click", function () {
411                trackGoal("short_shop_clicked", {
412                    aid: item.id, short_id: item.short_id, marketplace: item.marketplace
413                });
414            });
415            actions.appendChild(shopBtn);
416        }
417
418        // v0.25.11: единый wire-up handlers (используется и для SSR cards)
419        attachActionHandlers(article, item);
420
421        // Marketplace avatar
422        var mpAv = document.createElement("a");
423        mpAv.className = "pb-short-act-mp";
424        mpAv.href = "/?mp=" + encodeURIComponent(item.marketplace || "");
425        mpAv.setAttribute("aria-label", mpLabel(item.marketplace));
426        mpAv.textContent = mpAvatar(item.marketplace);
427        actions.appendChild(mpAv);
428
429        article.appendChild(actions);
430
431        // 4. Like burst (animated heart on double-tap)
432        var burst = document.createElement("div");
433        burst.className = "pb-short-like-burst";
434        burst.setAttribute("aria-hidden", "true");
435        burst.innerHTML =
436            '<svg viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
437        article.appendChild(burst);
438
439        // 5. Pause indicator (CSS-only floating overlay, shown when .is-user-paused)
440        var pauseInd = document.createElement("div");
441        pauseInd.className = "pb-short-pause-indicator";
442        pauseInd.setAttribute("aria-hidden", "true");
443        pauseInd.innerHTML =
444            '<svg viewBox="0 0 24 24" aria-hidden="true">' +
445            '<rect x="6" y="5" width="4" height="14" rx="1.2"/>' +
446            '<rect x="14" y="5" width="4" height="14" rx="1.2"/>' +
447            '</svg>';
448        article.appendChild(pauseInd);
449
450        return article;
451    }
452
453    function escapeHtml(s) {
454        return String(s == null ? "" : s)
455            .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
456            .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
457    }
458
459    /* ── v0.25.11: единый wire-up для action-rail (SSR + dynamic articles) ─
460       Раньше handlers навешивались inline в buildArticleEl, поэтому SSR-cards
461       (initial 2 в shorts.html) оставались "мёртвыми" — кнопки visible, но не
462       работали. Юзер: «в ленте кнопки не работают». Now: extract в helper +
463       вызывать и из buildArticleEl, и из wireSsrArticles в init(). */
464    function attachActionHandlers(article, item) {
465        if (!article) return;
466        if (article.getAttribute("data-acts-wired") === "1") return; // idempotent
467        article.setAttribute("data-acts-wired", "1");
468
469        var likeBtn    = article.querySelector('[data-act="like"]');
470        var dislikeBtn = article.querySelector('[data-act="dislike"]');
471        var shareBtn   = article.querySelector('[data-act="share"]');
472        var saveBtn    = article.querySelector('[data-act="save"]');
473        var reportBtn  = article.querySelector('[data-act="report"]');
474
475        if (likeBtn) {
476            if (inList(LIKED_KEY, item.id)) likeBtn.classList.add("is-liked");
477            likeBtn.addEventListener("click", function (e) {
478                e.stopPropagation();
479                toggleLike(article, item, likeBtn);
480            });
481        }
482        if (dislikeBtn) {
483            if (inList(DISLIKED_KEY, item.id)) dislikeBtn.classList.add("is-disliked");
484            dislikeBtn.addEventListener("click", function (e) {
485                e.stopPropagation();
486                toggleDislike(article, item, dislikeBtn);
487            });
488        }
489        if (shareBtn) {
490            shareBtn.addEventListener("click", function (e) {
491                e.stopPropagation();
492                openShare(item);
493            });
494        }
495        if (saveBtn) {
496            if (inList(SAVED_KEY, item.id)) saveBtn.classList.add("is-saved");
497            saveBtn.addEventListener("click", function (e) {
498                e.stopPropagation();
499                toggleSave(item, saveBtn);
500            });
501        }
502        if (reportBtn) {
503            // v0.25.11: на SSR это <a href="/p/...">. Click тогглит на полный
504            // отчёт, мы только трекаем событие.
505            reportBtn.addEventListener("click", function () {
506                trackGoal("short_full_report_clicked", {
507                    aid: item.id, short_id: item.short_id, verdict: item.verdict
508                });
509            });
510        }
511    }
512
513    /* ── Likes / saves / dislikes logic ─────────────────────────────────── */
514    function toggleLike(article, item, btn) {
515        var isLiked = btn.classList.toggle("is-liked");
516        if (isLiked) {
517            addToList(LIKED_KEY, item.id);
518            // v0.25.11: если ранее был dislike — сбрасываем (mutual exclusive)
519            if (inList(DISLIKED_KEY, item.id)) {
520                removeFromList(DISLIKED_KEY, item.id);
521                var dis = article && article.querySelector('[data-act="dislike"]');
522                if (dis) dis.classList.remove("is-disliked");
523            }
524            trackGoal("short_liked", { aid: item.id, short_id: item.short_id });
525            updateCounter(btn, "likes", 1);
526            fireBurst(article);
527            postLikeRemote(item.id, true).then(function (r) {
528                // Если backend вернул реальный counter — sync UI
529                if (r && typeof r.likes_count === "number") {
530                    var label = btn.querySelector('[data-counter="likes"]');
531                    if (label) {
532                        label.setAttribute("data-num", String(r.likes_count));
533                        label.textContent = fmtCount(r.likes_count);
534                    }
535                }
536            });
537        } else {
538            removeFromList(LIKED_KEY, item.id);
539            trackGoal("short_unliked", { aid: item.id });
540            updateCounter(btn, "likes", -1);
541            postLikeRemote(item.id, false);
542        }
543    }
544    function toggleSave(item, btn) {
545        var saved = btn.classList.toggle("is-saved");
546        if (saved) {
547            addToList(SAVED_KEY, item.id);
548            trackGoal("short_saved", { aid: item.id, short_id: item.short_id });
549        } else {
550            removeFromList(SAVED_KEY, item.id);
551            trackGoal("short_unsaved", { aid: item.id });
552        }
553        postSaveRemote(item.id, saved);
554    }
555    /* v0.25.11: dislike-кнопка раньше тогглила .is-liked (copy-paste bug) и
556       не сохраняла состояние. Now: persists в localStorage DISLIKED_KEY,
557       отдельный класс .is-disliked, пишет signal на /api/shorts/event. */
558    function toggleDislike(article, item, btn) {
559        var isDisliked = btn.classList.toggle("is-disliked");
560        if (isDisliked) {
561            addToList(DISLIKED_KEY, item.id);
562            // mutual exclusive с like
563            if (inList(LIKED_KEY, item.id)) {
564                removeFromList(LIKED_KEY, item.id);
565                var lk = article && article.querySelector('[data-act="like"]');
566                if (lk) {
567                    lk.classList.remove("is-liked");
568                    updateCounter(lk, "likes", -1);
569                    postLikeRemote(item.id, false);
570                }
571            }
572            trackGoal("short_disliked", { aid: item.id, short_id: item.short_id });
573        } else {
574            removeFromList(DISLIKED_KEY, item.id);
575            trackGoal("short_undisliked", { aid: item.id });
576        }
577    }
578    function updateCounter(btn, kind, delta) {
579        try {
580            var label = btn.querySelector('[data-counter="' + kind + '"]');
581            if (!label) return;
582            var cur = parseInt(label.getAttribute("data-num") || "0", 10) || 0;
583            var next = Math.max(0, cur + delta);
584            label.setAttribute("data-num", String(next));
585            label.textContent = fmtCount(next);
586        } catch (_) {}
587    }
588    function fireBurst(article) {
589        if (!article) return;
590        var burst = article.querySelector(".pb-short-like-burst");
591        if (!burst) return;
592        var rect = article.getBoundingClientRect();
593        burst.style.left = (rect.width / 2) + "px";
594        burst.style.top = (rect.height / 2) + "px";
595        burst.classList.remove("is-firing");
596        void burst.offsetWidth;
597        burst.classList.add("is-firing");
598        setTimeout(function () { burst.classList.remove("is-firing"); }, 750);
599    }
600
601    /* ── Share logic ────────────────────────────────────────────────────── */
602    function openShare(item) {
603        trackGoal("short_share_open", { aid: item.id, short_id: item.short_id });
604        if (window.PbShareCard && typeof window.PbShareCard.openShareCardModal === "function") {
605            try {
606                var data = {
607                    url: "https://probiwaem.ru/shorts/" + item.short_id,
608                    product_name: item.name,
609                    marketplace: item.marketplace,
610                    verdict: item.verdict,
611                    suspicion_pct: item.suspicion_pct,
612                    reviews_count: item.reviews_count,
613                    image_url: item.image_url || "",
614                };
615                window.PbShareCard.openShareCardModal(data);
616                return;
617            } catch (e) { console.warn("[shorts] PbShareCard failed", e); }
618        }
619        if (navigator.share) {
620            navigator.share({
621                title: "Расследование probiwaem.ru",
622                text: item.name + " — " + verdictLine(item.verdict, item.suspicion_pct),
623                url: "https://probiwaem.ru/shorts/" + item.short_id,
624            }).catch(function () {});
625        } else {
626            try {
627                navigator.clipboard.writeText("https://probiwaem.ru/shorts/" + item.short_id);
628            } catch (_) {}
629        }
630    }
631
632    /* ── Mount/unmount Short instance ───────────────────────────────────── */
633    function mountShort(article) {
634        var aid = parseInt(article.getAttribute("data-aid"), 10);
635        if (!aid || state.shorts[aid]) return state.shorts[aid] || null;
636        var item = state.items.filter(function (i) { return i.id === aid; })[0];
637        if (!item) return null;
638        if (typeof window.PbShort !== "function") return null;
639        var short = new window.PbShort(article, item, {
640            mute: state.muted,
641            progressEl: progressEl,
642            onComplete: function () {
643                trackGoal("short_completed", {
644                    aid: aid, short_id: item.short_id, verdict: item.verdict,
645                });
646            },
647        });
648        state.shorts[aid] = short;
649        return short;
650    }
651    function destroyShort(aid) {
652        var short = state.shorts[aid];
653        if (!short) return;
654        try { short.destroy(); } catch (_) {}
655        delete state.shorts[aid];
656    }
657
658    /* ── Embla setup + virtualization ────────────────────────────────────── */
659    function initEmbla() {
660        var EmblaCtor = window.EmblaCarousel || window.EmblaCarouselUMD;
661        if (typeof EmblaCtor !== "function") return null;
662        var embla = EmblaCtor(viewportEl, {
663            axis: "y",
664            loop: false,
665            dragFree: false,
666            containScroll: "trimSnaps",
667            skipSnaps: false,
668            duration: PB_LOW_END ? 18 : 25,
669            // v0.25.41: watchDrag function — Embla 8.x НЕ поддерживает
670            // data-embla-no-drag атрибут (мой v0.25.39 не работал!).
671            // Эта функция возвращает false → Embla skipит drag handling
672            // на action-rail buttons + topnav. Юзер: «лайк не срабатывает».
673            watchDrag: function (_embla, evt) {
674                var t = evt.target;
675                if (!t || !t.closest) return true;
676                if (t.closest("[data-act], .pb-short-actions-stack, .pb-short-act-btn, .pb-short-act-mp, .pb-shorts-topnav, [data-embla-no-drag]")) {
677                    return false; // не считать за drag → click fires нормально
678                }
679                return true; // обычный stage — Embla обрабатывает свайп
680            },
681        });
682        embla.on("select", onEmblaSelect);
683        embla.on("settle", onEmblaSettle);
684        return embla;
685    }
686    function onEmblaSelect() {
687        if (!state.embla) return;
688        var idx = state.embla.selectedScrollSnap();
689        var prev = state.currentIdx;
690        if (idx === prev) return;
691        state.currentIdx = idx;
692        // On scroll: clear user-paused state on previous short, reset top-nav vis
693        clearUserPausedAll();
694        showTopnav();
695        if (idx > prev) trackGoal("short_swipe_next", { from_idx: prev, to_idx: idx });
696        else trackGoal("short_swipe_prev", { from_idx: prev, to_idx: idx });
697    }
698    function onEmblaSettle() {
699        if (!state.embla) return;
700        var idx = state.embla.selectedScrollSnap();
701        // v0.25.15: memoize querySelectorAll — раньше virtualize() + playCurrentShort()
702        // вызывали querySelectorAll(".pb-short") дважды на каждый settle = O(2n).
703        // Now: 1 query → передаём NodeList в обе функции.
704        var articles = trackEl.querySelectorAll(".pb-short");
705        // v0.25.15: defer mount/destroy через setTimeout(0) — Investigation._build()
706        // блокирует main thread на 80-150ms. Defer чтобы Embla settle-transition
707        // успел завершиться без jank, потом mount нового short.
708        setTimeout(function () { virtualize(idx, articles); }, 0);
709        playCurrentShort(idx, articles);
710        scheduleTopnavAutoHide();
711        if (state.items.length - idx <= 3 && !state.exhausted) loadNextBatch();
712        if (idx > 0 && !isOnboarded()) { markOnboarded(); hideHint(); }
713    }
714    function virtualize(activeIdx, articles) {
715        articles = articles || trackEl.querySelectorAll(".pb-short");
716        // v0.25.15: window ±2 → ±1 (3 mounted instead of 5). Cinematic CSS
717        // композит-layers + per-instance Investigation = expensive с 5 active.
718        // 3 = current + prev + next (snappy swipes без preload-storm).
719        for (var i = 0; i < articles.length; i++) {
720            var article = articles[i];
721            var aid = parseInt(article.getAttribute("data-aid"), 10);
722            if (Math.abs(i - activeIdx) <= 1) {
723                if (!state.shorts[aid]) mountShort(article);
724            } else {
725                if (state.shorts[aid]) {
726                    // v0.25.15: defer destroyShort через setTimeout — старый Investigation
727                    // close() heavy (kill GSAP timelines, remove listeners). Не блокируем main.
728                    (function (a) { setTimeout(function () { destroyShort(a); }, 0); })(aid);
729                }
730            }
731        }
732    }
733    function playCurrentShort(idx, articles) {
734        articles = articles || trackEl.querySelectorAll(".pb-short");
735        for (var i = 0; i < articles.length; i++) {
736            var article = articles[i];
737            var aid = parseInt(article.getAttribute("data-aid"), 10);
738            var sh = state.shorts[aid];
739            if (i === idx) {
740                article.classList.add("is-active");
741                if (sh) {
742                    if (sh.state === "ready" || sh.state === "idle") sh.play();
743                    else if (sh.state === "paused") sh.resume();
744                    fireViewedAfterDelay(aid, idx);
745                }
746            } else {
747                article.classList.remove("is-active");
748                if (sh && sh.state === "playing") sh.pause();
749            }
750        }
751        // Reflect playing state on pause-button
752        state.userPaused = false;
753        updatePauseBtnVisual();
754        document.body.classList.remove("is-shorts-paused");
755    }
756    function fireViewedAfterDelay(aid, idx) {
757        if (state.viewedTimers[aid]) clearTimeout(state.viewedTimers[aid]);
758        state.viewedTimers[aid] = setTimeout(function () {
759            state.sessionShortsViewed++;
760            addToList(SEEN_KEY, aid, 200);
761            var item = state.items.filter(function (i) { return i.id === aid; })[0];
762            trackGoal("short_viewed", {
763                aid: aid, short_id: item ? item.short_id : "",
764                verdict: item ? item.verdict : "",
765                marketplace: item ? item.marketplace : "",
766                idx_in_session: idx,
767            });
768            if (state.sessionShortsViewed === 5) trackGoal("feed_session_5plus", { count: 5 });
769        }, 1000);
770    }
771
772    function loadNextBatch() {
773        if (state.loading || state.exhausted) return;
774        state.loading = true;
775        var url = "/api/shorts/feed?limit=7";
776        if (state.cursor) url += "&cursor=" + encodeURIComponent(state.cursor);
777        var seen = getList(SEEN_KEY);
778        if (seen.length) url += "&seen=" + encodeURIComponent(seen.join(","));
779        fetch(url, { headers: { Accept: "application/json" } })
780            .then(function (r) { return r.ok ? r.json() : null; })
781            .then(function (data) {
782                state.loading = false;
783                if (!data || !Array.isArray(data.items) || data.items.length === 0) {
784                    state.exhausted = true; return;
785                }
786                var existing = {};
787                state.items.forEach(function (it) { existing[it.id] = true; });
788                var fresh = data.items.filter(function (it) { return !existing[it.id]; });
789                if (fresh.length === 0) { state.exhausted = !data.next_cursor; return; }
790                state.items = state.items.concat(fresh);
791                state.cursor = data.next_cursor;
792                if (!data.next_cursor) state.exhausted = true;
793                appendArticles(fresh);
794                if (state.embla) state.embla.reInit();
795            })
796            .catch(function (e) {
797                state.loading = false;
798                console.warn("[shorts] loadNextBatch failed", e);
799            });
800    }
801    function appendArticles(items) {
802        var frag = document.createDocumentFragment();
803        for (var i = 0; i < items.length; i++) {
804            frag.appendChild(buildArticleEl(items[i], false));
805        }
806        trackEl.appendChild(frag);
807    }
808
809    /* ── Hint ───────────────────────────────────────────────────────────── */
810    function showHintIfNeeded() {
811        if (isOnboarded() || !hintEl) return;
812        hintEl.removeAttribute("hidden");
813        setTimeout(hideHint, 5000);
814        hintEl.addEventListener("click", function () {
815            hideHint(); markOnboarded();
816        });
817    }
818    function hideHint() { if (hintEl) hintEl.setAttribute("hidden", ""); }
819
820    /* ── Mute toggle ────────────────────────────────────────────────────── */
821    function setupMuteBtn() {
822        if (!muteBtn) return;
823        updateMuteBtnVisual();
824        muteBtn.addEventListener("click", function () {
825            state.muted = !state.muted;
826            updateMuteBtnVisual();
827            trackGoal(state.muted ? "audio_muted" : "audio_unmuted", {});
828        });
829    }
830    function updateMuteBtnVisual() {
831        if (!muteBtn) return;
832        if (state.muted) muteBtn.classList.add("is-muted");
833        else muteBtn.classList.remove("is-muted");
834    }
835
836    /* ── Pause-button (explicit toggle in top-nav) ──────────────────────── */
837    function setupPauseBtn() {
838        if (!pauseBtn) return;
839        updatePauseBtnVisual();
840        pauseBtn.addEventListener("click", function (e) {
841            e.stopPropagation();
842            toggleCurrentPause();
843        });
844    }
845    function updatePauseBtnVisual() {
846        if (!pauseBtn) return;
847        if (state.userPaused) pauseBtn.classList.add("is-paused");
848        else pauseBtn.classList.remove("is-paused");
849        pauseBtn.setAttribute("aria-label", state.userPaused ? "Продолжить" : "Пауза");
850    }
851
852    /* ── Auto-hide top-nav after settle ─────────────────────────────────── */
853    function scheduleTopnavAutoHide() {
854        if (!topnavEl) return;
855        if (state.autoHideTimer) clearTimeout(state.autoHideTimer);
856        topnavEl.classList.remove("is-auto-hidden");
857        if (PB_REDUCED) return; // never auto-hide if reduced motion
858        state.autoHideTimer = setTimeout(function () {
859            if (!state.userPaused) topnavEl.classList.add("is-auto-hidden");
860        }, 1500);
861    }
862    function showTopnav() {
863        if (!topnavEl) return;
864        topnavEl.classList.remove("is-auto-hidden");
865    }
866
867    /* ── Tap-to-pause + double-tap-to-like ──────────────────────────────── */
868    var lastTapTs = 0;
869    function setupTapInteractions() {
870        if (!viewportEl) return;
871        var tapTimer = null;
872        var touchStartTs = 0;
873        viewportEl.addEventListener("pointerdown", function (e) {
874            // v0.25.37: faster early-bail для action-rail таппов через [data-act] +
875            // single closest call. Раньше 4 closest calls подряд могли тормозить
876            // на slow devices → timer стартовал до bail → race с button click.
877            // v0.25.39: ещё расширил — actions-stack целиком + data-embla-no-drag
878            if (e.target && e.target.closest) {
879                if (e.target.closest("[data-act], [data-embla-no-drag], .pb-short-actions-stack, .pb-short-act-btn, .pb-short-act-mp, .pb-shorts-topnav, .pb-shorts-hint, .pb-short-caption-verdict-wrapper")) {
880                    return;
881                }
882            }
883            touchStartTs = Date.now();
884            tapTimer = setTimeout(function () { pauseCurrent(true); }, 320);
885        });
886        viewportEl.addEventListener("pointerup", function (e) {
887            // v0.25.37/v0.25.39: early-bail при tapе на action-rail. Также clear
888            // pending pause-timer чтобы не triggerнул pause после button click.
889            if (e.target && e.target.closest) {
890                if (e.target.closest("[data-act], [data-embla-no-drag], .pb-short-actions-stack, .pb-short-act-btn, .pb-short-act-mp, .pb-shorts-topnav, .pb-shorts-hint, .pb-short-caption-verdict-wrapper")) {
891                    if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; }
892                    return;
893                }
894            }
895            if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; }
896            var dt = Date.now() - touchStartTs;
897            if (dt < 200) {
898                // v0.25.7: pause работает МГНОВЕННО (юзер: «на паузу сразу
899                // не ставится»). Раньше ждали 320ms на возможный double-tap
900                // и только потом toggle pause — это создавало lag.
901                // Now: single-tap → toggle pause INSTANT. Double-tap (если
902                // в течение 320ms ещё один tap) → undo pause + fire like burst.
903                var now = Date.now();
904                if (now - lastTapTs < 320) {
905                    // Double-tap: undo предыдущий pause (если был) + like burst
906                    lastTapTs = 0;
907                    // Если предыдущий single-tap поставил на паузу — undo это
908                    if (state.shorts[currentAid()] && state.shorts[currentAid()].state === "paused") {
909                        resumeCurrent();
910                    }
911                    handleDoubleTap();
912                } else {
913                    // Single-tap → toggle pause IMMEDIATELY (без задержки)
914                    lastTapTs = now;
915                    toggleCurrentPause();
916                }
917            } else if (dt >= 320) {
918                resumeCurrent();
919            }
920        });
921        viewportEl.addEventListener("pointercancel", function () {
922            if (tapTimer) clearTimeout(tapTimer); tapTimer = null;
923        });
924    }
925    function handleDoubleTap() {
926        var aid = currentAid();
927        if (!aid) return;
928        var article = currentArticle();
929        var item = state.items.filter(function (i) { return i.id === aid; })[0];
930        if (!item || !article) return;
931        var likeBtn = article.querySelector('.pb-short-act-btn[data-act="like"]');
932        if (!likeBtn) return;
933        if (!likeBtn.classList.contains("is-liked")) {
934            toggleLike(article, item, likeBtn);
935        } else {
936            fireBurst(article);
937        }
938    }
939    function pauseCurrent(longPress) {
940        var aid = currentAid(); if (!aid) return;
941        var sh = state.shorts[aid];
942        if (sh && sh.state === "playing") {
943            sh.pause();
944            state.userPaused = true;
945            if (typeof sh.setUserPaused === "function") sh.setUserPaused(true);
946            document.body.classList.add("is-shorts-paused");
947            updatePauseBtnVisual();
948            showTopnav();
949            trackGoal("short_paused", { aid: aid, source: longPress ? "long_press" : "tap" });
950        }
951    }
952    function resumeCurrent() {
953        var aid = currentAid(); if (!aid) return;
954        var sh = state.shorts[aid];
955        if (sh && sh.state === "paused") {
956            sh.resume();
957            state.userPaused = false;
958            if (typeof sh.setUserPaused === "function") sh.setUserPaused(false);
959            document.body.classList.remove("is-shorts-paused");
960            updatePauseBtnVisual();
961            scheduleTopnavAutoHide();
962            trackGoal("short_resumed", { aid: aid });
963        }
964    }
965    function toggleCurrentPause() {
966        var aid = currentAid(); if (!aid) return;
967        var sh = state.shorts[aid]; if (!sh) return;
968        if (sh.state === "playing") {
969            sh.pause();
970            state.userPaused = true;
971            if (typeof sh.setUserPaused === "function") sh.setUserPaused(true);
972            document.body.classList.add("is-shorts-paused");
973            updatePauseBtnVisual();
974            showTopnav();
975            trackGoal("short_paused", { aid: aid, source: "explicit_btn" });
976        } else if (sh.state === "paused") {
977            sh.resume();
978            state.userPaused = false;
979            if (typeof sh.setUserPaused === "function") sh.setUserPaused(false);
980            document.body.classList.remove("is-shorts-paused");
981            updatePauseBtnVisual();
982            scheduleTopnavAutoHide();
983            trackGoal("short_resumed", { aid: aid, source: "explicit_btn" });
984        }
985    }
986    function clearUserPausedAll() {
987        var articles = trackEl ? trackEl.querySelectorAll(".pb-short.is-user-paused") : [];
988        for (var i = 0; i < articles.length; i++) {
989            articles[i].classList.remove("is-user-paused");
990        }
991    }
992    function currentArticle() {
993        var articles = trackEl.querySelectorAll(".pb-short");
994        return articles[state.currentIdx] || null;
995    }
996    function currentAid() {
997        var article = currentArticle();
998        if (!article) return null;
999        return parseInt(article.getAttribute("data-aid"), 10) || null;
1000    }
1001
1002    function setupVisibilityPause() {
1003        document.addEventListener("visibilitychange", function () {
1004            if (document.hidden) pauseCurrent(false); else resumeCurrent();
1005        });
1006    }
1007
1008    /* ── Keyboard navigation (↑/↓ scroll, Esc close, PgUp/PgDn 5x jump) ───── */
1009    function setupKeyboardNav() {
1010        document.addEventListener("keydown", function (e) {
1011            // Ignore when typing in inputs
1012            var t = e.target;
1013            if (t && t.tagName && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
1014            if (t && t.isContentEditable) return;
1015
1016            var key = e.key;
1017            if (!state.embla) return;
1018
1019            if (key === "ArrowDown" || key === "j") {
1020                e.preventDefault();
1021                state.embla.scrollNext();
1022                trackGoal("short_kbd_next", {});
1023            } else if (key === "ArrowUp" || key === "k") {
1024                e.preventDefault();
1025                state.embla.scrollPrev();
1026                trackGoal("short_kbd_prev", {});
1027            } else if (key === "Escape") {
1028                e.preventDefault();
1029                trackGoal("short_kbd_close", {});
1030                window.location.href = "/";
1031            } else if (key === "PageDown") {
1032                e.preventDefault();
1033                jumpBy(5);
1034                trackGoal("short_kbd_pgdn", {});
1035            } else if (key === "PageUp") {
1036                e.preventDefault();
1037                jumpBy(-5);
1038                trackGoal("short_kbd_pgup", {});
1039            } else if (key === " " || key === "Space" || key === "Spacebar") {
1040                e.preventDefault();
1041                toggleCurrentPause();
1042            } else if (key === "m" || key === "M") {
1043                if (muteBtn) muteBtn.click();
1044            }
1045        });
1046    }
1047    function jumpBy(delta) {
1048        if (!state.embla) return;
1049        var snaps = state.embla.scrollSnapList ? state.embla.scrollSnapList() : null;
1050        var len = snaps ? snaps.length : state.items.length;
1051        var target = Math.max(0, Math.min(len - 1, state.currentIdx + delta));
1052        if (typeof state.embla.scrollTo === "function") {
1053            state.embla.scrollTo(target, false);
1054        }
1055    }
1056
1057    /* ── v0.25.11: Desktop mouse wheel navigation ──────────────────────────
1058       Юзер: «скроллинг на ПК не работает через мышку». Embla не имеет встроенного
1059       wheel-plugin (vendored UMD не включает gestures). Now: ручной handler с
1060       600ms cooldown между snap'ами + 30px deltaY threshold (анти-trackpad-noise).
1061       Работает только когда target внутри viewport (не блокирует scroll вне). */
1062    /* v0.25.41: SINGLE event-delegated click listener на viewport — atomic
1063       safety net поверх per-button addEventListener. Если по любой причине
1064       per-button handler не attached / mounted (race during virtualization,
1065       SSR cards без data-aid, и т.д.) — этот сработает. Дубль-fire защита
1066       через data-act-handled flag (set + reset в next tick).
1067       Юзер: «нажимаю лайк и ничего не происходит» — это последний bullet. */
1068    function setupActionDelegation() {
1069        if (!viewportEl) return;
1070        viewportEl.addEventListener("click", function (e) {
1071            var btn = e.target && e.target.closest && e.target.closest("[data-act]");
1072            if (!btn) return;
1073            // Дубль-защита: если per-button уже сработал и пометил event,
1074            // skip delegated. Используем event flag.
1075            if (e._pbActHandled) return;
1076            e._pbActHandled = true;
1077            var article = btn.closest(".pb-short");
1078            if (!article) return;
1079            var aidStr = article.getAttribute("data-aid");
1080            var aid = aidStr ? parseInt(aidStr, 10) : null;
1081            if (!aid) return;
1082            var item = null;
1083            for (var i = 0; i < state.items.length; i++) {
1084                if (state.items[i].id === aid) { item = state.items[i]; break; }
1085            }
1086            if (!item) return;
1087            var act = btn.getAttribute("data-act");
1088            try {
1089                if (act === "like") toggleLike(article, item, btn);
1090                else if (act === "dislike") toggleDislike(article, item, btn);
1091                else if (act === "share") openShare(item);
1092                else if (act === "save") toggleSave(item, btn);
1093                else if (act === "report") {
1094                    trackGoal("short_full_report_clicked", {
1095                        aid: item.id, short_id: item.short_id, verdict: item.verdict
1096                    });
1097                } else if (act === "shop") {
1098                    trackGoal("short_shop_clicked", {
1099                        aid: item.id, short_id: item.short_id, marketplace: item.marketplace
1100                    });
1101                }
1102            } catch (err) {
1103                console.warn("[shorts] delegated click handler failed:", err);
1104            }
1105        }, false);
1106    }
1107
1108    function setupWheelNav() {
1109        if (!viewportEl || !state.embla) return;
1110        var wheelLockUntil = 0;
1111        // v0.25.15: passive: true — больше НЕ preventDefault на каждый wheel event.
1112        // Раньше preventDefault вызывался на cooldown/threshold-miss → 60+ blocked
1113        // events/сек на trackpad → main thread freeze. body{position:fixed} +
1114        // html{overflow:hidden} уже блокируют bubble — preventDefault не нужен.
1115        viewportEl.addEventListener("wheel", function (ev) {
1116            if (ev.target && ev.target.closest && ev.target.closest(".pb-short-act-btn-rail-scrollable")) return;
1117            var t = ev.target;
1118            if (t && t.tagName && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
1119            var now = Date.now();
1120            if (now < wheelLockUntil) return;            // cooldown — silent skip
1121            if (Math.abs(ev.deltaY) < 30) return;        // threshold — silent skip
1122            wheelLockUntil = now + 600;
1123            if (ev.deltaY > 0) state.embla.scrollNext();
1124            else state.embla.scrollPrev();
1125        }, { passive: true });
1126    }
1127
1128    /* ── 3D mouse-tracking parallax (desktop fine-pointer only) ────────────
1129       v0.25.15: skip-on-scroll integration. Раньше parallax продолжал
1130       transform'ить track во время Embla scroll → конфликт transforms +
1131       getBoundingClientRect spam = freeze. Now: isScrolling flag синхрон с
1132       Embla scroll/settle events; pointermove no-op во время скролла. */
1133    function setupParallax() {
1134        if (!viewportEl || !trackEl) return;
1135        if (PB_COARSE || PB_LOW_END || PB_REDUCED) return;
1136
1137        viewportEl.classList.add("is-parallax");
1138
1139        var maxRX = 4, maxRY = 6;
1140        var rafId = null;
1141        var pendingX = 0, pendingY = 0;
1142        var hasFrame = false;
1143        var lastTs = 0;
1144        var isScrolling = false;
1145
1146        // v0.25.15: hook Embla scroll/settle → freeze parallax during swipe
1147        if (state.embla) {
1148            state.embla.on("scroll", function () { isScrolling = true; });
1149            state.embla.on("settle", function () { isScrolling = false; });
1150        }
1151
1152        function applyTransform() {
1153            hasFrame = false;
1154            if (!trackEl) return;
1155            trackEl.style.setProperty("--rx", pendingX.toFixed(2) + "deg");
1156            trackEl.style.setProperty("--ry", pendingY.toFixed(2) + "deg");
1157        }
1158
1159        function onMove(e) {
1160            if (state.userPaused || isScrolling) return;
1161            var rect = viewportEl.getBoundingClientRect();
1162            var cx = rect.left + rect.width / 2;
1163            var cy = rect.top + rect.height / 2;
1164            var dx = (e.clientX - cx) / (rect.width / 2);
1165            var dy = (e.clientY - cy) / (rect.height / 2);
1166            if (dx < -1) dx = -1; else if (dx > 1) dx = 1;
1167            if (dy < -1) dy = -1; else if (dy > 1) dy = 1;
1168            pendingY = dx * maxRY;
1169            pendingX = -dy * maxRX;
1170            var now = performance.now ? performance.now() : Date.now();
1171            if (!hasFrame && (now - lastTs) >= 16) {
1172                hasFrame = true;
1173                lastTs = now;
1174                rafId = requestAnimationFrame(applyTransform);
1175            }
1176        }
1177        function onLeave() {
1178            pendingX = 0; pendingY = 0;
1179            if (rafId) cancelAnimationFrame(rafId);
1180            rafId = null;
1181            hasFrame = false;
1182            if (trackEl) {
1183                trackEl.style.setProperty("--rx", "0deg");
1184                trackEl.style.setProperty("--ry", "0deg");
1185            }
1186        }
1187
1188        document.addEventListener("pointermove", function (e) {
1189            if (e.pointerType && e.pointerType === "touch") return;
1190            onMove(e);
1191        }, { passive: true });
1192
1193        document.addEventListener("pointerleave", onLeave, { passive: true });
1194        window.addEventListener("blur", onLeave, { passive: true });
1195    }
1196
1197    /* ── Init ───────────────────────────────────────────────────────────── */
1198    function init() {
1199        trackEl = $("pbShortsTrack");
1200        viewportEl = $("pbShortsViewport");
1201        progressEl = $("pb-shorts-progress");
1202        hintEl = $("pb-shorts-hint");
1203        muteBtn = $("pb-shorts-mute-btn");
1204        pauseBtn = $("pb-shorts-pause-btn");
1205        topnavEl = document.querySelector(".pb-shorts-topnav");
1206
1207        if (!trackEl || !viewportEl) {
1208            console.warn("[shorts] missing DOM nodes");
1209            return;
1210        }
1211
1212        if (!PB_LOW_END) viewportEl.classList.add("is-cinematic");
1213        else viewportEl.classList.add("is-low-end");
1214
1215        var bootstrap = readBootstrap();
1216        state.items = (bootstrap.items || []).slice();
1217        state.cursor = bootstrap.next_cursor || null;
1218        state.exhausted = !state.cursor;
1219
1220        if (state.items.length === 0) {
1221            trackEl.innerHTML =
1222                '<article class="pb-short" style="display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);text-align:center;padding:32px">' +
1223                '<div><div style="font-size:18px;margin-bottom:12px;color:#fff;font-weight:700">Расследований пока нет</div>' +
1224                '<div style="font-size:14px">Заходи позже или <a href="/" style="color:#4ce2c1;font-weight:700">проверь свой товар →</a></div></div>' +
1225                '</article>';
1226            return;
1227        }
1228
1229        var existing = trackEl.querySelectorAll(".pb-short").length;
1230        if (state.items.length > existing) {
1231            appendArticles(state.items.slice(existing));
1232        }
1233
1234        // Inject pause-indicator and ensure CSS vars on track for any SSR pre-rendered articles
1235        // v0.25.11: ТАКЖЕ wire-up action handlers для SSR cards (раньше первые 2
1236        // карточки имели мёртвые кнопки — юзер: «в ленте кнопки не работают»).
1237        var ssrArticles = trackEl.querySelectorAll(".pb-short");
1238        for (var i = 0; i < ssrArticles.length; i++) {
1239            var art = ssrArticles[i];
1240            if (!art.querySelector(".pb-short-pause-indicator")) {
1241                var pi = document.createElement("div");
1242                pi.className = "pb-short-pause-indicator";
1243                pi.setAttribute("aria-hidden", "true");
1244                pi.innerHTML =
1245                    '<svg viewBox="0 0 24 24" aria-hidden="true">' +
1246                    '<rect x="6" y="5" width="4" height="14" rx="1.2"/>' +
1247                    '<rect x="14" y="5" width="4" height="14" rx="1.2"/>' +
1248                    '</svg>';
1249                art.appendChild(pi);
1250            }
1251            // v0.25.11: like-burst если отсутствует
1252            if (!art.querySelector(".pb-short-like-burst")) {
1253                var burst = document.createElement("div");
1254                burst.className = "pb-short-like-burst";
1255                burst.setAttribute("aria-hidden", "true");
1256                burst.innerHTML =
1257                    '<svg viewBox="0 0 24 24">
1257<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
1258                art.appendChild(burst);
1259            }
1260            // v0.25.11: wire SSR action-rail handlers — найти соответствующий item
1261            // в state.items по data-aid + вызвать attachActionHandlers
1262            var aidStr = art.getAttribute("data-aid");
1263            if (aidStr) {
1264                var aidNum = parseInt(aidStr, 10);
1265                var matchedItem = null;
1266                for (var mi = 0; mi < state.items.length; mi++) {
1267                    if (state.items[mi].id === aidNum) {
1268                        matchedItem = state.items[mi];
1269                        break;
1270                    }
1271                }
1272                if (matchedItem) {
1273                    attachActionHandlers(art, matchedItem);
1274                }
1275            }
1276        }
1277        // v0.25.11: body scroll-lock + html lock — гарантирует что page не скролится
1278        // когда юзер свайпает feed (юзер: «лента вместе скролится»).
1279        try {
1280            document.documentElement.classList.add("pb-shorts-html-active");
1281            document.body.classList.add("pb-shorts-body");
1282        } catch (_) {}
1283        // Initialize transform vars
1284        if (trackEl) {
1285            trackEl.style.setProperty("--rx", "0deg");
1286            trackEl.style.setProperty("--ry", "0deg");
1287        }
1288
1289        state.embla = initEmbla();
1290        if (!state.embla) console.warn("[shorts] Embla init failed");
1291
1292        setupMuteBtn();
1293        setupPauseBtn();
1294        setupTapInteractions();
1295        setupKeyboardNav();
1296        setupVisibilityPause();
1297        setupParallax();
1298        setupWheelNav(); // v0.25.11: desktop mouse wheel navigation
1299        setupActionDelegation(); // v0.25.41: atomic event delegation для action-rail
1300
1301        trackGoal("feed_open", {
1302            entry: window.location.pathname.indexOf("/shorts/") === 0 ? "deeplink" : "organic",
1303        });
1304
1305        virtualize(0);
1306        playCurrentShort(0);
1307        scheduleTopnavAutoHide();
1308
1309        if (!isOnboarded()) setTimeout(showHintIfNeeded, 1200);
1310
1311        window.addEventListener("beforeunload", function () {
1312            trackGoal("feed_session_drop_off", {
1313                last_idx: state.currentIdx,
1314                total_dwell_ms: Date.now() - state.sessionStarted,
1315                shorts_viewed: state.sessionShortsViewed,
1316            });
1317        });
1318    }
1319
1320    if (document.readyState === "loading") {
1321        document.addEventListener("DOMContentLoaded", init);
1322    } else {
1323        setTimeout(init, 50);
1324    }
1325})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.