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:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user