PageSourceSearch

https://www.leiflist.com/homepage/homepage.js

js leiflist.com collected 2026-10-02 01:34:21 UTC 42,660 bytes, 1,083 lines download raw bytes

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 = '&times;';
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.