From a579ebabebdf864240d4b5bf49732c54fd610044 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 6 Sep 2026 03:07:24 +0200 Subject: [PATCH] feat: Geschenk- und Honigwissen-Hinweise auf der Produktseite, Umlaut-Suche fixen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Produktdetailseite: dezenter Hinweis direkt bei der Kaufentscheidung, dass beim Checkout eine persoenliche Geschenknachricht hinterlegt werden kann (Feature existierte bereits, war aber nur im Checkout-Formular selbst sichtbar — zu spaet, um Mengen-/Produktwahl noch zu beeinflussen) - Produktdetailseite (Honig): kontextuelle Links zu passenden Honigwissen-Artikeln (Kristallisation immer, Fruehtracht/Sommertracht wenn aus Harvest.type erkennbar) direkt bei den Produktdetails statt einer isolierten, von der Kaufseite aus unverlinkten Wissenssektion - Suche (Shop-Uebersicht + Live-Suchvorschlaege) gleicht jetzt deutsche Umlaute/scharfes S gegen ihre ASCII-Transliteration ab (z.B. "Bluete" findet "Bluete"-Produkte, "kraeftig" findet "kräftig") — vorher lieferte eine Suche in der jeweils anderen Schreibweise keine Treffer, obwohl der Katalog durchgaengig echte Umlaute verwendet; Logik in neuer, reiner lib/search-match.ts mit eigenen Tests ausgelagert - Bewusst NICHT angefasst: Newsletter-Rabattanreiz (fehlt, aber Prozentsatz/ Mechanik ist eine Owner-Entscheidung) und Trust-Badges am Kaufabschluss (bereits vorhanden auf Produktseite/Warenkorb/Checkout) Verifikation: pnpm exec tsc --noEmit clean, pnpm test 151/151 (15 Dateien, +9 neue Tests fuer search-match), pnpm build erfolgreich. --- src/app/honig/[slug]/page.tsx | 28 ++++++++++++++++++++- src/i18n/dictionaries/de.ts | 2 ++ src/i18n/dictionaries/en.ts | 2 ++ src/i18n/dictionary-type.ts | 2 ++ src/lib/data.ts | 11 +++++---- src/lib/search-match.test.ts | 46 +++++++++++++++++++++++++++++++++++ src/lib/search-match.ts | 29 ++++++++++++++++++++++ 7 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 src/lib/search-match.test.ts create mode 100644 src/lib/search-match.ts 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)); +}