Fix order-filter dropdowns, restructure Konto identity line, drop blog reset link

- CustomSelect.tsx: mousedown on an option blurred the trigger button
  before the click landed, unmounting the list before anything could be
  selected — add preventDefault on the list's mousedown to stop that.
- KontoShell no longer renders "Eingeloggt als ..." above each page's
  content; each page renders its own title then AccountIdentity right
  below it, and ProfileForm's now-duplicate email/Kundennummer line is
  removed.
- Blog category filter: drop the separate "Zurücksetzen" link — clicking
  an active category badge again already deactivates it.
- Werkzeuge cards: more vertical gap between stacked cards below sm:.
- Legal pages' "Stand: ..." line is now derived from the LegalPages doc's
  own updatedAt instead of a hand-typed string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
This commit is contained in:
Marco
2026-07-31 22:29:36 +00:00
parent d018d87e25
commit bf6c5d079a
15 changed files with 80 additions and 50 deletions
+18 -5
View File
@@ -165,7 +165,7 @@ explains what does have access instead.
| `discount-codes` | Cart discount input (`/cart`, display-only on `/checkout`) | `code`, `type` (`percent`/`fixed`), `value`, `validFrom`/`validUntil`, `minOrderValue`, `maxRedemptions`, `redemptionCount` (server-incremented only), `active`. **Not public-read** — see Discount codes section below |
| `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `categories` (`hasMany` relation to `categories` — a post can have several, joined with `", "` wherever rendered), `excerpt`, `thumbnail`, `content` (richText, supports custom Lexical blocks — Bild/Bildergalerie/Video/Zitat — rendered via `app/components/RichText.tsx`'s `@payloadcms/richtext-lexical/react` renderer with custom `JSXConverters`), `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`), `seoTitle`/`seoDescription`/`seoImage` (each falls back to `title`/`excerpt`/`thumbnail` when empty, consumed by `/blog/[slug]`'s `generateMetadata()`) |
| `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) |
| `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). Each page's "Stand: …" line is derived from this doc's own `updatedAt` (`formatMonthYear()` in `app/lib/format.ts`), not a hand-typed string — it updates automatically the moment an admin edits the content, no separate field to remember |
| `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` (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` |
@@ -1269,16 +1269,29 @@ whatever page sent the visitor to log in instead of always
`orders/[orderNumber]` (PATCH — cancel/return-request, see "Order
cancellation & returns" below), and `orders/[orderNumber]/invoice`
(GET — invoice PDF download, see "Invoice PDFs" below).
- **`app/konto/components/KontoShell.tsx`** is the shared shell for every
logged-in `/konto/*` page (bestellungen, merkliste, profil) —
`AccountNav.tsx` (persistent sidebar on `sm:`+, a horizontal tab bar
below it) plus the `<main>`/`<Footer>` wrapping used to be duplicated
per page, with the equivalent of the nav buried as plain links at the
bottom of the orders list (unreachable once that list got long). Each
page still owns its own auth-check/redirect/data-fetching and renders
its own heading; right under that heading it renders
`AccountIdentity.tsx` ("Eingeloggt als …, Kundennummer …") so that line
looks identical on every page without `KontoShell` itself needing to
know each page's title. `AccountNav` also renders the "Abmelden"
button (logout, folded into the nav rather than living at the bottom of
a specific page).
- **`/konto/bestellungen`** lists a customer's own orders — a per-card CSS
Grid (`grid grid-cols-2 min-[500px]:grid-cols-4`, so every card's
Bestellnummer/Datum/Artikel/Status/Zahlungsstatus/Gesamtbetrag fields
line up at identical x-positions regardless of label width, dropping to
2 columns below 500px), status shown as a colored `OrderStatusBadge.tsx`
plus a `PaymentStatusBadge.tsx` (Offen/Bezahlt/…, in the same
red/subtle-background style as a failed fulfillment status when unpaid),
plus an "Abmelden" link (`LogoutButton.tsx`); optional
status/paymentStatus/year filters (`OrderFilters.tsx`, 3 `CustomSelect`
dropdowns, URL-search-param-driven) when the tenant's `orderFilterEnabled`
red/subtle-background style as a failed fulfillment status when unpaid);
optional status/paymentStatus/year filters (`OrderFilters.tsx`, 3
`CustomSelect` dropdowns, URL-search-param-driven, collapsed behind a
"Filter" toggle below `sm:`) when the tenant's `orderFilterEnabled`
toggle is on (see "Feature toggles" below). **`/konto/bestellungen/[orderNumber]`**
shows one order's full detail (items, address, totals, `status`, plus
companyName/VAT-ID and a VAT-exemption note when the order has them). A
+2 -1
View File
@@ -9,6 +9,7 @@ import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { formatMonthYear } from "../lib/format";
export const metadata: Metadata = {
title: "AGB",
@@ -36,7 +37,7 @@ export default async function AgbPage() {
>
Allgemeine Geschäftsbedingungen
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
{page && <p className="text-body text-text-muted">Stand: {formatMonthYear(page.updatedAt)}</p>}
</Reveal>
{/* MobileSectionTOC below lg: only, see SectionTOC.tsx's own
-8
View File
@@ -96,14 +96,6 @@ export default async function BlogOverviewPage({
</Link>
);
})}
{activeCategories.length > 0 && (
<Link
href="/blog"
className="inline-flex items-center px-3 py-1.5 text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
>
Zurücksetzen
</Link>
)}
</div>
)}
</div>
+5
View File
@@ -131,6 +131,11 @@ export function CustomSelect({
ref={listRef}
role="listbox"
aria-label={label}
// Without this, a mousedown on an <li> blurs the trigger button
// first (li isn't focusable) — the resulting onBlur closes and
// unmounts this list before the click event ever fires, so
// nothing is ever selectable by mouse/touch.
onMouseDown={(e) => e.preventDefault()}
className="absolute z-20 mt-1 w-full sm:min-w-[12rem] max-h-64 overflow-y-auto bg-bg-base border border-border rounded-sm shadow-lg py-1"
>
{allOptions.map((o, i) => (
+1 -1
View File
@@ -35,7 +35,7 @@ export async function Tools() {
arrives where the fluid floor also sits), stacked below sm;
internal icon+text layout stays horizontal at every size, only
the outer span changes */}
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-10 sm:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
<RevealGroup className="grid grid-cols-1 gap-y-14 sm:grid-cols-12 sm:gap-y-10 gap-x-10 sm:gap-x-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
{tools.map((tool) => (
<RevealItem
key={tool.id}
+2 -1
View File
@@ -8,6 +8,7 @@ import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { formatMonthYear } from "../lib/format";
export const metadata: Metadata = {
title: "Datenschutzerklärung",
@@ -35,7 +36,7 @@ export default async function DatenschutzPage() {
>
Datenschutzerklärung
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
{page && <p className="text-body text-text-muted">Stand: {formatMonthYear(page.updatedAt)}</p>}
</Reveal>
{/* MobileSectionTOC below lg: only, see SectionTOC.tsx's own
+8 -4
View File
@@ -14,6 +14,7 @@ import { getOrderFilterEnabled } from "../../lib/payload";
import { OrderStatusBadge } from "../components/OrderStatusBadge";
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
import { OrderFilters } from "./components/OrderFilters";
// robots: noindex — account area, same reasoning as /checkout.
@@ -59,11 +60,14 @@ export default async function KontoBestellungenPage({
]);
return (
<KontoShell customer={session.customer}>
<KontoShell>
<Reveal className="flex flex-col gap-6 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Bestellungen
</p>
<div className="flex flex-col gap-1 items-start">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Bestellungen
</p>
<AccountIdentity email={session.customer.email} customerNumber={session.customer.customerNumber} />
</div>
{orderFilterEnabled && availableYears.length > 0 && (
<Suspense fallback={null}>
+7
View File
@@ -0,0 +1,7 @@
export function AccountIdentity({ email, customerNumber }: { email: string; customerNumber: string }) {
return (
<p className="text-body-sm text-text-muted">
Eingeloggt als {email} (Kundennummer {customerNumber})
</p>
);
}
+7 -17
View File
@@ -12,28 +12,18 @@ import { AccountNav } from "./AccountNav";
// page-specific logic each page still needs (e.g. merkliste's return-URL
// login redirect, profil's second profile-fetch redirect).
//
// `customer` is rendered here, once, so "Eingeloggt als …" looks and
// appears identically on every /konto/* page — it used to be hand-copied
// onto individual pages (bestellungen had it, merkliste/profil didn't),
// which read as inconsistent rather than a deliberate per-page choice.
export async function KontoShell({
children,
customer,
}: {
children: ReactNode;
customer: { email: string; customerNumber: string };
}) {
// "Eingeloggt als …" is NOT rendered here (even though `customer` used to
// be the only prop this shell needed) — it belongs directly under each
// page's own title so it reads as "page title, then who's logged in", not
// above the title before the page has even introduced itself. See
// AccountIdentity.tsx, rendered by each page right after its own heading.
export async function KontoShell({ children }: { children: ReactNode }) {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col sm:flex-row gap-6 sm:gap-10 w-full max-w-[75rem] mx-auto px-[var(--layout-padding-x)] pt-8 sm:pt-10 pb-16">
<AccountNav />
<div className="flex-1 min-w-0 flex flex-col gap-6">
<p className="text-body-sm text-text-muted">
Eingeloggt als {customer.email} (Kundennummer {customer.customerNumber})
</p>
{children}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-6">{children}</div>
</div>
</main>
<Footer />
+8 -4
View File
@@ -4,6 +4,7 @@ import { Reveal } from "../../components/Reveal";
import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
import { MerklisteGrid } from "./components/MerklisteGrid";
// robots: noindex — account area, same reasoning as /konto/bestellungen.
@@ -40,11 +41,14 @@ export default async function KontoMerklistePage() {
const initialProducts = await getProductsByIds(wishlistItems.map((i) => i.productId));
return (
<KontoShell customer={session.customer}>
<KontoShell>
<Reveal className="flex flex-col gap-6 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Merkliste
</p>
<div className="flex flex-col gap-1 items-start">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Merkliste
</p>
<AccountIdentity email={session.customer.email} customerNumber={session.customer.customerNumber} />
</div>
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
</Reveal>
@@ -103,13 +103,6 @@ export function ProfileForm({
return (
<Reveal className="flex flex-col gap-6 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<p className="text-body-sm text-text-muted">
{profile.email} · Kundennummer {profile.customerNumber}
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
{/* Explicit heading, matching /checkout's own "1. Rechnungsadresse"
card title without it, this section and the "Lieferadresse"
+8 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { getShippingCountries } from "../../lib/payload";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
import { ProfileForm } from "./components/ProfileForm";
import { PasswordForm } from "./components/PasswordForm";
import { VerificationBanner } from "./components/VerificationBanner";
@@ -28,8 +29,14 @@ export default async function KontoProfilPage({
const { verified } = await searchParams;
return (
<KontoShell customer={{ email: profile.email, customerNumber: session.customer.customerNumber }}>
<KontoShell>
<div className="flex flex-col gap-10 items-start w-full max-w-[40rem]">
<div className="flex flex-col gap-1 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<AccountIdentity email={profile.email} customerNumber={session.customer.customerNumber} />
</div>
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
<PasswordForm email={profile.email} />
+9
View File
@@ -31,3 +31,12 @@ export function formatDate(iso: string): string {
const day = String(d.getDate()).padStart(2, "0");
return `${day}. ${MONTHS_DE[d.getMonth()]} ${d.getFullYear()}`;
}
// "Juli 2026" — for legal pages' "Stand: …" line, derived from the
// LegalPages doc's own updatedAt so it always reflects when the template
// was actually last edited, instead of a hand-typed string an admin has
// to remember to bump on every content change.
export function formatMonthYear(iso: string): string {
const d = new Date(iso);
return `${MONTHS_DE[d.getMonth()]} ${d.getFullYear()}`;
}
+3
View File
@@ -838,6 +838,7 @@ export type LegalPage = {
title: string;
content: unknown;
attachment: { url: string; title: string } | null;
updatedAt: string;
};
type PayloadLegalPage = {
@@ -845,6 +846,7 @@ type PayloadLegalPage = {
title: string;
content: unknown;
attachment: { url: string; title: string } | number | null;
updatedAt: string;
};
export async function getLegalPage(type: LegalPageType, options?: { draft?: boolean }): Promise<LegalPage | null> {
@@ -872,6 +874,7 @@ export async function getLegalPage(type: LegalPageType, options?: { draft?: bool
typeof doc.attachment === "object" && doc.attachment
? { url: doc.attachment.url, title: doc.attachment.title }
: null,
updatedAt: doc.updatedAt,
};
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { formatMonthYear } from "../lib/format";
export const metadata: Metadata = {
title: "Widerrufsrecht",
@@ -36,7 +37,7 @@ export default async function WiderrufPage() {
>
Widerrufsrecht
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
{page && <p className="text-body text-text-muted">Stand: {formatMonthYear(page.updatedAt)}</p>}
</Reveal>
{/* MobileSectionTOC below lg: only, see SectionTOC.tsx's own