Shop-Filter: echte, automatisch berechnete Bestseller (Top 6) anzeigbar
Neues Kästchen "🔥 Nur Bestseller (Top 6)" im Shop-Filter (alle 4 Sprachen) — zeigt beim Anhaken
genau die 6 Produkte mit den meisten echten Verkäufen. Keine manuelle Pflege nötig: neuer
öffentlicher Endpunkt GET /api/bestseller berechnet die Rangliste live per SQL-Aggregation aus
den echten Bestelldaten (order_items JOIN orders, GROUP BY Produkt-Slug, SUM der Menge), NICHT
die manuell im Admin-Bereich setzbare "Bestseller"-Auszeichnung.
Zählweise: stornierte und noch nicht bezahlte (zahlungOffen) Bestellungen zählen nicht mit (sonst
würden Fehlbestellungen/nie eingegangene Zahlungen die Rangliste verfälschen), ebenso wenig
kostenlose Positionen wie der Abo-Teddy (kein echtes Kaufinteresse). Liefert ausschließlich
Produkt-Slugs zurück, keinerlei Bestell-/Kundendaten — bleiben weiterhin nur in /api/verwaltung/*.
Verifiziert: SQL-Aggregation mit Testdaten geprüft (korrekte Rangfolge, korrekter Ausschluss von
storniert/zahlungOffen/gratis, LIMIT 6 exakt). Frontend-Verdrahtung (Checkbox -> fetch -> Filter)
per echtem Browsertest mit vorgetäuschter API-Antwort bestätigt (checken zeigt genau die
vorgegebenen Produkte, abwählen zeigt wieder alle).
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
GET /api/bestseller — liefert die Slugs der 6 echten meistverkauften Produkte, automatisch aus
|
||||||
|
den echten Bestelldaten berechnet (04.08.2026, auf ausdrücklichen Wunsch: "die Seite soll von
|
||||||
|
alleine wissen, welche Sachen am meisten verkauft werden").
|
||||||
|
|
||||||
|
BEWUSST öffentlich (kein Zugangscode-Sonderfall in functions/_middleware.js nötig): Diese Route
|
||||||
|
verhält sich genau wie /api/orders (Bestellung anlegen) — während der Vorschau-Phase greift die
|
||||||
|
normale Zugangsschranke wie bei jeder anderen /api/*-Route auch (nur qciga/VanVan kommen ran),
|
||||||
|
sobald der Shop später per SITE_PUBLIC=true live geht, wird sie automatisch mit-öffentlich,
|
||||||
|
ohne dass hier etwas geändert werden muss. Liefert NUR eine Liste von Produkt-Slugs zurück —
|
||||||
|
keinerlei Bestell-/Kundendaten (Namen, E-Mails, Adressen, Beträge), die bleiben ausschließlich
|
||||||
|
in /api/verwaltung/* (fest hinter der doppelten Schranke).
|
||||||
|
|
||||||
|
Zählweise (bewusst dieselbe Grundlage wie die "Bestseller je Kategorie"-Auswertung in
|
||||||
|
/verwaltung/, siehe src/pages/verwaltung/index.astro):
|
||||||
|
- Storniert und noch nicht bezahlte (zahlungOffen) Bestellungen zählen NICHT mit — sonst
|
||||||
|
würden Fehlbestellungen/nie bezahlte Überweisungen die Rangliste verfälschen.
|
||||||
|
- Kostenlose Positionen (gratis=1, z.B. der Teddy aus dem Abo) zählen NICHT mit — das war kein
|
||||||
|
echtes Kaufinteresse, sondern eine Prämie, würde sonst die Rangliste verzerren.
|
||||||
|
- Bei Gleichstand entscheidet der Produkt-Slug alphabetisch (rein für ein stabiles, immer
|
||||||
|
gleiches Ergebnis bei jedem Aufruf, keine fachliche Bedeutung). */
|
||||||
|
|
||||||
|
import { json } from "../_shared/http.js";
|
||||||
|
|
||||||
|
export async function onRequestGet(context) {
|
||||||
|
const { env } = context;
|
||||||
|
|
||||||
|
// Kein DB-Binding (z.B. sehr früher lokaler Dev-Stand ohne D1) oder noch gar keine Bestellungen
|
||||||
|
// -> einfach eine leere Liste, kein Fehler. Das Bestseller-Kästchen im Shop-Filter zeigt dann
|
||||||
|
// schlicht "keine Produkte gefunden", genau wie bei jeder anderen Filterkombination ohne Treffer.
|
||||||
|
if (!env.DB) {
|
||||||
|
return json(200, { ok: true, slugs: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results } = await env.DB.prepare(
|
||||||
|
`SELECT oi.produkt_slug AS slug, SUM(oi.menge) AS verkauft
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN orders o ON o.id = oi.order_id
|
||||||
|
WHERE o.status NOT IN ('storniert', 'zahlungOffen') AND oi.gratis = 0
|
||||||
|
GROUP BY oi.produkt_slug
|
||||||
|
ORDER BY verkauft DESC, slug ASC
|
||||||
|
LIMIT 6`
|
||||||
|
).all();
|
||||||
|
return json(200, { ok: true, slugs: results.map((r) => r.slug) });
|
||||||
|
} catch {
|
||||||
|
// Lieber still eine leere Liste als einen Fehler an den Shop-Filter durchreichen — eine
|
||||||
|
// kaputte Bestseller-Berechnung soll nie den ganzen Filterbereich lahmlegen.
|
||||||
|
return json(200, { ok: true, slugs: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -48,7 +48,7 @@ export const ui = {
|
|||||||
shop: {
|
shop: {
|
||||||
title: "Alle Produkte", lead: "Stöbere durch alle handgemachten Stücke – filtere und sortiere nach deinen Wünschen.",
|
title: "Alle Produkte", lead: "Stöbere durch alle handgemachten Stücke – filtere und sortiere nach deinen Wünschen.",
|
||||||
filters: "Filter", allCategories: "Alle Kategorien", priceUpTo: "Preis bis", priceUpToValue: (v: number) => `bis ${v} €`,
|
filters: "Filter", allCategories: "Alle Kategorien", priceUpTo: "Preis bis", priceUpToValue: (v: number) => `bis ${v} €`,
|
||||||
onlyAvailable: "Nur verfügbare Artikel", filterNote: "Material- und Farbfilter folgen, sobald mehr Produkte im Sortiment sind.",
|
onlyAvailable: "Nur verfügbare Artikel", onlyBestseller: "🔥 Nur Bestseller (Top 6)", filterNote: "Material- und Farbfilter folgen, sobald mehr Produkte im Sortiment sind.",
|
||||||
sortNew: "Neu", sortBestseller: "Bestseller", sortPriceAsc: "Preis aufsteigend", sortPriceDesc: "Preis absteigend", sortRating: "Beste Bewertung",
|
sortNew: "Neu", sortBestseller: "Bestseller", sortPriceAsc: "Preis aufsteigend", sortPriceDesc: "Preis absteigend", sortRating: "Beste Bewertung",
|
||||||
resultCount: (n: number) => `${n} Produkt${n === 1 ? "" : "e"}`, noResults: "Keine Produkte gefunden – bitte Filter anpassen.",
|
resultCount: (n: number) => `${n} Produkt${n === 1 ? "" : "e"}`, noResults: "Keine Produkte gefunden – bitte Filter anpassen.",
|
||||||
breadcrumbShop: "Shop", categoryEmpty: "In dieser Kategorie sind aktuell noch keine Produkte hinterlegt – schau bald wieder vorbei.",
|
breadcrumbShop: "Shop", categoryEmpty: "In dieser Kategorie sind aktuell noch keine Produkte hinterlegt – schau bald wieder vorbei.",
|
||||||
@@ -366,7 +366,7 @@ export const ui = {
|
|||||||
shop: {
|
shop: {
|
||||||
title: "All products", lead: "Browse all handmade pieces – filter and sort to your liking.",
|
title: "All products", lead: "Browse all handmade pieces – filter and sort to your liking.",
|
||||||
filters: "Filters", allCategories: "All categories", priceUpTo: "Price up to", priceUpToValue: (v: number) => `up to €${v}`,
|
filters: "Filters", allCategories: "All categories", priceUpTo: "Price up to", priceUpToValue: (v: number) => `up to €${v}`,
|
||||||
onlyAvailable: "In-stock items only", filterNote: "Material and color filters will follow once more products are in the shop.",
|
onlyAvailable: "In-stock items only", onlyBestseller: "🔥 Bestsellers only (Top 6)", filterNote: "Material and color filters will follow once more products are in the shop.",
|
||||||
sortNew: "New", sortBestseller: "Bestseller", sortPriceAsc: "Price: low to high", sortPriceDesc: "Price: high to low", sortRating: "Top rated",
|
sortNew: "New", sortBestseller: "Bestseller", sortPriceAsc: "Price: low to high", sortPriceDesc: "Price: high to low", sortRating: "Top rated",
|
||||||
resultCount: (n: number) => `${n} product${n === 1 ? "" : "s"}`, noResults: "No products found – please adjust your filters.",
|
resultCount: (n: number) => `${n} product${n === 1 ? "" : "s"}`, noResults: "No products found – please adjust your filters.",
|
||||||
breadcrumbShop: "Shop", categoryEmpty: "No products in this category yet – check back soon.",
|
breadcrumbShop: "Shop", categoryEmpty: "No products in this category yet – check back soon.",
|
||||||
@@ -684,7 +684,7 @@ export const ui = {
|
|||||||
shop: {
|
shop: {
|
||||||
title: "Alli Produkt", lead: "Lueg dr alli vo Hang gmachte Sächeli a – filtere und sortiere, wie's dir passt.",
|
title: "Alli Produkt", lead: "Lueg dr alli vo Hang gmachte Sächeli a – filtere und sortiere, wie's dir passt.",
|
||||||
filters: "Filter", allCategories: "Alli Kategorie", priceUpTo: "Pris bis", priceUpToValue: (v: number) => `bis ${v} €`,
|
filters: "Filter", allCategories: "Alli Kategorie", priceUpTo: "Pris bis", priceUpToValue: (v: number) => `bis ${v} €`,
|
||||||
onlyAvailable: "Nur wo grad a Lager isch", filterNote: "Material- und Farbfilter chöme, sobald meh Produkt im Sortiment sind.",
|
onlyAvailable: "Nur wo grad a Lager isch", onlyBestseller: "🔥 Nur Bestseller (Top 6)", filterNote: "Material- und Farbfilter chöme, sobald meh Produkt im Sortiment sind.",
|
||||||
sortNew: "Neu", sortBestseller: "Bestseller", sortPriceAsc: "Pris ufsteigend", sortPriceDesc: "Pris absteigend", sortRating: "Beschti Bewärtig",
|
sortNew: "Neu", sortBestseller: "Bestseller", sortPriceAsc: "Pris ufsteigend", sortPriceDesc: "Pris absteigend", sortRating: "Beschti Bewärtig",
|
||||||
resultCount: (n: number) => `${n} Produkt`, noResults: "Kei Produkt gfunde – bitte Filter apasse.",
|
resultCount: (n: number) => `${n} Produkt`, noResults: "Kei Produkt gfunde – bitte Filter apasse.",
|
||||||
breadcrumbShop: "Shop", categoryEmpty: "In däre Kategorie sind grad no kei Produkt hinterlegt – lueg spöter nomal verbi.",
|
breadcrumbShop: "Shop", categoryEmpty: "In däre Kategorie sind grad no kei Produkt hinterlegt – lueg spöter nomal verbi.",
|
||||||
@@ -1002,7 +1002,7 @@ export const ui = {
|
|||||||
shop: {
|
shop: {
|
||||||
title: "Tous les produits", lead: "Parcourez toutes les pièces faites main – filtrez et triez selon vos envies.",
|
title: "Tous les produits", lead: "Parcourez toutes les pièces faites main – filtrez et triez selon vos envies.",
|
||||||
filters: "Filtres", allCategories: "Toutes les catégories", priceUpTo: "Prix jusqu'à", priceUpToValue: (v: number) => `jusqu'à ${v} €`,
|
filters: "Filtres", allCategories: "Toutes les catégories", priceUpTo: "Prix jusqu'à", priceUpToValue: (v: number) => `jusqu'à ${v} €`,
|
||||||
onlyAvailable: "Articles disponibles uniquement", filterNote: "Les filtres matière et couleur arriveront dès que l'assortiment sera plus large.",
|
onlyAvailable: "Articles disponibles uniquement", onlyBestseller: "🔥 Meilleures ventes uniquement (Top 6)", filterNote: "Les filtres matière et couleur arriveront dès que l'assortiment sera plus large.",
|
||||||
sortNew: "Nouveautés", sortBestseller: "Best-sellers", sortPriceAsc: "Prix croissant", sortPriceDesc: "Prix décroissant", sortRating: "Mieux notés",
|
sortNew: "Nouveautés", sortBestseller: "Best-sellers", sortPriceAsc: "Prix croissant", sortPriceDesc: "Prix décroissant", sortRating: "Mieux notés",
|
||||||
resultCount: (n: number) => `${n} produit${n === 1 ? "" : "s"}`, noResults: "Aucun produit trouvé – veuillez ajuster les filtres.",
|
resultCount: (n: number) => `${n} produit${n === 1 ? "" : "s"}`, noResults: "Aucun produit trouvé – veuillez ajuster les filtres.",
|
||||||
breadcrumbShop: "Boutique", categoryEmpty: "Aucun produit dans cette catégorie pour le moment – repassez bientôt.",
|
breadcrumbShop: "Boutique", categoryEmpty: "Aucun produit dans cette catégorie pour le moment – repassez bientôt.",
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ const t = useTranslations(lang);
|
|||||||
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
||||||
<input id="f-verfuegbar" type="checkbox" />
|
<input id="f-verfuegbar" type="checkbox" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
{/* Echte, automatisch aus den Bestelldaten berechnete Bestseller (siehe
|
||||||
|
functions/api/bestseller.js) — NICHT die manuell im Admin-Bereich setzbare
|
||||||
|
"🔥 Bestseller"-Auszeichnung. Zeigt beim Anhaken immer genau die 6 Produkte mit den
|
||||||
|
meisten echten Verkäufen, automatisch aktuell bei jeder neuen Bestellung. */}
|
||||||
|
<label for="f-bestseller">{t.shop.onlyBestseller}</label>
|
||||||
|
<input id="f-bestseller" type="checkbox" />
|
||||||
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
|
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
|
||||||
<div class="cat-filter" id="rating-filter" data-dropdown>
|
<div class="cat-filter" id="rating-filter" data-dropdown>
|
||||||
@@ -133,6 +141,7 @@ const t = useTranslations(lang);
|
|||||||
const preisRange = document.getElementById("f-preis");
|
const preisRange = document.getElementById("f-preis");
|
||||||
const preisValue = document.getElementById("f-preis-value");
|
const preisValue = document.getElementById("f-preis-value");
|
||||||
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
||||||
|
const bestsellerChk = document.getElementById("f-bestseller");
|
||||||
const bewertungSel = document.getElementById("f-bewertung");
|
const bewertungSel = document.getElementById("f-bewertung");
|
||||||
const sortSel = document.getElementById("sort");
|
const sortSel = document.getElementById("sort");
|
||||||
const resultCount = document.getElementById("result-count");
|
const resultCount = document.getElementById("result-count");
|
||||||
@@ -143,10 +152,16 @@ const t = useTranslations(lang);
|
|||||||
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
|
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Echte, automatisch aus den Bestelldaten berechnete Top-6-Bestseller (siehe
|
||||||
|
// functions/api/bestseller.js) — leer, bis die Antwort da ist. Wird einmal beim Laden der
|
||||||
|
// Seite abgerufen (siehe ladeBestseller() weiter unten), nicht bei jedem Checkbox-Klick neu.
|
||||||
|
let bestsellerSlugs = [];
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
const kat = kategorieSel.value;
|
const kat = kategorieSel.value;
|
||||||
const maxPreis = Number(preisRange.value);
|
const maxPreis = Number(preisRange.value);
|
||||||
const nurVerfuegbar = verfuegbarChk.checked;
|
const nurVerfuegbar = verfuegbarChk.checked;
|
||||||
|
const nurBestseller = bestsellerChk.checked;
|
||||||
const minBewertung = Number(bewertungSel.value);
|
const minBewertung = Number(bewertungSel.value);
|
||||||
preisValue.textContent = formatPrice(maxPreis);
|
preisValue.textContent = formatPrice(maxPreis);
|
||||||
|
|
||||||
@@ -159,8 +174,9 @@ const t = useTranslations(lang);
|
|||||||
: slot.dataset.kategorie === kat;
|
: slot.dataset.kategorie === kat;
|
||||||
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
||||||
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
||||||
|
const matchBestseller = !nurBestseller || bestsellerSlugs.includes(slot.dataset.slug);
|
||||||
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
||||||
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
|
const show = matchKat && matchPreis && matchVerfuegbar && matchBestseller && matchBewertung;
|
||||||
slot.style.display = show ? "" : "none";
|
slot.style.display = show ? "" : "none";
|
||||||
if (show) visible++;
|
if (show) visible++;
|
||||||
});
|
});
|
||||||
@@ -180,7 +196,23 @@ const t = useTranslations(lang);
|
|||||||
sorted.forEach((el) => grid.appendChild(el));
|
sorted.forEach((el) => grid.appendChild(el));
|
||||||
}
|
}
|
||||||
|
|
||||||
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
[preisRange, verfuegbarChk, bestsellerChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||||
|
|
||||||
|
// Lädt einmal beim Seitenaufruf die echten Top-6-Bestseller (siehe functions/api/bestseller.js,
|
||||||
|
// berechnet live aus den tatsächlichen Bestellungen — keine manuelle Pflege nötig). Schlägt der
|
||||||
|
// Abruf fehl (z.B. offline), bleibt die Liste einfach leer, statt die ganze Seite zu stören —
|
||||||
|
// das Kästchen zeigt dann schlicht "keine Produkte gefunden", bis es wieder klappt.
|
||||||
|
async function ladeBestseller() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bestseller");
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
bestsellerSlugs = data?.ok && Array.isArray(data.slugs) ? data.slugs : [];
|
||||||
|
} catch {
|
||||||
|
bestsellerSlugs = [];
|
||||||
|
}
|
||||||
|
if (bestsellerChk.checked) apply();
|
||||||
|
}
|
||||||
|
ladeBestseller();
|
||||||
|
|
||||||
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
|
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
|
||||||
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
|
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ const t = useTranslations(lang);
|
|||||||
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
||||||
<input id="f-verfuegbar" type="checkbox" />
|
<input id="f-verfuegbar" type="checkbox" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
{/* Echte, automatisch aus den Bestelldaten berechnete Bestseller (siehe
|
||||||
|
functions/api/bestseller.js) — NICHT die manuell im Admin-Bereich setzbare
|
||||||
|
"🔥 Bestseller"-Auszeichnung. Zeigt beim Anhaken immer genau die 6 Produkte mit den
|
||||||
|
meisten echten Verkäufen, automatisch aktuell bei jeder neuen Bestellung. */}
|
||||||
|
<label for="f-bestseller">{t.shop.onlyBestseller}</label>
|
||||||
|
<input id="f-bestseller" type="checkbox" />
|
||||||
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
|
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
|
||||||
<div class="cat-filter" id="rating-filter" data-dropdown>
|
<div class="cat-filter" id="rating-filter" data-dropdown>
|
||||||
@@ -133,6 +141,7 @@ const t = useTranslations(lang);
|
|||||||
const preisRange = document.getElementById("f-preis");
|
const preisRange = document.getElementById("f-preis");
|
||||||
const preisValue = document.getElementById("f-preis-value");
|
const preisValue = document.getElementById("f-preis-value");
|
||||||
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
||||||
|
const bestsellerChk = document.getElementById("f-bestseller");
|
||||||
const bewertungSel = document.getElementById("f-bewertung");
|
const bewertungSel = document.getElementById("f-bewertung");
|
||||||
const sortSel = document.getElementById("sort");
|
const sortSel = document.getElementById("sort");
|
||||||
const resultCount = document.getElementById("result-count");
|
const resultCount = document.getElementById("result-count");
|
||||||
@@ -143,10 +152,16 @@ const t = useTranslations(lang);
|
|||||||
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
|
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Echte, automatisch aus den Bestelldaten berechnete Top-6-Bestseller (siehe
|
||||||
|
// functions/api/bestseller.js) — leer, bis die Antwort da ist. Wird einmal beim Laden der
|
||||||
|
// Seite abgerufen (siehe ladeBestseller() weiter unten), nicht bei jedem Checkbox-Klick neu.
|
||||||
|
let bestsellerSlugs = [];
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
const kat = kategorieSel.value;
|
const kat = kategorieSel.value;
|
||||||
const maxPreis = Number(preisRange.value);
|
const maxPreis = Number(preisRange.value);
|
||||||
const nurVerfuegbar = verfuegbarChk.checked;
|
const nurVerfuegbar = verfuegbarChk.checked;
|
||||||
|
const nurBestseller = bestsellerChk.checked;
|
||||||
const minBewertung = Number(bewertungSel.value);
|
const minBewertung = Number(bewertungSel.value);
|
||||||
preisValue.textContent = formatPrice(maxPreis);
|
preisValue.textContent = formatPrice(maxPreis);
|
||||||
|
|
||||||
@@ -159,8 +174,9 @@ const t = useTranslations(lang);
|
|||||||
: slot.dataset.kategorie === kat;
|
: slot.dataset.kategorie === kat;
|
||||||
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
||||||
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
||||||
|
const matchBestseller = !nurBestseller || bestsellerSlugs.includes(slot.dataset.slug);
|
||||||
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
||||||
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
|
const show = matchKat && matchPreis && matchVerfuegbar && matchBestseller && matchBewertung;
|
||||||
slot.style.display = show ? "" : "none";
|
slot.style.display = show ? "" : "none";
|
||||||
if (show) visible++;
|
if (show) visible++;
|
||||||
});
|
});
|
||||||
@@ -180,7 +196,23 @@ const t = useTranslations(lang);
|
|||||||
sorted.forEach((el) => grid.appendChild(el));
|
sorted.forEach((el) => grid.appendChild(el));
|
||||||
}
|
}
|
||||||
|
|
||||||
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
[preisRange, verfuegbarChk, bestsellerChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||||
|
|
||||||
|
// Lädt einmal beim Seitenaufruf die echten Top-6-Bestseller (siehe functions/api/bestseller.js,
|
||||||
|
// berechnet live aus den tatsächlichen Bestellungen — keine manuelle Pflege nötig). Schlägt der
|
||||||
|
// Abruf fehl (z.B. offline), bleibt die Liste einfach leer, statt die ganze Seite zu stören —
|
||||||
|
// das Kästchen zeigt dann schlicht "keine Produkte gefunden", bis es wieder klappt.
|
||||||
|
async function ladeBestseller() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bestseller");
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
bestsellerSlugs = data?.ok && Array.isArray(data.slugs) ? data.slugs : [];
|
||||||
|
} catch {
|
||||||
|
bestsellerSlugs = [];
|
||||||
|
}
|
||||||
|
if (bestsellerChk.checked) apply();
|
||||||
|
}
|
||||||
|
ladeBestseller();
|
||||||
|
|
||||||
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
|
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
|
||||||
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
|
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ const t = useTranslations(lang);
|
|||||||
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
||||||
<input id="f-verfuegbar" type="checkbox" />
|
<input id="f-verfuegbar" type="checkbox" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
{/* Echte, automatisch aus den Bestelldaten berechnete Bestseller (siehe
|
||||||
|
functions/api/bestseller.js) — NICHT die manuell im Admin-Bereich setzbare
|
||||||
|
"🔥 Bestseller"-Auszeichnung. Zeigt beim Anhaken immer genau die 6 Produkte mit den
|
||||||
|
meisten echten Verkäufen, automatisch aktuell bei jeder neuen Bestellung. */}
|
||||||
|
<label for="f-bestseller">{t.shop.onlyBestseller}</label>
|
||||||
|
<input id="f-bestseller" type="checkbox" />
|
||||||
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
|
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
|
||||||
<div class="cat-filter" id="rating-filter" data-dropdown>
|
<div class="cat-filter" id="rating-filter" data-dropdown>
|
||||||
@@ -133,6 +141,7 @@ const t = useTranslations(lang);
|
|||||||
const preisRange = document.getElementById("f-preis");
|
const preisRange = document.getElementById("f-preis");
|
||||||
const preisValue = document.getElementById("f-preis-value");
|
const preisValue = document.getElementById("f-preis-value");
|
||||||
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
||||||
|
const bestsellerChk = document.getElementById("f-bestseller");
|
||||||
const bewertungSel = document.getElementById("f-bewertung");
|
const bewertungSel = document.getElementById("f-bewertung");
|
||||||
const sortSel = document.getElementById("sort");
|
const sortSel = document.getElementById("sort");
|
||||||
const resultCount = document.getElementById("result-count");
|
const resultCount = document.getElementById("result-count");
|
||||||
@@ -143,10 +152,16 @@ const t = useTranslations(lang);
|
|||||||
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
|
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Echte, automatisch aus den Bestelldaten berechnete Top-6-Bestseller (siehe
|
||||||
|
// functions/api/bestseller.js) — leer, bis die Antwort da ist. Wird einmal beim Laden der
|
||||||
|
// Seite abgerufen (siehe ladeBestseller() weiter unten), nicht bei jedem Checkbox-Klick neu.
|
||||||
|
let bestsellerSlugs = [];
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
const kat = kategorieSel.value;
|
const kat = kategorieSel.value;
|
||||||
const maxPreis = Number(preisRange.value);
|
const maxPreis = Number(preisRange.value);
|
||||||
const nurVerfuegbar = verfuegbarChk.checked;
|
const nurVerfuegbar = verfuegbarChk.checked;
|
||||||
|
const nurBestseller = bestsellerChk.checked;
|
||||||
const minBewertung = Number(bewertungSel.value);
|
const minBewertung = Number(bewertungSel.value);
|
||||||
preisValue.textContent = formatPrice(maxPreis);
|
preisValue.textContent = formatPrice(maxPreis);
|
||||||
|
|
||||||
@@ -159,8 +174,9 @@ const t = useTranslations(lang);
|
|||||||
: slot.dataset.kategorie === kat;
|
: slot.dataset.kategorie === kat;
|
||||||
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
||||||
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
||||||
|
const matchBestseller = !nurBestseller || bestsellerSlugs.includes(slot.dataset.slug);
|
||||||
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
||||||
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
|
const show = matchKat && matchPreis && matchVerfuegbar && matchBestseller && matchBewertung;
|
||||||
slot.style.display = show ? "" : "none";
|
slot.style.display = show ? "" : "none";
|
||||||
if (show) visible++;
|
if (show) visible++;
|
||||||
});
|
});
|
||||||
@@ -180,7 +196,23 @@ const t = useTranslations(lang);
|
|||||||
sorted.forEach((el) => grid.appendChild(el));
|
sorted.forEach((el) => grid.appendChild(el));
|
||||||
}
|
}
|
||||||
|
|
||||||
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
[preisRange, verfuegbarChk, bestsellerChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||||
|
|
||||||
|
// Lädt einmal beim Seitenaufruf die echten Top-6-Bestseller (siehe functions/api/bestseller.js,
|
||||||
|
// berechnet live aus den tatsächlichen Bestellungen — keine manuelle Pflege nötig). Schlägt der
|
||||||
|
// Abruf fehl (z.B. offline), bleibt die Liste einfach leer, statt die ganze Seite zu stören —
|
||||||
|
// das Kästchen zeigt dann schlicht "keine Produkte gefunden", bis es wieder klappt.
|
||||||
|
async function ladeBestseller() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bestseller");
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
bestsellerSlugs = data?.ok && Array.isArray(data.slugs) ? data.slugs : [];
|
||||||
|
} catch {
|
||||||
|
bestsellerSlugs = [];
|
||||||
|
}
|
||||||
|
if (bestsellerChk.checked) apply();
|
||||||
|
}
|
||||||
|
ladeBestseller();
|
||||||
|
|
||||||
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
|
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
|
||||||
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
|
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ const t = useTranslations(lang);
|
|||||||
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
||||||
<input id="f-verfuegbar" type="checkbox" />
|
<input id="f-verfuegbar" type="checkbox" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
{/* Echte, automatisch aus den Bestelldaten berechnete Bestseller (siehe
|
||||||
|
functions/api/bestseller.js) — NICHT die manuell im Admin-Bereich setzbare
|
||||||
|
"🔥 Bestseller"-Auszeichnung. Zeigt beim Anhaken immer genau die 6 Produkte mit den
|
||||||
|
meisten echten Verkäufen, automatisch aktuell bei jeder neuen Bestellung. */}
|
||||||
|
<label for="f-bestseller">{t.shop.onlyBestseller}</label>
|
||||||
|
<input id="f-bestseller" type="checkbox" />
|
||||||
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
|
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
|
||||||
{/* Eigenes Dropdown statt eines nativen <select>: In der Browser-eigenen Auswahlliste
|
{/* Eigenes Dropdown statt eines nativen <select>: In der Browser-eigenen Auswahlliste
|
||||||
@@ -142,6 +150,7 @@ const t = useTranslations(lang);
|
|||||||
const preisRange = document.getElementById("f-preis");
|
const preisRange = document.getElementById("f-preis");
|
||||||
const preisValue = document.getElementById("f-preis-value");
|
const preisValue = document.getElementById("f-preis-value");
|
||||||
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
||||||
|
const bestsellerChk = document.getElementById("f-bestseller");
|
||||||
const bewertungSel = document.getElementById("f-bewertung");
|
const bewertungSel = document.getElementById("f-bewertung");
|
||||||
const sortSel = document.getElementById("sort");
|
const sortSel = document.getElementById("sort");
|
||||||
const resultCount = document.getElementById("result-count");
|
const resultCount = document.getElementById("result-count");
|
||||||
@@ -152,10 +161,16 @@ const t = useTranslations(lang);
|
|||||||
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
|
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Echte, automatisch aus den Bestelldaten berechnete Top-6-Bestseller (siehe
|
||||||
|
// functions/api/bestseller.js) — leer, bis die Antwort da ist. Wird einmal beim Laden der
|
||||||
|
// Seite abgerufen (siehe ladeBestseller() weiter unten), nicht bei jedem Checkbox-Klick neu.
|
||||||
|
let bestsellerSlugs = [];
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
const kat = kategorieSel.value;
|
const kat = kategorieSel.value;
|
||||||
const maxPreis = Number(preisRange.value);
|
const maxPreis = Number(preisRange.value);
|
||||||
const nurVerfuegbar = verfuegbarChk.checked;
|
const nurVerfuegbar = verfuegbarChk.checked;
|
||||||
|
const nurBestseller = bestsellerChk.checked;
|
||||||
const minBewertung = Number(bewertungSel.value);
|
const minBewertung = Number(bewertungSel.value);
|
||||||
preisValue.textContent = formatPrice(maxPreis);
|
preisValue.textContent = formatPrice(maxPreis);
|
||||||
|
|
||||||
@@ -168,8 +183,9 @@ const t = useTranslations(lang);
|
|||||||
: slot.dataset.kategorie === kat;
|
: slot.dataset.kategorie === kat;
|
||||||
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
||||||
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
||||||
|
const matchBestseller = !nurBestseller || bestsellerSlugs.includes(slot.dataset.slug);
|
||||||
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
||||||
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
|
const show = matchKat && matchPreis && matchVerfuegbar && matchBestseller && matchBewertung;
|
||||||
slot.style.display = show ? "" : "none";
|
slot.style.display = show ? "" : "none";
|
||||||
if (show) visible++;
|
if (show) visible++;
|
||||||
});
|
});
|
||||||
@@ -189,7 +205,23 @@ const t = useTranslations(lang);
|
|||||||
sorted.forEach((el) => grid.appendChild(el));
|
sorted.forEach((el) => grid.appendChild(el));
|
||||||
}
|
}
|
||||||
|
|
||||||
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
[preisRange, verfuegbarChk, bestsellerChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||||
|
|
||||||
|
// Lädt einmal beim Seitenaufruf die echten Top-6-Bestseller (siehe functions/api/bestseller.js,
|
||||||
|
// berechnet live aus den tatsächlichen Bestellungen — keine manuelle Pflege nötig). Schlägt der
|
||||||
|
// Abruf fehl (z.B. offline), bleibt die Liste einfach leer, statt die ganze Seite zu stören —
|
||||||
|
// das Kästchen zeigt dann schlicht "keine Produkte gefunden", bis es wieder klappt.
|
||||||
|
async function ladeBestseller() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bestseller");
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
bestsellerSlugs = data?.ok && Array.isArray(data.slugs) ? data.slugs : [];
|
||||||
|
} catch {
|
||||||
|
bestsellerSlugs = [];
|
||||||
|
}
|
||||||
|
if (bestsellerChk.checked) apply();
|
||||||
|
}
|
||||||
|
ladeBestseller();
|
||||||
|
|
||||||
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
|
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
|
||||||
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
|
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
|
||||||
|
|||||||
Reference in New Issue
Block a user