diff --git a/app/components/ProductBadge.tsx b/app/components/ProductBadge.tsx
new file mode 100644
index 0000000..df80702
--- /dev/null
+++ b/app/components/ProductBadge.tsx
@@ -0,0 +1,29 @@
+import type { Product } from "../lib/payload";
+import { discountPercent } from "../lib/format";
+
+// Single shared badge — used everywhere a product's image shows one:
+// PDP hero/pricing panel (ProductBlocks.tsx), shop grid, cart's
+// RelatedProducts, wishlist grid (all via ProductCard.tsx). Previously
+// each of those re-implemented the same Ausverkauft/discount logic
+// separately, so Products.badge ("Neu") only showed up on the PDP —
+// consolidated here so every surface stays in sync automatically
+// (reported 2026-08-29). Priority: out-of-stock, then discount, then
+// the one manual state (badge === "new") — never two pills at once.
+// Discount/Ausverkauft are derived, never editor-set — see Products.ts's
+// own field comment on why "new" is the only manual option.
+export function ProductBadge({ product }: { product: Product }) {
+ const discount = discountPercent(product.price, product.compareAtPrice);
+ const fullyOutOfStock =
+ !product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
+
+ if (fullyOutOfStock) {
+ return Ausverkauft;
+ }
+ if (discount !== null) {
+ return -{discount}%;
+ }
+ if (product.badge === "new") {
+ return Neu;
+ }
+ return null;
+}
diff --git a/app/components/ProductBlocks.tsx b/app/components/ProductBlocks.tsx
index 3846ab3..509bf0c 100644
--- a/app/components/ProductBlocks.tsx
+++ b/app/components/ProductBlocks.tsx
@@ -6,7 +6,6 @@ import {
getDefaultTaxRatePercent,
getKleinunternehmer,
getTestimonials,
- getProducts,
getProductsByIds,
} from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
@@ -16,16 +15,11 @@ import { ProductName } from "./ProductName";
import { RichText } from "./RichText";
import { Reveal } from "./Reveal";
import { ProductGallery } from "./ProductGallery";
+import { ProductBadge } from "./ProductBadge";
import { STEP_ICONS } from "./icons/StepIcons";
import { renderPageBlockSync } from "./PageBlocks";
import { TestimonialsGrid } from "./TestimonialsGrid";
-// Same 3-recommendation width as cart's RelatedProducts.tsx grid, minus
-// its cart-awareness (a PDP cross-sell doesn't need to exclude/reshuffle
-// around what's already in the cart, that logic is specific to the cart
-// page's own "here's what's missing" framing).
-const CROSS_SELL_COUNT = 3;
-
// Renders a Payload Products document's `layout` blocks field — same
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
// this delegates to directly for every block type the two fields share
@@ -57,52 +51,47 @@ export async function ProductBlocks({ product }: { product: Product }) {
);
}
- // Also needs its own async fetch — same reasoning as testimonialsRef
- // just above. `manual` resolves the block's own picks by id;
- // `automatic` picks active products sharing a category with this
- // one (falling back to any other active product if it has none),
- // excluding itself either way.
+ // Needs its own async fetch — same reasoning as testimonialsRef just
+ // above. A single hand-picked product (Payload dropdown, excludes
+ // itself) — same shape as Products.relatedProduct/the blog's
+ // relatedProduct, just editable per PDP block instead of a fixed
+ // shell field.
if (block.blockType === "crossSell") {
- const recommended = await getCrossSellProducts(block, product);
- if (recommended.length === 0) return null;
+ const [related] = await getProductsByIds([block.productId]);
+ if (!related) return null;
// Same bordered "Passt dazu" card as der-eine's own hand-coded
// PasstDazu.tsx (and the blog's "Passend dazu" card) — thumbnail +
// name + description + "Entdecken" arrow, no price/wishlist/buy
// button. Reads as editorial, not a commerce grid; ProductCard
// (price, wishlist heart, add-to-cart) was too busy here
- // (reported 2026-08-29). Stacked, not a grid — PasstDazu's own
- // design was always a single full-width card; this just allows
- // more than one recommendation without losing that feel.
+ // (reported 2026-08-29).
return (
-
- {recommended.map((p) => (
-
-
-
-
-
-
Passt dazu:
-
-
-
- {p.name}
-
-
{p.descriptionText}
-
-
- Entdecken
-
-
+
+
+
+
+
+
+
Passt dazu:
+
+
+
+ {related.name}
+
+
{related.descriptionText}
+
+ Entdecken
+
+
-
- ))}
+
+
);
}
@@ -125,22 +114,6 @@ export async function ProductBlocks({ product }: { product: Product }) {
return <>{rendered}>;
}
-async function getCrossSellProducts(
- block: Extract,
- current: Product
-): Promise {
- if (block.mode === "manual") {
- if (block.productIds.length === 0) return [];
- const picked = await getProductsByIds(block.productIds);
- return picked.filter((p) => p.id !== current.id).slice(0, CROSS_SELL_COUNT);
- }
- const all = await getProducts();
- const active = all.filter((p) => p.active && p.id !== current.id);
- const sameCategory = current.categories.length > 0 ? active.filter((p) => p.categories.some((c) => current.categories.includes(c))) : [];
- const pool = sameCategory.length > 0 ? sameCategory : active;
- return pool.slice(0, CROSS_SELL_COUNT);
-}
-
type ProductBlockContext = {
product: Product;
shipping: Awaited>;
@@ -208,29 +181,6 @@ function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): Reac
}
}
-// Shared by ProductHero and ProductPricingPanel — previously only the
-// pricing panel showed this (discount%/Ausverkauft), the hero showed
-// nothing. Priority: out-of-stock, then discount, then the one manual
-// state (Products.badge === "new") — never discount+badge at once, one
-// pill only. Discount/Ausverkauft are derived, never editor-set, per
-// Products.ts's own field comment on why "new" is the only manual option.
-function ProductBadge({ product }: { product: Product }) {
- const discount = discountPercent(product.price, product.compareAtPrice);
- const fullyOutOfStock =
- !product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
-
- if (fullyOutOfStock) {
- return Ausverkauft;
- }
- if (discount !== null) {
- return -{discount}%;
- }
- if (product.badge === "new") {
- return Neu;
- }
- return null;
-}
-
// Matches der-eine/todo-cards' own hand-coded Hero.tsx exactly — same
// section/grid/Reveal/padding structure, same edge-to-edge image with
// hover-scale — so a block-driven PDP hero doesn't visually stand apart
diff --git a/app/components/ProductCard.tsx b/app/components/ProductCard.tsx
index f3164f0..2192451 100644
--- a/app/components/ProductCard.tsx
+++ b/app/components/ProductCard.tsx
@@ -6,6 +6,7 @@ import { effectiveTaxRate } from "../lib/cartTotals";
import { AddToCartInlineButton } from "./AddToCartInlineButton";
import { WishlistButton } from "./WishlistButton";
import { ProductName } from "./ProductName";
+import { ProductBadge } from "./ProductBadge";
import type { Product } from "../lib/payload";
// Single shared card markup for every product grid (ProductGrid,
@@ -93,19 +94,9 @@ export function ProductCard({
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
)}
- {topLeftBadge !== undefined ? (
- topLeftBadge
- ) : fullyOutOfStock ? (
-
- Ausverkauft
-
- ) : (
- discount !== null && (
-
- -{discount}%
-
- )
- )}
+