Die Adresse der Seite war an zwei Stellen gepflegt: E-Mails nutzten NEXT_PUBLIC_SITE_URL, alles SEO-Relevante dagegen ein fest verdrahtetes "https://" + siteConfig.domain (16 Stellen, darunter eine roh eingetippte "https://wabenhain.com" im Produkt-JSON-LD). Laeuft der Shop unter einer anderen Domain als wabenhain.com — Staging, Vorschau-Host, spaeterer Domainwechsel, der im k8s-Ingress hinterlegte Host — zeigten damit sitemap.xml, robots.txt, saemtliche Canonical-Tags und alle strukturierten Daten geschlossen auf eine fremde Adresse. Google verwirft eine Sitemap mit fremden Hosts, und ein Canonical auf eine andere Domain weist die Indexierung aktiv von der echten Seite weg. Jetzt eine einzige Quelle: getSiteUrl() in lib/url.ts, mit siteConfig.domain als Rueckfallebene, damit eine vergessene Umgebungsvariable nicht "http://localhost:3000" in einer oeffentlichen Sitemap landen laesst. getBaseUrl() bleibt als Alias fuer den in E-Mail-Code eingebuergerten Namen und liefert denselben Wert. robots.ts bekommt dabei "force-dynamic" (wie sitemap.ts es schon hatte): ohne das rendert Next.js die Datei beim Build statisch vor und backt den Wert ein, den NEXT_PUBLIC_SITE_URL genau dann hatte. Im Docker-Build ist die Variable nicht gesetzt — die Sitemap-Zeile waere also dauerhaft auf dem Rueckfallwert eingefroren. Gegen einen laufenden Produktions-Build geprueft: robots.txt, sitemap.xml, Canonical-Tags und JSON-LD folgen jetzt alle dem zur Laufzeit gesetzten Wert. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
204 lines
7.7 KiB
TypeScript
204 lines
7.7 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { Container, SectionHeading } from "@/components/ui";
|
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
|
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 { getSiteUrl } from "@/lib/url";
|
|
import { getLocale } from "@/i18n/locale";
|
|
import { getDictionary } from "@/i18n/get-dictionary";
|
|
import type { ProductCategory } from "@/generated/prisma/client";
|
|
|
|
export const metadata: Metadata = {
|
|
title: "Shop",
|
|
description: "Hochwertiger Honig, Bienenwachskerzen und Propolis aus eigener Imkerei in Altenmünster.",
|
|
alternates: { canonical: "/shop" },
|
|
};
|
|
|
|
// Siehe Begruendung in app/page.tsx.
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function buildHref(params: {
|
|
q?: string;
|
|
kategorie?: string;
|
|
sortierung?: string;
|
|
verfuegbar?: string;
|
|
}): string {
|
|
const search = new URLSearchParams();
|
|
if (params.q) search.set("q", params.q);
|
|
if (params.kategorie) search.set("kategorie", params.kategorie);
|
|
if (params.sortierung) search.set("sortierung", params.sortierung);
|
|
if (params.verfuegbar) search.set("verfuegbar", params.verfuegbar);
|
|
const qs = search.toString();
|
|
return qs ? `/shop?${qs}` : "/shop";
|
|
}
|
|
|
|
export default async function ShopPage(props: PageProps<"/shop">) {
|
|
const t = getDictionary(await getLocale());
|
|
|
|
const categoryFilters = [
|
|
{ value: undefined, label: t.shop.allCategories },
|
|
{ value: "HONIG", label: t.common.categories.HONIG },
|
|
{ value: "KERZEN", label: t.common.categories.KERZEN },
|
|
{ value: "PROPOLIS", label: t.common.categories.PROPOLIS },
|
|
] as const;
|
|
|
|
const sortOptions: { value: ProductSort | ""; label: string }[] = [
|
|
{ value: "", label: t.shop.sortNewest },
|
|
{ value: "preis-auf", label: t.shop.sortPriceAsc },
|
|
{ value: "preis-ab", label: t.shop.sortPriceDesc },
|
|
{ value: "verfuegbarkeit", label: t.shop.sortAvailability },
|
|
];
|
|
|
|
const { q, kategorie, sortierung, verfuegbar } = await props.searchParams;
|
|
const query = typeof q === "string" ? q : undefined;
|
|
const category =
|
|
typeof kategorie === "string" && ["HONIG", "KERZEN", "PROPOLIS"].includes(kategorie)
|
|
? (kategorie as ProductCategory)
|
|
: undefined;
|
|
const sort =
|
|
typeof sortierung === "string" && ["preis-auf", "preis-ab", "verfuegbarkeit"].includes(sortierung)
|
|
? (sortierung as ProductSort)
|
|
: undefined;
|
|
const onlyAvailable = verfuegbar === "1";
|
|
|
|
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: base },
|
|
{ "@type": "ListItem", position: 2, name: "Shop", item: `${base}/shop` },
|
|
],
|
|
};
|
|
|
|
return (
|
|
<div className="py-20">
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
|
/>
|
|
<Container>
|
|
<ScrollReveal>
|
|
<SectionHeading
|
|
level="h1"
|
|
eyebrow={t.shop.eyebrow}
|
|
title={t.shop.title}
|
|
subtitle={t.shop.subtitle}
|
|
/>
|
|
</ScrollReveal>
|
|
|
|
<div className="mt-8 flex flex-wrap gap-3">
|
|
{categoryFilters.map((filter) => {
|
|
const isActive = filter.value === category;
|
|
const href = buildHref({
|
|
q: query,
|
|
kategorie: filter.value,
|
|
sortierung: sort,
|
|
verfuegbar: onlyAvailable ? "1" : undefined,
|
|
});
|
|
return (
|
|
<Link
|
|
key={filter.label}
|
|
href={href}
|
|
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
|
|
isActive
|
|
? "bg-forest text-cream border-forest"
|
|
: "border-ink/20 text-ink/70 hover:border-forest"
|
|
}`}
|
|
>
|
|
{filter.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="mt-6 flex flex-wrap items-center gap-4">
|
|
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
|
|
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen.
|
|
Die Suche ist bewusst Teil DIESES Formulars (statt nur der kleinen
|
|
Kopfzeilen-Suche): so werden Kategorie/Verfuegbarkeit-Filter beim
|
|
Aendern der Suche innerhalb der Shop-Seite nicht verworfen — anders
|
|
als bei der Kopfzeilen-Suche, die immer neu auf /shop startet. */}
|
|
<form action="/shop" method="GET" className="flex flex-wrap items-center gap-2 text-sm text-ink/70">
|
|
{category ? <input type="hidden" name="kategorie" value={category} /> : null}
|
|
{onlyAvailable ? <input type="hidden" name="verfuegbar" value="1" /> : null}
|
|
<label htmlFor="shop-search" className="sr-only">
|
|
{t.shop.searchInputLabel}
|
|
</label>
|
|
<input
|
|
id="shop-search"
|
|
type="search"
|
|
name="q"
|
|
defaultValue={query ?? ""}
|
|
placeholder={t.nav.searchPlaceholder}
|
|
className="border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
|
/>
|
|
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
|
|
<select
|
|
id="sortierung"
|
|
name="sortierung"
|
|
defaultValue={sort ?? ""}
|
|
className="border border-ink/20 bg-background px-2 py-2"
|
|
>
|
|
{sortOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button type="submit" className="border border-ink/20 px-3 py-2 hover:border-forest">
|
|
{t.shop.apply}
|
|
</button>
|
|
</form>
|
|
<Link
|
|
href={buildHref({
|
|
q: query,
|
|
kategorie: category,
|
|
sortierung: sort,
|
|
verfuegbar: onlyAvailable ? undefined : "1",
|
|
})}
|
|
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
|
|
onlyAvailable
|
|
? "bg-forest text-cream border-forest"
|
|
: "border-ink/20 text-ink/70 hover:border-forest"
|
|
}`}
|
|
>
|
|
{t.shop.onlyAvailable}
|
|
</Link>
|
|
</div>
|
|
|
|
{query ? (
|
|
<p className="mt-6 text-sm text-ink/70">
|
|
{t.shop.searchResults(query, products.length)}{" "}
|
|
<Link
|
|
href={buildHref({ kategorie: category, sortierung: sort, verfuegbar: onlyAvailable ? "1" : undefined })}
|
|
className="underline hover:text-forest"
|
|
>
|
|
{t.shop.clearSearch}
|
|
</Link>
|
|
</p>
|
|
) : null}
|
|
<h2 className="sr-only">{t.shop.productsHeading}</h2>
|
|
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
|
{products.map((product) => (
|
|
<ProductCard key={product.id} product={product} reviewSummary={reviewSummaries.get(product.id)} />
|
|
))}
|
|
</div>
|
|
{products.length === 0 ? (
|
|
<p className="mt-10 text-ink/70">
|
|
{query ? t.shop.noSearchResults : t.shop.noProducts}
|
|
</p>
|
|
) : null}
|
|
|
|
<RecentlyViewedSection />
|
|
</Container>
|
|
</div>
|
|
);
|
|
}
|