feat: V6 store page

This commit is contained in:
Brennan Wilkes (Text Groove) 2026-01-30 14:26:03 -08:00
parent 90ea304ff6
commit 92d6cfccd4
2 changed files with 125 additions and 71 deletions

View file

@ -14,27 +14,6 @@ function normStoreLabel(s) {
return String(s || "").trim().toLowerCase(); return String(s || "").trim().toLowerCase();
} }
const STORE_Q_LS_PREFIX = "stviz:v1:store:q:";
function storeQKey(storeLabel) {
return STORE_Q_LS_PREFIX + String(storeLabel || "").trim().toLowerCase();
}
function loadStoreQuery(storeLabel) {
try {
return localStorage.getItem(storeQKey(storeLabel)) || "";
} catch {
return "";
}
}
function saveStoreQuery(storeLabel, v) {
try {
localStorage.setItem(storeQKey(storeLabel), String(v ?? ""));
} catch {}
}
function readLinkHrefForSkuInStore(listingsLive, canonSku, storeLabelNorm) { function readLinkHrefForSkuInStore(listingsLive, canonSku, storeLabelNorm) {
// Prefer the most recent-ish url if multiple exist; stable enough for viz. // Prefer the most recent-ish url if multiple exist; stable enough for viz.
let bestUrl = ""; let bestUrl = "";
@ -84,7 +63,25 @@ export async function renderStore($app, storeLabelRaw) {
<div class="card"> <div class="card">
<input id="q" class="input" placeholder="Search in this store..." autocomplete="off" /> <input id="q" class="input" placeholder="Search in this store..." autocomplete="off" />
<div class="small" id="status" style="margin-top:10px;"></div> <div class="small" id="status" style="margin-top:10px;"></div>
<div id="results" class="list"></div>
<div id="results" class="storeGrid">
<div class="storeCol">
<div class="storeColHeader">
<span class="badge badgeGood">Exclusive</span>
<span class="small">Only sold here</span>
</div>
<div id="resultsExclusive" class="storeColList"></div>
</div>
<div class="storeCol">
<div class="storeColHeader">
<span class="badge">Price compare</span>
<span class="small">Cross-store pricing</span>
</div>
<div id="resultsCompare" class="storeColList"></div>
</div>
</div>
<div id="sentinel" class="small" style="text-align:center; padding:12px 0;"></div> <div id="sentinel" class="small" style="text-align:center; padding:12px 0;"></div>
</div> </div>
</div> </div>
@ -92,17 +89,24 @@ export async function renderStore($app, storeLabelRaw) {
document.getElementById("back").addEventListener("click", () => { document.getElementById("back").addEventListener("click", () => {
sessionStorage.setItem("viz:lastRoute", location.hash); sessionStorage.setItem("viz:lastRoute", location.hash);
location.hash = "#/" location.hash = "#/";
}); });
const $q = document.getElementById("q"); const $q = document.getElementById("q");
$q.value = loadStoreQuery(storeLabel);
const $status = document.getElementById("status"); const $status = document.getElementById("status");
const $results = document.getElementById("results"); const $resultsExclusive = document.getElementById("resultsExclusive");
const $resultsCompare = document.getElementById("resultsCompare");
const $sentinel = document.getElementById("sentinel"); const $sentinel = document.getElementById("sentinel");
const $resultsWrap = document.getElementById("results");
$results.innerHTML = `<div class="small">Loading…</div>`; // Persist query per store
const storeNorm = normStoreLabel(storeLabel);
const LS_KEY = `viz:storeQuery:${storeNorm}`;
const savedQ = String(localStorage.getItem(LS_KEY) || "");
if (savedQ) $q.value = savedQ;
$resultsExclusive.innerHTML = `<div class="small">Loading…</div>`;
$resultsCompare.innerHTML = ``;
const idx = await loadIndex(); const idx = await loadIndex();
rulesCache = await loadSkuRules(); rulesCache = await loadSkuRules();
@ -111,8 +115,6 @@ export async function renderStore($app, storeLabelRaw) {
const listingsAll = Array.isArray(idx.items) ? idx.items : []; const listingsAll = Array.isArray(idx.items) ? idx.items : [];
const liveAll = listingsAll.filter((r) => r && !r.removed); const liveAll = listingsAll.filter((r) => r && !r.removed);
const storeNorm = normStoreLabel(storeLabel);
// Build global per-canonical-SKU store presence + min prices // Build global per-canonical-SKU store presence + min prices
const storesBySku = new Map(); // sku -> Set(storeLabelNorm) const storesBySku = new Map(); // sku -> Set(storeLabelNorm)
const minPriceBySkuStore = new Map(); // sku -> Map(storeLabelNorm -> minPrice) const minPriceBySkuStore = new Map(); // sku -> Map(storeLabelNorm -> minPrice)
@ -157,7 +159,9 @@ export async function renderStore($app, storeLabelRaw) {
} }
// Store-specific live rows only (in-stock for that store) // Store-specific live rows only (in-stock for that store)
const rowsStoreLive = liveAll.filter((r) => normStoreLabel(r.storeLabel || r.store || "") === storeNorm); 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) // Aggregate in this store, grouped by canonical SKU (so mappings count as same bottle)
let items = aggregateBySku(rowsStoreLive, rules.canonicalSku); let items = aggregateBySku(rowsStoreLive, rules.canonicalSku);
@ -177,12 +181,15 @@ export async function renderStore($app, storeLabelRaw) {
const isBest = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false; 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 = const pctVsOther =
storePrice !== null && other !== null && other > 0 ? ((storePrice - other) / other) * 100 : null; storePrice !== null && other !== null && other > 0
? ((storePrice - other) / other) * 100
: null;
const pctVsBest = const pctVsBest =
storePrice !== null && bestAll !== null && bestAll > 0 ? ((storePrice - bestAll) / bestAll) * 100 : null; storePrice !== null && bestAll !== null && bestAll > 0
? ((storePrice - bestAll) / bestAll) * 100
: null;
return { return {
...it, ...it,
@ -213,22 +220,18 @@ export async function renderStore($app, storeLabelRaw) {
const pOther = it._pctVsOther; const pOther = it._pctVsOther;
const pBest = it._pctVsBest; const pBest = it._pctVsBest;
// If we can't compare, skip the % badge.
if (pOther === null || !Number.isFinite(pOther)) return ""; if (pOther === null || !Number.isFinite(pOther)) return "";
// Grey zone: -5% .. +5% => "same as next best price"
if (Math.abs(pOther) <= 5) { if (Math.abs(pOther) <= 5) {
return `<span class="badge badgeNeutral">same as next best price</span>`; 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) { if (pOther < 0 && it._bestOther !== null && it._bestOther > 0 && it._storePrice !== null) {
const pct = Math.round(((it._bestOther - it._storePrice) / it._bestOther) * 100); 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>`; 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>`; 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) { if (pBest !== null && Number.isFinite(pBest) && pBest > 0) {
const pct = Math.round(pBest); const pct = Math.round(pBest);
if (pct <= 5) return `<span class="badge badgeNeutral">same as next best price</span>`; if (pct <= 5) return `<span class="badge badgeNeutral">same as next best price</span>`;
@ -246,8 +249,7 @@ export async function renderStore($app, storeLabelRaw) {
const bestBadge = !it._exclusive && it._isBest ? `<span class="badge badgeBest">Best Price</span>` : ""; const bestBadge = !it._exclusive && it._isBest ? `<span class="badge badgeBest">Best Price</span>` : "";
const pctBadge = priceBadgeHtml(it); const pctBadge = priceBadgeHtml(it);
const skuLink = const skuLink = `#/link/?left=${encodeURIComponent(String(it.sku || ""))}`;
`#/link/?left=${encodeURIComponent(String(it.sku || ""))}`;
return ` return `
<div class="item" data-sku="${esc(it.sku)}"> <div class="item" data-sku="${esc(it.sku)}">
@ -256,9 +258,8 @@ export async function renderStore($app, storeLabelRaw) {
<div class="itemBody"> <div class="itemBody">
<div class="itemTop"> <div class="itemTop">
<div class="itemName">${esc(it.name || "(no name)")}</div> <div class="itemName">${esc(it.name || "(no name)")}</div>
<a class="badge mono skuLink" target="_blank" rel="noopener noreferrer" href="${esc(skuLink)}" onclick="event.stopPropagation()">${esc( <a class="badge mono skuLink" target="_blank" rel="noopener noreferrer"
displaySku(it.sku) href="${esc(skuLink)}" onclick="event.stopPropagation()">${esc(displaySku(it.sku))}</a>
)}</a>
</div> </div>
<div class="metaRow"> <div class="metaRow">
${exclusiveBadge} ${exclusiveBadge}
@ -277,14 +278,24 @@ export async function renderStore($app, storeLabelRaw) {
`; `;
} }
// ---- Infinite scroll paging ---- // ---- Infinite scroll paging (shared across both columns) ----
const PAGE_SIZE = 140; const PAGE_SIZE = 140; // total per "page" across both columns
const PAGE_EACH = Math.max(1, Math.floor(PAGE_SIZE / 2));
let filtered = items.slice(); let filteredExclusive = [];
let shown = 0; let filteredCompare = [];
let shownExclusive = 0;
let shownCompare = 0;
function totalFiltered() {
return filteredExclusive.length + filteredCompare.length;
}
function totalShown() {
return shownExclusive + shownCompare;
}
function setStatus() { function setStatus() {
const total = filtered.length; const total = totalFiltered();
if (!total) { if (!total) {
$status.textContent = "No in-stock items for this store."; $status.textContent = "No in-stock items for this store.";
return; return;
@ -294,28 +305,35 @@ export async function renderStore($app, storeLabelRaw) {
function renderNext(reset) { function renderNext(reset) {
if (reset) { if (reset) {
$results.innerHTML = ""; $resultsExclusive.innerHTML = "";
shown = 0; $resultsCompare.innerHTML = "";
shownExclusive = 0;
shownCompare = 0;
} }
const slice = filtered.slice(shown, shown + PAGE_SIZE); const sliceEx = filteredExclusive.slice(shownExclusive, shownExclusive + PAGE_EACH);
shown += slice.length; const sliceCo = filteredCompare.slice(shownCompare, shownCompare + PAGE_EACH);
if (slice.length) { shownExclusive += sliceEx.length;
$results.insertAdjacentHTML("beforeend", slice.map(renderCard).join("")); shownCompare += sliceCo.length;
}
if (!filtered.length) { if (sliceEx.length) $resultsExclusive.insertAdjacentHTML("beforeend", sliceEx.map(renderCard).join(""));
if (sliceCo.length) $resultsCompare.insertAdjacentHTML("beforeend", sliceCo.map(renderCard).join(""));
const total = totalFiltered();
const shown = totalShown();
if (!total) {
$sentinel.textContent = ""; $sentinel.textContent = "";
} else if (shown >= filtered.length) { } else if (shown >= total) {
$sentinel.textContent = `Showing ${shown} / ${filtered.length}`; $sentinel.textContent = `Showing ${shown} / ${total}`;
} else { } else {
$sentinel.textContent = `Showing ${shown} / ${filtered.length}`; $sentinel.textContent = `Showing ${shown} / ${total}`;
} }
} }
// Click -> item page (delegated). SKU + Open links stopPropagation already. // Click -> item page (delegated). SKU + Open links stopPropagation already.
$results.addEventListener("click", (e) => { $resultsWrap.addEventListener("click", (e) => {
const el = e.target.closest(".item"); const el = e.target.closest(".item");
if (!el) return; if (!el) return;
const sku = el.getAttribute("data-sku") || ""; const sku = el.getAttribute("data-sku") || "";
@ -325,26 +343,31 @@ export async function renderStore($app, storeLabelRaw) {
}); });
function applyFilter() { function applyFilter() {
const tokens = tokenizeQuery($q.value); const raw = String($q.value || "");
localStorage.setItem(LS_KEY, raw);
if (!tokens.length) { const tokens = tokenizeQuery(raw);
filtered = items.slice();
} else { let base = items;
filtered = items.filter((it) => matchesAllTokens(it.searchText, tokens)); if (tokens.length) {
base = items.filter((it) => matchesAllTokens(it.searchText, tokens));
} }
filteredExclusive = base.filter((it) => it._exclusive);
filteredCompare = base.filter((it) => !it._exclusive);
setStatus(); setStatus();
renderNext(true); renderNext(true);
} }
// Initial render // Initial render (apply saved query if present)
setStatus(); applyFilter();
renderNext(true);
const io = new IntersectionObserver( const io = new IntersectionObserver(
(entries) => { (entries) => {
const hit = entries.some((x) => x.isIntersecting); const hit = entries.some((x) => x.isIntersecting);
if (!hit) return; if (!hit) return;
if (shown >= filtered.length) return; if (totalShown() >= totalFiltered()) return;
renderNext(false); renderNext(false);
}, },
{ root: null, rootMargin: "600px 0px", threshold: 0.01 } { root: null, rootMargin: "600px 0px", threshold: 0.01 }
@ -353,7 +376,6 @@ export async function renderStore($app, storeLabelRaw) {
let t = null; let t = null;
$q.addEventListener("input", () => { $q.addEventListener("input", () => {
saveStoreQuery(storeLabel, $q.value);
if (t) clearTimeout(t); if (t) clearTimeout(t);
t = setTimeout(applyFilter, 60); t = setTimeout(applyFilter, 60);
}); });

View file

@ -253,7 +253,6 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; }
justify-content: space-between; /* spread each row to fill */ justify-content: space-between; /* spread each row to fill */
} }
.storeBtn { .storeBtn {
border: 1px solid var(--border); border: 1px solid var(--border);
background: #0f1318; background: #0f1318;
@ -358,3 +357,36 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; }
position: sticky; position: sticky;
bottom: 0; bottom: 0;
} }
/* --- Store page: two-column results (new; isolated to store page) --- */
.storeGrid {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
}
.storeCol {
min-width: 0;
}
.storeColHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 2px 10px 2px;
}
.storeColList {
display: flex;
flex-direction: column;
gap: 10px;
}
@media (max-width: 640px) {
.storeGrid {
grid-template-columns: 1fr; /* stack columns */
}
}