Files
einfach-produktiv/app/components/RichText.tsx
T
Marco 43944d8cc8 Fix navbar/discount/invoice bugs from manual QA, add VAT breakdown, shipping-address override, checkout persistence, redesigned mobile menu
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
  event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
  number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns

Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
  a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
  included, broken down per rate when a cart spans more than one
  (new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown

Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).

Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.

Checkout:
- Optional deviating shipping address (separate from the billing address
  used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
  selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
  address differs from billing

Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).

Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.

Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 22:52:15 +00:00

197 lines
7.6 KiB
TypeScript

import type { ReactNode } from "react";
import type { TOCSection } from "./SectionTOC";
import { QuoteLabel } from "./QuoteLabel";
// Minimal Lexical JSON → JSX renderer for Payload's richText fields.
// Deliberately small and dependency-free (matches the project's existing
// style — see Posts.ts's own hand-rolled extractPlainText on the Payload
// side) rather than pulling in @payloadcms/richtext-lexical's full React
// renderer just to walk a legal page's headings/paragraphs/lists. Covers
// the node types real content actually uses; add more only when a page
// genuinely needs them.
type LexicalNode = {
type: string;
children?: LexicalNode[];
text?: string;
format?: number;
tag?: string;
listType?: "bullet" | "number";
fields?: { url?: string };
};
// Lexical's text format is a bitmask — see TextFormatType in the Lexical
// source (IS_BOLD = 1, IS_ITALIC = 2, IS_UNDERLINE = 8).
const BOLD = 1;
const ITALIC = 2;
const UNDERLINE = 8;
function plainText(node: LexicalNode): string {
if (node.type === "text") return node.text ?? "";
return (node.children ?? []).map(plainText).join("");
}
// Numbered legal-page headings ("1. Verantwortlicher") get a stable
// "section-1" id from the leading number — immune to copy edits changing
// the heading text later, unlike a text-derived slug. Anything else
// (headings with no leading number) falls back to a plain slugify.
function headingId(text: string): string {
const numbered = text.match(/^(\d+)\./);
if (numbered) return `section-${numbered[1]}`;
return text
.toLowerCase()
.replace(/[^a-z0-9äöüß]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// Walks the same tree the renderer does, collecting h2 headings for a
// SectionTOC sidebar — kept in this file (not duplicated) so the ids it
// produces can never drift from the ones the renderer actually assigns.
export function extractHeadings(content: unknown): TOCSection[] {
const root = (content as { root?: LexicalNode })?.root;
if (!root?.children) return [];
const headings: TOCSection[] = [];
const walk = (nodes: LexicalNode[]) => {
for (const node of nodes) {
if (node.type === "heading" && (node.tag ?? "h2") === "h2") {
const text = plainText(node);
headings.push({ id: headingId(text), title: text });
}
if (node.children) walk(node.children);
}
};
walk(root.children);
return headings;
}
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string, quoteLabel: string): ReactNode {
if (!nodes) return null;
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`, quoteLabel));
}
function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNode {
switch (node.type) {
case "linebreak":
return <br key={key} />;
case "text": {
let el: ReactNode = node.text;
const format = node.format ?? 0;
if (format & BOLD) el = <strong key={key}>{el}</strong>;
if (format & ITALIC) el = <em key={key}>{el}</em>;
if (format & UNDERLINE) el = <u key={key}>{el}</u>;
return <span key={key}>{el}</span>;
}
case "link":
return (
<a
key={key}
href={node.fields?.url ?? "#"}
className="text-brand hover:underline"
>
{renderChildren(node.children, key, quoteLabel)}
</a>
);
case "heading": {
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const text = plainText(node);
return (
<Tag
key={key}
id={Tag === "h2" ? headingId(text) : undefined}
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, quoteLabel)}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</Tag>
);
}
case "list": {
const ListTag = node.listType === "number" ? "ol" : "ul";
return (
<ListTag
key={key}
className={
"flex flex-col gap-2 text-body text-text-body " +
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
}
>
{renderChildren(node.children, key, quoteLabel)}
</ListTag>
);
}
case "listitem":
return (
<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, quoteLabel)}
</p>
);
// Lexical's default blockquote feature — used sitewide as a "Merke
// dir:" pull-quote callout, per page-blog-detail's actual built Figma
// frame (node 4676:341, file jCCZyh1DGwdjpv1wGge9To) — NOT a bordered/
// background card (an earlier version of this guessed one; the real
// design has no background or padding at all, just a plain 3-column
// row: label+underline, a full-height divider rule, then the quote
// lines). Icon is the actual exported sparkle asset from that node
// (icon-sparkle-merke-dir.png), not a hand-drawn approximation. The
// "Merke dir:" label itself is generic/hardcoded here rather than
// content-authored, since a blog post's own body text drives which
// lines get quoted, not the label framing them — legal pages never
// use blockquotes, so this styling is effectively blog-only in
// practice despite living in the shared renderer.
case "quote":
return (
<div key={key} className="relative flex items-start gap-6 w-full my-6">
{/* 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 label={quoteLabel} />}
<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
blockquote in the editor exits it into a new paragraph
rather than adding a line within it (confirmed by reading
@lexical/rich-text's QuoteNode.insertNewAfter). An earlier
version of this case assumed nested-paragraph children,
which only happened to work for this session's own
hand-authored seed JSON — any blockquote actually typed in
the CMS (Shift+Enter for a soft line break) rendered blank,
since child.children was undefined on a plain text node. */}
<p
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
style={{ fontFamily: "var(--font-caveat)" }}
>
{renderChildren(node.children, key, quoteLabel)}
</p>
</div>
);
default:
return renderChildren(node.children, key, quoteLabel);
}
}
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", quoteLabel)}
</div>
);
}