mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
feat: V6 store page
This commit is contained in:
parent
90ea304ff6
commit
92d6cfccd4
2 changed files with 125 additions and 71 deletions
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue