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