mirror of
https://github.com/badlogic/heissepreise.git
synced 2024-06-28 11:25:50 +02:00
More components, checkbox, items filter.
This commit is contained in:
parent
2eaa529f84
commit
323fd15a62
|
@ -2,9 +2,8 @@
|
||||||
|
|
||||||
<div class="w-full relative px-4 flex-1">
|
<div class="w-full relative px-4 flex-1">
|
||||||
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">Preisänderungen</h1>
|
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">Preisänderungen</h1>
|
||||||
<items-filter class="bg-stone-200 rounded-xl p-4 max-w-4xl mx-auto md:mb-12 md:mt-6"></items-filter>
|
<items-filter x-id="items-filter" pricechanges pricedirection stores placeholder="Filtern... (mind. 3 Zeichen)"></items-filter>
|
||||||
<div class="px-4 py-2 my-4 text-sm border rounded-xl md:mt-8 md:rounded-b-none md:mb-0 bg-gray-100" id="numresults"></div>
|
<items-list></items-list>
|
||||||
<table id="result" class="hidden w-full"></table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="changes-new.js"></script>
|
<script src="changes-new.js"></script>
|
||||||
|
|
|
@ -3,5 +3,9 @@ require("./views");
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await model.load();
|
await model.load();
|
||||||
document.querySelector("items-filter").model(model.items);
|
const itemsFilter = document.querySelector("items-filter");
|
||||||
|
itemsFilter.model = model.items;
|
||||||
|
itemsFilter.addEventListener("change", (event) => {
|
||||||
|
console.log("Filter changed: " + event.target.getAttribute("x-id"));
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
19
site/misc.js
19
site/misc.js
|
@ -21,6 +21,14 @@ if (typeof window !== "undefined") {
|
||||||
setupLiveEdit();
|
setupLiveEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.today = () => {
|
||||||
|
const currentDate = new Date();
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(currentDate.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
exports.fetchJSON = async (url) => {
|
exports.fetchJSON = async (url) => {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
|
@ -45,9 +53,20 @@ exports.dom = (element, innerHTML) => {
|
||||||
return el;
|
return el;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches all children with attribute `x-id` and returns them
|
||||||
|
* in an object under keys equal to the `x-id` value.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} dom
|
||||||
|
* @returns Object storing each child under a key equal to its `x-id`.
|
||||||
|
*/
|
||||||
exports.getDynamicElements = (dom) => {
|
exports.getDynamicElements = (dom) => {
|
||||||
const elements = dom.querySelectorAll("[x-id]");
|
const elements = dom.querySelectorAll("[x-id]");
|
||||||
const result = {};
|
const result = {};
|
||||||
elements.forEach((element) => (result[element.getAttribute("x-id")] = element));
|
elements.forEach((element) => (result[element.getAttribute("x-id")] = element));
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.getBooleanAttribute = (element, name) => {
|
||||||
|
return element.hasAttribute(name) && (element.getAttribute(name).length == 0 || element.getAttribute(name) === "true");
|
||||||
|
};
|
||||||
|
|
|
@ -173,3 +173,15 @@ thead > tr {
|
||||||
@apply border-stone-400;
|
@apply border-stone-400;
|
||||||
@apply hover:bg-stone-400;
|
@apply hover:bg-stone-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* items filter */
|
||||||
|
.items-filter {
|
||||||
|
@apply bg-stone-200;
|
||||||
|
@apply rounded-xl;
|
||||||
|
@apply p-4;
|
||||||
|
@apply max-w-4xl;
|
||||||
|
@apply mx-auto;
|
||||||
|
@apply md:mb-12;
|
||||||
|
@apply md:mt-6;
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
36
site/views/custom-checkbox.js
Normal file
36
site/views/custom-checkbox.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
const { dom, getDynamicElements } = require("../misc");
|
||||||
|
const { View } = require("./view");
|
||||||
|
|
||||||
|
class CustomCheckbox extends View {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const isChecked = this.hasAttribute("checked") && (this.getAttribute("checked").length == 0 || this.getAttribute("checked") === "true");
|
||||||
|
const label = this.hasAttribute("label") ? this.getAttribute("label") : "";
|
||||||
|
const abbr = this.hasAttribute("abbr") ? this.getAttribute("abbr") : "";
|
||||||
|
this.innerHTML = /*html*/ `
|
||||||
|
<label class="inline-flex items-center gap-x-1 cursor-pointer">
|
||||||
|
<input x-id="checkbox" x-change type="checkbox" ${isChecked ? "checked" : ""} class="hidden peer">
|
||||||
|
<svg class="h-2 w-2 stroke-gray-600 fill-gray-100 peer-checked:fill-gray-600" viewBox="0 0 6 6">
|
||||||
|
<circle cx="3" cy="3" r="2" />
|
||||||
|
</svg>
|
||||||
|
${this.hasAttribute("abbr") ? `<abbr title="${abbr}">${label}</abbr>` : label}
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
this.classList.add("customcheckbox");
|
||||||
|
this._checkbox = getDynamicElements(this).checkbox;
|
||||||
|
this.setupEventHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
get checkbox() {
|
||||||
|
return this._checkbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
get checked() {
|
||||||
|
return this._checkbox.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
set checked(value) {
|
||||||
|
this._checkbox.checked = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define("custom-checkbox", CustomCheckbox);
|
|
@ -1,2 +1,4 @@
|
||||||
|
require("./custom-checkbox");
|
||||||
require("./carts-list");
|
require("./carts-list");
|
||||||
require("./items-filter");
|
require("./items-filter");
|
||||||
|
require("./items-list");
|
||||||
|
|
|
@ -1,22 +1,137 @@
|
||||||
const { dom, getDynamicElements } = require("../misc");
|
const { today, dom, getBooleanAttribute } = require("../misc");
|
||||||
|
const { stores, STORE_KEYS, BUDGET_BRANDS } = require("../model/stores");
|
||||||
const { View } = require("./view");
|
const { View } = require("./view");
|
||||||
|
|
||||||
class ItemsFilter extends View {
|
class ItemsFilter extends View {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this._hidePriceChanges = getBooleanAttribute(this, "pricechanges") ? "" : "hidden";
|
||||||
|
this._hidePriceDirection = getBooleanAttribute(this, "pricedirection") ? "" : "hidden";
|
||||||
|
this._hideStores = getBooleanAttribute(this, "stores") ? "" : "hidden";
|
||||||
|
this._hideMisc = getBooleanAttribute(this, "misc") ? "" : "hidden";
|
||||||
|
this._placeholder = this.hasAttribute("placeholder") ? this.getAttribute("placeholder") : "Produkte suchen... (mind. 3 Zeichen)";
|
||||||
|
|
||||||
this.innerHTML = /*html*/ `
|
this.innerHTML = /*html*/ `
|
||||||
<div class="filters mb-4 text-sm flex justify-center gap-4">
|
<input x-id="query" x-state class="rounded-lg px-2 py-1 w-full" type="text" placeholder="${this._placeholder}" />
|
||||||
<label>
|
|
||||||
<input type="radio" id="date" name="type" value="date" checked /> Datum
|
<div x-id="stores" class="flex justify-center gap-2 flex-wrap mt-4 ${this._hideStores}">
|
||||||
<select id="dates" class="bg-white rounded-full"></select>
|
<custom-checkbox x-id="allStores" label="Alle" checked></custom-checkbox>
|
||||||
</label>
|
${STORE_KEYS.map(
|
||||||
<label><input type="radio" id="cheaper" name="type" value="cheaper" /> Billiger seit letzter Änderung</label>
|
(store) => /*html*/ `
|
||||||
|
<custom-checkbox
|
||||||
|
x-id="${store}" x-state x-change
|
||||||
|
label="${stores[store].name}"
|
||||||
|
class="${stores[store].color}"
|
||||||
|
checked
|
||||||
|
></custom-checkbox>`
|
||||||
|
).join("")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-id="priceChanges" class="text-sm flex justify-center gap-4 mt-4 ${this._hidePriceChanges}">
|
||||||
|
<label>
|
||||||
|
<input x-id="priceChangesToday" x-state type="radio" name="type" checked /> Datum
|
||||||
|
<select x-id="priceChangesDate" x-state x-change class="bg-white rounded-full">
|
||||||
|
<option>${today()}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input x-id="priceChangesSinceLast" x-state type="radio" name="type" /> Billiger seit letzter Änderung
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-id="priceDirection" class="flex justify-center gap-2 mt-4 ${this._hideMisc ? "" : "mb-4"} ${this._hidePriceDirection}">
|
||||||
|
<custom-checkbox x-id="priceIncreased" x-state x-change label="Teurer" checked class="gray"></custom-checkbox>
|
||||||
|
<custom-checkbox x-id="priceDecreased" x-state x-change label="Billiger" checked class="gray"></custom-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-id="misc" class="flex items-center justify-center flex-wrap gap-2 mt-4 ${this._hideMisc}">
|
||||||
|
<custom-checkbox
|
||||||
|
x-id="budgetBrands" x-state x-change
|
||||||
|
label="Nur Diskont-Eigenmarken"
|
||||||
|
abbr="${BUDGET_BRANDS.map((budgetBrand) => budgetBrand.toUpperCase()).join(", ")}"
|
||||||
|
></custom-checkbox>
|
||||||
|
<custom-checkbox x-id="bio" x-state x-change label="Nur Bio"></custom-checkbox>
|
||||||
|
<custom-checkbox x-id="exact" x-state x-change label="Exaktes Wort"></custom-checkbox>
|
||||||
|
<label class="cursor-pointer inline-flex items-center gap-x-1 rounded-full bg-white border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600">
|
||||||
|
Preis € <input x-id="minPrice" x-state x-change class="w-12" type="number" min="0" value="0">
|
||||||
|
-
|
||||||
|
<input x-id="maxPrice" x-state x-change class="w-12" type="number" min="0" value="100">
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input id="filter" class="search rounded-lg px-2 py-1 w-full mb-4" type="text" placeholder="Filtern... (mind. 3 Zeichen)" />
|
|
||||||
<div class="filters flex justify-center gap-2 flex-wrap" id="filters-store"></div>
|
|
||||||
<div class="filters flex justify-center gap-2 mt-4" id="filters-changes"></div>
|
|
||||||
`;
|
`;
|
||||||
|
this.classList.add("items-filter");
|
||||||
|
|
||||||
|
const elements = this.elements;
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 100;
|
||||||
|
let timeoutId;
|
||||||
|
elements.query.addEventListener("input", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("change", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.allStores.addEventListener("change", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const checked = elements.allStores.checked;
|
||||||
|
STORE_KEYS.forEach((store) => (elements[store].checked = checked));
|
||||||
|
this.fireChangeEvent();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.priceChangesToday.addEventListener("change", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (elements.priceChangesToday.checked) elements.priceDirection.classList.remove("hidden");
|
||||||
|
else elements.priceDirection.classList.add("hidden");
|
||||||
|
this.fireChangeEvent();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.priceChangesSinceLast.addEventListener("change", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (elements.priceChangesSinceLast.checked) elements.priceDirection.classList.add("hidden");
|
||||||
|
else elements.priceDirection.classList.remove("hidden");
|
||||||
|
this.fireChangeEvent();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupEventHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const now = performance.now();
|
||||||
|
const elements = this.elements;
|
||||||
|
const items = this.model.items;
|
||||||
|
const dates = {};
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.priceHistory.length == 1) continue;
|
||||||
|
for (let i = 0; i < item.priceHistory.length; i++) {
|
||||||
|
const price = item.priceHistory[i];
|
||||||
|
if (i + 1 < item.priceHistory.length) {
|
||||||
|
if (item.priceHistory[i].price != item.priceHistory[i + 1].price)
|
||||||
|
dates[price.date] = dates[price.date] ? dates[price.date] + 1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceChangesDates = elements.priceChangesDate;
|
||||||
|
priceChangesDates.innerHTML = "";
|
||||||
|
for (const date of Object.keys(dates).sort((a, b) => b.localeCompare(a))) {
|
||||||
|
const dateDom = dom("option");
|
||||||
|
dateDom.value = date;
|
||||||
|
dateDom.innerText = `${date} (${dates[date]})`;
|
||||||
|
priceChangesDates.append(dateDom);
|
||||||
|
}
|
||||||
|
console.log("Rendering items filter took " + (performance.now() - now) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
get checkedStores() {
|
||||||
|
return STORE_KEYS.filter((store) => elements[store].checked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
70
site/views/items-list.js
Normal file
70
site/views/items-list.js
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
const { downloadJSON, dom, getDynamicElements } = require("../misc");
|
||||||
|
const { View } = require("./view");
|
||||||
|
|
||||||
|
class ItemsList extends View {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.innerHTML = /*html*/ `
|
||||||
|
<div class="flex flex-col md:flex-row gap-4 px-4 py-2 my-4 justify-between items-center text-sm border rounded-xl md:mt-8 md:rounded-b-none md:mb-0 bg-gray-100 ">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col md:flex-row gap-2 items-center">
|
||||||
|
<span x-id="numItems"></span>
|
||||||
|
<span>
|
||||||
|
<a x-id="shareLink" class="querylink text-primary font-medium hover:underline">Teilen</a>
|
||||||
|
<a x-id="json" class="text-primary font-medium hover:underline" href="">JSON</a>
|
||||||
|
</span>
|
||||||
|
<custom-checkbox x-id="chart" label="Diagramm"></custom-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Sortieren
|
||||||
|
<select x-id="sort">
|
||||||
|
<option value="price-asc">Preis aufsteigend</option>
|
||||||
|
<option value="price-desc">Preis absteigend</option>
|
||||||
|
<option value="quantity-asc">Menge aufsteigend</option>
|
||||||
|
<option value="quantity-desc">Menge absteigend</option>
|
||||||
|
<option value="name-similarity">Namensähnlichkeit</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="rounded-b-xl overflow-hidden w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<th class="text-center">Kette</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Preis <span x-id="expandPrices">+</span></th>
|
||||||
|
<th></th>
|
||||||
|
</thead>
|
||||||
|
<tbody x-id="tableBody" >
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this._itemTemplate = dom(
|
||||||
|
"tr",
|
||||||
|
/*html*/ `
|
||||||
|
<td x-id="store" data-label="Kette"></td>
|
||||||
|
<td data-label="Name">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<a x-id="name" target="_blank" class="hover:underline" rel="noopener noreferrer nofollow" href=""></a>
|
||||||
|
<small x-id="quantity" class="ml-auto"></small>
|
||||||
|
</div>
|
||||||
|
<table class="priceinfo hidden" aria-hidden="true">
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td data-label="Preis">
|
||||||
|
<span x-id="price"></span>
|
||||||
|
<span x-id="percentageChange"></span>
|
||||||
|
<span x-id="numPrices"></span>
|
||||||
|
<span class="chevron">▼</span>
|
||||||
|
</td>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const items = model.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("items-list", ItemsList);
|
|
@ -3,16 +3,99 @@ class View extends HTMLElement {
|
||||||
super();
|
super();
|
||||||
this._model = null;
|
this._model = null;
|
||||||
this._listener = () => this.render();
|
this._listener = () => this.render();
|
||||||
|
this._disableChangeEvent = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get elements() {
|
||||||
|
const elements = this.querySelectorAll("[x-id]");
|
||||||
|
const result = {};
|
||||||
|
elements.forEach((element) => (result[element.getAttribute("x-id")] = element));
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
set model(model) {
|
set model(model) {
|
||||||
if (this._model) this._model.removeListener(this._listener);
|
if (this._model) this._model.removeListener(this._listener);
|
||||||
this._model = model;
|
this._model = model;
|
||||||
this._model.addListener(this._listener);
|
this._model.addListener(this._listener);
|
||||||
this.render(model);
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
get model() {
|
||||||
|
return this._model;
|
||||||
|
}
|
||||||
|
|
||||||
|
get state() {
|
||||||
|
const elements = this.elements;
|
||||||
|
const properties = ["checked", "value"];
|
||||||
|
const state = {};
|
||||||
|
for (const key of Object.keys(elements)) {
|
||||||
|
const element = elements[key];
|
||||||
|
if (!element.hasAttribute("x-state")) continue;
|
||||||
|
const elementState = {};
|
||||||
|
for (const property of properties) {
|
||||||
|
if (property in element) {
|
||||||
|
elementState[property] = element[property];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state[key] = elementState;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
set state(state) {
|
||||||
|
const elements = this.elements;
|
||||||
|
this._disableChangeEvent = true;
|
||||||
|
for (const key of Object.keys(elements)) {
|
||||||
|
const elementState = state[key];
|
||||||
|
if (elementState) {
|
||||||
|
const element = elements[key];
|
||||||
|
for (const property in elementState) {
|
||||||
|
element[property] = elementState[property];
|
||||||
|
if (element.localName === "input" && element.getAttribute("type") === "radio") {
|
||||||
|
const changeEvent = new CustomEvent("change", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
element.dispatchEvent(changeEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._disableChangeEvent = false;
|
||||||
|
this.fireChangeEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {}
|
render() {}
|
||||||
|
|
||||||
|
setupEventHandlers() {
|
||||||
|
const handler = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.fireChangeEvent();
|
||||||
|
};
|
||||||
|
|
||||||
|
const elements = this.elements;
|
||||||
|
for (const key of Object.keys(elements)) {
|
||||||
|
const element = elements[key];
|
||||||
|
if (element.hasAttribute("x-change")) {
|
||||||
|
element.addEventListener("change", handler);
|
||||||
|
}
|
||||||
|
if (element.hasAttribute("x-click")) {
|
||||||
|
element.addEventListener("click", handler);
|
||||||
|
}
|
||||||
|
if (element.hasAttribute("x-input")) {
|
||||||
|
element.addEventListener("click", handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fireChangeEvent() {
|
||||||
|
if (this._disableChangeEvent) return;
|
||||||
|
const event = new CustomEvent("change", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
this.dispatchEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.View = View;
|
exports.View = View;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user