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
+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>
);