Add mobile swipe gallery, shared ProductCard, dynamic Datenschutz address, GSC verification

- ProductGallery: touch swipe (left/right) now navigates slides on mobile,
  previously click-only.
- New shared ProductCard component used by ProductGrid/RelatedProducts/
  MerklisteGrid — product title is now the card's link (replaces the
  separate "Mehr erfahren" line), consistent aspect ratio across all
  three grids, more compact cards.
- Datenschutz: name/address/email now come from company-settings via a
  new VerantwortlicherBlock, same single-source pattern as Impressum's
  AnbieterAngaben — no more hand-typed address to keep in sync.
- layout.tsx: render googleSearchConsoleVerification via Next's native
  verification.google metadata field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-08-02 05:38:55 +00:00
parent 23f8efcc2b
commit 9617478b0d
10 changed files with 283 additions and 265 deletions
+5 -1
View File
@@ -123,7 +123,11 @@ Every page can carry real per-page metadata — `/blog/[slug]`'s
`generateMetadata()` reads a post's own `seoTitle`/`seoDescription`/`seoImage`
(see the `posts` collection below); `app/layout.tsx`'s `generateMetadata()`
reads `getSeoSettings()` (ISR-cached, backed by `company-settings`) for
the site-wide fallback.
the site-wide fallback. That same `generateMetadata()` also sets
`verification: { google: seo.googleSearchConsoleVerification }` when an
admin has filled in company-settings' SEO-tab verification field — Next.js
renders that natively as `<meta name="google-site-verification">`, no
separate literal tag in this file.
JSON-LD structured data (`app/lib/structuredData.ts`, no headless
external validation step, just direct grepping of the rendered
+12 -83
View File
@@ -1,13 +1,10 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useProducts } from "../../lib/products";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { Reveal } from "../../components/Reveal";
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { WishlistButton } from "../../components/WishlistButton";
import { ProductCard } from "../../components/ProductCard";
import { FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { useCart } from "../../lib/cart";
const DISPLAY_COUNT = 3;
@@ -133,17 +130,16 @@ export function RelatedProducts({
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
<div className="grid items-start grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// Same "any vs. every" split as ProductGrid.tsx.
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<div
{displayProducts.map((product, i) => (
<ProductCard
key={product.id}
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled={wishlistEnabled}
wishlistRevealOnHover
className={
"group sm:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full gap-4 transition-transform duration-300 hover:-translate-y-1 " +
"sm:col-span-4 " +
// Center the row when there are fewer than 3 cards to show
// (e.g. only 1 active product left once the others are
// already in the cart) — only the first card needs an
@@ -157,75 +153,8 @@ export function RelatedProducts({
: ""
: "")
}
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 640px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Same top-left pill pattern as ProductGrid.tsx/
ProductSpotlight.tsx — position: absolute, so it never
affects this card's height. Only the discount/Ausverkauft
pill lives here now; the low-stock hint moved to a
reserved-height text line below (see the min-h paragraph
under the price) — plain conditional text here is what
broke equal card heights in this grid before. */}
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full flex-1">
<div className="flex flex-col gap-1 items-start w-full">
{/* Same eyebrow treatment as ProductGrid.tsx/Blog.tsx's
category line — omitted for an uncategorized product. */}
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
</div>
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
{/* Always rendered, text conditional — min-h reserves this
line's height in both states so cards in the same row
stay equal height regardless of low-stock status. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
{/* flex-1 spacer — pins every card's button to the same Y
regardless of whether `product.name` wraps to one or two
lines (see ProductGrid.tsx's identical spacer). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</div>
);
})}
/>
))}
</div>
</section>
);
+129
View File
@@ -0,0 +1,129 @@
import Link from "next/link";
import Image from "next/image";
import type { ReactNode } from "react";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
import { AddToCartInlineButton } from "./AddToCartInlineButton";
import { WishlistButton } from "./WishlistButton";
import type { Product } from "../lib/payload";
// Single shared card markup for every product grid (ProductGrid,
// RelatedProducts, MerklisteGrid) — these three used to each duplicate
// this JSX independently and had drifted (different image aspect ratios,
// a "Mehr erfahren" link present on some but not others, purchased/
// out-of-stock badges only on some). The title itself is now the card's
// only link (product.href, when set) — no separate "Mehr erfahren" CTA
// line, which is also what makes the card more compact than before.
// No "use client" — plain enough (no hooks/browser APIs of its own) to
// render from both ProductGrid's Server Component and RelatedProducts'/
// MerklisteGrid's Client Components.
export function ProductCard({
product,
defaultTaxRate,
kleinunternehmer,
wishlistEnabled,
wishlistRevealOnHover = false,
topLeftBadge,
belowPrice,
className = "",
}: {
product: Product;
defaultTaxRate: number;
kleinunternehmer: boolean;
wishlistEnabled: boolean;
/** See WishlistButton's own doc: true for any grid where an unprompted
* heart on every card would read as noise (ProductGrid, RelatedProducts);
* false (default) for /konto/merkliste, where every card is already
* wishlisted. */
wishlistRevealOnHover?: boolean;
/** Overrides the default Ausverkauft/discount pill — used by
* MerklisteGrid for its "Gekauft am ..." badge. Pass `null` to render
* no badge at all. */
topLeftBadge?: ReactNode;
/** Rendered directly under the price — e.g. ProductGrid's delivery-time
* line. Omitted entirely by grids that don't have anything to say there
* (RelatedProducts, MerklisteGrid), rather than every card carrying a
* fixed slot for content only one of the three actually has. */
belowPrice?: ReactNode;
className?: string;
}) {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<div
className={`group bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1 ${className}`}
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{topLeftBadge !== undefined ? (
topLeftBadge
) : fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
)}
</div>
<div className="flex flex-col gap-3 items-start px-5 pb-5 pt-4 w-full flex-1">
<div className="flex flex-col gap-1 items-start w-full">
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h4 text-text-primary w-full hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</Link>
) : (
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
)}
</div>
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
{belowPrice}
{/* Always rendered, text conditional — reserved height keeps every
card in a row equal height regardless of low-stock status. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
{/* flex-1 spacer — pins every card's button to the same Y
regardless of whether the title/category line wraps. */}
<div className="flex-1" />
<AddToCartInlineButton
id={product.id}
numericId={product.numericId}
outOfStock={product.outOfStock}
maxQty={product.maxQty}
variants={product.variants}
/>
</div>
</div>
);
}
+20 -2
View File
@@ -1,8 +1,10 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import Image from "next/image";
const SWIPE_THRESHOLD_PX = 40;
/**
* Main image + thumbnail strip, swappable on click. `image` is always
* slide zero, `gallery` (Products.gallery, optional/empty for most
@@ -16,6 +18,7 @@ import Image from "next/image";
export function ProductGallery({ image, gallery, alt }: { image: string; gallery: string[]; alt: string }) {
const slides = [image, ...gallery];
const [current, setCurrent] = useState(0);
const touchStartX = useRef<number | null>(null);
if (slides.length <= 1) {
return (
@@ -32,9 +35,24 @@ export function ProductGallery({ image, gallery, alt }: { image: string; gallery
setCurrent((c) => (c - 1 + slides.length) % slides.length);
}
function handleTouchStart(e: React.TouchEvent) {
touchStartX.current = e.touches[0].clientX;
}
function handleTouchEnd(e: React.TouchEvent) {
if (touchStartX.current === null) return;
const delta = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (delta <= -SWIPE_THRESHOLD_PX) next();
else if (delta >= SWIPE_THRESHOLD_PX) prev();
}
return (
<div className="flex flex-col gap-3.5 w-full">
<div className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base">
<div
className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base touch-pan-y"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<Image src={slides[current]} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
<button
type="button"
@@ -0,0 +1,54 @@
import { headingId } from "../../components/RichText";
import type { CompanySettings } from "../../lib/payload";
import type { TOCSection } from "../../components/SectionTOC";
// Renders "1. Verantwortlicher" straight from company-settings, same
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx — this
// used to be hand-typed name/address/email baked into the Datenschutz
// richText (seed-datenschutz.ts on the Payload side), silently out of
// date the moment company-settings changed without a matching manual
// edit here too. The heading itself stays numbered "1." (headingId's own
// `^(\d+)\.` match turns that into "section-1", exactly the id this
// section's TOC entry already had before the RichText stopped rendering
// it — no anchor breakage).
export function verantwortlicherHeadings(): TOCSection[] {
return [{ id: headingId("1. Verantwortlicher"), title: "1. Verantwortlicher" }];
}
function Heading({ children }: { children: string }) {
return (
<h2
id={headingId(children)}
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
style={{ fontFamily: "var(--font-lora)" }}
>
{children}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</h2>
);
}
function P({ children }: { children: React.ReactNode }) {
return <p className="text-body text-text-body">{children}</p>;
}
export function VerantwortlicherBlock({ seller }: { seller: CompanySettings }) {
return (
<div className="flex flex-col gap-4 w-full">
<Heading>1. Verantwortlicher</Heading>
<P>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</P>
<div className="flex flex-col gap-1">
<P>{seller.managingDirector || seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
<P>
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
</P>
</div>
<P>Ein gesetzlich vorgeschriebener Datenschutzbeauftragter ist für unser Unternehmen aufgrund seiner Größe derzeit nicht erforderlich.</P>
</div>
);
}
+12 -4
View File
@@ -7,8 +7,9 @@ import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { getLegalPage, getCompanySettings } from "../lib/payload";
import { formatMonthYear } from "../lib/format";
import { VerantwortlicherBlock, verantwortlicherHeadings } from "./components/VerantwortlicherBlock";
export const metadata: Metadata = {
title: "Datenschutzerklärung",
@@ -18,8 +19,11 @@ export const metadata: Metadata = {
export default async function DatenschutzPage() {
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("datenschutz", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
const [page, seller] = await Promise.all([getLegalPage("datenschutz", { draft: isPreview }), getCompanySettings()]);
// "1. Verantwortlicher" headings first — that block renders above the
// CMS content below (same reasoning as Impressum's own headings
// composition, see AnbieterAngaben.tsx).
const headings = [...verantwortlicherHeadings(), ...(page ? extractHeadings(page.content) : [])];
return (
<>
@@ -69,7 +73,11 @@ export default async function DatenschutzPage() {
</div>
</div>
<div className="w-full lg:flex-1 min-w-0">
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
{/* Name/Adresse/E-Mail kommen direkt aus company-settings, nicht
aus der CMS-Richtext unten — single-sourced, gleiche
Begründung wie Impressum's AnbieterAngaben.tsx. */}
{seller && <VerantwortlicherBlock seller={seller} />}
{page ? (
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
@@ -1,12 +1,9 @@
"use client";
import Image from "next/image";
import { RevealGroup, RevealItem } from "../../../components/Reveal";
import { formatPrice, discountPercent, formatDate } from "../../../lib/format";
import { AddToCartInlineButton } from "../../../components/AddToCartInlineButton";
import { WishlistButton } from "../../../components/WishlistButton";
import { formatDate } from "../../../lib/format";
import { ProductCard } from "../../../components/ProductCard";
import { useWishlist } from "../../../lib/useWishlist";
import { effectiveTaxRate } from "../../../lib/cartTotals";
import type { Product } from "../../../lib/payload";
// Client Component so removing an item (WishlistButton toggling it off)
@@ -43,73 +40,32 @@ export function MerklisteGrid({
return (
<RevealGroup className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{visibleEntries.map(({ item, product }) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
// Already-purchased takes precedence over "Ausverkauft" — a
// customer who already bought this doesn't need a restock notice,
// they need to know they already own it (and can still remove it
// manually via WishlistButton — this is a status note, not an
// auto-removal, see feedback discussion this implements).
return (
<RevealItem key={`${product.id}-${item.variant}`} className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full">
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover ${item.purchasedAt || fullyOutOfStock ? "opacity-60" : ""}`}
/>
{item.purchasedAt ? (
{visibleEntries.map(({ item, product }) => (
<RevealItem key={`${product.id}-${item.variant}`}>
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled
className="h-full"
// Already-purchased takes precedence over the default
// Ausverkauft/discount badge — a customer who already bought
// this doesn't need a restock notice, they need to know they
// already own it (and can still remove it manually via
// WishlistButton — this is a status note, not an
// auto-removal, see feedback discussion this implements).
// undefined (not purchased) falls through to ProductCard's
// own default Ausverkauft/discount badge.
topLeftBadge={
item.purchasedAt ? (
<span className="absolute top-3 left-3 rounded-full bg-success-subtle px-2.5 py-1 text-label font-bold text-success">
Gekauft am {formatDate(item.purchasedAt)}
</span>
) : (
fullyOutOfStock && (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
)
)}
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<div className="flex flex-col gap-1 items-start w-full">
{/* Same eyebrow treatment as ProductGrid.tsx/Blog.tsx's
category line — omitted for an uncategorized product. */}
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
</div>
<div className="flex flex-col gap-1 items-start">
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
{/* flex-1 spacer — pins every card's button to the same Y
regardless of whether `product.name` wraps to one or two
lines (see ProductGrid.tsx's identical spacer). */}
<div className="flex-1" />
{/* No className override — AddToCartInlineButton's `className`
prop REPLACES its whole default styling (`?? defaultClass`,
not a merge), so passing just "w-full" here previously threw
away all the button's actual styling. Its default is
already `w-full`. */}
<AddToCartInlineButton id={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
})}
) : undefined
}
/>
</RevealItem>
))}
</RevealGroup>
);
}
+3
View File
@@ -56,6 +56,9 @@ export async function generateMetadata(): Promise<Metadata> {
card: "summary_large_image",
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
},
verification: seo.googleSearchConsoleVerification
? { google: seo.googleSearchConsoleVerification }
: undefined,
};
}
+5
View File
@@ -1133,6 +1133,7 @@ export type SeoSettings = {
titleTemplate: string | null;
defaultDescription: string | null;
defaultOgImage: string | null;
googleSearchConsoleVerification: string | null;
};
// Fallback matches the values hardcoded in app/layout.tsx before this field
@@ -1145,6 +1146,7 @@ const SEO_SETTINGS_FALLBACK: SeoSettings = {
defaultDescription:
"Kleine Impulse, praktische Werkzeuge und ehrliche Gedanken für mehr Klarheit im Alltag weil du auch noch ein Leben hast.",
defaultOgImage: "/og-image.png",
googleSearchConsoleVerification: null,
};
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
@@ -1166,6 +1168,7 @@ export async function getSeoSettings(): Promise<SeoSettings> {
seoTitleTemplate?: string | null;
seoDefaultDescription?: string | null;
seoDefaultOgImage?: { url?: string } | number | null;
googleSearchConsoleVerification?: string | null;
}[];
} = await res.json();
const doc = data.docs?.[0];
@@ -1176,6 +1179,8 @@ export async function getSeoSettings(): Promise<SeoSettings> {
defaultDescription: doc.seoDefaultDescription || SEO_SETTINGS_FALLBACK.defaultDescription,
defaultOgImage:
(typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage,
googleSearchConsoleVerification:
doc.googleSearchConsoleVerification || SEO_SETTINGS_FALLBACK.googleSearchConsoleVerification,
};
}
+18 -106
View File
@@ -1,12 +1,6 @@
import Link from "next/link";
import Image from "next/image";
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled, getShopFilterEnabled } from "../../lib/payload";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { formatPrice, discountPercent } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
import { WishlistButton } from "../../components/WishlistButton";
import { ArrowRightIcon } from "../../components/ArrowRightIcon";
import { ProductCard } from "../../components/ProductCard";
import { PriceRangeFilter } from "./PriceRangeFilter";
import { CategoryFilter } from "./CategoryFilter";
@@ -124,105 +118,23 @@ export async function ProductGrid({
key={products.map((p) => p.id).join(",")}
className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full"
>
{products.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// A varianted product only reads as "ausverkauft" overall once
// every one of its variants is — a single sold-out variant just
// shows as such in the picker itself (AddToCartInlineButton),
// not as a blanket badge that would misleadingly suggest the
// whole product is unavailable while other variants still are.
const fullyOutOfStock = isProductFullyOutOfStock(product);
// Mirrors fullyOutOfStock's "any vs. every" split — a varianted
// product reads as low-stock as soon as one variant is, since a
// shopper landing on the grid hasn't picked a variant yet.
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<RevealItem
key={product.id}
className="group lg:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<div className="flex flex-col gap-1 items-start w-full">
{/* Same eyebrow treatment as the blog cards' category line
(Blog.tsx) — omitted entirely for an uncategorized
product rather than showing an empty line. */}
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
</div>
<div className="flex flex-col gap-1 items-start">
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
{/* Always rendered, text conditional — not a conditional
block — so this line's height (min-h as a cross-browser
safety net for the empty case) is identical whether or
not the product is low-stock. See AddToCartButton.tsx's
own comment: an earlier text-based low-stock hint here
broke equal card heights across the grid, which is why
it moved to the image-overlay pill in the first place. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
{product.href && (
<Link
href={product.href}
className="flex items-center gap-1 font-bold text-label text-text-primary hover:text-brand transition-colors"
>
<span>Mehr erfahren</span>
<ArrowRightIcon />
</Link>
)}
{/* flex-1 spacer — pins every card's button to the same Y
regardless of the "Mehr erfahren" line only products with
a detail page have (see figma-to-nextjs skill's Tools.tsx
equal-height lesson). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
})}
{products.map((product) => (
<RevealItem key={product.id} className="lg:col-span-4">
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled={wishlistEnabled}
wishlistRevealOnHover
belowPrice={
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
}
className="h-full"
/>
</RevealItem>
))}
</RevealGroup>
</div>
</div>