PageSourceSearch

https://lysonsakylab.vn/wp-content/themes/lysonsaky-v9/assets/js/main.js?ver=4.0.66

js lysonsakylab.vn collected 2026-10-04 19:03:06 UTC 33,840 bytes, 691 lines download raw bytes

1/**
2 * Lysonsaky Theme — main.js v5.0
3 * Pure Vanilla JS — no jQuery, no Slick, no Fancybox, no external libs.
4 */
5(function () {
6    'use strict';
7
8    /* ═══════════════════════════════════════════════════
9       1. HERO SLIDER — Pure CSS transitions
10    ═══════════════════════════════════════════════════ */
11    function initHeroSlider() {
12        var wrap = document.querySelector('.lssk-slides');
13        if (!wrap) return;
14        var slides = Array.from(wrap.querySelectorAll('.lssk-slide'));
15        if (slides.length < 2) {
16            if (slides[0]) slides[0].classList.add('is-active');
17            return;
18        }
19        var cur = 0, timer, SPEED = 5500;
20
21        function goTo(n) {
22            slides[cur].classList.remove('is-active', 'is-prev');
23            slides[cur].classList.add('is-prev');
24            cur = ((n % slides.length) + slides.length) % slides.length;
25            slides[cur].classList.add('is-active');
26            setTimeout(function () {
27                slides.forEach(function (s) { s.classList.remove('is-prev'); });
28            }, 800);
29            updateDots();
30        }
31        function next() { goTo(cur + 1); }
32        function prev() { goTo(cur - 1); }
33        function startAuto() { timer = setInterval(next, SPEED); }
34        function stopAuto()  { clearInterval(timer); }
35
36        slides[0].classList.add('is-active');
37        wrap.classList.add('js-ready');
38
39        /* Arrows */
40        var prevBtn = wrap.parentElement.querySelector('.lssk-slide-prev');
41        var nextBtn = wrap.parentElement.querySelector('.lssk-slide-next');
42        if (!prevBtn) {
43            prevBtn = document.createElement('button');
44            prevBtn.type = 'button'; prevBtn.className = 'lssk-slide-prev';
45            prevBtn.setAttribute('aria-label', 'Trước');
46            prevBtn.innerHTML = '&#8249;';
47            nextBtn = document.createElement('button');
48            nextBtn.type = 'button'; nextBtn.className = 'lssk-slide-next';
49            nextBtn.setAttribute('aria-label', 'Sau');
50            nextBtn.innerHTML = '&#8250;';
51            var hero = wrap.closest('.lssk-hero') || wrap.parentElement;
52            hero.appendChild(prevBtn);
53            hero.appendChild(nextBtn);
54        }
55        prevBtn.addEventListener('click', function () { stopAuto(); prev(); startAuto(); });
56        nextBtn.addEventListener('click', function () { stopAuto(); next(); startAuto(); });
57
58        /* Dots */
59        var dotsWrap = document.createElement('div');
60        dotsWrap.className = 'lssk-slide-dots';
61        slides.forEach(function (_, i) {
62            var d = document.createElement('button');
63            d.type = 'button'; d.className = 'lssk-slide-dot';
64            if (i === 0) d.classList.add('is-active');
65            d.setAttribute('aria-label', 'Slide ' + (i + 1));
66            d.addEventListener('click', function () { stopAuto(); goTo(i); startAuto(); });
67            dotsWrap.appendChild(d);
68        });
69        (wrap.closest('.lssk-hero') || wrap.parentElement).appendChild(dotsWrap);
70
71        function updateDots() {
72            Array.from(dotsWrap.children).forEach(function (d, i) {
73                d.classList.toggle('is-active', i === cur);
74            });
75        }
76
77        /* Touch swipe */
78        var sx = 0;
79        wrap.addEventListener('touchstart', function (e) { sx = e.touches[0].clientX; }, { passive: true });
80        wrap.addEventListener('touchend',   function (e) {
81            var dx = e.changedTouches[0].clientX - sx;
82            if (Math.abs(dx) > 50) { stopAuto(); dx < 0 ? next() : prev(); startAuto(); }
83        });
84
85        /* Pause on hover */
86        wrap.addEventListener('mouseenter', stopAuto);
87        wrap.addEventListener('mouseleave', startAuto);
88
89        startAuto();
90    }
91
92    /* ═══════════════════════════════════════════════════
93       2. MOBILE NAV + MULTI-LEVEL
94    ═══════════════════════════════════════════════════ */
95    function initNav() {
96        var toggle  = document.getElementById('lssk-hamburger');
97        var nav     = document.getElementById('lssk-nav');
98        var overlay = document.getElementById('lssk-nav-overlay');
99        var close   = document.getElementById('lssk-nav-close');
100        var BREAK   = 1280;
101        if (!toggle || !nav) return;
102
103        var _navScrollY = 0;
104
105        function closeNav() {
106            nav.classList.remove('is-open');
107            toggle.classList.remove('is-active');
108            toggle.setAttribute('aria-expanded', 'false');
109            overlay && overlay.classList.remove('is-visible');
110            document.body.classList.remove('nav-open');
111            // iOS Safari: khôi phục scroll position sau khi bỏ position:fixed
112            document.body.style.top = '';
113            window.scrollTo(0, _navScrollY);
114            nav.querySelectorAll('.lssk-submenu').forEach(function (s) {
115                s.classList.remove('is-open');
116                s.style.display = '';
117            });
118            nav.querySelectorAll('.lssk-has-children').forEach(function (li) {
119                li.classList.remove('sub-open');
120            });
121        }
122        function openNav() {
123            _navScrollY = window.scrollY;
124            // iOS Safari: ghi nhớ vị trí scroll, position:fixed reset về 0
125            document.body.style.top = '-' + _navScrollY + 'px';
126            nav.classList.add('is-open');
127            toggle.classList.add('is-active');
128            toggle.setAttribute('aria-expanded', 'true');
129            overlay && overlay.classList.add('is-visible');
130            document.body.classList.add('nav-open');
131        }
132
133        toggle.addEventListener('click', function () {
134            nav.classList.contains('is-open') ? closeNav() : openNav();
135        });
136        close   && close.addEventListener('click', closeNav);
137        overlay && overlay.addEventListener('click', closeNav);
138        document.addEventListener('keydown', function (e) {
139            if (e.key === 'Escape') closeNav();
140        });
141
142        /* Mobile: slide toggle sub-menu */
143        nav.addEventListener('click', function (e) {
144            var link = e.target.closest('.lssk-has-children > a');
145            if (!link || window.innerWidth >= BREAK) return;
146            e.preventDefault();
147            var li  = link.parentElement;
148            var sub = li.querySelector(':scope > .lssk-submenu');
149            if (!sub) return;
150            /* close siblings */
151            li.parentElement.querySelectorAll(':scope > .lssk-has-children.sub-open').forEach(function (sib) {
152                if (sib !== li) {
153                    sib.classList.remove('sub-open');
154                    var sibSub = sib.querySelector(':scope > .lssk-submenu');
155                    if (sibSub) slideUp(sibSub);
156                }
157            });
158            li.classList.toggle('sub-open');
159            li.classList.contains('sub-open') ? slideDown(sub) : slideUp(sub);
160        });
161
162        window.addEventListener('resize', function () {
163            if (window.innerWidth >= BREAK) closeNav();
164        });
165
166        /* Desktop hover — JS managed (không dùng CSS :hover để tránh gap flicker)
167           Dùng mouseenter/mouseleave trên <li> — vì padding-bottom 10px extend hit area,
168           cursor không rời <li> khi di chuyển qua khoảng trống vào dropdown */
169        function initDesktopHover() {
170            var hoverTimers = new Map();
171
172            nav.querySelectorAll('.lssk-menu-item--d0').forEach(function (li) {
173                var sub = li.querySelector(':scope > .lssk-submenu--d0');
174                if (!sub) return; // không có submenu → bỏ qua
175
176                var DELAY = 120; // ms
177
178                function openItem() {
179                    /* Đóng anh chị em trước */
180                    li.parentElement && li.parentElement.querySelectorAll(':scope > .lssk-menu-item--d0.is-hovered').forEach(function (s) {
181                        if (s !== li) {
182                            clearTimeout(hoverTimers.get(s));
183                            s.classList.remove('is-hovered');
184                        }
185                    });
186                    clearTimeout(hoverTimers.get(li));
187                    li.classList.add('is-hovered');
188                }
189
190                function scheduleClose() {
191                    hoverTimers.set(li, setTimeout(function () {
192                        li.classList.remove('is-hovered');
193                    }, DELAY));
194                }
195
196                function cancelClose() {
197                    clearTimeout(hoverTimers.get(li));
198                }
199
200                /* LI hover */
201                li.addEventListener('mouseenter', openItem);
202                li.addEventListener('mouseleave', scheduleClose);
203
204                /* Submenu hover — hủy timer để không đóng khi cursor vào dropdown */
205                sub.addEventListener('mouseenter', cancelClose);
206                sub.addEventListener('mouseleave', scheduleClose);
207
208                /* Keyboard: Enter/Space mở dropdown, Escape đóng */
209                li.querySelector(':scope > a') && li.querySelector(':scope > a').addEventListener('keydown', function (e) {
210                    if (window.innerWidth < BREAK) return;
211                    if (e.key === 'Enter' || e.key === ' ') {
212                        if (li.classList.contains('is-hovered')) {
213                            li.classList.remove('is-hovered'); e.preventDefault();
214                        } else {
215                            openItem(); e.preventDefault();
216                        }
217                    }
218                    if (e.key === 'Escape') { li.classList.remove('is-hovered'); }
219                });
220            });
221
222            /* Click outside → close all */
223            document.addEventListener('click', function (e) {
224                if (window.innerWidth < BREAK) return;
225                if (!e.target.closest('#lssk-nav')) {
226                    nav.querySelectorAll('.lssk-menu-item--d0.is-hovered').forEach(function (li) {
227                        li.classList.remove('is-hovered');
228                    });
229                }
230            });
231        }
232
233        var _hoverInited = false;
234        function maybeInitHover() {
235            if (window.innerWidth >= BREAK && !_hoverInited) {
236                _hoverInited = true;
237                initDesktopHover();
238            }
239        }
240        maybeInitHover();
241        window.addEventListener('resize', maybeInitHover);
242    }
243
244    /* ─ slideDown / slideUp ─ */
245    function slideDown(el) {
246        el.style.display = 'block';
247        var h = el.scrollHeight;
248        el.style.overflow = 'hidden';
249        el.style.height = '0';
250        el.style.transition = 'height .22s ease';
251        requestAnimationFrame(function () { el.style.height = h + 'px'; });
252        el.addEventListener('transitionend', function done() {
253            el.style.height = el.style.overflow = el.style.transition = '';
254            el.removeEventListener('transitionend', done);
255        });
256    }
257    function slideUp(el) {
258        el.style.overflow = 'hidden';
259        el.style.height = el.scrollHeight + 'px';
260        el.style.transition = 'height .22s ease';
261        requestAnimationFrame(function () { el.style.height = '0'; });
262        el.addEventListener('transitionend', function done() {
263            el.style.display = '';
264            el.style.height = el.style.overflow = el.style.transition = '';
265            el.removeEventListener('transitionend', done);
266        });
267    }
268
269    /* ═══════════════════════════════════════════════════
270       3. LIVE SEARCH
271    ═══════════════════════════════════════════════════ */
272    function initSearch() {
273        var btn     = document.getElementById('lssk-search-btn');
274        var overlay = document.getElementById('lssk-search-overlay');
275        var closeBtn= document.getElementById('lssk-search-close');
276        var input   = document.getElementById('lssk-search-input');
277        var results = document.getElementById('lssk-search-results');
278        if (!btn || !overlay) return;
279        var timer;
280
281        function openSearch() {
282            _searchScrollY = window.scrollY;
283            document.body.style.top = '-' + _searchScrollY + 'px';
284            overlay.classList.add('is-active');
285            document.body.classList.add('search-open');
286            setTimeout(function () { input && input.focus(); }, 80);
287        }
288        var _searchScrollY = 0;
289
290        function closeSearch() {
291            overlay.classList.remove('is-active');
292            document.body.classList.remove('search-open');
293            document.body.style.top = '';
294            window.scrollTo(0, _searchScrollY);
295            if (input)   input.value = '';
296            if (results) results.innerHTML = '';
297        }
298
299        btn.addEventListener('click', openSearch);
300        closeBtn && closeBtn.addEventListener('click', closeSearch);
301        overlay.addEventListener('click', function (e) {
302            if (e.target === overlay) closeSearch();
303        });
304        document.addEventListener('keydown', function (e) {
305            if (e.key === 'Escape' && overlay.classList.contains('is-active')) closeSearch();
306        });
307
308        if (!input || !results) return;
309        input.addEventListener('input', function () {
310            clearTimeout(timer);
311            var kw = input.value.trim();
312            if (kw.length < 2) { results.innerHTML = ''; return; }
313            results.innerHTML = '<div class="lssk-search-loading">Đang tìm...</div>';
314            timer = setTimeout(function () {
315                var fd = new FormData();
316                fd.append('action',  'lssk_live_search');
317                fd.append('nonce',   lssk.nonce);
318                fd.append('keyword', kw);
319                fetch(lssk.ajax_url, { method: 'POST', body: fd })
320                    .then(function (r) { return r.json(); })
321                    .then(function (res) {
322                        if (!res.success || !res.data || !res.data.length) {
323                            results.innerHTML = '<div class="lssk-search-empty">Không tìm thấy kết quả cho "<strong>' + escHtml(kw) + '</strong>"</div>';
324                            return;
325                        }
326                        var html = '<ul class="lssk-search-list">';
327                        res.data.forEach(function (item) {
328                            html += '<li><a href="' + item.url + '">' +
329                                '<img src="' + item.thumb + '" alt="" loading="lazy">' +
330                                '<div class="lssk-search-item-body">' +
331                                '<span class="lssk-search-type">' + escHtml(item.type) + '</span>' +
332                                '<strong>' + escHtml(item.title) + '</strong>' +
333                                '<span class="lssk-search-excerpt">' + escHtml(item.excerpt) + '</span>' +
334                                '</div></a></li>';
335                        });
336                        html += '</ul><div class="lssk-search-all-row"><a href="' +
337                            lssk.search_url + encodeURIComponent(kw) + '">Xem tất cả kết quả &#8250;</a></div>';
338                        results.innerHTML = html;
339                    })
340                    .catch(function () {
341                        results.innerHTML = '<div class="lssk-search-empty">Lỗi kết nối, thử lại.</div>';
342                    });
343            }, 350);
344        });
345        input.addEventListener('keydown', function (e) {
346            if (e.key === 'Enter') {
347                window.location.href = lssk.search_url + encodeURIComponent(input.value.trim());
348            }
349        });
350    }
351
352    /* ═══════════════════════════════════════════════════
353       3b. LIVE SEARCH INLINE (Header Mẫu 2 — desktop + mobile)
354       Dùng chung API/markup lssk_live_search với initSearch(), chỉ khác
355       là kết quả render ngay dưới ô input (.lssk-inline-search-form) thay
356       vì trong overlay toàn màn hình.
357    ═══════════════════════════════════════════════════ */
358    function initInlineLiveSearch() {
359        var forms = document.querySelectorAll('.lssk-inline-search-form');
360        if (!forms.length) return;
361
362        forms.forEach(function (form) {
363            var input   = form.querySelector('input[type="search"]');
364            var results = form.querySelector('.lssk-search-results');
365            if (!input || !results) return;
366            var timer;
367
368            input.addEventListener('input', function () {
369                clearTimeout(timer);
370                var kw = input.value.trim();
371                if (kw.length < 2) { results.innerHTML = ''; return; }
372                results.innerHTML = '<div class="lssk-search-loading">Đang tìm...</div>';
373                timer = setTimeout(function () {
374                    var fd = new FormData();
375                    fd.append('action',  'lssk_live_search');
376                    fd.append('nonce',   lssk.nonce);
377                    fd.append('keyword', kw);
378                    fetch(lssk.ajax_url, { method: 'POST', body: fd })
379                        .then(function (r) { return r.json(); })
380                        .then(function (res) {
381                            if (!res.success || !res.data || !res.data.length) {
382                                results.innerHTML = '<div class="lssk-search-empty">Không tìm thấy kết quả cho "<strong>' + escHtml(kw) + '</strong>"</div>';
383                                return;
384                            }
385                            var html = '<ul class="lssk-search-list">';
386                            res.data.forEach(function (item) {
387                                html += '<li><a href="' + item.url + '">' +
388                                    '<img src="' + item.thumb + '" alt="" loading="lazy">' +
389                                    '<div class="lssk-search-item-body">' +
390                                    '<span class="lssk-search-type">' + escHtml(item.type) + '</span>' +
391                                    '<strong>' + escHtml(item.title) + '</strong>' +
392                                    '<span class="lssk-search-excerpt">' + escHtml(item.excerpt) + '</span>' +
393                                    '</div></a></li>';
394                            });
395                            html += '</ul><div class="lssk-search-all-row"><a href="' +
396                                lssk.search_url + encodeURIComponent(kw) + '">Xem tất cả kết quả &#8250;</a></div>';
397                            results.innerHTML = html;
398                        })
399                        .catch(function () {
400                            results.innerHTML = '<div class="lssk-search-empty">Lỗi kết nối, thử lại.</div>';
401                        });
402                }, 350);
403            });
404
405            document.addEventListener('click', function (e) {
406                if (!form.contains(e.target)) results.innerHTML = '';
407            });
408            document.addEventListener('keydown', function (e) {
409                if (e.key === 'Escape') results.innerHTML = '';
410            });
411        });
412    }
413
414    /* ═══════════════════════════════════════════════════
415       4. SCROLL-TO-TOP
416    ═══════════════════════════════════════════════════ */
417    function initStickyUI() {
418        var toTop = document.getElementById('lssk-to-top');
419        if (!toTop) return;
420        window.addEventListener('scroll', function () {
421            toTop.classList.toggle('visible', window.scrollY > 400);
422        }, { passive: true });
423        toTop.addEventListener('click', function () {
424            window.scrollTo({ top: 0, behavior: 'smooth' });
425        });
426    }
427
428    /* ═══════════════════════════════════════════════════
429       5. ENTRANCE ANIMATIONS — IntersectionObserver
430    ═══════════════════════════════════════════════════ */
431    function initAnimations() {
432        var els = document.querySelectorAll('.lssk-animate');
433        if (!('IntersectionObserver' in window)) {
434            els.forEach(function (el) { el.classList.add('is-visible'); });
435            return;
436        }
437        var obs = new IntersectionObserver(function (entries) {
438            entries.forEach(function (entry) {
439                if (entry.isIntersecting) {
440                    entry.target.classList.add('is-visible');
441                    obs.unobserve(entry.target);
442                }
443            });
444        }, { threshold: 0.1 });
445        els.forEach(function (el) { obs.observe(el); });
446    }
447
448    /* ═══════════════════════════════════════════════════
449       6. NUMBER COUNTER — IntersectionObserver
450    ═══════════════════════════════════════════════════ */
451    function initCounters() {
452        // Chỉ animate bộ đếm số liệu (thẻ lá chỉ chứa số); BỎ QUA gallery có data-count (có phần tử con)
453        var els = Array.prototype.filter.call(
454            document.querySelectorAll('[data-count]'),
455            function (el) { return el.children.length === 0; }
456        );
457        if (!els.length || !('IntersectionObserver' in window)) return;
458        var obs = new IntersectionObserver(function (entries) {
459            entries.forEach(function (entry) {
460                if (!entry.isIntersecting) return;
461                var el  = entry.target;
462                var end = parseInt(el.getAttribute('data-count'), 10);
463                var dur = 1600, step = 16;
464                var inc = end / (dur / step), cur = 0;
465                var iv = setInterval(function () {
466                    cur = Math.min(cur + inc, end);
467                    el.textContent = Math.floor(cur).toLocaleString('vi-VN');
468                    if (cur >= end) clearInterval(iv);
469                }, step);
470                obs.unobserve(el);
471            });
472        }, { threshold: 0.5 });
473        els.forEach(function (el) { obs.observe(el); });
474    }
475
476    /* ═══════════════════════════════════════════════════
477       CONTACT FORM VALIDATION — lssk-contact-form
478    ═══════════════════════════════════════════════════ */
479    function initFormValidation() {
480        document.querySelectorAll('.lssk-contact-form, .lssk-form--validate').forEach(function (form) {
481            form.setAttribute('novalidate', '');
482
483            function showError(field, msg) {
484                field.classList.add('lssk-field--error');
485                var err = field.parentNode.querySelector('.lssk-field-error');
486                if (!err) {
487                    err = document.createElement('span');
488                    err.className = 'lssk-field-error';
489                    field.parentNode.appendChild(err);
490                }
491                err.textContent = msg;
492            }
493            function clearError(field) {
494                field.classList.remove('lssk-field--error');
495                var err = field.parentNode.querySelector('.lssk-field-error');
496                if (err) err.remove();
497            }
498
499            /* Clear on input */
500            form.querySelectorAll('input, textarea, select').forEach(function (f) {
501                f.addEventListener('input', function () { clearError(f); });
502            });
503
504            form.addEventListener('submit', function (e) {
505                var valid = true;
506                form.querySelectorAll('[required]').forEach(function (field) {
507                    var v = field.value.trim();
508                    clearError(field);
509                    if (!v) {
510                        showError(field, 'Vui lòng điền vào trường này');
511                        valid = false;
512                    } else if (field.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
513                        showError(field, 'Email không đúng định dạng');
514                        valid = false;
515                    } else if (field.type === 'tel' && v.replace(/\D/g, '').length < 9) {
516                        showError(field, 'Số điện thoại không hợp lệ');
517                        valid = false;
518                    }
519                });
520                if (!valid) {
521                    e.preventDefault();
522                    var firstErr = form.querySelector('.lssk-field--error');
523                    if (firstErr) firstErr.scrollIntoView({ behavior: 'smooth', block: 'center' });
524                }
525            });
526        });
527    }
528
529    /* ═══════════════════════════════════════════════════
530       LAZY IMAGES — IntersectionObserver fallback
531       Native loading="lazy" handles most cases;
532       this adds fade-in for better UX
533    ═══════════════════════════════════════════════════ */
534    function initLazyImages() {
535        if (!('IntersectionObserver' in window)) return;
536        var imgs = document.querySelectorAll('img[loading="lazy"]:not([data-lssk-observed])');
537        if (!imgs.length) return;
538        var obs = new IntersectionObserver(function (entries) {
539            entries.forEach(function (entry) {
540                if (entry.isIntersecting) {
541                    var img = entry.target;
542                    img.addEventListener('load', function () {
543                        img.style.opacity = '1';
544                    });
545                    img.style.opacity = img.complete ? '1' : '0';
546                    img.style.transition = 'opacity .3s ease';
547                    if (img.complete) img.style.opacity = '1';
548                    obs.unobserve(img);
549                }
550            });
551        }, { rootMargin: '200px 0px' });
552
553        imgs.forEach(function (img) {
554            img.setAttribute('data-lssk-observed', '1');
555            obs.observe(img);
556        });
557    }
558
559    /* ═══════════════════════════════════════════════════
560       6b. PRODUCT TABS — Mô tả / Video / Mô hình 3D
561    ═══════════════════════════════════════════════════ */
562    function initProductTabs() {
563        document.querySelectorAll('.lssk-sp-tabs').forEach(function (wrap) {
564            var btns = Array.from(wrap.querySelectorAll('.lssk-sp-tab-btn'));
565            if (!btns.length) return;
566
567            /* ── Activate một tab theo target id ── */
568            function activate(target, updateHash) {
569                /* Ẩn tất cả panels, deselect tất cả buttons */
570                btns.forEach(function (b) {
571                    var isTarget = b.dataset.tab === target;
572                    b.classList.toggle('is-active', isTarget);
573                    b.setAttribute('aria-selected', isTarget ? 'true' : 'false');
574                });
575                wrap.querySelectorAll('.lssk-sp-tab-panel').forEach(function (p) {
576                    var isTarget = p.id === 'sp-tab-' + target;
577                    p.classList.toggle('is-active', isTarget);
578                    /* hidden attribute: loại bỏ khỏi tab order + ẩn với AT */
579                    if (isTarget) p.removeAttribute('hidden');
580                    else          p.setAttribute('hidden', '');
581                });
582
583                /* Lazy-init model-viewer khi mở tab model lần đầu */
584                if (target === 'model') {
585                    var panel = wrap.querySelector('#sp-tab-model');
586                    var mv = panel && panel.querySelector('model-viewer:not([data-loaded])');
587                    if (mv) {
588                        mv.setAttribute('data-loaded', '1');
589                        setTimeout(function () { window.dispatchEvent(new Event('resize')); }, 120);
590                    }
591                }
592
593                /* Cập nhật URL hash (không scroll lại) */
594                if (updateHash && history.replaceState) {
595                    history.replaceState(null, '', '#sp-' + target);
596                }
597            }
598
599            /* ── Click ── */
600            btns.forEach(function (btn) {
601                btn.addEventListener('click', function () {
602                    activate(btn.dataset.tab, true);
603                });
604            });
605
606            /* ── Keyboard: ← → Home End (ARIA tabs pattern) ── */
607            wrap.querySelector('.lssk-sp-tab-nav').addEventListener('keydown', function (e) {
608                var cur = btns.indexOf(document.activeElement);
609                if (cur === -1) return;
610                var next = cur;
611                if (e.key === 'ArrowRight') next = (cur + 1) % btns.length;
612                else if (e.key === 'ArrowLeft')  next = (cur - 1 + btns.length) % btns.length;
613                else if (e.key === 'Home')        next = 0;
614                else if (e.key === 'End')         next = btns.length - 1;
615                else return;
616                e.preventDefault();
617                btns[next].focus();
618                activate(btns[next].dataset.tab, true);
619            });
620
621            /* ── Hash on load — /san-pham/slug/#sp-video mở đúng tab ── */
622            var hash = window.location.hash.replace('#sp-', '');
623            var hashBtn = hash && btns.find(function (b) { return b.dataset.tab === hash; });
624            if (hashBtn) {
625                /* Scroll vào tab section sau khi DOM ready */
626                setTimeout(function () {
627                    activate(hash, false);
628                    wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
629                }, 300);
630            }
631        });
632    }
633
634    /* ═══════════════════════════════════════════════════
635       7. ENTRY-CONTENT IMAGE LIGHTBOX
636       Dùng lssk-lb (gallery.js) thay Fancybox
637    ═══════════════════════════════════════════════════ */
638    function initContentImages() {
639        document.querySelectorAll('.lssk-entry-content').forEach(function (content) {
640            var imageLinks = Array.from(content.querySelectorAll('a')).filter(function (a) {
641                return /\.(jpg|jpeg|png|webp|gif|avif)(\?|$)/i.test(a.href);
642            });
643            if (!imageLinks.length) return;
644
645            /* Build group for lightbox navigation */
646            var group = imageLinks.map(function (a) {
647                var img = a.querySelector('img');
648                return { src: a.href, thumb: a.href, alt: img ? (img.alt || '') : '' };
649            });
650
651            imageLinks.forEach(function (a, idx) {
652                a.addEventListener('click', function (e) {
653                    e.preventDefault();
654                    document.dispatchEvent(new CustomEvent('lssk:open-image', {
655                        detail: { group: group, index: idx },
656                    }));
657                });
658            });
659        });
660    }
661
662    /* ═══════════════════════════════════════════════════
663       UTILITY
664    ═══════════════════════════════════════════════════ */
665    function escHtml(str) {
666        return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
667    }
668
669    /* ═══════════════════════════════════════════════════
670       INIT
671    ═══════════════════════════════════════════════════ */
672    function init() {
673        initHeroSlider();
674        initNav();
675        initSearch();
676        initInlineLiveSearch();
677        initStickyUI();
678        initAnimations();
679        initCounters();
680        initLazyImages();
681        initContentImages();
682        initProductTabs();
683    }
684
685    if (document.readyState === 'loading') {
686        document.addEventListener('DOMContentLoaded', init);
687    } else {
688        init();
689    }
690
691})();

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.