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:
Marco
2026-08-25 13:45:52 +00:00
parent b59c9a0e4b
commit 1f537ccb06
5 changed files with 40 additions and 24 deletions
+14 -12
View File
@@ -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>
);
}
+13 -11
View File
@@ -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
+1 -1
View File
@@ -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 />
</>
+1
View File
@@ -27,6 +27,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
taxRatePercent: null,
noShippingCost: false,
categories: [],
relatedProduct: null,
...overrides,
});
+11
View File
@@ -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,
};
}