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>
|
||||
|
||||
@@ -6,11 +6,9 @@ import Image from "next/image";
|
||||
import { useCart } 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";
|
||||
|
||||
const EXPRESS_SHIPPING_COST = 4.9;
|
||||
import type { ShippingMethod, PaymentMethod, TrustBadge } from "../../lib/payload";
|
||||
|
||||
const steps = [
|
||||
{ label: "Warenkorb", state: "done" as const },
|
||||
@@ -59,11 +57,19 @@ function FormField({
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckoutContent() {
|
||||
export function CheckoutContent({
|
||||
shippingMethods,
|
||||
paymentMethods,
|
||||
trustBadges,
|
||||
}: {
|
||||
shippingMethods: ShippingMethod[];
|
||||
paymentMethods: PaymentMethod[];
|
||||
trustBadges: TrustBadge[];
|
||||
}) {
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
const [shippingMethod, setShippingMethod] = useState<"standard" | "express">("standard");
|
||||
const [paymentMethod, setPaymentMethod] = useState<"card" | "paypal" | "bank">("card");
|
||||
const [shippingMethodId, setShippingMethodId] = useState<number | null>(shippingMethods[0]?.id ?? null);
|
||||
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(paymentMethods[0]?.id ?? null);
|
||||
const [versandOpen, setVersandOpen] = useState(false);
|
||||
|
||||
const productsLoading = products.length === 0 && cart.length > 0;
|
||||
@@ -72,12 +78,12 @@ export function CheckoutContent() {
|
||||
.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 freeShipping = subtotal >= FREE_SHIPPING_THRESHOLD;
|
||||
const shipping = items.length === 0 || freeShipping
|
||||
? 0
|
||||
: shippingMethod === "express"
|
||||
? EXPRESS_SHIPPING_COST
|
||||
: SHIPPING_COST;
|
||||
const selectedShipping = shippingMethods.find((m) => m.id === shippingMethodId) ?? null;
|
||||
const freeShipping =
|
||||
selectedShipping?.freeShippingThreshold !== null &&
|
||||
selectedShipping?.freeShippingThreshold !== undefined &&
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
const total = subtotal + shipping;
|
||||
|
||||
if (!productsLoading && items.length === 0) {
|
||||
@@ -209,29 +215,27 @@ export function CheckoutContent() {
|
||||
>
|
||||
2. Versandart
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
{ id: "standard", label: "Standardversand (2–4 Werktage)", price: SHIPPING_COST },
|
||||
{ id: "express", label: "Expressversand (1–2 Werktage)", price: EXPRESS_SHIPPING_COST },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<label key={option.id} className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="shipping"
|
||||
checked={shippingMethod === option.id}
|
||||
onChange={() => setShippingMethod(option.id)}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 flex flex-col gap-0.5">
|
||||
<span className="text-body-sm text-text-primary">{option.label}</span>
|
||||
<span className="text-label text-text-muted">innerhalb Deutschlands</span>
|
||||
</span>
|
||||
<span className="text-body-sm text-text-primary whitespace-nowrap">
|
||||
{freeShipping ? "Kostenlos" : formatPrice(option.price)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{shippingMethods.map((method) => {
|
||||
const methodFree = method.freeShippingThreshold !== null && subtotal >= method.freeShippingThreshold;
|
||||
return (
|
||||
<label key={method.id} className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="shipping"
|
||||
checked={shippingMethodId === method.id}
|
||||
onChange={() => setShippingMethodId(method.id)}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 flex flex-col gap-0.5">
|
||||
<span className="text-body-sm text-text-primary">{method.title}</span>
|
||||
<span className="text-label text-text-muted">{method.description}</span>
|
||||
</span>
|
||||
<span className="text-body-sm text-text-primary whitespace-nowrap">
|
||||
{methodFree ? "Kostenlos" : formatPrice(method.price)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</Reveal>
|
||||
|
||||
{/* 3. Zahlungsart */}
|
||||
@@ -243,45 +247,23 @@ export function CheckoutContent() {
|
||||
3. Zahlungsart
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethod === "card"}
|
||||
onChange={() => setPaymentMethod("card")}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">Kreditkarte</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
<img alt="Visa" src="/icon-payment-visa.png" className="h-[1.1875rem] w-auto" />
|
||||
<img alt="Mastercard" src="/icon-payment-mastercard.png" className="h-[1.1875rem] w-auto" />
|
||||
<img alt="American Express" src="/icon-payment-amex.png" className="h-[1.1875rem] w-auto" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethod === "paypal"}
|
||||
onChange={() => setPaymentMethod("paypal")}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">PayPal</span>
|
||||
<img alt="PayPal" src="/icon-payment-paypal.png" className="h-5 w-auto shrink-0" />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethod === "bank"}
|
||||
onChange={() => setPaymentMethod("bank")}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">Überweisung</span>
|
||||
<img alt="" src="/icon-payment-bank.png" className="h-[1.3125rem] w-auto shrink-0" />
|
||||
</label>
|
||||
{paymentMethods.map((method) => (
|
||||
<label key={method.id} className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethodId === method.id}
|
||||
onChange={() => setPaymentMethodId(method.id)}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
{method.icons.map((icon, i) => (
|
||||
<img key={i} alt="" src={icon} className="h-5 w-auto" />
|
||||
))}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<Link
|
||||
href="/bestellbestaetigung"
|
||||
@@ -348,7 +330,7 @@ export function CheckoutContent() {
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">innerhalb Deutschlands</p>
|
||||
<p className="text-label text-text-muted">{selectedShipping?.description ?? "innerhalb Deutschlands"}</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
@@ -368,17 +350,15 @@ export function CheckoutContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* title + description here — /cart's sidebar renders the same
|
||||
CartTrustBadges docs with title only, see CartContent.tsx. */}
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
{[
|
||||
{ icon: "/icon-lock.svg", title: "Sichere Zahlung", desc: "Deine Daten sind bei uns sicher und geschützt." },
|
||||
{ icon: "/icon-trust-return.png", title: "14 Tage Rückgaberecht", desc: "Nicht zufrieden? Sende deine Bestellung innerhalb von 14 Tagen zurück." },
|
||||
{ icon: "/icon-trust-leaf.png", title: "Nachhaltig verpackt", desc: "Wir achten auf umweltfreundliche Materialien und plastikfreien Versand." },
|
||||
].map((b) => (
|
||||
<div key={b.title} className="flex gap-3 items-start w-full">
|
||||
{trustBadges.map((b) => (
|
||||
<div key={b.id} className="flex gap-3 items-start w-full">
|
||||
<img alt="" src={b.icon} className="size-5 shrink-0 object-contain mt-0.5" />
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm font-semibold text-text-primary">{b.title}</p>
|
||||
<p className="text-label text-text-muted">{b.desc}</p>
|
||||
<p className="text-label text-text-muted">{b.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { CheckoutContent } from "./components/CheckoutContent";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getShippingMethods, getPaymentMethods, getCartTrustBadges } from "../lib/payload";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /cart.
|
||||
export const metadata: Metadata = {
|
||||
@@ -13,11 +14,17 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function CheckoutPage() {
|
||||
export default async function CheckoutPage() {
|
||||
const [shippingMethods, paymentMethods, trustBadges] = await Promise.all([
|
||||
getShippingMethods(),
|
||||
getPaymentMethods(),
|
||||
getCartTrustBadges(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CheckoutContent />
|
||||
<CheckoutContent shippingMethods={shippingMethods} paymentMethods={paymentMethods} trustBadges={trustBadges} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -5,7 +5,6 @@ import { addToCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
const FEEDBACK_MS = 2000;
|
||||
const PRODUCT_ID = "todo-karten";
|
||||
|
||||
/**
|
||||
* Shared by /todo-cards's Hero + pricing panel and Home's product
|
||||
@@ -17,9 +16,14 @@ const PRODUCT_ID = "todo-karten";
|
||||
export function AddToCartButton({
|
||||
label,
|
||||
className,
|
||||
productId = "todo-karten",
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
/** Defaults to "todo-karten" for /todo-cards' own hardcoded usage — Home's
|
||||
* ProductSpotlight passes the actual CMS-selected spotlight product's id
|
||||
* explicitly, since that can now be a different product. */
|
||||
productId?: string;
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
@@ -29,7 +33,7 @@ export function AddToCartButton({
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
function handleClick() {
|
||||
addToCart(PRODUCT_ID);
|
||||
addToCart(productId);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
|
||||
@@ -2,36 +2,38 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { getProductBySlug } from "../lib/payload";
|
||||
import { getSpotlightProduct } from "../lib/payload";
|
||||
import { formatPrice } from "../lib/format";
|
||||
|
||||
/**
|
||||
* Product teaser for ToDo-Karten, placed after the Werkzeuge section (not
|
||||
* right after the Hero — that already has its own primary CTA, the
|
||||
* 7-Tage-Challenge, and a second strong purchase CTA competing with it
|
||||
* there would dilute focus). Werkzeuge already introduces ToDo-Karten as
|
||||
* a concept with an "Entdecken" link; this is the natural next step —
|
||||
* a concrete way to buy it, right where interest was just built, rather
|
||||
* 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. 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.
|
||||
* Product teaser for whichever product is marked `spotlight` in Payload
|
||||
* (defaults to none — the section just doesn't render until one is set),
|
||||
* placed after the Werkzeuge section (not right after the Hero — that
|
||||
* already has its own primary CTA, the 7-Tage-Challenge, and a second
|
||||
* strong purchase CTA competing with it there would dilute focus).
|
||||
* Werkzeuge already introduces the flagship tool as a concept with an
|
||||
* "Entdecken" link; this is the natural next step — a concrete way to buy
|
||||
* it, right where interest was just built, rather 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 /todo-cards' pricing panel.
|
||||
* Headline/copy/photo are the product's own dedicated spotlight* fields
|
||||
* (deliberately separate from its plain catalog name/description/image —
|
||||
* see Products.ts), not duplicated here as hardcoded literals.
|
||||
*/
|
||||
export async function ProductSpotlight() {
|
||||
const product = await getProductBySlug("todo-karten");
|
||||
const product = await getSpotlightProduct();
|
||||
if (!product) return null;
|
||||
|
||||
const image = product.spotlightImage || product.image;
|
||||
|
||||
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.image}
|
||||
alt="ToDo-Karten Set"
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 380px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
@@ -44,12 +46,15 @@ export async function ProductSpotlight() {
|
||||
className="font-semibold text-h-section text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
ToDo-Karten – Kleine Karten. Große Wirkung.
|
||||
{product.spotlightHeadline || product.name}
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
50 hochwertige Karten, die dir helfen, deinen Kopf frei zu bekommen und das Wesentliche zu sehen — analog, minimalistisch, für jeden Tag.
|
||||
{product.spotlightText || product.description}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
{product.compareAtPrice && product.compareAtPrice > product.price && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice)}</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>
|
||||
@@ -58,13 +63,15 @@ export async function ProductSpotlight() {
|
||||
(matches Tools/Blog above/below), same as AddToCartButton's
|
||||
own default styling/ring-offset, so no override is needed
|
||||
here. */}
|
||||
<AddToCartButton label="In den Warenkorb" />
|
||||
<Link
|
||||
href="/todo-cards"
|
||||
className="inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm border border-border text-body font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Mehr erfahren
|
||||
</Link>
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} />
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
className="inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm border border-border text-body font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Mehr erfahren
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
+17
-35
@@ -1,28 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
import { getWerkzeugeCards } from "../lib/payload";
|
||||
|
||||
const tools = [
|
||||
{
|
||||
icon: { src: "/icon-rocket.svg", w: "3.375rem", h: "4.122rem", transform: "-scale-y-100" },
|
||||
title: "Mini-Challenge",
|
||||
description: "In 7 Tagen zu mehr Klarheit. Kleine Gewohnheiten, die Großes bewirken.",
|
||||
cta: { label: "Starten", href: "/challenge" },
|
||||
},
|
||||
{
|
||||
icon: { src: "/icon-todo.svg", w: "3rem", h: "3.879rem", transform: "-scale-y-100" },
|
||||
title: "ToDo-Karten",
|
||||
description: "Das Werkzeug für Fokus im Alltag. bringe Struktur in deine Aufgaben und gewinne Zeit zurück.",
|
||||
cta: { label: "Entdecken", href: "/todo-cards" },
|
||||
},
|
||||
{
|
||||
icon: { src: "/icon-newsletter.svg", w: "3rem", h: "2.579rem", transform: "-rotate-4 -scale-y-100" },
|
||||
title: "Impulse & Tipps",
|
||||
description: "Wöchentliche Impulse mit konkreten Ideen und erprobten Tipps für weniger Reibung und mehr Leichtigkeit.",
|
||||
cta: { label: "Anmelden", href: "/newsletter" },
|
||||
},
|
||||
];
|
||||
// Content now lives in Payload (WerkzeugeCards collection). Icons use a
|
||||
// uniform box here (object-contain) rather than the previous hardcoded
|
||||
// per-card hand-tuned width/height/rotation — that only made sense for a
|
||||
// fixed, known set of 3 SVGs authored upside-down for a specific
|
||||
// hand-drawn look, which doesn't generalize to a real CMS field. The 3
|
||||
// original icons were re-exported pre-flipped/rotated as PNGs so they
|
||||
// still display correctly with plain object-contain.
|
||||
export async function Tools() {
|
||||
const tools = await getWerkzeugeCards();
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
export function Tools() {
|
||||
return (
|
||||
<div id="werkzeuge" className="flex flex-col gap-12 items-start pb-16 pt-8 w-full bg-bg-base">
|
||||
|
||||
@@ -44,20 +34,12 @@ export function Tools() {
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{tools.map((tool) => (
|
||||
<RevealItem
|
||||
key={tool.title}
|
||||
key={tool.id}
|
||||
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex items-center justify-center shrink-0">
|
||||
<div className={tool.icon.transform}>
|
||||
<div className="relative" style={{ width: tool.icon.w, height: tool.icon.h }}>
|
||||
<img
|
||||
alt=""
|
||||
src={tool.icon.src}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Icon — uniform box, pre-flipped/rotated source asset */}
|
||||
<div className="flex items-center justify-center shrink-0 size-14">
|
||||
<img alt="" src={tool.icon} className="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Card content — self-stretch + h-full + justify-between so
|
||||
@@ -85,10 +67,10 @@ export function Tools() {
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={tool.cta.href}
|
||||
href={tool.ctaHref}
|
||||
className="font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ {tool.cta.label}
|
||||
→ {tool.ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
+11
-18
@@ -1,31 +1,24 @@
|
||||
import { FREE_SHIPPING_THRESHOLD, TOTAL_DAYS_DE } from "../lib/shipping";
|
||||
import { formatPrice } from "../lib/format";
|
||||
import { getTrustBadges } from "../lib/payload";
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: "/icon-trust-shipping.png",
|
||||
title: "Schneller Versand",
|
||||
desc: `In ${TOTAL_DAYS_DE.min}–${TOTAL_DAYS_DE.max} Werktagen bei dir.`,
|
||||
},
|
||||
{
|
||||
icon: "/icon-trust-free-shipping.png",
|
||||
title: "Versandkostenfrei",
|
||||
desc: `Ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} Bestellwert innerhalb DE.`,
|
||||
},
|
||||
{ icon: "/icon-trust-heart.png", title: "Mit Liebe verpackt", desc: "Für mehr Freude beim Auspacken." },
|
||||
];
|
||||
// Content now lives in Payload (TrustBadges collection) instead of being
|
||||
// hardcoded here, so copy (e.g. the shipping timeframe/threshold numbers)
|
||||
// can be updated without a code deploy. If the fetch fails or nothing is
|
||||
// seeded yet, the row just doesn't render rather than showing stale
|
||||
// hardcoded fallback text that could drift from the real numbers.
|
||||
export async function TrustRow() {
|
||||
const items = await getTrustBadges();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
export function TrustRow() {
|
||||
return (
|
||||
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.title} className="flex items-center gap-6 md:gap-12">
|
||||
<div key={item.id} className="flex items-center gap-6 md:gap-12">
|
||||
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
|
||||
<div className="flex gap-4 items-center">
|
||||
<img alt="" src={item.icon} className="size-8 shrink-0 object-contain" />
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.desc}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,7 @@ export type Product = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
compareAtPrice: number | null;
|
||||
image: string;
|
||||
href: string | null;
|
||||
};
|
||||
@@ -128,6 +129,7 @@ type PayloadProduct = {
|
||||
slug: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
compareAtPrice: number | null;
|
||||
image: { url: string } | number | null;
|
||||
detailHref: string | null;
|
||||
};
|
||||
@@ -155,6 +157,7 @@ export async function getProducts(): Promise<Product[]> {
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
price: product.price,
|
||||
compareAtPrice: product.compareAtPrice ?? null,
|
||||
image: typeof product.image === "object" && product.image ? product.image.url : "",
|
||||
href: product.detailHref || null,
|
||||
}));
|
||||
@@ -165,6 +168,219 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
|
||||
return products.find((p) => p.id === slug) ?? null;
|
||||
}
|
||||
|
||||
export type SpotlightProduct = Product & {
|
||||
spotlightHeadline: string | null;
|
||||
spotlightText: string | null;
|
||||
spotlightImage: string | null;
|
||||
};
|
||||
|
||||
type PayloadSpotlightProduct = PayloadProduct & {
|
||||
spotlightHeadline: string | null;
|
||||
spotlightText: string | null;
|
||||
spotlightImage: { url: string } | number | null;
|
||||
};
|
||||
|
||||
// sort: "-spotlight,-updatedAt" — same deterministic-tie-breaker pattern
|
||||
// as getBlogPosts' featured post: if more than one product is accidentally
|
||||
// marked spotlight, the most recently updated one wins, no error.
|
||||
export async function getSpotlightProduct(): Promise<SpotlightProduct | null> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[spotlight][equals]": "true",
|
||||
sort: "-updatedAt",
|
||||
depth: "2",
|
||||
limit: "1",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getSpotlightProduct: Payload returned ${res.status} ${res.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadSpotlightProduct[] } = await res.json();
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return null;
|
||||
return {
|
||||
id: doc.slug,
|
||||
name: doc.name,
|
||||
description: doc.description ?? "",
|
||||
price: doc.price,
|
||||
compareAtPrice: doc.compareAtPrice ?? null,
|
||||
image: typeof doc.image === "object" && doc.image ? doc.image.url : "",
|
||||
href: doc.detailHref || null,
|
||||
spotlightHeadline: doc.spotlightHeadline || null,
|
||||
spotlightText: doc.spotlightText || null,
|
||||
spotlightImage:
|
||||
typeof doc.spotlightImage === "object" && doc.spotlightImage ? doc.spotlightImage.url : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type TrustBadge = { id: number; title: string; description: string; icon: string };
|
||||
|
||||
type PayloadTrustBadge = { id: number; title: string; description: string; icon: { url: string } | number | null };
|
||||
|
||||
async function fetchTrustBadgeList(collection: "trust-badges" | "cart-trust-badges"): Promise<TrustBadge[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
sort: "sortOrder",
|
||||
depth: "1",
|
||||
limit: "50",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/${collection}?${params}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`fetchTrustBadgeList(${collection}): Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadTrustBadge[] } = await res.json();
|
||||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
description: doc.description,
|
||||
icon: typeof doc.icon === "object" && doc.icon ? doc.icon.url : "",
|
||||
}));
|
||||
}
|
||||
|
||||
// Powers TrustRow.tsx — the horizontal "Schneller Versand /
|
||||
// Versandkostenfrei / Mit Liebe verpackt" row.
|
||||
export async function getTrustBadges(): Promise<TrustBadge[]> {
|
||||
return fetchTrustBadgeList("trust-badges");
|
||||
}
|
||||
|
||||
// Powers the "Sichere Zahlung / 14 Tage Rückgaberecht / Nachhaltig
|
||||
// verpackt" sidebar bullets on /cart (title only) and /checkout
|
||||
// (title + description) — a different list from TrustBadges.
|
||||
export async function getCartTrustBadges(): Promise<TrustBadge[]> {
|
||||
return fetchTrustBadgeList("cart-trust-badges");
|
||||
}
|
||||
|
||||
export type ShippingMethod = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
freeShippingThreshold: number | null;
|
||||
};
|
||||
|
||||
type PayloadShippingMethod = ShippingMethod & { active: boolean };
|
||||
|
||||
export async function getShippingMethods(): Promise<ShippingMethod[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
sort: "sortOrder",
|
||||
limit: "20",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/shipping-methods?${params}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getShippingMethods: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadShippingMethod[] } = await res.json();
|
||||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
description: doc.description,
|
||||
price: doc.price,
|
||||
freeShippingThreshold: doc.freeShippingThreshold ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export type PaymentMethod = { id: number; title: string; icons: string[] };
|
||||
|
||||
type PayloadPaymentMethod = {
|
||||
id: number;
|
||||
title: string;
|
||||
active: boolean;
|
||||
icons: { icon: { url: string } | number | null }[];
|
||||
};
|
||||
|
||||
export async function getPaymentMethods(): Promise<PaymentMethod[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
sort: "sortOrder",
|
||||
depth: "1",
|
||||
limit: "20",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/payment-methods?${params}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getPaymentMethods: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadPaymentMethod[] } = await res.json();
|
||||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
icons: (doc.icons ?? [])
|
||||
.map((row) => (typeof row.icon === "object" && row.icon ? row.icon.url : null))
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
}));
|
||||
}
|
||||
|
||||
export type WerkzeugeCard = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
type PayloadWerkzeugeCard = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: { url: string } | number | null;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
export async function getWerkzeugeCards(): Promise<WerkzeugeCard[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
sort: "sortOrder",
|
||||
depth: "1",
|
||||
limit: "20",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/werkzeuge-cards?${params}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getWerkzeugeCards: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadWerkzeugeCard[] } = await res.json();
|
||||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
description: doc.description,
|
||||
icon: typeof doc.icon === "object" && doc.icon ? doc.icon.url : "",
|
||||
ctaLabel: doc.ctaLabel,
|
||||
ctaHref: doc.ctaHref,
|
||||
}));
|
||||
}
|
||||
|
||||
export type LegalPageType = "impressum" | "datenschutz" | "agb" | "widerruf";
|
||||
|
||||
export type LegalPage = {
|
||||
|
||||
Reference in New Issue
Block a user