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
+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 = [
{