mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
v1 of store page
This commit is contained in:
parent
e34406c21c
commit
54c99c3a32
4 changed files with 458 additions and 28 deletions
|
|
@ -1,14 +1,16 @@
|
|||
/**
|
||||
* Hash routes:
|
||||
* #/ search
|
||||
* #/item/<sku> detail
|
||||
* #/link sku linker (local-write only)
|
||||
* #/ search
|
||||
* #/item/<sku> detail
|
||||
* #/link sku linker (local-write only)
|
||||
* #/store/<storeLabel> store page (in-stock only)
|
||||
*/
|
||||
|
||||
import { destroyChart } from "./item_page.js";
|
||||
import { renderSearch } from "./search_page.js";
|
||||
import { renderItem } from "./item_page.js";
|
||||
import { renderSkuLinker } from "./linker_page.js";
|
||||
import { renderStore } from "./store_page.js";
|
||||
|
||||
function route() {
|
||||
const $app = document.getElementById("app");
|
||||
|
|
@ -23,6 +25,7 @@ function route() {
|
|||
if (parts.length === 0) return renderSearch($app);
|
||||
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]));
|
||||
|
||||
return renderSearch($app);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ export function renderSearch($app) {
|
|||
<a class="btn" href="#/link" style="text-decoration:none;">Link SKUs</a>
|
||||
</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">
|
||||
<input id="q" class="input" placeholder="e.g. bowmore sherry, 303821, sierrasprings..." autocomplete="off" />
|
||||
<div id="results" class="list"></div>
|
||||
|
|
@ -26,6 +36,10 @@ export function renderSearch($app) {
|
|||
const $q = document.getElementById("q");
|
||||
const $results = document.getElementById("results");
|
||||
|
||||
const $storeSelect = document.getElementById("storeSelect");
|
||||
const $storeFilter = document.getElementById("storeFilter");
|
||||
const $stores = document.getElementById("stores");
|
||||
|
||||
$q.value = loadSavedQuery();
|
||||
|
||||
let aggBySku = new Map();
|
||||
|
|
@ -35,6 +49,53 @@ export function renderSearch($app) {
|
|||
// canonicalSku -> storeLabel -> url
|
||||
let URL_BY_SKU_STORE = new Map();
|
||||
|
||||
// --- Store nav ---
|
||||
let ALL_STORES = [];
|
||||
|
||||
function storeLabelForRow(r) {
|
||||
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) {
|
||||
const out = new Map();
|
||||
for (const r of Array.isArray(listings) ? listings : []) {
|
||||
|
|
@ -157,14 +218,14 @@ export function renderSearch($app) {
|
|||
function rankRecent(r, canonSkuFn) {
|
||||
const rawSku = String(r?.sku || "");
|
||||
const sku = String(canonSkuFn ? canonSkuFn(rawSku) : rawSku);
|
||||
|
||||
|
||||
const agg = aggBySku.get(sku) || null;
|
||||
|
||||
|
||||
const storeLabelRaw = String(r?.storeLabel || r?.store || "").trim();
|
||||
const bestStoreRaw = String(agg?.cheapestStoreLabel || "").trim();
|
||||
|
||||
|
||||
const normStore = (s) => String(s || "").trim().toLowerCase();
|
||||
|
||||
|
||||
// Normalize kind
|
||||
let kind = String(r?.kind || "");
|
||||
if (kind === "price_change") {
|
||||
|
|
@ -175,36 +236,36 @@ export function renderSearch($app) {
|
|||
else if (n > o) kind = "price_up";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const pctOff = kind === "price_down" ? salePctOff(r?.oldPrice || "", r?.newPrice || "") : null;
|
||||
const pctUp = kind === "price_up" ? pctChange(r?.oldPrice || "", r?.newPrice || "") : null;
|
||||
|
||||
|
||||
const isNew = kind === "new";
|
||||
const storeCount = agg?.stores?.size || 0;
|
||||
const isNewUnique = isNew && storeCount <= 1;
|
||||
|
||||
|
||||
// Cheapest checks (use aggregate index)
|
||||
const newPriceNum = kind === "price_down" || kind === "price_up" ? parsePriceToNumber(r?.newPrice || "") : null;
|
||||
const bestPriceNum = Number.isFinite(agg?.cheapestPriceNum) ? agg.cheapestPriceNum : null;
|
||||
|
||||
|
||||
const EPS = 0.01;
|
||||
const priceMatchesBest =
|
||||
Number.isFinite(newPriceNum) && Number.isFinite(bestPriceNum) ? Math.abs(newPriceNum - bestPriceNum) <= EPS : false;
|
||||
|
||||
|
||||
const storeIsBest =
|
||||
normStore(storeLabelRaw) && normStore(bestStoreRaw) && normStore(storeLabelRaw) === normStore(bestStoreRaw);
|
||||
|
||||
|
||||
const saleIsCheapestHere = kind === "price_down" && storeIsBest && priceMatchesBest;
|
||||
const saleIsTiedCheapest = kind === "price_down" && !storeIsBest && priceMatchesBest;
|
||||
const saleIsCheapest = saleIsCheapestHere || saleIsTiedCheapest;
|
||||
|
||||
|
||||
// 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
|
||||
|
|
@ -217,7 +278,7 @@ export function renderSearch($app) {
|
|||
return 1000; // bottom-ish
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (kind === "price_down") {
|
||||
score = saleBucketScore(saleIsCheapest, pctOff);
|
||||
} else if (isNewUnique) {
|
||||
|
|
@ -234,16 +295,16 @@ export function renderSearch($app) {
|
|||
} 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);
|
||||
else tie = tsValue(r);
|
||||
|
||||
|
||||
return { sku, kind, pctOff, storeCount, isNewUnique, score, tie };
|
||||
}
|
||||
|
||||
|
||||
function renderRecent(recent, canonicalSkuFn) {
|
||||
const items = Array.isArray(recent?.items) ? recent.items : [];
|
||||
if (!items.length) {
|
||||
|
|
@ -373,15 +434,11 @@ export function renderSearch($app) {
|
|||
|
||||
const offBadge =
|
||||
meta.kind === "price_down" && meta.pctOff !== null
|
||||
? `<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>`
|
||||
? `<span class="badge good" style="margin-left:6px;">[${esc(meta.pctOff)}% Off]</span>`
|
||||
: "";
|
||||
|
||||
const kindBadgeStyle =
|
||||
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);"`
|
||||
: "";
|
||||
meta.kind === "new" && meta.isNewUnique ? ` class="badge good"` : ` class="badge"`;
|
||||
|
||||
return `
|
||||
<div class="item" data-sku="${esc(sku)}">
|
||||
|
|
@ -395,7 +452,7 @@ export function renderSearch($app) {
|
|||
<span class="badge mono">${esc(displaySku(sku))}</span>
|
||||
</div>
|
||||
<div class="metaRow">
|
||||
<span class="badge"${kindBadgeStyle}>${esc(kindLabel)}</span>
|
||||
<span${kindBadgeStyle}>${esc(kindLabel)}</span>
|
||||
<span class="mono price">${esc(priceLine)}</span>
|
||||
${offBadge}
|
||||
${storeBadge}
|
||||
|
|
@ -418,7 +475,6 @@ export function renderSearch($app) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function applySearch() {
|
||||
if (!indexReady) return;
|
||||
|
||||
|
|
@ -455,6 +511,12 @@ export function renderSearch($app) {
|
|||
Promise.all([loadIndex(), loadSkuRules()])
|
||||
.then(([idx, rules]) => {
|
||||
const listings = Array.isArray(idx.items) ? idx.items : [];
|
||||
|
||||
// store nav: build once
|
||||
ALL_STORES = extractStores(listings);
|
||||
renderStoreSelect(ALL_STORES);
|
||||
renderStoreChips($storeFilter.value);
|
||||
|
||||
allAgg = aggregateBySku(listings, rules.canonicalSku);
|
||||
aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x]));
|
||||
URL_BY_SKU_STORE = buildUrlMap(listings, rules.canonicalSku);
|
||||
|
|
|
|||
300
viz/app/store_page.js
Normal file
300
viz/app/store_page.js
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { esc, renderThumbHtml } from "./dom.js";
|
||||
import {
|
||||
tokenizeQuery,
|
||||
matchesAllTokens,
|
||||
displaySku,
|
||||
keySkuForRow,
|
||||
parsePriceToNumber,
|
||||
normSearchText,
|
||||
} from "./sku.js";
|
||||
import { loadIndex } from "./state.js";
|
||||
import { aggregateBySku } from "./catalog.js";
|
||||
import { loadSkuRules } from "./mapping.js";
|
||||
|
||||
function storeLabelForRow(r) {
|
||||
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 urlQuality(u) {
|
||||
const s = String(u || "").trim();
|
||||
if (!s) return -1;
|
||||
let sc = 0;
|
||||
sc += s.length;
|
||||
if (/\bproduct\/\d+\//.test(s)) sc += 50;
|
||||
if (/[a-z0-9-]{8,}/i.test(s)) sc += 10;
|
||||
return sc;
|
||||
}
|
||||
|
||||
function pctClass(pct) {
|
||||
if (!Number.isFinite(pct)) return "neutral";
|
||||
if (pct >= 5) return "good";
|
||||
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 = `
|
||||
<div class="container">
|
||||
<div class="topbar">
|
||||
<button id="back" class="btn">← Back</button>
|
||||
<span class="badge">${esc(storeLabelInput || "Store")}</span>
|
||||
<div style="flex:1"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="small" id="subtitle">Loading…</div>
|
||||
<input id="q" class="input" placeholder="Search this store (name / url / sku)..." autocomplete="off" />
|
||||
<div id="results" class="list"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("back").addEventListener("click", () => (location.hash = "#/"));
|
||||
|
||||
const $subtitle = document.getElementById("subtitle");
|
||||
const $q = document.getElementById("q");
|
||||
const $results = document.getElementById("results");
|
||||
|
||||
$results.innerHTML = `<div class="small">Loading index…</div>`;
|
||||
|
||||
let idx, rules;
|
||||
try {
|
||||
[idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]);
|
||||
} catch (e) {
|
||||
$results.innerHTML = `<div class="small">Failed to load: ${esc(e?.message || String(e))}</div>`;
|
||||
$subtitle.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const listings = Array.isArray(idx?.items) ? idx.items : [];
|
||||
const stores = extractStores(listings);
|
||||
|
||||
// Normalize store label by case-insensitive match (helps if someone edits hash by hand)
|
||||
const wantLower = String(storeLabelInput || "").trim().toLowerCase();
|
||||
const storeLabel =
|
||||
stores.find((s) => String(s).toLowerCase() === wantLower) || String(storeLabelInput || "").trim();
|
||||
|
||||
// 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();
|
||||
if (!skuKey) continue;
|
||||
|
||||
const sku = String(rules.canonicalSku(skuKey) || "").trim();
|
||||
if (!sku) continue;
|
||||
|
||||
const lab = storeLabelForRow(r);
|
||||
if (!lab) continue;
|
||||
|
||||
// price
|
||||
const pNum = parsePriceToNumber(r.price);
|
||||
const pStr = String(r.price || "").trim();
|
||||
|
||||
let sm = PRICE_BY_SKU_STORE.get(sku);
|
||||
if (!sm) PRICE_BY_SKU_STORE.set(sku, (sm = new Map()));
|
||||
|
||||
if (pNum !== null) {
|
||||
const prev = sm.get(lab);
|
||||
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
|
||||
const EPS = 0.01;
|
||||
|
||||
const base = [];
|
||||
for (const it of allAgg) {
|
||||
if (!it || !it.stores || !it.stores.has(storeLabel)) continue;
|
||||
|
||||
const pm = PRICE_BY_SKU_STORE.get(String(it.sku || ""));
|
||||
const storeRec = pm?.get(storeLabel) || null;
|
||||
const storePriceNum = storeRec?.priceNum ?? null;
|
||||
const storePriceStr = storeRec?.priceStr || it.cheapestPriceStr || "(no price)";
|
||||
|
||||
let globalMin = null;
|
||||
let otherMin = null;
|
||||
|
||||
if (pm) {
|
||||
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;
|
||||
|
||||
let pct = null;
|
||||
if (storePriceNum !== null && otherMin !== null && otherMin > 0) {
|
||||
pct = Math.round(((otherMin - storePriceNum) / otherMin) * 100);
|
||||
}
|
||||
|
||||
const exclusive = it.stores.size === 1;
|
||||
|
||||
const href =
|
||||
URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(storeLabel) ||
|
||||
String(it.sampleUrl || "").trim() ||
|
||||
"";
|
||||
|
||||
const storeSearchText = normSearchText([it.searchText || "", href || ""].join(" | "));
|
||||
|
||||
base.push({
|
||||
...it,
|
||||
_exclusive: exclusive,
|
||||
_bestHere: bestHere,
|
||||
_pct: pct,
|
||||
_storePriceNum: storePriceNum,
|
||||
_storePriceStr: storePriceStr,
|
||||
_href: href,
|
||||
_storeSearchText: storeSearchText,
|
||||
});
|
||||
}
|
||||
|
||||
base.sort((a, b) => {
|
||||
const ax = a._exclusive ? 0 : 1;
|
||||
const bx = b._exclusive ? 0 : 1;
|
||||
if (ax !== bx) return ax - bx;
|
||||
|
||||
// Exclusives: stable by name
|
||||
if (ax === 0) return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku));
|
||||
|
||||
const ap = Number.isFinite(a._pct) ? a._pct : -1e9;
|
||||
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));
|
||||
});
|
||||
|
||||
$subtitle.textContent = `In-stock items for ${storeLabel} — Exclusives first, then best deals.`;
|
||||
|
||||
function renderList(query) {
|
||||
const tokens = tokenizeQuery(query);
|
||||
|
||||
const items = !tokens.length
|
||||
? base
|
||||
: base.filter((it) => matchesAllTokens(String(it._storeSearchText || ""), tokens));
|
||||
|
||||
if (!items.length) {
|
||||
$results.innerHTML = `<div class="small">No matches.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
$results.innerHTML = items
|
||||
.map((it) => {
|
||||
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"))) {
|
||||
el.addEventListener("click", () => {
|
||||
const sku = el.getAttribute("data-sku") || "";
|
||||
if (!sku) return;
|
||||
location.hash = `#/item/${encodeURIComponent(sku)}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderList("");
|
||||
|
||||
let t = null;
|
||||
$q.addEventListener("input", () => {
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(() => renderList($q.value), 50);
|
||||
});
|
||||
}
|
||||
|
|
@ -276,3 +276,68 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
|||
position: sticky;
|
||||
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