feat: Produktseiten liefern Google Versandkosten, Ruecknahme und Verkaeufer

Das Product-Schema der Produktseite enthielt Preis, Verfuegbarkeit und
Bewertung, aber keine der Angaben, die Google fuer ein vollstaendiges
Produkt-Suchergebnis erwartet. Praktische Folge: der Preis wurde nach einiger
Zeit als moeglicherweise veraltet eingestuft und ausgeblendet, und
Versandkosten schaetzte Google selbst — bei einem Shop, dessen Versandstaffel
ohnehin auf der Seite steht.

Neu im Offer-Block:

- shippingDetails mit der tatsaechlichen Staffel fuer ein Glas, also demselben
  "ab"-Preis, der auf der Seite ausgewiesen ist. Das Liefergebiet kommt aus
  der Admin-Einstellung (Deutschland oder EU), nicht aus einer festen Liste.
- hasMerchantReturnPolicy mit dem gesetzlichen Widerrufsrecht: vierzehn Tage
  ab Erhalt, Rueckgabe per Versand. returnFees fehlt bewusst — wer die Kosten
  der Ruecksendung traegt, sagt die Widerrufsbelehrung nicht, und eine
  Annahme an dieser Stelle waere eine Zusage gegenueber Google, die der Shop
  nirgends macht.
- priceValidUntil, itemCondition sowie seller als Verweis auf das
  Store-Schema aus dem Root-Layout, statt Name und Anschrift ein zweites Mal
  auszuschreiben.
- weight als Fuellmenge in Gramm — der Wert, ueber den Kaeufer
  Lebensmittelangebote vergleichen.

Ausserdem ein eigener openGraph-Block. Ohne ihn erbte jede Produktseite den
Block aus dem Root-Layout unveraendert: ein geteilter Honig-Link zeigte in
WhatsApp, Facebook oder Slack Titel und Text der STARTSEITE, egal welches
Produkt verlinkt war. Das Feld "images" bleibt bewusst leer, damit die
gestaltete Vorschaukarte aus opengraph-image.tsx erhalten bleibt — ein
Eintrag dort haette sie durch das rohe Produktfoto ersetzt.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-20 03:06:02 +02:00
1 parent a8990a872a
commit 73c070f11e
1 file changed
+67 -2
+67 -2
View File
@@ -18,7 +18,8 @@ import { getCompanyInfo } from "@/lib/company-info";
import { getCurrentCustomer } from "@/lib/auth";
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
import { isProductWishlisted } from "@/lib/wishlist";
import { getShippingTiers } from "@/lib/shipping";
import { getShippingTiers, getShippingRegion } from "@/lib/shipping";
import { allowedCountryCodes } from "@/lib/shipping-region";
import { resolveShippingCostCents } from "@/lib/shipping-calc";
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
import { TurnstileField } from "@/components/turnstile-widget";
@@ -38,10 +39,29 @@ export async function generateMetadata(
const product = await getProductBySlug(slug);
if (!product) return {};
const description = `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`;
return {
title: product.name,
description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`,
description,
alternates: { canonical: `/honig/${slug}` },
// Ohne diesen Block erbt die Produktseite den openGraph-Block aus dem
// Root-Layout unveraendert — jeder geteilte Produktlink zeigte in WhatsApp,
// Facebook oder Slack also Titel und Text der STARTSEITE, egal welcher
// Honig verlinkt wurde. Metadata erbt openGraph feldweise vom Layout;
// ein hier gesetzter Titel ueberschreibt nur diesen einen Wert.
//
// "images" fehlt absichtlich: das produktspezifische Vorschaubild liefert
// bereits opengraph-image.tsx in diesem Verzeichnis. Next.js traegt es
// automatisch nach, SOLANGE hier kein eigenes images-Feld steht — ein
// Eintrag hier wuerde die gestaltete Karte durch das rohe Produktfoto
// ersetzen.
openGraph: {
type: "website",
title: product.name,
description,
url: `/honig/${slug}`,
},
// Deaktivierte Produkte bleiben erreichbar (die Bestellhistorie und die
// Sendungsverfolgung verlinken hierher zum Bewerten), gehoeren aber nicht
// in den Suchindex: wer ueber Google kaeme, faende ein Produkt, das nicht
@@ -91,6 +111,8 @@ export default async function ProductDetailPage(
getCompanyInfo(),
getShippingTiers(),
]);
const shippingRegion = await getShippingRegion();
const shippingCountries = allowedCountryCodes(shippingRegion);
// "Ab"-Preis fuer eine einzelne Einheit — die Staffel, die beim Kauf genau
// dieses einen Produkts greifen wuerde. Macht Versandkosten schon auf der
// Produktseite sichtbar statt erst im Warenkorb/Checkout (Kaufentscheidung
@@ -114,12 +136,55 @@ export default async function ProductDetailPage(
...(product.images.length > 0
? { image: product.images.map((src) => `${base}${src}`) }
: {}),
// Fuellmenge als strukturierte Angabe — bei Lebensmitteln ist das der
// Wert, ueber den Kaeufer Angebote vergleichen ("500 g" statt "Glas").
weight: { "@type": "QuantitativeValue", value: product.weightGrams, unitCode: "GRM" },
offers: {
"@type": "Offer",
priceCurrency: product.currency,
price: (product.priceCents / 100).toFixed(2),
availability: isPurchasable ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
itemCondition: "https://schema.org/NewCondition",
url: `${base}/honig/${product.slug}`,
// Verweist auf das Store-Schema aus dem Root-Layout, statt Name und
// Anschrift hier ein zweites Mal auszuschreiben: so bleibt fuer Google
// erkennbar, dass Verkaeufer und Betrieb dieselbe Einheit sind.
seller: { "@id": `${base}/#business` },
// Ohne priceValidUntil stuft Google den Preis nach einiger Zeit als
// moeglicherweise veraltet ein und blendet ihn im Suchergebnis aus.
// Jahresende des Folgejahres statt "heute + 365 Tage": ein fester
// Stichtag bleibt ueber viele Seitenaufrufe derselbe Wert.
priceValidUntil: `${new Date().getFullYear() + 1}-12-31`,
// Versandkosten im Suchergebnis: Google zeigt sie direkt neben dem Preis
// an. Fehlen sie, rechnet Google im Zweifel mit dem hoechsten Satz oder
// laesst die Angabe weg — beides kostet Klicks. Der Wert ist die Staffel
// fuer genau ein Glas, also derselbe "ab"-Preis wie auf der Seite.
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: (baseShippingCents / 100).toFixed(2),
currency: product.currency,
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: shippingCountries,
},
},
// Gesetzliches Widerrufsrecht (siehe Widerrufsbelehrung): vierzehn Tage
// ab Erhalt der Ware, Rueckgabe per Versand.
//
// "returnFees" fehlt hier bewusst: wer die Kosten der Ruecksendung
// traegt, ist in der Widerrufsbelehrung nicht geregelt. Eine Annahme an
// dieser Stelle waere eine Preisangabe gegenueber Google, die der Shop
// nirgends zusagt.
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: shippingCountries,
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
},
},
...(reviewSummary.count > 0
? {