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>
This commit is contained in:
Marco
2026-07-22 22:52:15 +00:00
parent 7a9fed6f95
commit 43944d8cc8
39 changed files with 1435 additions and 306 deletions
+10 -2
View File
@@ -18,6 +18,7 @@ export function AddToCartButton({
className,
productId = "todo-karten",
outOfStock = false,
lowStock = false,
variants = [],
}: {
label: string;
@@ -29,10 +30,13 @@ export function AddToCartButton({
/** Product-level — only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: boolean;
/** Product-level low-stock hint, same "only meaningful without variants"
* split as outOfStock. */
lowStock?: boolean;
/** Optional — same shape/semantics as AddToCartInlineButton's own
* `variants` prop; all three callers already fetch the full product
* server-side, so this is just threaded straight through. */
variants?: { name: string; priceOverride: number | null; outOfStock: boolean }[];
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[];
}) {
const [added, setAdded] = useState(false);
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
@@ -43,6 +47,7 @@ export function AddToCartButton({
useEffect(() => () => clearTimeout(timeoutRef.current), []);
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentlyLowStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.lowStock ?? false) : lowStock;
function handleClick() {
if (currentlyOutOfStock) return;
@@ -87,11 +92,14 @@ export function AddToCartButton({
{variants.map((v) => (
<option key={v.name} value={v.name}>
{v.name}
{v.outOfStock ? " (ausverkauft)" : ""}
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
</option>
))}
</select>
)}
{currentlyLowStock && !currentlyOutOfStock && (
<p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>
)}
<button
ref={buttonRef}
type="button"
+10 -2
View File
@@ -21,6 +21,7 @@ export function AddToCartInlineButton({
label = "In den Warenkorb",
className,
outOfStock = false,
lowStock = false,
variants = [],
}: {
id: string;
@@ -29,12 +30,15 @@ export function AddToCartInlineButton({
/** Product-level — only meaningful when `variants` is empty. A varianted
* product's buyability is entirely per-variant instead (see below). */
outOfStock?: boolean;
/** Product-level low-stock hint, same "only meaningful without variants"
* split as outOfStock. */
lowStock?: boolean;
/** Optional — products.variants (name + optional priceOverride + its own
* outOfStock). When non-empty, a variant must be picked (defaults to the
* first *in-stock* one, or just the first if all are out) before "add to
* cart" is enabled — the selected variant's name is snapshotted onto the
* cart line and, later, the order itself. */
variants?: { name: string; priceOverride: number | null; outOfStock: boolean }[];
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[];
}) {
const [added, setAdded] = useState(false);
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
@@ -47,6 +51,7 @@ export function AddToCartInlineButton({
// Whichever is actually being offered right now — the selected variant's
// own flag if there are variants, otherwise the plain product-level one.
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentlyLowStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.lowStock ?? false) : lowStock;
function handleClick() {
if (currentlyOutOfStock) return;
@@ -83,11 +88,14 @@ export function AddToCartInlineButton({
{variants.map((v) => (
<option key={v.name} value={v.name}>
{v.name}
{v.outOfStock ? " (ausverkauft)" : ""}
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
</option>
))}
</select>
)}
{currentlyLowStock && !currentlyOutOfStock && (
<p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>
)}
<button
ref={buttonRef}
type="button"
+168 -124
View File
@@ -4,7 +4,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { useCartCount } from "../lib/cart";
import { AUTH_CHANGED_EVENT } from "../lib/auth";
import { NewsletterModal } from "./NewsletterModal";
import { useCartFly } from "./CartFly";
@@ -82,28 +84,29 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
// and reading the session cookie there (next/headers' cookies()) would
// force the entire site into per-request dynamic rendering just for this.
// `loggedIn === null` is the brief "not checked yet" state on first paint.
function AccountLink({ variant }: { variant: "icon" | "mobile" }) {
function AccountLink() {
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
useEffect(() => {
fetch("/api/account/me")
.then((res) => setLoggedIn(res.ok))
.catch(() => setLoggedIn(false));
function checkAuth() {
fetch("/api/account/me")
.then((res) => setLoggedIn(res.ok))
.catch(() => setLoggedIn(false));
}
checkAuth();
// Navbar lives in the root layout and never unmounts across
// navigations, so this effect only ever runs once on its own —
// router.refresh() (called after login/logout) re-fetches Server
// Component data but doesn't re-run an already-mounted Client
// Component's effects. AUTH_CHANGED_EVENT is dispatched explicitly by
// every login/logout call site (see dispatchAuthChanged() in
// ../lib/auth) so this stays in sync without a hard reload.
window.addEventListener(AUTH_CHANGED_EVENT, checkAuth);
return () => window.removeEventListener(AUTH_CHANGED_EVENT, checkAuth);
}, []);
const href = loggedIn ? "/konto/bestellungen" : "/konto/login";
if (variant === "mobile") {
return (
<Link
href={href}
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
{loggedIn ? "Mein Konto" : "Anmelden"}
</Link>
);
}
return (
<Link
href={href}
@@ -327,6 +330,32 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
return () => document.removeEventListener("keydown", onKeyDown);
}, [mobileOpen]);
// Background scroll lock while the fullscreen panel is open — same
// wheel/touchmove interception as NewsletterModal.tsx (see that
// component's own comment on why this approach over overflow:hidden or
// position:fixed on body). Needed now that the panel actually covers the
// viewport instead of pushing page content down in normal flow.
useEffect(() => {
if (!mobileOpen) return;
const isInsidePanel = (target: EventTarget | null) =>
target instanceof Node && !!panelRef.current?.contains(target);
const onWheel = (e: WheelEvent) => {
if (!isInsidePanel(e.target)) e.preventDefault();
};
const onTouchMove = (e: TouchEvent) => {
if (!isInsidePanel(e.target)) e.preventDefault();
};
document.addEventListener("wheel", onWheel, { passive: false });
document.addEventListener("touchmove", onTouchMove, { passive: false });
return () => {
document.removeEventListener("wheel", onWheel);
document.removeEventListener("touchmove", onTouchMove);
};
}, [mobileOpen]);
const closeMobile = () => setMobileOpen(false);
return (
@@ -345,18 +374,11 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
// position itself.
<>
<header
// min-h-, not a fixed h- — the fixed height used to cap this flex
// column at exactly 6.25rem regardless of content. The top row below
// has shrink-0 (always fills all 6.25rem on its own), so the mobile
// drawer panel — the column's only other child — had zero room left.
// Flex items default to `min-height: auto` (their own content size),
// which normally would've still forced the column taller — but the
// panel's own `overflow-hidden` (needed for its open/close animation)
// resets that automatic minimum to 0 per spec, so flexbox was free to
// crush it to a literal 0px box instead of pushing the header taller.
// The hamburger icon itself was never the problem — it toggled to "X"
// correctly; the panel it opens was rendering at zero height beneath it.
className={`sticky top-0 z-50 w-full min-h-[6.25rem] flex flex-col transition-[background-color,backdrop-filter] duration-300 ${
// The mobile panel used to be a child of this element and needed the
// header itself to be a flexible column that could grow for it — it's
// now a fixed-position sibling instead (see that panel's own comment
// on why), so this is back to a plain fixed-height bar.
className={`sticky top-0 z-50 w-full h-[6.25rem] transition-[background-color,backdrop-filter] duration-300 ${
scrolled || mobileOpen
? "bg-bg-base/80 backdrop-blur-md"
: "bg-bg-base"
@@ -453,7 +475,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
(below lg). Grouped so spacing stays consistent as individual
children hide/show across the three breakpoint tiers. */}
<div className="flex items-center gap-2">
<AccountLink variant="icon" />
<AccountLink />
<CartLink />
{/* CTA buttons — inline from md (768px) up, i.e. through both
@@ -513,104 +535,126 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
</div>
</div>
{/* Mobile drawer panel — toggleable below lg (see hamburger above).
`grid-rows-[0fr]/[1fr]` (not a guessed max-h-[Nrem]) is what
actually animates the open/close height — a CSS grid row sized in
`fr` units transitions smoothly between 0 and its content's real
height with no fixed cap to guess/outgrow, the modern replacement
for the old max-height-accordion trick. `overflow-hidden` has to
live on the *inner* div, not this one — collapsing a 0fr grid row
already clips its content on its own, and this outer element is
also where the opacity/translate fade below is applied, which
must NOT itself be clipped (a translateY sliding a hidden/clipped
element in doesn't read as a fade-in, just a hard cut). */}
<div
id="mobile-nav-panel"
ref={panelRef}
className={`lg:hidden grid w-full transition-[grid-template-rows] duration-300 ease-in-out ${
mobileOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
}`}
>
<div
className={`overflow-hidden transition-[opacity,transform] duration-300 ease-out ${
mobileOpen ? "opacity-100 translate-y-0 delay-100" : "opacity-0 -translate-y-2"
}`}
>
<nav className="flex flex-col gap-6 px-8 pt-2 pb-6">
{navLinks.map((link) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
return link.href.startsWith("#") ? (
<Link
key={link.href}
href={resolvedHref}
onClick={
isHomeAnchor
? (e) => {
e.preventDefault();
closeMobile();
const el = document.getElementById(link.href.slice(1));
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
smoothScrollTo(top);
}
history.replaceState(null, "", link.href);
}
: closeMobile
}
className="min-h-11 flex flex-col justify-center gap-1 text-h4 font-semibold text-text-primary w-fit"
>
{link.label}
<span
className={`h-[2px] bg-brand transition-opacity duration-200 ${
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
}`}
/>
</Link>
) : (
<Link
key={link.href}
href={link.href}
onClick={closeMobile}
className="min-h-11 flex items-center text-h4 font-semibold text-text-primary"
>
{link.label}
</Link>
);
})}
</nav>
<div className="flex flex-col gap-3 px-8 pb-8">
{/* md:hidden — these two duplicate the inline CTA pair that's
already visible in the header itself from md (768px) up (see
"Trailing controls" above); only genuinely missing below
that, where the inline pair is hidden and the drawer is
these buttons' only way to reach them. */}
<button
type="button"
onClick={() => {
closeMobile();
setNewsletterOpen(true);
}}
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
Newsletter
</button>
<Link
href="/challenge"
onClick={closeMobile}
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
7-Tage-Challenge
</Link>
<div onClick={closeMobile}>
<AccountLink variant="mobile" />
</div>
</div>
</div>
</div>
</header>
{/* Fullscreen mobile panel — a sibling of <header>, deliberately NOT
nested inside it (same reason as NewsletterModal, see the
top-of-file comment: `mobileOpen` gives the header its own
backdrop-blur, which would make it a new containing block for any
`position: fixed` descendant and break the panel's fixed-to-
viewport positioning). Circular clip-path reveal expanding from
the hamburger's own corner (top-right) — the growing circle
naturally sweeps toward the opposite corner (bottom-left) last,
reading as the diagonal wipe this is going for without needing a
literal diagonal clip polygon. `vmax` (not %) for the radius so
full coverage holds regardless of viewport aspect ratio. */}
<AnimatePresence>
{mobileOpen && (
<motion.div
id="mobile-nav-panel"
ref={panelRef}
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
exit={{ clipPath: "circle(0vmax at 100% 0%)" }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<div className="flex flex-col min-h-full pt-[6.25rem]">
<nav className="flex flex-col flex-1 items-center justify-center gap-6 px-8 py-10 text-center">
{navLinks.map((link, i) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
// Staggered fade+rise entrance, timed to land after the
// clip-path reveal has visibly opened up — same
// "fancy but restrained" register as the rest of this
// codebase's motion usage (Reveal.tsx et al.), not a
// separate animation language just for this panel.
const linkMotionProps = {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0 },
transition: { delay: 0.15 + i * 0.05, duration: 0.3, ease: "easeOut" as const },
};
return link.href.startsWith("#") ? (
<motion.div key={link.href} {...linkMotionProps}>
<Link
href={resolvedHref}
onClick={
isHomeAnchor
? (e) => {
e.preventDefault();
closeMobile();
const el = document.getElementById(link.href.slice(1));
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
smoothScrollTo(top);
}
history.replaceState(null, "", link.href);
}
: closeMobile
}
className="min-h-11 flex flex-col justify-center gap-1 text-h-feature font-semibold text-text-primary w-fit"
style={{ fontFamily: "var(--font-lora)" }}
>
{link.label}
<span
className={`h-[2px] bg-brand transition-opacity duration-200 ${
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
}`}
/>
</Link>
</motion.div>
) : (
<motion.div key={link.href} {...linkMotionProps}>
<Link
href={link.href}
onClick={closeMobile}
className="min-h-11 flex items-center text-h-feature font-semibold text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{link.label}
</Link>
</motion.div>
);
})}
</nav>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 + navLinks.length * 0.05, duration: 0.3, ease: "easeOut" }}
className="flex flex-col gap-3 px-8 pb-16 pt-10"
>
{/* md:hidden — these two duplicate the inline CTA pair that's
already visible in the header itself from md (768px) up (see
"Trailing controls" above); only genuinely missing below
that, where the inline pair is hidden and the panel is
these buttons' only way to reach them. No login/account CTA
here (removed — Nutzer-Entscheidung: that's already reachable
via the account icon in the header itself, outside this
panel, no need to duplicate it inside). */}
<button
type="button"
onClick={() => {
closeMobile();
setNewsletterOpen(true);
}}
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
Newsletter
</button>
<Link
href="/challenge"
onClick={closeMobile}
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
7-Tage-Challenge
</Link>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
</>
);
+24 -5
View File
@@ -2,8 +2,9 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct, getShippingSettings } from "../lib/payload";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
/**
* Product teaser for whichever product is marked `spotlight` in Payload
@@ -22,11 +23,19 @@ import { formatPrice, discountPercent } from "../lib/format";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const [product, shipping] = await Promise.all([getSpotlightProduct(), getShippingSettings()]);
const [product, shipping, defaultTaxRate] = await Promise.all([
getSpotlightProduct(),
getShippingSettings(),
getDefaultTaxRatePercent(),
]);
if (!product) return null;
const image = product.spotlightImage || product.image;
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// Same "any vs. every" split as ProductGrid.tsx.
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
// id="spotlight" — the Navbar's "Shop" link becomes an anchor to this
@@ -42,10 +51,20 @@ export async function ProductSpotlight() {
sizes="(min-width: 768px) 380px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{discount !== null && (
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : discount !== null ? (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
) : (
anyLowStock && (
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</span>
)
)}
</div>
@@ -66,7 +85,7 @@ export async function ProductSpotlight() {
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt. zzgl. Versand</p>
</div>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
@@ -77,7 +96,7 @@ export async function ProductSpotlight() {
(matches Tools/Blog above/below), same as AddToCartButton's
own default styling/ring-offset, so no override is needed
here. */}
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} variants={product.variants} />
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
{product.href && (
<Link
href={product.href}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import Image from "next/image";
// Native size of the hand-drawn underline texture (icon-merke-dir-underline.png,
// exported from the Figma "label-underline" node) — used as the SSR/pre-hydration
// fallback width before the label's actual rendered width is measured.
const UNDERLINE_NATIVE_WIDTH = 136;
// Blockquote "Merke dir:" label + sparkle icon + underline (see RichText.tsx's
// "quote" case). Split out as its own client component because the underline
// needs to be stretched to match the label's actual rendered width — a fixed
// width only ever matched the one label length it was eyeballed against,
// leaving the underline too short (overflowing labels) or too long (sparse-
// looking short labels) for anything else.
export function QuoteLabel({ label }: { label: string }) {
const labelRef = useRef<HTMLSpanElement>(null);
const [underlineWidth, setUnderlineWidth] = useState(UNDERLINE_NATIVE_WIDTH);
useLayoutEffect(() => {
const el = labelRef.current;
if (!el) return;
const measure = () => setUnderlineWidth(el.offsetWidth);
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [label]);
return (
<div className="relative 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
ref={labelRef}
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
{label}
</span>
{/* object-fill (not cover) — the box's height stays fixed, only the
width tracks the label, so the texture stretches horizontally to
match rather than getting cropped. */}
<Image
alt=""
src="/icon-merke-dir-underline.png"
width={UNDERLINE_NATIVE_WIDTH}
height={23}
className="absolute left-8 top-[2.1875rem] h-[1.4375rem] object-fill pointer-events-none"
style={{ width: underlineWidth }}
/>
</div>
);
}
+2 -25
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import Image from "next/image";
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
@@ -149,30 +149,7 @@ function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNo
{/* 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"
/>
</>
)}
{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
+28
View File
@@ -0,0 +1,28 @@
import { formatPrice } from "../lib/format";
import type { TaxBreakdownGroup } from "../lib/taxBreakdown";
// The actual amount of VAT included in a total — not just a disclosure
// that VAT is included (see cartTotals.ts's effectiveTaxRate() for the
// "which %" shown next to each line item elsewhere). One line per rate
// when a cart/order spans more than one; a single line otherwise.
export function VatBreakdown({ groups }: { groups: TaxBreakdownGroup[] }) {
if (groups.length === 0) return null;
if (groups.length === 1) {
const [g] = groups;
return (
<p className="text-label text-text-muted">
enthält {g.rate}% MwSt.: {formatPrice(g.tax)}
</p>
);
}
return (
<div className="flex flex-col gap-0.5">
<p className="text-label text-text-muted">enthält MwSt.:</p>
{groups.map((g) => (
<p key={g.rate} className="text-label text-text-muted pl-2">
{g.rate}%: {formatPrice(g.tax)}
</p>
))}
</div>
);
}