Simplify crossSell to a single hand-picked product; badge everywhere; gallery cursor

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
This commit is contained in:
Marco
2026-08-29 23:53:41 +00:00
parent 48e73796b1
commit 31b8e89ea4
5 changed files with 72 additions and 103 deletions
+29
View File
@@ -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 <span className="rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">Ausverkauft</span>;
}
if (discount !== null) {
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">-{discount}%</span>;
}
if (product.badge === "new") {
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">Neu</span>;
}
return null;
}
+33 -83
View File
@@ -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 }) {
</div>
);
}
// 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 (
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12 flex flex-col gap-4 max-w-[48rem] mx-auto">
{recommended.map((p) => (
<Link
key={p.id}
href={p.href ?? `/${p.id}`}
className="group flex items-center gap-4 sm:gap-6 border border-border rounded-md px-5 py-5 sm:px-9 sm:py-7 hover:border-brand transition-colors"
>
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
<Image src={p.image} alt="" fill sizes="64px" className="object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
<p className="font-bold text-[0.8125rem] text-brand">Passt dazu:</p>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
<div className="flex flex-col gap-2 items-start w-full sm:w-[19rem] sm:shrink-0">
<p className="font-semibold text-[1.375rem] text-text-primary sm:whitespace-nowrap" style={{ fontFamily: "var(--font-lora)" }}>
{p.name}
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{p.descriptionText}</p>
</div>
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap mt-1 sm:mt-0">
Entdecken
<svg viewBox="0 0 20 20" className="size-3.5 transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12 max-w-[48rem] mx-auto">
<Link
href={related.href ?? `/${related.id}`}
className="group flex items-center gap-4 sm:gap-6 border border-border rounded-md px-5 py-5 sm:px-9 sm:py-7 hover:border-brand transition-colors"
>
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
<Image src={related.image} alt="" fill sizes="64px" className="object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
<p className="font-bold text-[0.8125rem] text-brand">Passt dazu:</p>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
<div className="flex flex-col gap-2 items-start w-full sm:w-[19rem] sm:shrink-0">
<p className="font-semibold text-[1.375rem] text-text-primary sm:whitespace-nowrap" style={{ fontFamily: "var(--font-lora)" }}>
{related.name}
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{related.descriptionText}</p>
</div>
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap mt-1 sm:mt-0">
Entdecken
<svg viewBox="0 0 20 20" className="size-3.5 transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</Link>
))}
</div>
</Link>
</div>
);
}
@@ -125,22 +114,6 @@ export async function ProductBlocks({ product }: { product: Product }) {
return <>{rendered}</>;
}
async function getCrossSellProducts(
block: Extract<ProductBlock, { blockType: "crossSell" }>,
current: Product
): Promise<Product[]> {
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<ReturnType<typeof getShippingSettings>>;
@@ -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 <span className="rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">Ausverkauft</span>;
}
if (discount !== null) {
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">-{discount}%</span>;
}
if (product.badge === "new") {
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">Neu</span>;
}
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
+4 -13
View File
@@ -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 ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
<div className="absolute top-3 left-3">
{topLeftBadge !== undefined ? topLeftBadge : <ProductBadge product={product} />}
</div>
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
)}
+3 -3
View File
@@ -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"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M15 19l-7-7 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
@@ -84,7 +84,7 @@ export function ProductGallery({
type="button"
onClick={next}
aria-label="Nächstes Bild"
className="absolute right-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 right-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"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
@@ -102,7 +102,7 @@ export function ProductGallery({
onClick={() => setCurrent(i)}
aria-label={`Bild ${i + 1} anzeigen`}
aria-current={i === current}
className={`relative h-[4.75rem] w-[4.75rem] shrink-0 overflow-hidden rounded-sm border-2 transition-opacity ${
className={`relative h-[4.75rem] w-[4.75rem] shrink-0 overflow-hidden rounded-sm border-2 transition-opacity cursor-pointer ${
i === current ? "border-brand opacity-100" : "border-transparent opacity-70 hover:opacity-100"
}`}
>
+3 -4
View File
@@ -1384,7 +1384,7 @@ export type ProductBlock =
// needs getProductsByIds), done in ProductBlocks.tsx's own outer
// Promise.all, mirroring how that file already special-cases
// testimonialsRef for the exact same reason.
| { blockType: "crossSell"; id: string; mode: "automatic" | "manual"; productIds: number[] };
| { blockType: "crossSell"; id: string; productId: number };
type PayloadProductBlock =
| { blockType: "productHero"; id: string; body?: unknown | null }
@@ -1409,7 +1409,7 @@ type PayloadProductBlock =
bullets?: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
| { blockType: "crossSell"; id: string; mode?: "automatic" | "manual" | null; products?: (number | { id: number })[] | null };
| { blockType: "crossSell"; id: string; product: number | { id: number } };
function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
switch (block.blockType) {
@@ -1445,8 +1445,7 @@ function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
return {
blockType: "crossSell",
id: block.id,
mode: block.mode ?? "automatic",
productIds: (block.products ?? []).map((p) => (typeof p === "object" ? p.id : p)),
productId: typeof block.product === "object" ? block.product.id : block.product,
};
default:
return block;