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:
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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 = [
|
||||
{
|
||||
|
||||
@@ -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(".", ",")} €`;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user