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:
1 parent
de0f6ee249
commit
a579ebabeb
7 files changed
+114
-6
No files matched your search
@@ -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)
|
||||
)}
|
||||
</Link>
|
||||
<p className="mt-2 text-xs text-ink/65">{t.productPage.giftHint}</p>
|
||||
|
||||
{isHoney ? (
|
||||
<div className="mt-8 space-y-3">
|
||||
@@ -286,10 +298,24 @@ export default async function ProductDetailPage(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHoney ? (
|
||||
<div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||
<span className="text-ink/65">{t.productPage.learnMoreLabel}</span>
|
||||
<Link href="/wissen/warum-kristallisiert-honig" className="text-forest underline">
|
||||
{t.wissenArticles["warum-kristallisiert-honig"].title}
|
||||
</Link>
|
||||
{harvestWissenSlug ? (
|
||||
<Link href={`/wissen/${harvestWissenSlug}`} className="text-forest underline">
|
||||
{t.wissenArticles[harvestWissenSlug].title}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isHoney && product.batch ? (
|
||||
<Link
|
||||
href={`/charge/${product.batch.code}`}
|
||||
className="mt-6 inline-block text-sm text-forest underline"
|
||||
className="mt-3 inline-block text-sm text-forest underline"
|
||||
>
|
||||
{t.productPage.viewBatchOrigin}
|
||||
</Link>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1046,6 +1046,8 @@ export interface Dictionary {
|
||||
infantWarningText: string;
|
||||
nonHoneyOriginText: string;
|
||||
viewBatchOrigin: string;
|
||||
giftHint: string;
|
||||
learnMoreLabel: string;
|
||||
reviewsHeading: string;
|
||||
noReviewsForProduct: string;
|
||||
loginToReview: string;
|
||||
|
||||
+6
-5
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user