Compare commits

..
3 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 7dd42e35bc perf: Produktbilder werden als AVIF ausgeliefert
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m43s
next/image lieferte bisher nur WebP (die Next.js-Vorgabe). Am Hero-Bild der
Startseite (wabenhain.com, 1920x1280) nachgemessen, mit genau den
Einstellungen, die Next.js selbst verwendet — WebP q75 gegen AVIF q47
(image-optimizer.js skaliert die Qualitaet mit 50/80) bei effort 3:

    Breite   WebP        AVIF
      640     21,2 kB     12,0 kB   -43 %
      828     30,2 kB     17,4 kB   -42 %
     1080     44,4 kB     26,5 kB   -40 %
     1920    105,0 kB     65,7 kB   -37 %

Auf einer Shop-Uebersicht mit einem Dutzend Kacheln ist das der groesste
einzelne Posten der Seite. Browser ohne AVIF-Unterstuetzung bekommen
unveraendert WebP, ganz alte das Original; die Auswahl trifft Next.js anhand
des Accept-Headers.

Die Kodierdauer spricht nicht dagegen: bei effort 3 lag AVIF in derselben
Messung durchgehend gleichauf oder schneller als WebP (1920 px: 60 ms gegen
122 ms). minimumCacheTTL steigt trotzdem von den voreingestellten vier Wochen
auf ein Jahr — nicht wegen der Kodierdauer, sondern weil hochgeladene Bilder
unveraenderlich sind: lib/uploads.ts legt jede Datei unter einem eigenen
Namen ab, ein ausgetauschtes Produktbild bekommt also eine neue URL. Ein
kurzes TTL wirft hier nur brauchbare Ergebnisse weg.

Dazu zwei ausdrueckliche Festschreibungen, die das bisherige Verhalten
dokumentieren statt es zu aendern:

- qualities: [75] entspricht der Vorgabe von Next.js 16, die andere Werte
  bereits ablehnt (gegen die laufende Seite geprueft: ?q=42 antwortet mit
  400). Ausgeschrieben, damit eine spaetere Erweiterung der Liste eine
  bewusste Entscheidung ist und nicht unbemerkt Kodier-Last je Wert oeffnet.
- dangerouslyAllowSVG: false entspricht ebenfalls der Vorgabe. Ausgeschrieben,
  weil ein SVG ein Dokument ist, das Skripte enthalten kann, und hier
  same-origin ausgeliefert wuerde.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:12:44 +02:00
Nick SzaunigandClaude Opus 5 73c070f11e 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]>
2026-09-20 03:06:02 +02:00
Nick SzaunigandClaude Opus 5 a8990a872a fix: Warenkorb, Kasse, Konto und Rechnungen fielen nicht aus dem Suchindex
robots.txt sperrte /checkout, /warenkorb, /konto und /rechnung fuer Crawler.
Das verbietet aber nur das Abrufen, nicht das Indexieren: verweist irgendwo
ein Link auf eine dieser Seiten, nimmt Google die URL trotzdem auf — als
inhaltslosen Treffer mit dem Hinweis "Fuer diese Seite sind keine
Informationen verfuegbar".

Der uebliche Ausweg, ein noindex-Meta-Tag, half hier nicht: eine per
robots.txt gesperrte Seite wird nie geladen, also sieht der Crawler das Tag
nie. Die Sperre verhinderte ausgerechnet das Mittel, das die URLs wieder aus
dem Index geholt haette. Keine dieser Seiten trug bisher ueberhaupt ein
noindex.

Jetzt greifen beide Mechanismen zusammen statt gegeneinander:

- lib/seo.ts buendelt die beiden robots-Bausteine. noIndex laesst Links
  weiterhin folgen (Footer- und Produktlinks im Warenkorb sollen ihre
  Linkkraft behalten), noIndexPrivate unterbindet zusaetzlich Cache-Kopie,
  Bildindexierung und Textausschnitt.
