feat: Geschenk- und Honigwissen-Hinweise auf der Produktseite, Umlaut-Suche fixen

- 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.
This commit is contained in:
Nick Szaunig committed 2026-09-06 03:07:24 +02:00
1 parent de0f6ee249
commit a579ebabeb
7 files changed
+114 -6

No files matched your search

+6 -5
View File
@@ -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);
});
}
+46
View File
@@ -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);
});
});
+29
View File
@@ -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));
}