mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
feat: V2 store page
This commit is contained in:
parent
54c99c3a32
commit
d92e4b8cf3
4 changed files with 315 additions and 347 deletions
|
|
@ -1,9 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Hash routes:
|
* Hash routes:
|
||||||
* #/ search
|
* #/ search
|
||||||
* #/item/<sku> detail
|
* #/item/<sku> detail
|
||||||
* #/link sku linker (local-write only)
|
* #/link sku linker (local-write only)
|
||||||
* #/store/<storeLabel> store page (in-stock only)
|
* #/store/<store> store page (in-stock only)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { destroyChart } from "./item_page.js";
|
import { destroyChart } from "./item_page.js";
|
||||||
|
|
@ -24,8 +24,8 @@ function route() {
|
||||||
|
|
||||||
if (parts.length === 0) return renderSearch($app);
|
if (parts.length === 0) return renderSearch($app);
|
||||||
if (parts[0] === "item" && parts[1]) return renderItem($app, decodeURIComponent(parts[1]));
|
if (parts[0] === "item" && parts[1]) return renderItem($app, decodeURIComponent(parts[1]));
|
||||||
if (parts[0] === "link") return renderSkuLinker($app);
|
|
||||||
if (parts[0] === "store" && parts[1]) return renderStore($app, decodeURIComponent(parts[1]));
|
if (parts[0] === "store" && parts[1]) return renderStore($app, decodeURIComponent(parts[1]));
|
||||||
|
if (parts[0] === "link") return renderSkuLinker($app);
|
||||||
|
|
||||||
return renderSearch($app);
|
return renderSearch($app);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,18 @@ export function renderSearch($app) {
|
||||||
$app.innerHTML = `
|
$app.innerHTML = `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<div style="min-width:0;">
|
||||||
<h1 class="h1">Spirit Tracker Viz</h1>
|
<h1 class="h1">Spirit Tracker Viz</h1>
|
||||||
<div class="small">Search name / url / sku (word AND)</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn" href="#/link" style="text-decoration:none;">Link SKUs</a>
|
<a class="btn" href="#/link" style="text-decoration:none;">Link SKUs</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card storeNav">
|
|
||||||
<div class="storeNavRow">
|
|
||||||
<select id="storeSelect" class="input storeSelect">
|
|
||||||
<option value="">Open store…</option>
|
|
||||||
</select>
|
|
||||||
<input id="storeFilter" class="input storeFilter" placeholder="Filter stores…" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div id="stores" class="storeList"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<input id="q" class="input" placeholder="e.g. bowmore sherry, 303821, sierrasprings..." autocomplete="off" />
|
<input id="q" class="input" placeholder="e.g. bowmore sherry, 303821, sierrasprings..." autocomplete="off" />
|
||||||
<div id="results" class="list"></div>
|
<div id="results" class="list"></div>
|
||||||
|
|
@ -35,10 +30,7 @@ export function renderSearch($app) {
|
||||||
|
|
||||||
const $q = document.getElementById("q");
|
const $q = document.getElementById("q");
|
||||||
const $results = document.getElementById("results");
|
const $results = document.getElementById("results");
|
||||||
|
const $storeBar = document.getElementById("storeBar");
|
||||||
const $storeSelect = document.getElementById("storeSelect");
|
|
||||||
const $storeFilter = document.getElementById("storeFilter");
|
|
||||||
const $stores = document.getElementById("stores");
|
|
||||||
|
|
||||||
$q.value = loadSavedQuery();
|
$q.value = loadSavedQuery();
|
||||||
|
|
||||||
|
|
@ -49,53 +41,14 @@ export function renderSearch($app) {
|
||||||
// canonicalSku -> storeLabel -> url
|
// canonicalSku -> storeLabel -> url
|
||||||
let URL_BY_SKU_STORE = new Map();
|
let URL_BY_SKU_STORE = new Map();
|
||||||
|
|
||||||
// --- Store nav ---
|
function normStoreLabel(s) {
|
||||||
let ALL_STORES = [];
|
return String(s || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function storeLabelForRow(r) {
|
function storeLabelFromRow(r) {
|
||||||
return String(r?.storeLabel || r?.store || "").trim();
|
return String(r?.storeLabel || r?.store || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractStores(listings) {
|
|
||||||
const s = new Set();
|
|
||||||
for (const r of Array.isArray(listings) ? listings : []) {
|
|
||||||
const lab = storeLabelForRow(r);
|
|
||||||
if (lab) s.add(lab);
|
|
||||||
}
|
|
||||||
return Array.from(s).sort((a, b) => a.localeCompare(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStoreSelect(stores) {
|
|
||||||
$storeSelect.innerHTML =
|
|
||||||
`<option value="">Open store…</option>` +
|
|
||||||
stores.map((x) => `<option value="${esc(x)}">${esc(x)}</option>`).join("");
|
|
||||||
|
|
||||||
$storeSelect.addEventListener("change", () => {
|
|
||||||
const v = String($storeSelect.value || "");
|
|
||||||
if (!v) return;
|
|
||||||
location.hash = `#/store/${encodeURIComponent(v)}`;
|
|
||||||
$storeSelect.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStoreChips(filterText) {
|
|
||||||
const f = String(filterText || "").trim().toLowerCase();
|
|
||||||
const filtered = !f ? ALL_STORES : ALL_STORES.filter((x) => String(x).toLowerCase().includes(f));
|
|
||||||
|
|
||||||
$stores.innerHTML = filtered.length
|
|
||||||
? filtered
|
|
||||||
.map(
|
|
||||||
(lab) =>
|
|
||||||
`<a class="badge storeChip" href="#/store/${encodeURIComponent(lab)}">${esc(lab)}</a>`
|
|
||||||
)
|
|
||||||
.join("")
|
|
||||||
: `<div class="small">No store matches.</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$storeFilter.addEventListener("input", () => {
|
|
||||||
renderStoreChips($storeFilter.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildUrlMap(listings, canonicalSkuFn) {
|
function buildUrlMap(listings, canonicalSkuFn) {
|
||||||
const out = new Map();
|
const out = new Map();
|
||||||
for (const r of Array.isArray(listings) ? listings : []) {
|
for (const r of Array.isArray(listings) ? listings : []) {
|
||||||
|
|
@ -107,7 +60,7 @@ export function renderSearch($app) {
|
||||||
const sku = String(canonicalSkuFn ? canonicalSkuFn(skuKey) : skuKey);
|
const sku = String(canonicalSkuFn ? canonicalSkuFn(skuKey) : skuKey);
|
||||||
if (!sku) continue;
|
if (!sku) continue;
|
||||||
|
|
||||||
const storeLabel = String(r.storeLabel || r.store || "").trim();
|
const storeLabel = storeLabelFromRow(r);
|
||||||
const url = String(r.url || "").trim();
|
const url = String(r.url || "").trim();
|
||||||
if (!storeLabel || !url) continue;
|
if (!storeLabel || !url) continue;
|
||||||
|
|
||||||
|
|
@ -124,6 +77,20 @@ export function renderSearch($app) {
|
||||||
return URL_BY_SKU_STORE.get(sku)?.get(s) || "";
|
return URL_BY_SKU_STORE.get(sku)?.get(s) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStoreBar(listingsLive, storeLabelMapDisplay) {
|
||||||
|
if (!$storeBar) return;
|
||||||
|
|
||||||
|
const stores = Array.from(storeLabelMapDisplay.values()).sort((a, b) => a.localeCompare(b));
|
||||||
|
if (!stores.length) {
|
||||||
|
$storeBar.innerHTML = `<span class="small">No stores</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$storeBar.innerHTML = stores
|
||||||
|
.map((s) => `<a class="badge storePill" href="#/store/${encodeURIComponent(s)}">${esc(s)}</a>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function renderAggregates(items) {
|
function renderAggregates(items) {
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
$results.innerHTML = `<div class="small">No matches.</div>`;
|
$results.innerHTML = `<div class="small">No matches.</div>`;
|
||||||
|
|
@ -434,11 +401,15 @@ export function renderSearch($app) {
|
||||||
|
|
||||||
const offBadge =
|
const offBadge =
|
||||||
meta.kind === "price_down" && meta.pctOff !== null
|
meta.kind === "price_down" && meta.pctOff !== null
|
||||||
? `<span class="badge good" style="margin-left:6px;">[${esc(meta.pctOff)}% Off]</span>`
|
? `<span class="badge" style="margin-left:6px; color:rgba(20,110,40,0.95); background:rgba(20,110,40,0.10); border:1px solid rgba(20,110,40,0.20);">[${esc(
|
||||||
|
meta.pctOff
|
||||||
|
)}% Off]</span>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const kindBadgeStyle =
|
const kindBadgeStyle =
|
||||||
meta.kind === "new" && meta.isNewUnique ? ` class="badge good"` : ` class="badge"`;
|
meta.kind === "new" && meta.isNewUnique
|
||||||
|
? ` style="color:rgba(20,110,40,0.95); background:rgba(20,110,40,0.10); border:1px solid rgba(20,110,40,0.20);"`
|
||||||
|
: "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="item" data-sku="${esc(sku)}">
|
<div class="item" data-sku="${esc(sku)}">
|
||||||
|
|
@ -452,7 +423,7 @@ export function renderSearch($app) {
|
||||||
<span class="badge mono">${esc(displaySku(sku))}</span>
|
<span class="badge mono">${esc(displaySku(sku))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metaRow">
|
<div class="metaRow">
|
||||||
<span${kindBadgeStyle}>${esc(kindLabel)}</span>
|
<span class="badge"${kindBadgeStyle}>${esc(kindLabel)}</span>
|
||||||
<span class="mono price">${esc(priceLine)}</span>
|
<span class="mono price">${esc(priceLine)}</span>
|
||||||
${offBadge}
|
${offBadge}
|
||||||
${storeBadge}
|
${storeBadge}
|
||||||
|
|
@ -507,15 +478,22 @@ export function renderSearch($app) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$results.innerHTML = `<div class="small">Loading index…</div>`;
|
$results.innerHTML = `<div class="small">Loading index…</div>`;
|
||||||
|
if ($storeBar) $storeBar.innerHTML = `<span class="small">Loading stores…</span>`;
|
||||||
|
|
||||||
Promise.all([loadIndex(), loadSkuRules()])
|
Promise.all([loadIndex(), loadSkuRules()])
|
||||||
.then(([idx, rules]) => {
|
.then(([idx, rules]) => {
|
||||||
const listings = Array.isArray(idx.items) ? idx.items : [];
|
const listings = Array.isArray(idx.items) ? idx.items : [];
|
||||||
|
|
||||||
// store nav: build once
|
// Build store list from LIVE rows only
|
||||||
ALL_STORES = extractStores(listings);
|
const live = listings.filter((r) => r && !r.removed);
|
||||||
renderStoreSelect(ALL_STORES);
|
const storeDisplayByNorm = new Map();
|
||||||
renderStoreChips($storeFilter.value);
|
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);
|
||||||
|
|
||||||
allAgg = aggregateBySku(listings, rules.canonicalSku);
|
allAgg = aggregateBySku(listings, rules.canonicalSku);
|
||||||
aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x]));
|
aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x]));
|
||||||
|
|
@ -533,6 +511,7 @@ export function renderSearch($app) {
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
$results.innerHTML = `<div class="small">Failed to load: ${esc(e.message)}</div>`;
|
$results.innerHTML = `<div class="small">Failed to load: ${esc(e.message)}</div>`;
|
||||||
|
if ($storeBar) $storeBar.innerHTML = ``;
|
||||||
});
|
});
|
||||||
|
|
||||||
let t = null;
|
let t = null;
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,60 @@
|
||||||
import { esc, renderThumbHtml } from "./dom.js";
|
import { esc, renderThumbHtml } from "./dom.js";
|
||||||
import {
|
import { tokenizeQuery, matchesAllTokens, displaySku, keySkuForRow, parsePriceToNumber } from "./sku.js";
|
||||||
tokenizeQuery,
|
|
||||||
matchesAllTokens,
|
|
||||||
displaySku,
|
|
||||||
keySkuForRow,
|
|
||||||
parsePriceToNumber,
|
|
||||||
normSearchText,
|
|
||||||
} from "./sku.js";
|
|
||||||
import { loadIndex } from "./state.js";
|
import { loadIndex } from "./state.js";
|
||||||
import { aggregateBySku } from "./catalog.js";
|
import { aggregateBySku } from "./catalog.js";
|
||||||
import { loadSkuRules } from "./mapping.js";
|
import { loadSkuRules } from "./mapping.js";
|
||||||
|
|
||||||
function storeLabelForRow(r) {
|
function normStoreLabel(s) {
|
||||||
|
return String(s || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeLabelFromRow(r) {
|
||||||
return String(r?.storeLabel || r?.store || "").trim();
|
return String(r?.storeLabel || r?.store || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractStores(listings) {
|
function storeQueryKey(storeNorm) {
|
||||||
const s = new Set();
|
return `stviz:v1:store:q:${storeNorm}`;
|
||||||
for (const r of Array.isArray(listings) ? listings : []) {
|
}
|
||||||
const lab = storeLabelForRow(r);
|
|
||||||
if (lab) s.add(lab);
|
function loadStoreQuery(storeNorm) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(storeQueryKey(storeNorm)) || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
return Array.from(s).sort((a, b) => a.localeCompare(b));
|
}
|
||||||
|
|
||||||
|
function saveStoreQuery(storeNorm, v) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storeQueryKey(storeNorm), String(v ?? ""));
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlQuality(u) {
|
function urlQuality(u) {
|
||||||
const s = String(u || "").trim();
|
u = String(u || "").trim();
|
||||||
if (!s) return -1;
|
if (!u) return -1;
|
||||||
let sc = 0;
|
let s = 0;
|
||||||
sc += s.length;
|
s += u.length;
|
||||||
if (/\bproduct\/\d+\//.test(s)) sc += 50;
|
if (/\bproduct\/\d+\//.test(u)) s += 50;
|
||||||
if (/[a-z0-9-]{8,}/i.test(s)) sc += 10;
|
if (/[a-z0-9-]{8,}/i.test(u)) s += 10;
|
||||||
return sc;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pctClass(pct) {
|
export async function renderStore($app, storeParamRaw) {
|
||||||
if (!Number.isFinite(pct)) return "neutral";
|
const storeParam = String(storeParamRaw || "").trim();
|
||||||
if (pct >= 5) return "good";
|
const storeNorm = normStoreLabel(storeParam);
|
||||||
if (pct <= -5) return "bad";
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pctLabel(pct) {
|
|
||||||
if (!Number.isFinite(pct)) return "No compare";
|
|
||||||
const s = pct > 0 ? `+${pct}` : `${pct}`;
|
|
||||||
return `${s}% vs next`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renderStore($app, storeLabelInput) {
|
|
||||||
$app.innerHTML = `
|
$app.innerHTML = `
|
||||||
<div class="container">
|
<div class="container" style="max-width:980px;">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button id="back" class="btn">← Back</button>
|
<button id="back" class="btn">← Back</button>
|
||||||
<span class="badge">${esc(storeLabelInput || "Store")}</span>
|
<span class="badge">${esc(storeParam || "Store")}</span>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="small" id="subtitle">Loading…</div>
|
<input id="q" class="input" placeholder="Search this store…" autocomplete="off" />
|
||||||
<input id="q" class="input" placeholder="Search this store (name / url / sku)..." autocomplete="off" />
|
<div id="status" class="small" style="margin-top:10px;"></div>
|
||||||
<div id="results" class="list"></div>
|
<div id="results" class="list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,52 +62,50 @@ export async function renderStore($app, storeLabelInput) {
|
||||||
|
|
||||||
document.getElementById("back").addEventListener("click", () => (location.hash = "#/"));
|
document.getElementById("back").addEventListener("click", () => (location.hash = "#/"));
|
||||||
|
|
||||||
const $subtitle = document.getElementById("subtitle");
|
|
||||||
const $q = document.getElementById("q");
|
const $q = document.getElementById("q");
|
||||||
const $results = document.getElementById("results");
|
const $results = document.getElementById("results");
|
||||||
|
const $status = document.getElementById("status");
|
||||||
|
|
||||||
$results.innerHTML = `<div class="small">Loading index…</div>`;
|
$q.value = loadStoreQuery(storeNorm);
|
||||||
|
|
||||||
let idx, rules;
|
$results.innerHTML = `<div class="small">Loading…</div>`;
|
||||||
try {
|
|
||||||
[idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]);
|
const [idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]);
|
||||||
} catch (e) {
|
const allRows = Array.isArray(idx.items) ? idx.items : [];
|
||||||
$results.innerHTML = `<div class="small">Failed to load: ${esc(e?.message || String(e))}</div>`;
|
|
||||||
$subtitle.textContent = "";
|
// Live only
|
||||||
|
const liveAll = allRows.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const listings = Array.isArray(idx?.items) ? idx.items : [];
|
// Global presence + min-price map (by canonical sku)
|
||||||
const stores = extractStores(listings);
|
const presenceBySku = new Map(); // sku -> Set(storeNorm)
|
||||||
|
const minPriceBySkuStore = new Map(); // sku -> Map(storeNorm -> minPrice)
|
||||||
|
|
||||||
// Normalize store label by case-insensitive match (helps if someone edits hash by hand)
|
for (const r of liveAll) {
|
||||||
const wantLower = String(storeLabelInput || "").trim().toLowerCase();
|
const storeLab = storeLabelFromRow(r);
|
||||||
const storeLabel =
|
const sNorm = normStoreLabel(storeLab);
|
||||||
stores.find((s) => String(s).toLowerCase() === wantLower) || String(storeLabelInput || "").trim();
|
if (!sNorm) continue;
|
||||||
|
|
||||||
// Update header badge text (if normalized)
|
|
||||||
const $badge = document.querySelector(".topbar .badge");
|
|
||||||
if ($badge) $badge.textContent = storeLabel || "Store";
|
|
||||||
|
|
||||||
if (!storeLabel || !stores.includes(storeLabel)) {
|
|
||||||
$subtitle.textContent = "Unknown store.";
|
|
||||||
$results.innerHTML =
|
|
||||||
`<div class="small">Pick a store:</div>` +
|
|
||||||
`<div class="storeList" style="margin-top:10px;">` +
|
|
||||||
stores.map((s) => `<a class="badge storeChip" href="#/store/${encodeURIComponent(s)}">${esc(s)}</a>`).join("") +
|
|
||||||
`</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build canonical aggregates
|
|
||||||
const allAgg = aggregateBySku(listings, rules.canonicalSku);
|
|
||||||
|
|
||||||
// Build per-(canonical sku)->store min price + best URL (LIVE only)
|
|
||||||
const PRICE_BY_SKU_STORE = new Map(); // sku -> Map(store -> {priceNum, priceStr})
|
|
||||||
const URL_BY_SKU_STORE = new Map(); // sku -> Map(store -> url)
|
|
||||||
|
|
||||||
for (const r of listings) {
|
|
||||||
if (!r || r.removed) continue;
|
|
||||||
|
|
||||||
const skuKey = String(keySkuForRow(r) || "").trim();
|
const skuKey = String(keySkuForRow(r) || "").trim();
|
||||||
if (!skuKey) continue;
|
if (!skuKey) continue;
|
||||||
|
|
@ -119,182 +113,181 @@ export async function renderStore($app, storeLabelInput) {
|
||||||
const sku = String(rules.canonicalSku(skuKey) || "").trim();
|
const sku = String(rules.canonicalSku(skuKey) || "").trim();
|
||||||
if (!sku) continue;
|
if (!sku) continue;
|
||||||
|
|
||||||
const lab = storeLabelForRow(r);
|
let pres = presenceBySku.get(sku);
|
||||||
if (!lab) continue;
|
if (!pres) presenceBySku.set(sku, (pres = new Set()));
|
||||||
|
pres.add(sNorm);
|
||||||
|
|
||||||
// price
|
const p = parsePriceToNumber(r?.price);
|
||||||
const pNum = parsePriceToNumber(r.price);
|
if (p === null) continue;
|
||||||
const pStr = String(r.price || "").trim();
|
|
||||||
|
|
||||||
let sm = PRICE_BY_SKU_STORE.get(sku);
|
let m = minPriceBySkuStore.get(sku);
|
||||||
if (!sm) PRICE_BY_SKU_STORE.set(sku, (sm = new Map()));
|
if (!m) minPriceBySkuStore.set(sku, (m = new Map()));
|
||||||
|
|
||||||
if (pNum !== null) {
|
const prev = m.get(sNorm);
|
||||||
const prev = sm.get(lab);
|
if (!Number.isFinite(prev) || p < prev) m.set(sNorm, p);
|
||||||
if (!prev || pNum < prev.priceNum) sm.set(lab, { priceNum: pNum, priceStr: pStr });
|
|
||||||
else if (prev && pNum === prev.priceNum && pStr && (!prev.priceStr || pStr.length < prev.priceStr.length))
|
|
||||||
sm.set(lab, { priceNum: pNum, priceStr: pStr });
|
|
||||||
}
|
|
||||||
|
|
||||||
// url (prefer better)
|
|
||||||
const url = String(r.url || "").trim();
|
|
||||||
if (url) {
|
|
||||||
let um = URL_BY_SKU_STORE.get(sku);
|
|
||||||
if (!um) URL_BY_SKU_STORE.set(sku, (um = new Map()));
|
|
||||||
|
|
||||||
const prev = um.get(lab);
|
|
||||||
if (!prev) um.set(lab, url);
|
|
||||||
else {
|
|
||||||
const a = urlQuality(prev);
|
|
||||||
const b = urlQuality(url);
|
|
||||||
if (b > a) um.set(lab, url);
|
|
||||||
else if (b === a && url < prev) um.set(lab, url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build store-only list (in stock in this store), compute exclusive + pct vs next cheapest other store
|
// Build store-only aggregates (canonicalized)
|
||||||
const EPS = 0.01;
|
const storeAgg = aggregateBySku(liveStore, rules.canonicalSku);
|
||||||
|
|
||||||
const base = [];
|
// Best URL for this store per canonical SKU
|
||||||
for (const it of allAgg) {
|
const urlBySku = new Map(); // sku -> url
|
||||||
if (!it || !it.stores || !it.stores.has(storeLabel)) continue;
|
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 pm = PRICE_BY_SKU_STORE.get(String(it.sku || ""));
|
const u = String(r?.url || "").trim();
|
||||||
const storeRec = pm?.get(storeLabel) || null;
|
if (!u) continue;
|
||||||
const storePriceNum = storeRec?.priceNum ?? null;
|
|
||||||
const storePriceStr = storeRec?.priceStr || it.cheapestPriceStr || "(no price)";
|
|
||||||
|
|
||||||
let globalMin = null;
|
const prev = urlBySku.get(sku);
|
||||||
let otherMin = null;
|
if (!prev) {
|
||||||
|
urlBySku.set(sku, u);
|
||||||
if (pm) {
|
continue;
|
||||||
for (const [lab, rec] of pm.entries()) {
|
|
||||||
const v = rec?.priceNum;
|
|
||||||
if (!Number.isFinite(v)) continue;
|
|
||||||
|
|
||||||
globalMin = globalMin === null ? v : Math.min(globalMin, v);
|
|
||||||
|
|
||||||
if (lab !== storeLabel) {
|
|
||||||
otherMin = otherMin === null ? v : Math.min(otherMin, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bestHere = globalMin !== null && storePriceNum !== null && Math.abs(storePriceNum - globalMin) <= EPS;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
let pct = null;
|
function computeCompare(it) {
|
||||||
if (storePriceNum !== null && otherMin !== null && otherMin > 0) {
|
const sku = String(it?.sku || "");
|
||||||
pct = Math.round(((otherMin - storePriceNum) / otherMin) * 100);
|
const pres = presenceBySku.get(sku) || new Set([storeNorm]);
|
||||||
|
const exclusive = pres.size === 1 && pres.has(storeNorm);
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exclusive = it.stores.size === 1;
|
// pct: (this - nextBestOther)/nextBestOther * 100
|
||||||
|
const pct =
|
||||||
|
storePrice !== null && bestOther !== null && bestOther > 0
|
||||||
|
? ((storePrice - bestOther) / bestOther) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
const href =
|
const EPS = 0.01;
|
||||||
URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(storeLabel) ||
|
const isBestPrice = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false;
|
||||||
String(it.sampleUrl || "").trim() ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
const storeSearchText = normSearchText([it.searchText || "", href || ""].join(" | "));
|
return { exclusive, pct, isBestPrice };
|
||||||
|
}
|
||||||
|
|
||||||
base.push({
|
const items = storeAgg
|
||||||
...it,
|
.map((it) => {
|
||||||
_exclusive: exclusive,
|
const c = computeCompare(it);
|
||||||
_bestHere: bestHere,
|
return {
|
||||||
_pct: pct,
|
...it,
|
||||||
_storePriceNum: storePriceNum,
|
_exclusive: c.exclusive,
|
||||||
_storePriceStr: storePriceStr,
|
_pct: c.pct,
|
||||||
_href: href,
|
_isBestPrice: c.isBestPrice,
|
||||||
_storeSearchText: storeSearchText,
|
};
|
||||||
|
})
|
||||||
|
.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;
|
||||||
|
|
||||||
|
return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function pctBadge(pct) {
|
||||||
|
if (!Number.isFinite(pct)) return null;
|
||||||
|
|
||||||
|
const p = Math.round(pct);
|
||||||
|
const txt = `${p >= 0 ? "+" : ""}${p}% vs next`;
|
||||||
|
|
||||||
|
if (pct < -5) return { cls: "badge badgeGood", txt };
|
||||||
|
if (pct > 5) return { cls: "badge badgeBad", txt };
|
||||||
|
return { cls: "badge badgeNeutral", txt }; // -5%..+5%
|
||||||
}
|
}
|
||||||
|
|
||||||
base.sort((a, b) => {
|
function renderCard(it) {
|
||||||
const ax = a._exclusive ? 0 : 1;
|
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
||||||
const bx = b._exclusive ? 0 : 1;
|
const href = urlBySku.get(String(it.sku || "")) || String(it.sampleUrl || "").trim();
|
||||||
if (ax !== bx) return ax - bx;
|
|
||||||
|
|
||||||
// Exclusives: stable by name
|
const storeBadge = href
|
||||||
if (ax === 0) return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku));
|
? `<a class="badge" href="${esc(href)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${esc(
|
||||||
|
storeDisplay
|
||||||
|
)}</a>`
|
||||||
|
: `<span class="badge">${esc(storeDisplay)}</span>`;
|
||||||
|
|
||||||
const ap = Number.isFinite(a._pct) ? a._pct : -1e9;
|
const badges = [];
|
||||||
const bp = Number.isFinite(b._pct) ? b._pct : -1e9;
|
|
||||||
if (bp !== ap) return bp - ap;
|
|
||||||
|
|
||||||
return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku));
|
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>`);
|
||||||
|
|
||||||
$subtitle.textContent = `In-stock items for ${storeLabel} — Exclusives first, then best deals.`;
|
if (!it._exclusive) {
|
||||||
|
const pb = pctBadge(it._pct);
|
||||||
|
if (pb) badges.push(`<span class="${esc(pb.cls)}">${esc(pb.txt)}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
function renderList(query) {
|
return `
|
||||||
const tokens = tokenizeQuery(query);
|
<div class="item" data-sku="${esc(it.sku)}">
|
||||||
|
<div class="itemRow">
|
||||||
|
<div class="thumbBox">${renderThumbHtml(it.img)}</div>
|
||||||
|
<div class="itemBody">
|
||||||
|
<div class="itemTop">
|
||||||
|
<div class="itemName">${esc(it.name || "(no name)")}</div>
|
||||||
|
<span class="badge mono">${esc(displaySku(it.sku))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metaRow metaRowWrap">
|
||||||
|
${badges.join("")}
|
||||||
|
<span class="mono price">${esc(price)}</span>
|
||||||
|
${storeBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
const items = !tokens.length
|
function renderList(filtered) {
|
||||||
? base
|
if (!filtered.length) {
|
||||||
: base.filter((it) => matchesAllTokens(String(it._storeSearchText || ""), tokens));
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
$results.innerHTML = `<div class="small">No matches.</div>`;
|
$results.innerHTML = `<div class="small">No matches.</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$results.innerHTML = items
|
const limited = filtered.slice(0, 120);
|
||||||
.map((it) => {
|
$results.innerHTML = limited.map(renderCard).join("");
|
||||||
const exclusiveBadge = it._exclusive ? `<span class="badge exclusive">EXCLUSIVE</span>` : ``;
|
|
||||||
const bestBadge = it._bestHere ? `<span class="badge good">Best Price</span>` : ``;
|
|
||||||
|
|
||||||
const pct = it._pct;
|
|
||||||
const pctBadge =
|
|
||||||
it._exclusive
|
|
||||||
? ``
|
|
||||||
: `<span class="badge ${pctClass(pct)}">${esc(pctLabel(pct))}</span>`;
|
|
||||||
|
|
||||||
const href = String(it._href || "").trim();
|
|
||||||
const storeBadge = href
|
|
||||||
? `<a class="badge" href="${esc(
|
|
||||||
href
|
|
||||||
)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${esc(storeLabel)}</a>`
|
|
||||||
: `<span class="badge">${esc(storeLabel)}</span>`;
|
|
||||||
|
|
||||||
const price = String(it._storePriceStr || "(no price)");
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="item" data-sku="${esc(it.sku)}">
|
|
||||||
<div class="itemRow">
|
|
||||||
<div class="thumbBox">${renderThumbHtml(it.img)}</div>
|
|
||||||
<div class="itemBody">
|
|
||||||
<div class="itemTop">
|
|
||||||
<div class="itemName">${esc(it.name || "(no name)")}</div>
|
|
||||||
<span class="badge mono">${esc(displaySku(it.sku))}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metaRow">
|
|
||||||
${exclusiveBadge}
|
|
||||||
${bestBadge}
|
|
||||||
${pctBadge}
|
|
||||||
<span class="mono price">${esc(price)}</span>
|
|
||||||
${storeBadge}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
for (const el of Array.from($results.querySelectorAll(".item"))) {
|
for (const el of Array.from($results.querySelectorAll(".item"))) {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
const sku = el.getAttribute("data-sku") || "";
|
const sku = el.getAttribute("data-sku") || "";
|
||||||
if (!sku) return;
|
if (!sku) return;
|
||||||
|
saveStoreQuery(storeNorm, $q.value);
|
||||||
location.hash = `#/item/${encodeURIComponent(sku)}`;
|
location.hash = `#/item/${encodeURIComponent(sku)}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderList("");
|
function applySearch() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$q.focus();
|
||||||
|
applySearch();
|
||||||
|
|
||||||
let t = null;
|
let t = null;
|
||||||
$q.addEventListener("input", () => {
|
$q.addEventListener("input", () => {
|
||||||
if (t) clearTimeout(t);
|
if (t) clearTimeout(t);
|
||||||
t = setTimeout(() => renderList($q.value), 50);
|
t = setTimeout(applySearch, 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
viz/style.css
126
viz/style.css
|
|
@ -159,6 +159,8 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metaRowWrap { flex-wrap: wrap; } /* used for store page so badges can wrap */
|
||||||
|
|
||||||
.price {
|
.price {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|
@ -196,6 +198,56 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Store selector pills (search page header) --- */
|
||||||
|
.storeBarWrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeBarLabel {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeBar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap; /* desktop: wrap nicely into 2 lines if needed */
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storePill {
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
/* Detail view sizing */
|
/* Detail view sizing */
|
||||||
.detailCard {
|
.detailCard {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -258,6 +310,15 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
padding: 8px;
|
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 {
|
.chartBox canvas {
|
||||||
|
|
@ -276,68 +337,3 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Store nav (search page) --- */
|
|
||||||
.storeNav {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storeNavRow {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storeSelect {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storeFilter {
|
|
||||||
width: 260px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storeList {
|
|
||||||
margin-top: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
max-height: 160px;
|
|
||||||
overflow: auto;
|
|
||||||
padding-right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storeChip {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.storeFilter { display: none; }
|
|
||||||
.storeList { max-height: 220px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Highlight badges (store page + reused) --- */
|
|
||||||
.badge.good {
|
|
||||||
color: rgba(20,110,40,0.95);
|
|
||||||
background: rgba(20,110,40,0.10);
|
|
||||||
border: 1px solid rgba(20,110,40,0.20);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.bad {
|
|
||||||
color: rgba(160,40,40,0.95);
|
|
||||||
background: rgba(160,40,40,0.12);
|
|
||||||
border: 1px solid rgba(160,40,40,0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.neutral {
|
|
||||||
color: var(--muted);
|
|
||||||
background: rgba(200,200,200,0.06);
|
|
||||||
border: 1px solid rgba(200,200,200,0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.exclusive {
|
|
||||||
color: rgba(20,110,40,0.95);
|
|
||||||
background: rgba(20,110,40,0.10);
|
|
||||||
border: 1px solid rgba(20,110,40,0.20);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue