Files
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

57 lines
2.2 KiB
TypeScript

"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>
);
}