PageSourceSearch

https://fpt.vn/2026/home/assets/js/package-tabs-home.js?v=v1.50.17

js fpt.vn collected 2026-10-01 11:55:54 UTC 25,587 bytes, 699 lines download raw bytes

1/* =============================================================================
2   PACKAGE TABS — BẢN CLONE CHO TRANG /home
3   -----------------------------------------------------------------------------
4   Clone từ: public/frontend_layout_2025_vibecode/assets/js/package-tabs-dynamic-2-fixed.js
5   Bản gốc GIỮ NGUYÊN, /vi vẫn chạy bằng bản gốc.
6
7   Khác bản gốc:
8   - init nhận `root` để chạy lại theo scope cho HTML nạp trễ (bản gốc chỉ nhìn
9     thấy DOM tại thời điểm DOMContentLoaded);
10   - có cờ chống init trùng;
11   - toàn bộ bọc trong IIFE và chỉ expose window.homeInitPackageTabs. Bản gốc vẫn được
12     master nạp trên /home, không bọc thì các khai báo top-level trùng tên sẽ
13     ném SyntaxError "already been declared".
14   ============================================================================= */
15
16(function () {
17/**
18 * Dynamic Package Tabs with Lazy Loading
19 * Chỉ load danh sách khi click vào tab
20 */
21
22/**
23 * Vị trí đã lưu ở localStorage — cùng nguồn location-modal.js dùng để vẽ header/popup.
24 * Gửi kèm cho server ưu tiên dùng thay vì tự đọc cookie location_id (có thể lệch/stale).
25 * Trả về chuỗi bắt đầu bằng '&', rỗng nếu chưa có location nào lưu.
26 */
27function storedLocationQuery() {
28    var keys = { location_id: 'locationid', district_id: 'districtid', ward_id: 'wardid' };
29    var out = '';
30    try {
31        Object.keys(keys).forEach(function (param) {
32            var value = localStorage.getItem(keys[param]);
33            if (value) {
34                out += '&' + param + '=' + encodeURIComponent(value);
35            }
36        });
37    } catch (e) { /* private mode / không có localStorage */ }
38    return out;
39}
40
41
42/**
43 * Package Card Renderer
44 */
45class DynamicPackageCardRenderer {
46
47    /**
48     * Render loading skeleton
49     */
50    static renderLoadingSkeleton(count = 3) {
51        const skeletons = Array(count).fill(0).map(() => `
52        <div class="package-card swiper-slide skeleton">
53            <div class="skeleton-header"></div>
54            <div class="skeleton-body">
55            <div class="skeleton-title"></div>
56            <div class="skeleton-price"></div>
57            <div class="skeleton-speed"></div>
58            </div>
59        </div>
60    `).join('');
61
62        return skeletons;
63    }
64
65    /**
66     * Render empty state
67     */
68    static renderEmptyState() {
69        return `
70        <div class="empty-state">
71            <img src="frontend_layout_2025_vibecode/assets/images/empty-data.svg" alt="No data">
72            <p>Không có sản phẩm nào</p>
73        </div>`;
74    }
75}
76
77/**
78 * Package Tabs Manager
79 */
80class PackageTabsManager {
81    constructor(sectionElement) {
82        // Nếu không truyền section, fallback về cách cũ (backward compatible)
83        if (!sectionElement) {
84            sectionElement = document.querySelector('#featured-products-with-tabs');
85            if (!sectionElement) {
86                console.error('PackageTabsManager: No section element provided and default #featured-products-with-tabs not found');
87                return;
88            }
89        }
90
91        this.section = sectionElement;
92        this.currentTab = null;
93        this.loadedTabs = new Set(); // Track đã load tab nào
94        this.tabContents = new Map(); // Cache nội dung của từng tab
95        this.cardsContainer = null;
96        this.swiperContainer = null;
97        this.swiperInstance = null;
98        this.button = null;
99        this.init();
100    }
101
102    init() {
103        // Scope to the provided section element
104        if (!this.section) {
105            console.error('PackageTabsManager: Section element is required');
106            return;
107        }
108
109        this.attachTabClickHandlers();
110        this.swiperContainer = this.section.querySelector('.package-cards-grid');
111        this.cardsContainer = this.swiperContainer ? this.swiperContainer.querySelector('.embla-package__container') : null;
112
113        if (!this.cardsContainer) {
114            console.error('PackageTabsManager: Cards container not found in section');
115            return;
116        }
117
118        this.loadDefaultTab();
119    }
120    checkEmblaCenter() {
121        if (this.section){
122            const $container = $(this.section).find('.embla-package__container');
123            const $slides = $container.find('.embla-package__slide'); // các slide con
124            // console.log($container.html());
125            console.log($slides.length);
126            if ($slides.length === 0) return; // không có slide -> bỏ qua
127
128            // Tính tổng width tất cả slide
129            let totalWidth = 0;
130            $slides.each(function () {
131                totalWidth += $(this).outerWidth(true); // tính cả margin
132            });
133
134            const containerWidth = $container.width();
135
136            // Thêm / bỏ class
137            if (totalWidth < containerWidth) {
138                $container.addClass('_js_embla_center');
139            } else {
140                $container.removeClass('_js_embla_center');
141            }
142        }
143    }
144
145    /**
146     * Attach click handlers to tabs
147     */
148    attachTabClickHandlers() {
149        this.section.addEventListener('click', (e) => {
150            const button = e.target.closest('.package-tab');
151            if (!button) return;
152
153            const tabId = button.getAttribute('data-tab');
154            this.button = button;
155
156            if (!tabId) {
157                console.warn('PackageTabsManager: Tab button missing data-tab attribute');
158                return;
159            }
160
161            // Lấy data từ section tại đây
162            const sectionData = {
163                from_screen: this.section.getAttribute('data_sp_from_screen'),
164                data_area: this.section.getAttribute('data_sp_area'),
165                area_position: this.section.getAttribute('data_sp_area_position')
166            };
167
168            this.switchTab(button, tabId, sectionData);
169        });
170    }
171
172    /**
173     * Switch to different tab
174     */
175    switchTab(clickedButton, tabId, sectionData) {
176        // Update active state - ONLY within this section
177        this.section.querySelectorAll('.package-tab').forEach(btn => {
178            btn.classList.remove('active');
179        });
180        clickedButton.classList.add('active');
181
182        // 2) Lấy danh sách tab trong section
183        const tabs = Array.from(this.section.querySelectorAll('.package-tab'));
184
185        // 3) Tìm index của tab vừa click
186        const tabIndex = tabs.indexOf(clickedButton) + 1;  // +1 vì index 0 → tab 1
187
188        // 4) Load tab content với tabIndex
189        this.loadTabContent(tabId, tabIndex, sectionData);
190    }
191
192    /**
193     * Load content for a specific tab
194     */
195    async loadTabContent(tabId, tabIndex, sectionData) {
196        // Nếu tab này đã được load, chỉ cần restore content từ cache
197        // if (this.loadedTabs.has(tabId)) {
198        // console.log(`Tab "${tabId}" already loaded, restoring from cache...`);
199        // const cachedContent = this.tabContents.get(tabId);
200        // if (cachedContent) {
201        // this.cardsContainer.innerHTML = cachedContent;
202        // this.currentTab = tabId;
203        // this.initSwiper();
204        // }
205        // return;
206        // }
207
208        // Show loading skeleton
209        this.showLoading();
210
211        // Fetch data từ API, JSON file, hoặc dùng local data
212        try {
213            let cardsData = null;
214
215            // F-Tracking
216            const ftrackingData = {
217                data_from_screen: sectionData.from_screen,
218                data_area: sectionData.data_area,
219                data_area_position: Number(sectionData.area_position)
220            };
221
222            // Kiểm tra nếu có JSON file được config
223            // if (window.PACKAGE_JSON_FILE) {
224            //     // Fetch từ JSON file
225            //     const response = await fetch(window.PACKAGE_JSON_FILE);
226            //     const allData = await response.json();
227            //     cardsData = allData[tabId]; // Lấy data theo key (ví dụ: allData["internet-ca-nhan"])
228            // }
229
230            // Hoặc kiểm tra nếu có API endpoint được config
231            if (window.PACKAGE_API_ENDPOINT) {
232                // Fetch từ API: /api/packages?category=internet-ca-nhan
233                const ft = encodeURIComponent(JSON.stringify(ftrackingData));
234                const response = await fetch(`${window.PACKAGE_API_ENDPOINT}?category=${tabId}&ftracking_data=${ft}&tab_index=${tabIndex}${storedLocationQuery()}`);
235                const result = await response.json();
236                cardsData = result.data || result; // Support cả {data: [...]} hoặc [...]
237            }
238
239            // Fallback: Dùng local data
240            // else {
241            //     const dataKey = TAB_DATA_MAP[tabId];
242            //     cardsData = packageTabsData[dataKey];
243            // }
244
245            // Kiểm tra data
246            if (!cardsData || cardsData.length === 0) {
247                this.showEmptyState();
248                return;
249            }
250
251            // Render cards
252            this.renderCards(cardsData, tabId);
253            this.checkEmblaCenter();
254            // Mark tab as loaded
255            // this.loadedTabs.add(tabId);
256            this.currentTab = tabId;
257            // Render link "Xem tất cả"
258            this.renderLoadMore();
259            // Reinitialize Swiper
260            // this.initSwiper();
261            // this.initEmblaForCurrentTab()
262
263        } catch (error) {
264            console.error(`Error loading tab "${tabId}":`, error);
265
266            // Fallback to local data nếu API fail
267            const dataKey = TAB_DATA_MAP[tabId];
268            
269            if (packageTabsData[dataKey]) {
270                const cardsData = packageTabsData[dataKey];
271                this.renderCards(cardsData, tabId);
272                // this.loadedTabs.add(tabId);
273                this.currentTab = tabId;
274                this.initSwiper();
275            } else {
276                this.showEmptyState();
277            }
278        }
279    }
280
281    /**
282     * 
283     */
284    renderLoadMore() {
285        // Render link "Xem tất cả"
286        const loadmoreLink = this.button.getAttribute('data-link-load-more');
287        const loadmoreText = this.button.getAttribute('data-load-more');
288
289        // Tìm tất cả phần tử có class .package-view-all trong section
290        const loadmoreEls = this.section.querySelectorAll('.package-view-all');
291
292        loadmoreEls.forEach(el => {
293            const linkEl = el.querySelector('.view-all-link');
294            if (!loadmoreLink || !linkEl) {
295                el.classList.add('hidden');
296            } else {
297                linkEl.href = loadmoreLink;
298                linkEl.textContent = loadmoreText || 'Xem tất cả';
299                el.classList.remove('hidden');
300            }
301        });
302    }
303
304    /**
305     * Show loading skeleton
306     */
307    showLoading() {
308        this.cardsContainer.innerHTML = DynamicPackageCardRenderer.renderLoadingSkeleton(4);
309    }
310
311    /**
312     * Show empty state
313     */
314    showEmptyState() {
315        this.cardsContainer.innerHTML = DynamicPackageCardRenderer.renderEmptyState();
316    }
317
318    /**
319     * Render cards
320     */
321    renderCards(cardsData, tabId) {
322        if (!cardsData || cardsData.length === 0) {
323            this.showEmptyState();
324            return;
325        }
326
327        const cardsHTML = cardsData.html || '';
328
329        // Fade out animation
330        this.cardsContainer.style.opacity = '0';
331
332        setTimeout(() => {
333            // Inject new HTML
334            this.cardsContainer.innerHTML = cardsHTML;
335
336            // ⛔ PHẢI cleanup Embla cũ trước khi init mới
337            if (typeof window.cleanupEmblaCarousels === 'function') {
338                try { window.cleanupEmblaCarousels(); } catch (e) { console.warn('cleanupEmbla err', e); }
339            }
340
341            // Fade in animation
342            setTimeout(() => {
343                this.cardsContainer.style.opacity = '1';
344
345                // ⛔ PHẢI init Embla lại sau khi fade-in
346                setTimeout(() => {
347                    if (typeof window.initEmblaCarousels === 'function') {
348                        try {
349                            window.initEmblaCarousels();
350                        } catch (e) {
351                            console.warn('initEmblaCarousels err', e);
352                        }
353                    }
354
355                    // ép update active slides sau khi Embla init xong
356                    setTimeout(() => {
357                        const inst = window.emblaInstances || [];
358                        inst.forEach(api => {
359                            if (api && api.slideNodes) {
360                                const slides = api.slideNodes();
361                                const viewport = api.rootNode ? api.rootNode() : null;
362                                if (!viewport) return;
363
364                                const vpRect = viewport.getBoundingClientRect();
365                                slides.forEach(slide => {
366                                    const r = slide.getBoundingClientRect();
367                                    const visible = Math.max(0, Math.min(r.right, vpRect.right) - Math.max(r.left, vpRect.left));
368                                    const ratio = visible / r.width;
369                                    if (ratio >= 0.3) slide.classList.add('active');
370                                    else slide.classList.remove('active');
371                                });
372                            }
373                        });
374                    }, 80);
375
376                }, 60);
377
378            }, 50);
379
380        }, 200);
381    }
382
383    /**
384     * Initialize Swiper for cards
385     */
386    initSwiper() {
387        // Destroy existing instance
388        if (this.swiperInstance) {
389            // this.swiperInstance.destroy(true, true);
390            this.swiperInstance.update();
391            return;
392        }
393        if ( !this.swiperContainer || !(this.swiperContainer instanceof HTMLElement) ) {
394            console.warn('Swiper container not valid:', this.swiperContainer);
395            return;
396        }
397        // Initialize new Swiper using the stored container reference
398        
399        this.swiperInstance = new Swiper(this.swiperContainer, {
400            slidesPerView: 'auto',
401            spaceBetween: 24,
402            freeMode: true,
403            grabCursor: true,
404            breakpoints: {
405                320: {
406                    slidesPerView: 1.2,
407                    spaceBetween: 16
408                },
409                768: {
410                    slidesPerView: 2.5,
411                    spaceBetween: 20
412                },
413                1024: {
414                    slidesPerView: 3.5,
415                    spaceBetween: 24
416                }
417            }
418        });
419    }
420
421    /**
422     * Initialize Embla for current tab
423     * (Add active class to slides that are >=50% visible)
424     */
425    initEmblaForCurrentTab() {
426        const emblaNode = this.cardsContainer ? this.cardsContainer.querySelector('.embla-package') : null;
427        if (!emblaNode) return;
428
429        // Destroy previous Embla if exists
430        if (this.emblaApi && typeof this.emblaApi.destroy === 'function') {
431            try { this.emblaApi.destroy(); } catch (e) { /* ignore */ }
432        }
433
434        // Initialize Embla
435        try {
436            this.emblaApi = EmblaCarousel(emblaNode, {
437                align: 'start',
438                containScroll: 'trimSnaps',
439                dragFree: false,
440                inViewThreshold: 0.5
441            });
442        } catch (e) {
443            // Embla not available
444            this.emblaApi = null;
445        }
446
447        const update = () => {
448            const viewport = emblaNode.getBoundingClientRect();
449            const slides = this.emblaApi ? this.emblaApi.slideNodes() : emblaNode.querySelectorAll('.embla-package__slide');
450
451            slides.forEach(slide => {
452                const rect = slide.getBoundingClientRect();
453                const visible = Math.min(rect.right, viewport.right) - Math.max(rect.left, viewport.left);
454                const half = rect.width * 0.5;
455
456                if (visible >= half) slide.classList.add('active');
457                else slide.classList.remove('active');
458            });
459        };
460
461        // attach events if emblaApi exists
462        if (this.emblaApi) {
463            this.emblaApi.on('init', update);
464            this.emblaApi.on('scroll', update);
465            this.emblaApi.on('select', update);
466            this.emblaApi.on('resize', update);
467        }
468
469        // ensure one-time update
470        setTimeout(update, 60);
471    }
472
473    /**
474     * Update active slides for current embla container (when Embla not available)
475     */
476    updateActiveSlides() {
477        const container = this.cardsContainer;
478        if (!container) return;
479        const slides = container.querySelectorAll('.embla-package__slide');
480        const viewportRect = container.getBoundingClientRect();
481
482        slides.forEach(slide => {
483            const rect = slide.getBoundingClientRect();
484            const visible = Math.min(rect.right, viewportRect.right) - Math.max(rect.left, viewportRect.left);
485            const half = rect.width * 0.5;
486            if (visible >= half) slide.classList.add('active');
487            else slide.classList.remove('active');
488        });
489    }
490
491    /**
492     * Gọi Tab đầu tiên 
493     */
494    applyPositionForDefaultTab() {
495        const firstTab = this.section.querySelector('.package-tab.active');
496        if (!firstTab) return;
497
498        const tabs = Array.from(this.section.querySelectorAll('.package-tab'));
499        const tabIndex = tabs.indexOf(firstTab) + 1; // luôn = 1
500
501        const cards = this.section.querySelectorAll('.package-card');
502
503        cards.forEach((card, idx) => {
504            const pos = `${tabIndex}.${idx + 1}`;
505            const btns = card.querySelectorAll('[data-sp-item-position]');
506            btns.forEach(btn => btn.setAttribute('data-sp-item-position', pos));
507        });
508    }
509
510    /**
511     * Load default tab (first tab)
512     */
513    loadDefaultTab() {
514    const firstTab = this.section.querySelector('.package-tab.active');
515        if (!firstTab) return;
516
517        const tabId = firstTab.getAttribute('data-tab');
518        if (!tabId) return;
519
520        this.currentTab = tabId;
521
522        // 🔥 ADD THIS — update position without API
523        this.applyPositionForDefaultTab();
524
525        this.initSwiper();
526
527        try {
528            this.initEmblaForCurrentTabSafe();
529        } catch (err) {
530            console.warn('loadDefaultTab: initEmblaForCurrentTabSafe failed', err);
531        }
532    }
533
534    /**
535     * Clear loaded tabs cache (useful for refresh)
536     */
537    clearCache() {
538        this.loadedTabs.clear();
539    }
540
541    /**
542   * Safe update active slides for the current cards container.
543   * - Tính dựa trên viewport của container (this.cardsContainer) hoặc .embla-package nếu cần
544   * - Thực thi sau khi renderCards() đã set innerHTML + fade-in
545   */
546    updateActiveSlidesForContainer() {
547        try {
548            const container = this.cardsContainer || this.section.querySelector('.embla-package__container');
549            if (!container) {
550                console.debug('updateActiveSlidesForContainer: no cards container found');
551                return;
552            }
553
554            // Prefer embla viewport node if exists inside container
555            const emblaNode = container.querySelector('.embla-package') || container;
556            const viewportRect = emblaNode.getBoundingClientRect();
557
558            // Slides selector used by your server view
559            const slides = emblaNode.querySelectorAll('.embla-package__slide, .package-card');
560
561            if (!slides || slides.length === 0) {
562                console.debug('updateActiveSlidesForContainer: no slides found to update');
563                return;
564            }
565
566            slides.forEach(slide => {
567                const rect = slide.getBoundingClientRect();
568                const slideWidth = rect.width || 0;
569                const visibleWidth = Math.min(rect.right, viewportRect.right) - Math.max(rect.left, viewportRect.left);
570                const isVisibleHalf = slideWidth > 0 && visibleWidth >= (slideWidth * 0.5);
571
572                if (isVisibleHalf) slide.classList.add('active');
573                else slide.classList.remove('active');
574            });
575        } catch (err) {
576            console.error('updateActiveSlidesForContainer error', err);
577        }
578    }
579
580    /**
581     * Safe init Embla for container (if Embla exists). This also attaches scroll/resize handlers
582     * and calls updateActiveSlidesForContainer() once ready.
583     */
584    initEmblaForCurrentTabSafe() {
585        try {
586            const container = this.cardsContainer || this.section.querySelector('.embla-package__container');
587            if (!container) {
588                console.debug('initEmblaForCurrentTabSafe: no container');
589                return;
590            }
591            const emblaNode = container.querySelector('.embla-package') || container;
592            if (!emblaNode) {
593                console.debug('initEmblaForCurrentTabSafe: no .embla-package inside container');
594                // still run DOM calculation once
595                setTimeout(() => this.updateActiveSlidesForContainer(), 60);
596                return;
597            }
598
599            // destroy previous emblaApi if we created one earlier
600            try {
601                if (this.emblaApi && typeof this.emblaApi.destroy === 'function') {
602                    this.emblaApi.destroy();
603                }
604            } catch (e) {
605                console.warn('initEmblaForCurrentTabSafe: destroy previous embla failed', e);
606            }
607
608            if (typeof EmblaCarousel === 'function') {
609                try {
610                    this.emblaApi = EmblaCarousel(emblaNode, {
611                        align: 'start',
612                        containScroll: 'trimSnaps',
613                        dragFree: false,
614                        inViewThreshold: 0
615                    });
616
617                    const update = () => this.updateActiveSlidesForContainer();
618
619                    // attach embla events (safe)
620                    try { this.emblaApi.on('init', update); } catch (e) { /* ignore */ }
621                    try { this.emblaApi.on('scroll', update); } catch (e) { /* ignore */ }
622                    try { this.emblaApi.on('select', update); } catch (e) { /* ignore */ }
623                    try { this.emblaApi.on('resize', update); } catch (e) { /* ignore */ }
624
625                    // run once shortly after init so layout has settled
626                    setTimeout(update, 80);
627                    return;
628                } catch (err) {
629                    console.warn('initEmblaForCurrentTabSafe: Embla init failed, falling back to DOM only', err);
630                }
631            } else {
632                console.debug('initEmblaForCurrentTabSafe: EmblaCarousel not present, using DOM fallback');
633            }
634
635            // fallback: just compute based on DOM
636            setTimeout(() => this.updateActiveSlidesForContainer(), 60);
637        } catch (err) {
638            console.error('initEmblaForCurrentTabSafe general error', err);
639        }
640    }
641}
642
643/**
644 * Auto-initialize all package sections with tabs
645 * Supports multiple sections on the same page
646 */
647function initializeAllPackageTabs(root) {
648    // root: giới hạn phạm vi khi init lại cho HTML mới chèn (lazy load section)
649    const scope = (root && root.querySelectorAll) ? root : document;
650    const packageSections = scope.querySelectorAll('.package-section-tab');
651    const managers = [];
652
653    packageSections.forEach((section, index) => {
654        // Đã init rồi thì bỏ qua — tránh gắn 2 lần listener lên cùng 1 section
655        if (section.dataset.tabsInit === '1') return;
656
657        // Check if this section has tabs
658        const hasTabs = section.querySelector('.package-tabs') || section.querySelector('.package-tab');
659
660        if (hasTabs) {
661            section.dataset.tabsInit = '1';
662            // console.log(`Initializing PackageTabsManager for section ${index + 1}`);
663            const manager = new PackageTabsManager(section);
664            managers.push(manager);
665        }
666    });
667
668    // Store all managers globally for external access if needed.
669    // Cộng dồn chứ không ghi đè: lazy load gọi hàm này nhiều lần theo từng section,
670    // ghi đè sẽ làm mất manager của các section đã init trước đó.
671    window.homePackageTabsManagers = (window.homePackageTabsManagers || []).concat(managers);
672
673    // Keep backward compatibility: store first manager as packageTabsManager
674    if (!window.homePackageTabsManager && managers.length > 0) {
675        window.homePackageTabsManager = managers[0];
676    }
677
678    return managers;
679}
680
681// KHÔNG auto-init lúc DOMContentLoaded: bản gốc đã làm, và ở /home lúc ấy chưa có
682// section nào. home-lazy.js sẽ gọi sau khi chèn HTML.
683window.homeInitPackageTabs = initializeAllPackageTabs;
684
685// Export for external use
686// if (typeof module !== 'undefined' && module.exports) {
687//   module.exports = {
688//     PackageTabsManager,
689//     DynamicPackageCardRenderer,
690//     packageTabsData,
691//     initializeAllPackageTabs
692//   };
693// }
694
695// Expose globally for manual initialization
696// window.PackageTabsManager = PackageTabsManager;
697// window.initializeAllPackageTabs = initializeAllPackageTabs;
698
699})();

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.