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
+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>
);