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
+20
View File
@@ -1854,6 +1854,26 @@ has its own dedicated Live Preview renderer, not the shared generic
order-status one — there's no order for this email type, so no fake
"Bestellnummer" line; CTA is "Zum Produkt", not "Bestellung ansehen".
## Product image gallery
`ProductGallery.tsx` — main image + thumbnail strip, swappable on click,
backed by an optional `Products.gallery` field (backend, `hasMany` upload,
empty for most products — see the Payload README). Renders as a single
plain image with no thumbnail row at all when `gallery` is empty, i.e. the
same appearance every product already had before this existed — a product
only gets the gallery UI once someone deliberately adds extra photos in
the admin.
There's no generic `/shop/[slug]` product detail page in this app — every
product beyond the plain grid card gets a bespoke hero page (currently
just `/todo-cards`), matching the custom-per-client site model.
`ProductGallery` is wired into `TodoKartenHero.tsx` as a drop-in
replacement for that page's static hero photo — but only once
`product.gallery.length > 0`; otherwise the curated static lifestyle
photo (`/hero-todo-karten.png`, deliberately not just the catalog image)
stays exactly as it was. A future bespoke product page can reuse the same
component the same way.
## Tests
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
+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 textimage
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>
);