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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user