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 { 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
|
||||
? {
|
||||
|
||||
Reference in new issue
Block a user