feat(blog): optional quote label + swappable related-product card per post
Both were fully hardcoded before: every blockquote showed a static "Merke dir:" label, and every post's "Passend dazu" card always linked the same flagship product. Now driven by two new Posts fields — quoteLabel (empty hides the label/icon/underline, blockquote still renders) and relatedProduct (empty hides the card entirely) — mirroring Products.spotlight but per-post instead of a single site-wide flag. README's collection table updated to match today's Payload changes (shipping-settings, the new Posts fields, trust-badges' placeholder tokens, admin sidebar grouping) — also fixed a stale claim that `media` isn't tenant-scoped; it already was.
This commit is contained in:
@@ -70,15 +70,22 @@ admin-gated in the Payload admin UI at `payload.mk360.de/admin`.
|
||||
| Collection (slug) | Used for | Key fields |
|
||||
|---|---|---|
|
||||
| `products` | `/shop` grid, homepage spotlight, cart, checkout | `name`, `slug` (cart item id — **not** Payload's numeric id, so existing localStorage carts survive catalog changes), `description`, `price`, `compareAtPrice` (optional strikethrough), `image`, `detailHref`, `sortOrder`, `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set) |
|
||||
| `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `category` (relation to `categories`), `excerpt`, `thumbnail`, `content` (richText), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt` |
|
||||
| `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `category` (relation to `categories`), `excerpt`, `thumbnail`, `content` (richText), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt`, `quoteLabel` (label + icon + underline shown next to every blockquote in `content`, default `"Merke dir:"` — leave empty to hide that framing, the blockquote text itself still renders), `relatedProduct` (optional relation to `products`, powers the "Passend dazu" card at the end of the post — leave empty to hide that card, or empty if the linked product has no `detailHref`) |
|
||||
| `categories` | Blog post categorization | `name`, `slug` (unique per tenant, not globally) |
|
||||
| `legal-pages` | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | `type` (`impressum`/`datenschutz`/`agb`/`widerruf`, one doc per type per tenant), `title`, `content` (richText), `attachment` (optional file, e.g. the Muster-Widerrufsformular PDF) |
|
||||
| `trust-badges` | Horizontal "Schneller Versand / Versandkostenfrei / Mit Liebe verpackt" row — shown on `/shop`, `/cart`, `/checkout`, `/widerruf`, `/agb`, 404 | `title`, `description`, `icon`, `sortOrder` |
|
||||
| `trust-badges` | Horizontal "Schneller Versand / Versandkostenfrei / Mit Liebe verpackt" row — shown on `/shop`, `/cart`, `/checkout`, `/widerruf`, `/agb`, 404 | `title`, `description` (supports `{{lieferzeit}}`/`{{kostenfreiab}}` placeholders, resolved by the frontend from `shipping-settings`/`shipping-methods` at render time — not by Payload itself), `icon`, `sortOrder` |
|
||||
| `cart-trust-badges` | Sidebar bullets on `/cart` (title only) and `/checkout` (title + description) — deliberately a separate collection from `trust-badges` so the two pages can't drift into showing different claims | `title`, `description`, `icon`, `sortOrder` |
|
||||
| `shipping-methods` | `/checkout` shipping selection | `title`, `description`, `price`, `freeShippingThreshold` (per-method, optional — leave empty for a method that should never be free, e.g. Express), `active` (inactive methods are hidden, not shown disabled), `sortOrder` |
|
||||
| `shipping-methods` | `/checkout` shipping selection | `title` (no day-range in the title text — that lives in `shipping-settings` now, keeping both in one title used to drift), `description`, `price`, `freeShippingThreshold` (per-method, optional — leave empty for a method that should never be free, e.g. Express), `active` (inactive methods are hidden, not shown disabled), `sortOrder` |
|
||||
| `shipping-settings` | Delivery-time disclosure shown on `/shop`, homepage spotlight, ToDo-Karten, `/cart`, `/checkout`, `/versand` (Art. 246a §1 Abs.1 Nr.8 EGBGB requires this visible before checkout) | `handlingDaysMin`/`handlingDaysMax` (processing time before it ships), `transitDaysMin`/`transitDaysMax` (carrier time) — the app derives the combined total itself. One row per tenant. |
|
||||
| `payment-methods` | `/checkout` payment selection | `title`, `icons` (array — e.g. 3 logos for "Kreditkarte"), `active`, `sortOrder` |
|
||||
| `werkzeuge-cards` | Homepage "Meine Werkzeuge" 3-card grid | `title`, `description`, `icon`, `ctaLabel`, `ctaHref`, `sortOrder` |
|
||||
| `media` | Shared upload collection (not tenant-scoped) backing every `image`/`icon`/`thumbnail`/`attachment` field above | `alt` (required for images), `title` (optional display name for download links) |
|
||||
| `media` | Shared upload collection backing every `image`/`icon`/`thumbnail`/`attachment` field above | `alt` (required for images), `title` (optional display name for download links) |
|
||||
|
||||
All of the above (except `media`, `users`, `tenants`) are grouped in the
|
||||
Payload admin sidebar under **Commerce** (`products`, `shipping-methods`,
|
||||
`shipping-settings`, `payment-methods`, `trust-badges`, `cart-trust-badges`)
|
||||
or **Content** (`posts`, `categories`, `legal-pages`, `werkzeuge-cards`);
|
||||
`media`/`users`/`tenants` sit under **Platform**.
|
||||
|
||||
Editorial changes (prices, copy, images, toggling a shipping/payment method
|
||||
on or off) all happen in the Payload admin UI — no code deploy needed. Adding
|
||||
|
||||
+48
-44
@@ -94,52 +94,56 @@ export default async function BlogDetailPage({
|
||||
)}
|
||||
|
||||
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<RichText content={post.content} />
|
||||
<RichText content={post.content} quoteLabel={post.quoteLabel} />
|
||||
|
||||
{/* "Passend dazu" — static cross-promo, not CMS content (same
|
||||
reasoning as the legal pages' brand callouts): every post
|
||||
currently points at the same flagship product rather than
|
||||
needing a per-post "related product" field that nothing else
|
||||
uses yet. Matches the actual built Figma frame exactly (node
|
||||
4674:349, fetched via get_design_context) — 1px border-border,
|
||||
rounded-md, px-9/py-7 padding; an earlier version guessed a
|
||||
plain borderless row instead, which get_metadata's structural
|
||||
dump didn't reveal (frame-level stroke/padding/radius aren't
|
||||
visible there, only get_design_context shows those). */}
|
||||
<Link
|
||||
href="/todo-cards"
|
||||
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
|
||||
>
|
||||
<Image alt="" src="/icon-todo-passend-dazu.png" width={64} height={75} className="w-16 h-[4.6875rem] shrink-0 object-contain" />
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
|
||||
<div className="flex items-end justify-between gap-4 w-full">
|
||||
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
|
||||
so the description wraps at the same point instead of
|
||||
stretching out to fill the space before "Entdecken". */}
|
||||
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
|
||||
<p
|
||||
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
ToDo-Karten
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">Bringe Struktur in deine Aufgaben und gewinne Zeit zurück.</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
|
||||
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>
|
||||
{/* "Passend dazu" — per-post CMS content now (Posts.relatedProduct),
|
||||
not a hardcoded flagship-product link. Hidden entirely if the
|
||||
post has no related product, or that product has no detail
|
||||
page to send "Entdecken" to. Matches the actual built Figma
|
||||
frame exactly (node 4674:349, fetched via get_design_context) —
|
||||
1px border-border, rounded-md, px-9/py-7 padding; an earlier
|
||||
version guessed a plain borderless row instead, which
|
||||
get_metadata's structural dump didn't reveal (frame-level
|
||||
stroke/padding/radius aren't visible there, only
|
||||
get_design_context shows those). */}
|
||||
{post.relatedProduct?.href && (
|
||||
<Link
|
||||
href={post.relatedProduct.href}
|
||||
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image alt="" src={post.relatedProduct.image} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
|
||||
<div className="flex items-end justify-between gap-4 w-full">
|
||||
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
|
||||
so the description wraps at the same point instead of
|
||||
stretching out to fill the space before "Entdecken". */}
|
||||
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
|
||||
<p
|
||||
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.relatedProduct.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.description}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
|
||||
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>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
{/* Author bio — same hardcoded-brand-chrome reasoning as above;
|
||||
|
||||
+49
-32
@@ -64,12 +64,12 @@ export function extractHeadings(content: unknown): TOCSection[] {
|
||||
return headings;
|
||||
}
|
||||
|
||||
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string): ReactNode {
|
||||
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string, quoteLabel: string): ReactNode {
|
||||
if (!nodes) return null;
|
||||
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`));
|
||||
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`, quoteLabel));
|
||||
}
|
||||
|
||||
function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNode {
|
||||
switch (node.type) {
|
||||
case "linebreak":
|
||||
return <br key={key} />;
|
||||
@@ -88,7 +88,7 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
href={node.fields?.url ?? "#"}
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</a>
|
||||
);
|
||||
case "heading": {
|
||||
@@ -101,7 +101,7 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
||||
</Tag>
|
||||
);
|
||||
@@ -116,18 +116,18 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
|
||||
}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</ListTag>
|
||||
);
|
||||
}
|
||||
case "listitem":
|
||||
return (
|
||||
<li key={key}>{renderChildren(node.children, key)}</li>
|
||||
<li key={key}>{renderChildren(node.children, key, quoteLabel)}</li>
|
||||
);
|
||||
case "paragraph":
|
||||
return (
|
||||
<p key={key} className="text-body text-text-body">
|
||||
{renderChildren(node.children, key)}
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</p>
|
||||
);
|
||||
// Lexical's default blockquote feature — used sitewide as a "Merke
|
||||
@@ -146,26 +146,33 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
case "quote":
|
||||
return (
|
||||
<div key={key} className="relative flex items-start gap-6 w-full my-6">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
|
||||
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
Merke dir:
|
||||
</span>
|
||||
</div>
|
||||
{/* Hand-drawn underline image, not a plain bar — exported
|
||||
straight from the Figma node (label-underline). */}
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-merke-dir-underline.png"
|
||||
width={136}
|
||||
height={23}
|
||||
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
|
||||
/>
|
||||
{/* Label/icon/underline are optional (Posts.quoteLabel) — if
|
||||
empty, only the divider + quote text render. The blockquote
|
||||
itself is never optional, just this framing around it. */}
|
||||
{quoteLabel && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
|
||||
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{quoteLabel}
|
||||
</span>
|
||||
</div>
|
||||
{/* Hand-drawn underline image, not a plain bar — exported
|
||||
straight from the Figma node (label-underline). */}
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-merke-dir-underline.png"
|
||||
width={136}
|
||||
height={23}
|
||||
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
{/* Lexical's real QuoteNode holds flat text/linebreak children
|
||||
directly, NOT nested paragraphs — pressing Enter inside a
|
||||
@@ -181,22 +188,32 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return renderChildren(node.children, key);
|
||||
return renderChildren(node.children, key, quoteLabel);
|
||||
}
|
||||
}
|
||||
|
||||
export function RichText({ content }: { content: unknown }) {
|
||||
export function RichText({
|
||||
content,
|
||||
quoteLabel = "Merke dir:",
|
||||
}: {
|
||||
content: unknown;
|
||||
/** Label for any blockquote's callout (see the "quote" case above) —
|
||||
* defaults to "Merke dir:" for callers that don't pass one (legal pages
|
||||
* never use blockquotes, so this only actually matters for blog posts).
|
||||
* Pass "" to hide the label/icon/underline for every blockquote here. */
|
||||
quoteLabel?: string;
|
||||
}) {
|
||||
const root = (content as { root?: LexicalNode })?.root;
|
||||
if (!root?.children) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{renderChildren(root.children, "root")}
|
||||
{renderChildren(root.children, "root", quoteLabel)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+32
-10
@@ -71,9 +71,21 @@ export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
|
||||
|
||||
export type PostDetail = BlogPost & {
|
||||
content: unknown;
|
||||
/** Label for every blockquote callout in this post's content (icon +
|
||||
* underline included) — empty string hides that left side entirely,
|
||||
* the blockquote itself still renders. See RichText.tsx's "quote" case. */
|
||||
quoteLabel: string;
|
||||
/** Powers the "Passend dazu" card at the end of the post — null hides
|
||||
* the card entirely, per-post choice (unlike Products.spotlight, which
|
||||
* is a single site-wide flag). */
|
||||
relatedProduct: Product | null;
|
||||
};
|
||||
|
||||
type PayloadPostDetail = PayloadPost & { content: unknown };
|
||||
type PayloadPostDetail = PayloadPost & {
|
||||
content: unknown;
|
||||
quoteLabel: string | null;
|
||||
relatedProduct: PayloadProduct | null;
|
||||
};
|
||||
|
||||
export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
|
||||
const params = new URLSearchParams({
|
||||
@@ -107,6 +119,8 @@ export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
|
||||
content: doc.content,
|
||||
publishedAt: doc.publishedAt,
|
||||
featured: doc.featured,
|
||||
quoteLabel: doc.quoteLabel ?? "",
|
||||
relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +150,22 @@ type PayloadProduct = {
|
||||
detailHref: string | null;
|
||||
};
|
||||
|
||||
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
|
||||
// one place instead of duplicating the same field mapping, which is
|
||||
// exactly the kind of drift this session's Shipping Settings work was
|
||||
// about eliminating elsewhere.
|
||||
function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
return {
|
||||
id: product.slug,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
price: product.price,
|
||||
compareAtPrice: product.compareAtPrice ?? null,
|
||||
image: typeof product.image === "object" && product.image ? product.image.url : "",
|
||||
href: product.detailHref || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProducts(): Promise<Product[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
@@ -154,15 +184,7 @@ export async function getProducts(): Promise<Product[]> {
|
||||
|
||||
const data: { docs?: PayloadProduct[] } = await res.json();
|
||||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||||
return docs.map((product) => ({
|
||||
id: product.slug,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
price: product.price,
|
||||
compareAtPrice: product.compareAtPrice ?? null,
|
||||
image: typeof product.image === "object" && product.image ? product.image.url : "",
|
||||
href: product.detailHref || null,
|
||||
}));
|
||||
return docs.map(mapPayloadProduct);
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string): Promise<Product | null> {
|
||||
|
||||
Reference in New Issue
Block a user