diff --git a/functions/api/bestseller.js b/functions/api/bestseller.js new file mode 100644 index 0000000..ce8c1c4 --- /dev/null +++ b/functions/api/bestseller.js @@ -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: [] }); + } +} diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 085e9bc..57ef8c1 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -48,7 +48,7 @@ export const ui = { shop: { 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} €`, - 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", 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.", @@ -366,7 +366,7 @@ export const ui = { shop: { 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}`, - 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", 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.", @@ -684,7 +684,7 @@ export const ui = { shop: { 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} €`, - 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", 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.", @@ -1002,7 +1002,7 @@ export const ui = { shop: { 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} €`, - 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", 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.", diff --git a/src/pages/ch/shop/index.astro b/src/pages/ch/shop/index.astro index 1765bf6..7a11ac4 100644 --- a/src/pages/ch/shop/index.astro +++ b/src/pages/ch/shop/index.astro @@ -60,6 +60,14 @@ const t = useTranslations(lang); +
+ {/* 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. */} + + +
{t.shop.minRatingLabel}
@@ -133,6 +141,7 @@ const t = useTranslations(lang); const preisRange = document.getElementById("f-preis"); const preisValue = document.getElementById("f-preis-value"); const verfuegbarChk = document.getElementById("f-verfuegbar"); + const bestsellerChk = document.getElementById("f-bestseller"); const bewertungSel = document.getElementById("f-bewertung"); const sortSel = document.getElementById("sort"); const resultCount = document.getElementById("result-count"); @@ -143,10 +152,16 @@ const t = useTranslations(lang); 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() { const kat = kategorieSel.value; const maxPreis = Number(preisRange.value); const nurVerfuegbar = verfuegbarChk.checked; + const nurBestseller = bestsellerChk.checked; const minBewertung = Number(bewertungSel.value); preisValue.textContent = formatPrice(maxPreis); @@ -159,8 +174,9 @@ const t = useTranslations(lang); : slot.dataset.kategorie === kat; const matchPreis = Number(slot.dataset.preis) <= maxPreis; const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0; + const matchBestseller = !nurBestseller || bestsellerSlugs.includes(slot.dataset.slug); 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"; if (show) visible++; }); @@ -180,7 +196,23 @@ const t = useTranslations(lang); 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
+
+ {/* 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. */} + + +
{t.shop.minRatingLabel}
@@ -133,6 +141,7 @@ const t = useTranslations(lang); const preisRange = document.getElementById("f-preis"); const preisValue = document.getElementById("f-preis-value"); const verfuegbarChk = document.getElementById("f-verfuegbar"); + const bestsellerChk = document.getElementById("f-bestseller"); const bewertungSel = document.getElementById("f-bewertung"); const sortSel = document.getElementById("sort"); const resultCount = document.getElementById("result-count"); @@ -143,10 +152,16 @@ const t = useTranslations(lang); 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() { const kat = kategorieSel.value; const maxPreis = Number(preisRange.value); const nurVerfuegbar = verfuegbarChk.checked; + const nurBestseller = bestsellerChk.checked; const minBewertung = Number(bewertungSel.value); preisValue.textContent = formatPrice(maxPreis); @@ -159,8 +174,9 @@ const t = useTranslations(lang); : slot.dataset.kategorie === kat; const matchPreis = Number(slot.dataset.preis) <= maxPreis; const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0; + const matchBestseller = !nurBestseller || bestsellerSlugs.includes(slot.dataset.slug); 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"; if (show) visible++; }); @@ -180,7 +196,23 @@ const t = useTranslations(lang); 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
+
+ {/* 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. */} + + +
{t.shop.minRatingLabel}
@@ -133,6 +141,7 @@ const t = useTranslations(lang); const preisRange = document.getElementById("f-preis"); const preisValue = document.getElementById("f-preis-value"); const verfuegbarChk = document.getElementById("f-verfuegbar"); + const bestsellerChk = document.getElementById("f-bestseller"); const bewertungSel = document.getElementById("f-bewertung"); const sortSel = document.getElementById("sort"); const resultCount = document.getElementById("result-count"); @@ -143,10 +152,16 @@ const t = useTranslations(lang); 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() { const kat = kategorieSel.value; const maxPreis = Number(preisRange.value); const nurVerfuegbar = verfuegbarChk.checked; + const nurBestseller = bestsellerChk.checked; const minBewertung = Number(bewertungSel.value); preisValue.textContent = formatPrice(maxPreis); @@ -159,8 +174,9 @@ const t = useTranslations(lang); : slot.dataset.kategorie === kat; const matchPreis = Number(slot.dataset.preis) <= maxPreis; const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0; + const matchBestseller = !nurBestseller || bestsellerSlugs.includes(slot.dataset.slug); 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"; if (show) visible++; }); @@ -180,7 +196,23 @@ const t = useTranslations(lang); 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
+
+ {/* 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. */} + + +
{t.shop.minRatingLabel} {/* Eigenes Dropdown statt eines nativen -Felder): Der Browser kann in seiner eigenen // Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier