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:
1 parent
a8990a872a
commit
73c070f11e
1 file changed
+67
-2
@@ -18,7 +18,8 @@ import { getCompanyInfo } from "@/lib/company-info";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
||||||
import { isProductWishlisted } from "@/lib/wishlist";
|
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 { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||||
import { TurnstileField } from "@/components/turnstile-widget";
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
@@ -38,10 +39,29 @@ export async function generateMetadata(
|
|||||||
const product = await getProductBySlug(slug);
|
const product = await getProductBySlug(slug);
|
||||||
if (!product) return {};
|
if (!product) return {};
|
||||||
|
|
||||||
|
const description = `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: product.name,
|
title: product.name,
|
||||||
description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`,
|
description,
|
||||||
alternates: { canonical: `/honig/${slug}` },
|
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
|
// Deaktivierte Produkte bleiben erreichbar (die Bestellhistorie und die
|
||||||
// Sendungsverfolgung verlinken hierher zum Bewerten), gehoeren aber nicht
|
// Sendungsverfolgung verlinken hierher zum Bewerten), gehoeren aber nicht
|
||||||
// in den Suchindex: wer ueber Google kaeme, faende ein Produkt, das nicht
|
// in den Suchindex: wer ueber Google kaeme, faende ein Produkt, das nicht
|
||||||
@@ -91,6 +111,8 @@ export default async function ProductDetailPage(
|
|||||||
getCompanyInfo(),
|
getCompanyInfo(),
|
||||||
getShippingTiers(),
|
getShippingTiers(),
|
||||||
]);
|
]);
|
||||||
|
const shippingRegion = await getShippingRegion();
|
||||||
|
const shippingCountries = allowedCountryCodes(shippingRegion);
|
||||||
// "Ab"-Preis fuer eine einzelne Einheit — die Staffel, die beim Kauf genau
|
// "Ab"-Preis fuer eine einzelne Einheit — die Staffel, die beim Kauf genau
|
||||||
// dieses einen Produkts greifen wuerde. Macht Versandkosten schon auf der
|
// dieses einen Produkts greifen wuerde. Macht Versandkosten schon auf der
|
||||||
// Produktseite sichtbar statt erst im Warenkorb/Checkout (Kaufentscheidung
|
// Produktseite sichtbar statt erst im Warenkorb/Checkout (Kaufentscheidung
|
||||||
@@ -114,12 +136,55 @@ export default async function ProductDetailPage(
|
|||||||
...(product.images.length > 0
|
...(product.images.length > 0
|
||||||
? { image: product.images.map((src) => `${base}${src}`) }
|
? { 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: {
|
offers: {
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
priceCurrency: product.currency,
|
priceCurrency: product.currency,
|
||||||
price: (product.priceCents / 100).toFixed(2),
|
price: (product.priceCents / 100).toFixed(2),
|
||||||
availability: isPurchasable ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
|
availability: isPurchasable ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
|
||||||
|
itemCondition: "https://schema.org/NewCondition",
|
||||||
url: `${base}/honig/${product.slug}`,
|
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
|
...(reviewSummary.count > 0
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in new issue
Block a user