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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user