From d92e4b8cf32608388591bc9b8a4457225dc57666 Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Fri, 30 Jan 2026 13:04:50 -0800 Subject: [PATCH] feat: V2 store page --- viz/app/main.js | 10 +- viz/app/search_page.js | 111 +++++------ viz/app/store_page.js | 415 ++++++++++++++++++++--------------------- viz/style.css | 126 ++++++------- 4 files changed, 315 insertions(+), 347 deletions(-) diff --git a/viz/app/main.js b/viz/app/main.js index a2429d5..c37f042 100644 --- a/viz/app/main.js +++ b/viz/app/main.js @@ -1,9 +1,9 @@ /** * Hash routes: - * #/ search - * #/item/ detail - * #/link sku linker (local-write only) - * #/store/ store page (in-stock only) + * #/ search + * #/item/ detail + * #/link sku linker (local-write only) + * #/store/ store page (in-stock only) */ import { destroyChart } from "./item_page.js"; @@ -24,8 +24,8 @@ 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])); + if (parts[0] === "link") return renderSkuLinker($app); return renderSearch($app); } diff --git a/viz/app/search_page.js b/viz/app/search_page.js index 1fa3bbc..c600517 100644 --- a/viz/app/search_page.js +++ b/viz/app/search_page.js @@ -9,23 +9,18 @@ export function renderSearch($app) { $app.innerHTML = `
-
+

Spirit Tracker Viz

Search name / url / sku (word AND)
+ +
+
Stores:
+
+
Link SKUs
-
-
- - -
-
-
-
@@ -35,10 +30,7 @@ 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"); + const $storeBar = document.getElementById("storeBar"); $q.value = loadSavedQuery(); @@ -49,53 +41,14 @@ export function renderSearch($app) { // canonicalSku -> storeLabel -> url let URL_BY_SKU_STORE = new Map(); - // --- Store nav --- - let ALL_STORES = []; + function normStoreLabel(s) { + return String(s || "").trim().toLowerCase(); + } - function storeLabelForRow(r) { + function storeLabelFromRow(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 = - `` + - stores.map((x) => ``).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) => - `${esc(lab)}` - ) - .join("") - : `
No store matches.
`; - } - - $storeFilter.addEventListener("input", () => { - renderStoreChips($storeFilter.value); - }); - function buildUrlMap(listings, canonicalSkuFn) { const out = new Map(); for (const r of Array.isArray(listings) ? listings : []) { @@ -107,7 +60,7 @@ export function renderSearch($app) { const sku = String(canonicalSkuFn ? canonicalSkuFn(skuKey) : skuKey); if (!sku) continue; - const storeLabel = String(r.storeLabel || r.store || "").trim(); + const storeLabel = storeLabelFromRow(r); const url = String(r.url || "").trim(); if (!storeLabel || !url) continue; @@ -124,6 +77,20 @@ export function renderSearch($app) { 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 = `No stores`; + return; + } + + $storeBar.innerHTML = stores + .map((s) => `${esc(s)}`) + .join(""); + } + function renderAggregates(items) { if (!items.length) { $results.innerHTML = `
No matches.
`; @@ -434,11 +401,15 @@ export function renderSearch($app) { const offBadge = meta.kind === "price_down" && meta.pctOff !== null - ? `[${esc(meta.pctOff)}% Off]` + ? `[${esc( + meta.pctOff + )}% Off]` : ""; 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 `
@@ -452,7 +423,7 @@ export function renderSearch($app) { ${esc(displaySku(sku))}
- ${esc(kindLabel)} + ${esc(kindLabel)} ${esc(priceLine)} ${offBadge} ${storeBadge} @@ -507,15 +478,22 @@ export function renderSearch($app) { } $results.innerHTML = `
Loading index…
`; + if ($storeBar) $storeBar.innerHTML = `Loading stores…`; 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); + // 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); allAgg = aggregateBySku(listings, rules.canonicalSku); aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x])); @@ -533,6 +511,7 @@ export function renderSearch($app) { }) .catch((e) => { $results.innerHTML = `
Failed to load: ${esc(e.message)}
`; + if ($storeBar) $storeBar.innerHTML = ``; }); let t = null; diff --git a/viz/app/store_page.js b/viz/app/store_page.js index 26d2c58..cff5c44 100644 --- a/viz/app/store_page.js +++ b/viz/app/store_page.js @@ -1,64 +1,60 @@ import { esc, renderThumbHtml } from "./dom.js"; -import { - tokenizeQuery, - matchesAllTokens, - displaySku, - keySkuForRow, - parsePriceToNumber, - normSearchText, -} 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"; -function storeLabelForRow(r) { +function normStoreLabel(s) { + return String(s || "").trim().toLowerCase(); +} + +function storeLabelFromRow(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); +function storeQueryKey(storeNorm) { + return `stviz:v1:store:q:${storeNorm}`; +} + +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) { - 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; + 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; } -function pctClass(pct) { - if (!Number.isFinite(pct)) return "neutral"; - if (pct >= 5) return "good"; - if (pct <= -5) return "bad"; - return "neutral"; -} +export async function renderStore($app, storeParamRaw) { + const storeParam = String(storeParamRaw || "").trim(); + const storeNorm = normStoreLabel(storeParam); -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 = ` -
+
- ${esc(storeLabelInput || "Store")} + ${esc(storeParam || "Store")}
-
Loading…
- + +
@@ -66,52 +62,50 @@ export async function renderStore($app, storeLabelInput) { document.getElementById("back").addEventListener("click", () => (location.hash = "#/")); - const $subtitle = document.getElementById("subtitle"); const $q = document.getElementById("q"); const $results = document.getElementById("results"); + const $status = document.getElementById("status"); - $results.innerHTML = `
Loading index…
`; + $q.value = loadStoreQuery(storeNorm); - let idx, rules; - try { - [idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]); - } catch (e) { - $results.innerHTML = `
Failed to load: ${esc(e?.message || String(e))}
`; - $subtitle.textContent = ""; + $results.innerHTML = `
Loading…
`; + + const [idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]); + const allRows = Array.isArray(idx.items) ? idx.items : []; + + // 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 = `
No in-stock items for this store.
`; + $status.textContent = ""; return; } - const listings = Array.isArray(idx?.items) ? idx.items : []; - const stores = extractStores(listings); + // Global presence + min-price map (by canonical sku) + 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) - 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 = - `
Pick a store:
` + - `
` + - stores.map((s) => `${esc(s)}`).join("") + - `
`; - 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; + for (const r of liveAll) { + const storeLab = storeLabelFromRow(r); + const sNorm = normStoreLabel(storeLab); + if (!sNorm) continue; const skuKey = String(keySkuForRow(r) || "").trim(); if (!skuKey) continue; @@ -119,182 +113,181 @@ export async function renderStore($app, storeLabelInput) { const sku = String(rules.canonicalSku(skuKey) || "").trim(); if (!sku) continue; - const lab = storeLabelForRow(r); - if (!lab) continue; + let pres = presenceBySku.get(sku); + if (!pres) presenceBySku.set(sku, (pres = new Set())); + pres.add(sNorm); - // price - const pNum = parsePriceToNumber(r.price); - const pStr = String(r.price || "").trim(); + const p = parsePriceToNumber(r?.price); + if (p === null) continue; - let sm = PRICE_BY_SKU_STORE.get(sku); - if (!sm) PRICE_BY_SKU_STORE.set(sku, (sm = new Map())); + let m = minPriceBySkuStore.get(sku); + if (!m) minPriceBySkuStore.set(sku, (m = 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); - } - } + const prev = m.get(sNorm); + if (!Number.isFinite(prev) || p < prev) m.set(sNorm, p); } - // Build store-only list (in stock in this store), compute exclusive + pct vs next cheapest other store - const EPS = 0.01; + // Build store-only aggregates (canonicalized) + const storeAgg = aggregateBySku(liveStore, rules.canonicalSku); - const base = []; - for (const it of allAgg) { - if (!it || !it.stores || !it.stores.has(storeLabel)) continue; + // 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 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)"; + const u = String(r?.url || "").trim(); + if (!u) continue; - 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 prev = urlBySku.get(sku); + if (!prev) { + urlBySku.set(sku, u); + continue; } - 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; - if (storePriceNum !== null && otherMin !== null && otherMin > 0) { - pct = Math.round(((otherMin - storePriceNum) / otherMin) * 100); + function computeCompare(it) { + const sku = String(it?.sku || ""); + 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 = - URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(storeLabel) || - String(it.sampleUrl || "").trim() || - ""; + const EPS = 0.01; + const isBestPrice = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false; - const storeSearchText = normSearchText([it.searchText || "", href || ""].join(" | ")); + return { exclusive, pct, isBestPrice }; + } - base.push({ - ...it, - _exclusive: exclusive, - _bestHere: bestHere, - _pct: pct, - _storePriceNum: storePriceNum, - _storePriceStr: storePriceStr, - _href: href, - _storeSearchText: storeSearchText, + const items = storeAgg + .map((it) => { + const c = computeCompare(it); + return { + ...it, + _exclusive: c.exclusive, + _pct: c.pct, + _isBestPrice: c.isBestPrice, + }; + }) + .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) => { - const ax = a._exclusive ? 0 : 1; - const bx = b._exclusive ? 0 : 1; - if (ax !== bx) return ax - bx; + function renderCard(it) { + const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)"; + const href = urlBySku.get(String(it.sku || "")) || String(it.sampleUrl || "").trim(); - // Exclusives: stable by name - if (ax === 0) return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku)); + const storeBadge = href + ? `${esc( + storeDisplay + )}` + : `${esc(storeDisplay)}`; - const ap = Number.isFinite(a._pct) ? a._pct : -1e9; - const bp = Number.isFinite(b._pct) ? b._pct : -1e9; - if (bp !== ap) return bp - ap; + const badges = []; - return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku)); - }); + if (it._exclusive) badges.push(`EXCLUSIVE`); + if (!it._exclusive && it._isBestPrice) badges.push(`Best Price`); - $subtitle.textContent = `In-stock items for ${storeLabel} — Exclusives first, then best deals.`; + if (!it._exclusive) { + const pb = pctBadge(it._pct); + if (pb) badges.push(`${esc(pb.txt)}`); + } - function renderList(query) { - const tokens = tokenizeQuery(query); + return ` +
+
+
${renderThumbHtml(it.img)}
+
+
+
${esc(it.name || "(no name)")}
+ ${esc(displaySku(it.sku))} +
+
+ ${badges.join("")} + ${esc(price)} + ${storeBadge} +
+
+
+
+ `; + } - const items = !tokens.length - ? base - : base.filter((it) => matchesAllTokens(String(it._storeSearchText || ""), tokens)); - - if (!items.length) { + function renderList(filtered) { + if (!filtered.length) { $results.innerHTML = `
No matches.
`; return; } - $results.innerHTML = items - .map((it) => { - const exclusiveBadge = it._exclusive ? `EXCLUSIVE` : ``; - const bestBadge = it._bestHere ? `Best Price` : ``; - - const pct = it._pct; - const pctBadge = - it._exclusive - ? `` - : `${esc(pctLabel(pct))}`; - - const href = String(it._href || "").trim(); - const storeBadge = href - ? `${esc(storeLabel)}` - : `${esc(storeLabel)}`; - - const price = String(it._storePriceStr || "(no price)"); - - return ` -
-
-
${renderThumbHtml(it.img)}
-
-
-
${esc(it.name || "(no name)")}
- ${esc(displaySku(it.sku))} -
-
- ${exclusiveBadge} - ${bestBadge} - ${pctBadge} - ${esc(price)} - ${storeBadge} -
-
-
-
- `; - }) - .join(""); + const limited = filtered.slice(0, 120); + $results.innerHTML = limited.map(renderCard).join(""); 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)}`; }); } } - 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; $q.addEventListener("input", () => { if (t) clearTimeout(t); - t = setTimeout(() => renderList($q.value), 50); + t = setTimeout(applySearch, 50); }); } diff --git a/viz/style.css b/viz/style.css index 75c1656..6fca2a8 100644 --- a/viz/style.css +++ b/viz/style.css @@ -159,6 +159,8 @@ 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); @@ -196,6 +198,56 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } 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 */ .detailCard { display: flex; @@ -258,6 +310,15 @@ 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 { @@ -276,68 +337,3 @@ 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); -}