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
+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"