diff --git a/src/app/honig/[slug]/page.tsx b/src/app/honig/[slug]/page.tsx index 377d89c..abfb998 100644 --- a/src/app/honig/[slug]/page.tsx +++ b/src/app/honig/[slug]/page.tsx @@ -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 ? {