1/** 2 * PopStore Storefront - Motor de Filtro e Streaming 100% Client-Side (/produtos) 3 */ 4window.PopStoreFilterEngine = (function() { 5 let state = { 6 activeSizes: new Set(), 7 activeColors: new Set(), 8 activeTypes: new Set(), 9 activeMeta: new Set(), 10 activeBrands: new Set(), 11 activeCollections: new Set(), 12 activeDeals: new Set(), 13 priceMin: null, 14 priceMax: null, 15 boundsMin: 0, 16 boundsMax: 1000, 17 sort: 'default', 18 isStreaming: false 19 }; 20 21 const SIZE_ORDER = [ 22 'RN', 'PP', 'P', 'M', 'G', 'GG', 'XG', 'XXG', 'XGG', 'XXGG', 23 'G1', 'G2', 'G3', 'G4', 'G5', 24 '32', '33', '34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44', '45', '46', '47', '48', '50', '52', '54', '56', 25 'U' 26 ]; 27 28 const CURRENT_LANG = (window.popstore && window.popstore.lang) 29 || (document.documentElement.lang && document.documentElement.lang.startsWith('en') ? 'en' : (document.documentElement.lang && document.documentElement.lang.startsWith('es') ? 'es' : (window.location.pathname.startsWith('/en') ? 'en' : (window.location.pathname.startsWith('/es') ? 'es' : 'br')))); 30 31 const I18N = { 32 priceRange: { br: 'Faixa de Preço', en: 'Price Range', es: 'Rango de Precios' }, 33 price: { br: 'Preço', en: 'Price', es: 'Precio' }, 34 minPrice: { br: 'MÃn', en: 'Min', es: 'MÃn' }, 35 maxPrice: { br: 'Máx', en: 'Max', es: 'Máx' }, 36 to: { br: 'até', en: 'to', es: 'hasta' }, 37 deals: { br: 'Oportunidades & Descontos', en: 'Deals & Discounts', es: 'Ofertas y Descuentos' }, 38 newIn: { br: 'Novidades', en: 'New In', es: 'Novedades' }, 39 sale: { br: 'Promo', en: 'Sale', es: 'Promo' }, 40 outlet: { br: 'Outlet', en: 'Outlet', es: 'Outlet' }, 41 sizes: { br: 'Tamanhos', en: 'Sizes', es: 'Tallas' }, 42 colors: { br: 'Cores', en: 'Colors', es: 'Colores' }, 43 productTypes: { br: 'Tipos de Produto', en: 'Product Types', es: 'Tipos de Producto' }, 44 specifications: { br: 'Especificações & Tecnologias', en: 'Specifications & Technologies', es: 'Especificaciones y TecnologÃas' }, 45 brands: { br: 'Marcas', en: 'Brands', es: 'Marcas' }, 46 collections: { br: 'Coleções', en: 'Collections', es: 'Colecciones' }, 47 items: { br: 'itens', en: 'items', es: 'artÃculos' }, 48 size: { br: 'Tamanho', en: 'Size', es: 'Talla' }, 49 sizeAbbr: { br: 'Tam', en: 'Size', es: 'Talla' }, 50 colorAbbr: { br: 'Cor', en: 'Color', es: 'Color' }, 51 loadingFull: { br: 'Carregando catálogo completo...', en: 'Loading full catalog...', es: 'Cargando catálogo completo...' }, 52 loadingMore: { br: 'Carregando mais produtos...', en: 'Loading more products...', es: 'Cargando más productos...' }, 53 of: { br: 'de', en: 'of', es: 'de' } 54 }; 55 56 function tJs(key) { 57 const item = I18N[key]; 58 if (!item) return key; 59 return item[CURRENT_LANG] || item['br'] || key; 60 } 61 62 const LOCALE_CODE = CURRENT_LANG === 'br' ? 'pt-BR' : (CURRENT_LANG === 'es' ? 'es-ES' : 'en-US'); 63 64 function formatPrice(val) { 65 const num = parseFloat(val); 66 if (isNaN(num)) return 'R$ 0'; 67 try { 68 const currency = CURRENT_LANG === 'en' ? 'USD' : (CURRENT_LANG === 'es' ? 'EUR' : 'BRL'); 69 return new Intl.NumberFormat(LOCALE_CODE, { style: 'currency', currency: currency, maximumFractionDigits: 0 }).format(num); 70 } catch(e) { 71 return 'R$ ' + Math.round(num); 72 } 73 } 74 75 function isReferenceCode(str) { 76 if (!str) return true; 77 const s = String(str).trim(); 78 if (!s) return true; 79 if (!isNaN(s)) return true; 80 if (/^[a-z]{1,4}[-_\s]?\d+([-_\s]?[a-z0-9]+)?$/i.test(s)) return true; 81 if (/^(cor|ref|sku|cod|codigo|color|prod)[-_\s]?\w+$/i.test(s)) return true; 82 if (/^#[0-9a-f]{3,8}$/i.test(s)) return true; 83 if (/[a-z0-9]*\d{3,}[a-z0-9]*/i.test(s) && !/[aeiou]{2,}/i.test(s)) return true; 84 return false; 85 } 86 87 function formatToneLabel(token) { 88 if (!token) return 'Cor';
89 return token.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); 90 } 91 92 function canonicalColorSlug(slug) { 93 if (!slug) return ''; 94 const s = String(slug).toLowerCase().trim().replace(/-/g, '_'); 95 const aliases = { 96 'verde_claro': 'verde', 97 'verde_escuro': 'verde', 98 'menta': 'verde', 99 'militar': 'verde', 100 'musgo': 'verde', 101 'oliva': 'verde', 102 'esmeralda': 'verde', 103 'azul_claro': 'azul', 104 'azul_escuro': 'azul', 105 'marinho': 'azul', 106 'royal': 'azul', 107 'celeste': 'azul', 108 'turquesa': 'azul', 109 'indigo': 'azul', 110 'navy': 'azul', 111 'rosa_claro': 'rosa', 112 'pink': 'rosa', 113 'fucsia': 'rosa', 114 'magenta': 'rosa', 115 'rose': 'rosa', 116 'salmao': 'rosa', 117 'blush': 'rosa', 118 'cinza_claro': 'cinza', 119 'cinza_escuro': 'cinza', 120 'chumbo': 'cinza', 121 'grafite': 'cinza', 122 'mescla': 'cinza', 123 'mostarda': 'amarelo', 124 'gold': 'dourado', 125 'ouro': 'dourado', 126 'dourada': 'dourado', 127 'prata': 'prateado', 128 'prateada': 'prateado', 129 'silver': 'prateado', 130 'terracota': 'marrom', 131 'caramelo': 'marrom', 132 'chocolate': 'marrom', 133 'conhaque': 'marrom', 134 'coral': 'laranja', 135 'pessego': 'laranja', 136 'nude': 'bege', 137 'areia': 'bege', 138 'palha': 'bege', 139 'offwhite': 'off_white', 140 'marfim': 'off_white', 141 'cru': 'off_white', 142 'lavanda': 'lilas', 143 'bordo': 'vinho', 144 'marsala': 'vinho', 145 'burgundy': 'vinho' 146 }; 147 return aliases[s] || s; 148 } 149 150 function normalizeSize(sz) { 151 if (!sz) return ''; 152 let s = String(sz).trim(); 153 if (s.includes('(')) { 154 s = s.split('(')[0].trim(); 155 } 156 s = s.replace(/^tam(anho)?\.?\s+/i, '').trim(); 157 const sUpper = s.toUpperCase(); 158 const sAscii = sUpper.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 159 160 if ( 161 ['U', 'UN', 'TU', 'ONE SIZE', 'U.'].includes(sUpper) || 162 /\bunic[oa]s?\b/i.test(sAscii) || 163 /^(tam(anho)?\.?\s*)?u\.?$/i.test(sAscii) 164 ) { 165 return 'U'; 166 } 167 168 return sUpper; 169 } 170 171 function sortSizeKeys(keys) { 172 return keys.sort((a, b) => { 173 const idxA = SIZE_ORDER.indexOf(a); 174 const idxB = SIZE_ORDER.indexOf(b); 175 if (idxA !== -1 && idxB !== -1) return idxA - idxB; 176 if (idxA !== -1) return -1; 177 if (idxB !== -1) return 1; 178 179 const isNumA = !isNaN(a); 180 const isNumB = !isNaN(b); 181 if (isNumA && isNumB) return parseFloat(a) - parseFloat(b); 182 if (isNumA) return -1; 183 if (isNumB) return 1; 184 185 return a.localeCompare(b, undefined, { numeric: true }); 186 }); 187 } 188 189 function getAllCards() { 190 const cards = Array.from(document.querySelectorAll('#pop-catalog-grid > .pop-product-card, #products-grid-container > .pop-product-card, .products-grid-container > .pop-product-card')); 191 cards.forEach((c, idx) => { 192 if (!c.hasAttribute('data-initial-order')) { 193 c.setAttribute('data-initial-order', idx); 194 } 195 }); 196 return cards; 197 } 198 199 /** 200 * Coleta todos os atributos disponÃveis dos produtos em tela e constrói o UI dos filtros 201 */ 202 function buildFilterData() { 203 const cards = getAllCards(); 204 const sizesMap = {}; 205 const colorsMap = {}; 206 const typesMap = {}; 207 const brandsMap = {}; 208 const colsMap = {}; 209 const metaFiltersMap = {}; 210 211 // Processa as cores a partir dos swatches agrupados por tom e dados fiéis da loja 212 const sortedByDate = [...cards].sort((a, b) => (parseInt(b.getAttribute('data-date')) || 0) - (parseInt(a.getAttribute('data-date')) || 0)); 213 214 sortedByDate.forEach(card => { 215 let processedSwatches = false;
216 try { 217 const swRaw = card.getAttribute('data-color-swatches'); 218 if (swRaw) { 219 const swList = JSON.parse(swRaw); 220 if (swList && typeof swList === 'object') { 221 Object.values(swList).forEach(sw => { 222 if (!sw || !sw.token) return; 223 const token = sw.token; 224 let displayName = sw.name || sw.label || token; 225 if (isReferenceCode(displayName)) { 226 displayName = sw.label || formatToneLabel(token); 227 } 228 if (!colorsMap[token]) { 229 colorsMap[token] = { 230 token: token, 231 code: token, 232 name: displayName, 233 hex: sw.hex || '#000000', 234 hex2: sw.hex2 || null, 235 is_pattern: token === 'estampado' || !!sw.is_pattern, 236 sort: sw.sort ?? 99, 237 count: 0 238 }; 239 } 240 colorsMap[token].count++; 241 processedSwatches = true; 242 }); 243 } 244 } 245 } catch (e) {} 246 247 if (!processedSwatches) { 248 // Fallback para cards legados 249 let colorCode = (card.getAttribute('data-color-code') || card.getAttribute('data-color-name') || card.getAttribute('data-color') || '').trim(); 250 let colorName = (card.getAttribute('data-color-name') || card.getAttribute('data-color') || '').trim(); 251 const colorHex = (card.getAttribute('data-color-hex') || '#000000').trim(); 252 const colorHex2 = (card.getAttribute('data-color-hex2') || '').trim() || null; 253 254 if (isReferenceCode(colorName)) { 255 try { 256 const namesAttr = JSON.parse(card.getAttribute('data-color-names') || '[]'); 257 if (Array.isArray(namesAttr)) { 258 for (const n of namesAttr) { 259 if (n && !isReferenceCode(n)) { 260 colorName = n; 261 break; 262 } 263 } 264 } 265 } catch (e) {} 266 } 267 268 if (isReferenceCode(colorName)) { 269 try { 270 const tokensAttr = JSON.parse(card.getAttribute('data-color-tokens') || '[]'); 271 if (Array.isArray(tokensAttr) && tokensAttr[0]) { 272 colorName = formatToneLabel(tokensAttr[0]); 273 if (isReferenceCode(colorCode)) colorCode = tokensAttr[0]; 274 } 275 } catch (e) {} 276 } 277 278 if (isReferenceCode(colorName)) { 279 colorName = 'Outras'; 280 } 281 282 if (colorCode) { 283 if (!colorsMap[colorCode]) { 284 colorsMap[colorCode] = { 285 token: colorCode, 286 code: colorCode, 287 name: colorName, 288 hex: colorHex, 289 hex2: colorHex2, 290 is_pattern: colorCode === 'estampado' || colorName.toLowerCase().includes('estampa') || colorName.toLowerCase().includes('floral'), 291 sort: 99, 292 count: 0 293 }; 294 } 295 colorsMap[colorCode].count++; 296 } 297 } 298 }); 299 300 let calculatedMinPrice = Infinity; 301 let calculatedMaxPrice = -Infinity; 302 const dealsData = { 303 hasNovidades: false,
304 hasPromo: false, 305 hasOutlet: false, 306 discountRangesMap: {} 307 }; 308 309 cards.forEach(card => { 310 // 1. Tamanhos com estoque 311 try { 312 const sizesRaw = card.getAttribute('data-sizes'); 313 if (sizesRaw) { 314 const sizes = JSON.parse(sizesRaw); 315 if (Array.isArray(sizes)) { 316 sizes.forEach(sz => { 317 const cleanSz = normalizeSize(sz); 318 if (cleanSz) { 319 sizesMap[cleanSz] = (sizesMap[cleanSz] || 0) + 1; 320 } 321 }); 322 } 323 } 324 } catch(e) {} 325 326 // 3. Tipos / Partes de Produtos (Top, Legging, Blusa, Shorts, etc.) 327 try { 328 const typesRaw = card.getAttribute('data-types'); 329 if (typesRaw) { 330 const types = JSON.parse(typesRaw); 331 if (Array.isArray(types)) { 332 types.forEach(t => { 333 const cleanT = String(t).trim(); 334 if (cleanT) { 335 typesMap[cleanT] = (typesMap[cleanT] || 0) + 1; 336 } 337 }); 338 } 339 } else { 340 const t = (card.getAttribute('data-type') || '').trim(); 341 if (t) typesMap[t] = (typesMap[t] || 0) + 1; 342 } 343 } catch(e) { 344 const t = (card.getAttribute('data-type') || '').trim(); 345 if (t) typesMap[t] = (typesMap[t] || 0) + 1; 346 } 347 348 // 4. Marcas 349 const brand = (card.getAttribute('data-brand') || '').trim(); 350 if (brand) { 351 brandsMap[brand] = (brandsMap[brand] || 0) + 1; 352 } 353 354 // 5. Coleções 355 const col = (card.getAttribute('data-collection') || '').trim(); 356 if (col) { 357 colsMap[col] = (colsMap[col] || 0) + 1; 358 } 359 360 // 6. Metadados & Especificações Técnicas do Produto (Independe de Loja/Marketplace) 361 try { 362 const metaRaw = card.getAttribute('data-metadata'); 363 if (metaRaw) { 364 let metaObj = JSON.parse(metaRaw); 365 if (typeof metaObj === 'string') { 366 metaObj = JSON.parse(metaObj); 367 } 368 if (metaObj && typeof metaObj === 'object') { 369 if (metaObj.br || metaObj.pt) { 370 metaObj = metaObj.br || metaObj.pt; 371 } 372 Object.entries(metaObj).forEach(([mKey, mVal]) => { 373 if (mKey === 'images') return; 374 const k = String(mKey).trim(); 375 const v = String(mVal).trim(); 376 if (k && v && typeof mVal !== 'object') { 377 if (!metaFiltersMap[k]) metaFiltersMap[k] = {}; 378 metaFiltersMap[k][v] = (metaFiltersMap[k][v] || 0) + 1; 379 } 380 }); 381 } 382 } 383 } catch(e) {} 384 // Preço 385 const p = parseFloat(card.getAttribute('data-price') || '0'); 386 if (p > 0) { 387 if (p < calculatedMinPrice) calculatedMinPrice = p; 388 if (p > calculatedMaxPrice) calculatedMaxPrice = p; 389 } 390 391 // Oportunidades & Descontos 392 const disc = parseInt(card.getAttribute('data-discount') || '0', 10); 393 if (disc <= 0) { 394 dealsData.hasNovidades = true; 395 } else if (disc < 50) { 396 dealsData.hasPromo = true; 397 } else { 398 dealsData.hasOutlet = true; 399 } 400 if (disc >= 10) { 401 const dStep = Math.min(70, Math.floor(disc / 10) * 10); 402 dealsData.discountRangesMap[dStep] = (dealsData.discountRangesMap[dStep] || 0) + 1; 403 } 404 }); 405 406 if (calculatedMinPrice !== Infinity && calculatedMaxPrice !== -Infinity) { 407 state.boundsMin = Math.floor(calculatedMinPrice); 408 state.boundsMax = Math.ceil(calculatedMaxPrice); 409 if (state.boundsMax <= state.boundsMin) { 410 state.boundsMax = state.boundsMin + 100; 411 } 412 } 413 414 return { sizesMap, colorsMap, typesMap, metaFiltersMap, brandsMap, colsMap, dealsData }; 415 } 416 417 /**
418 * Renderiza o HTML das seções de filtro (Preço -> Oportunidades & Descontos -> Tamanhos -> Cores -> Tipos -> Especificações -> Marcas -> Coleções) 419 */ 420 function renderFiltersUI() { 421 const { sizesMap, colorsMap, typesMap, metaFiltersMap, brandsMap, colsMap, dealsData } = buildFilterData(); 422 let html = ''; 423 424 // 0. FAIXA DE PREÃO (Dual Range Slider no Topo dos Filtros) 425 const bMin = state.boundsMin; 426 const bMax = state.boundsMax; 427 const curMin = state.priceMin !== null ? Math.max(bMin, Math.min(state.priceMin, bMax)) : bMin; 428 const curMax = state.priceMax !== null ? Math.min(bMax, Math.max(state.priceMax, bMin)) : bMax; 429 const span = Math.max(1, bMax - bMin); 430 const pctMin = Math.max(0, Math.min(100, Math.round(((curMin - bMin) / span) * 100))); 431 const pctMax = Math.max(0, Math.min(100, Math.round(((curMax - bMin) / span) * 100))); 432 const leftOffset = `calc(${pctMin}% + ${10 - 0.2 * pctMin}px)`; 433 const rightOffset = `calc(${100 - pctMax}% + ${10 - 0.2 * (100 - pctMax)}px)`; 434 435 html += ` 436 <div class="filter-group-block filter-group-price"> 437 <h3 class="filter-group-title"> 438 <span>${tJs('priceRange')}</span> 439 </h3> 440 <div class="filter-price-slider-container" data-min="${bMin}" data-max="${bMax}" data-cur-min="${curMin}" data-cur-max="${curMax}"> 441 <div class="filter-price-display-row"> 442 <div class="filter-price-val-box"> 443 <span class="filter-price-label">${tJs('minPrice')}</span> 444 <span class="filter-price-amount price-min-display">${formatPrice(curMin)}</span> 445 </div> 446 <div class="filter-price-separator">${tJs('to')}</div> 447 <div class="filter-price-val-box is-right"> 448 <span class="filter-price-label">${tJs('maxPrice')}</span> 449 <span class="filter-price-amount price-max-display">${formatPrice(curMax)}</span> 450 </div> 451 </div> 452 <div class="filter-price-track-wrapper"> 453 <div class="filter-price-track-bg"></div> 454 <div class="filter-price-track-fill" style="left: ${leftOffset}; right: ${rightOffset};"></div> 455 <input type="range" class="filter-price-range range-min" min="${bMin}" max="${bMax}" step="1" value="${curMin}" aria-label="${tJs('minPrice')}" oninput="PopStoreFilterEngine.handlePriceInput(this, 'min')" onchange="PopStoreFilterEngine.applyPriceFilter()"> 456 <input type="range" class="filter-price-range range-max" min="${bMin}" max="${bMax}" step="1" value="${curMax}" aria-label="${tJs('maxPrice')}" oninput="PopStoreFilterEngine.handlePriceInput(this, 'max')" onchange="PopStoreFilterEngine.applyPriceFilter()"> 457 </div> 458 </div> 459 </div>`; 460 461 // 0.5. OPORTUNIDADES & DESCONTOS (Abaixo do Slider de Preço) 462 if (dealsData) { 463 const { hasNovidades, hasPromo, hasOutlet, discountRangesMap } = dealsData; 464 const hasAnyDeal = hasNovidades || hasPromo || hasOutlet || Object.keys(discountRangesMap || {}).length > 0; 465 if (hasAnyDeal) { 466 html += ` 467 <div class="filter-group-block filter-group-deals"> 468 <h3 class="filter-group-title"> 469 <span>${tJs('deals')}</span> 470 </h3> 471 <div class="filter-deals-pills-grid">`; 472 473 if (hasNovidades) { 474 const isSel = state.activeDeals.has('novidades'); 475 html += ` 476 <button type="button" class="filter-deal-pill is-novidades ${isSel ? 'is-selected' : ''}" onclick="PopStoreFilterEngine.toggleDeal('novidades')" title="${tJs('newIn')}"> 477 <span>${tJs('newIn')}</span> 478 </button>`; 479 } 480 if (hasPromo) { 481 const isSel = state.activeDeals.has('promo'); 482 html += ` 483 <button type="button" class="filter-deal-pill is-promo ${isSel ? 'is-selected' : ''}" onclick="PopStoreFilterEngine.toggleDeal('promo')" title="${tJs('sale')}">
484 <span>${tJs('sale')}</span> 485 </button>`; 486 } 487 if (hasOutlet) { 488 const isSel = state.activeDeals.has('outlet'); 489 html += ` 490 <button type="button" class="filter-deal-pill is-outlet ${isSel ? 'is-selected' : ''}" onclick="PopStoreFilterEngine.toggleDeal('outlet')" title="${tJs('outlet')}"> 491 <span>${tJs('outlet')}</span> 492 </button>`; 493 } 494 495 if (discountRangesMap) { 496 const dSteps = Object.keys(discountRangesMap).map(Number).sort((a, b) => a - b); 497 dSteps.forEach(dStep => { 498 const dKey = `d${dStep}`; 499 const isSel = state.activeDeals.has(dKey); 500 const dLabel = dStep >= 60 ? `${dStep}%+ OFF` : `${dStep}% OFF`; 501 html += ` 502 <button type="button" class="filter-deal-pill ${isSel ? 'is-selected' : ''}" onclick="PopStoreFilterEngine.toggleDeal('${dKey}')" title="${dLabel}"> 503 <span class="deal-percent-badge">${dLabel}</span> 504 </button>`; 505 }); 506 } 507 508 html += ` 509 </div> 510 </div>`; 511 } 512 } 513 514 // 1. TAMANHOS (Somente com estoque disponÃvel na tela) 515 const sizeKeys = Object.keys(sizesMap); 516 if (sizeKeys.length > 0) { 517 sortSizeKeys(sizeKeys); 518 519 html += ` 520 <div class="filter-group-block"> 521 <h3 class="filter-group-title"> 522 <span>${tJs('sizes')}</span> 523 </h3> 524 <div class="filter-sizes-pills-grid"> 525 ${sizeKeys.map(sz => { 526 const isSel = state.activeSizes.has(sz); 527 return ` 528 <button type="button" 529 class="filter-size-pill ${isSel ? 'is-selected' : ''}" 530 onclick="PopStoreFilterEngine.toggleSize('${sz}')" 531 title="${tJs('size')} ${sz}"> 532 <span>${sz}</span> 533 </button>`; 534 }).join('')} 535 </div> 536 </div>`; 537 } 538 539 // 2. CORES (Pills com bolinha colorida fiel à loja e ordenação canônica de tons) 540 const colorCodes = Object.keys(colorsMap).sort((a, b) => { 541 const sortA = colorsMap[a].sort ?? 99; 542 const sortB = colorsMap[b].sort ?? 99; 543 if (sortA !== sortB) return sortA - sortB; 544 return (colorsMap[a].name || '').localeCompare(colorsMap[b].name || ''); 545 }); 546 547 if (colorCodes.length > 0) { 548 html += ` 549 <div class="filter-group-block"> 550 <h3 class="filter-group-title"> 551 <span>${tJs('colors')}</span> 552 </h3> 553 <div class="filter-colors-grid"> 554 ${colorCodes.map(cCode => { 555 const c = colorsMap[cCode]; 556 const tokenOrCode = c.token || cCode; 557 const isSel = state.activeColors.has(tokenOrCode) || state.activeColors.has(c.name); 558 const isPattern = tokenOrCode === 'estampado' || cCode === 'estampado' || !!c.is_pattern; 559 const cHex = c.hex || '#000000'; 560 const isLight = ['#ffffff', '#fff', '#f8fafc', '#f1f5f9', '#f8f5ee'].includes(cHex.toLowerCase()); 561 const borderStyle = isLight ? 'border: 1px solid #cbd5e1;' : ''; 562 const patternSvg = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><rect width='24' height='24' fill='%23fff1f2'/><circle cx='6' cy='6' r='4' fill='%23f43f5e'/><circle cx='18' cy='18' r='4' fill='%230284c7'/><circle cx='18' cy='6' r='3.5' fill='%23eab308'/><circle cx='6' cy='18' r='3.5' fill='%2316a34a'/><circle cx='12' cy='12' r='3' fill='%239333ea'/></svg>"; 563 const bgStyle = isPattern 564 ? '' 565 : (c.hex2 566 ? `background: linear-gradient(135deg, ${cHex} 50%, ${c.hex2} 50%);` 567 : `background-color: ${cHex};`); 568 return ` 569 <button type="button" 570 class="filter-color-pill ${isSel ? 'is-selected' : ''}" 571 onclick="PopStoreFilterEngine.toggleColor('${tokenOrCode}')" 572 title="${c.name}">
573 <span class="color-swatch-dot ${isPattern ? 'is-pattern' : ''}" style="${bgStyle} ${borderStyle}"></span> 574 <span class="color-swatch-name">${c.name}</span> 575 </button>`; 576 }).join('')} 577 </div> 578 </div>`; 579 } 580 581 // 3. TIPOS DE PRODUTOS (Ordem alfabética) 582 const typeKeys = Object.keys(typesMap).sort((a, b) => a.localeCompare(b, CURRENT_LANG === 'br' ? 'pt-BR' : 'en', { sensitivity: 'base' })); 583 if (typeKeys.length > 0) { 584 html += ` 585 <div class="filter-group-block"> 586 <h3 class="filter-group-title"> 587 <span>${tJs('productTypes')}</span> 588 </h3> 589 <ul class="filter-checkboxes-list"> 590 ${typeKeys.map(t => { 591 const isSel = state.activeTypes.has(t); 592 const safeT = t.replace(/'/g, "\\'"); 593 return ` 594 <li class="filter-checkbox-item"> 595 <label style="display: flex; align-items: center; cursor: pointer; min-width: 0; width: 100%; user-select: none;"> 596 <input type="checkbox" ${isSel ? 'checked' : ''} onchange="PopStoreFilterEngine.toggleType('${safeT}')"> 597 <span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;">${t}</span> 598 </label> 599 </li>`; 600 }).join('')} 601 </ul> 602 </div>`; 603 } 604 605 // Bloco: ESPECIFICAÃÃES & TECNOLOGIAS (Metadados do Produto - Exibido logo abaixo de Tipos de Produtos) 606 const metaKeys = Object.keys(metaFiltersMap).sort((a, b) => a.localeCompare(b, CURRENT_LANG === 'br' ? 'pt-BR' : 'en', { sensitivity: 'base' })); 607 if (metaKeys.length > 0) { 608 html += ` 609 <div class="filter-group-block filter-group-specifications"> 610 <h3 class="filter-group-title"> 611 <span>${tJs('specifications')}</span> 612 </h3> 613 <ul class="filter-checkboxes-list">`; 614 615 metaKeys.forEach(mKey => { 616 const valMap = metaFiltersMap[mKey]; 617 const valKeys = Object.keys(valMap).sort(); 618 valKeys.forEach(mVal => { 619 const token = `${mKey}:::${mVal}`; 620 const isSel = state.activeMeta.has(token); 621 const isBool = ['sim', 'yes', 'si', '1', 'true'].includes(mVal.toLowerCase()); 622 const displayLabel = isBool ? mKey : `${mKey}: ${mVal}`; 623 const safeKey = mKey.replace(/'/g, "\\'"); 624 const safeVal = mVal.replace(/'/g, "\\'"); 625 html += ` 626 <li class="filter-checkbox-item"> 627 <label style="display: flex; align-items: center; cursor: pointer; min-width: 0; width: 100%; user-select: none;"> 628 <input type="checkbox" ${isSel ? 'checked' : ''} onchange="PopStoreFilterEngine.toggleMeta('${safeKey}', '${safeVal}')"> 629 <span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;" title="${displayLabel}">${displayLabel}</span> 630 </label> 631 </li>`; 632 }); 633 }); 634 635 html += ` 636 </ul> 637 </div>`; 638 } 639 640 // 4. MARCAS 641 const brandKeys = Object.keys(brandsMap).sort(); 642 if (brandKeys.length > 0) { 643 html += ` 644 <div class="filter-group-block"> 645 <h3 class="filter-group-title"> 646 <span>${tJs('brands')}</span> 647 </h3> 648 <ul class="filter-checkboxes-list"> 649 ${brandKeys.map(b => { 650 const isSel = state.activeBrands.has(b); 651 const safeB = b.replace(/'/g, "\\'"); 652 return ` 653 <li class="filter-checkbox-item"> 654 <label style="display: flex; align-items: center; cursor: pointer; min-width: 0; width: 100%; user-select: none;"> 655 <input type="checkbox" ${isSel ? 'checked' : ''} onchange="PopStoreFilterEngine.toggleBrand('${safeB}')">
656 <span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;">${b}</span> 657 </label> 658 </li>`; 659 }).join('')} 660 </ul> 661 </div>`; 662 } 663 664 // 5. COLEÃÃES 665 const colKeys = Object.keys(colsMap).sort(); 666 if (colKeys.length > 0) { 667 html += ` 668 <div class="filter-group-block"> 669 <h3 class="filter-group-title"> 670 <span>${tJs('collections')}</span> 671 </h3> 672 <ul class="filter-checkboxes-list"> 673 ${colKeys.map(col => { 674 const isSel = state.activeCollections.has(col); 675 const safeCol = col.replace(/'/g, "\\'"); 676 return ` 677 <li class="filter-checkbox-item"> 678 <label style="display: flex; align-items: center; cursor: pointer; min-width: 0; width: 100%; user-select: none;"> 679 <input type="checkbox" ${isSel ? 'checked' : ''} onchange="PopStoreFilterEngine.toggleCollection('${safeCol}')"> 680 <span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;">${col}</span> 681 </label> 682 </li>`; 683 }).join('')} 684 </ul> 685 </div>`; 686 } 687 688 const dContainer = document.getElementById('desktop-filters-container'); 689 if (dContainer) dContainer.innerHTML = html; 690 691 const mContainer = document.getElementById('mobile-filters-container'); 692 if (mContainer) mContainer.innerHTML = html; 693 694 renderActiveTags(); 695 } 696 697 /** 698 * Renderiza as tags de filtros ativos com botão (x) para remover individualmente 699 */ 700 function renderActiveTags() { 701 const { colorsMap } = buildFilterData(); 702 const tags = [];
703 state.activeSizes.forEach(s => tags.push({ label: `${tJs('sizeAbbr')}: ${s}`, action: () => toggleSize(s) })); 704 state.activeColors.forEach(cCode => { 705 let labelName = colorsMap[cCode]?.name || cCode.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); 706 if (isReferenceCode(labelName)) { 707 labelName = formatToneLabel(cCode); 708 } 709 tags.push({ label: `${tJs('colorAbbr')}: ${labelName}`, action: () => toggleColor(cCode) }); 710 }); 711 state.activeTypes.forEach(t => tags.push({ label: t, action: () => toggleType(t) })); 712 state.activeMeta.forEach(token => { 713 const [k, ...vParts] = token.split(':::'); 714 const v = vParts.join(':::'); 715 const isBool = ['sim', 'yes', 'si', '1', 'true'].includes(v.toLowerCase()); 716 const label = isBool ? k : `${k}: ${v}`; 717 tags.push({ label: label, action: () => toggleMeta(k, v) }); 718 }); 719 state.activeBrands.forEach(b => tags.push({ label: b, action: () => toggleBrand(b) })); 720 state.activeCollections.forEach(col => tags.push({ label: col, action: () => toggleCollection(col) })); 721 722 state.activeDeals.forEach(dealKey => { 723 let label = dealKey; 724 if (dealKey === 'novidades') label = tJs('newIn'); 725 else if (dealKey === 'promo') label = tJs('sale'); 726 else if (dealKey === 'outlet') label = tJs('outlet'); 727 else if (dealKey.startsWith('d')) { 728 const s = dealKey.substring(1); 729 label = s >= 60 ? `${s}%+ OFF` : `${s}% OFF`; 730 } 731 tags.push({ label: label, action: () => toggleDeal(dealKey) }); 732 }); 733 734 if (state.priceMin !== null || state.priceMax !== null) { 735 const hasMin = state.priceMin !== null && state.priceMin > state.boundsMin; 736 const hasMax = state.priceMax !== null && state.priceMax < state.boundsMax; 737 if (hasMin || hasMax) { 738 const pMinDisplay = formatPrice(state.priceMin !== null ? state.priceMin : state.boundsMin); 739 const pMaxDisplay = formatPrice(state.priceMax !== null ? state.priceMax : state.boundsMax); 740 tags.push({ 741 label: `${tJs('price')}: ${pMinDisplay} - ${pMaxDisplay}`, 742 action: () => { 743 state.priceMin = null; 744 state.priceMax = null; 745 syncAllPriceSliders(state.boundsMin, state.boundsMax); 746 apply(); 747 } 748 }); 749 } 750 } 751 752 const totalActive = tags.length; 753 const btnClear = document.getElementById('btn-clear-all-filters-desktop'); 754 if (btnClear) btnClear.style.display = totalActive > 0 ? 'inline' : 'none'; 755 756 const badgeMobile = document.getElementById('mobile-filter-count-badge'); 757 if (badgeMobile) { 758 badgeMobile.style.display = totalActive > 0 ? 'inline-flex' : 'none'; 759 badgeMobile.textContent = totalActive; 760 } 761 762 const tagsHtml = tags.map((t, idx) => ` 763 <span class="active-filter-tag"> 764 <span>${t.label}</span> 765 <span class="tag-remove" onclick="PopStoreFilterEngine.removeTag(${idx})">×</span> 766 </span> 767 `).join(''); 768 769 window._activeTagsActions = tags.map(t => t.action); 770 771 const dTags = document.getElementById('desktop-active-filter-tags'); 772 if (dTags) dTags.innerHTML = tagsHtml; 773 774 const mTags = document.getElementById('mobile-active-filter-tags'); 775 if (mTags) mTags.innerHTML = tagsHtml; 776 } 777 778 function removeTag(idx) { 779 if (window._activeTagsActions && window._activeTagsActions[idx]) { 780 window._activeTagsActions[idx](); 781 } 782 } 783 784 let priceDebounceTimer = null; 785 786 /** 787 * Manipula o input em tempo real dos sliders duplos de preço 788 */ 789 function handlePriceInput(inputEl, type) { 790 const container = inputEl.closest('.filter-price-slider-container'); 791 if (!container) return; 792 const rangeMin = container.querySelector('.range-min'); 793 const rangeMax = container.querySelector('.range-max'); 794 if (!rangeMin || !rangeMax) return; 795 796 let valMin = parseFloat(rangeMin.value); 797 let valMax = parseFloat(rangeMax.value); 798 799 if (type === 'min') { 800 rangeMin.classList.add('is-active'); 801 rangeMax.classList.remove('is-active'); 802 if (valMin > valMax) { 803 valMin = valMax; 804 rangeMin.value = valMin; 805 } 806 } else { 807 rangeMax.classList.add('is-active'); 808 rangeMin.classList.remove('is-active'); 809 if (valMax < valMin) { 810 valMax = valMin; 811 rangeMax.value = valMax; 812 } 813 } 814 815 state.priceMin = valMin; 816 state.priceMax = valMax; 817 818 syncAllPriceSliders(valMin, valMax); 819 820 clearTimeout(priceDebounceTimer); 821 priceDebounceTimer = setTimeout(() => { 822 apply(true); 823 }, 300); 824 } 825 826 function applyPriceFilter() { 827 clearTimeout(priceDebounceTimer); 828 apply(true); 829 } 830 831 function syncAllPriceSliders(valMin, valMax) { 832 const containers = document.querySelectorAll('.filter-price-slider-container');
833 containers.forEach(container => { 834 const rangeMin = container.querySelector('.range-min'); 835 const rangeMax = container.querySelector('.range-max'); 836 const fill = container.querySelector('.filter-price-track-fill'); 837 const minDisp = container.querySelector('.price-min-display'); 838 const maxDisp = container.querySelector('.price-max-display'); 839 840 const bMin = parseFloat(container.getAttribute('data-min')) || state.boundsMin || 0; 841 const bMax = parseFloat(container.getAttribute('data-max')) || state.boundsMax || 1000; 842 const span = Math.max(1, bMax - bMin); 843 844 const safeMin = Math.min(valMin, valMax); 845 const safeMax = Math.max(valMin, valMax); 846 847 if (rangeMin && rangeMin !== document.activeElement) rangeMin.value = safeMin; 848 if (rangeMax && rangeMax !== document.activeElement) rangeMax.value = safeMax; 849 850 if (minDisp) minDisp.textContent = formatPrice(safeMin); 851 if (maxDisp) maxDisp.textContent = formatPrice(safeMax); 852 853 if (fill) { 854 const pctMin = Math.max(0, Math.min(100, ((safeMin - bMin) / span) * 100)); 855 const pctMax = Math.max(0, Math.min(100, ((safeMax - bMin) / span) * 100)); 856 if (pctMin >= pctMax) { 857 fill.style.display = 'none'; 858 } else { 859 fill.style.display = ''; 860 fill.style.left = `calc(${pctMin}% + ${10 - 0.2 * pctMin}px)`; 861 fill.style.right = `calc(${100 - pctMax}% + ${10 - 0.2 * (100 - pctMax)}px)`; 862 fill.style.width = 'auto'; 863 } 864 } 865 }); 866 } 867 868 /** 869 * Verifica se um card corresponde aos filtros ativos de cor 870 */ 871 function cardMatchesColor(card) { 872 if (state.activeColors.size === 0) return true; 873 874 const activeColorsCanon = Array.from(state.activeColors).map(c => canonicalColorSlug(c)); 875 876 // 1. Verifica tokens de grupos/tons (ex: 'verde', 'azul', 'rosa') 877 try { 878 const tokensRaw = card.getAttribute('data-color-tokens'); 879 if (tokensRaw) { 880 const tokens = JSON.parse(tokensRaw); 881 if (Array.isArray(tokens) && tokens.some(t => { 882 const tCanon = canonicalColorSlug(t); 883 return activeColorsCanon.includes(tCanon) || state.activeColors.has(tCanon) || state.activeColors.has(t); 884 })) { 885 return true; 886 } 887 } 888 } catch (e) {} 889 890 // 2. Verifica nomes exatos das cores oficiais da loja 891 try { 892 const namesRaw = card.getAttribute('data-color-names'); 893 if (namesRaw) { 894 const names = JSON.parse(namesRaw); 895 if (Array.isArray(names) && names.some(n => { 896 const nCanon = canonicalColorSlug(n); 897 return activeColorsCanon.includes(nCanon) || state.activeColors.has(nCanon) || state.activeColors.has(n); 898 })) { 899 return true; 900 } 901 } 902 } catch (e) {} 903 904 // 3. Fallback para atributos diretos do card 905 const colorCode = canonicalColorSlug(card.getAttribute('data-color-code') || ''); 906 const colorName = canonicalColorSlug(card.getAttribute('data-color-name') || ''); 907 const color = canonicalColorSlug(card.getAttribute('data-color') || ''); 908 909 if (colorCode && (activeColorsCanon.includes(colorCode) || state.activeColors.has(colorCode))) return true; 910 if (colorName && (activeColorsCanon.includes(colorName) || state.activeColors.has(colorName))) return true; 911 if (color && (activeColorsCanon.includes(color) || state.activeColors.has(color))) return true; 912 913 return false; 914 } 915 916 /** 917 * Verifica se um card corresponde aos filtros ativos de tamanho com estoque 918 */ 919 function cardMatchesSize(card) { 920 if (state.activeSizes.size === 0) return true; 921 try { 922 const raw = card.getAttribute('data-sizes'); 923 if (!raw) return false;
924 const sizes = JSON.parse(raw); 925 if (!Array.isArray(sizes)) return false; 926 return sizes.some(s => { 927 const clean = normalizeSize(s); 928 return clean && state.activeSizes.has(clean); 929 }); 930 } catch (_) { 931 return false; 932 } 933 } 934 935 /** 936 * Verifica se um card corresponde aos filtros ativos de tipos / partes do produto 937 */ 938 function cardMatchesType(card) { 939 if (state.activeTypes.size === 0) return true; 940 const activeLower = Array.from(state.activeTypes).map(x => String(x).toLowerCase().trim()); 941 const t = (card.getAttribute('data-type') || '').toLowerCase().trim(); 942 if (t && activeLower.includes(t)) return true; 943 try { 944 const raw = card.getAttribute('data-types'); 945 if (raw) { 946 const types = JSON.parse(raw); 947 if (Array.isArray(types) && types.some(x => activeLower.includes(String(x).toLowerCase().trim()))) { 948 return true; 949 } 950 } 951 } catch (_) {} 952 return false; 953 } 954 955 /** 956 * Verifica se um card corresponde aos filtros ativos de marca 957 */ 958 function cardMatchesBrand(card) { 959 if (state.activeBrands.size === 0) return true; 960 const b = (card.getAttribute('data-brand') || '').trim(); 961 return b && state.activeBrands.has(b); 962 } 963 964 /** 965 * Verifica se um card corresponde aos filtros ativos de coleção 966 */ 967 function cardMatchesCollection(card) { 968 if (state.activeCollections.size === 0) return true; 969 const c = (card.getAttribute('data-collection') || '').trim(); 970 return c && state.activeCollections.has(c); 971 } 972 973 /** 974 * Verifica se um card corresponde à faixa de preço ativa 975 */ 976 function cardMatchesPrice(card) { 977 const p = parseFloat(card.getAttribute('data-price')) || 0; 978 if (state.priceMin !== null && p < state.priceMin) return false; 979 if (state.priceMax !== null && p > state.priceMax) return false; 980 return true; 981 } 982 983 /** 984 * Verifica se um card corresponde aos filtros de oportunidade / descontos 985 */ 986 function cardMatchesDeal(card) { 987 if (state.activeDeals.size === 0) return true; 988 const disc = parseFloat(card.getAttribute('data-discount')) || 0; 989 for (const deal of state.activeDeals) { 990 if (deal === 'novidades' && disc <= 0) return true; 991 if ((deal === 'promocoes' || deal === 'promo') && disc > 0 && disc < 50) return true; 992 if (deal === 'outlet' && disc >= 50) return true; 993 if (deal.startsWith('disc_')) { 994 const minDisc = parseInt(deal.replace('disc_', ''), 10) || 0; 995 if (disc >= minDisc) return true; 996 } 997 if (/^d\d+$/.test(deal)) { 998 const step = parseInt(deal.substring(1), 10) || 0; 999 if (step >= 60) { 1000 if (disc >= step) return true; 1001 } else { 1002 if (disc >= step && disc < (step + 10)) return true; 1003 } 1004 } 1005 } 1006 return false; 1007 } 1008 1009 /** 1010 * Verifica se um card corresponde aos filtros ativos de metadados / especificações 1011 */ 1012 function cardMatchesMeta(card) { 1013 if (state.activeMeta.size === 0) return true; 1014 try { 1015 const metaRaw = card.getAttribute('data-metadata'); 1016 if (!metaRaw) return false; 1017 let metaObj = JSON.parse(metaRaw); 1018 if (typeof metaObj === 'string') { 1019 metaObj = JSON.parse(metaObj); 1020 } 1021 if (!metaObj || typeof metaObj !== 'object') return false; 1022 if (metaObj.br || metaObj.pt) { 1023 metaObj = metaObj.br || metaObj.pt; 1024 } 1025 1026 const requiredGroups = {}; 1027 state.activeMeta.forEach(token => { 1028 const [k, ...vParts] = token.split(':::'); 1029 const v = vParts.join(':::').toLowerCase(); 1030 if (!requiredGroups[k]) requiredGroups[k] = []; 1031 requiredGroups[k].push(v); 1032 }); 1033 1034 for (const [k, allowedVals] of Object.entries(requiredGroups)) { 1035 const cardVal = metaObj[k]; 1036 if (cardVal === undefined || cardVal === null) return false;
1037 const strVal = String(cardVal).trim().toLowerCase(); 1038 if (!allowedVals.includes(strVal)) return false; 1039 } 1040 return true; 1041 } catch (e) { 1042 return false; 1043 } 1044 } 1045 1046 /** 1047 * Sincroniza o estado atual dos filtros na URL da barra de endereços (history.replaceState) 1048 */ 1049 function syncUrlParams() { 1050 try { 1051 const url = new URL(window.location.href); 1052 const params = url.searchParams; 1053 1054 // 0. Preço 1055 if (state.priceMin !== null && state.priceMin > state.boundsMin) { 1056 params.set('pmin', Math.round(state.priceMin)); 1057 } else { 1058 params.delete('pmin'); 1059 } 1060 if (state.priceMax !== null && state.priceMax < state.boundsMax) { 1061 params.set('pmax', Math.round(state.priceMax)); 1062 } else { 1063 params.delete('pmax'); 1064 } 1065 1066 // 1. Cores 1067 if (state.activeColors.size > 0) { 1068 params.set('cores', Array.from(state.activeColors).join(',')); 1069 } else { 1070 params.delete('cores'); 1071 params.delete('colors'); 1072 params.delete('cor'); 1073 } 1074 1075 // 2. Tamanhos 1076 if (state.activeSizes.size > 0) { 1077 params.set('tamanhos', Array.from(state.activeSizes).join(',')); 1078 } else { 1079 params.delete('tamanhos'); 1080 params.delete('sizes'); 1081 params.delete('tamanho'); 1082 } 1083 1084 // 3. Tipos 1085 if (state.activeTypes.size > 0) { 1086 params.set('tipos', Array.from(state.activeTypes).join(',')); 1087 } else { 1088 params.delete('tipos'); 1089 params.delete('types'); 1090 params.delete('tipo'); 1091 } 1092 1093 // Metadados / Especificações 1094 if (state.activeMeta.size > 0) { 1095 params.set('meta', Array.from(state.activeMeta).join(';;')); 1096 } else { 1097 params.delete('meta'); 1098 params.delete('specs'); 1099 } 1100 1101 // 4. Marcas 1102 if (state.activeBrands.size > 0) { 1103 params.set('marcas', Array.from(state.activeBrands).join(',')); 1104 } else { 1105 params.delete('marcas'); 1106 params.delete('brands'); 1107 params.delete('marca'); 1108 } 1109 1110 // 5. Coleções 1111 if (state.activeCollections.size > 0) { 1112 params.set('colecoes', Array.from(state.activeCollections).join(',')); 1113 } else { 1114 params.delete('colecoes'); 1115 params.delete('collections'); 1116 params.delete('colecao'); 1117 } 1118 1119 // Oportunidades & Descontos 1120 if (state.activeDeals.size > 0) { 1121 params.set('descontos', Array.from(state.activeDeals).join(',')); 1122 } else { 1123 params.delete('descontos'); 1124 params.delete('deals'); 1125 } 1126 1127 // 6. Ordenação 1128 if (state.sort && state.sort !== 'default') { 1129 params.set('ordem', state.sort); 1130 } else { 1131 params.delete('ordem'); 1132 params.delete('sort'); 1133 } 1134 1135 const newSearch = params.toString(); 1136 const newUrl = url.pathname + (newSearch ? '?' + newSearch : '') + url.hash; 1137 1138 if (window.location.pathname + window.location.search !== url.pathname + (newSearch ? '?' + newSearch : '')) { 1139 window.history.replaceState({ popstore_filters: true }, '', newUrl); 1140 } 1141 } catch (e) { 1142 console.warn('Erro ao sincronizar parâmetros da URL:', e); 1143 } 1144 } 1145 1146 /** 1147 * Carrega e ativa os filtros passados na query string da URL (para links compartilhados) 1148 */ 1149 function loadStateFromUrl() { 1150 try { 1151 const params = new URLSearchParams(window.location.search); 1152 let hasChanges = false; 1153 1154 // 0. Preço 1155 const pmin = params.get('pmin'); 1156 const pmax = params.get('pmax'); 1157 if (pmin !== null && !isNaN(pmin)) { 1158 state.priceMin = parseFloat(pmin); 1159 hasChanges = true; 1160 } else { 1161 state.priceMin = null; 1162 } 1163 if (pmax !== null && !isNaN(pmax)) { 1164 state.priceMax = parseFloat(pmax); 1165 hasChanges = true; 1166 } else { 1167 state.priceMax = null; 1168 } 1169 1170 // Oportunidades & Descontos 1171 state.activeDeals.clear(); 1172 const rawDeals = params.get('descontos') || params.get('deals'); 1173 if (rawDeals) { 1174 rawDeals.split(',').forEach(d => { 1175 const clean = d.trim(); 1176 if (clean) { 1177 state.activeDeals.add(clean); 1178 hasChanges = true; 1179 } 1180 }); 1181 } 1182 1183 // 1. Cores 1184 state.activeColors.clear(); 1185 const rawColors = params.get('cores') || params.get('colors') || params.get('cor'); 1186 if (rawColors) { 1187 rawColors.split(',').forEach(c => { 1188 const clean = c.trim().toLowerCase(); 1189 if (clean) { 1190 const canon = canonicalColorSlug(clean); 1191 state.activeColors.add(canon); 1192 hasChanges = true; 1193 } 1194 }); 1195 } 1196 1197 // 2. Tamanhos 1198 state.activeSizes.clear(); 1199 const rawSizes = params.get('tamanhos') || params.get('sizes') || params.get('tamanho'); 1200 if (rawSizes) { 1201 rawSizes.split(',').forEach(s => { 1202 const clean = normalizeSize(s.trim()); 1203 if (clean) { 1204 state.activeSizes.add(clean); 1205 hasChanges = true; 1206 } 1207 }); 1208 } 1209 1210 // 3. Tipos 1211 state.activeTypes.clear(); 1212 const rawTypes = params.get('tipos') || params.get('types') || params.get('tipo'); 1213 if (rawTypes) { 1214 rawTypes.split(',').forEach(t => { 1215 const clean = t.trim(); 1216 if (clean) { 1217 state.activeTypes.add(clean); 1218 hasChanges = true; 1219 } 1220 }); 1221 } 1222 1223 // Metadados / Especificações 1224 state.activeMeta.clear(); 1225 const rawMeta = params.get('meta') || params.get('specs'); 1226 if (rawMeta) { 1227 rawMeta.split(';;').forEach(item => { 1228 const clean = item.trim(); 1229 if (clean && clean.includes(':::')) { 1230 state.activeMeta.add(clean); 1231 hasChanges = true; 1232 } 1233 }); 1234 } 1235 1236 // 4. Marcas 1237 state.activeBrands.clear(); 1238 const rawBrands = params.get('marcas') || params.get('brands') || params.get('marca'); 1239 if (rawBrands) { 1240 rawBrands.split(',').forEach(b => { 1241 const clean = b.trim(); 1242 if (clean) { 1243 state.activeBrands.add(clean); 1244 hasChanges = true; 1245 } 1246 }); 1247 } 1248 1249 // 5. Coleções 1250 state.activeCollections.clear(); 1251 const rawCols = params.get('colecoes') || params.get('collections') || params.get('colecao'); 1252 if (rawCols) { 1253 rawCols.split(',').forEach(col => { 1254 const clean = col.trim(); 1255 if (clean) { 1256 state.activeCollections.add(clean); 1257 hasChanges = true; 1258 } 1259 }); 1260 } 1261 1262 // 6. Ordenação 1263 const rawSort = params.get('ordem') || params.get('sort'); 1264 if (rawSort) { 1265 state.sort = rawSort; 1266 const sortSelect = document.getElementById('pop-catalog-sort-select') || document.getElementById('catalog-sort-select') || document.getElementById('sort-by-select'); 1267 if (sortSelect) sortSelect.value = rawSort; 1268 if (rawSort !== 'default') { 1269 hasChanges = true; 1270 } 1271 } 1272 1273 return hasChanges; 1274 } catch (e) { 1275 console.warn('Erro ao carregar filtros da URL:', e); 1276 return false;
1277 } 1278 } 1279 1280 function syncActivePillClasses() { 1281 document.querySelectorAll('[data-filter-size]').forEach(el => { 1282 const sz = el.getAttribute('data-filter-size'); 1283 const isSel = state.activeSizes.has(sz); 1284 el.classList.toggle('is-active', isSel); 1285 el.classList.toggle('is-selected', isSel); 1286 }); 1287 document.querySelectorAll('[data-filter-color]').forEach(el => { 1288 const clr = el.getAttribute('data-filter-color'); 1289 const isSel = state.activeColors.has(clr); 1290 el.classList.toggle('is-active', isSel); 1291 el.classList.toggle('is-selected', isSel); 1292 }); 1293 document.querySelectorAll('input[data-filter-type]').forEach(el => { 1294 const t = el.getAttribute('data-filter-type'); 1295 el.checked = state.activeTypes.has(t); 1296 }); 1297 document.querySelectorAll('input[data-filter-brand]').forEach(el => { 1298 const b = el.getAttribute('data-filter-brand'); 1299 el.checked = state.activeBrands.has(b); 1300 }); 1301 document.querySelectorAll('input[data-filter-col]').forEach(el => { 1302 const col = el.getAttribute('data-filter-col'); 1303 el.checked = state.activeCollections.has(col); 1304 }); 1305 document.querySelectorAll('[data-deal]').forEach(el => { 1306 const deal = el.getAttribute('data-deal'); 1307 const isSel = state.activeDeals.has(deal); 1308 el.classList.toggle('is-active', isSel); 1309 el.classList.toggle('is-selected', isSel); 1310 }); 1311 document.querySelectorAll('input[data-filter-meta]').forEach(el => { 1312 const token = el.getAttribute('data-filter-meta'); 1313 el.checked = state.activeMeta.has(token); 1314 }); 1315 } 1316 1317 let filterAbortController = null; 1318 1319 /** 1320 * Aplica os filtros ativos recarregando o lote de produtos correspondentes a partir do offset 0 1321 * Garante paginação e scroll infinito consistentes em qualquer tamanho de catálogo 1322 */ 1323 async function apply(updateUrl = true) { 1324 syncActivePillClasses(); 1325 renderActiveTags(); 1326 if (updateUrl) { 1327 syncUrlParams(); 1328 } 1329 1330 const container = document.getElementById('pop-catalog-grid') || document.getElementById('products-grid-container') || document.querySelector('.products-grid-container'); 1331 const loader = document.getElementById('pop-catalog-stream-loader') || document.getElementById('catalog-stream-loader'); 1332 if (!container) return; 1333 1334 // Feedback visual imediato: reduz levemente opacidade durante reload 1335 container.style.transition = 'opacity 0.15s ease'; 1336 container.style.opacity = '0.4'; 1337 container.style.pointerEvents = 'none'; 1338 1339 if (filterAbortController) { 1340 filterAbortController.abort(); 1341 } 1342 filterAbortController = new AbortController(); 1343 1344 try { 1345 const currentUrl = new URL(window.location.href); 1346 currentUrl.searchParams.set('partial_grid', '1'); 1347 currentUrl.searchParams.delete('offset'); 1348 currentUrl.searchParams.delete('limit'); 1349 currentUrl.searchParams.delete('page'); 1350 currentUrl.searchParams.delete('partial_batch'); 1351 1352 const res = await fetch(currentUrl.toString(), { 1353 signal: filterAbortController.signal, 1354 headers: { 'X-Requested-With': 'XMLHttpRequest' } 1355 }); 1356 if (!res.ok) throw new Error('HTTP ' + res.status); 1357 1358 const data = await res.json(); 1359 1360 // Atualiza grid HTML 1361 container.innerHTML = data.html || ''; 1362 container.style.opacity = '1'; 1363 container.style.pointerEvents = 'auto'; 1364 1365 // Rola a tela suavemente para o inÃcio dos produtos caso o usuário estivesse rolado para baixo 1366 const catalogTopTarget = document.getElementById('pop-catalog-header') || document.getElementById('pop-catalog-layout') || container; 1367 if (catalogTopTarget) { 1368 const currentY = window.pageYOffset || document.documentElement.scrollTop || 0; 1369 const targetY = catalogTopTarget.getBoundingClientRect().top + currentY - 16; 1370 if (currentY > targetY + 60) { 1371 window.scrollTo({ 1372 top: Math.max(0, targetY), 1373 behavior: 'smooth' 1374 }); 1375 } 1376 } 1377 1378 // Atualiza contadores 1379 const total = parseInt(data.totalCount) || 0; 1380 const loaded = parseInt(data.loadedCount) || 0; 1381 1382 const countBadge = document.getElementById('catalog-visible-count'); 1383 if (countBadge) countBadge.textContent = total.toLocaleStr
1383ing(LOCALE_CODE); 1384 1385 const drawerCount = document.getElementById('mobile-drawer-apply-count'); 1386 if (drawerCount) drawerCount.textContent = total.toLocaleString(LOCALE_CODE); 1387 1388 // Atualiza Stream Loader com novo baseUrl, offset e total 1389 if (loader) { 1390 const streamBaseUrl = new URL(window.location.pathname, window.location.origin); 1391 const currentParams = new URLSearchParams(window.location.search); 1392 currentParams.forEach((v, k) => { 1393 if (k !== 'partial_batch' && k !== 'partial_grid' && k !== 'offset' && k !== 'limit' && k !== 'page') { 1394 streamBaseUrl.searchParams.set(k, v); 1395 } 1396 }); 1397 streamBaseUrl.searchParams.set('partial_batch', '1'); 1398 1399 loader.setAttribute('data-base-url', streamBaseUrl.pathname + '?' + streamBaseUrl.searchParams.toString()); 1400 loader.setAttribute('data-offset', loaded); 1401 loader.setAttribute('data-loaded-count', loaded); 1402 loader.setAttribute('data-total-count', total); 1403 1404 const statusText = document.getElementById('stream-status-text'); 1405 if (statusText) { 1406 statusText.innerHTML = `${tJs('loadingMore')} (<b>${loaded}</b> ${tJs('of')} <b>${total}</b>)`; 1407 } 1408 1409 if (total > loaded) { 1410 loader.style.display = ''; 1411 loader.style.opacity = '1'; 1412 } else { 1413 loader.style.display = 'none'; 1414 } 1415 } 1416 1417 // Re-sincroniza componentes auxiliares 1418 if (window.PopStoreWishlist && typeof PopStoreWishlist.syncCards === 'function') { 1419 PopStoreWishlist.syncCards(container); 1420 } 1421 if (typeof initCardVideos === 'function') { 1422 initCardVideos(container); 1423 } 1424 if (typeof lucide !== 'undefined') { 1425 lucide.createIcons(); 1426 } 1427 1428 setupInfiniteScroll(); 1429 1430 } catch (err) { 1431 if (err.name === 'AbortError') return; 1432 console.error('Erro ao atualizar catálogo por filtro:', err); 1433 container.style.opacity = '1'; 1434 container.style.pointerEvents = 'auto'; 1435 } 1436 } 1437 1438 /** 1439 * Ordena os cards no DOM respeitando a opção selecionada pelo usuário 1440 */ 1441 function sortCards(sortKey) { 1442 const container = document.getElementById('pop-catalog-grid') || document.getElementById('products-grid-container') || document.querySelector('.products-grid-container'); 1443 if (!container) return; 1444 1445 const cards = getAllCards(); 1446 cards.sort((a, b) => { 1447 if (sortKey === 'news') { 1448 const dateB = parseInt(b.getAttribute('data-date')) || parseInt(b.getAttribute('data-product-id')) || 0; 1449 const dateA = parseInt(a.getAttribute('data-date')) || parseInt(a.getAttribute('data-product-id')) || 0; 1450 if (dateB !== dateA) return dateB - dateA; 1451 return (parseInt(b.getAttribute('data-product-id')) || 0) - (parseInt(a.getAttribute('data-product-id')) || 0); 1452 } 1453 if (sortKey === 'price_asc') { 1454 const priceA = parseFloat(a.getAttribute('data-price')) || 0; 1455 const priceB = parseFloat(b.getAttribute('data-price')) || 0; 1456 if (priceA !== priceB) return priceA - priceB; 1457 return (parseInt(a.getAttribute('data-initial-order')) || 0) - (parseInt(b.getAttribute('data-initial-order')) || 0); 1458 } 1459 if (sortKey === 'price_desc') { 1460 const priceA = parseFloat(a.getAttribute('data-price')) || 0; 1461 const priceB = parseFloat(b.getAttribute('data-price')) || 0; 1462 if (priceA !== priceB) return priceB - priceA; 1463 return (parseInt(a.getAttribute('data-initial-order')) || 0) - (parseInt(b.getAttribute('data-initial-order')) || 0); 1464 } 1465 if (sortKey === 'discounts') { 1466 const discA = parseInt(a.getAttribute('data-discount')) || 0; 1467 const discB = parseInt(b.getAttribute('data-discount')) || 0; 1468 if (discB !== discA) return discB - discA; 1469 return (parseInt(a.getAttribute('data-initial-order')) || 0) - (parseInt(b.getAttribute('data-initial-order')) || 0); 1470 } 1471 if (sortKey === 'popular') { 1472 const likesA = parseInt(a.getAttribute('data-likes')) || 0; 1473 const likesB = parseInt(b.getAttribute('data-likes')) || 0; 1474 if (likesB !== likesA) return likesB - likesA; 1475 return (parseInt(a.getAttribute('data-initial-order')) || 0) - (parseInt(b.getAttribute('data-initial-order')) || 0); 1476 } 1477 if (sortKey === 'name_asc') { 1478 const nameA = a.getAttribute('data-name') || ''; 1479 const nameB = b.getAttribute('data-name') || ''; 1480 const cmp = nameA.localeCompare(nameB, LOCALE_CODE, { numeric: true, sensitivity: 'base' }); 1481 if (cmp !== 0) return cmp; 1482 return (parseInt(a.getAttribute('data-initial-order')) || 0) - (parseInt(b.getAttribute('data-initial-order')) || 0); 1483 } 1484 if (sortKey === 'name_desc') { 1485 const nameA = a.getAttribute('data-name') || ''; 1486 const nameB = b.getAttribute('data-name') || ''; 1487 const cmp = nameB.localeCompare(nameA, LOCALE_CODE, { numeric: true, sensitivity: 'base' }); 1488 if (cmp !== 0) return cmp; 1489 return (parseInt(a.getAttribute('data-initial-order')) || 0) - (parseInt(b.getAttribute('data-initial-order')) || 0); 1490 } 1491 // Padrão / Relevância da Busca: preserva a ordem natural de chegada/offset 1492 return (parseInt(a.getAttribute('data-initial-order')) || 0) - (parseInt(b.getAttribute('data-initial-order')) || 0); 1493 }); 1494 1495 cards.forEach(c => container.appendChild(c)); 1496 } 1497 1498 function toggleSize(sz) { 1499 sz = normalizeSize(sz); 1500 if (state.activeSizes.has(sz)) state.activeSizes.delete(sz); 1501 else state.activeSizes.add(sz); 1502 apply(); 1503 } 1504 1505 function toggleColor(clr) { 1506 clr = canonicalColorSlug(clr); 1507 if (state.activeColors.has(clr)) state.activeColors.delete(clr); 1508 else state.activeColors.add(clr); 1509 apply(); 1510 } 1511 1512 function toggleType(t) { 1513 if (state.activeTypes.has(t)) state.activeTypes.delete(t); 1514 else state.activeTypes.add(t); 1515 apply(); 1516 } 1517 1518 function toggleMeta(k, v) { 1519 const token = `${k}:::${v}`; 1520 if (state.activeMeta.has(token)) state.activeMeta.delete(token); 1521 else state.activeMeta.add(token); 1522 apply(); 1523 } 1524 1525 function toggleBrand(b) { 1526 if (state.activeBrands.has(b)) state.activeBrands.delete(b); 1527 else state.activeBrands.add(b); 1528 apply(); 1529 } 1530 1531 function toggleCollection(col) { 1532 if (state.activeCollections.has(col)) state.activeCollections.delete(col); 1533 else state.activeCollections.add(col); 1534 apply(); 1535 } 1536 1537 function toggleDeal(dealKey) { 1538 if (state.activeDeals.has(dealKey)) state.activeDeals.delete(
1538dealKey); 1539 else state.activeDeals.add(dealKey); 1540 apply(); 1541 } 1542 1543 function setSort(val) { 1544 state.sort = val || 'default'; 1545 const sortSelect = document.getElementById('pop-catalog-sort-select') || document.getElementById('catalog-sort-select') || document.getElementById('sort-by-select'); 1546 if (sortSelect && sortSelect.value !== state.sort) { 1547 sortSelect.value = state.sort; 1548 } 1549 apply(); 1550 } 1551 1552 function clearAll() { 1553 state.activeSizes.clear(); 1554 state.activeColors.clear(); 1555 state.activeTypes.clear(); 1556 state.activeMeta.clear(); 1557 state.activeBrands.clear(); 1558 state.activeCollections.clear(); 1559 state.activeDeals.clear(); 1560 state.priceMin = null; 1561 state.priceMax = null; 1562 syncAllPriceSliders(state.boundsMin, state.boundsMax); 1563 apply(); 1564 } 1565 1566 function openDrawer() { 1567 const backdrop = document.getElementById('mobile-filter-drawer-backdrop') || document.getElementById('pop-catalog-mobile-drawer-backdrop'); 1568 const drawer = document.getElementById('pop-catalog-mobile-drawer') || document.getElementById('mobile-filter-drawer'); 1569 if (backdrop) backdrop.classList.add('is-open'); 1570 if (drawer) drawer.classList.add('is-open'); 1571 document.body.style.overflow = 'hidden'; 1572 1573 const mContainer = document.getElementById('mobile-filters-container'); 1574 const dContainer = document.getElementById('desktop-filters-container'); 1575 if (mContainer && (!mContainer.children.length || !mContainer.querySelector('.filter-group-price'))) { 1576 if (dContainer && dContainer.innerHTML.trim().length > 0) { 1577 mContainer.innerHTML = dContainer.innerHTML; 1578 } 1579 } 1580 1581 syncActivePillClasses(); 1582 syncAllPriceSliders(state.priceMin !== null ? state.priceMin : state.boundsMin, state.priceMax !== null ? state.priceMax : state.boundsMax); 1583 } 1584 1585 function closeDrawer() { 1586 const backdrop = document.getElementById('mobile-filter-drawer-backdrop') || document.getElementById('pop-catalog-mobile-drawer-backdrop'); 1587 const drawer = document.getElementById('pop-catalog-mobile-drawer') || document.getElementById('mobile-filter-drawer'); 1588 if (backdrop) backdrop.classList.remove('is-open'); 1589 if (drawer) drawer.classList.remove('is-open'); 1590 document.body.style.overflow = ''; 1591 } 1592 1593 let infiniteObserver = null; 1594 1595 /** 1596 * Carregamento sob demanda / Scroll Infinito progressivo e suave 1597 * Busca os próximos produtos apenas quando o usuário se aproxima do final da página 1598 */ 1599 async function fetchNextBatch() { 1600 const loader = document.getElementById('pop-catalog-stream-loader') || document.getElementById('catalog-stream-loader'); 1601 const container = document.getElementById('pop-catalog-grid') || document.getElementById('products-grid-container') || document.querySelector('.products-grid-container'); 1602 if (!loader || !container || state.isStreaming) return; 1603 1604 let currentOffset = parseInt(loader.getAttribute('data-offset')) || 0; 1605 const chunkSize = parseInt(loader.getAttribute('data-chunk-size')) || 36; 1606 const totalCount = parseInt(loader.getAttribute('data-total-count')) || 0; 1607 const baseUrl = loader.getAttribute('data-base-url'); 1608 1609 if (!baseUrl || currentOffset >= totalCount) { 1610 loader.style.display = 'none'; 1611 return; 1612 } 1613 1614 state.isStreaming = true; 1615 1616 try { 1617 const streamUrl = `${baseUrl}&offset=${currentOffset}&limit=${chunkSize}`; 1618 const res = await fetch(streamUrl); 1619 if (!res.ok) throw new Error('HTTP ' + res.status); 1620 1621 const htmlCards = await res.text(); 1622 if (!htmlCards || htmlCards.trim().length === 0) { 1623 loader.style.display = 'none'; 1624 return; 1625 } 1626 1627 const tempDiv = document.createElement('div'); 1628 tempDiv.innerHTML = htmlCards; 1629 const newCards = Array.from(tempDiv.children); 1630 1631 if (newCards.length === 0) { 1632 loader.style.display = 'none'; 1633 return; 1634 } 1635 1636 // Anexa os novos cards no final do container 1637 const fragment = document.createDocumentFragment(); 1638 newCards.forEach(c => { 1639 c.style.display = ''; 1640 fragment.appendChild(c); 1641 }); 1642 container.appendChild(fragment); 1643 1644 currentOffset += newCards.length; 1645 loader.setAttribute('data-offset', currentOffset); 1646 loader.setAttribute('data-loaded-count', currentOffset); 1647 1648 const statusText = document.getElementById('stream-status-text'); 1649 if (statusText) { 1650 statusText.innerHTML = `${tJs('loadingMore')} (<b>${Math.min(currentOffset, totalCount)}</b> ${tJs('of')} <b>${totalCount}</b>)`; 1651 } 1652 1653 if (currentOffset >= totalCount) { 1654 loader.style.transition = 'opacity 0.25s ease'; 1655 loader.style.opacity = '0'; 1656 setTimeout(() => { 1657 loader.style.display = 'none'; 1658 loader.style.opacity = '1'; 1659 }, 250); 1660 } 1661 1662 if (window.PopStoreWishlist && typeof PopStoreWishlist.syncCards === 'function') { 1663 PopStoreWishlist.syncCards(container); 1664 } 1665 1666 if (typeof initCardVideos === 'function') { 1667 initCardVideos(container); 1668 } 1669 1670 if (typeof lucide !== 'undefined') { 1671 lucide.createIcons(); 1672 } 1673 1674 } catch (err) { 1675 console.error('Erro no scroll infinito do catálogo:', err); 1676 } finally { 1677 state.isStreaming = false;
1678 } 1679 } 1680 1681 /** 1682 * Configura o IntersectionObserver para scroll infinito 1683 */ 1684 function setupInfiniteScroll() { 1685 const loader = document.getElementById('pop-catalog-stream-loader') || document.getElementById('catalog-stream-loader'); 1686 if (!loader) return; 1687 1688 if (infiniteObserver) { 1689 infiniteObserver.disconnect(); 1690 } 1691 1692 loader.style.cursor = 'pointer'; 1693 loader.onclick = () => fetchNextBatch(); 1694 1695 if ('IntersectionObserver' in window) { 1696 infiniteObserver = new IntersectionObserver((entries) => { 1697 entries.forEach(entry => { 1698 if (entry.isIntersecting && !state.isStreaming) { 1699 fetchNextBatch(); 1700 } 1701 }); 1702 }, { 1703 root: null, 1704 rootMargin: '450px 0px 450px 0px', 1705 threshold: 0.01 1706 }); 1707 1708 infiniteObserver.observe(loader); 1709 } 1710 } 1711 1712 return { 1713 init: function() { 1714 // Inicializa limites reais a partir do container SSR se presente 1715 const priceContainer = document.querySelector('.filter-price-slider-container'); 1716 if (priceContainer) { 1717 const bMinAttr = parseFloat(priceContainer.getAttribute('data-min')); 1718 const bMaxAttr = parseFloat(priceContainer.getAttribute('data-max')); 1719 if (!isNaN(bMinAttr)) state.boundsMin = bMinAttr; 1720 if (!isNaN(bMaxAttr)) state.boundsMax = bMaxAttr; 1721 } 1722 1723 loadStateFromUrl(); 1724 1725 // Fallback de segurança: se por qualquer razão o servidor não gerou blocos SSR, popula via JS 1726 const dContainer = document.getElementById('desktop-filters-container'); 1727 if (dContainer && (!dContainer.children.length || !dContainer.querySelector('.filter-group-block'))) { 1728 dContainer.innerHTML = renderFiltersUI(); 1729 } 1730 1731 // Sincroniza visualmente os sliders de preço com os valores da URL e limites da página 1732 const initValMin = state.priceMin !== null ? state.priceMin : state.boundsMin; 1733 const initValMax = state.priceMax !== null ? state.priceMax : state.boundsMax; 1734 syncAllPriceSliders(initValMin, initValMax); 1735 1736 syncActivePillClasses(); 1737 renderActiveTags(); 1738 1739 window.addEventListener('popstate', () => { 1740 loadStateFromUrl(); 1741 apply(false); 1742 }); 1743 1744 setupInfiniteScroll(); 1745 }, 1746 toggleSize, 1747 toggleColor, 1748 toggleType, 1749 toggleMeta, 1750 toggleBrand, 1751 toggleCollection, 1752 toggleDeal, 1753 handlePriceInput, 1754 applyPriceFilter, 1755 setSort, 1756 clearAll, 1757 openDrawer, 1758 closeDrawer, 1759 removeTag 1760 }; 1761})(); 1762 1763// Inicializa o motor de filtros assim que o DOM estiver pronto 1764if (document.readyState === 'loading') { 1765 document.addEventListener('DOMContentLoaded', () => PopStoreFilterEngine.init()); 1766} else { 1767 PopStoreFilterEngine.init(); 1768} 1769 1770if (typeof lucide !== 'undefined') { 1771 lucide.createIcons(); 1772}
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.