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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-2
@@ -3,6 +3,7 @@ import { CartContent } from "./components/CartContent";
|
||||
import { RelatedProducts } from "./components/RelatedProducts";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getCartTrustBadges, getShippingMethods } from "../lib/payload";
|
||||
|
||||
// robots: noindex — transactional page (mirrors a specific shopper's cart
|
||||
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
|
||||
@@ -16,11 +17,28 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function CartPage() {
|
||||
export default async function CartPage() {
|
||||
const [trustBadges, shippingMethods] = await Promise.all([getCartTrustBadges(), getShippingMethods()]);
|
||||
|
||||
// The cart doesn't ask which shipping method the shopper wants yet
|
||||
// (that's /checkout) — it just estimates using the first active method
|
||||
// (Standard, by sortOrder) for the sidebar's "Versand" line, and shows
|
||||
// the FreeShippingBanner toward whichever active method's threshold is
|
||||
// lowest/easiest to reach (Express has none — it never goes free).
|
||||
const defaultShipping = shippingMethods[0] ?? null;
|
||||
const thresholds = shippingMethods
|
||||
.map((m) => m.freeShippingThreshold)
|
||||
.filter((t): t is number => t !== null);
|
||||
const freeShippingThreshold = thresholds.length > 0 ? Math.min(...thresholds) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CartContent />
|
||||
<CartContent
|
||||
trustBadges={trustBadges}
|
||||
shippingCost={defaultShipping?.price ?? 0}
|
||||
freeShippingThreshold={freeShippingThreshold}
|
||||
/>
|
||||
<RelatedProducts />
|
||||
<TrustRow />
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user