diff --git a/src/app/honig/[slug]/page.tsx b/src/app/honig/[slug]/page.tsx
index 34efe2d..0eb4f35 100644
--- a/src/app/honig/[slug]/page.tsx
+++ b/src/app/honig/[slug]/page.tsx
@@ -22,6 +22,7 @@ import { resolveShippingCostCents } from "@/lib/shipping-calc";
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
+import type { WissenSlug } from "@/i18n/dictionary-type";
// Produktdaten (Preis, Bestand, Charge) aendern sich laufend — Rendering pro
// Request statt statischem Build entkoppelt zudem den Docker-Build-Schritt
@@ -53,6 +54,16 @@ export default async function ProductDetailPage(
if (!product) notFound();
const isHoney = product.category === "HONIG" && product.harvest && product.batch;
+ // Verlinkt die Ernteart auf den passenden Honigwissen-Artikel, sofern der
+ // Ernte-Typ (freier Admin-Text, siehe Harvest.type) eindeutig als Frueh-
+ // oder Sommertracht erkennbar ist — sonst einfach kein zusaetzlicher Link,
+ // statt eine falsche Zuordnung zu raten.
+ const harvestTypeLower = product.harvest?.type.toLowerCase() ?? "";
+ const harvestWissenSlug: WissenSlug | null = harvestTypeLower.includes("früh")
+ ? "was-bedeutet-fruehtracht"
+ : harvestTypeLower.includes("sommer")
+ ? "was-bedeutet-sommertracht"
+ : null;
const stock = product.inventory?.quantity ?? 0;
const inStock = stock > 0;
const lowStock = inStock && stock <= 5;
@@ -183,6 +194,7 @@ export default async function ProductDetailPage(
baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale)
)}
+
{t.productPage.giftHint}
{isHoney ? (
@@ -286,10 +298,24 @@ export default async function ProductDetailPage(
)}
+ {isHoney ? (
+
+ {t.productPage.learnMoreLabel}
+
+ {t.wissenArticles["warum-kristallisiert-honig"].title}
+
+ {harvestWissenSlug ? (
+
+ {t.wissenArticles[harvestWissenSlug].title}
+
+ ) : null}
+
+ ) : null}
+
{isHoney && product.batch ? (
{t.productPage.viewBatchOrigin}
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts
index f07d73b..938c65b 100644
--- a/src/i18n/dictionaries/de.ts
+++ b/src/i18n/dictionaries/de.ts
@@ -1374,6 +1374,8 @@ export const de: Dictionary = {
infantWarningText: "Aufgrund des natürlichen Sporengehalts ist Honig nicht für Säuglinge unter 12 Monaten geeignet.",
nonHoneyOriginText: "Aus eigener Imkerei und Produktion in Altenmünster.",
viewBatchOrigin: "Chargen-Herkunft dieses Glases ansehen →",
+ giftHint: "Verschenkst du dieses Glas? Beim Checkout kannst du eine persönliche Nachricht beilegen lassen.",
+ learnMoreLabel: "Mehr erfahren:",
reviewsHeading: "Bewertungen",
noReviewsForProduct: "Für dieses Produkt gibt es noch keine Bewertungen.",
loginToReview: "um dieses Produkt zu bewerten.",
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index 4b0120b..eac195f 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -1374,6 +1374,8 @@ export const en: Dictionary = {
infantWarningText: "Due to its natural spore content, honey is not suitable for infants under 12 months.",
nonHoneyOriginText: "From our own apiary and production in Altenmünster.",
viewBatchOrigin: "View this jar's batch origin →",
+ giftHint: "Giving this as a gift? You can add a personal message at checkout.",
+ learnMoreLabel: "Learn more:",
reviewsHeading: "Reviews",
noReviewsForProduct: "There are no reviews for this product yet.",
loginToReview: "to review this product.",
diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts
index ae67061..3655514 100644
--- a/src/i18n/dictionary-type.ts
+++ b/src/i18n/dictionary-type.ts
@@ -1046,6 +1046,8 @@ export interface Dictionary {
infantWarningText: string;
nonHoneyOriginText: string;
viewBatchOrigin: string;
+ giftHint: string;
+ learnMoreLabel: string;
reviewsHeading: string;
noReviewsForProduct: string;
loginToReview: string;
diff --git a/src/lib/data.ts b/src/lib/data.ts
index d67e0da..60d2c34 100644
--- a/src/lib/data.ts
+++ b/src/lib/data.ts
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
+import { matchesSearchQuery } from "@/lib/search-match";
import type { Product, Harvest, Batch, ProductCategory, Prisma } from "@/generated/prisma/client";
// tasteTags/images sind im Schema als Json modelliert (MySQL-Kompatibilitaet,
@@ -70,12 +71,12 @@ export async function getActiveProducts(
// ueberschaubaren Katalogsgroesse wird deshalb hier statt in der DB-Query
// gefiltert. Das behebt zugleich, dass mehrwortige, gemischt geschriebene
// Tags wie "reines Bienenwachs" bei case-insensitiver Suche nie trafen.
- const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
+ // matchesSearchQuery gleicht zusaetzlich Umlaute/ASCII-Transliteration ab
+ // (z.B. "Bluete" <-> "Blüte"), da Produktnamen/-tags durchgaengig echte
+ // Umlaute verwenden, aber nicht jede Kundin/jeder Kunde sie eintippt.
return products.filter((product) => {
- const haystack = [product.name, product.description, ...product.tasteTags]
- .join(" ")
- .toLowerCase();
- return words.every((word) => haystack.includes(word));
+ const haystack = [product.name, product.description, ...product.tasteTags].join(" ");
+ return matchesSearchQuery(haystack, trimmed);
});
}
diff --git a/src/lib/search-match.test.ts b/src/lib/search-match.test.ts
new file mode 100644
index 0000000..363a8ba
--- /dev/null
+++ b/src/lib/search-match.test.ts
@@ -0,0 +1,46 @@
+import { describe, test, expect } from "vitest";
+import { normalizeSearchText, matchesSearchQuery } from "./search-match";
+
+describe("normalizeSearchText", () => {
+ test("lowercases and transliterates umlauts to their ASCII equivalent", () => {
+ expect(normalizeSearchText("Frühtracht")).toBe("fruehtracht");
+ expect(normalizeSearchText("kräftig")).toBe("kraeftig");
+ expect(normalizeSearchText("Altenmünster")).toBe("altenmuenster");
+ expect(normalizeSearchText("rußarm")).toBe("russarm");
+ });
+
+ test("leaves already-ASCII text unchanged apart from casing", () => {
+ expect(normalizeSearchText("Honig")).toBe("honig");
+ });
+});
+
+describe("matchesSearchQuery", () => {
+ test("matches when the query uses real umlauts and the haystack does too", () => {
+ expect(matchesSearchQuery("Frühtracht 2026", "Früh")).toBe(true);
+ });
+
+ test("matches when the query uses the ASCII transliteration of an umlaut", () => {
+ expect(matchesSearchQuery("Frühtracht 2026", "fruehtracht")).toBe(true);
+ });
+
+ test("matches when the haystack uses the ASCII transliteration and the query the real umlaut", () => {
+ expect(matchesSearchQuery("kraeftiger Sommerhonig", "kräftig")).toBe(true);
+ });
+
+ test("is case-insensitive", () => {
+ expect(matchesSearchQuery("Blütenhonig", "BLUETE")).toBe(true);
+ });
+
+ test("requires every word of a multi-word query to match", () => {
+ expect(matchesSearchQuery("kräftiger Sommerhonig aus Altenmünster", "kraeftig honig")).toBe(true);
+ expect(matchesSearchQuery("kräftiger Sommerhonig aus Altenmünster", "kraeftig kerze")).toBe(false);
+ });
+
+ test("returns false when no word matches", () => {
+ expect(matchesSearchQuery("Bienenwachskerze", "propolis")).toBe(false);
+ });
+
+ test("returns true for an empty query (no filter applied)", () => {
+ expect(matchesSearchQuery("Bienenwachskerze", "")).toBe(true);
+ });
+});
diff --git a/src/lib/search-match.ts b/src/lib/search-match.ts
new file mode 100644
index 0000000..ca44990
--- /dev/null
+++ b/src/lib/search-match.ts
@@ -0,0 +1,29 @@
+/**
+ * Normalisiert deutsche Umlaute/scharfes S fuer die Produktsuche: "ü", "ö",
+ * "ä" und "ß" werden auf ihre gaengige ASCII-Transliteration ("ue", "oe",
+ * "ae", "ss") abgebildet. Ohne das liefert eine Suche nach "Bluete" keine
+ * Treffer fuer "Blüte" und umgekehrt — viele Kundinnen und Kunden tippen aus
+ * Gewohnheit oder auf einer nicht-deutschen Tastatur die ASCII-Schreibweise,
+ * waehrend Produktnamen/-tags im Katalog durchgaengig echte Umlaute nutzen
+ * (z.B. "Frühtracht", "kräftig", "Altenmünster").
+ */
+export function normalizeSearchText(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/ä/g, "ae")
+ .replace(/ö/g, "oe")
+ .replace(/ü/g, "ue")
+ .replace(/ß/g, "ss");
+}
+
+/**
+ * Mehrwortige Teilstring-Suche: jedes Wort aus `query` muss irgendwo in
+ * `haystack` vorkommen (Reihenfolge egal), umlaut-unabhaengig in beide
+ * Richtungen (echter Umlaut <-> ASCII-Transliteration).
+ */
+export function matchesSearchQuery(haystack: string, query: string): boolean {
+ const words = normalizeSearchText(query).split(/\s+/).filter(Boolean);
+ if (words.length === 0) return true;
+ const normalizedHaystack = normalizeSearchText(haystack);
+ return words.every((word) => normalizedHaystack.includes(word));
+}