diff --git a/README.md b/README.md index 553cfaf..ae62152 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 8c163aa..263b9c2 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -94,52 +94,56 @@ export default async function BlogDetailPage({ )} - + - {/* "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). */} - - -
-

Passend dazu:

-
- {/* 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". */} -
-

- ToDo-Karten -

-

Bringe Struktur in deine Aufgaben und gewinne Zeit zurück.

-
- - Entdecken - - + {/* "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 && ( + +
+
-
- +
+

Passend dazu:

+
+ {/* 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". */} +
+

+ {post.relatedProduct.name} +

+

{post.relatedProduct.description}

+
+ + Entdecken + + +
+
+ + )} {/* Author bio — same hardcoded-brand-chrome reasoning as above; diff --git a/app/components/RichText.tsx b/app/components/RichText.tsx index 584f8ac..2e00a3c 100644 --- a/app/components/RichText.tsx +++ b/app/components/RichText.tsx @@ -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
; @@ -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)} ); 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)} ); @@ -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)} ); } case "listitem": return ( -
  • {renderChildren(node.children, key)}
  • +
  • {renderChildren(node.children, key, quoteLabel)}
  • ); case "paragraph": return (

    - {renderChildren(node.children, key)} + {renderChildren(node.children, key, quoteLabel)}

    ); // Lexical's default blockquote feature — used sitewide as a "Merke @@ -146,26 +146,33 @@ function renderNode(node: LexicalNode, key: string): ReactNode { case "quote": return (
    -
    - - - - - Merke dir: - -
    - {/* Hand-drawn underline image, not a plain bar — exported - straight from the Figma node (label-underline). */} - + {/* 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 && ( + <> +
    + + + + + {quoteLabel} + +
    + {/* Hand-drawn underline image, not a plain bar — exported + straight from the Figma node (label-underline). */} + + + )}
    {/* 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)}

    ); 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 (
    - {renderChildren(root.children, "root")} + {renderChildren(root.children, "root", quoteLabel)}
    ); } diff --git a/app/lib/payload.ts b/app/lib/payload.ts index 86e3e51..eaacc44 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -71,9 +71,21 @@ export async function getBlogPosts(limit = 3): Promise { 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 { const params = new URLSearchParams({ @@ -107,6 +119,8 @@ export async function getPostBySlug(slug: string): Promise { 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 { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, @@ -154,15 +184,7 @@ export async function getProducts(): Promise { 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 {