- Kasse, Bestellbestaetigung und Rechnungsansicht bekommen noIndexPrivate,
  der Warenkorb noIndex.
- Der gesamte Kontobereich bekommt das Tag einmal ueber ein neues
  konto/layout.tsx statt in zehn Unterseiten einzeln. Metadata erbt
  feldweise, ein eigener title in einer Unterseite loescht den Wert also
  nicht.
- robots.txt sperrt nur noch /api/ und /admin — also das, was ein Crawler
  wirklich nie abrufen soll. Alles andere darf gecrawlt werden, damit das
  noindex ueberhaupt gelesen wird. /uploads bleibt bewusst offen: dort liegen
  die Produktbilder, die in der Bildersuche erwuenscht sind.

Zusaetzlich eine ausdrueckliche Regel fuer GPTBot, ClaudeBot, PerplexityBot
und Google-Extended (oeffentliche Inhalte ja, interne Bereiche nein) sowie
die host-Angabe in robots.txt.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:05:54 +02:00
9 changed files with 209 additions and 7 deletions

No files matched your search

+37
View File
@@ -25,6 +25,43 @@ const nextConfig: NextConfig = {
// werden." // werden."
serverExternalPackages: ["pdfkit", "ffmpeg-static"], serverExternalPackages: ["pdfkit", "ffmpeg-static"],
images: {
// AVIF vor WebP. Next.js liefert das erste Format, das der Browser laut
// Accept-Header unterstuetzt; Browser ohne AVIF bekommen unveraendert
// WebP, ganz alte das Original.
//
// Am Hero-Bild der Startseite nachgemessen (1920x1280), mit den
// Einstellungen aus next/dist/server/image-optimizer.js — WebP q75 gegen
// AVIF q47 (Next.js skaliert die Qualitaet mit 50/80) bei effort 3:
// 640 px -43 %, 828 px -42 %, 1080 px -40 %, 1920 px -37 %.
//
// Die Kodierdauer spricht nicht dagegen: bei effort 3 lag AVIF in
// derselben Messung gleichauf oder schneller als WebP (1920 px: 60 ms
// gegen 122 ms). Die frueher hier vermutete teure Erstkodierung gibt es
// in dieser Einstellung nicht.
formats: ["image/avif", "image/webp"],
// Ein Jahr statt der Next.js-Vorgabe von vier Wochen. Hochgeladene Bilder
// sind unveraenderlich: lib/uploads.ts legt jede Datei unter einem eigenen
// Namen ab, ein ausgetauschtes Produktbild bekommt also eine neue URL.
// Ein kurzes TTL wuerde hier nur brauchbare Ergebnisse wegwerfen.
minimumCacheTTL: 60 * 60 * 24 * 365,
// Entspricht der Vorgabe von Next.js 16, die andere Werte bereits ablehnt
// (gegen die laufende Seite geprueft: ?q=42 antwortet mit 400) — aendert
// das Verhalten also nicht. Ausgeschrieben, damit eine spaetere
// Erweiterung der Liste eine bewusste Entscheidung ist: jeder zusaetzlich
// erlaubte Wert oeffnet einen eigenen Cache-Eintrag und eine eigene
// Kodierung je Bild, von aussen ueber die URL ausloesbar.
qualities: [75],
// Ebenfalls die Vorgabe, ausdruecklich festgehalten: ein SVG ist ein
// Dokument, das Skripte enthalten kann — same-origin ausgeliefert waere
// das ein XSS-Vektor. Diese Zeile soll verhindern, dass der Schalter
// spaeter beilaeufig umgelegt wird.
dangerouslyAllowSVG: false,
},
// Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack) // Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack)
// kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis // kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis
// wiederholt mit der Cloud-Synchronisierung ("Failed to open database" / // wiederholt mit der Cloud-Synchronisierung ("Failed to open database" /
+4 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noIndexPrivate } from "@/lib/seo";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
import { getAddressesForCustomer } from "@/lib/account-data"; import { getAddressesForCustomer } from "@/lib/account-data";
import { getShippingTiers, getShippingRegion } from "@/lib/shipping"; import { getShippingTiers, getShippingRegion } from "@/lib/shipping";
@@ -16,7 +17,9 @@ export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
return { title: t.checkoutPage.eyebrow }; // Die Kasse traegt Adress- und Zahlungsdaten und soll nie als
// Suchtreffer erscheinen.
return { title: t.checkoutPage.eyebrow, robots: noIndexPrivate };
} }
export default async function CheckoutPage() { export default async function CheckoutPage() {
+4 -1
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noIndexPrivate } from "@/lib/seo";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
return { title: t.checkoutSuccessPage.successEyebrow }; // Die Bestaetigungsseite zeigt Bestelldaten und ist ueber eine
// Bestell-ID in der URL erreichbar — nicht indexieren.
return { title: t.checkoutSuccessPage.successEyebrow, robots: noIndexPrivate };
} }
export default function CheckoutSuccessLayout({ children }: { children: React.ReactNode }) { export default function CheckoutSuccessLayout({ children }: { children: React.ReactNode }) {
+67 -2
View File
@@ -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
? { ? {
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { noIndexPrivate } from "@/lib/seo";
// Der Kontobereich enthaelt ausschliesslich personenbezogene Seiten
// (Bestellungen, Adressen, Wunschliste, Zwei-Faktor-Einrichtung) sowie die
// Anmeldung selbst. Nichts davon gehoert in den Suchindex.
//
// Gesetzt einmal hier im Layout statt in jeder Unterseite: /konto hat aktuell
// zehn Routen, und die naechste vergisst man sonst garantiert. Next.js
// vererbt robots aus dem Layout an alle Unterseiten; setzt eine Unterseite
// eigene Metadata (z.B. nur einen title), bleibt der geerbte robots-Wert
// erhalten, weil Metadata feldweise und nicht als Ganzes ueberschrieben wird.
export const metadata: Metadata = {
robots: noIndexPrivate,
};
export default function KontoLayout({ children }: { children: React.ReactNode }) {
return children;
}
+4 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noIndexPrivate } from "@/lib/seo";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { Container } from "@/components/ui"; import { Container } from "@/components/ui";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
@@ -10,7 +11,9 @@ import { invoiceVatBreakdown } from "@/lib/vat";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Rechnung" }; // Rechnungen enthalten Name, Anschrift und Kaufhistorie. noIndexPrivate
// unterbindet zusaetzlich Cache-Kopie und Snippet (siehe lib/seo.ts).
export const metadata: Metadata = { title: "Rechnung", robots: noIndexPrivate };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNumber]">) { export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNumber]">) {
+31 -1
View File
@@ -9,15 +9,45 @@ import { getSiteUrl } from "@/lib/url";
// egal unter welcher Domain die Seite spaeter tatsaechlich lief. // egal unter welcher Domain die Seite spaeter tatsaechlich lief.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// Nur Pfade, die ein Crawler ueberhaupt nicht ABRUFEN soll. Das ist eine
// bewusst kurze Liste, denn "Disallow" und "noindex" schliessen einander aus:
// eine per robots.txt gesperrte Seite wird nie geladen, also sieht Google das
// dort gesetzte noindex nie — und nimmt die URL bei einem eingehenden Link
// trotzdem als inhaltslosen Treffer in den Index auf.
//
// Deshalb stehen /checkout, /warenkorb, /konto und /rechnung hier NICHT mehr
// (frueher taten sie es). Sie tragen stattdessen ein noindex-Meta-Tag (siehe
// lib/seo.ts) und duerfen dafuer gecrawlt werden — nur so faellt eine bereits
// indexierte URL wieder aus dem Index. Der zusaetzliche Crawl-Aufwand ist bei
// einer Handvoll Seiten vernachlaessigbar.
//
// Was bleibt:
// - /api: Maschinenschnittstellen ohne jeden Nutzen in Suchergebnissen.
// - /admin: liefert einem anonymen Crawler ohnehin nur eine Weiterleitung auf
// die Anmeldung; jeder Abruf ist reine Last.
// - /uploads ist bewusst NICHT gesperrt — dort liegen die Produktbilder, die
// in der Google-Bildersuche durchaus erwuenscht sind.
const disallow = ["/api/", "/admin"];
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: [ rules: [
{ {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: ["/admin", "/checkout", "/warenkorb", "/api", "/konto", "/rechnung"], disallow,
},
// GPTBot & Co. duerfen die oeffentlichen Inhalte lesen (Sichtbarkeit in
// KI-Antworten ist erwuenscht), aber ohne die internen Bereiche.
// Separat aufgefuehrt, damit eine spaetere Entscheidung dagegen nur
// diesen Block betrifft und nicht die Suchmaschinen mit einschliesst.
{
userAgent: ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"],
allow: "/",
disallow,
}, },
], ],
sitemap: `${getSiteUrl()}/sitemap.xml`, sitemap: `${getSiteUrl()}/sitemap.xml`,
host: getSiteUrl(),
}; };
} }
+4 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noIndex } from "@/lib/seo";
import { getShippingTiers } from "@/lib/shipping"; import { getShippingTiers } from "@/lib/shipping";
import { getActiveProducts } from "@/lib/data"; import { getActiveProducts } from "@/lib/data";
import { getBulkDiscountTiers } from "@/lib/bulk-discount"; import { getBulkDiscountTiers } from "@/lib/bulk-discount";
@@ -12,7 +13,9 @@ export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
return { title: t.cart.title }; // Ein Warenkorb ist pro Besucher verschieden und als Suchtreffer wertlos —
// siehe Begruendung in lib/seo.ts, warum robots.ts allein nicht genuegt.
return { title: t.cart.title, robots: noIndex };
} }
export default async function WarenkorbPage() { export default async function WarenkorbPage() {
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
/**
* Metadata-Bausteine fuer Seiten, die NICHT in den Suchindex gehoeren.
*
* Warum reicht der Eintrag in robots.ts nicht?
* "Disallow" verbietet nur das CRAWLEN, nicht das INDEXIEREN. Verweist
* irgendwo ein Link auf /warenkorb oder /konto/bestellungen, nimmt Google die
* URL trotzdem in den Index auf — nur eben ohne Inhalt, als Treffer mit dem
* Hinweis "Fuer diese Seite sind keine Informationen verfuegbar". Schlimmer:
* weil der Crawler die Seite nicht lesen darf, sieht er ein dort gesetztes
* noindex NIE. Deshalb muessen beide Mechanismen zusammenspielen — robots.ts
* haelt den Crawler von den nutzlosen Seiten fern, dieses Meta-Tag sorgt
* dafuer, dass eine trotzdem entdeckte URL wieder aus dem Index faellt.
*
* follow: true statt false ist Absicht. Links auf diesen Seiten (Footer,
* Navigation, Produktlinks im Warenkorb) sollen ihre Linkkraft weiterhin an
* die oeffentlichen Seiten weitergeben; nur die Seite selbst soll nicht als
* Suchtreffer erscheinen.
*/
export const noIndex: Metadata["robots"] = {
index: false,
follow: true,
googleBot: { index: false, follow: true },
};
/**
* Wie noIndex, zusaetzlich ohne Cache-Kopie und ohne Textausschnitt in den
* Suchergebnissen. Fuer Seiten mit personenbezogenen Daten (Rechnungen,
* Bestellungen, Kontodaten): selbst wenn eine URL versehentlich oeffentlich
* erreichbar waere, soll kein Inhalt in einem Suchmaschinen-Snippet oder
* -Cache landen.
*/
export const noIndexPrivate: Metadata["robots"] = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true, "max-snippet": 0 },
};