Add product image gallery, harden Klaro border reset, remove attribution link

ProductGallery.tsx (main image + thumbnail strip) wired into
TodoKartenHero.tsx, only replacing the static hero photo once a
product actually has extra gallery photos — no visual change
otherwise. Backend field: Products.gallery (see payload repo).

Klaro's hard black border was still showing after the previous
color-only pass — the targeted border:none rule wasn't enough, so
this blanket-resets border/outline on every .klaro descendant and
re-adds only the shadow this theme actually wants. Also disables the
"Realisiert mit Klaro!" attribution link (BSD-3-Clause has no
on-page-attribution requirement, Klaro's own disablePoweredBy flag
covers this cleanly).

Updates the frontend README with the gallery + Klaro fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-08-01 21:41:23 +00:00
parent f6b001dd11
commit 417bbd430e
7 changed files with 151 additions and 16 deletions
+7 -2
View File
@@ -38,9 +38,14 @@ import { loadTrackingCode } from "../lib/loadTrackingCode";
function KlaroTheme() {
return (
<style>{`
.klaro, .klaro * { box-sizing: border-box; }
/* Blanket reset first — the previous pass only targeted
.cookie-notice/.cookie-modal directly and a hard black border
still showed up in production (screenshot-confirmed), so every
descendant gets border/outline stripped here regardless of which
specific Klaro rule was actually drawing it; the two rules below
re-add exactly the borders this theme actually wants. */
.klaro, .klaro * { box-sizing: border-box; border: 0 !important; outline: 0 !important; }
.klaro .cookie-notice, .klaro .cookie-modal {
border: none !important;
box-shadow: 0 20px 44px -14px rgba(26,26,24,0.22), 0 4px 14px rgba(26,26,24,0.07) !important;
border-radius: 18px !important;
font-family: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import Image from "next/image";
/**
* Main image + thumbnail strip, swappable on click. `image` is always
* slide zero, `gallery` (Products.gallery, optional/empty for most
* products) fills in the rest. Renders as a single plain image with no
* thumbnail row at all when `gallery` is empty — the common case, and
* exactly today's pre-gallery appearance, no behavior change for any
* product that hasn't opted in. Plain divs/buttons, no carousel package —
* same "no charting/UI-library dependency for a simple case" reasoning as
* OrderQueueWidget.tsx's own OrderSparkline.
*/
export function ProductGallery({ image, gallery, alt }: { image: string; gallery: string[]; alt: string }) {
const slides = [image, ...gallery];
const [current, setCurrent] = useState(0);
if (slides.length <= 1) {
return (
<div className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base">
<Image src={image} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
</div>
);
}
function next() {
setCurrent((c) => (c + 1) % slides.length);
}
function prev() {
setCurrent((c) => (c - 1 + slides.length) % slides.length);
}
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">
<Image src={slides[current]} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
<button
type="button"
onClick={prev}
aria-label="Vorheriges Bild"
className="absolute left-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M15 19l-7-7 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="Nächstes Bild"
className="absolute right-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<span className="absolute bottom-3.5 right-3.5 rounded-full bg-text-primary/60 px-2.5 py-1 text-label font-semibold text-white tabular-nums">
{current + 1} / {slides.length}
</span>
</div>
<div className="flex gap-2.5">
{slides.map((src, i) => (
<button
key={src + i}
type="button"
onClick={() => setCurrent(i)}
aria-label={`Bild ${i + 1} anzeigen`}
aria-current={i === current}
className={`relative h-[4.75rem] w-[4.75rem] shrink-0 overflow-hidden rounded-sm border-2 transition-opacity ${
i === current ? "border-brand opacity-100" : "border-transparent opacity-70 hover:opacity-100"
}`}
>
<Image src={src} alt="" fill sizes="76px" className="object-cover" />
</button>
))}
</div>
</div>
);
}
+1
View File
@@ -10,6 +10,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
price: 12.9,
compareAtPrice: null,
image: "",
gallery: [],
href: null,
active: true,
updatedAt: new Date().toISOString(),
+6
View File
@@ -15,6 +15,7 @@ export type KlaroConfig = {
acceptAll: boolean;
hideDeclineAll: boolean;
noticeAsModal: boolean;
disablePoweredBy: boolean;
translations: Record<string, Record<string, unknown>>;
services: {
name: string;
@@ -96,6 +97,11 @@ export function buildKlaroConfig(codes: TrackingCode[], onAccept: (code: Trackin
acceptAll: true,
hideDeclineAll: false,
noticeAsModal: false,
// Removes the "Realisiert mit Klaro!" attribution link in the modal —
// BSD-3-Clause has no on-page-attribution requirement (only source/
// binary copyright-notice retention), this is just the maintainers'
// own polite ask via a dedicated config flag, not a license term.
disablePoweredBy: true,
translations: {
de: {
consentModal: {
+9
View File
@@ -185,6 +185,11 @@ export type Product = {
price: number;
compareAtPrice: number | null;
image: string;
// Extra photos beyond `image` above, for ProductGallery.tsx's
// thumbnail-strip UI — empty for the vast majority of products (opt-in
// per product, see Products.ts's own field comment). Never includes
// `image` itself; ProductGallery treats `image` as always-slide-zero.
gallery: string[];
href: string | null;
// `active` is opt-in for callers to filter by, not applied inside
// getProducts()/getProductBySlug() themselves — cart, checkout, order
@@ -250,6 +255,7 @@ type PayloadProduct = {
price: number;
compareAtPrice: number | null;
image: { url: string } | number | null;
gallery: ({ url: string } | number)[] | null;
detailHref: string | null;
active: boolean;
updatedAt: string;
@@ -314,6 +320,9 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
price: product.price,
compareAtPrice: product.compareAtPrice ?? null,
image: typeof product.image === "object" && product.image ? product.image.url : "",
gallery: (product.gallery ?? [])
.map((g) => (typeof g === "object" && g ? g.url : null))
.filter((url): url is string => Boolean(url)),
href: product.detailHref || null,
active: product.active,
updatedAt: product.updatedAt,
+27 -14
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { ProductGallery } from "../../components/ProductGallery";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
@@ -163,20 +164,32 @@ export async function TodoKartenHero() {
(not the image) keeps the zoom from spilling past the rounded
corners. delay={0.15} matches the Home Hero's text→image
stagger — both fire ~immediately since Hero is already in the
initial viewport, so this doubles as the page's entrance. */}
<Reveal
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
delay={0.15}
>
<Image
src="/hero-todo-karten.png"
alt="ToDo-Karten in Anwendung, mit Notizbuch und Kaffee"
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
initial viewport, so this doubles as the page's entrance.
Falls back to the curated static hero photo (a lifestyle shot,
"in Anwendung, mit Notizbuch und Kaffee" — deliberately not
just the plain catalog image) whenever this product has no
`gallery` entries — the common case, and zero visual change
from before ProductGallery existed. Only switches to the
gallery once someone actually adds extra photos in the admin. */}
{product && product.gallery.length > 0 ? (
<Reveal className="order-2 lg:order-none lg:col-span-7" delay={0.15}>
<ProductGallery image={product.image} gallery={product.gallery} alt={product.name} />
</Reveal>
) : (
<Reveal
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
delay={0.15}
>
<Image
src="/hero-todo-karten.png"
alt="ToDo-Karten in Anwendung, mit Notizbuch und Kaffee"
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
)}
</div>
</section>
);