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.