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