diff --git a/README.md b/README.md
index 5db54da..cb77f48 100644
--- a/README.md
+++ b/README.md
@@ -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 ``, 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
diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx
index d03cb82..5811ac5 100644
--- a/app/cart/components/RelatedProducts.tsx
+++ b/app/cart/components/RelatedProducts.tsx
@@ -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. */}
-
- {/* 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 ? (
-
- Ausverkauft
-
- ) : (
- discount !== null && (
-
- -{discount}%
-
- )
- )}
- {wishlistEnabled && (
-
- )}
-
-
-
- {/* Same eyebrow treatment as ProductGrid.tsx/Blog.tsx's
- category line — omitted for an uncategorized product. */}
- {product.categories.length > 0 && (
-
- {/* 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. */}
-
- {anyLowStock ? "Nur noch wenige verfügbar" : null}
-
-
- {/* 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). */}
-
-
-
-
-
- );
- })}
+ />
+ ))}
);
diff --git a/app/components/ProductCard.tsx b/app/components/ProductCard.tsx
new file mode 100644
index 0000000..f63abe6
--- /dev/null
+++ b/app/components/ProductCard.tsx
@@ -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 (
+
+ {belowPrice}
+ {/* Always rendered, text conditional — reserved height keeps every
+ card in a row equal height regardless of low-stock status. */}
+
{anyLowStock ? "Nur noch wenige verfügbar" : null}
+
+ {/* flex-1 spacer — pins every card's button to the same Y
+ regardless of whether the title/category line wraps. */}
+
+
+
+
+
+ );
+}
diff --git a/app/components/ProductGallery.tsx b/app/components/ProductGallery.tsx
index d8aec08..227807e 100644
--- a/app/components/ProductGallery.tsx
+++ b/app/components/ProductGallery.tsx
@@ -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(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 (
-
+
-
+
+ {/* 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 && }
{page ? (
isPreview ? :
) : (
diff --git a/app/konto/merkliste/components/MerklisteGrid.tsx b/app/konto/merkliste/components/MerklisteGrid.tsx
index ccb0b5f..9651be6 100644
--- a/app/konto/merkliste/components/MerklisteGrid.tsx
+++ b/app/konto/merkliste/components/MerklisteGrid.tsx
@@ -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 (
- {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 (
-
-
- {/* Same eyebrow treatment as ProductGrid.tsx/Blog.tsx's
- category line — omitted for an uncategorized product. */}
- {product.categories.length > 0 && (
-
- {/* 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). */}
-
-
- {/* 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`. */}
-
-
-
- );
- })}
+ ) : undefined
+ }
+ />
+
+ ))}
);
}
diff --git a/app/layout.tsx b/app/layout.tsx
index cc45144..4a44722 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -56,6 +56,9 @@ export async function generateMetadata(): Promise {
card: "summary_large_image",
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
},
+ verification: seo.googleSearchConsoleVerification
+ ? { google: seo.googleSearchConsoleVerification }
+ : undefined,
};
}
diff --git a/app/lib/payload.ts b/app/lib/payload.ts
index 8dbe8e7..28acf5f 100644
--- a/app/lib/payload.ts
+++ b/app/lib/payload.ts
@@ -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 {
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 {
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,
};
}
diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx
index 10f9cc9..43303f6 100644
--- a/app/shop/components/ProductGrid.tsx
+++ b/app/shop/components/ProductGrid.tsx
@@ -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 (
-
-
- {/* 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 && (
-
- Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
-
-
- {/* 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. */}
-
- {anyLowStock ? "Nur noch wenige verfügbar" : null}
-
- {product.href && (
-
- Mehr erfahren
-
-
- )}
-
- {/* 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). */}
-
-
-
-