From 31b8e89ea45d35a3d386c2dfc6ae050d50bf748b Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 29 Aug 2026 23:53:41 +0000 Subject: [PATCH] Simplify crossSell to a single hand-picked product; badge everywhere; gallery cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CrossSellBlock: dropped mode (automatic/manual) + hasMany products down to one required `product` relationship, same filterOptions- excludes-self dropdown as Products.relatedProduct — the "automatic, same category" mode added complexity a plain dropdown already covered better, and the frontend's card was always designed around one recommendation, not a grid (reported 2026-08-29). - New shared ProductBadge component (app/components/ProductBadge.tsx), used by both ProductBlocks.tsx (PDP) and ProductCard.tsx (shop grid, cart's RelatedProducts, wishlist grid) — Products.badge ("Neu") only showed on the PDP before, now consistent everywhere a product's image renders a badge. - ProductGallery's arrow/thumbnail buttons get an explicit cursor-pointer class. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8 --- app/components/ProductBadge.tsx | 29 ++++++++ app/components/ProductBlocks.tsx | 116 +++++++++--------------------- app/components/ProductCard.tsx | 17 ++--- app/components/ProductGallery.tsx | 6 +- app/lib/payload.ts | 7 +- 5 files changed, 72 insertions(+), 103 deletions(-) create mode 100644 app/components/ProductBadge.tsx 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}% - - ) - )} +
+ {topLeftBadge !== undefined ? topLeftBadge : } +
{wishlistEnabled && ( )} diff --git a/app/components/ProductGallery.tsx b/app/components/ProductGallery.tsx index 9092dde..8e06805 100644 --- a/app/components/ProductGallery.tsx +++ b/app/components/ProductGallery.tsx @@ -74,7 +74,7 @@ export function ProductGallery({ type="button" onClick={prev} aria-label="Vorheriges Bild" - className="absolute left-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors" + className="absolute left-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors cursor-pointer" >