mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
feat: V10 store page
This commit is contained in:
parent
1d336dc1e7
commit
7ac41370cf
1 changed files with 102 additions and 36 deletions
|
|
@ -63,12 +63,38 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||||
<input id="q" class="input" placeholder="Search in this store..." autocomplete="off" style="flex: 1 1 320px;" />
|
<input id="q" class="input" placeholder="Search in this store..." autocomplete="off" />
|
||||||
<div id="priceWrap" style="display:flex; gap:10px; align-items:center; flex: 1 1 320px; min-width:260px;">
|
|
||||||
<div class="small" style="white-space:nowrap;">Max price:</div>
|
<div id="priceWrap" style="display:flex; align-items:center; gap:10px;">
|
||||||
<input id="maxPrice" type="range" min="0" max="1000" step="1" value="1000" style="flex:1 1 auto;" />
|
<div class="small" style="white-space:nowrap; opacity:.75;">Max price</div>
|
||||||
<div class="badge mono" id="maxPriceLabel" style="white-space:nowrap;"></div>
|
|
||||||
|
<input
|
||||||
|
id="maxPrice"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1000"
|
||||||
|
step="1"
|
||||||
|
value="1000"
|
||||||
|
style="
|
||||||
|
width: 320px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #9aa3b2;
|
||||||
|
opacity: .85;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="badge mono"
|
||||||
|
id="maxPriceLabel"
|
||||||
|
style="
|
||||||
|
width: 120px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: .9;
|
||||||
|
"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -253,7 +279,7 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku);
|
return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Max price slider (exponential mapping) ----
|
// ---- Max price slider (exponential mapping + clicky rounding) ----
|
||||||
const MIN_PRICE = 25;
|
const MIN_PRICE = 25;
|
||||||
|
|
||||||
function maxStorePriceOnPage() {
|
function maxStorePriceOnPage() {
|
||||||
|
|
@ -267,11 +293,20 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageMax = maxStorePriceOnPage();
|
const pageMax = maxStorePriceOnPage();
|
||||||
// If nothing priced, hide slider (still functional, but not meaningful)
|
|
||||||
const boundMax = pageMax !== null ? Math.max(MIN_PRICE, pageMax) : MIN_PRICE;
|
const boundMax = pageMax !== null ? Math.max(MIN_PRICE, pageMax) : MIN_PRICE;
|
||||||
|
|
||||||
// Exponential scale: t in [0..1] maps price in [MIN_PRICE..boundMax]
|
function stepForPrice(p) {
|
||||||
// price = MIN_PRICE * exp( ln(boundMax/MIN_PRICE) * t )
|
const x = Number.isFinite(p) ? p : boundMax;
|
||||||
|
if (x < 120) return 5;
|
||||||
|
if (x < 250) return 10;
|
||||||
|
if (x < 600) return 25;
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
function roundToStep(p) {
|
||||||
|
const step = stepForPrice(p);
|
||||||
|
return Math.round(p / step) * step;
|
||||||
|
}
|
||||||
|
|
||||||
function priceFromT(t) {
|
function priceFromT(t) {
|
||||||
t = Math.max(0, Math.min(1, t));
|
t = Math.max(0, Math.min(1, t));
|
||||||
if (boundMax <= MIN_PRICE) return MIN_PRICE;
|
if (boundMax <= MIN_PRICE) return MIN_PRICE;
|
||||||
|
|
@ -291,9 +326,18 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
return Math.max(MIN_PRICE, Math.min(boundMax, p));
|
return Math.max(MIN_PRICE, Math.min(boundMax, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize selected max price:
|
function clampAndRound(p) {
|
||||||
// default = highest price on page, otherwise MIN_PRICE
|
const c = clampPrice(p);
|
||||||
let selectedMaxPrice = clampPrice(
|
const r = roundToStep(c);
|
||||||
|
return clampPrice(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDollars(p) {
|
||||||
|
if (!Number.isFinite(p)) return "";
|
||||||
|
return `$${Math.round(p)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedMaxPrice = clampAndRound(
|
||||||
savedMaxPrice !== null ? savedMaxPrice : boundMax
|
savedMaxPrice !== null ? savedMaxPrice : boundMax
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -302,18 +346,13 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
const v = Math.round(t * 1000);
|
const v = Math.round(t * 1000);
|
||||||
$maxPrice.value = String(v);
|
$maxPrice.value = String(v);
|
||||||
}
|
}
|
||||||
function getPriceFromSlider() {
|
|
||||||
|
function getRawPriceFromSlider() {
|
||||||
const v = Number($maxPrice.value);
|
const v = Number($maxPrice.value);
|
||||||
const t = Number.isFinite(v) ? v / 1000 : 1;
|
const t = Number.isFinite(v) ? v / 1000 : 1;
|
||||||
return clampPrice(priceFromT(t));
|
return clampPrice(priceFromT(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDollars(p) {
|
|
||||||
if (!Number.isFinite(p)) return "";
|
|
||||||
const rounded = Math.round(p);
|
|
||||||
return `$${rounded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMaxPriceLabel() {
|
function updateMaxPriceLabel() {
|
||||||
if (pageMax === null) {
|
if (pageMax === null) {
|
||||||
$maxPriceLabel.textContent = "No prices";
|
$maxPriceLabel.textContent = "No prices";
|
||||||
|
|
@ -326,21 +365,27 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageMax === null) {
|
if (pageMax === null) {
|
||||||
// No prices found; slider isn't useful.
|
|
||||||
$maxPrice.disabled = true;
|
$maxPrice.disabled = true;
|
||||||
$priceWrap.title = "No priced items in this store.";
|
$priceWrap.title = "No priced items in this store.";
|
||||||
setSliderFromPrice(boundMax);
|
|
||||||
selectedMaxPrice = boundMax;
|
selectedMaxPrice = boundMax;
|
||||||
|
setSliderFromPrice(boundMax);
|
||||||
localStorage.setItem(LS_MAX_PRICE, String(selectedMaxPrice));
|
localStorage.setItem(LS_MAX_PRICE, String(selectedMaxPrice));
|
||||||
updateMaxPriceLabel();
|
updateMaxPriceLabel();
|
||||||
} else {
|
} else {
|
||||||
// Clamp saved value to bounds (and write back clamped value)
|
selectedMaxPrice = clampAndRound(selectedMaxPrice);
|
||||||
selectedMaxPrice = clampPrice(selectedMaxPrice);
|
|
||||||
localStorage.setItem(LS_MAX_PRICE, String(selectedMaxPrice));
|
localStorage.setItem(LS_MAX_PRICE, String(selectedMaxPrice));
|
||||||
setSliderFromPrice(selectedMaxPrice);
|
setSliderFromPrice(selectedMaxPrice);
|
||||||
updateMaxPriceLabel();
|
updateMaxPriceLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Round listing display price to nearest $1 ----
|
||||||
|
function roundedListingPriceStr(it) {
|
||||||
|
const p = it && Number.isFinite(it._storePrice) ? it._storePrice : null;
|
||||||
|
if (p === null) return it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
||||||
|
const dollars = Math.round(p);
|
||||||
|
return `$${dollars}`;
|
||||||
|
}
|
||||||
|
|
||||||
function priceBadgeHtml(it) {
|
function priceBadgeHtml(it) {
|
||||||
if (it._exclusive || it._lastStock) return "";
|
if (it._exclusive || it._lastStock) return "";
|
||||||
|
|
||||||
|
|
@ -360,7 +405,7 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(it) {
|
function renderCard(it) {
|
||||||
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
const price = roundedListingPriceStr(it);
|
||||||
const href = String(it.sampleUrl || "").trim();
|
const href = String(it.sampleUrl || "").trim();
|
||||||
|
|
||||||
const specialBadge = it._lastStock
|
const specialBadge = it._lastStock
|
||||||
|
|
@ -406,7 +451,7 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Infinite scroll paging (shared across both columns) ----
|
// ---- Infinite scroll paging (shared across both columns) ----
|
||||||
const PAGE_SIZE = 140; // total per "page" across both columns
|
const PAGE_SIZE = 140;
|
||||||
const PAGE_EACH = Math.max(1, Math.floor(PAGE_SIZE / 2));
|
const PAGE_EACH = Math.max(1, Math.floor(PAGE_SIZE / 2));
|
||||||
|
|
||||||
let filteredExclusive = [];
|
let filteredExclusive = [];
|
||||||
|
|
@ -452,8 +497,16 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
shownExclusive += sliceEx.length;
|
shownExclusive += sliceEx.length;
|
||||||
shownCompare += sliceCo.length;
|
shownCompare += sliceCo.length;
|
||||||
|
|
||||||
if (sliceEx.length) $resultsExclusive.insertAdjacentHTML("beforeend", sliceEx.map(renderCard).join(""));
|
if (sliceEx.length)
|
||||||
if (sliceCo.length) $resultsCompare.insertAdjacentHTML("beforeend", sliceCo.map(renderCard).join(""));
|
$resultsExclusive.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
sliceEx.map(renderCard).join("")
|
||||||
|
);
|
||||||
|
if (sliceCo.length)
|
||||||
|
$resultsCompare.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
sliceCo.map(renderCard).join("")
|
||||||
|
);
|
||||||
|
|
||||||
const total = totalFiltered();
|
const total = totalFiltered();
|
||||||
const shown = totalShown();
|
const shown = totalShown();
|
||||||
|
|
@ -467,7 +520,6 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click -> item page (delegated). SKU + Open links stopPropagation already.
|
|
||||||
$resultsWrap.addEventListener("click", (e) => {
|
$resultsWrap.addEventListener("click", (e) => {
|
||||||
const el = e.target.closest(".item");
|
const el = e.target.closest(".item");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -485,12 +537,10 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
|
|
||||||
let base = items;
|
let base = items;
|
||||||
|
|
||||||
// Search filter
|
|
||||||
if (tokens.length) {
|
if (tokens.length) {
|
||||||
base = base.filter((it) => matchesAllTokens(it.searchText, tokens));
|
base = base.filter((it) => matchesAllTokens(it.searchText, tokens));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max price filter (include items with no price)
|
|
||||||
if (pageMax !== null && Number.isFinite(selectedMaxPrice)) {
|
if (pageMax !== null && Number.isFinite(selectedMaxPrice)) {
|
||||||
const cap = selectedMaxPrice + 0.0001;
|
const cap = selectedMaxPrice + 0.0001;
|
||||||
base = base.filter((it) => {
|
base = base.filter((it) => {
|
||||||
|
|
@ -506,7 +556,6 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
renderNext(true);
|
renderNext(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial render (apply saved query/max price if present)
|
|
||||||
applyFilter();
|
applyFilter();
|
||||||
|
|
||||||
const io = new IntersectionObserver(
|
const io = new IntersectionObserver(
|
||||||
|
|
@ -527,14 +576,31 @@ export async function renderStore($app, storeLabelRaw) {
|
||||||
});
|
});
|
||||||
|
|
||||||
let tp = null;
|
let tp = null;
|
||||||
|
function setSelectedMaxPriceFromSlider() {
|
||||||
|
const raw = getRawPriceFromSlider();
|
||||||
|
const rounded = clampAndRound(raw);
|
||||||
|
if (Math.abs(rounded - selectedMaxPrice) > 0.001) {
|
||||||
|
selectedMaxPrice = rounded;
|
||||||
|
localStorage.setItem(LS_MAX_PRICE, String(selectedMaxPrice));
|
||||||
|
updateMaxPriceLabel();
|
||||||
|
} else {
|
||||||
|
updateMaxPriceLabel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$maxPrice.addEventListener("input", () => {
|
$maxPrice.addEventListener("input", () => {
|
||||||
if (pageMax === null) return;
|
if (pageMax === null) return;
|
||||||
selectedMaxPrice = getPriceFromSlider();
|
setSelectedMaxPriceFromSlider();
|
||||||
selectedMaxPrice = clampPrice(selectedMaxPrice);
|
|
||||||
localStorage.setItem(LS_MAX_PRICE, String(selectedMaxPrice));
|
|
||||||
updateMaxPriceLabel();
|
|
||||||
|
|
||||||
if (tp) clearTimeout(tp);
|
if (tp) clearTimeout(tp);
|
||||||
tp = setTimeout(applyFilter, 40);
|
tp = setTimeout(applyFilter, 40);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$maxPrice.addEventListener("change", () => {
|
||||||
|
if (pageMax === null) return;
|
||||||
|
setSelectedMaxPriceFromSlider();
|
||||||
|
setSliderFromPrice(selectedMaxPrice);
|
||||||
|
updateMaxPriceLabel();
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue