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