feat: V3 store page

This commit is contained in:
Brennan Wilkes (Text Groove) 2026-01-30 13:37:34 -08:00
parent d92e4b8cf3
commit 13f15a8eb0
4 changed files with 384 additions and 301 deletions

View file

@ -1114,12 +1114,53 @@ export async function renderSkuLinker($app) {
});
}
function findAggForPreselectSku(rawSku) {
const want = String(rawSku || "").trim();
if (!want) return null;
// exact match first
let it = allAgg.find((x) => String(x?.sku || "") === want);
if (it) return it;
// try canonical group match
const canonWant = String(rules.canonicalSku(want) || want).trim();
if (!canonWant) return null;
it = allAgg.find((x) => String(x?.sku || "") === canonWant);
if (it) return it;
// any member whose canonicalSku matches
return (
allAgg.find((x) => String(rules.canonicalSku(String(x?.sku || "")) || "") === canonWant) ||
null
);
}
function updateAll() {
// One-time left preselect from hash query:
// #/link/?left=<sku>
// (works with your router because "link" stays as the first path segment)
if (!updateAll._didPreselect) {
updateAll._didPreselect = true;
const h = String(location.hash || "");
const qi = h.indexOf("?");
if (qi !== -1) {
const qs = new URLSearchParams(h.slice(qi + 1));
const leftSku = String(qs.get("left") || qs.get("sku") || "").trim();
if (leftSku && !pinnedL) {
const it = findAggForPreselectSku(leftSku);
if (it) pinnedL = it;
}
}
}
renderSide("L");
renderSide("R");
updateButtons();
}
let tL = null,
tR = null;
$qL.addEventListener("input", () => {

View file

@ -9,16 +9,18 @@ export function renderSearch($app) {
$app.innerHTML = `
<div class="container">
<div class="header">
<div style="min-width:0;">
<div class="headerLeft">
<h1 class="h1">Spirit Tracker Viz</h1>
<div class="small">Search name / url / sku (word AND)</div>
<div class="storeBarWrap">
<div class="small storeBarLabel">Stores:</div>
<div id="storeBar" class="storeBar"></div>
<div id="stores" class="storeBar"></div>
</div>
</div>
<a class="btn" href="#/link" style="text-decoration:none;">Link SKUs</a>
<div class="headerRight">
<a class="btn btnWide" href="#/link" style="text-decoration:none;">Link SKUs</a>
</div>
</div>
<div class="card">
@ -30,7 +32,7 @@ export function renderSearch($app) {
const $q = document.getElementById("q");
const $results = document.getElementById("results");
const $storeBar = document.getElementById("storeBar");
const $stores = document.getElementById("stores");
$q.value = loadSavedQuery();
@ -41,14 +43,6 @@ export function renderSearch($app) {
// canonicalSku -> storeLabel -> url
let URL_BY_SKU_STORE = new Map();
function normStoreLabel(s) {
return String(s || "").trim().toLowerCase();
}
function storeLabelFromRow(r) {
return String(r?.storeLabel || r?.store || "").trim();
}
function buildUrlMap(listings, canonicalSkuFn) {
const out = new Map();
for (const r of Array.isArray(listings) ? listings : []) {
@ -60,7 +54,7 @@ export function renderSearch($app) {
const sku = String(canonicalSkuFn ? canonicalSkuFn(skuKey) : skuKey);
if (!sku) continue;
const storeLabel = storeLabelFromRow(r);
const storeLabel = String(r.storeLabel || r.store || "").trim();
const url = String(r.url || "").trim();
if (!storeLabel || !url) continue;
@ -77,17 +71,26 @@ export function renderSearch($app) {
return URL_BY_SKU_STORE.get(sku)?.get(s) || "";
}
function renderStoreBar(listingsLive, storeLabelMapDisplay) {
if (!$storeBar) return;
function normStoreLabel(s) {
return String(s || "").trim();
}
function renderStoreButtons(listings) {
// include all stores seen (live or removed) so the selector is stable
const set = new Set();
for (const r of Array.isArray(listings) ? listings : []) {
const lab = normStoreLabel(r?.storeLabel || r?.store || "");
if (lab) set.add(lab);
}
const stores = Array.from(set).sort((a, b) => a.localeCompare(b));
const stores = Array.from(storeLabelMapDisplay.values()).sort((a, b) => a.localeCompare(b));
if (!stores.length) {
$storeBar.innerHTML = `<span class="small">No stores</span>`;
$stores.innerHTML = "";
return;
}
$storeBar.innerHTML = stores
.map((s) => `<a class="badge storePill" href="#/store/${encodeURIComponent(s)}">${esc(s)}</a>`)
$stores.innerHTML = stores
.map((s) => `<a class="storeBtn" href="#/store/${encodeURIComponent(s)}">${esc(s)}</a>`)
.join("");
}
@ -115,6 +118,8 @@ export function renderSearch($app) {
)}</a>`
: `<span class="badge">${esc(store)}${esc(plus)}</span>`;
const skuLink = `#/link/?left=${encodeURIComponent(String(it.sku || ""))}`;
return `
<div class="item" data-sku="${esc(it.sku)}">
<div class="itemRow">
@ -124,7 +129,9 @@ export function renderSearch($app) {
<div class="itemBody">
<div class="itemTop">
<div class="itemName">${esc(it.name || "(no name)")}</div>
<span class="badge mono">${esc(displaySku(it.sku))}</span>
<a class="badge mono skuLink" href="${esc(
skuLink
)}" onclick="event.stopPropagation()">${esc(displaySku(it.sku))}</a>
</div>
<div class="metaRow">
<span class="mono price">${esc(price)}</span>
@ -175,13 +182,7 @@ export function renderSearch($app) {
return Number.isFinite(ms2) ? ms2 : 0;
}
// Custom priority:
// - Sales that make this store cheapest (or tied cheapest) are most interesting
// - New unique (no other stores have canonical SKU)
// - Other sales (not cheapest) are demoted
// - Removed
// - Price increases
// - New (available elsewhere)
// Custom priority (unchanged)
function rankRecent(r, canonSkuFn) {
const rawSku = String(r?.sku || "");
const sku = String(canonSkuFn ? canonSkuFn(rawSku) : rawSku);
@ -229,41 +230,38 @@ export function renderSearch($app) {
// Bucketed scoring (higher = earlier)
let score = 0;
// Helper for sales buckets
function saleBucketScore(isCheapest, pct) {
const p = Number.isFinite(pct) ? pct : 0;
if (isCheapest) {
if (p >= 20) return 9000 + p; // Bucket #1
if (p >= 10) return 7000 + p; // Bucket #3
if (p > 0) return 6000 + p; // Bucket #4
return 5900; // weird but keep below real pct
if (p >= 20) return 9000 + p;
if (p >= 10) return 7000 + p;
if (p > 0) return 6000 + p;
return 5900;
} else {
if (p >= 20) return 4500 + p; // Bucket #5 (below NEW unique)
if (p >= 10) return 1500 + p; // Bucket #8
if (p > 0) return 1200 + p; // Bucket #9
return 1000; // bottom-ish
if (p >= 20) return 4500 + p;
if (p >= 10) return 1500 + p;
if (p > 0) return 1200 + p;
return 1000;
}
}
if (kind === "price_down") {
score = saleBucketScore(saleIsCheapest, pctOff);
} else if (isNewUnique) {
score = 8000; // Bucket #2
score = 8000;
} else if (kind === "removed") {
score = 3000; // Bucket #6
score = 3000;
} else if (kind === "price_up") {
score = 2000 + Math.min(99, Math.max(0, pctUp || 0)); // Bucket #7
score = 2000 + Math.min(99, Math.max(0, pctUp || 0));
} else if (kind === "new") {
score = 1100; // Bucket #10
score = 1100;
} else if (kind === "restored") {
// not in your bucket list, but keep it reasonably high (below NEW unique, above removals)
score = 5000;
} else {
score = 0;
}
// Tie-breaks: within bucket prefer bigger % for sales, then recency
let tie = 0;
if (kind === "price_down") tie = (pctOff || 0) * 100000 + tsValue(r);
else if (kind === "price_up") tie = (pctUp || 0) * 100000 + tsValue(r);
@ -289,7 +287,6 @@ export function renderSearch($app) {
const ms = t ? Date.parse(t) : NaN;
if (Number.isFinite(ms)) return ms;
// fallback: date-only => treat as start of day UTC-ish
const d = String(r?.date || "");
const ms2 = d ? Date.parse(d + "T00:00:00Z") : NaN;
return Number.isFinite(ms2) ? ms2 : 0;
@ -305,7 +302,6 @@ export function renderSearch($app) {
return;
}
// --- DEDUPE: canonical SKU -> (store -> most recent event for that store) ---
const bySkuStore = new Map();
for (const r of inWindow) {
@ -325,7 +321,6 @@ export function renderSearch($app) {
if (!prev || eventMs(prev) < ms) storeMap.set(storeLabel, r);
}
// --- PICK ONE PER SKU: choose the most "important" among latest-per-store events ---
const picked = [];
for (const [sku, storeMap] of bySkuStore.entries()) {
let best = null;
@ -411,6 +406,8 @@ export function renderSearch($app) {
? ` style="color:rgba(20,110,40,0.95); background:rgba(20,110,40,0.10); border:1px solid rgba(20,110,40,0.20);"`
: "";
const skuLink = `#/link/?left=${encodeURIComponent(String(sku || ""))}`;
return `
<div class="item" data-sku="${esc(sku)}">
<div class="itemRow">
@ -420,7 +417,9 @@ export function renderSearch($app) {
<div class="itemBody">
<div class="itemTop">
<div class="itemName">${esc(r.name || "(no name)")}</div>
<span class="badge mono">${esc(displaySku(sku))}</span>
<a class="badge mono skuLink" href="${esc(
skuLink
)}" onclick="event.stopPropagation()">${esc(displaySku(sku))}</a>
</div>
<div class="metaRow">
<span class="badge"${kindBadgeStyle}>${esc(kindLabel)}</span>
@ -454,7 +453,6 @@ export function renderSearch($app) {
const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens));
// If query prefixes an SMWS distillery name, also surface SMWS bottles by code XXX.YYY where XXX matches.
const wantCodes = new Set(smwsDistilleryCodesForQueryPrefix($q.value));
if (!wantCodes.size) {
renderAggregates(matches);
@ -473,27 +471,16 @@ export function renderSearch($app) {
}
}
// Put SMWS distillery matches first, then normal search matches.
renderAggregates([...extra, ...matches]);
}
$results.innerHTML = `<div class="small">Loading index…</div>`;
if ($storeBar) $storeBar.innerHTML = `<span class="small">Loading stores…</span>`;
Promise.all([loadIndex(), loadSkuRules()])
.then(([idx, rules]) => {
const listings = Array.isArray(idx.items) ? idx.items : [];
// Build store list from LIVE rows only
const live = listings.filter((r) => r && !r.removed);
const storeDisplayByNorm = new Map();
for (const r of live) {
const label = storeLabelFromRow(r);
if (!label) continue;
const n = normStoreLabel(label);
if (!storeDisplayByNorm.has(n)) storeDisplayByNorm.set(n, label);
}
renderStoreBar(live, storeDisplayByNorm);
renderStoreButtons(listings);
allAgg = aggregateBySku(listings, rules.canonicalSku);
aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x]));
@ -511,7 +498,6 @@ export function renderSearch($app) {
})
.catch((e) => {
$results.innerHTML = `<div class="small">Failed to load: ${esc(e.message)}</div>`;
if ($storeBar) $storeBar.innerHTML = ``;
});
let t = null;

View file

@ -1,5 +1,11 @@
import { esc, renderThumbHtml } from "./dom.js";
import { tokenizeQuery, matchesAllTokens, displaySku, keySkuForRow, parsePriceToNumber } from "./sku.js";
import {
tokenizeQuery,
matchesAllTokens,
displaySku,
keySkuForRow,
parsePriceToNumber,
} from "./sku.js";
import { loadIndex } from "./state.js";
import { aggregateBySku } from "./catalog.js";
import { loadSkuRules } from "./mapping.js";
@ -8,54 +14,57 @@ function normStoreLabel(s) {
return String(s || "").trim().toLowerCase();
}
function storeLabelFromRow(r) {
return String(r?.storeLabel || r?.store || "").trim();
}
function readLinkHrefForSkuInStore(listingsLive, canonSku, storeLabelNorm) {
// Prefer the most recent-ish url if multiple exist; stable enough for viz.
let bestUrl = "";
let bestScore = -1;
function storeQueryKey(storeNorm) {
return `stviz:v1:store:q:${storeNorm}`;
}
function loadStoreQuery(storeNorm) {
try {
return localStorage.getItem(storeQueryKey(storeNorm)) || "";
} catch {
return "";
function scoreUrl(u) {
if (!u) return -1;
let s = u.length;
if (/\bproduct\/\d+\//.test(u)) s += 50;
if (/[a-z0-9-]{8,}/i.test(u)) s += 10;
return s;
}
for (const r of listingsLive) {
if (!r || r.removed) continue;
const store = normStoreLabel(r.storeLabel || r.store || "");
if (store !== storeLabelNorm) continue;
const skuKey = String(rulesCache?.canonicalSku(keySkuForRow(r)) || keySkuForRow(r));
if (skuKey !== canonSku) continue;
const u = String(r.url || "").trim();
const sc = scoreUrl(u);
if (sc > bestScore) {
bestScore = sc;
bestUrl = u;
} else if (sc === bestScore && u && bestUrl && u < bestUrl) {
bestUrl = u;
}
}
return bestUrl;
}
function saveStoreQuery(storeNorm, v) {
try {
localStorage.setItem(storeQueryKey(storeNorm), String(v ?? ""));
} catch {}
}
// small module-level cache so we can reuse in readLinkHrefForSkuInStore
let rulesCache = null;
function urlQuality(u) {
u = String(u || "").trim();
if (!u) return -1;
let s = 0;
s += u.length;
if (/\bproduct\/\d+\//.test(u)) s += 50;
if (/[a-z0-9-]{8,}/i.test(u)) s += 10;
return s;
}
export async function renderStore($app, storeParamRaw) {
const storeParam = String(storeParamRaw || "").trim();
const storeNorm = normStoreLabel(storeParam);
export async function renderStore($app, storeLabelRaw) {
const storeLabel = String(storeLabelRaw || "").trim();
$app.innerHTML = `
<div class="container" style="max-width:980px;">
<div class="container">
<div class="topbar">
<button id="back" class="btn"> Back</button>
<span class="badge">${esc(storeParam || "Store")}</span>
<div style="flex:1"></div>
<span class="badge">${esc(storeLabel || "Store")}</span>
</div>
<div class="card">
<input id="q" class="input" placeholder="Search this store…" autocomplete="off" />
<div id="status" class="small" style="margin-top:10px;"></div>
<input id="q" class="input" placeholder="Search in this store..." autocomplete="off" />
<div class="small" id="status" style="margin-top:10px;"></div>
<div id="results" class="list"></div>
<div id="sentinel" class="small" style="text-align:center; padding:12px 0;"></div>
</div>
</div>
`;
@ -63,175 +72,156 @@ export async function renderStore($app, storeParamRaw) {
document.getElementById("back").addEventListener("click", () => (location.hash = "#/"));
const $q = document.getElementById("q");
const $results = document.getElementById("results");
const $status = document.getElementById("status");
$q.value = loadStoreQuery(storeNorm);
const $results = document.getElementById("results");
const $sentinel = document.getElementById("sentinel");
$results.innerHTML = `<div class="small">Loading…</div>`;
const [idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]);
const allRows = Array.isArray(idx.items) ? idx.items : [];
const idx = await loadIndex();
rulesCache = await loadSkuRules();
const rules = rulesCache;
// Live only
const liveAll = allRows.filter((r) => r && !r.removed);
const listingsAll = Array.isArray(idx.items) ? idx.items : [];
const liveAll = listingsAll.filter((r) => r && !r.removed);
// Resolve store display label (in case casing differs)
let storeDisplay = storeParam || "Store";
{
const dispByNorm = new Map();
for (const r of liveAll) {
const lab = storeLabelFromRow(r);
if (!lab) continue;
const n = normStoreLabel(lab);
if (!dispByNorm.has(n)) dispByNorm.set(n, lab);
}
storeDisplay = dispByNorm.get(storeNorm) || storeDisplay;
}
const storeNorm = normStoreLabel(storeLabel);
// Filter rows for this store
const liveStore = liveAll.filter((r) => normStoreLabel(storeLabelFromRow(r)) === storeNorm);
if (!liveStore.length) {
$results.innerHTML = `<div class="small">No in-stock items for this store.</div>`;
$status.textContent = "";
return;
}
// Global presence + min-price map (by canonical sku)
const presenceBySku = new Map(); // sku -> Set(storeNorm)
const minPriceBySkuStore = new Map(); // sku -> Map(storeNorm -> minPrice)
// Build global per-canonical-SKU store presence + min prices
const storesBySku = new Map(); // sku -> Set(storeLabelNorm)
const minPriceBySkuStore = new Map(); // sku -> Map(storeLabelNorm -> minPrice)
for (const r of liveAll) {
const storeLab = storeLabelFromRow(r);
const sNorm = normStoreLabel(storeLab);
if (!sNorm) continue;
const store = normStoreLabel(r.storeLabel || r.store || "");
if (!store) continue;
const skuKey = String(keySkuForRow(r) || "").trim();
if (!skuKey) continue;
const skuKey = keySkuForRow(r);
const sku = String(rules.canonicalSku(skuKey) || skuKey);
const sku = String(rules.canonicalSku(skuKey) || "").trim();
if (!sku) continue;
let ss = storesBySku.get(sku);
if (!ss) storesBySku.set(sku, (ss = new Set()));
ss.add(store);
let pres = presenceBySku.get(sku);
if (!pres) presenceBySku.set(sku, (pres = new Set()));
pres.add(sNorm);
const p = parsePriceToNumber(r?.price);
if (p === null) continue;
let m = minPriceBySkuStore.get(sku);
if (!m) minPriceBySkuStore.set(sku, (m = new Map()));
const prev = m.get(sNorm);
if (!Number.isFinite(prev) || p < prev) m.set(sNorm, p);
}
// Build store-only aggregates (canonicalized)
const storeAgg = aggregateBySku(liveStore, rules.canonicalSku);
// Best URL for this store per canonical SKU
const urlBySku = new Map(); // sku -> url
for (const r of liveStore) {
const skuKey = String(keySkuForRow(r) || "").trim();
if (!skuKey) continue;
const sku = String(rules.canonicalSku(skuKey) || "").trim();
if (!sku) continue;
const u = String(r?.url || "").trim();
if (!u) continue;
const prev = urlBySku.get(sku);
if (!prev) {
urlBySku.set(sku, u);
continue;
const p = parsePriceToNumber(r.price);
if (p !== null) {
let m = minPriceBySkuStore.get(sku);
if (!m) minPriceBySkuStore.set(sku, (m = new Map()));
const prev = m.get(store);
if (prev === undefined || p < prev) m.set(store, p);
}
const a = urlQuality(prev);
const b = urlQuality(u);
if (b > a) urlBySku.set(sku, u);
else if (b === a && u < prev) urlBySku.set(sku, u);
}
function computeCompare(it) {
const sku = String(it?.sku || "");
const pres = presenceBySku.get(sku) || new Set([storeNorm]);
const exclusive = pres.size === 1 && pres.has(storeNorm);
function bestAllPrice(sku) {
const m = minPriceBySkuStore.get(sku);
if (!m) return null;
let best = null;
for (const v of m.values()) best = best === null ? v : Math.min(best, v);
return best;
}
const storePrice = Number.isFinite(it?.cheapestPriceNum) ? it.cheapestPriceNum : null;
const m = minPriceBySkuStore.get(sku) || new Map();
let bestAll = null;
let bestOther = null;
for (const [sNorm, p] of m.entries()) {
if (!Number.isFinite(p)) continue;
bestAll = bestAll === null ? p : Math.min(bestAll, p);
if (sNorm !== storeNorm) bestOther = bestOther === null ? p : Math.min(bestOther, p);
function bestOtherPrice(sku, store) {
const m = minPriceBySkuStore.get(sku);
if (!m) return null;
let best = null;
for (const [k, v] of m.entries()) {
if (k === store) continue;
best = best === null ? v : Math.min(best, v);
}
// pct: (this - nextBestOther)/nextBestOther * 100
const pct =
storePrice !== null && bestOther !== null && bestOther > 0
? ((storePrice - bestOther) / bestOther) * 100
: null;
const EPS = 0.01;
const isBestPrice = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false;
return { exclusive, pct, isBestPrice };
return best;
}
const items = storeAgg
// Store-specific live rows only (in-stock for that store)
const rowsStoreLive = liveAll.filter((r) => normStoreLabel(r.storeLabel || r.store || "") === storeNorm);
// Aggregate in this store, grouped by canonical SKU (so mappings count as same bottle)
let items = aggregateBySku(rowsStoreLive, rules.canonicalSku);
// Decorate each item with pricing comparisons + exclusivity
const EPS = 0.01;
items = items
.map((it) => {
const c = computeCompare(it);
const sku = String(it.sku || "");
const storeSet = storesBySku.get(sku) || new Set([storeNorm]);
const exclusive = storeSet.size === 1 && storeSet.has(storeNorm);
const storePrice = Number.isFinite(it.cheapestPriceNum) ? it.cheapestPriceNum : null;
const bestAll = bestAllPrice(sku);
const other = bestOtherPrice(sku, storeNorm);
const isBest = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false;
// Sort key: compare vs cheapest alternative store (if we're best, that's "next best"; else that's "best")
const pctVsOther =
storePrice !== null && other !== null && other > 0 ? ((storePrice - other) / other) * 100 : null;
const pctVsBest =
storePrice !== null && bestAll !== null && bestAll > 0 ? ((storePrice - bestAll) / bestAll) * 100 : null;
return {
...it,
_exclusive: c.exclusive,
_pct: c.pct,
_isBestPrice: c.isBestPrice,
_exclusive: exclusive,
_storePrice: storePrice,
_bestAll: bestAll,
_bestOther: other,
_isBest: isBest,
_pctVsOther: pctVsOther,
_pctVsBest: pctVsBest,
};
})
.sort((a, b) => {
if (a._exclusive !== b._exclusive) return a._exclusive ? -1 : 1;
const ap = Number.isFinite(a._pct) ? a._pct : Infinity;
const bp = Number.isFinite(b._pct) ? b._pct : Infinity;
if (ap !== bp) return ap - bp;
const pa = a._pctVsOther;
const pb = b._pctVsOther;
const sa = pa === null ? 999999 : pa;
const sb = pb === null ? 999999 : pb;
if (sa !== sb) return sa - sb;
return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku));
return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku);
});
function pctBadge(pct) {
if (!Number.isFinite(pct)) return null;
function priceBadgeHtml(it) {
if (it._exclusive) return "";
const p = Math.round(pct);
const txt = `${p >= 0 ? "+" : ""}${p}% vs next`;
const pOther = it._pctVsOther;
const pBest = it._pctVsBest;
if (pct < -5) return { cls: "badge badgeGood", txt };
if (pct > 5) return { cls: "badge badgeBad", txt };
return { cls: "badge badgeNeutral", txt }; // -5%..+5%
// If we can't compare, skip the % badge.
if (pOther === null || !Number.isFinite(pOther)) return "";
// Grey zone: -5% .. +5% => "same as next best price"
if (Math.abs(pOther) <= 5) {
return `<span class="badge badgeNeutral">same as next best price</span>`;
}
// Deals: we're best (cheaper than other)
if (pOther < 0 && it._bestOther !== null && it._bestOther > 0 && it._storePrice !== null) {
const pct = Math.round(((it._bestOther - it._storePrice) / it._bestOther) * 100);
if (pct <= 0) return `<span class="badge badgeNeutral">same as next best price</span>`;
return `<span class="badge badgeGood">${esc(pct)}% vs next best price</span>`;
}
// More expensive: show vs best price
if (pBest !== null && Number.isFinite(pBest) && pBest > 0) {
const pct = Math.round(pBest);
if (pct <= 5) return `<span class="badge badgeNeutral">same as next best price</span>`;
return `<span class="badge badgeBad">${esc(pct)}% vs best price</span>`;
}
return "";
}
function renderCard(it) {
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
const href = urlBySku.get(String(it.sku || "")) || String(it.sampleUrl || "").trim();
const href = String(it.sampleUrl || "").trim();
const storeBadge = href
? `<a class="badge" href="${esc(href)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${esc(
storeDisplay
)}</a>`
: `<span class="badge">${esc(storeDisplay)}</span>`;
const exclusiveBadge = it._exclusive ? `<span class="badge badgeGood">Exclusive</span>` : "";
const bestBadge = !it._exclusive && it._isBest ? `<span class="badge badgeBest">Best Price</span>` : "";
const pctBadge = priceBadgeHtml(it);
const badges = [];
if (it._exclusive) badges.push(`<span class="badge badgeExclusive">EXCLUSIVE</span>`);
if (!it._exclusive && it._isBestPrice) badges.push(`<span class="badge badgeGood">Best Price</span>`);
if (!it._exclusive) {
const pb = pctBadge(it._pct);
if (pb) badges.push(`<span class="${esc(pb.cls)}">${esc(pb.txt)}</span>`);
}
const skuLink =
`#/link/?left=${encodeURIComponent(String(it.sku || ""))}`;
return `
<div class="item" data-sku="${esc(it.sku)}">
@ -240,12 +230,20 @@ export async function renderStore($app, storeParamRaw) {
<div class="itemBody">
<div class="itemTop">
<div class="itemName">${esc(it.name || "(no name)")}</div>
<span class="badge mono">${esc(displaySku(it.sku))}</span>
<a class="badge mono skuLink" href="${esc(skuLink)}" onclick="event.stopPropagation()">${esc(
displaySku(it.sku)
)}</a>
</div>
<div class="metaRow metaRowWrap">
${badges.join("")}
<div class="metaRow">
${exclusiveBadge}
${bestBadge}
${pctBadge}
<span class="mono price">${esc(price)}</span>
${storeBadge}
${
href
? `<a class="badge" href="${esc(href)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Open</a>`
: ``
}
</div>
</div>
</div>
@ -253,41 +251,81 @@ export async function renderStore($app, storeParamRaw) {
`;
}
function renderList(filtered) {
if (!filtered.length) {
$results.innerHTML = `<div class="small">No matches.</div>`;
// ---- Infinite scroll paging ----
const PAGE_SIZE = 140;
let filtered = items.slice();
let shown = 0;
function setStatus() {
const total = filtered.length;
if (!total) {
$status.textContent = "No in-stock items for this store.";
return;
}
$status.textContent = `In stock: ${total} item(s).`;
}
const limited = filtered.slice(0, 120);
$results.innerHTML = limited.map(renderCard).join("");
function renderNext(reset) {
if (reset) {
$results.innerHTML = "";
shown = 0;
}
for (const el of Array.from($results.querySelectorAll(".item"))) {
el.addEventListener("click", () => {
const sku = el.getAttribute("data-sku") || "";
if (!sku) return;
saveStoreQuery(storeNorm, $q.value);
location.hash = `#/item/${encodeURIComponent(sku)}`;
});
const slice = filtered.slice(shown, shown + PAGE_SIZE);
shown += slice.length;
if (slice.length) {
$results.insertAdjacentHTML("beforeend", slice.map(renderCard).join(""));
}
if (!filtered.length) {
$sentinel.textContent = "";
} else if (shown >= filtered.length) {
$sentinel.textContent = `Showing ${shown} / ${filtered.length}`;
} else {
$sentinel.textContent = `Showing ${shown} / ${filtered.length}`;
}
}
function applySearch() {
// Click -> item page (delegated). SKU + Open links stopPropagation already.
$results.addEventListener("click", (e) => {
const el = e.target.closest(".item");
if (!el) return;
const sku = el.getAttribute("data-sku") || "";
if (!sku) return;
location.hash = `#/item/${encodeURIComponent(sku)}`;
});
function applyFilter() {
const tokens = tokenizeQuery($q.value);
saveStoreQuery(storeNorm, $q.value);
const filtered = tokens.length ? items.filter((it) => matchesAllTokens(it.searchText, tokens)) : items;
$status.textContent = `In stock: ${items.length}. Showing: ${filtered.length}.`;
renderList(filtered);
if (!tokens.length) {
filtered = items.slice();
} else {
filtered = items.filter((it) => matchesAllTokens(it.searchText, tokens));
}
setStatus();
renderNext(true);
}
$q.focus();
applySearch();
// Initial render
setStatus();
renderNext(true);
const io = new IntersectionObserver(
(entries) => {
const hit = entries.some((x) => x.isIntersecting);
if (!hit) return;
if (shown >= filtered.length) return;
renderNext(false);
},
{ root: null, rootMargin: "600px 0px", threshold: 0.01 }
);
io.observe($sentinel);
let t = null;
$q.addEventListener("input", () => {
if (t) clearTimeout(t);
t = setTimeout(applySearch, 50);
t = setTimeout(applyFilter, 60);
});
}

