diff --git a/src/app/honig/[slug]/page.tsx b/src/app/honig/[slug]/page.tsx index 1316161..e57b9d3 100644 --- a/src/app/honig/[slug]/page.tsx +++ b/src/app/honig/[slug]/page.tsx @@ -13,7 +13,7 @@ import { TrustBadges } from "@/components/trust-badges"; import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar"; import { getProductBySlug, getRelatedProducts } from "@/lib/data"; import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format"; -import { siteConfig } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; import { getCompanyInfo } from "@/lib/company-info"; import { getCurrentCustomer } from "@/lib/auth"; import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews"; @@ -87,6 +87,7 @@ export default async function ProductDetailPage( // haengt bei einem kleinen Versandhandel oft direkt daran). const baseShippingCents = resolveShippingCostCents(shippingTiers, 1); + const base = getSiteUrl(); const jsonLd = { "@context": "https://schema.org", "@type": "Product", @@ -101,14 +102,14 @@ export default async function ProductDetailPage( // vortaeuschen wuerden (Google Rich-Results/Merchant-Anforderung: absolute // URL zu einem echten Produktfoto, keine Platzhaltergrafik). ...(product.images.length > 0 - ? { image: product.images.map((src) => `https://${siteConfig.domain}${src}`) } + ? { image: product.images.map((src) => `${base}${src}`) } : {}), offers: { "@type": "Offer", priceCurrency: product.currency, price: (product.priceCents / 100).toFixed(2), availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock", - url: `https://wabenhain.com/honig/${product.slug}`, + url: `${base}/honig/${product.slug}`, }, ...(reviewSummary.count > 0 ? { @@ -124,18 +125,18 @@ export default async function ProductDetailPage( "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ - { "@type": "ListItem", position: 1, name: "Shop", item: `https://${siteConfig.domain}/shop` }, + { "@type": "ListItem", position: 1, name: "Shop", item: `${base}/shop` }, { "@type": "ListItem", position: 2, name: t.common.categories[product.category], - item: `https://${siteConfig.domain}/shop?kategorie=${product.category}`, + item: `${base}/shop?kategorie=${product.category}`, }, { "@type": "ListItem", position: 3, name: product.name, - item: `https://${siteConfig.domain}/honig/${product.slug}`, + item: `${base}/honig/${product.slug}`, }, ], }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d76e14e..ac736f8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import { Header, CartDrawer } from "@/components/site-chrome"; import { Footer } from "@/components/site-footer"; import { CookieConsentBanner } from "@/components/cookie-consent"; -import { siteConfig } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; import { getCompanyInfo } from "@/lib/company-info"; import { getSocialLinks } from "@/lib/social-links"; import { getLocale } from "@/i18n/locale"; @@ -26,7 +26,7 @@ export async function generateMetadata(): Promise { const company = await getCompanyInfo(); const title = `${company.name} | Regionaler Honig aus Altenmünster`; return { - metadataBase: new URL(`https://${siteConfig.domain}`), + metadataBase: new URL(getSiteUrl()), title: { default: title, template: `%s | ${company.name}` }, description: company.description, openGraph: { @@ -44,6 +44,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { const locale = await getLocale(); const dict = getDictionary(locale); const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]); + const base = getSiteUrl(); // LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt // auf jeder Seite einzeln) — hilft insbesondere bei lokaler Google-Suche @@ -54,10 +55,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { const localBusinessJsonLd = { "@context": "https://schema.org", "@type": "Store", - "@id": `https://${siteConfig.domain}/#business`, + "@id": `${base}/#business`, name: company.name, description: company.description, - url: `https://${siteConfig.domain}`, + url: base, address: { "@type": "PostalAddress", streetAddress: company.address.street, @@ -73,10 +74,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { "@context": "https://schema.org", "@type": "WebSite", name: company.name, - url: `https://${siteConfig.domain}`, + url: base, potentialAction: { "@type": "SearchAction", - target: `https://${siteConfig.domain}/shop?q={search_term_string}`, + target: `${base}/shop?q={search_term_string}`, "query-input": "required name=search_term_string", }, }; diff --git a/src/app/robots.ts b/src/app/robots.ts index 869faca..bf969bb 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,5 +1,13 @@ import type { MetadataRoute } from "next"; -import { siteConfig } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; + +// Muss zur Laufzeit statt zur Build-Zeit gerendert werden (gleicher Grund wie +// bei sitemap.ts): sonst backt Next.js die Datei beim Build als statische +// Antwort ein — samt dem Wert, den NEXT_PUBLIC_SITE_URL GERADE DA hatte. Im +// Docker-Build ist die Variable nicht gesetzt (siehe Dockerfile), die Sitemap- +// Zeile fror damit dauerhaft auf dem Rueckfallwert aus site-config.ts ein, +// egal unter welcher Domain die Seite spaeter tatsaechlich lief. +export const dynamic = "force-dynamic"; export default function robots(): MetadataRoute.Robots { return { @@ -10,6 +18,6 @@ export default function robots(): MetadataRoute.Robots { disallow: ["/admin", "/checkout", "/warenkorb", "/api", "/konto", "/rechnung"], }, ], - sitemap: `https://${siteConfig.domain}/sitemap.xml`, + sitemap: `${getSiteUrl()}/sitemap.xml`, }; } diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx index da59eac..dbfb903 100644 --- a/src/app/shop/page.tsx +++ b/src/app/shop/page.tsx @@ -6,7 +6,7 @@ import { ProductCard } from "@/components/product-card"; import { RecentlyViewedSection } from "@/components/recently-viewed-section"; import { getActiveProducts, type ProductSort } from "@/lib/data"; import { getReviewSummariesByProductIds } from "@/lib/reviews"; -import { siteConfig } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import type { ProductCategory } from "@/generated/prisma/client"; @@ -67,12 +67,13 @@ export default async function ShopPage(props: PageProps<"/shop">) { const products = await getActiveProducts({ query, category, sort, onlyAvailable }); const reviewSummaries = await getReviewSummariesByProductIds(products.map((p) => p.id)); + const base = getSiteUrl(); const breadcrumbJsonLd = { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ - { "@type": "ListItem", position: 1, name: "Start", item: `https://${siteConfig.domain}` }, - { "@type": "ListItem", position: 2, name: "Shop", item: `https://${siteConfig.domain}/shop` }, + { "@type": "ListItem", position: 1, name: "Start", item: base }, + { "@type": "ListItem", position: 2, name: "Shop", item: `${base}/shop` }, ], }; diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 7dd7f16..a300625 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,7 @@ import type { MetadataRoute } from "next"; import { getActiveProducts, getHarvests } from "@/lib/data"; -import { wissenSlugs, siteConfig } from "@/lib/site-config"; +import { wissenSlugs } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; // priority/changeFrequency sind Hinweise an Crawler, keine Garantien — // die Startseite und der Shop aendern sich am haeufigsten und sind fuer @@ -27,7 +28,7 @@ const staticRoutes: { path: string; priority: number; changeFrequency: MetadataR export const dynamic = "force-dynamic"; export default async function sitemap(): Promise { - const base = `https://${siteConfig.domain}`; + const base = getSiteUrl(); const [products, harvests] = await Promise.all([getActiveProducts(), getHarvests()]); return [ diff --git a/src/app/wissen/[slug]/page.tsx b/src/app/wissen/[slug]/page.tsx index 3a88636..a4f2bb6 100644 --- a/src/app/wissen/[slug]/page.tsx +++ b/src/app/wissen/[slug]/page.tsx @@ -3,7 +3,8 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { Container } from "@/components/ui"; import { BackLink } from "@/components/back-link"; -import { wissenSlugs, siteConfig } from "@/lib/site-config"; +import { wissenSlugs } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; import { getCompanyInfo } from "@/lib/company-info"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -42,7 +43,7 @@ export default async function WissenArticlePage( const otherArticles = wissenSlugs.filter((s) => s !== slug).slice(0, 2); const company = await getCompanyInfo(); - const base = `https://${siteConfig.domain}`; + const base = getSiteUrl(); // Kein datePublished/image: fuer diese Artikel existiert kein echtes // Veroeffentlichungsdatum und keine echte Fotografie — beides zu erfinden // waere irrefuehrende strukturierte Daten statt hilfreicher. diff --git a/src/lib/url.test.ts b/src/lib/url.test.ts new file mode 100644 index 0000000..f87479f --- /dev/null +++ b/src/lib/url.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { getSiteUrl, getBaseUrl } from "./url"; +import { siteConfig } from "./site-config"; + +const original = process.env.NEXT_PUBLIC_SITE_URL; + +afterEach(() => { + if (original === undefined) delete process.env.NEXT_PUBLIC_SITE_URL; + else process.env.NEXT_PUBLIC_SITE_URL = original; +}); + +describe("getSiteUrl", () => { + it("nutzt NEXT_PUBLIC_SITE_URL, wenn gesetzt", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de"; + + expect(getSiteUrl()).toBe("https://shop.beispiel.de"); + }); + + it("entfernt abschliessende Schraegstriche, damit keine doppelten entstehen", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de//"; + + // Zusammengesetzt ergaebe das sonst "https://shop.beispiel.de//shop" — + // fuer Google eine andere URL als die kanonische Variante. + expect(`${getSiteUrl()}/shop`).toBe("https://shop.beispiel.de/shop"); + }); + + it("faellt auf die konfigurierte Domain zurueck statt auf localhost", () => { + delete process.env.NEXT_PUBLIC_SITE_URL; + + // Eine vergessene Umgebungsvariable darf nicht dazu fuehren, dass + // "http://localhost:3000" in einer oeffentlichen Sitemap landet. + expect(getSiteUrl()).toBe(`https://${siteConfig.domain}`); + }); + + it("ignoriert einen Wert, der nur aus Leerzeichen besteht", () => { + process.env.NEXT_PUBLIC_SITE_URL = " "; + + expect(getSiteUrl()).toBe(`https://${siteConfig.domain}`); + }); + + it("liefert unter getBaseUrl denselben Wert — E-Mails und SEO duerfen nicht auseinanderlaufen", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de"; + + expect(getBaseUrl()).toBe(getSiteUrl()); + }); +}); diff --git a/src/lib/url.ts b/src/lib/url.ts index eb82395..2cf9f5b 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -1,8 +1,36 @@ +import { siteConfig } from "@/lib/site-config"; + /** - * Basis-URL fuer absolute Links in E-Mails (Bestellbestaetigung, - * Newsletter-Opt-in, Passwort-Reset). Webhooks/Server Actions haben keinen - * verlaesslichen Request-Origin, deshalb NEXT_PUBLIC_SITE_URL statt Header. + * Oeffentliche Basis-URL der Seite, ohne abschliessenden Schraegstrich + * (z.B. "https://wabenhain.com"). + * + * EINZIGE Quelle fuer absolute Links — sowohl fuer E-Mails (Bestellbestaetigung, + * Newsletter-Opt-in, Passwort-Reset) als auch fuer alles, was Suchmaschinen + * sehen: metadataBase/Canonicals (app/layout.tsx), sitemap.xml, robots.txt und + * saemtliche JSON-LD-Bloecke. + * + * Vorher war das auf zwei Quellen verteilt: E-Mails nutzten + * NEXT_PUBLIC_SITE_URL, SEO-relevante Stellen dagegen ein fest verdrahtetes + * "https://" + siteConfig.domain. Laeuft die Seite unter einer anderen Domain + * (Staging, Vorschau-Host, spaeterer Domainwechsel), zeigten Sitemap, + * Canonical-Tags und strukturierte Daten damit geschlossen auf eine fremde + * Adresse: Google verwirft eine Sitemap mit fremden Hosts, und Canonicals auf + * eine andere Domain weisen die Indexierung aktiv von der echten Seite weg. + * + * siteConfig.domain bleibt als Rueckfallebene erhalten, damit eine vergessene + * Umgebungsvariable in Produktion nicht zu "http://localhost:3000" in einer + * oeffentlichen Sitemap fuehrt. + */ +export function getSiteUrl(): string { + const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (configured) return configured.replace(/\/+$/, ""); + return `https://${siteConfig.domain}`; +} + +/** + * Alias von getSiteUrl() unter dem in E-Mail-Code eingebuergerten Namen. + * Beide liefern denselben Wert — der Unterschied ist rein sprachlich. */ export function getBaseUrl(): string { - return process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + return getSiteUrl(); }