PasstDazu driven by Products.relatedProduct; circle back on Focus icons
- PasstDazu.tsx now takes a productSlug prop and reads its cross-sell target from the product's own relatedProduct field (Payload) instead of hardcoding "todo-karten" — reusable on any product page now. - payload.ts: Product/PayloadProduct gained relatedProduct, resolved via a bounded-recursion mapPayloadProduct call (safe — the fetch's depth:2 means the nested relation's own relatedProduct is never populated). - Focus.tsx icons: added the black stroked circle back around each glyph (matches icon-step-3.png exactly) — dropped in the previous pass by mistake while fixing the color/fill.
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
|
||||
// Bold black outline, no fill, no container — matching /todo-cards' own
|
||||
// icon language (tool-icon-todo-karten.png, icon-step-2.png/icon-step-3.png:
|
||||
// thick uniform-weight black stroke, no color fill, no circle/badge
|
||||
// wrapper), not the thinner colored-badge treatment tried first.
|
||||
// Bold black outline, no fill, circled — matching /todo-cards'
|
||||
// icon-step-3.png exactly (a stroked circle around the glyph, same
|
||||
// uniform-weight black stroke as the glyph itself), not the plain
|
||||
// uncircled version tried right before this, nor the orange-badge version
|
||||
// tried before that.
|
||||
function IconBadge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg width="56" height="56" viewBox="0 0 28 28" fill="none" strokeWidth="2.2" stroke="#231f20" strokeLinecap="round" strokeLinejoin="round" className="h-14 w-auto shrink-0">
|
||||
{children}
|
||||
<svg width="56" height="56" viewBox="0 0 28 28" fill="none" strokeWidth="1.8" stroke="#231f20" strokeLinecap="round" strokeLinejoin="round" className="h-14 w-auto shrink-0">
|
||||
<circle cx="14" cy="14" r="12.5" />
|
||||
<g strokeWidth="1.5">{children}</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +18,8 @@ function IconBadge({ children }: { children: ReactNode }) {
|
||||
function IconPencil() {
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M18.5 4.5l5 5L9 24H4v-5L18.5 4.5z" />
|
||||
<path d="M15.5 7.5l5 5" />
|
||||
<path d="M17 8l3 3L10 21H7v-3L17 8z" />
|
||||
<path d="M15 10l3 3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
@@ -25,8 +27,8 @@ function IconPencil() {
|
||||
function IconPen() {
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M6 22l3-1 13-13-2-2L7 19l-1 3z" />
|
||||
<path d="M17 8l3 3" />
|
||||
<path d="M9 19l2-0.7L20 9l-1.3-1.3L10 17l-1 2z" />
|
||||
<path d="M17 9.5l1.5 1.5" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +36,7 @@ function IconPen() {
|
||||
function IconSparkle() {
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M14 3v8M14 17v8M3 14h8M17 14h8M6.5 6.5l5.5 5.5M16 16l5.5 5.5M21.5 6.5L16 12M12 16l-5.5 5.5" />
|
||||
<path d="M14 6.5v4M14 17.5v4M6.5 14h4M17.5 14h4M9 9l2.5 2.5M16.5 16.5L19 19M19 9l-2.5 2.5M11.5 16.5L9 19" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +44,7 @@ function IconSparkle() {
|
||||
function IconShield() {
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M14 3l9 3.5v6c0 6-4 10-9 12.5-5-2.5-9-6.5-9-12.5v-6L14 3z" />
|
||||
<path d="M14 6l6 2.3v4c0 4-2.7 6.6-6 8-3.3-1.4-6-4-6-8v-4L14 6z" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,23 +6,25 @@ import { getProductBySlug } from "../../lib/payload";
|
||||
// Replaces the page's final price/buy bar entirely (explicit choice —
|
||||
// Hero.tsx already has the primary buy CTA, this page doesn't need a
|
||||
// second one at the very bottom too). Same "Passend dazu" card markup as
|
||||
// blog/[slug]/page.tsx's own related-product card (that one is driven by
|
||||
// Posts.relatedProduct, a per-post CMS field; this page hardcodes
|
||||
// "todo-karten" directly, same as Hero.tsx already hardcodes
|
||||
// "stift-kugelschreiber" — a fixed cross-sell, not a general relation).
|
||||
export async function PasstDazu() {
|
||||
const product = await getProductBySlug("todo-karten");
|
||||
if (!product?.href) return null;
|
||||
// blog/[slug]/page.tsx's own related-product card. Driven by
|
||||
// Products.relatedProduct (2026-08-25, same field/pattern as
|
||||
// Posts.relatedProduct, just product->product instead of post->product) —
|
||||
// reusable on any product page by passing that page's own product slug,
|
||||
// not hardcoded to "todo-karten" like the first version of this component.
|
||||
export async function PasstDazu({ productSlug }: { productSlug: string }) {
|
||||
const product = await getProductBySlug(productSlug);
|
||||
const related = product?.relatedProduct;
|
||||
if (!related?.href) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-12 sm:py-16">
|
||||
<Reveal className="max-w-[48rem] mx-auto">
|
||||
<Link
|
||||
href={product.href}
|
||||
href={related.href}
|
||||
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 alt="" src={product.image} fill sizes="64px" className="object-cover" />
|
||||
<Image alt="" src={related.image} 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>
|
||||
@@ -32,9 +34,9 @@ export async function PasstDazu() {
|
||||
className="font-semibold text-[1.375rem] text-text-primary sm:whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
{related.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{product.description}</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{related.description}</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
|
||||
|
||||
@@ -44,7 +44,7 @@ export default async function DerEinePage() {
|
||||
<main className="flex flex-col flex-1">
|
||||
<Hero />
|
||||
<Focus />
|
||||
<PasstDazu />
|
||||
<PasstDazu productSlug="stift-kugelschreiber" />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -27,6 +27,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
categories: [],
|
||||
relatedProduct: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -251,6 +251,12 @@ export type Product = {
|
||||
// than "matches none", since an uncategorized product shouldn't just
|
||||
// disappear the moment a category filter is applied.
|
||||
categories: string[];
|
||||
// Optional cross-sell pick (Products.relatedProduct) — powers a "Passt
|
||||
// dazu" card on the product's own detail page, same field/pattern as
|
||||
// Posts.relatedProduct. null means no card. Only one level deep (this
|
||||
// related product's own relatedProduct is never resolved/shown) — see
|
||||
// mapPayloadProduct's own comment.
|
||||
relatedProduct: Product | null;
|
||||
};
|
||||
|
||||
type PayloadProduct = {
|
||||
@@ -288,6 +294,7 @@ type PayloadProduct = {
|
||||
}[]
|
||||
| null;
|
||||
categories: ({ name: string } | number)[] | null;
|
||||
relatedProduct: PayloadProduct | number | null;
|
||||
};
|
||||
|
||||
// A product/variant is only actually unbuyable when it opted into
|
||||
@@ -354,6 +361,10 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
categories: (product.categories ?? [])
|
||||
.map((c) => (typeof c === "object" && c ? c.name : null))
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
// Recursive call is safe here — depth=2 on the originating fetch means
|
||||
// this nested object's OWN relatedProduct is never populated (stays a
|
||||
// raw id or absent), so the recursion bottoms out after exactly one level.
|
||||
relatedProduct: typeof product.relatedProduct === "object" && product.relatedProduct ? mapPayloadProduct(product.relatedProduct) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user