View file

@ -22,6 +22,10 @@ a:hover { text-decoration: underline; }
a.badge { color: var(--muted); }
a.badge:hover { text-decoration: underline; cursor: pointer; }
/* SKU badge links (search/store -> link page) */
a.skuLink { color: var(--muted); }
a.skuLink:hover { text-decoration: underline; cursor: pointer; }
.container {
max-width: 980px;
margin: 0 auto;
@ -31,9 +35,19 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
.header {
display: flex;
gap: 12px;
align-items: center;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 14px;
flex-wrap: wrap; /* keeps Link SKUs comfy on small screens */
}
.headerLeft {
flex: 1 1 520px;
min-width: 240px;
}
.headerRight {
flex: 0 0 auto;
}
.h1 {
@ -148,6 +162,29 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
gap: 6px;
}
.badgeGood {
color: rgba(20,110,40,0.95);
background: rgba(20,110,40,0.10);
border-color: rgba(20,110,40,0.25);
}
.badgeNeutral {
color: var(--muted);
background: rgba(255,255,255,0.02);
}
.badgeBad {
color: rgba(160,40,40,0.95);
background: rgba(160,40,40,0.12);
border-color: rgba(160,40,40,0.25);
}
.badgeBest {
color: rgba(160,120,20,0.95);
background: rgba(160,120,20,0.10);
border-color: rgba(160,120,20,0.22);
}
.metaRow {
margin-top: 8px;
display: flex;
@ -159,8 +196,6 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
font-size: 13px;
}
.metaRowWrap { flex-wrap: wrap; } /* used for store page so badges can wrap */
.price {
font-weight: 700;
color: var(--text);
@ -186,6 +221,13 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
.btn:hover { border-color: #2f3a46; }
.btnWide {
padding-left: 14px;
padding-right: 14px;
min-width: 120px;
text-align: center;
}
.links {
display: flex;
gap: 10px;
@ -198,54 +240,39 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
font-size: 12px;
}
/* --- Store selector pills (search page header) --- */
/* --- Store selector (top of search page) --- */
.storeBarWrap {
margin-top: 8px;
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.storeBarLabel {
flex: 0 0 auto;
margin-top: 12px; /* slightly more gap from the top text */
}
.storeBar {
display: flex;
gap: 8px;
flex-wrap: wrap; /* desktop: wrap nicely into 2 lines if needed */
min-width: 0;
flex-wrap: wrap;
}
.storePill {
padding: 2px 9px;
.storeBtn {
border: 1px solid var(--border);
background: #0f1318;
color: var(--muted);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
line-height: 1;
display: inline-flex;
align-items: center;
}
/* Badge color variants (store page percent badges) */
.badgeGood {
color: rgba(20,110,40,0.95);
background: rgba(20,110,40,0.10);
border-color: rgba(20,110,40,0.20);
.storeBtn:hover {
border-color: #2f3a46;
color: var(--text);
text-decoration: none;
}
.badgeBad {
color: rgba(180,70,60,0.95);
background: rgba(180,70,60,0.10);
border-color: rgba(180,70,60,0.25);
}
.badgeNeutral {
color: rgba(154,166,178,0.95);
background: rgba(154,166,178,0.08);
border-color: rgba(154,166,178,0.18);
}
.badgeExclusive {
color: rgba(125,211,252,0.95);
background: rgba(125,211,252,0.10);
border-color: rgba(125,211,252,0.20);
.storeBtnActive {
border-color: #37566b;
outline: 1px solid #37566b;
color: var(--text);
}
/* Detail view sizing */
@ -310,15 +337,6 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
min-height: 260px;
padding: 8px;
}
/* mobile: make stores a single-row scroll instead of wrapping */
.storeBar {
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 6px;
-webkit-overflow-scrolling: touch;
}
.storeBar::-webkit-scrollbar { display: none; }
}
.chartBox canvas {