mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
feat: V3 store page
This commit is contained in:
parent
d92e4b8cf3
commit
13f15a8eb0
4 changed files with 384 additions and 301 deletions
|
|
@ -1114,11 +1114,52 @@ export async function renderSkuLinker($app) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findAggForPreselectSku(rawSku) {
|
||||||
|
const want = String(rawSku || "").trim();
|
||||||
|
if (!want) return null;
|
||||||
|
|
||||||
|
// exact match first
|
||||||
|
let it = allAgg.find((x) => String(x?.sku || "") === want);
|
||||||
|
if (it) return it;
|
||||||
|
|
||||||
|
// try canonical group match
|
||||||
|
const canonWant = String(rules.canonicalSku(want) || want).trim();
|
||||||
|
if (!canonWant) return null;
|
||||||
|
|
||||||
|
it = allAgg.find((x) => String(x?.sku || "") === canonWant);
|
||||||
|
if (it) return it;
|
||||||
|
|
||||||
|
// any member whose canonicalSku matches
|
||||||
|
return (
|
||||||
|
allAgg.find((x) => String(rules.canonicalSku(String(x?.sku || "")) || "") === canonWant) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function updateAll() {
|
function updateAll() {
|
||||||
|
// One-time left preselect from hash query:
|
||||||
|
// #/link/?left=<sku>
|
||||||
|
// (works with your router because "link" stays as the first path segment)
|
||||||
|
if (!updateAll._didPreselect) {
|
||||||
|
updateAll._didPreselect = true;
|
||||||
|
|
||||||
|
const h = String(location.hash || "");
|
||||||
|
const qi = h.indexOf("?");
|
||||||
|
if (qi !== -1) {
|
||||||
|
const qs = new URLSearchParams(h.slice(qi + 1));
|
||||||
|
const leftSku = String(qs.get("left") || qs.get("sku") || "").trim();
|
||||||
|
if (leftSku && !pinnedL) {
|
||||||
|
const it = findAggForPreselectSku(leftSku);
|
||||||
|
if (it) pinnedL = it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderSide("L");
|
renderSide("L");
|
||||||
renderSide("R");
|
renderSide("R");
|
||||||
updateButtons();
|
updateButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let tL = null,
|
let tL = null,
|
||||||
tR = null;
|
tR = null;
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,18 @@ export function renderSearch($app) {
|
||||||
$app.innerHTML = `
|
$app.innerHTML = `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div style="min-width:0;">
|
<div class="headerLeft">
|
||||||
<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="storeBarWrap">
|
||||||
<div class="small storeBarLabel">Stores:</div>
|
<div id="stores" class="storeBar"></div>
|
||||||
<div id="storeBar" class="storeBar"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn" href="#/link" style="text-decoration:none;">Link SKUs</a>
|
|
||||||
|
<div class="headerRight">
|
||||||
|
<a class="btn btnWide" href="#/link" style="text-decoration:none;">Link SKUs</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -30,7 +32,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 $stores = document.getElementById("stores");
|
||||||
|
|
||||||
$q.value = loadSavedQuery();
|
$q.value = loadSavedQuery();
|
||||||
|
|
||||||
|
|
@ -41,14 +43,6 @@ export function renderSearch($app) {
|
||||||
// canonicalSku -> storeLabel -> url
|
// canonicalSku -> storeLabel -> url
|
||||||
let URL_BY_SKU_STORE = new Map();
|
let URL_BY_SKU_STORE = new Map();
|
||||||
|
|
||||||
function normStoreLabel(s) {
|
|
||||||
return String(s || "").trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeLabelFromRow(r) {
|
|
||||||
return String(r?.storeLabel || r?.store || "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 : []) {
|
||||||
|
|
@ -60,7 +54,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 = storeLabelFromRow(r);
|
const storeLabel = String(r.storeLabel || r.store || "").trim();
|
||||||
const url = String(r.url || "").trim();
|
const url = String(r.url || "").trim();
|
||||||
if (!storeLabel || !url) continue;
|
if (!storeLabel || !url) continue;
|
||||||
|
|
||||||
|
|
@ -77,17 +71,26 @@ 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) {
|
function normStoreLabel(s) {
|
||||||
if (!$storeBar) return;
|
return String(s || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStoreButtons(listings) {
|
||||||
|
// include all stores seen (live or removed) so the selector is stable
|
||||||
|
const set = new Set();
|
||||||
|
for (const r of Array.isArray(listings) ? listings : []) {
|
||||||
|
const lab = normStoreLabel(r?.storeLabel || r?.store || "");
|
||||||
|
if (lab) set.add(lab);
|
||||||
|
}
|
||||||
|
const stores = Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
const stores = Array.from(storeLabelMapDisplay.values()).sort((a, b) => a.localeCompare(b));
|
|
||||||
if (!stores.length) {
|
if (!stores.length) {
|
||||||
$storeBar.innerHTML = `<span class="small">No stores</span>`;
|
$stores.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$storeBar.innerHTML = stores
|
$stores.innerHTML = stores
|
||||||
.map((s) => `<a class="badge storePill" href="#/store/${encodeURIComponent(s)}">${esc(s)}</a>`)
|
.map((s) => `<a class="storeBtn" href="#/store/${encodeURIComponent(s)}">${esc(s)}</a>`)
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +118,8 @@ export function renderSearch($app) {
|
||||||
)}</a>`
|
)}</a>`
|
||||||
: `<span class="badge">${esc(store)}${esc(plus)}</span>`;
|
: `<span class="badge">${esc(store)}${esc(plus)}</span>`;
|
||||||
|
|
||||||
|
const skuLink = `#/link/?left=${encodeURIComponent(String(it.sku || ""))}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="item" data-sku="${esc(it.sku)}">
|
<div class="item" data-sku="${esc(it.sku)}">
|
||||||
<div class="itemRow">
|
<div class="itemRow">
|
||||||
|
|
@ -124,7 +129,9 @@ export function renderSearch($app) {
|
||||||
<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>
|
||||||
<span class="badge mono">${esc(displaySku(it.sku))}</span>
|
<a class="badge mono skuLink" href="${esc(
|
||||||
|
skuLink
|
||||||
|
)}" onclick="event.stopPropagation()">${esc(displaySku(it.sku))}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="metaRow">
|
<div class="metaRow">
|
||||||
<span class="mono price">${esc(price)}</span>
|
<span class="mono price">${esc(price)}</span>
|
||||||
|
|
@ -175,13 +182,7 @@ export function renderSearch($app) {
|
||||||
return Number.isFinite(ms2) ? ms2 : 0;
|
return Number.isFinite(ms2) ? ms2 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom priority:
|
// Custom priority (unchanged)
|
||||||
// - Sales that make this store cheapest (or tied cheapest) are most interesting
|
|
||||||
// - New unique (no other stores have canonical SKU)
|
|
||||||
// - Other sales (not cheapest) are demoted
|
|
||||||
// - Removed
|
|
||||||
// - Price increases
|
|
||||||
// - New (available elsewhere)
|
|
||||||
function rankRecent(r, canonSkuFn) {
|
function rankRecent(r, canonSkuFn) {
|
||||||
const rawSku = String(r?.sku || "");
|
const rawSku = String(r?.sku || "");
|
||||||
const sku = String(canonSkuFn ? canonSkuFn(rawSku) : rawSku);
|
const sku = String(canonSkuFn ? canonSkuFn(rawSku) : rawSku);
|
||||||
|
|
@ -229,41 +230,38 @@ export function renderSearch($app) {
|
||||||
// Bucketed scoring (higher = earlier)
|
// Bucketed scoring (higher = earlier)
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
// Helper for sales buckets
|
|
||||||
function saleBucketScore(isCheapest, pct) {
|
function saleBucketScore(isCheapest, pct) {
|
||||||
const p = Number.isFinite(pct) ? pct : 0;
|
const p = Number.isFinite(pct) ? pct : 0;
|
||||||
|
|
||||||
if (isCheapest) {
|
if (isCheapest) {
|
||||||
if (p >= 20) return 9000 + p; // Bucket #1
|
if (p >= 20) return 9000 + p;
|
||||||
if (p >= 10) return 7000 + p; // Bucket #3
|
if (p >= 10) return 7000 + p;
|
||||||
if (p > 0) return 6000 + p; // Bucket #4
|
if (p > 0) return 6000 + p;
|
||||||
return 5900; // weird but keep below real pct
|
return 5900;
|
||||||
} else {
|
} else {
|
||||||
if (p >= 20) return 4500 + p; // Bucket #5 (below NEW unique)
|
if (p >= 20) return 4500 + p;
|
||||||
if (p >= 10) return 1500 + p; // Bucket #8
|
if (p >= 10) return 1500 + p;
|
||||||
if (p > 0) return 1200 + p; // Bucket #9
|
if (p > 0) return 1200 + p;
|
||||||
return 1000; // bottom-ish
|
return 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "price_down") {
|
if (kind === "price_down") {
|
||||||
score = saleBucketScore(saleIsCheapest, pctOff);
|
score = saleBucketScore(saleIsCheapest, pctOff);
|
||||||
} else if (isNewUnique) {
|
} else if (isNewUnique) {
|
||||||
score = 8000; // Bucket #2
|
score = 8000;
|
||||||
} else if (kind === "removed") {
|
} else if (kind === "removed") {
|
||||||
score = 3000; // Bucket #6
|
score = 3000;
|
||||||
} else if (kind === "price_up") {
|
} else if (kind === "price_up") {
|
||||||
score = 2000 + Math.min(99, Math.max(0, pctUp || 0)); // Bucket #7
|
score = 2000 + Math.min(99, Math.max(0, pctUp || 0));
|
||||||
} else if (kind === "new") {
|
} else if (kind === "new") {
|
||||||
score = 1100; // Bucket #10
|
score = 1100;
|
||||||
} else if (kind === "restored") {
|
} else if (kind === "restored") {
|
||||||
// not in your bucket list, but keep it reasonably high (below NEW unique, above removals)
|
|
||||||
score = 5000;
|
score = 5000;
|
||||||
} else {
|
} else {
|
||||||
score = 0;
|
score = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tie-breaks: within bucket prefer bigger % for sales, then recency
|
|
||||||
let tie = 0;
|
let tie = 0;
|
||||||
if (kind === "price_down") tie = (pctOff || 0) * 100000 + tsValue(r);
|
if (kind === "price_down") tie = (pctOff || 0) * 100000 + tsValue(r);
|
||||||
else if (kind === "price_up") tie = (pctUp || 0) * 100000 + tsValue(r);
|
else if (kind === "price_up") tie = (pctUp || 0) * 100000 + tsValue(r);
|
||||||
|
|
@ -289,7 +287,6 @@ export function renderSearch($app) {
|
||||||
const ms = t ? Date.parse(t) : NaN;
|
const ms = t ? Date.parse(t) : NaN;
|
||||||
if (Number.isFinite(ms)) return ms;
|
if (Number.isFinite(ms)) return ms;
|
||||||
|
|
||||||
// fallback: date-only => treat as start of day UTC-ish
|
|
||||||
const d = String(r?.date || "");
|
const d = String(r?.date || "");
|
||||||
const ms2 = d ? Date.parse(d + "T00:00:00Z") : NaN;
|
const ms2 = d ? Date.parse(d + "T00:00:00Z") : NaN;
|
||||||
return Number.isFinite(ms2) ? ms2 : 0;
|
return Number.isFinite(ms2) ? ms2 : 0;
|
||||||
|
|
@ -305,7 +302,6 @@ export function renderSearch($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DEDUPE: canonical SKU -> (store -> most recent event for that store) ---
|
|
||||||
const bySkuStore = new Map();
|
const bySkuStore = new Map();
|
||||||
|
|
||||||
for (const r of inWindow) {
|
for (const r of inWindow) {
|
||||||
|
|
@ -325,7 +321,6 @@ export function renderSearch($app) {
|
||||||
if (!prev || eventMs(prev) < ms) storeMap.set(storeLabel, r);
|
if (!prev || eventMs(prev) < ms) storeMap.set(storeLabel, r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PICK ONE PER SKU: choose the most "important" among latest-per-store events ---
|
|
||||||
const picked = [];
|
const picked = [];
|
||||||
for (const [sku, storeMap] of bySkuStore.entries()) {
|
for (const [sku, storeMap] of bySkuStore.entries()) {
|
||||||
let best = null;
|
let best = null;
|
||||||
|
|
@ -411,6 +406,8 @@ export function renderSearch($app) {
|
||||||
? ` style="color:rgba(20,110,40,0.95); background:rgba(20,110,40,0.10); border:1px solid rgba(20,110,40,0.20);"`
|
? ` style="color:rgba(20,110,40,0.95); background:rgba(20,110,40,0.10); border:1px solid rgba(20,110,40,0.20);"`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const skuLink = `#/link/?left=${encodeURIComponent(String(sku || ""))}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="item" data-sku="${esc(sku)}">
|
<div class="item" data-sku="${esc(sku)}">
|
||||||
<div class="itemRow">
|
<div class="itemRow">
|
||||||
|
|
@ -420,7 +417,9 @@ export function renderSearch($app) {
|
||||||
<div class="itemBody">
|
<div class="itemBody">
|
||||||
<div class="itemTop">
|
<div class="itemTop">
|
||||||
<div class="itemName">${esc(r.name || "(no name)")}</div>
|
<div class="itemName">${esc(r.name || "(no name)")}</div>
|
||||||
<span class="badge mono">${esc(displaySku(sku))}</span>
|
<a class="badge mono skuLink" href="${esc(
|
||||||
|
skuLink
|
||||||
|
)}" onclick="event.stopPropagation()">${esc(displaySku(sku))}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="metaRow">
|
<div class="metaRow">
|
||||||
<span class="badge"${kindBadgeStyle}>${esc(kindLabel)}</span>
|
<span class="badge"${kindBadgeStyle}>${esc(kindLabel)}</span>
|
||||||
|
|
@ -454,7 +453,6 @@ export function renderSearch($app) {
|
||||||
|
|
||||||
const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens));
|
const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens));
|
||||||
|
|
||||||
// If query prefixes an SMWS distillery name, also surface SMWS bottles by code XXX.YYY where XXX matches.
|
|
||||||
const wantCodes = new Set(smwsDistilleryCodesForQueryPrefix($q.value));
|
const wantCodes = new Set(smwsDistilleryCodesForQueryPrefix($q.value));
|
||||||
if (!wantCodes.size) {
|
if (!wantCodes.size) {
|
||||||
renderAggregates(matches);
|
renderAggregates(matches);
|
||||||
|
|
@ -473,27 +471,16 @@ export function renderSearch($app) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put SMWS distillery matches first, then normal search matches.
|
|
||||||
renderAggregates([...extra, ...matches]);
|
renderAggregates([...extra, ...matches]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$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 : [];
|
||||||
|
|
||||||
// Build store list from LIVE rows only
|
renderStoreButtons(listings);
|
||||||
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);
|
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]));
|
||||||
|
|
@ -511,7 +498,6 @@ 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,5 +1,11 @@
|
||||||
import { esc, renderThumbHtml } from "./dom.js";
|
import { esc, renderThumbHtml } from "./dom.js";
|
||||||
import { tokenizeQuery, matchesAllTokens, displaySku, keySkuForRow, parsePriceToNumber } from "./sku.js";
|
import {
|
||||||
|
tokenizeQuery,
|
||||||
|
matchesAllTokens,
|
||||||
|
displaySku,
|
||||||
|
keySkuForRow,
|
||||||
|
parsePriceToNumber,
|
||||||
|
} 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";
|
||||||
|
|
@ -8,54 +14,57 @@ function normStoreLabel(s) {
|
||||||
return String(s || "").trim().toLowerCase();
|
return String(s || "").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeLabelFromRow(r) {
|
function readLinkHrefForSkuInStore(listingsLive, canonSku, storeLabelNorm) {
|
||||||
return String(r?.storeLabel || r?.store || "").trim();
|
// Prefer the most recent-ish url if multiple exist; stable enough for viz.
|
||||||
}
|
let bestUrl = "";
|
||||||
|
let bestScore = -1;
|
||||||
|
|
||||||
function storeQueryKey(storeNorm) {
|
function scoreUrl(u) {
|
||||||
return `stviz:v1:store:q:${storeNorm}`;
|
if (!u) return -1;
|
||||||
}
|
let s = u.length;
|
||||||
|
if (/\bproduct\/\d+\//.test(u)) s += 50;
|
||||||
function loadStoreQuery(storeNorm) {
|
if (/[a-z0-9-]{8,}/i.test(u)) s += 10;
|
||||||
try {
|
return s;
|
||||||
return localStorage.getItem(storeQueryKey(storeNorm)) || "";
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const r of listingsLive) {
|
||||||
|
if (!r || r.removed) continue;
|
||||||
|
const store = normStoreLabel(r.storeLabel || r.store || "");
|
||||||
|
if (store !== storeLabelNorm) continue;
|
||||||
|
|
||||||
|
const skuKey = String(rulesCache?.canonicalSku(keySkuForRow(r)) || keySkuForRow(r));
|
||||||
|
if (skuKey !== canonSku) continue;
|
||||||
|
|
||||||
|
const u = String(r.url || "").trim();
|
||||||
|
const sc = scoreUrl(u);
|
||||||
|
if (sc > bestScore) {
|
||||||
|
bestScore = sc;
|
||||||
|
bestUrl = u;
|
||||||
|
} else if (sc === bestScore && u && bestUrl && u < bestUrl) {
|
||||||
|
bestUrl = u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveStoreQuery(storeNorm, v) {
|
// small module-level cache so we can reuse in readLinkHrefForSkuInStore
|
||||||
try {
|
let rulesCache = null;
|
||||||
localStorage.setItem(storeQueryKey(storeNorm), String(v ?? ""));
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlQuality(u) {
|
export async function renderStore($app, storeLabelRaw) {
|
||||||
u = String(u || "").trim();
|
const storeLabel = String(storeLabelRaw || "").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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renderStore($app, storeParamRaw) {
|
|
||||||
const storeParam = String(storeParamRaw || "").trim();
|
|
||||||
const storeNorm = normStoreLabel(storeParam);
|
|
||||||
|
|
||||||
$app.innerHTML = `
|
$app.innerHTML = `
|
||||||
<div class="container" style="max-width:980px;">
|
<div class="container">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button id="back" class="btn">← Back</button>
|
<button id="back" class="btn">← Back</button>
|
||||||
<span class="badge">${esc(storeParam || "Store")}</span>
|
<span class="badge">${esc(storeLabel || "Store")}</span>
|
||||||
<div style="flex:1"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<input id="q" class="input" placeholder="Search this store…" autocomplete="off" />
|
<input id="q" class="input" placeholder="Search in this store..." autocomplete="off" />
|
||||||
<div id="status" class="small" 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="list"></div>
|
||||||
|
<div id="sentinel" class="small" style="text-align:center; padding:12px 0;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -63,175 +72,156 @@ export async function renderStore($app, storeParamRaw) {
|
||||||
document.getElementById("back").addEventListener("click", () => (location.hash = "#/"));
|
document.getElementById("back").addEventListener("click", () => (location.hash = "#/"));
|
||||||
|
|
||||||
const $q = document.getElementById("q");
|
const $q = document.getElementById("q");
|
||||||
const $results = document.getElementById("results");
|
|
||||||
const $status = document.getElementById("status");
|
const $status = document.getElementById("status");
|
||||||
|
const $results = document.getElementById("results");
|
||||||
$q.value = loadStoreQuery(storeNorm);
|
const $sentinel = document.getElementById("sentinel");
|
||||||
|
|
||||||
$results.innerHTML = `<div class="small">Loading…</div>`;
|
$results.innerHTML = `<div class="small">Loading…</div>`;
|
||||||
|
|
||||||
const [idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]);
|
const idx = await loadIndex();
|
||||||
const allRows = Array.isArray(idx.items) ? idx.items : [];
|
rulesCache = await loadSkuRules();
|
||||||
|
const rules = rulesCache;
|
||||||
|
|
||||||
// Live only
|
const listingsAll = Array.isArray(idx.items) ? idx.items : [];
|
||||||
const liveAll = allRows.filter((r) => r && !r.removed);
|
const liveAll = listingsAll.filter((r) => r && !r.removed);
|
||||||
|
|
||||||
// Resolve store display label (in case casing differs)
|
const storeNorm = normStoreLabel(storeLabel);
|
||||||
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
|
// Build global per-canonical-SKU store presence + min prices
|
||||||
const liveStore = liveAll.filter((r) => normStoreLabel(storeLabelFromRow(r)) === storeNorm);
|
const storesBySku = new Map(); // sku -> Set(storeLabelNorm)
|
||||||
|
const minPriceBySkuStore = new Map(); // sku -> Map(storeLabelNorm -> minPrice)
|
||||||
if (!liveStore.length) {
|
|
||||||
$results.innerHTML = `<div class="small">No in-stock items for this store.</div>`;
|
|
||||||
$status.textContent = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global presence + min-price map (by canonical sku)
|
|
||||||
const presenceBySku = new Map(); // sku -> Set(storeNorm)
|
|
||||||
const minPriceBySkuStore = new Map(); // sku -> Map(storeNorm -> minPrice)
|
|
||||||
|
|
||||||
for (const r of liveAll) {
|
for (const r of liveAll) {
|
||||||
const storeLab = storeLabelFromRow(r);
|
const store = normStoreLabel(r.storeLabel || r.store || "");
|
||||||
const sNorm = normStoreLabel(storeLab);
|
if (!store) continue;
|
||||||
if (!sNorm) continue;
|
|
||||||
|
|
||||||
const skuKey = String(keySkuForRow(r) || "").trim();
|
const skuKey = keySkuForRow(r);
|
||||||
if (!skuKey) continue;
|
const sku = String(rules.canonicalSku(skuKey) || skuKey);
|
||||||
|
|
||||||
const sku = String(rules.canonicalSku(skuKey) || "").trim();
|
let ss = storesBySku.get(sku);
|
||||||
if (!sku) continue;
|
if (!ss) storesBySku.set(sku, (ss = new Set()));
|
||||||
|
ss.add(store);
|
||||||
|
|
||||||
let pres = presenceBySku.get(sku);
|
const p = parsePriceToNumber(r.price);
|
||||||
if (!pres) presenceBySku.set(sku, (pres = new Set()));
|
if (p !== null) {
|
||||||
pres.add(sNorm);
|
let m = minPriceBySkuStore.get(sku);
|
||||||
|
if (!m) minPriceBySkuStore.set(sku, (m = new Map()));
|
||||||
const p = parsePriceToNumber(r?.price);
|
const prev = m.get(store);
|
||||||
if (p === null) continue;
|
if (prev === undefined || p < prev) m.set(store, p);
|
||||||
|
|
||||||
let m = minPriceBySkuStore.get(sku);
|
|
||||||
if (!m) minPriceBySkuStore.set(sku, (m = new Map()));
|
|
||||||
|
|
||||||
const prev = m.get(sNorm);
|
|
||||||
if (!Number.isFinite(prev) || p < prev) m.set(sNorm, p);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build store-only aggregates (canonicalized)
|
|
||||||
const storeAgg = aggregateBySku(liveStore, rules.canonicalSku);
|
|
||||||
|
|
||||||
// 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 u = String(r?.url || "").trim();
|
|
||||||
if (!u) continue;
|
|
||||||
|
|
||||||
const prev = urlBySku.get(sku);
|
|
||||||
if (!prev) {
|
|
||||||
urlBySku.set(sku, u);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCompare(it) {
|
function bestAllPrice(sku) {
|
||||||
const sku = String(it?.sku || "");
|
const m = minPriceBySkuStore.get(sku);
|
||||||
const pres = presenceBySku.get(sku) || new Set([storeNorm]);
|
if (!m) return null;
|
||||||
const exclusive = pres.size === 1 && pres.has(storeNorm);
|
let best = null;
|
||||||
|
for (const v of m.values()) best = best === null ? v : Math.min(best, v);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
const storePrice = Number.isFinite(it?.cheapestPriceNum) ? it.cheapestPriceNum : null;
|
function bestOtherPrice(sku, store) {
|
||||||
|
const m = minPriceBySkuStore.get(sku);
|
||||||
const m = minPriceBySkuStore.get(sku) || new Map();
|
if (!m) return null;
|
||||||
let bestAll = null;
|
let best = null;
|
||||||
let bestOther = null;
|
for (const [k, v] of m.entries()) {
|
||||||
|
if (k === store) continue;
|
||||||
for (const [sNorm, p] of m.entries()) {
|
best = best === null ? v : Math.min(best, v);
|
||||||
if (!Number.isFinite(p)) continue;
|
|
||||||
bestAll = bestAll === null ? p : Math.min(bestAll, p);
|
|
||||||
if (sNorm !== storeNorm) bestOther = bestOther === null ? p : Math.min(bestOther, p);
|
|
||||||
}
|
}
|
||||||
|
return best;
|
||||||
// pct: (this - nextBestOther)/nextBestOther * 100
|
|
||||||
const pct =
|
|
||||||
storePrice !== null && bestOther !== null && bestOther > 0
|
|
||||||
? ((storePrice - bestOther) / bestOther) * 100
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const EPS = 0.01;
|
|
||||||
const isBestPrice = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false;
|
|
||||||
|
|
||||||
return { exclusive, pct, isBestPrice };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = storeAgg
|
// Store-specific live rows only (in-stock for that store)
|
||||||
|
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)
|
||||||
|
let items = aggregateBySku(rowsStoreLive, rules.canonicalSku);
|
||||||
|
|
||||||
|
// Decorate each item with pricing comparisons + exclusivity
|
||||||
|
const EPS = 0.01;
|
||||||
|
|
||||||
|
items = items
|
||||||
.map((it) => {
|
.map((it) => {
|
||||||
const c = computeCompare(it);
|
const sku = String(it.sku || "");
|
||||||
|
const storeSet = storesBySku.get(sku) || new Set([storeNorm]);
|
||||||
|
const exclusive = storeSet.size === 1 && storeSet.has(storeNorm);
|
||||||
|
|
||||||
|
const storePrice = Number.isFinite(it.cheapestPriceNum) ? it.cheapestPriceNum : null;
|
||||||
|
const bestAll = bestAllPrice(sku);
|
||||||
|
const other = bestOtherPrice(sku, storeNorm);
|
||||||
|
|
||||||
|
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 =
|
||||||
|
storePrice !== null && other !== null && other > 0 ? ((storePrice - other) / other) * 100 : null;
|
||||||
|
|
||||||
|
const pctVsBest =
|
||||||
|
storePrice !== null && bestAll !== null && bestAll > 0 ? ((storePrice - bestAll) / bestAll) * 100 : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...it,
|
...it,
|
||||||
_exclusive: c.exclusive,
|
_exclusive: exclusive,
|
||||||
_pct: c.pct,
|
_storePrice: storePrice,
|
||||||
_isBestPrice: c.isBestPrice,
|
_bestAll: bestAll,
|
||||||
|
_bestOther: other,
|
||||||
|
_isBest: isBest,
|
||||||
|
_pctVsOther: pctVsOther,
|
||||||
|
_pctVsBest: pctVsBest,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a._exclusive !== b._exclusive) return a._exclusive ? -1 : 1;
|
if (a._exclusive !== b._exclusive) return a._exclusive ? -1 : 1;
|
||||||
|
|
||||||
const ap = Number.isFinite(a._pct) ? a._pct : Infinity;
|
const pa = a._pctVsOther;
|
||||||
const bp = Number.isFinite(b._pct) ? b._pct : Infinity;
|
const pb = b._pctVsOther;
|
||||||
if (ap !== bp) return ap - bp;
|
const sa = pa === null ? 999999 : pa;
|
||||||
|
const sb = pb === null ? 999999 : pb;
|
||||||
|
if (sa !== sb) return sa - sb;
|
||||||
|
|
||||||
return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku));
|
return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku);
|
||||||
});
|
});
|
||||||
|
|
||||||
function pctBadge(pct) {
|
function priceBadgeHtml(it) {
|
||||||
if (!Number.isFinite(pct)) return null;
|
if (it._exclusive) return "";
|
||||||
|
|
||||||
const p = Math.round(pct);
|
const pOther = it._pctVsOther;
|
||||||
const txt = `${p >= 0 ? "+" : ""}${p}% vs next`;
|
const pBest = it._pctVsBest;
|
||||||
|
|
||||||
if (pct < -5) return { cls: "badge badgeGood", txt };
|
// If we can't compare, skip the % badge.
|
||||||
if (pct > 5) return { cls: "badge badgeBad", txt };
|
if (pOther === null || !Number.isFinite(pOther)) return "";
|
||||||
return { cls: "badge badgeNeutral", txt }; // -5%..+5%
|
|
||||||
|
// Grey zone: -5% .. +5% => "same as next best price"
|
||||||
|
if (Math.abs(pOther) <= 5) {
|
||||||
|
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) {
|
||||||
|
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>`;
|
||||||
|
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) {
|
||||||
|
const pct = Math.round(pBest);
|
||||||
|
if (pct <= 5) return `<span class="badge badgeNeutral">same as next best price</span>`;
|
||||||
|
return `<span class="badge badgeBad">${esc(pct)}% vs best price</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(it) {
|
function renderCard(it) {
|
||||||
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
||||||
const href = urlBySku.get(String(it.sku || "")) || String(it.sampleUrl || "").trim();
|
const href = String(it.sampleUrl || "").trim();
|
||||||
|
|
||||||
const storeBadge = href
|
const exclusiveBadge = it._exclusive ? `<span class="badge badgeGood">Exclusive</span>` : "";
|
||||||
? `<a class="badge" href="${esc(href)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${esc(
|
const bestBadge = !it._exclusive && it._isBest ? `<span class="badge badgeBest">Best Price</span>` : "";
|
||||||
storeDisplay
|
const pctBadge = priceBadgeHtml(it);
|
||||||
)}</a>`
|
|
||||||
: `<span class="badge">${esc(storeDisplay)}</span>`;
|
|
||||||
|
|
||||||
const badges = [];
|
const skuLink =
|
||||||
|
`#/link/?left=${encodeURIComponent(String(it.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>`);
|
|
||||||
|
|
||||||
if (!it._exclusive) {
|
|
||||||
const pb = pctBadge(it._pct);
|
|
||||||
if (pb) badges.push(`<span class="${esc(pb.cls)}">${esc(pb.txt)}</span>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="item" data-sku="${esc(it.sku)}">
|
<div class="item" data-sku="${esc(it.sku)}">
|
||||||
|
|
@ -240,12 +230,20 @@ export async function renderStore($app, storeParamRaw) {
|
||||||
<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>
|
||||||
<span class="badge mono">${esc(displaySku(it.sku))}</span>
|
<a class="badge mono skuLink" href="${esc(skuLink)}" onclick="event.stopPropagation()">${esc(
|
||||||
|
displaySku(it.sku)
|
||||||
|
)}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="metaRow metaRowWrap">
|
<div class="metaRow">
|
||||||
${badges.join("")}
|
${exclusiveBadge}
|
||||||
|
${bestBadge}
|
||||||
|
${pctBadge}
|
||||||
<span class="mono price">${esc(price)}</span>
|
<span class="mono price">${esc(price)}</span>
|
||||||
${storeBadge}
|
${
|
||||||
|
href
|
||||||
|
? `<a class="badge" href="${esc(href)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Open</a>`
|
||||||
|
: ``
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -253,41 +251,81 @@ export async function renderStore($app, storeParamRaw) {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList(filtered) {
|
// ---- Infinite scroll paging ----
|
||||||
if (!filtered.length) {
|
const PAGE_SIZE = 140;
|
||||||
$results.innerHTML = `<div class="small">No matches.</div>`;
|
|
||||||
|
let filtered = items.slice();
|
||||||
|
let shown = 0;
|
||||||
|
|
||||||
|
function setStatus() {
|
||||||
|
const total = filtered.length;
|
||||||
|
if (!total) {
|
||||||
|
$status.textContent = "No in-stock items for this store.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$status.textContent = `In stock: ${total} item(s).`;
|
||||||
|
}
|
||||||
|
|
||||||
const limited = filtered.slice(0, 120);
|
function renderNext(reset) {
|
||||||
$results.innerHTML = limited.map(renderCard).join("");
|
if (reset) {
|
||||||
|
$results.innerHTML = "";
|
||||||
|
shown = 0;
|
||||||
|
}
|
||||||
|
|
||||||
for (const el of Array.from($results.querySelectorAll(".item"))) {
|
const slice = filtered.slice(shown, shown + PAGE_SIZE);
|
||||||
el.addEventListener("click", () => {
|
shown += slice.length;
|
||||||
const sku = el.getAttribute("data-sku") || "";
|
|
||||||
if (!sku) return;
|
if (slice.length) {
|
||||||
saveStoreQuery(storeNorm, $q.value);
|
$results.insertAdjacentHTML("beforeend", slice.map(renderCard).join(""));
|
||||||
location.hash = `#/item/${encodeURIComponent(sku)}`;
|
}
|
||||||
});
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
$sentinel.textContent = "";
|
||||||
|
} else if (shown >= filtered.length) {
|
||||||
|
$sentinel.textContent = `Showing ${shown} / ${filtered.length}`;
|
||||||
|
} else {
|
||||||
|
$sentinel.textContent = `Showing ${shown} / ${filtered.length}…`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySearch() {
|
// Click -> item page (delegated). SKU + Open links stopPropagation already.
|
||||||
|
$results.addEventListener("click", (e) => {
|
||||||
|
const el = e.target.closest(".item");
|
||||||
|
if (!el) return;
|
||||||
|
const sku = el.getAttribute("data-sku") || "";
|
||||||
|
if (!sku) return;
|
||||||
|
location.hash = `#/item/${encodeURIComponent(sku)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
const tokens = tokenizeQuery($q.value);
|
const tokens = tokenizeQuery($q.value);
|
||||||
saveStoreQuery(storeNorm, $q.value);
|
if (!tokens.length) {
|
||||||
|
filtered = items.slice();
|
||||||
const filtered = tokens.length ? items.filter((it) => matchesAllTokens(it.searchText, tokens)) : items;
|
} else {
|
||||||
|
filtered = items.filter((it) => matchesAllTokens(it.searchText, tokens));
|
||||||
$status.textContent = `In stock: ${items.length}. Showing: ${filtered.length}.`;
|
}
|
||||||
renderList(filtered);
|
setStatus();
|
||||||
|
renderNext(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$q.focus();
|
// Initial render
|
||||||
applySearch();
|
setStatus();
|
||||||
|
renderNext(true);
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const hit = entries.some((x) => x.isIntersecting);
|
||||||
|
if (!hit) return;
|
||||||
|
if (shown >= filtered.length) return;
|
||||||
|
renderNext(false);
|
||||||
|
},
|
||||||
|
{ root: null, rootMargin: "600px 0px", threshold: 0.01 }
|
||||||
|
);
|
||||||
|
io.observe($sentinel);
|
||||||
|
|
||||||
let t = null;
|
let t = null;
|
||||||
$q.addEventListener("input", () => {
|
$q.addEventListener("input", () => {
|
||||||
if (t) clearTimeout(t);
|
if (t) clearTimeout(t);
|
||||||
t = setTimeout(applySearch, 50);
|
t = setTimeout(applyFilter, 60);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
viz/style.css
112
viz/style.css
|
|
@ -22,6 +22,10 @@ a:hover { text-decoration: underline; }
|
||||||
a.badge { color: var(--muted); }
|
a.badge { color: var(--muted); }
|
||||||
a.badge:hover { text-decoration: underline; cursor: pointer; }
|
a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
|
/* SKU badge links (search/store -> link page) */
|
||||||
|
a.skuLink { color: var(--muted); }
|
||||||
|
a.skuLink:hover { text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 980px;
|
max-width: 980px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
@ -31,9 +35,19 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap; /* keeps Link SKUs comfy on small screens */
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLeft {
|
||||||
|
flex: 1 1 520px;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerRight {
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h1 {
|
.h1 {
|
||||||
|
|
@ -148,6 +162,29 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badgeGood {
|
||||||
|
color: rgba(20,110,40,0.95);
|
||||||
|
background: rgba(20,110,40,0.10);
|
||||||
|
border-color: rgba(20,110,40,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeNeutral {
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeBad {
|
||||||
|
color: rgba(160,40,40,0.95);
|
||||||
|
background: rgba(160,40,40,0.12);
|
||||||
|
border-color: rgba(160,40,40,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeBest {
|
||||||
|
color: rgba(160,120,20,0.95);
|
||||||
|
background: rgba(160,120,20,0.10);
|
||||||
|
border-color: rgba(160,120,20,0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.metaRow {
|
.metaRow {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -159,8 +196,6 @@ 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);
|
||||||
|
|
@ -186,6 +221,13 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
.btn:hover { border-color: #2f3a46; }
|
.btn:hover { border-color: #2f3a46; }
|
||||||
|
|
||||||
|
.btnWide {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.links {
|
.links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
@ -198,54 +240,39 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Store selector pills (search page header) --- */
|
/* --- Store selector (top of search page) --- */
|
||||||
.storeBarWrap {
|
.storeBarWrap {
|
||||||
margin-top: 8px;
|
margin-top: 12px; /* slightly more gap from the top text */
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storeBarLabel {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.storeBar {
|
.storeBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap; /* desktop: wrap nicely into 2 lines if needed */
|
flex-wrap: wrap;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.storePill {
|
.storeBtn {
|
||||||
padding: 2px 9px;
|
border: 1px solid var(--border);
|
||||||
|
background: #0f1318;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badge color variants (store page percent badges) */
|
.storeBtn:hover {
|
||||||
.badgeGood {
|
border-color: #2f3a46;
|
||||||
color: rgba(20,110,40,0.95);
|
color: var(--text);
|
||||||
background: rgba(20,110,40,0.10);
|
text-decoration: none;
|
||||||
border-color: rgba(20,110,40,0.20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgeBad {
|
.storeBtnActive {
|
||||||
color: rgba(180,70,60,0.95);
|
border-color: #37566b;
|
||||||
background: rgba(180,70,60,0.10);
|
outline: 1px solid #37566b;
|
||||||
border-color: rgba(180,70,60,0.25);
|
color: var(--text);
|
||||||
}
|
|
||||||
|
|
||||||
.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 */
|
||||||
|
|
@ -310,15 +337,6 @@ 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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue