Dynamic AGB address, real shipping costs on /versand instead of hardcoded constants
- AGB: name/address/email now come from company-settings via new VertragspartnerBlock (mid-document, see LegalPages.ts's contentPart2). - VersandSections/VersandModal/CartContent/CheckoutContent/versand page: shipping cost and free-shipping threshold now come from the real ShippingMethods data (already fetched elsewhere for checkout), not the hardcoded lib/shipping.ts constants — those had already drifted from the real Payload values once. lib/shipping.ts deleted, nothing left to export. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { headingId } from "../../components/RichText";
|
||||
import type { CompanySettings } from "../../lib/payload";
|
||||
import type { TOCSection } from "../../components/SectionTOC";
|
||||
|
||||
// Renders "2. Vertragspartner" straight from company-settings, same
|
||||
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx and
|
||||
// Datenschutz's VerantwortlicherBlock.tsx — this used to be hand-typed
|
||||
// name/address/email baked into the AGB richText (seed-agb.ts on the
|
||||
// Payload side), and had already drifted from the real company-settings
|
||||
// values once (the richText's placeholder "Björn Wendt"/"Musterstraße
|
||||
// 12" never got updated when the real address was set). Sits mid-document
|
||||
// (section 1 "Geltungsbereich" comes before it), which is why AGB's
|
||||
// content is split into `content` (section 1) + `contentPart2` (sections
|
||||
// 3 onward) rather than just prepending this block like Datenschutz did —
|
||||
// see LegalPages.ts's `contentPart2` field comment.
|
||||
export function vertragspartnerHeadings(): TOCSection[] {
|
||||
return [{ id: headingId("2. Vertragspartner"), title: "2. Vertragspartner" }];
|
||||
}
|
||||
|
||||
function Heading({ children }: { children: string }) {
|
||||
return (
|
||||
<h2
|
||||
id={headingId(children)}
|
||||
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{children}
|
||||
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function P({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-body text-text-body">{children}</p>;
|
||||
}
|
||||
|
||||
export function VertragspartnerBlock({ seller }: { seller: CompanySettings }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Heading>2. Vertragspartner</Heading>
|
||||
<P>Der Kaufvertrag kommt zustande mit:</P>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>
|
||||
<strong>einfach produktiv. – {seller.sellerName}</strong>
|
||||
</P>
|
||||
<P>
|
||||
<strong>
|
||||
{seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity}
|
||||
</strong>
|
||||
</P>
|
||||
<P>
|
||||
<strong>
|
||||
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="hover:underline">{seller.sellerEmail}</a>
|
||||
</strong>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+23
-5
@@ -8,8 +8,9 @@ import { TrustRow } from "../components/TrustRow";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
import { getLegalPage, getCompanySettings } from "../lib/payload";
|
||||
import { formatMonthYear } from "../lib/format";
|
||||
import { VertragspartnerBlock, vertragspartnerHeadings } from "./components/VertragspartnerBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AGB",
|
||||
@@ -19,8 +20,16 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function AgbPage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const page = await getLegalPage("agb", { draft: isPreview });
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
const [page, seller] = await Promise.all([getLegalPage("agb", { draft: isPreview }), getCompanySettings()]);
|
||||
// Section 1 ("Geltungsbereich") comes first from `content`, THEN
|
||||
// "2. Vertragspartner" (dynamic, sits between two CMS-driven halves —
|
||||
// see LegalPages.ts's `contentPart2` comment), then the rest from
|
||||
// `contentPart2`.
|
||||
const headings = [
|
||||
...(page ? extractHeadings(page.content) : []),
|
||||
...vertragspartnerHeadings(),
|
||||
...(page?.contentPart2 ? extractHeadings(page.contentPart2) : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,9 +78,18 @@ export default async function AgbPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:flex-1 min-w-0">
|
||||
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
|
||||
{page ? (
|
||||
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
|
||||
<>
|
||||
{isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />}
|
||||
{/* Name/Adresse/E-Mail kommen direkt aus company-settings,
|
||||
nicht aus der CMS-Richtext — single-sourced, gleiche
|
||||
Begründung wie Impressum/Datenschutz. */}
|
||||
{seller && <VertragspartnerBlock seller={seller} />}
|
||||
{page.contentPart2 ? (
|
||||
isPreview ? <LiveRichText initialContent={page.contentPart2} /> : <RichText content={page.contentPart2} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
|
||||
)}
|
||||
|
||||
@@ -524,7 +524,13 @@ export function CartContent({
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
|
||||
<VersandModal
|
||||
open={versandOpen}
|
||||
onClose={() => setVersandOpen(false)}
|
||||
shipping={shippingSettings}
|
||||
shippingCost={shippingCost}
|
||||
freeShippingThreshold={freeShippingThreshold}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -397,6 +397,11 @@ export function CheckoutContent({
|
||||
selectedShipping?.freeShippingThreshold !== undefined &&
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || !cartHasShippableItem(items) || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
// For VersandModal — same "lowest freeShippingThreshold among active
|
||||
// methods" rule as CartContent.tsx/getTrustBadges(), independent of
|
||||
// whichever method is currently selected in the radio list above.
|
||||
const shippingMethodThresholds = shippingMethods.map((m) => m.freeShippingThreshold).filter((t): t is number => t !== null);
|
||||
const lowestFreeShippingThreshold = shippingMethodThresholds.length > 0 ? Math.min(...shippingMethodThresholds) : null;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
@@ -1551,7 +1556,13 @@ export function CheckoutContent({
|
||||
</Reveal>
|
||||
</form>
|
||||
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
|
||||
<VersandModal
|
||||
open={versandOpen}
|
||||
onClose={() => setVersandOpen(false)}
|
||||
shipping={shippingSettings}
|
||||
shippingCost={shippingMethods[0]?.price ?? 0}
|
||||
freeShippingThreshold={lowestFreeShippingThreshold}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,14 @@ export function VersandModal({
|
||||
open,
|
||||
onClose,
|
||||
shipping,
|
||||
shippingCost,
|
||||
freeShippingThreshold,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shipping: ShippingSettings;
|
||||
shippingCost: number;
|
||||
freeShippingThreshold: number | null;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -109,7 +113,7 @@ export function VersandModal({
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 pb-8">
|
||||
<VersandSections shipping={shipping} />
|
||||
<VersandSections shipping={shipping} shippingCost={shippingCost} freeShippingThreshold={freeShippingThreshold} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -855,6 +855,12 @@ export type LegalPage = {
|
||||
type: LegalPageType;
|
||||
title: string;
|
||||
content: unknown;
|
||||
// Only populated for pages that need a dynamically-rendered section
|
||||
// MID-document (currently just AGB's "Vertragspartner", sourced from
|
||||
// company-settings — see VertragspartnerBlock.tsx). null everywhere
|
||||
// else, including pages like Datenschutz whose one dynamic section sits
|
||||
// at the very start and so can just be prepended instead of split.
|
||||
contentPart2: unknown | null;
|
||||
attachment: { url: string; title: string } | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -863,6 +869,7 @@ type PayloadLegalPage = {
|
||||
type: LegalPageType;
|
||||
title: string;
|
||||
content: unknown;
|
||||
contentPart2?: unknown;
|
||||
attachment: { url: string; title: string } | number | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -888,6 +895,7 @@ export async function getLegalPage(type: LegalPageType, options?: { draft?: bool
|
||||
type: doc.type,
|
||||
title: doc.title,
|
||||
content: doc.content,
|
||||
contentPart2: doc.contentPart2 ?? null,
|
||||
attachment:
|
||||
typeof doc.attachment === "object" && doc.attachment
|
||||
? { url: doc.attachment.url, title: doc.attachment.title }
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Single source of truth for shipping numbers — consumed by both the
|
||||
// cart's order summary (CartContent.tsx) and the /versand policy page, so
|
||||
// the two can never drift apart.
|
||||
//
|
||||
// The delivery-timeframe numbers (handling/transit days, Art. 246a § 1
|
||||
// Abs. 1 Nr. 8 EGBGB) used to live here too, but now come from Payload's
|
||||
// Shipping Settings collection instead (see lib/payload.ts's
|
||||
// getShippingSettings()) — that duplication is exactly why the
|
||||
// ShippingMethods CMS title once carried a different, drifted day-range
|
||||
// than this file did. Cost/threshold below have the same drift risk
|
||||
// against Payload's ShippingMethods price/freeShippingThreshold fields,
|
||||
// not yet addressed here.
|
||||
export const SHIPPING_COST = 2.9;
|
||||
export const FREE_SHIPPING_THRESHOLD = 39;
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import type { ShippingSettings } from "../../lib/payload";
|
||||
|
||||
@@ -49,9 +48,21 @@ function Section({
|
||||
export function VersandSections({
|
||||
withAnchors = false,
|
||||
shipping,
|
||||
shippingCost,
|
||||
freeShippingThreshold,
|
||||
}: {
|
||||
withAnchors?: boolean;
|
||||
shipping: ShippingSettings;
|
||||
/** Price of the default (first active, i.e. Standard) ShippingMethod —
|
||||
* same value/estimate CartContent.tsx already computes for its own
|
||||
* order-summary line, threaded down here instead of this component
|
||||
* having its own hardcoded lib/shipping.ts constant, which is exactly
|
||||
* what let this text drift from the real Payload ShippingMethods price
|
||||
* (see lib/shipping.ts's own comment on this). */
|
||||
shippingCost: number;
|
||||
/** Lowest freeShippingThreshold among active ShippingMethods, or null if
|
||||
* none has one — same rule CartContent.tsx/getTrustBadges() already use. */
|
||||
freeShippingThreshold: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 items-start w-full">
|
||||
@@ -72,9 +83,9 @@ export function VersandSections({
|
||||
|
||||
<Section id="versandkosten" title="Versandkosten" withAnchor={withAnchors}>
|
||||
<p>
|
||||
Die Versandkosten innerhalb Deutschlands betragen pauschal {formatPrice(SHIPPING_COST)}{" "}
|
||||
pro Bestellung. Ab einem Bestellwert von {formatPrice(FREE_SHIPPING_THRESHOLD)} versenden
|
||||
wir kostenlos.
|
||||
Die Versandkosten innerhalb Deutschlands betragen pauschal {formatPrice(shippingCost)}{" "}
|
||||
pro Bestellung.
|
||||
{freeShippingThreshold !== null && ` Ab einem Bestellwert von ${formatPrice(freeShippingThreshold)} versenden wir kostenlos.`}
|
||||
</p>
|
||||
<p>Alle angegebenen Preise verstehen sich inklusive der gesetzlichen Mehrwertsteuer.</p>
|
||||
<p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { VersandSections } from "./components/VersandSections";
|
||||
import { VersandTOC, MobileVersandTOC } from "./components/VersandTOC";
|
||||
import { getShippingSettings } from "../lib/payload";
|
||||
import { getShippingSettings, getShippingMethods } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Versand",
|
||||
@@ -14,7 +14,12 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function VersandPage() {
|
||||
const shipping = await getShippingSettings();
|
||||
const [shipping, shippingMethods] = await Promise.all([getShippingSettings(), getShippingMethods()]);
|
||||
// Same "first active method / lowest threshold among active methods"
|
||||
// convention as CartContent.tsx/getTrustBadges() — see VersandSections.tsx.
|
||||
const shippingCost = shippingMethods[0]?.price ?? 0;
|
||||
const shippingMethodThresholds = shippingMethods.map((m) => m.freeShippingThreshold).filter((t): t is number => t !== null);
|
||||
const freeShippingThreshold = shippingMethodThresholds.length > 0 ? Math.min(...shippingMethodThresholds) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -47,7 +52,7 @@ export default async function VersandPage() {
|
||||
<VersandTOC />
|
||||
</div>
|
||||
<div className="w-full lg:flex-1 max-w-[45rem]">
|
||||
<VersandSections withAnchors shipping={shipping} />
|
||||
<VersandSections withAnchors shipping={shipping} shippingCost={shippingCost} freeShippingThreshold={freeShippingThreshold} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user