Move product catalog to Payload CMS

Products now come from Payload's new "products" collection instead of a
hardcoded catalog, same pattern already used for blog posts:

- lib/payload.ts: getProducts()/getProductBySlug() (server-side fetch,
  60s ISR)
- New /api/products route so client components (CartContent,
  RelatedProducts) can reach the same data without a server-only import
- lib/products.ts: useProducts() hook replacing the old PRODUCTS record
- ProductGrid (/shop) fetches server-side directly; now shows all
  catalog products except notizbuch-klarheit (matches Figma's 4-card
  page-shop-overview — still cross-sold via RelatedProducts)
- ProductSpotlight and /todo-cards' Pricing now pull price/photo from
  the same CMS product instead of a separately hardcoded "12,90 €", so
  the two can't silently drift apart
- formatPrice moved to a new lib/format.ts (plain, no "use client") —
  Server Components can't call functions exported from a "use client"
  module directly, which lib/products.ts now is because of the hook

Also fixes two unrelated bugs surfaced along the way: the add-to-cart
button visibly resizing when its "Hinzugefügt ✓" success state showed
(fixed with a CSS-grid text stack sized to the wider of the two
strings), and removes the now-unused local product images from public/.
This commit is contained in:
Marco
2026-07-19 17:00:07 +00:00
parent c595e89305
commit 8c397c5dcb
19 changed files with 346 additions and 204 deletions
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { getProducts } from "../../lib/payload";
// Same-origin proxy for client components (CartContent, RelatedProducts)
// that need the full catalog reactively — Payload's own API is public-read
// and CORS wouldn't be a blocker, but going through the app's own origin
// avoids depending on that, reuses Next.js's fetch cache from getProducts()
// (no extra round trip to Payload beyond the first request within the 60s
// revalidate window), and keeps the Payload URL itself as a server-only
// implementation detail the client never talks to directly.
export async function GET() {
const products = await getProducts();
return NextResponse.json(products);
}
+17 -4
View File
@@ -4,7 +4,8 @@ import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { PRODUCTS, formatPrice } from "../../lib/products";
import { useProducts } from "../../lib/products";
import { formatPrice } from "../../lib/format";
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
@@ -13,8 +14,16 @@ import { FreeShippingBanner } from "./FreeShippingBanner";
export function CartContent() {
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
const products = useProducts();
// While the /api/products fetch is still pending, treat a non-empty
// cart as "loading" rather than "empty" — the old hardcoded PRODUCTS
// lookup was synchronous, so this distinction didn't exist before;
// without it, a returning shopper with items already in their cart
// would briefly see the empty-cart message flash before their real
// cart content gets a chance to render.
const productsLoading = products.length === 0 && cart.length > 0;
const items = cart
.map((entry) => ({ entry, product: PRODUCTS[entry.id] }))
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
@@ -37,11 +46,15 @@ export function CartContent() {
Warenkorb
</p>
<p className="text-body text-text-muted">
{items.length > 0 ? "Schön, dass du da bist." : "Dein Warenkorb ist noch leer."}
{productsLoading
? "Einen Moment…"
: items.length > 0
? "Schön, dass du da bist."
: "Dein Warenkorb ist noch leer."}
</p>
</Reveal>
{items.length === 0 ? (
{productsLoading ? null : items.length === 0 ? (
<Reveal className="flex flex-col gap-6 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
<p className="text-body text-text-body">
Schau dir unsere Produkte an und finde, was zu dir passt.
+36 -26
View File
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
import { formatPrice } from "../../lib/products";
import { formatPrice } from "../../lib/format";
const SUCCESS_VISIBLE_MS = 2500;
@@ -49,36 +50,45 @@ export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
return () => clearTimeout(t);
}, [phase]);
if (phase === "hidden") return null;
const remaining = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal);
const progressPct = Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100);
return (
<div
className={
"w-full rounded-md border p-4 flex flex-col gap-2 transition-colors duration-300 " +
(phase === "success" ? "border-success bg-success-subtle" : "border-border bg-bg-base")
}
>
<p
className={
"text-body-sm font-semibold " + (phase === "success" ? "text-success" : "text-text-primary")
}
>
{phase === "success"
? "Kostenloser Versand freigeschaltet ✓"
: `Noch ${formatPrice(remaining)} bis zum kostenlosen Versand!`}
</p>
<div className="h-1.5 w-full rounded-full bg-border overflow-hidden">
<div
// AnimatePresence + exit, not a plain `if (phase === "hidden") return
// null` — that cut the banner instantly with no transition. initial={false}
// keeps the appearance/re-appearance instant (only the disappearance
// fades) since that's the specific bit that was asked to be smoother.
<AnimatePresence>
{phase !== "hidden" && (
<motion.div
initial={false}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className={
"h-full rounded-full transition-[width] duration-500 ease-out " +
(phase === "success" ? "bg-success" : "bg-brand")
"w-full rounded-md border p-4 flex flex-col gap-2 transition-colors duration-300 " +
(phase === "success" ? "border-success bg-success-subtle" : "border-border bg-bg-base")
}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
>
<p
className={
"text-body-sm font-semibold " + (phase === "success" ? "text-success" : "text-text-primary")
}
>
{phase === "success"
? "Kostenloser Versand freigeschaltet ✓"
: `Noch ${formatPrice(remaining)} bis zum kostenlosen Versand!`}
</p>
<div className="h-1.5 w-full rounded-full bg-border overflow-hidden">
<div
className={
"h-full rounded-full transition-[width] duration-500 ease-out " +
(phase === "success" ? "bg-success" : "bg-brand")
}
style={{ width: `${progressPct}%` }}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+65 -57
View File
@@ -1,54 +1,61 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { PRODUCTS, RELATED_PRODUCT_IDS, formatPrice } from "../../lib/products";
import { useProducts } from "../../lib/products";
import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { useCart } from "../../lib/cart";
const ALL_PRODUCT_IDS = Object.keys(PRODUCTS);
const DISPLAY_COUNT = 3;
function pickRandom(excludeIds: string[], count: number): string[] {
const pool = ALL_PRODUCT_IDS.filter((id) => !excludeIds.includes(id));
function pickRandom(allIds: string[], excludeIds: string[], count: number): string[] {
const pool = allIds.filter((id) => !excludeIds.includes(id));
const shuffled = [...pool].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
export function RelatedProducts() {
const cart = useCart();
const products = useProducts();
const hasItems = cart.length > 0;
const cartKey = cart
.map((i) => i.id)
.sort()
.join(",");
// useMemo, not a plain .map() — .map() would return a new array
// reference on every render regardless of whether `products` itself
// changed, which would make the effect below re-run (and re-pick) every
// single render if `productIds` were listed as its dependency.
const productIds = useMemo(() => products.map((p) => p.id), [products]);
// Math.random() can't run during the render that has to match the SSR
// pass (that would be a hydration mismatch — server and client would
// pick different products) — so the first paint always shows the fixed
// RELATED_PRODUCT_IDS fallback, and a client-only effect swaps in the
// real, cart-aware random pick right after mount.
const [displayIds, setDisplayIds] = useState<string[]>(RELATED_PRODUCT_IDS);
// Starts empty — the catalog itself is now fetched (useProducts()), so
// there's nothing to pick a random set from until that resolves. The
// effect below picks as soon as products arrive, same idea as
// CartContent's productsLoading guard.
const [displayIds, setDisplayIds] = useState<string[]>([]);
// First run (mount/hydration): pick the initial random set. After that,
// don't reshuffle everything on every cart change — one of these cards'
// own AddToCartInlineButton adds to the cart without navigating away,
// and a full reshuffle would yank the other two cards out from under
// the user mid-browse. Instead, only swap out whichever displayed card
// just became "already in the cart" (no longer relevant to recommend)
// and top that one slot back up — the rest stay exactly where they were.
// First run once products have loaded: pick the initial random set.
// After that, don't reshuffle everything on every cart change — one of
// these cards' own AddToCartInlineButton adds to the cart without
// navigating away, and a full reshuffle would yank the other two cards
// out from under the user mid-browse. Instead, only swap out whichever
// displayed card just became "already in the cart" (no longer relevant
// to recommend) and top that one slot back up — the rest stay exactly
// where they were.
const pickedRef = useRef(false);
const swapTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(swapTimeoutRef.current), []);
useEffect(() => {
if (productIds.length === 0) return;
const cartIds = cartKey ? cartKey.split(",") : [];
if (!pickedRef.current) {
pickedRef.current = true;
setDisplayIds(pickRandom(cartIds, DISPLAY_COUNT));
setDisplayIds(pickRandom(productIds, cartIds, DISPLAY_COUNT));
return;
}
@@ -63,26 +70,30 @@ export function RelatedProducts() {
const missing = DISPLAY_COUNT - stillRelevant.length;
if (missing <= 0) return stillRelevant;
let replacements = pickRandom([...cartIds, ...stillRelevant], missing);
let replacements = pickRandom(productIds, [...cartIds, ...stillRelevant], missing);
// The catalog only has 5 products — once the cart holds 3+
// distinct ones, "3 recommendations that aren't already in the
// cart" becomes mathematically impossible (5 - 3 in cart leaves
// only 2 to show). Falling back to re-suggesting something
// already in the cart (a normal "grab another one" pattern) beats
// silently shrinking the grid below DISPLAY_COUNT.
// The catalog only has a handful of products — once the cart
// holds enough distinct ones, "N recommendations that aren't
// already in the cart" can become impossible. Falling back to
// re-suggesting something already in the cart (a normal "grab
// another one" pattern) beats silently shrinking the grid below
// DISPLAY_COUNT.
if (replacements.length < missing) {
const stillMissing = missing - replacements.length;
const fallback = pickRandom([...stillRelevant, ...replacements], stillMissing);
const fallback = pickRandom(productIds, [...stillRelevant, ...replacements], stillMissing);
replacements = [...replacements, ...fallback];
}
return [...stillRelevant, ...replacements];
});
}, FEEDBACK_MS);
}, [cartKey]);
}, [cartKey, productIds]);
if (displayIds.length === 0) return null;
const displayProducts = displayIds
.map((id) => products.find((p) => p.id === id))
.filter((p): p is NonNullable<typeof p> => Boolean(p));
if (displayProducts.length === 0) return null;
return (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
@@ -114,35 +125,32 @@ export function RelatedProducts() {
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayIds.map((id) => {
const product = PRODUCTS[id];
return (
<div
key={id}
className="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 768px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
<AddToCartInlineButton id={id} />
</div>
{displayProducts.map((product) => (
<div
key={product.id}
className="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 768px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
);
})}
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
<AddToCartInlineButton id={product.id} />
</div>
</div>
))}
</div>
</section>
);
+16 -1
View File
@@ -54,7 +54,22 @@ export function AddToCartButton({
onClick={handleClick}
className={`${base} ${stateClasses}`}
>
{added ? "Hinzugefügt ✓" : label}
{/* CSS-grid text-stack, not just swapping the button's text node
directly — this button is inline-flex/content-sized (no w-full),
so "Hinzugefügt ✓" being shorter than most labels made the whole
button visibly shrink while showing the success state. Stacking
both possible texts in the same grid cell (both invisible ones
still contribute to sizing) reserves width for whichever is
wider, so the button's box never changes size either way. */}
<span className="relative grid">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Hinzugefügt
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : label}</span>
</span>
</button>
);
}
+1 -1
View File
@@ -48,7 +48,7 @@ export function AddToCartInlineButton({
// border-border/border-success both being present would silently race on
// CSS source order instead of one cleanly winning.
const stateClasses = added
? "border-success bg-success-subtle scale-[1.02]"
? "border-success bg-success-subtle"
: "border-border hover:border-brand";
return (
+13 -4
View File
@@ -2,6 +2,8 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getProductBySlug } from "../lib/payload";
import { formatPrice } from "../lib/format";
/**
* Product teaser for ToDo-Karten, placed after the Werkzeuge section (not
@@ -13,15 +15,22 @@ import { Reveal } from "./Reveal";
* than dropped at the very top before the page has earned any trust.
* Not derived from a Figma frame (page-home never had this section) —
* a deliberate, code-only addition, styled to match the /todo-cards
* pricing panel it's a teaser for.
* pricing panel it's a teaser for. Price/photo come from Payload (same
* "todo-karten" product the shop/cart use) rather than being duplicated
* here as a hardcoded literal, so they can never silently drift apart —
* the marketing headline/copy below stays hand-written, since it's
* deliberately punchier than the plain catalog description.
*/
export function ProductSpotlight() {
export async function ProductSpotlight() {
const product = await getProductBySlug("todo-karten");
if (!product) return null;
return (
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src="/product-todo-karten.png"
src={product.image}
alt="ToDo-Karten Set"
fill
sizes="(min-width: 768px) 380px, 100vw"
@@ -41,7 +50,7 @@ export function ProductSpotlight() {
50 hochwertige Karten, die dir helfen, deinen Kopf frei zu bekommen und das Wesentliche zu sehen analog, minimalistisch, für jeden Tag.
</p>
<div className="flex gap-2 items-center">
<p className="font-bold text-h3 text-text-primary">12,90 </p>
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
+1 -1
View File
@@ -1,5 +1,5 @@
import { FREE_SHIPPING_THRESHOLD, TOTAL_DAYS_DE } from "../lib/shipping";
import { formatPrice } from "../lib/products";
import { formatPrice } from "../lib/format";
const items = [
{
+11
View File
@@ -0,0 +1,11 @@
// Plain utility, no client-only behavior — kept out of lib/products.ts
// (which is "use client" for its useProducts() hook) specifically so
// Server Components can still call it directly. Any export from a
// "use client" module becomes a client-only reference as far as Next.js's
// RSC boundary is concerned, even a pure function with zero hooks — a
// Server Component importing formatPrice from products.ts fails at
// runtime with "Attempted to call formatPrice() from the server but
// formatPrice is on the client."
export function formatPrice(value: number): string {
return `${value.toFixed(2).replace(".", ",")}`;
}
+57
View File
@@ -55,3 +55,60 @@ export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
: null,
}));
}
// Frontend-facing shape — `id` is Payload's `slug` field, not its numeric
// row id. Cart items are stored in localStorage keyed by this string (see
// lib/cart.ts), so slugs were chosen in the Products collection to match
// the ids the old hardcoded catalog used ("todo-karten" etc.) — switching
// to numeric ids here would silently orphan every existing shopper's cart.
export type Product = {
id: string;
name: string;
description: string;
price: number;
image: string;
href: string | null;
};
type PayloadProduct = {
id: number;
name: string;
slug: string;
description: string | null;
price: number;
image: { url: string } | number | null;
detailHref: string | null;
};
export async function getProducts(): Promise<Product[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
sort: "sortOrder",
depth: "2",
limit: "100",
});
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getProducts: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadProduct[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((product) => ({
id: product.slug,
name: product.name,
description: product.description ?? "",
price: product.price,
image: typeof product.image === "object" && product.image ? product.image.url : "",
href: product.detailHref || null,
}));
}
export async function getProductBySlug(slug: string): Promise<Product | null> {
const products = await getProducts();
return products.find((p) => p.id === slug) ?? null;
}
+44 -60
View File
@@ -1,65 +1,49 @@
// Small in-code product catalog — this project has no commerce backend,
// so cart items (stored in localStorage as {id, qty} pairs, see cart.ts)
// need somewhere to look up name/price/photo/description by id. Only
// "todo-karten" has a real detail page (/todo-cards) right now; the
// other four exist because they're shown in Figma's page-cart "Passt
// perfekt dazu" row and are addable to the cart from there, same as the
// real product — they just don't have their own detail pages built yet
// (matching the project's established pattern of cross-linking to
// not-yet-built routes rather than leaving buttons disconnected).
export type Product = {
id: string;
name: string;
description: string;
price: number;
image: string;
href?: string;
};
"use client";
export const PRODUCTS: Record<string, Product> = {
"todo-karten": {
id: "todo-karten",
name: "ToDo-Karten Set",
description: "50 ToDo-Karten für mehr Fokus und Klarheit im Alltag.",
price: 12.9,
image: "/product-todo-karten.png",
href: "/todo-cards",
},
"notizbuch-klarheit": {
id: "notizbuch-klarheit",
name: "Notizbuch Klarheit",
description: "Dein Begleiter für Gedanken, Notizen und neue Perspektiven.",
price: 9.9,
image: "/product-notizbuch-klarheit.png",
},
wochenplaner: {
id: "wochenplaner",
name: "Wochenplaner Überblick",
description: "Behalte deine Woche im Blick und setze klare Prioritäten.",
price: 14.9,
image: "/product-wochenplaner.png",
},
"notizbuch-fokus": {
id: "notizbuch-fokus",
name: "Notizbuch Fokus",
description: "Für mehr Konzentration und einen klaren Kopf im Alltag.",
price: 9.9,
image: "/product-notizbuch-fokus.png",
},
zielkarten: {
id: "zielkarten",
name: "Zielkarten Set",
description: "Definiere deine Ziele und behalte sie fest im Blick.",
price: 11.9,
image: "/product-zielkarten.png",
},
};
import { useEffect, useState } from "react";
import type { Product } from "./payload";
export const RELATED_PRODUCT_IDS = ["wochenplaner", "notizbuch-fokus", "zielkarten"];
export type { Product };
// Shop overview grid order — mirrors page-shop-overview in Figma.
export const SHOP_PRODUCT_IDS = ["todo-karten", "wochenplaner", "notizbuch-fokus", "zielkarten"];
// Products now live in Payload's "products" collection (tenant
// einfach-produktiv), not a hardcoded catalog — see lib/payload.ts's
// getProducts() for the server-side fetch. Client components (CartContent,
// RelatedProducts) can't call that directly the way a Server Component
// can, so this hook fetches the same-origin /api/products proxy instead,
// with a tiny module-level cache so /cart's two consumers (CartContent +
// RelatedProducts) share one request instead of firing it twice.
let cache: Product[] | null = null;
let inflight: Promise<Product[]> | null = null;
export function formatPrice(value: number): string {
return `${value.toFixed(2).replace(".", ",")}`;
async function fetchProducts(): Promise<Product[]> {
if (cache) return cache;
if (!inflight) {
inflight = fetch("/api/products")
.then((res) => (res.ok ? res.json() : []))
.then((data: Product[]) => {
cache = data;
return data;
})
.catch(() => []);
}
return inflight;
}
// Starts empty (SSR-safe — matches useCart()'s pattern of a safe default
// that fills in after a client-only effect, see lib/cart.ts) and updates
// once the fetch resolves.
export function useProducts(): Product[] {
const [products, setProducts] = useState<Product[]>(cache ?? []);
useEffect(() => {
let cancelled = false;
fetchProducts().then((data) => {
if (!cancelled) setProducts(data);
});
return () => {
cancelled = true;
};
}, []);
return products;
}
+58 -46
View File
@@ -1,61 +1,73 @@
import Link from "next/link";
import Image from "next/image";
import { PRODUCTS, SHOP_PRODUCT_IDS, formatPrice } from "../../lib/products";
import { getProducts } from "../../lib/payload";
import { formatPrice } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
export function ProductGrid() {
// Server Component — fetches straight from Payload (getProducts(), ISR
// cached 60s) rather than going through the client-side useProducts()
// hook /cart's components need; this grid doesn't react to cart state, so
// there's no reason to pay for a client fetch when a server one already
// gives faster first paint and no loading flash.
// "notizbuch-klarheit" is deliberately excluded from the shop grid — it
// has no card in Figma's page-shop-overview (only 4 products do), even
// though it's a real product in Payload. Still shown elsewhere as a
// cross-sell (RelatedProducts on /cart).
const SHOP_GRID_EXCLUDE_IDS = ["notizbuch-klarheit"];
export async function ProductGrid() {
const products = (await getProducts()).filter((p) => !SHOP_GRID_EXCLUDE_IDS.includes(p.id));
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
{SHOP_PRODUCT_IDS.map((id) => {
const product = PRODUCTS[id];
return (
<RevealItem
key={id}
className="group md:col-span-3 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: 768px) 25vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
{products.map((product) => (
<RevealItem
key={product.id}
className="group md:col-span-3 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: 768px) 25vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
<p className="flex items-baseline gap-1.5">
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. MwSt.</span>
</p>
{product.href && (
<Link
href={product.href}
className="flex items-center gap-1 font-bold text-label text-brand hover:underline"
>
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
{product.href && (
<Link
href={product.href}
className="flex items-center gap-1 font-bold text-label text-brand hover:underline"
>
<span>Mehr erfahren</span>
<span aria-hidden></span>
</Link>
)}
<span>Mehr erfahren</span>
<span aria-hidden></span>
</Link>
)}
{/* flex-1 spacer — pins every card's button to the same Y
regardless of the "Mehr erfahren" line only the
todo-karten card has (see figma-to-nextjs skill's
Tools.tsx equal-height lesson). */}
<div className="flex-1" />
{/* 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={id} />
</div>
</RevealItem>
);
})}
<AddToCartInlineButton id={product.id} />
</div>
</RevealItem>
))}
</RevealGroup>
<p className="text-label text-text-muted pt-6">Alle Preise inkl. MwSt., zzgl. Versandkosten.</p>
</section>
);
}
+12 -3
View File
@@ -2,6 +2,8 @@ import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { TOTAL_DAYS_DE } from "../../lib/shipping";
import { getProductBySlug } from "../../lib/payload";
import { formatPrice } from "../../lib/format";
const bullets = [
"50 ToDo-Karten",
@@ -11,7 +13,14 @@ const bullets = [
"Nachhaltig produziert in Deutschland",
];
export function Pricing() {
// Price/photo come from Payload (same "todo-karten" product the shop/cart
// use), not a hardcoded literal — see ProductSpotlight.tsx for the same
// reasoning; the bullet list stays hand-written since it's spec detail,
// not something the Products collection models.
export async function Pricing() {
const product = await getProductBySlug("todo-karten");
if (!product) return null;
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
{/* Three flex children (photo, info, price/CTA) competing for space
@@ -20,7 +29,7 @@ export function Pricing() {
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src="/product-todo-karten.png"
src={product.image}
alt="ToDo-Karten Set"
fill
sizes="(min-width: 1024px) 410px, 100vw"
@@ -47,7 +56,7 @@ export function Pricing() {
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<div className="flex gap-2 items-center">
<p className="font-bold text-h3 text-text-primary">12,90 </p>
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
</div>
<p className="text-label text-text-muted">
+1 -1
View File
@@ -6,7 +6,7 @@ import {
TRANSIT_DAYS_DE,
TOTAL_DAYS_DE,
} from "../../lib/shipping";
import { formatPrice } from "../../lib/products";
import { formatPrice } from "../../lib/format";
// Single source of truth for both the full /versand page and the cart's
// quick-reference VersandModal — same section ids/titles/copy either way,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB