1// /homepage/homepage.js 2import { getProductsByCategory, getSupplierByName } from '../utils/api.js'; 3import { 4 renderLoading, 5 renderError, 6 renderNotFound, 7 renderStars, 8} from '../utils/renderHelpers.js'; 9import { buildBrandPill } from '../utils/publishedTier.js'; 10import { toAffiliateUrl, isPriceFresh, formatPriceTimestamp, HAS_PA_API_ACCESS } from '../utils/productCard.js'; 11import { findAltImage } from '../utils/imageDedupe.js'; 12 13// Helper to resolve assets â delegates to utils/platform.js 14function asset(path) { 15 return window.leifPlatform.assetUrl(path); 16} 17 18// Utility: canonical DP URL 19function normalizeAmazonUrl(url) { 20 if (!url || typeof url !== 'string') return ''; 21 const match = 22 url.match(/\/dp\/([A-Z0-9]{10})/i) || 23 url.match(/\/gp\/product\/([A-Z0-9]{10})/i); 24 return match 25 ? `https://www.amazon.com/dp/${match[1].toUpperCase()}` 26 : url.trim(); 27} 28 29// --- DOM 30const searchInput = document.getElementById('searchInput'); 31const resultsContainer= document.getElementById('results'); 32const sortSelect = document.getElementById('filterSortSelect'); 33const filterToggle = document.getElementById('filterToggle'); 34const filterPanel = document.getElementById('filterPanel'); 35const filterUnion = document.getElementById('filterUnion'); 36const filterUSA = document.getElementById('filterUSA'); 37const filterEmployee = document.getElementById('filterEmployee'); // not used now, but kept harmless 38const filterFemale = document.getElementById('filterFemale'); // not used now, but kept harmless 39const userScoreSwitch = document.getElementById('userScoreSwitch'); 40 41// State 42let lastQuery = ''; 43let scoringMode = 'leif'; // 'leif' | 'user' 44let userPrefsCache = null; // { weights:{...}, norm:{...}, prefs:{...} } 45 46// ========================================================= 47// Search bar expand/collapse (Quince-style) 48// ========================================================= 49const headerSearch = document.getElementById('headerSearch'); 50const searchOverlay = document.getElementById('searchOverlay'); 51const headerSearchClear = document.getElementById('headerSearchClear'); 52const headerSearchSubmit = document.getElementById('headerSearchSubmit'); 53const headerSearchCancel = document.getElementById('headerSearchCancel'); 54const brandHeader = document.querySelector('.brand-header'); 55 56function openSearchOverlay() { 57 if (headerSearch) headerSearch.classList.add('expanded'); 58 if (searchOverlay) searchOverlay.classList.add('active'); 59 if (brandHeader) brandHeader.classList.add('search-active'); 60 const searchAreaEl = document.getElementById('searchArea'); 61 if (searchAreaEl) searchAreaEl.classList.add('search-active'); 62 document.body.style.overflow = 'hidden'; 63 if (searchInput) searchInput.focus(); 64} 65 66function closeSearchOverlay() { 67 if (headerSearch) headerSearch.classList.remove('expanded'); 68 if (searchOverlay) searchOverlay.classList.remove('active'); 69 if (brandHeader) brandHeader.classList.remove('search-active'); 70 document.body.style.overflow = ''; 71 // Hide toolbar if no active search query 72 const currentQuery = (window.__lastQuery ?? '').trim(); 73 if (!currentQuery) { 74 const searchAreaEl = document.getElementById('searchArea'); 75 if (searchAreaEl) searchAreaEl.classList.remove('search-active'); 76 } 77 // Hide suggestions 78 const suggestions = document.getElementById('searchSuggestions'); 79 if (suggestions) suggestions.classList.remove('visible'); 80 if (searchInput) searchInput.blur(); 81} 82 83// Expose globally so instantsearch.setup.js can call it 84window.__closeSearchOverlay = closeSearchOverlay; 85 86// Open on focus 87if (searchInput) { 88 searchInput.addEventListener('focus', openSearchOverlay); 89} 90 91// Prevent clicks inside the expanded search from closing it 92if (headerSearch) { 93 headerSearch.addEventListener('mousedown', (e) => { 94 if (headerSearch.classList.contains('expanded')) { 95 // Don't let clicks inside the popup reach the overlay 96 e.stopPropagation(); 97 } 98 }); 99} 100 101// Close on overlay click 102if (searchOverlay) { 103 searchOverlay.addEventListener('click', closeSearchOverlay); 104} 105 106// Cancel button 107if (headerSearchCancel) { 108 headerSearchCancel.addEventListener('click', () => { 109 if (searchInput) searchInput.value = ''; 110 window.__lastQuery = ''; 111 if (window.__searchRefine) window.__searchRefine(''); 112 closeSearchOverlay(); 113 const searchAreaEl = document.getElementById('searchArea'); 114 if (searchAreaEl) searchAreaEl.classList.remove('search-active'); 115 }); 116} 117 118// Clear button 119if (headerSearchClear) { 120 headerSearchClear.addEventListener('click', () => { 121 if (searchInput) { 122 searchInput.value = ''; 123 searchInput.focus(); 124 } 125 window.__lastQuery = ''; 126 if (window.__searchRefine) window.__searchRefine(''); 127 const suggestions = document.getElementById('searchSuggestions'); 128 if (suggestions) suggestions.classList.remove('visible'); 129 const searchAreaEl = document.getElementById('searchArea'); 130 if (searchAreaEl) searchAreaEl.classList.remove('search-active'); 131 }); 132} 133 134// Clear Search button â clears query, filters, and closes entire search results section 135const clearSearchBtn = document.getElementById('clearSearchBtn'); 136if (clearSearchBtn) { 137 clearSearchBtn.addEventListener('click', () => { 138 if (searchInput) searchInput.value = ''; 139 window.__lastQuery = ''; 140 if (window.__searchRefine) window.__searchRefine(''); 141 closeSearchOverlay(); 142 const searchAreaEl = document.getElementById('searchArea'); 143 if (searchAreaEl) searchAreaEl.classList.remove('search-active'); 144 // Reset inline filter dropdowns 145 const sortSel = document.getElementById('sortSelectInline'); 146 const priceSel = document.getElementById('filterPriceInline'); 147 const ratingSel = document.getElementById('filterRatingInline'); 148 if (sortSel) sortSel.value = ''; 149 if (priceSel) priceSel.value = ''; 150 if (ratingSel) ratingSel.value = ''; 151 // Clear all drawer filters too 152 if (typeof clearAllFilters === 'function') clearAllFilters(); 153 }); 154} 155 156// Submit button â trigger Enter 157if (headerSearchSubmit) { 158 headerSearchSubmit.addEventListener('click', () => { 159 if (searchInput) { 160 searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); 161 } 162 }); 163} 164 165// ============================================================================ 166// Featured Brands (Partner tier) â homepage section. Fetches all Partner-tier 167// merchants from affiliate_merchants and renders one card per brand. Card 168// click routes to the Partner supplier page (supplier.html?merchant=<slug>). 169// ============================================================================ 170 171// Header bits (logo/user profile link) 172document.addEventListener('DOMContentLoaded', () => { 173 const userProfile = document.getElementById('userProfileBtn'); 174 if (userProfile) { 175 const url = window.leifPlatform.pageUrl('user_profile/user_profile.html'); 176 userProfile.setAttribute('href', url); 177 } 178 179 180 // Shop by Values â each chip navigates to the dedicated values browsing page 181 const valueChips = document.querySelectorAll('.value-chip'); 182 valueChips.forEach(chip => { 183 chip.addEventListener('click', () => { 184 const term = chip.getAttribute('data-search-term'); 185 if (!term) return; 186 const valuesUrl = window.leifPlatform.pageUrl('values/values.html', { value: term }); 187 window.location.href = valuesUrl; 188 }); 189 }); 190 191 // ========================================================= 192 // Category nav: links to category pages or anchor scroll 193 // ========================================================= 194 const navLinks = document.querySelectorAll('.category-nav-link'); 195 navLinks.forEach(link => { 196 link.addEventListener('click', (e) => { 197 const category = link.getAttribute('data-category'); 198 const href = link.getAttribute('href'); 199 200 if (category) { 201 // Navigate to category page 202 e.preventDefault(); 203 const catUrl = window.leifPlatform.pageUrl('category/category.html', { category }); 204 window.location.href = catUrl; 205 } else if (href && href.startsWith('#')) { 206 // Anchor scroll for on-page sections (e.g. Featured Brands) 207 e.preventDefault(); 208 const target = document.getElementById(href.slice(1)); 209 if (target) { 210 const headerH = document.querySelector('.brand-header')?.offsetHeight || 0; 211 const navH = document.querySelector('.category-nav')?.offsetHeight || 0; 212 const top = target.getBoundingClientRect().top + window.scrollY - headerH - navH - 8; 213 window.scrollTo({ top, behavior: 'smooth' }); 214 } 215 } 216 }); 217 }); 218}); 219 220(function initHeader() { 221 try { 222 const logo = document.getElementById('brandLogo'); 223 if (logo) { 224 const path = 'assets/leif-lockup.svg'; 225 if (window.leifPlatform) { 226 logo.src = window.leifPlatform.assetUrl(path); 227 } else { 228 logo.src = '../' + path; 229 } 230 logo.addEventListener('error', () => { 231 logo.style.visibility = 'hidden'; 232 console.warn('[home] logo failed to load:', logo.src); 233 }); 234 // Click logo to reset page: clear search results and return to default view 235 logo.addEventListener('click', () => { 236 if (searchInput) searchInput.value = ''; 237 window.__lastQuery = ''; 238 if (window.__searchRefine) window.__searchRefine(''); 239 closeSearchOverlay(); 240 const searchAreaEl = document.getElementById('searchArea'); 241 if (searchAreaEl) searchAreaEl.classList.remove('search-active'); 242 // Clear results. Only empty the grid â wiping all of #results also 243 // destroys the .results-grid node that instantsearch.setup.js holds a 244 // reference to, so every later search would render into a detached 245 // element and the page would look permanently empty. 246 const grid = resultsContainer?.querySelector('.results-grid'); 247 if (grid) grid.innerHTML = '';
248 const loadMore = resultsContainer?.querySelector('.load-more-btn'); 249 if (loadMore) loadMore.remove(); 250 // Scroll to top 251 window.scrollTo({ top: 0, behavior: 'smooth' }); 252 }); 253 } 254 const signIn = document.getElementById('signInBtn'); 255 if (signIn) { 256 signIn.href = window.leifPlatform.pageUrl('vendor/login.html'); 257 } 258 } catch (e) { 259 console.warn('[home] header init error:', e); 260 } 261})(); 262 263// Ensure "Sort by" placeholder exists 264(function ensureSortPlaceholder() { 265 if (sortSelect && !sortSelect.querySelector('option[value=""]')) { 266 const opt = document.createElement('option'); 267 opt.value = ''; 268 opt.textContent = 'Sort by'; 269 opt.disabled = true; 270 opt.selected = true; 271 sortSelect.insertBefore(opt, sortSelect.firstChild); 272 } 273})(); 274 275// ========================================================= 276// Filter Drawer (slide-in panel) + Active Filter Pills 277// ========================================================= 278const filterOverlay = document.getElementById('filterOverlay'); 279const drawerClose = document.getElementById('drawerClose'); 280const drawerViewResults = document.getElementById('drawerViewResults'); 281const drawerClearAll = document.getElementById('drawerClearAll'); 282const drawerPillsClear = document.getElementById('drawerPillsClear'); 283const activeFiltersClearAll = document.getElementById('activeFiltersClearAll'); 284const filterCountBadge = document.getElementById('filterCount'); 285const drawerFilterCount = document.getElementById('drawerFilterCount'); 286 287function openFilterDrawer() { 288 if (filterPanel) { 289 filterPanel.classList.add('open'); 290 filterPanel.setAttribute('aria-hidden', 'false'); 291 } 292 if (filterOverlay) filterOverlay.classList.add('active'); 293 document.body.style.overflow = 'hidden'; 294} 295 296function closeFilterDrawer() { 297 if (filterPanel) { 298 filterPanel.classList.remove('open'); 299 filterPanel.setAttribute('aria-hidden', 'true'); 300 } 301 if (filterOverlay) filterOverlay.classList.remove('active'); 302 document.body.style.overflow = ''; 303} 304 305// Gather all active filters as descriptive objects 306function getActiveFilters() { 307 const active = []; 308 // Checkboxes (brand values) 309 const checkMap = [ 310 { el: document.getElementById('filterUnion'), label: 'Union Made' }, 311 { el: document.getElementById('filterUSA'), label: 'Made in USA' }, 312 { el: document.getElementById('filterEmployeeOwned'), label: 'Employee-Owned' }, 313 { el: document.getElementById('filterFemaleOwned'), label: 'Female-Owned' }, 314 ]; 315 checkMap.forEach(({ el, label }) => { 316 if (el?.checked) active.push({ type: 'checkbox', id: el.id, label }); 317 }); 318 // Selects 319 const selectMap = [ 320 { el: document.getElementById('filterCategory'), prefix: '' }, 321 { el: document.getElementById('filterDemographic'), prefix: '' }, 322 { el: document.getElementById('filterMarketplace'), prefix: '' }, 323 { el: document.getElementById('filterPrice'), prefix: '' }, 324 { el: document.getElementById('filterRating'), prefix: '' }, 325 { el: document.getElementById('filterMinScore'), prefix: 'ESG ' }, 326 { el: document.getElementById('filterSortSelect'), prefix: 'Sort: ' }, 327 ]; 328 selectMap.forEach(({ el, prefix }) => { 329 if (el?.value) { 330 const opt = el.options[el.selectedIndex]; 331 active.push({ type: 'select', id: el.id, label: prefix + opt.textContent }); 332 } 333 }); 334 // Material pills 335 document.querySelectorAll('.filter-material:checked').forEach(cb => { 336 active.push({ type: 'pill', className: 'filter-material', value: cb.value, label: cb.parentElement.textContent.trim() }); 337 }); 338 // Feature pills 339 document.querySelectorAll('.filter-feature:checked').forEach(cb => { 340 active.push({ type: 'pill', className: 'filter-feature', value: cb.value, label: cb.parentElement.textContent.trim() }); 341 }); 342 // ESG attribute pills 343 document.querySelectorAll('.filter-esg-attr:checked').forEach(cb => { 344 active.push({ type: 'pill', className: 'filter-esg-attr', value: cb.value, label: cb.parentElement.textContent.trim() }); 345 }); 346 return active; 347} 348 349// Build pill HTML for an active filter 350function buildPillEl(filter) { 351 const pill = document.createElement('span'); 352 pill.className = 'active-filter-pill'; 353 pill.textContent = filter.label; 354 const removeBtn = document.createElement('button'); 355 removeBtn.className = 'pill-remove'; 356 removeBtn.innerHTML = '×'; 357 removeBtn.setAttribute('aria-label', 'Remove ' + filter.label); 358 removeBtn.addEventListener('click', (e) => { 359 e.stopPropagation(); 360 removeFilter(filter); 361 }); 362 pill.appendChild(removeBtn); 363 return pill; 364} 365 366// Remove a specific filter 367function removeFilter(filter) { 368 if (filter.type === 'checkbox') { 369 const el = document.getElementById(filter.id); 370 if (el) { el.checked = false; el.dispatchEvent(new Event('change', { bubbles: true })); } 371 } else if (filter.type === 'select') { 372 const el = document.getElementById(filter.id); 373 if (el) { el.value = ''; el.dispatchEvent(new Event('change', { bubbles: true })); } 374 } else if (filter.type === 'pill') { 375 const cb = document.querySelector(`.${filter.className}[value="${filter.value}"]`); 376 if (cb) { cb.checked = false; cb.dispatchEvent(new Event('change', { bubbles: true })); } 377 } 378 updateActiveFilterPills(); 379} 380 381// Clear all filters 382function clearAllFilters() { 383 // Uncheck all checkboxes 384 ['filterUnion', 'filterUSA', 'filterEmployeeOwned', 'filterFemaleOwned'].forEach(id => { 385 const el = document.getElementById(id); 386 if (el) el.checked = false;
387 }); 388 // Reset all selects 389 ['filterSortSelect', 'filterCategory', 'filterDemographic', 'filterMarketplace', 390 'filterPrice', 'filterRating', 'filterMinScore'].forEach(id => { 391 const el = document.getElementById(id); 392 if (el) el.value = ''; 393 }); 394 // Uncheck all pills (materials, features, ESG attributes) 395 document.querySelectorAll('.filter-material, .filter-feature, .filter-esg-attr').forEach(cb => { 396 cb.checked = false; 397 }); 398 updateActiveFilterPills(); 399 // Trigger a search refresh 400 document.querySelectorAll('.fp-select, .fp-tag input, .filter-material, .filter-feature, .filter-esg-attr').forEach(el => { 401 el.dispatchEvent(new Event('change', { bubbles: true })); 402 }); 403} 404 405// Render active filter pills in both the toolbar bar and drawer header 406function updateActiveFilterPills() { 407 const active = getActiveFilters(); 408 const count = active.length; 409 410 // Toolbar badge 411 if (filterCountBadge) { 412 filterCountBadge.textContent = count; 413 filterCountBadge.style.display = count > 0 ? '' : 'none'; 414 } 415 // Drawer header badge 416 if (drawerFilterCount) { 417 drawerFilterCount.textContent = count > 0 ? `(${count})` : ''; 418 } 419 420 // Toolbar active pills bar 421 const bar = document.getElementById('activeFiltersPills'); 422 const list = document.getElementById('activeFiltersList'); 423 if (bar && list) { 424 list.innerHTML = ''; 425 if (count > 0) { 426 active.forEach(f => list.appendChild(buildPillEl(f))); 427 bar.style.display = ''; 428 } else { 429 bar.style.display = 'none'; 430 } 431 } 432 433 // Drawer active pills 434 const drawerPillsWrap = document.getElementById('drawerActivePills'); 435 const drawerPillsList = document.getElementById('drawerActivePillsList'); 436 if (drawerPillsWrap && drawerPillsList) { 437 drawerPillsList.innerHTML = ''; 438 if (count > 0) { 439 active.forEach(f => drawerPillsList.appendChild(buildPillEl(f))); 440 drawerPillsWrap.style.display = ''; 441 } else { 442 drawerPillsWrap.style.display = 'none'; 443 } 444 } 445} 446 447// Wire up drawer open/close 448if (filterToggle) { 449 filterToggle.addEventListener('click', openFilterDrawer); 450} 451if (filterOverlay) { 452 filterOverlay.addEventListener('click', closeFilterDrawer); 453} 454if (drawerClose) { 455 drawerClose.addEventListener('click', closeFilterDrawer); 456} 457if (drawerViewResults) { 458 drawerViewResults.addEventListener('click', closeFilterDrawer); 459} 460if (drawerClearAll) { 461 drawerClearAll.addEventListener('click', clearAllFilters); 462} 463if (drawerPillsClear) { 464 drawerPillsClear.addEventListener('click', clearAllFilters); 465} 466if (activeFiltersClearAll) { 467 activeFiltersClearAll.addEventListener('click', clearAllFilters); 468} 469 470// Listen for filter changes to update pills 471document.addEventListener('change', (e) => { 472 if (e.target.closest('.filter-drawer') || e.target.closest('.filter-panel')) { 473 updateActiveFilterPills(); 474 } 475}); 476 477// Initial render 478updateActiveFilterPills(); 479 480// Apply filters (legacy path only) 481function applyFilters(products) { 482 return products.filter(p => { 483 if (filterUnion?.checked && !p.unionMade) return false; 484 if (filterUSA?.checked && !p.madeInUSA) return false; 485 if (filterEmployee?.checked && !p.employeeOwned) return false; 486 if (filterFemale?.checked && !p.femaleOwned) return false; 487 return true; 488 }); 489} 490 491// Load user prefs once (used only in legacy path) 492async function loadUserPrefsOnce() { 493 if (userPrefsCache) return userPrefsCache; 494 const auth = firebase.auth(); 495 const user = auth.currentUser; 496 if (!user) return null; 497 const idToken = await user.getIdToken(); 498 const projectId = firebase.app().options.projectId; 499 const username = encodeURIComponent( 500 (user.email?.trim()) ?? 501 (user.phoneNumber?.trim()) ?? 502 user.uid 503 ); 504 const url = `https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents/users/${username}`; 505 const resp = await fetch(url, { 506 method: 'GET', 507 headers: { 'Authorization': `Bearer ${idToken}` } 508 }); 509 if (resp.status === 404) return null; 510 if (!resp.ok) { 511 console.warn('[home] loadUserPrefsOnce failed:', resp.status, await resp.text()); 512 return null; 513 } 514 const doc = await resp.json(); 515 const f = doc?.fields ?? {}; 516 const wf = f.weights?.mapValue?.fields ?? {}; 517 const pf = f.prefs?.mapValue?.fields ?? {}; 518 const n = (x) => x?.doubleValue ?? x?.integerValue ?? (x?.stringValue ? Number(x.str
518ingValue) : null); 519 const b = (x) => x?.booleanValue === true; 520 521 const weights = { 522 environmentalWeight: Number(n(wf.environmentalWeight) ?? 33.3), 523 laborWeight: Number(n(wf.laborWeight) ?? 33.3), 524 complianceWeight: Number(n(wf.complianceWeight) ?? 33.4), 525 }; 526 const sum = (weights.environmentalWeight + weights.laborWeight + weights.complianceWeight) || 100; 527 const norm = { 528 w_env: weights.environmentalWeight / sum, 529 w_lab: weights.laborWeight / sum, 530 w_com: weights.complianceWeight / sum, 531 }; 532 533 userPrefsCache = { 534 weights, 535 norm, 536 prefs: { 537 madeInUSA: b(pf.madeInUSA), 538 unionMade: b(pf.unionMade), 539 employeeOwned: b(pf.employeeOwned), 540 femaleOwned: b(pf.femaleOwned), 541 } 542 }; 543 return userPrefsCache; 544} 545 546// Display score (legacy path) â now returns { score, isPercentile } 547function toNumber(x) { const n = typeof x === 'number' ? x : parseFloat(x); return Number.isFinite(n) ? n : NaN; } 548function computeDisplayScore(product, mode, prefs) { 549 if (mode === 'leif') { 550 // Use raw esgScore in leif mode (matches supplier page display) 551 const val = Number.isFinite(parseFloat(product.esgScore)) ? parseFloat(product.esgScore) : 0; 552 return { score: val, isPercentile: false }; 553 } 554 // User mode: weight pillar percentiles, fall back to raw pillars 555 const envPct = toNumber(product._percentiles?.environmental); 556 const labPct = toNumber(product._percentiles?.labor); 557 const comPct = toNumber(product._percentiles?.compliance); 558 const env = Number.isFinite(envPct) ? envPct : toNumber(product._pillars?.environmental); 559 const lab = Number.isFinite(labPct) ? labPct : toNumber(product._pillars?.labor); 560 const com = Number.isFinite(comPct) ? comPct : toNumber(product._pillars?.compliance); 561 const available = [ 562 ['env', env], ['lab', lab], ['com', com], 563 ].filter(([, v]) => Number.isFinite(v)); 564 565 if (available.length === 0) { 566 const pct = Number.isFinite(+product.esgPercentile) ? +product.esgPercentile : null; 567 if (pct != null && pct > 0) return { score: pct, isPercentile: true }; 568 return { score: Number.isFinite(parseFloat(product.esgScore)) ? parseFloat(product.esgScore) : 0, isPercentile: false }; 569 } 570 571 const w = prefs?.norm || { w_env: 1/3, w_lab: 1/3, w_com: 1/3 }; 572 let w_env = w.w_env, w_lab = w.w_lab, w_com = w.w_com; 573 if (!Number.isFinite(env)) w_env = 0; 574 if (!Number.isFinite(lab)) w_lab = 0; 575 if (!Number.isFinite(com)) w_com = 0; 576 const wSum = w_env + w_lab + w_com; 577 if (wSum <= 0) { 578 const pct = Number.isFinite(+product.esgPercentile) ? +product.esgPercentile : null; 579 if (pct != null && pct > 0) return { score: pct, isPercentile: true }; 580 return { score: Number.isFinite(parseFloat(product.esgScore)) ? parseFloat(product.esgScore) : 0, isPercentile: false }; 581 } 582 w_env /= wSum; w_lab /= wSum; w_com /= wSum; 583 const s = (Number.isFinite(env) ? w_env * env : 0) 584 + (Number.isFinite(lab) ? w_lab * lab : 0) 585 + (Number.isFinite(com) ? w_com * com : 0); 586 return { score: s, isPercentile: true }; 587} 588 589// Search core (LEGACY â kept for completeness; disabled when InstantSearch is active) 590async function searchProducts(query) { 591 if (!query) { 592 renderNotFound(resultsContainer, 'Enter a search term to begin.'); 593 return; 594 } 595 renderLoading(resultsContainer, 'Searchingâ¦'); 596 597 try { 598 let products = await getProductsByCategory(query.toLowerCase()); 599 console.log('[home] fetched products:', products?.length); 600 if (!products || products.length === 0) { 601 renderNotFound(resultsContainer, 'No products found.'); 602 return; 603 } 604 605 // Hydrate suppliers for pillar scores 606 const supplierNames = [ 607 ...new Set( 608 products 609 .map(p => (p.supplierName || p.brand || '').trim()) 610 .filter(Boolean) 611 ), 612 ]; 613 const supplierCache = new Map(); 614 await Promise.all( 615 supplierNames.map(async (name) => { 616 try { 617 const sup = await getSupplierByName(name); 618 if (sup) supplierCache.set(name, sup); 619 } catch (e) { 620 console.warn('Supplier lookup failed:', name, e); 621 } 622 }) 623 ); 624 625 products = products.map(p => { 626 const supplierName = (p.supplierName || p.brand || '').trim(); 627 const sup = supplierName ? supplierCache.get(supplierName) : null; 628 const env = toNumber(sup?.environmentalScore); 629 const lab = toNumber(sup?.laborScore); 630 const com = toNumber(sup?.complianceScore); 631 const supplierEsgScore = Number.isFinite(toNumber(sup?.esgScore)) ? toNumber(sup.esgScore) : null; 632 const esgPct = Number.isFinite(toNumber(sup?.esgPercentile)) ? toNumber(sup.esgPercentile) : null; 633 const envPct = Number.isFinite(toNumber(sup?.environmentalPercentile)) ? toNumber(sup.environmentalPercentile) : null; 634 const labPct = Number.isFinite(toNumber(sup?.laborPercentile)) ? toNumber(sup.laborPercentile) : null; 635 const comPct = Number.isFinite(toNumber(sup?.compliancePercentile)) ? toNumber(sup.compliancePercentile) : null; 636 return { 637 ...p, 638 esgScore: supplierEsgScore ?? p.esgScore ?? null, 639 esgPercentile: esgPct, 640 // Carry the supplier's published tier onto the product so the card can 641 // render pill-or-pending without a second lookup. `source` ri
641des along 642 // because getPublishedTier gates the tier on it â a tier without its 643 // source silently reads as pending. 644 likelyTier: sup?.likelyTier ?? p.likelyTier ?? null, 645 likelyTierSource: sup?.likelyTierSource ?? p.likelyTierSource ?? null, 646 source: sup?.source ?? p.source ?? null, 647 unionMade: typeof sup?.unionMade === 'boolean' ? sup.unionMade : !!p.unionMade, 648 madeInUSA: typeof sup?.madeInUSA === 'boolean' ? sup.madeInUSA : !!p.madeInUSA, 649 employeeOwned: typeof sup?.employeeOwned === 'boolean' ? sup.employeeOwned : !!p.employeeOwned, 650 femaleOwned: typeof sup?.femaleOwned === 'boolean' ? sup.femaleOwned : !!p.femaleOwned, 651 _pillars: { 652 environmental: Number.isFinite(env) ? env : null, 653 labor: Number.isFinite(lab) ? lab : null, 654 compliance: Number.isFinite(com) ? com : null 655 }, 656 _percentiles: { 657 environmental: envPct, 658 labor: labPct, 659 compliance: comPct 660 } 661 }; 662 }); 663 664 let prefs = null; 665 if (scoringMode === 'user') { 666 prefs = await loadUserPrefsOnce(); 667 if (!prefs) { 668 scoringMode = 'leif'; 669 if (userScoreSwitch) userScoreSwitch.checked = false; 670 } 671 } 672 673 products = products.map(p => { 674 const result = computeDisplayScore(p, scoringMode, prefs); 675 return { ...p, displayScore: result.score, _isPercentile: result.isPercentile }; 676 }); 677 678 const sortValue = sortSelect ? sortSelect.value : ''; 679 if (sortValue === 'price') { 680 products.sort((a, b) => { 681 const aVal = Number.isFinite(parseFloat(a.price)) ? parseFloat(a.price) : Infinity; 682 const bVal = Number.isFinite(parseFloat(b.price)) ? parseFloat(b.price) : Infinity; 683 return aVal - bVal; 684 }); 685 } else if (sortValue === 'score') { 686 products.sort((a, b) => { 687 const aVal = Number.isFinite(toNumber(a.displayScore)) ? toNumber(a.displayScore) : -Infinity; 688 const bVal = Number.isFinite(toNumber(b.displayScore)) ? toNumber(b.displayScore) : -Infinity; 689 return bVal - aVal; 690 }); 691 } 692 693 products = applyFilters(products); 694 console.log('[home] after filters:', products?.length); 695 696 resultsContainer.innerHTML = '<div class="results-grid"></div>'; 697 const grid = resultsContainer.querySelector('.results-grid'); 698 699 products.forEach(p => { 700 const card = document.createElement('div'); 701 card.className = 'product-card'; 702 703 const productTop = document.createElement('div'); 704 productTop.className = 'product-top'; 705 706 const image = document.createElement('img'); 707 image.className = 'product-image'; 708 const primaryImg = p.imageurl ?? asset('assets/placeholder.png'); 709 image.src = primaryImg; 710 image.alt = p.title ?? 'Product image'; 711 image.onerror = () => { image.src = asset('assets/placeholder.png'); }; 712 const altImg = findAltImage(p.images, p.imageurl); 713 if (altImg) { 714 card.addEventListener('mouseenter', () => { image.src = altImg; }); 715 card.addEventListener('mouseleave', () => { image.src = primaryImg; }); 716 } 717 718 const info = document.createElement('div'); 719 info.className = 'product-info'; 720 721 // Rating â the same pill-or-pending as every other surface. The score 722 // wheels that lived here (percentile + raw esgScore, and the "N/A" ring) 723 // are gone: no number renders anywhere. 724 const scoreWrapper = document.createElement('div'); 725 scoreWrapper.className = 'product-score'; 726 scoreWrapper.appendChild( 727 buildBrandPill(p, { extraClass: 'product-score-pill--inline product-score-pill--onlight' }), 728 ); 729 730 const price = document.createElement('div'); 731 price.className = 'product-price'; 732 const priceValue = Number(p.price); 733 const priceTs = p._priceFetchedAt ?? null; 734 const canShowPrice = HAS_PA_API_ACCESS && isPriceFresh(priceTs) && Number.isFinite(priceValue); 735 if (canShowPrice) { 736 price.textContent = `$${priceValue.toFixed(2)}`; 737 const tsText = formatPriceTimestamp(priceTs); 738 if (tsText) { 739 const tsEl = document.createElement('span'); 740 tsEl.className = 'price-timestamp'; 741 tsEl.textContent = tsText; 742 price.appendChild(tsEl); 743 } 744 } else { 745 const seePriceLink = document.createElement('a'); 746 seePriceLink.className = 'see-on-amazon-link'; 747 seePriceLink.href = toAffiliateUrl(normalizeAmazonUrl(p.amazonUrl ?? p.url ?? '#')); 748 seePriceLink.target = '_blank'; 749 seePriceLink.rel = 'noopener noreferrer'; 750 seePriceLink.textContent = 'See price on Amazon'; 751 price.appendChild(seePriceLink); 752 } 753 754 const starsWrapper = document.createElement('div'); 755 starsWrapper.className = 'product-stars';
756 const ratingVal = 757 typeof p.rating === 'number' ? p.rating : 758 (typeof p.reviewRating === 'number' ? p.reviewRating : NaN); 759 const ratingCountVal = 760 typeof p.ratingCount === 'number' ? p.ratingCount : 761 (typeof p.reviewCount === 'number' ? p.reviewCount : undefined); 762 if (!Number.isNaN(ratingVal)) { 763 const starsBlock = renderStars(ratingVal, ratingCountVal); 764 starsWrapper.appendChild(starsBlock); 765 } 766 767 const scorePriceRow = document.createElement('div'); 768 scorePriceRow.className = 'product-score-price-row'; 769 scorePriceRow.appendChild(scoreWrapper); 770 scorePriceRow.appendChild(price); 771 info.appendChild(scorePriceRow); 772 if (starsWrapper.childNodes.length) info.appendChild(starsWrapper); 773 774 productTop.appendChild(image); 775 productTop.appendChild(info); 776 777 const title = document.createElement('h3'); 778 title.className = 'product-title'; 779 780 const link = document.createElement('a'); 781 link.textContent = p.title ?? 'Unnamed product'; 782 link.href = toAffiliateUrl(normalizeAmazonUrl(p.amazonUrl ?? p.url ?? '#')); 783 link.target = '_blank'; 784 link.rel = 'noopener noreferrer'; 785 786 title.appendChild(link); 787 788 card.appendChild(productTop); 789 card.appendChild(title); 790 791 card.addEventListener('click', (e) => { 792 if (e.target.closest('a')) return; 793 showSupplierPreviewModal(p); 794 }); 795 796 grid.appendChild(card); 797 }); 798 } catch (err) { 799 console.error(err); 800 renderError(resultsContainer, 'Error loading results.'); 801 } 802} 803 804// Supplier preview modal (legacy path; InstantSearch uses its own inline modal) 805function showSupplierPreviewModal(product) { 806 const canonicalUrl = normalizeAmazonUrl(product?.amazonUrl ?? product?.url ?? ''); 807 const supplierPreviewUrl = `../supplier/supplier.html?product=${encodeURIComponent(canonicalUrl)}&embed=1`; 808 const supplierFullUrl = `../supplier/supplier.html?product=${encodeURIComponent(canonicalUrl)}`; 809 810 const modal = document.createElement('div'); 811 modal.className = 'product-modal'; 812 813 const content = document.createElement('div'); 814 content.className = 'modal-content modal-content--supplier-preview'; 815 816 const closeBtn = document.createElement('button'); 817 closeBtn.className = 'btn btn-secondary'; 818 closeBtn.textContent = 'â'; 819 closeBtn.id = 'modalCloseBtn'; 820 closeBtn.style.cssText = ` 821 position: absolute; top: 10px; right: 10px; z-index: 10; 822 width: 34px; height: 34px; border-radius: 50%; 823 display: flex; align-items: center; justify-content: center; 824 background: #fff; border: 2px solid #014421; color: #014421; 825 font-size: 18px; font-weight: 700; cursor: pointer; padding: 0; line-height: 1; 826 box-shadow: 0 2px 8px rgba(0,0,0,.25); 827 `; 828 829 const expandBtn = document.createElement('a'); 830 expandBtn.className = 'btn btn-primary'; 831 expandBtn.textContent = 'â'; 832 expandBtn.setAttribute('aria-label', 'Open supplier page'); 833 expandBtn.href = supplierFullUrl; 834 expandBtn.target = '_blank'; 835 expandBtn.rel = 'noopener noreferrer'; 836 expandBtn.style.cssText = ` 837 position: absolute; top: 10px; right: 52px; z-index: 10; 838 width: 34px; height: 34px; border-radius: 50%; 839 display: flex; align-items: center; justify-content: center; 840 background: #014421; color: #fff; border: 2px solid #014421; 841 font-size: 18px; font-weight: 700; cursor: pointer; padding: 0; line-height: 1; 842 text-decoration: none; box-shadow: 0 2px 8px rgba(0,0,0,.25); 843 `; 844 845 const iframe = document.createElement('iframe'); 846 iframe.className = 'modal-frame'; 847 iframe.src = supplierPreviewUrl; 848 iframe.setAttribute('title', 'Supplier summary preview'); 849 iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-top-navigation-by-user-activation allow-modals'); 850 iframe.setAttribute('loading', 'eager'); 851 852 content.append(closeBtn, expandBtn, iframe); 853 modal.appendChild(content); 854 855 closeBtn.addEventListener('click', () => modal.remove()); 856 modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); 857 858 document.body.appendChild(modal); 859} 860 861/* ======================= 862 Event wiring (LEGACY) â
863 Guarded so it does NOT run when InstantSearch is active 864 ======================= */ 865 866// Search input (guarded) 867if (searchInput && !window.__INSTANTSEARCH_ACTIVE__) { 868 searchInput.addEventListener('change', e => { 869 const q = e.target.value.trim(); 870 if (!q && sortSelect) sortSelect.value = ''; 871 lastQuery = q; 872 searchProducts(q); 873 }); 874 searchInput.addEventListener('input', e => { 875 const q = e.target.value.trim(); 876 lastQuery = q; 877 if (q) searchProducts(q); 878 }); 879} else if (!searchInput) { 880 console.warn('[home] Missing #searchInput; check homepage.html'); 881} 882 883// Sort change (guarded) 884if (sortSelect && !window.__INSTANTSEARCH_ACTIVE__) { 885 sortSelect.addEventListener('change', () => { 886 if (lastQuery) searchProducts(lastQuery); 887 }); 888} else if (!sortSelect) { 889 console.warn('[home] Missing #filterSortSelect; check homepage.html'); 890} 891 892// Filter checkboxes (guarded) 893if (!window.__INSTANTSEARCH_ACTIVE__) { 894 document.querySelectorAll('.filter-options input')?.forEach(cb => { 895 cb.addEventListener?.('change', () => { 896 if (lastQuery) searchProducts(lastQuery); 897 }); 898 }); 899} 900 901// === User Score switch (legacy path) ======================================== 902 903/* Scoring-mode hydration signal. 904 --------------------------------------------------------------------------- 905 The homepage carousels must render EXACTLY ONCE, in the final scoring mode. 906 Two things resolve on load â Firebase auth, and this file's decision about 907 whether the visitor is in 'user' or 'leif' mode (which needs prefs, so it 908 lands strictly after auth). We used to announce that decision by firing a 909 synthetic `change` on #userScoreSwitch, which had two bad effects: 910 ⢠instantsearch.setup.js had already rendered every rail off its own 911 onAuthStateChanged in the pre-hydration mode, so the synthetic change 912 re-rendered all of them a second time with different products; and 913 ⢠the switch's real change handler treats every event as a deliberate 914 user toggle, so each page load logged a phantom analytics toggle. 915 Instead we emit a distinct one-shot event that only the carousel renderer 916 listens for. A real user toggle still fires a real `change`. */ 917let _scoringModeAnnounced = false; 918function announceScoringMode() { 919 if (_scoringModeAnnounced) return; 920 _scoringModeAnnounced = true; 921 // Latch as well as dispatch: a one-shot event is lost on anyone who registers 922 // after it fires, and one branch below announces synchronously during module 923 // evaluation. Listeners check the latch first. 924 window.__leifScoringModeReady = true; 925 document.dispatchEvent(new CustomEvent('leif:scoring-mode-ready', { 926 detail: { mode: scoringMode }, 927 })); 928} 929 930if (userScoreSwitch) { 931 // 1) Restore prior session choice if present 932 try { 933 const saved = sessionStorage.getItem('leif_scoring_mode'); 934 if (saved === 'user') { 935 userScoreSwitch.checked = true; 936 scoringMode = 'user'; 937 } else if (saved === 'leif') { 938 userScoreSwitch.checked = false; 939 scoringMode = 'leif'; 940 } 941 } catch (_) {} 942 943 // 2) Default-to-ON when signed in and no session choice yet + hydrate on load 944 try { 945 const auth = firebase.auth(); 946 auth.onAuthStateChanged(async (user) => { 947 if (!userScoreSwitch) return; 948 const saved = sessionStorage.getItem('leif_scoring_mode'); 949 950 if (saved === 'user') { 951 userScoreSwitch.checked = true; 952 scoringMode = 'user'; 953 if (user) { 954 const prefs = await loadUserPrefsOnce(); 955 if (prefs) { 956 if (typeof prefs.prefs?.madeInUSA === 'boolean') filterUSA.checked = prefs.prefs.madeInUSA; 957 if (typeof prefs.prefs?.unionMade === 'boolean') filterUnion.checked = prefs.prefs.unionMade; 958 if (lastQuery) searchProducts(lastQuery); 959 } 960 } 961 // Mode settled â release the carousel render. 962 announceScoringMode(); 963 return; 964 } 965 966 if (saved === 'leif') { 967 userScoreSwitch.checked = false; 968 scoringMode = 'leif'; 969 announceScoringMode(); 970 return; 971 } 972 973 if (user) { 974 const prefs = await loadUserPrefsOnce(); 975 if (prefs) { 976 userScoreSwitch.checked = true; 977 scoringMode = 'user'; 978 try { sessionStorage.setItem('leif_scoring_mode', 'user'); } catch(_) {} 979 if (typeof prefs.prefs?.madeInUSA === 'boolean') filterUSA.checked = prefs.prefs.madeInUSA; 980 if (typeof prefs.prefs?.unionMade === 'boolean') filterUnion.checked = prefs.prefs.unionMade; 981 if (lastQuery) searchProducts(lastQuery); 982 announceScoringMode(); 983 } else { 984 userScoreSwitch.checked = false;
985 scoringMode = 'leif'; 986 try { sessionStorage.setItem('leif_scoring_mode', 'leif'); } catch(_) {} 987 if (lastQuery) searchProducts(lastQuery); 988 announceScoringMode(); 989 } 990 } else { 991 // Not signed in -> leave the switch as-is, but the mode IS settled 992 // ('leif'), so the carousels still need their release signal. 993 announceScoringMode(); 994 } 995 }); 996 } catch (e) { 997 console.warn('[home] auth default-on bootstrap failed:', e); 998 // Never let a bootstrap failure strand the carousels waiting on a signal 999 // that will now never come â they'd sit on static cards forever. 1000 announceScoringMode(); 1001 } 1002 1003 // 3) Normal change handler (legacy) 1004 userScoreSwitch.addEventListener('change', async () => { 1005 const auth = firebase.auth(); 1006 const signedIn = !!auth.currentUser; 1007 1008 if (userScoreSwitch.checked) { 1009 if (!signedIn) { 1010 alert('Sign in to use your preferences.'); 1011 userScoreSwitch.checked = false; 1012 scoringMode = 'leif'; 1013 try { sessionStorage.setItem('leif_scoring_mode', 'leif'); } catch(_) {} 1014 return; 1015 } 1016 const prefs = await loadUserPrefsOnce(); 1017 if (!prefs) { 1018 alert('Could not load your preferences. Using Leif score instead.'); 1019 userScoreSwitch.checked = false; 1020 scoringMode = 'leif'; 1021 try { sessionStorage.setItem('leif_scoring_mode', 'leif'); } catch(_) {} 1022 return; 1023 } 1024 if (typeof prefs.prefs?.madeInUSA === 'boolean') filterUSA.checked = prefs.prefs.madeInUSA; 1025 if (typeof prefs.prefs?.unionMade === 'boolean') filterUnion.checked = prefs.prefs.unionMade; 1026 1027 scoringMode = 'user'; 1028 try { sessionStorage.setItem('leif_scoring_mode', 'user'); } catch(_) {} 1029 } else { 1030 scoringMode = 'leif'; 1031 try { sessionStorage.setItem('leif_scoring_mode', 'leif'); } catch(_) {} 1032 } 1033 if (lastQuery) searchProducts(lastQuery); 1034 }); 1035} else { 1036 console.warn('[home] Missing #userScoreSwitch; check homepage.html'); 1037 // No switch means no mode hydration will ever run â release the carousels 1038 // immediately rather than leaving them stuck on the static cards. 1039 announceScoringMode(); 1040} 1041 1042// ========================================================= 1043// Mobile overflow menu (hamburger) 1044// ========================================================= 1045const mobileMenuBtn = document.getElementById('mobileMenuBtn'); 1046const mobileMenuDropdown = document.getElementById('mobileMenuDropdown'); 1047const mobileMenuTutorial = document.getElementById('mobileMenuTutorial'); 1048const mobileScoreSwitch = document.getElementById('userScoreSwitchMobile'); 1049 1050if (mobileMenuBtn && mobileMenuDropdown) { 1051 mobileMenuBtn.addEventListener('click', (e) => { 1052 e.stopPropagation(); 1053 mobileMenuDropdown.classList.toggle('open'); 1054 }); 1055 1056 // Close dropdown when clicking outside 1057 document.addEventListener('click', (e) => { 1058 if (!mobileMenuDropdown.contains(e.target) && !mobileMenuBtn.contains(e.target)) { 1059 mobileMenuDropdown.classList.remove('open'); 1060 } 1061 }); 1062 1063 // Tutorial item â trigger the real tutorial button 1064 if (mobileMenuTutorial) { 1065 mobileMenuTutorial.addEventListener('click', () => { 1066 mobileMenuDropdown.classList.remove('open'); 1067 const tutBtn = document.getElementById('tutorialBtn'); 1068 if (tutBtn) tutBtn.click(); 1069 }); 1070 } 1071 1072 // Sync mobile score switch with the main one 1073 if (mobileScoreSwitch && userScoreSwitch) { 1074 mobileScoreSwitch.checked = userScoreSwitch.checked; 1075 mobileScoreSwitch.addEventListener('change', () => { 1076 userScoreSwitch.checked = mobileScoreSwitch.checked; 1077 userScoreSwitch.dispatchEvent(new Event('change')); 1078 }); 1079 userScoreSwitch.addEventListener('change', () => { 1080 mobileScoreSwitch.checked = userScoreSwitch.checked; 1081 }); 1082 } 1083}
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.