Move trust badges, shipping/payment methods, Werkzeuge cards, product spotlight into CMS

New Payload collections, all editable without a code deploy:
- TrustBadges: the horizontal Schneller-Versand/Versandkostenfrei/Mit-
  Liebe-verpackt row (TrustRow.tsx, now an async server component).
- CartTrustBadges: the Sichere-Zahlung/14-Tage-Rückgaberecht/Nachhaltig-
  verpackt sidebar bullets — shared by /cart (title only) and /checkout
  (title + description), which previously had two different hardcoded
  bullet lists for what's conceptually the same content.
- ShippingMethods: /checkout's Versandart radios. Each method has its own
  optional freeShippingThreshold — omitted means "never free" (Express),
  not "always free". /cart's FreeShippingBanner now targets the lowest
  threshold among active methods instead of a single global constant, and
  hides entirely if no active method has one.
- PaymentMethods: /checkout's Zahlungsart radios, icons as an array
  (Kreditkarte shows 3 logos, PayPal/Überweisung show 1).
- Products: new spotlight/spotlightHeadline/spotlightText/spotlightImage/
  compareAtPrice fields. ProductSpotlight.tsx (homepage) now shows
  whichever product has `spotlight` checked instead of being hardcoded to
  ToDo-Karten, with its own marketing copy separate from the plain
  catalog name/description. AddToCartButton takes an explicit productId
  prop now instead of a hardcoded "todo-karten" constant.
- WerkzeugeCards: the homepage's "Meine Werkzeuge" 3-card grid (Tools.tsx,
  now async). Icons use a uniform box instead of the previous per-card
  hand-tuned width/height/rotation, which only worked for 3 known,
  upside-down-authored SVGs — those were re-exported as pre-flipped PNGs.

lib/payload.ts gained getTrustBadges/getCartTrustBadges/getShippingMethods/
getPaymentMethods/getWerkzeugeCards/getSpotlightProduct, all with the same
graceful-empty-array-on-fetch-failure pattern as the existing functions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-19 23:15:46 +00:00
parent 754966c8ba
commit d472eb546f
10 changed files with 419 additions and 186 deletions
+27 -13
View File
@@ -6,12 +6,25 @@ import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
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";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge } from "../../lib/payload";
export function CartContent() {
export function CartContent({
trustBadges,
shippingCost,
freeShippingThreshold,
}: {
trustBadges: TrustBadge[];
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
* estimate, since the cart doesn't ask which method the shopper wants
* yet (that's /checkout). */
shippingCost: number;
/** Lowest freeShippingThreshold among active ShippingMethods, or null if
* none has one (in which case FreeShippingBanner just doesn't render). */
freeShippingThreshold: number | null;
}) {
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
const products = useProducts();
@@ -27,7 +40,10 @@ export function CartContent() {
.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);
const shipping = items.length === 0 || subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_COST;
const shipping =
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
? 0
: shippingCost;
const total = subtotal + shipping;
return (
@@ -70,7 +86,7 @@ export function CartContent() {
) : (
<>
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
<FreeShippingBanner subtotal={subtotal} />
<FreeShippingBanner subtotal={subtotal} threshold={freeShippingThreshold} />
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-4 px-[var(--layout-padding-x)] w-full">
{/* Cart card — lg:-only split from the sidebar (same "wide content
@@ -179,8 +195,8 @@ export function CartContent() {
</span>
</div>
<p className="text-label text-text-muted">
{shipping === 0
? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands`
{shipping === 0 && freeShippingThreshold !== null
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
: "innerhalb Deutschlands"}
</p>
</div>
@@ -214,15 +230,13 @@ export function CartContent() {
</div>
</div>
{/* Title only — /checkout renders the same CartTrustBadges
docs with description too, see CheckoutContent.tsx. */}
<div className="flex flex-col gap-4 items-start w-full">
{[
{ icon: "/icon-trust-leaf.png", text: "Nachhaltig produziert in Deutschland" },
{ icon: "/icon-trust-materials.png", text: "Hochwertige Materialien" },
{ icon: "/icon-trust-return.png", text: "14 Tage Rückgaberecht" },
].map((b) => (
<div key={b.text} className="flex gap-3 items-center w-full">
{trustBadges.map((b) => (
<div key={b.id} className="flex gap-3 items-center w-full">
<img alt="" src={b.icon} className="size-[1.375rem] shrink-0 object-contain" />
<span className="flex-1 text-body-sm text-text-primary">{b.text}</span>
<span className="flex-1 text-body-sm text-text-primary">{b.title}</span>
</div>
))}
</div>
+17 -5
View File
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
import { formatPrice } from "../../lib/format";
const SUCCESS_VISIBLE_MS = 2500;
@@ -14,9 +13,22 @@ type Phase = "progress" | "success" | "hidden";
* /shop, per the deliberately narrower scope agreed with the user (a cart
* item can leave/re-enter the threshold as quantities change, so this
* needs to react to that, not just fire once).
*
* `threshold` is the lowest freeShippingThreshold among /checkout's active
* ShippingMethods (computed by the caller) — not every method necessarily
* has one (Express never does, it always costs extra), so this shows the
* easiest one to reach rather than an arbitrary/average value. `null`
* means no active method has a threshold at all, so there's nothing to
* nudge toward — the banner just doesn't render.
*/
export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
const reached = subtotal >= FREE_SHIPPING_THRESHOLD;
export function FreeShippingBanner({ subtotal, threshold }: { subtotal: number; threshold: number | null }) {
if (threshold === null) return null;
return <FreeShippingBannerInner subtotal={subtotal} threshold={threshold} />;
}
function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; threshold: number }) {
const reached = subtotal >= threshold;
const [phase, setPhase] = useState<Phase>(reached ? "success" : "progress");
// React's documented pattern for "adjust state when a prop changes" —
@@ -50,8 +62,8 @@ export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
return () => clearTimeout(t);
}, [phase]);
const remaining = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal);
const progressPct = Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100);
const remaining = Math.max(0, threshold - subtotal);
const progressPct = Math.min(100, (subtotal / threshold) * 100);
return (
// AnimatePresence + exit, not a plain `if (phase === "hidden") return