Compare commits
253 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd1b73e304 | |||
| 98b3b3b6da | |||
| 8f006b6603 | |||
| 0791e7c8bc | |||
| e882b8b6ac | |||
| 1ab4088bf0 | |||
| a2d4cb29fc | |||
| 1d7f49217c | |||
| 8b9e2bdb42 | |||
| a1b0dffff7 | |||
| 1330e3e621 | |||
| 21b5f41127 | |||
| 7489f83564 | |||
| 82696cb259 | |||
| 0fea84f362 | |||
| 56fae6ff13 | |||
| 39ebf2604d | |||
| 2280a4a4ba | |||
| b0d76c891f | |||
| 7fc7ee2c3a | |||
| 098ba79c8f | |||
| f4af0bc15c | |||
| 2d9508324a | |||
| 885389b300 | |||
| 940545888c | |||
| e2d81fabc7 | |||
| a1c9a2127b | |||
| c2d0b6e364 | |||
| 349bea5261 | |||
| 85478ff746 | |||
| cf5d7f8739 | |||
| b26a44b979 | |||
| 0fedb386b4 | |||
| 7b9e833560 | |||
| 3be8682e1a | |||
| 5e48b67588 | |||
| 4326072fbe | |||
| f0f5252d10 | |||
| 0de2e1c1be | |||
| 76d59a5682 | |||
| af5956848b | |||
| cdc779aebe | |||
| 9af970587d | |||
| fff2467d19 | |||
| e9a793db08 | |||
| cfa658a210 | |||
| 2dee2e7871 | |||
| 790762979e | |||
| c64902c381 | |||
| bf48a1561c | |||
| 168f17bcef | |||
| 7e00d51b89 | |||
| 233a41f7c9 | |||
| 24ae330037 | |||
| 821fbb0207 | |||
| 194117c07d | |||
| d2332c37e9 | |||
| d2255c47f2 | |||
| 2b39ec757e | |||
| 1f537ccb06 | |||
| b59c9a0e4b | |||
| 6debcd5a46 | |||
| b3d7855b0e | |||
| ba6e486e14 | |||
| 71cd75425d | |||
| 4fa80d6bfb | |||
| eb160cac61 | |||
| 13fb37eb5e | |||
| 988f259371 | |||
| a085a75dea | |||
| b6e76ccd22 | |||
| c065a3223f | |||
| 7f95711048 | |||
| e7ad9cd88d | |||
| 340dcd2c94 | |||
| 70bc839f48 | |||
| 82c1b1fd43 | |||
| c8f231baa6 | |||
| 9617478b0d | |||
| 23f8efcc2b | |||
| 6e6f273524 | |||
| 3336ce77c1 | |||
| 90c52687bf | |||
| 7ef6d2652b | |||
| ac6c3d6b71 | |||
| 417bbd430e | |||
| f6b001dd11 | |||
| a8ab3e4f44 | |||
| 72b7bc629c | |||
| 43ab5f2407 | |||
| 537543bf91 | |||
| 259002f5c0 | |||
| 87388479de | |||
| 70ee518e1d | |||
| c6b12e9d60 | |||
| d046e5c3bb | |||
| 2638515683 | |||
| 0dcaad8b8c | |||
| b0df2f13fa | |||
| 51632b1668 | |||
| d010a3aff1 | |||
| 1ec442cc05 | |||
| 552718c09a | |||
| ce3ac3e79a | |||
| 56c279b51b | |||
| 77a9ef27a9 | |||
| 45bd321bb0 | |||
| 9a0729adef | |||
| cc17311d03 | |||
| 83de7df089 | |||
| aabfad3541 | |||
| 557f6a1abc | |||
| f95feafb1a | |||
| e49dacf057 | |||
| 5c4a0e011d | |||
| aa491aa5fd | |||
| 8b636bef25 | |||
| 9f61322604 | |||
| b88a8edd35 | |||
| 7c26bf7b7c | |||
| bf6c5d079a | |||
| d018d87e25 | |||
| f54ff267a7 | |||
| 6f033d52b5 | |||
| 92727ff22a | |||
| 1a3a8ba20e | |||
| b144758b82 | |||
| 92c6953724 | |||
| 9bf6e3f4f2 | |||
| a077adea0f | |||
| b6a826dc46 | |||
| 230b8ebaad | |||
| 8a4170a1e6 | |||
| 51e4860fe3 | |||
| 8c2220ae45 | |||
| ee39a5d7dc | |||
| 2b2ee4d23c | |||
| f5b898a69a | |||
| fc8a1d4201 | |||
| bce5a9f82c | |||
| bc40e22910 | |||
| 2eda211a29 | |||
| 8c5e284b6c | |||
| 72c7b0454a | |||
| 11aa9689c1 | |||
| 1d91a3f21c | |||
| 475ee3fa16 | |||
| c8cf6ae10d | |||
| 2f8c184dc6 | |||
| e7d5dd8b1e | |||
| 79d3f836a2 | |||
| 113d25eef9 | |||
| 785c1e6eba | |||
| 9024112d88 | |||
| 7ce8d02afe | |||
| 11c8d0b7b6 | |||
| 6b8ba44d46 | |||
| ea7f4a4b3b | |||
| 6262a8ab01 | |||
| 7baf8f9e24 | |||
| a036c89291 | |||
| 32f4068272 | |||
| 275d5abd95 | |||
| 0f50ab334c | |||
| 54725f7c5f | |||
| 7c9bfc3897 | |||
| 014f1edcdc | |||
| cca555c0f4 | |||
| c6fe65ad99 | |||
| 73b4b06eae | |||
| 3704acab41 | |||
| 53c452583d | |||
| 80843cc08b | |||
| c6051442df | |||
| 1fe1a260e4 | |||
| 5d588c55b4 | |||
| 44f98a3d64 | |||
| 8ddde89143 | |||
| 84e21589c3 | |||
| 9502cf81b9 | |||
| 18f5156fd8 | |||
| f0b2d21989 | |||
| 2e537bc78d | |||
| 4adb844f8d | |||
| beea592706 | |||
| 6448c8736a | |||
| 0e995884a7 | |||
| 8c843c0ac1 | |||
| e3352d7e32 | |||
| 1467750db6 | |||
| c83bbfbc35 | |||
| c852752006 | |||
| a3f843ad15 | |||
| bffc7d39a2 | |||
| 70abe20250 | |||
| 22fef4fe49 | |||
| fb1bedcacd | |||
| 27d383b559 | |||
| 4524005f0e | |||
| 14b47fd049 | |||
| 47789e94db | |||
| a6e1734799 | |||
| 0a467aed59 | |||
| 14094ab1d4 | |||
| 96fddf4cd9 | |||
| 3fc98b8480 | |||
| 5f219c23f9 | |||
| 908f46c962 | |||
| d389790c57 | |||
| 3aa8720daf | |||
| 077986affb | |||
| 5d61dce12b | |||
| 17679cc81e | |||
| 19440b9bcf | |||
| 56fc8d970d | |||
| 61afa0dcae | |||
| 724530b073 | |||
| 5c43a49ba9 | |||
| e0299713f5 | |||
| a9c8344472 | |||
| cbb423f8ca | |||
| dfb6eb5e23 | |||
| 5e1d88d13e | |||
| c3a6c84d8a | |||
| 932cf60c5c | |||
| cc935420ec | |||
| 94cba756c8 | |||
| b835e8622f | |||
| 7286c76787 | |||
| e175d2b4c3 | |||
| 1306dce6e4 | |||
| 8fa4a20e63 | |||
| 1d875a65c0 | |||
| 0436e87147 | |||
| 7feda76b3c | |||
| c4fb6586e7 | |||
| 3dccc229af | |||
| 6c43171b11 | |||
| 8d4ee75cfb | |||
| 8a910f7ff8 | |||
| a7fa0ec027 | |||
| 0ac2e45077 | |||
| fd98f68ba6 | |||
| bccfc28012 | |||
| 55df2e104c | |||
| 1706da8598 | |||
| 1dbc0c31ff | |||
| 06a00d67e4 | |||
| 952b902702 | |||
| f959ebb998 | |||
| bae23775f2 | |||
| 0e12ab1f1e | |||
| cb4c9d77f2 |
+4
-4
@@ -12,12 +12,12 @@ function LockIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
export function EmailCapture({ buttonLabel = "Klarheits-Check starten" }: { buttonLabel?: string }) {
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("challenge");
|
||||
|
||||
if (status === "success") {
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.</p>;
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">{successMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -60,7 +60,7 @@ export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabe
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[#444] leading-normal">
|
||||
@@ -5,6 +5,7 @@ import { draftMode } from "next/headers";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { StepArrow } from "../components/StepArrow";
|
||||
import { STEP_ICONS } from "../components/icons/StepIcons";
|
||||
import { TestimonialsGrid } from "../components/TestimonialsGrid";
|
||||
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
@@ -18,12 +19,12 @@ export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/challenge",
|
||||
canonical: "/7-tage-klarheits-check",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/challenge",
|
||||
url: "/7-tage-klarheits-check",
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
twitter: {
|
||||
@@ -33,49 +34,6 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
function IconEnvelope() {
|
||||
return (
|
||||
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
|
||||
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M2 2l24 22L50 2" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMailLines() {
|
||||
return (
|
||||
<svg width="56" height="44" viewBox="0 0 56 44" fill="none">
|
||||
<line x1="2" y1="12" x2="12" y2="12" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="20" x2="9" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="28" x2="7" y2="28" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<rect x="13" y="2" width="41" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M13 2l20.5 19L54 2" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconNotepad() {
|
||||
return (
|
||||
<svg width="46" height="52" viewBox="0 0 46 52" fill="none">
|
||||
<rect x="2" y="4" width="32" height="42" rx="2" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="9" y1="16" x2="27" y2="16" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="23" x2="24" y2="23" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="30" x2="20" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="37" cy="40" r="7" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="37" y1="27" x2="37" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCheckCircle() {
|
||||
return (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M14 24l7.5 7.5L34 16" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Check() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
@@ -86,22 +44,22 @@ function Check() {
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: <IconEnvelope />,
|
||||
icon: STEP_ICONS.envelope(),
|
||||
title: "1. Anmelden",
|
||||
desc: "Trage dich mit deiner E-Mail-Adresse ein und starte sofort.",
|
||||
},
|
||||
{
|
||||
icon: <IconMailLines />,
|
||||
icon: STEP_ICONS.mailLines(),
|
||||
title: "2. E-Mail erhalten",
|
||||
desc: "Du bekommst 7 Tage lang jeweils einen Impuls direkt in dein Postfach.",
|
||||
},
|
||||
{
|
||||
icon: <IconNotepad />,
|
||||
icon: STEP_ICONS.notepad(),
|
||||
title: "3. Umsetzen",
|
||||
desc: "Setze die einfache Aufgabe in wenigen Minuten um – für mehr Klarheit und Fokus.",
|
||||
desc: "Gehe die einfachen Gedankenanstöße in wenigen Minuten durch – für mehr Klarheit und Fokus",
|
||||
},
|
||||
{
|
||||
icon: <IconCheckCircle />,
|
||||
icon: STEP_ICONS.checkCircle(),
|
||||
title: "4. Dranbleiben",
|
||||
desc: "Kleine Schritte führen zu großen Veränderungen – Tag für Tag.",
|
||||
},
|
||||
@@ -117,7 +75,7 @@ const benefits = [
|
||||
|
||||
export default async function ChallengePage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const testimonials = await getTestimonials("challenge", { draft: isPreview });
|
||||
const testimonials = await getTestimonials("klarheits-check", { draft: isPreview });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -152,7 +110,7 @@ export default async function ChallengePage() {
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">Werkzeuge</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">7-Tage-Challenge</span>
|
||||
<span className="text-text-primary">7-Tage-Klarheits-Check</span>
|
||||
</p>
|
||||
|
||||
{/* Everything else — centered in the remaining vertical space,
|
||||
@@ -227,7 +185,7 @@ export default async function ChallengePage() {
|
||||
className="font-semibold text-[#222221]"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
|
||||
>
|
||||
So funktioniert die Challenge
|
||||
So funktioniert der Check
|
||||
</h2>
|
||||
<p className="text-[1rem] text-[#666]">Jeden Tag ein Impuls. In nur wenigen Minuten.</p>
|
||||
</Reveal>
|
||||
@@ -345,7 +303,7 @@ export default async function ChallengePage() {
|
||||
className="font-semibold text-[#222221] leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.125rem, 2vw, 1.375rem)" }}
|
||||
>
|
||||
Starte jetzt deine 7-Tage-Challenge
|
||||
Starte jetzt deine 7 Tage Klarheit
|
||||
</p>
|
||||
<p className="text-[0.9rem] text-[#555] leading-[1.5]">
|
||||
Trage dich ein und erhalte ab sofort täglich einen Impuls für mehr Klarheit und Fokus.
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { PageBlocks } from "../components/PageBlocks";
|
||||
import { LivePageContent } from "../components/LivePageContent";
|
||||
import { getPageBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildWebPageSchema } from "../lib/structuredData";
|
||||
|
||||
// First generic catch-all content route in this repo — every other page
|
||||
// (blog posts excepted, which have their own [slug]) is its own static
|
||||
// directory under app/. Powers Payload's new Pages collection (the page
|
||||
// builder): a doc's `layout` blocks field renders via PageBlocks.tsx,
|
||||
// structurally parallel to how RichText.tsx renders Posts.content's own
|
||||
// (differently-shaped) Blocks. Next.js resolves any matching static route
|
||||
// (e.g. app/lebensuhr/page.tsx) before ever reaching this catch-all, so a
|
||||
// Pages document only actually serves a URL once the static file for that
|
||||
// slug, if any, is removed.
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const page = await getPageBySlug(slug);
|
||||
if (!page) return { title: "Seite nicht gefunden" };
|
||||
|
||||
const title = page.seoTitle || page.title;
|
||||
const description = page.seoDescription ?? undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `/${page.slug}` },
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: `/${page.slug}`,
|
||||
type: "website",
|
||||
images: page.seoImage ? [{ url: page.seoImage }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images: page.seoImage ? [page.seoImage] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DynamicPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const page = await getPageBySlug(slug, { draft: isPreview });
|
||||
if (!page) notFound();
|
||||
|
||||
const seller = await getCompanySettings();
|
||||
const pageSchema = buildWebPageSchema(page, seller);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(pageSchema) }} />
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{isPreview ? (
|
||||
<LivePageContent initialPage={page} />
|
||||
) : (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">{page.title}</span>
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14 flex flex-col gap-5">
|
||||
<PageBlocks blocks={page.layout} />
|
||||
</Reveal>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
-6
@@ -8,7 +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",
|
||||
@@ -18,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 (
|
||||
<>
|
||||
@@ -36,7 +46,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
|
||||
@@ -68,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>
|
||||
)}
|
||||
|
||||
@@ -10,9 +10,9 @@ export async function GET() {
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const profile = await getCustomerProfile(session.token);
|
||||
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
|
||||
const orderSummaries = await getCustomerOrders(session.token, session.customer.id, false);
|
||||
const orders = await Promise.all(
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber, false)),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../../../lib/payload";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../../../../lib/payments";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Lets a logged-in customer move an existing, still-unpaid Überweisung
|
||||
// order onto a Stripe PaymentIntent instead of waiting on their own bank
|
||||
// transfer — see the backend's switchPaymentToStripe.ts for the matching
|
||||
// endpoint and why this needs a dedicated backend route rather than the
|
||||
// generic customer-JWT order-PATCH path (paymentProvider/paymentStatus
|
||||
// are system fields a customer JWT can never touch).
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
// Same eligibility the backend endpoint re-checks authoritatively —
|
||||
// checked here too for a friendly error instead of a bare 409. An
|
||||
// explicit allowlist ('pending'/'not_applicable'), not a
|
||||
// paymentStatus !== "paid" blocklist — see switchPaymentToStripe.ts's
|
||||
// own comment on why 'failed'/'refunded'/'partially_refunded' aren't
|
||||
// meaningful states to switch from either.
|
||||
const eligiblePaymentStatus = order.paymentStatus === "pending" || order.paymentStatus === "not_applicable";
|
||||
if (order.paymentProvider !== "manual" || order.status !== "received" || !eligiblePaymentStatus) {
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlungsart kann für diese Bestellung gerade nicht geändert werden." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only offer this when a real Stripe payment method is actually active
|
||||
// — same "Online-Zahlung" grouping the checkout itself uses, so this
|
||||
// never presents an option that checkout wouldn't currently accept either.
|
||||
const methods = groupPaymentMethodsForCheckout(await getPaymentMethods());
|
||||
const hasStripeOption = methods.some((m) => m.provider === "stripe");
|
||||
if (!hasStripeOption) {
|
||||
return NextResponse.json({ ok: false, reason: "Aktuell steht keine Online-Zahlung zur Verfügung." }, { status: 400 });
|
||||
}
|
||||
|
||||
let intent: { clientSecret: string; providerReference: string };
|
||||
try {
|
||||
intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(order.total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: order.customerEmail,
|
||||
description: `Bestellung ${order.orderNumber}`,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, reason: "Zahlung konnte nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${order.id}/switch-payment-to-stripe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET },
|
||||
body: JSON.stringify({ providerReference: intent.providerReference }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json({ ok: false, reason: data?.reason ?? "Umstellung fehlgeschlagen." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Best-effort, same as checkout's own call — a failure here doesn't
|
||||
// block the payment itself, only the confirm-payment webhook's metadata
|
||||
// lookup, which the frontend's own webhook route already alerts on.
|
||||
await paymentProvider.attachOrderMetadata(intent.providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch(() => {});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
clientSecret: intent.clientSecret,
|
||||
orderId: order.id,
|
||||
orderNumber: order.orderNumber,
|
||||
testMode: isPaymentTestMode,
|
||||
...(isPaymentTestMode ? { providerReference: intent.providerReference } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
|
||||
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
|
||||
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
@@ -13,13 +13,36 @@ export async function PATCH(request: Request) {
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
street,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName,
|
||||
vatId,
|
||||
hasDifferentShippingAddress,
|
||||
shippingFirstName,
|
||||
shippingLastName,
|
||||
shippingCompanyName,
|
||||
shippingDeliveryMethod,
|
||||
shippingStreet,
|
||||
shippingPackstationNumber,
|
||||
shippingPostNumber,
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
} = body ?? {};
|
||||
if (
|
||||
typeof firstName !== "string" ||
|
||||
!firstName ||
|
||||
typeof lastName !== "string" ||
|
||||
!lastName ||
|
||||
(deliveryMethod !== "address" && deliveryMethod !== "packstation") ||
|
||||
typeof street !== "string" ||
|
||||
!street ||
|
||||
typeof zip !== "string" ||
|
||||
!zip ||
|
||||
typeof city !== "string" ||
|
||||
@@ -29,12 +52,6 @@ export async function PATCH(request: Request) {
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (deliveryMethod === "address" && !street) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
|
||||
}
|
||||
if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
|
||||
}
|
||||
// Both independently optional (see Customers.ts's own comment) — only
|
||||
// format-checked when actually provided, same as the backend field itself.
|
||||
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
|
||||
@@ -42,18 +59,56 @@ export async function PATCH(request: Request) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Same shape as the checkout's own shipping-address-override validation
|
||||
// (api/checkout/route.ts) — required fields only apply when the toggle
|
||||
// is actually on, since this whole block is optional otherwise.
|
||||
if (hasDifferentShippingAddress) {
|
||||
if (
|
||||
typeof shippingFirstName !== "string" ||
|
||||
!shippingFirstName ||
|
||||
typeof shippingLastName !== "string" ||
|
||||
!shippingLastName ||
|
||||
(shippingDeliveryMethod !== "address" && shippingDeliveryMethod !== "packstation") ||
|
||||
typeof shippingZip !== "string" ||
|
||||
!shippingZip ||
|
||||
typeof shippingCity !== "string" ||
|
||||
!shippingCity ||
|
||||
typeof shippingCountry !== "string" ||
|
||||
!shippingCountry
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder der Lieferadresse ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (shippingDeliveryMethod === "address" && !shippingStreet) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
if (shippingDeliveryMethod === "packstation" && (!shippingPackstationNumber || !shippingPostNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateCustomerProfile(session.token, session.customer.id, {
|
||||
firstName,
|
||||
lastName,
|
||||
deliveryMethod,
|
||||
deliveryMethod: "address",
|
||||
street,
|
||||
packstationNumber,
|
||||
postNumber,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
|
||||
vatId: normalizedVatId,
|
||||
hasDifferentShippingAddress: Boolean(hasDifferentShippingAddress),
|
||||
shippingFirstName: hasDifferentShippingAddress ? shippingFirstName : undefined,
|
||||
shippingLastName: hasDifferentShippingAddress ? shippingLastName : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress && shippingCompanyName ? shippingCompanyName : undefined,
|
||||
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
|
||||
shippingStreet: hasDifferentShippingAddress ? shippingStreet : undefined,
|
||||
shippingPackstationNumber: hasDifferentShippingAddress ? shippingPackstationNumber : undefined,
|
||||
shippingPostNumber: hasDifferentShippingAddress ? shippingPostNumber : undefined,
|
||||
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress && shippingContactEmail ? shippingContactEmail : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress && shippingContactPhone ? shippingContactPhone : undefined,
|
||||
});
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer } from "../../../lib/customerAuth";
|
||||
import { getWishlist, toggleWishlistItem } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ items: [] }, { status: 401 });
|
||||
const items = await getWishlist(session.token, session.customer.id);
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const productId = Number(body?.productId);
|
||||
const variant = typeof body?.variant === "string" ? body.variant : "";
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await toggleWishlistItem(session.token, session.customer.id, productId, variant);
|
||||
if (!result.ok) return NextResponse.json({ ok: false, reason: "Merkliste konnte nicht aktualisiert werden." }, { status: 500 });
|
||||
return NextResponse.json({ ok: true, wishlisted: result.wishlisted });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { autocompleteDhlAddress } from "../../../lib/shippingDhl";
|
||||
|
||||
// Proxies the checkout's address-autocomplete input through to Payload's
|
||||
// DHL DataFactory endpoint — same reasoning as validate-dhl-postnumber's
|
||||
// own route: tenant DHL credentials must never reach the browser.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("query") ?? "";
|
||||
const suggestions = await autocompleteDhlAddress(query);
|
||||
return NextResponse.json({ ok: true, suggestions });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { CartItem } from "../../lib/cart";
|
||||
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
|
||||
import { getShippingMethods, getPaymentMethods, getCompanySettings, groupPaymentMethodsForCheckout } from "../../lib/payload";
|
||||
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
|
||||
import { createOrder } from "../../lib/orderServer";
|
||||
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
|
||||
@@ -8,8 +8,8 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
|
||||
import { describeBundleContents } from "../../lib/bundleContents";
|
||||
import { sendCriticalAlert } from "../../lib/alertAdmin";
|
||||
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../lib/vies";
|
||||
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
|
||||
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/vies";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { upsertNewsletterContact } from "../../lib/brevo";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
|
||||
@@ -54,6 +54,9 @@ type CheckoutBody = {
|
||||
shippingZip?: string;
|
||||
shippingCity?: string;
|
||||
shippingCountry?: string;
|
||||
shippingCompanyName?: string;
|
||||
shippingContactEmail?: string;
|
||||
shippingContactPhone?: string;
|
||||
newsletterOptIn: boolean;
|
||||
};
|
||||
|
||||
@@ -126,7 +129,7 @@ export async function POST(request: Request) {
|
||||
customer = session.customer;
|
||||
} else {
|
||||
if (!body.password) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 });
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein Konto vergeben." }, { status: 400 });
|
||||
}
|
||||
const result = await registerCustomer({
|
||||
firstName: body.firstName,
|
||||
@@ -160,6 +163,7 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: number;
|
||||
bundleContents: string | null;
|
||||
variantName: string | null;
|
||||
sku: string | null;
|
||||
}[] = [];
|
||||
for (const line of body.cart) {
|
||||
const product = productsBySlug.get(line.id);
|
||||
@@ -168,7 +172,7 @@ export async function POST(request: Request) {
|
||||
// requested variant that no longer exists on this product (removed,
|
||||
// or never existed — a tampered request) fails the whole checkout
|
||||
// rather than silently falling back to the base product/price.
|
||||
let variant: { name: string; priceOverride: number | null } | null = null;
|
||||
let variant: { name: string; priceOverride: number | null; sku: string | null } | null = null;
|
||||
if (line.variant) {
|
||||
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
|
||||
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
|
||||
@@ -195,15 +199,25 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
|
||||
bundleContents: describeBundleContents(product),
|
||||
variantName: variant?.name ?? null,
|
||||
// Variant sku takes precedence over the product's own — same
|
||||
// "variant overrides product" precedence unitPrice already uses.
|
||||
sku: variant?.sku ?? product.sku ?? null,
|
||||
});
|
||||
}
|
||||
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
|
||||
// Products.noShippingCost — a cart made up entirely of items that opt
|
||||
// out of shipping costs (e.g. purely digital downloads) never gets
|
||||
// charged shipping at all, regardless of the free-shipping threshold.
|
||||
// A single item WITHOUT the flag still triggers normal shipping for the
|
||||
// whole order — this only exempts a product, never the whole cart just
|
||||
// because it contains an exempt item.
|
||||
const hasShippableItem = body.cart.some((line) => !productsBySlug.get(line.id)?.noShippingCost);
|
||||
|
||||
const shippingMethods = await getShippingMethods();
|
||||
const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId);
|
||||
if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 });
|
||||
const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold;
|
||||
const shippingCost = freeShipping ? 0 : shippingMethod.price;
|
||||
const shippingCost = !hasShippableItem || freeShipping ? 0 : shippingMethod.price;
|
||||
|
||||
const paymentMethods = await getPaymentMethods();
|
||||
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
|
||||
@@ -338,11 +352,15 @@ export async function POST(request: Request) {
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
shippingCompanyName: Boolean(body.hasDifferentShippingAddress) ? body.shippingCompanyName || undefined : undefined,
|
||||
shippingContactEmail: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactEmail || undefined : undefined,
|
||||
shippingContactPhone: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactPhone || undefined : undefined,
|
||||
newsletterOptIn: Boolean(body.newsletterOptIn),
|
||||
items,
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
shippingMethodTitle: shippingMethod.title,
|
||||
shippingMethod: shippingMethod.id,
|
||||
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
|
||||
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
|
||||
// customer hasn't actually chosen an instrument yet at this point,
|
||||
@@ -422,6 +440,7 @@ export async function POST(request: Request) {
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingCompanyName: body.hasDifferentShippingAddress ? body.shippingCompanyName : undefined,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
@@ -429,6 +448,8 @@ export async function POST(request: Request) {
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
shippingContactEmail: body.hasDifferentShippingAddress ? body.shippingContactEmail : undefined,
|
||||
shippingContactPhone: body.hasDifferentShippingAddress ? body.shippingContactPhone : undefined,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
@@ -438,12 +459,17 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
sku: i.sku,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
isManualPayment: true,
|
||||
// Only meaningful for the Vorkasse notice above — whether a
|
||||
// switch to Kreditkarte/PayPal is even worth mentioning right now.
|
||||
hasOnlinePaymentOption: groupPaymentMethodsForCheckout(paymentMethods).some((m) => m.provider === "stripe"),
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDhlPostNumber } from "../../../lib/shippingDhl";
|
||||
|
||||
// Called from CheckoutContent.tsx's Postnummer field blur (Packstation
|
||||
// delivery). Proxied through this Next.js route rather than fetched
|
||||
// directly from the client the way VIES is (see validate-vat/route.ts) —
|
||||
// DHL credentials are tenant-specific and live in Payload, unlike VIES's
|
||||
// public EU endpoint, so the browser must never call Payload's DHL
|
||||
// endpoint (or hold its own copy of tenant credentials) directly.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const postNumber = typeof body?.postNumber === "string" ? body.postNumber : "";
|
||||
const firstName = typeof body?.firstName === "string" ? body.firstName : "";
|
||||
const lastName = typeof body?.lastName === "string" ? body.lastName : "";
|
||||
if (!postNumber || !firstName || !lastName) {
|
||||
return NextResponse.json({ ok: false, reason: "Postnummer, Vorname und Nachname sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDhlPostNumber({ postNumber, firstName, lastName });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../../lib/vies";
|
||||
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
|
||||
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/vies";
|
||||
|
||||
// Called from CheckoutContent.tsx on the USt-IdNr. field's blur, whenever
|
||||
// the billing country is Österreich — the only cross-border-EU option this
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View, StyleSheet, Font, renderToBuffer } from "@react-pdf/renderer";
|
||||
import { getCompanySettings } from "../../lib/payload";
|
||||
|
||||
// react-pdf's default hyphenation would otherwise auto-split long words at
|
||||
// syllable boundaries to fit the line — including inside the seller's own
|
||||
// email address (confirmed: "hallo@einfach-produktiv.com" rendered as
|
||||
// "einfach-produk-tiv.com"), which must never be broken mid-word.
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
|
||||
// Generated on-the-fly from live company-settings instead of a static
|
||||
// uploaded PDF (see /widerruf's own download link) — the "An:"
|
||||
// address used to be baked into a hand-crafted PDF and silently went
|
||||
// stale the moment an admin updated the Impressum's Anbieterdaten without
|
||||
// remembering to also re-export/re-upload this file by hand (exactly what
|
||||
// happened 2026-07-29: the address changed in company-settings, the PDF
|
||||
// still had the old one). Same source data as AnbieterAngaben.tsx's
|
||||
// Impressum block, so the two can never drift apart again.
|
||||
const BRAND = "#f6a701";
|
||||
const DARK = "#1b1b1a";
|
||||
const GREY = "#737371";
|
||||
const RULE = "#d1cec4";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontSize: 10.5,
|
||||
color: DARK,
|
||||
paddingTop: 78,
|
||||
paddingBottom: 64,
|
||||
paddingHorizontal: 64,
|
||||
},
|
||||
topBar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 8,
|
||||
backgroundColor: BRAND,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontFamily: "Times-Bold",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontFamily: "Times-Bold",
|
||||
marginBottom: 10,
|
||||
},
|
||||
accent: {
|
||||
width: 32,
|
||||
height: 2.2,
|
||||
backgroundColor: BRAND,
|
||||
marginBottom: 14,
|
||||
},
|
||||
note: {
|
||||
fontSize: 9.5,
|
||||
color: GREY,
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
marginBottom: 4,
|
||||
},
|
||||
paragraph: {
|
||||
lineHeight: 1.45,
|
||||
marginBottom: 4,
|
||||
},
|
||||
paragraphSpaced: {
|
||||
lineHeight: 1.45,
|
||||
marginTop: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 8.5,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: GREY,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionRule: {
|
||||
borderBottomWidth: 0.75,
|
||||
borderBottomColor: RULE,
|
||||
},
|
||||
footerNote: {
|
||||
fontSize: 8.5,
|
||||
color: GREY,
|
||||
marginTop: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const SECTION_HEADERS = [
|
||||
"BESTELLTE WARE(N) / DIENSTLEISTUNG",
|
||||
"BESTELLT AM (*)",
|
||||
"ERHALTEN AM (*)",
|
||||
"NAME DES/DER VERBRAUCHER(S)",
|
||||
"ANSCHRIFT DES/DER VERBRAUCHER(S)",
|
||||
"UNTERSCHRIFT DES/DER VERBRAUCHER(S) (NUR BEI MITTEILUNG AUF PAPIER)",
|
||||
"DATUM",
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
const seller = await getCompanySettings();
|
||||
|
||||
const addressLine = seller
|
||||
? `${seller.sellerName} - ${seller.sellerStreet}, ${seller.sellerZip} ${seller.sellerCity}, E-Mail: ${seller.sellerEmail}`
|
||||
: "einfach produktiv.";
|
||||
|
||||
const doc = React.createElement(
|
||||
Document,
|
||||
{ title: "Muster-Widerrufsformular - einfach produktiv." },
|
||||
React.createElement(
|
||||
Page,
|
||||
{ size: "A4", style: styles.page },
|
||||
React.createElement(View, { style: styles.topBar }),
|
||||
React.createElement(Text, { style: styles.title }, "einfach produktiv."),
|
||||
React.createElement(Text, { style: styles.subtitle }, "Muster-Widerrufsformular"),
|
||||
React.createElement(View, { style: styles.accent }),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: styles.note },
|
||||
"(Wenn du diesen Vertrag widerrufen möchtest, fülle bitte dieses Formular aus und sende es per Post oder als E-Mail-Anhang an uns zurück.)",
|
||||
),
|
||||
React.createElement(Text, { style: styles.label }, "An:"),
|
||||
React.createElement(Text, { style: styles.paragraph }, addressLine),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: styles.paragraphSpaced },
|
||||
"Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*):",
|
||||
),
|
||||
...SECTION_HEADERS.map((header) =>
|
||||
React.createElement(
|
||||
View,
|
||||
{ key: header, style: styles.section },
|
||||
React.createElement(Text, { style: styles.sectionLabel }, header),
|
||||
React.createElement(View, { style: styles.sectionRule }),
|
||||
),
|
||||
),
|
||||
React.createElement(Text, { style: styles.footerNote }, "(*) Unzutreffendes streichen."),
|
||||
),
|
||||
);
|
||||
|
||||
const buffer = await renderToBuffer(doc);
|
||||
|
||||
return new Response(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": 'inline; filename="Muster-Widerrufsformular.pdf"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,15 @@ export async function POST(req: Request) {
|
||||
const source = body.source && VALID_SOURCES.includes(body.source) ? body.source : "newsletter-page";
|
||||
const result = await upsertNewsletterContact(email, source);
|
||||
if (!result.ok) {
|
||||
// The customer-facing message stays generic on purpose (never leak
|
||||
// Brevo's internal error text to a customer) — but the real reason
|
||||
// was previously discarded entirely, which cost real debugging time
|
||||
// tracking down a misconfigured BREVO_LIST_ID in Coolify (2026-07-25):
|
||||
// every failure looked identical from the outside, whether it was a
|
||||
// bad env var, a Brevo outage, or something else. Logged here so it's
|
||||
// at least diagnosable from the container's own logs going forward.
|
||||
console.error(`newsletter subscribe failed for source=${source}: ${result.reason}`);
|
||||
return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: true, alreadySubscribed: result.alreadySubscribed ?? false });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
|
||||
export type SearchResult = {
|
||||
type: "product" | "post";
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
thumbnail: string | null;
|
||||
};
|
||||
|
||||
// Lightweight instant search — plain `where[...][contains]` queries
|
||||
// against Payload (Postgres ILIKE under the hood) rather than a real
|
||||
// search index (Meilisearch/Algolia). Fine at this catalog size (a
|
||||
// handful of products + blog posts, see [[project-ecommerce-sota-gaps]]'s
|
||||
// own "search becomes necessary past ~20 products" note) — worth
|
||||
// upgrading only once the catalog actually grows into that range, this
|
||||
// route can be swapped out later without touching the frontend overlay.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get("q")?.trim() ?? "";
|
||||
if (q.length < 2) return NextResponse.json({ results: [] });
|
||||
|
||||
const productParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
"where[name][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
const postParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[status][equals]": "published",
|
||||
"where[title][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
|
||||
const [productsRes, postsRes] = await Promise.all([
|
||||
fetch(`${PAYLOAD_URL}/api/products?${productParams}`, { next: { revalidate: 30 } }),
|
||||
fetch(`${PAYLOAD_URL}/api/posts?${postParams}`, { next: { revalidate: 30 } }),
|
||||
]);
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
if (productsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; name: string; detailHref: string | null; image: { url: string } | number | null }[] } =
|
||||
await productsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "product",
|
||||
id: `product-${doc.id}`,
|
||||
title: doc.name,
|
||||
href: doc.detailHref || "/shop",
|
||||
thumbnail: typeof doc.image === "object" && doc.image ? doc.image.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (postsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; title: string; thumbnail: { url: string } | number | null }[] } = await postsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "post",
|
||||
id: `post-${doc.id}`,
|
||||
title: doc.title,
|
||||
href: `/blog/${doc.slug}`,
|
||||
thumbnail: typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isValidEmail } from "../../lib/email";
|
||||
import { createStockNotification } from "../../lib/stockNotifications";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email.trim() : "";
|
||||
const productId = Number(body?.productId);
|
||||
const variantName = typeof body?.variantName === "string" ? body.variantName : "";
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 });
|
||||
}
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createStockNotification(email, productId, variantName);
|
||||
if (!result.ok) return NextResponse.json(result, { status: 500 });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -183,9 +183,23 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
</Reveal>
|
||||
|
||||
{!productsLoading && (
|
||||
// Outer card+sidebar split stays lg: (fixed 18rem delivery-status
|
||||
// sidebar, L297 below — same fixed-width-block category as Tier C
|
||||
// exceptions elsewhere), while the inner order-meta/items row
|
||||
// already switches at sm: — intentional, not an inconsistent
|
||||
// leftover: the inner row has no fixed-width sidebar to fight for
|
||||
// room against, just two flexible columns.
|
||||
//
|
||||
// max-w-[36rem] between sm and lg (same fix/value as
|
||||
// NewsletterModal.tsx's own dialog cap) — max-w-[56rem] (896px)
|
||||
// doesn't actually constrain anything below that viewport width,
|
||||
// so at real Tablet widths (640-1023px) this card stretched to
|
||||
// fill the full page width instead of reading as a compact,
|
||||
// centered card. Widens to the real 56rem cap only once lg:'s
|
||||
// sidebar split kicks in and needs the room.
|
||||
<Reveal
|
||||
delay={0.05}
|
||||
className="w-full max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
|
||||
className="w-full max-w-[36rem] lg:max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
|
||||
>
|
||||
<div className="flex-1 p-6 md:p-8 flex flex-col sm:flex-row gap-8">
|
||||
{/* Order meta */}
|
||||
@@ -328,8 +342,8 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
identical height regardless of how many lines the quote wraps
|
||||
to (a min-height alone lets whichever column has more content
|
||||
stretch the row past what the other side visually fills). */}
|
||||
<Reveal className="relative w-full flex items-stretch h-[14rem] md:h-[16rem] bg-bg-muted overflow-hidden">
|
||||
<div className="relative w-full md:w-[45%] shrink-0">
|
||||
<Reveal className="relative w-full flex items-stretch h-[14rem] sm:h-[16rem] bg-bg-muted overflow-hidden">
|
||||
<div className="relative w-full sm:w-[45%] shrink-0">
|
||||
{/* -inset-1, not inset-0 — this section fades in via Reveal's
|
||||
y:28→0 transform; a plain inset-0 image can leave a
|
||||
hairline gap at the top edge while that's still settling
|
||||
@@ -343,10 +357,10 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
src="/bestellbestaetigung-testimonial-photo.jpg"
|
||||
width={366}
|
||||
height={126}
|
||||
sizes="(min-width: 768px) 45vw, 100vw"
|
||||
sizes="(min-width: 640px) 45vw, 100vw"
|
||||
className="absolute -inset-1 w-[calc(100%+0.5rem)] h-[calc(100%+0.5rem)] object-cover"
|
||||
/>
|
||||
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
|
||||
<div className="hidden sm:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16">
|
||||
<p
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../.
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of the blog detail page: title/category/readTime/
|
||||
// Live-previewable subset of the blog detail page: title/categories/readTime/
|
||||
// excerpt/byline, the thumbnail, and the RichText body — the fields an
|
||||
// editor actually watches update while typing. The author bio card,
|
||||
// "Weiterlesen" card, and Footer stay static in page.tsx: they either
|
||||
@@ -27,7 +27,7 @@ export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { LivePostContent } from "./components/LivePostContent";
|
||||
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
|
||||
import { getBlogPosts, getPostBySlug, getCompanySettings } from "../../lib/payload";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { buildArticleSchema } from "../../lib/structuredData";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -61,9 +62,12 @@ export default async function BlogDetailPage({
|
||||
// showing a fake/duplicate card when this is the only post.
|
||||
const otherPosts = await getBlogPosts(4);
|
||||
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
|
||||
const seller = await getCompanySettings();
|
||||
const articleSchema = buildArticleSchema(post, seller);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{isPreview ? (
|
||||
<LivePostContent initialPost={post} />
|
||||
@@ -71,7 +75,7 @@ export default async function BlogDetailPage({
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
@@ -158,7 +162,7 @@ export default async function BlogDetailPage({
|
||||
>
|
||||
{post.relatedProduct.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.description}</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.descriptionText}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap mt-1 sm:mt-0">
|
||||
Entdecken
|
||||
@@ -217,7 +221,7 @@ export default async function BlogDetailPage({
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{nextPost.category}</span>
|
||||
<span>{nextPost.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{nextPost.readTime} Min</span>
|
||||
</div>
|
||||
|
||||
+95
-41
@@ -4,7 +4,8 @@ import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getBlogPosts } from "../lib/payload";
|
||||
import { BlogCategoryFilter } from "../components/BlogCategoryFilter";
|
||||
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
|
||||
import { formatDate } from "../lib/format";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -20,56 +21,93 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogOverviewPage() {
|
||||
const posts = await getBlogPosts(100);
|
||||
const [featured, ...rest] = posts;
|
||||
// Every distinct category name in DOM order — Payload's `categories` field
|
||||
// stores related docs, no separate slug on this side, but since this page
|
||||
// already fetches every published post (limit 100, no pagination), a
|
||||
// plain client-agnostic array filter is enough; no separate Payload query
|
||||
// per category needed.
|
||||
function distinctCategories(posts: { categories: string[] }[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const post of posts) for (const c of post.categories) seen.add(c);
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
export default async function BlogOverviewPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ categories?: string }>;
|
||||
}) {
|
||||
const [allPosts, blogFilterEnabled] = await Promise.all([getBlogPosts(100), getBlogFilterEnabled()]);
|
||||
const { categories: categoriesParam } = await searchParams;
|
||||
const activeCategories = blogFilterEnabled && categoriesParam ? categoriesParam.split(",").filter(Boolean) : [];
|
||||
const allCategories = blogFilterEnabled ? distinctCategories(allPosts) : [];
|
||||
const posts =
|
||||
activeCategories.length === 0 ? allPosts : allPosts.filter((post) => post.categories.some((c) => activeCategories.includes(c)));
|
||||
// getBlogPosts sorts "-featured,-publishedAt", so index 0 IS the actual
|
||||
// featured post when the unfiltered list is shown — but a category
|
||||
// filter can (and often will) exclude it entirely, in which case index 0
|
||||
// is just the newest matching post, not an editorially featured one. It
|
||||
// still doesn't deserve the big hero-card treatment (implies "the
|
||||
// featured post", not "whatever sorted first"), so that layout is gated
|
||||
// on the post's own `featured` flag, not on array position.
|
||||
const [first, ...restAll] = posts;
|
||||
const featured = first?.featured ? first : undefined;
|
||||
const rest = featured ? restAll : posts;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{/* Header — copy left, photo bleeds to the viewport edge on the
|
||||
right with a left-edge fade into bg-base, matching Figma's
|
||||
hero-fade-left/hero-fade-bottom overlays (node 4577:330). */}
|
||||
<Reveal className="relative flex flex-col md:flex-row items-center w-full min-h-[20rem] md:min-h-[27.5rem] border-b border-border overflow-hidden">
|
||||
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 md:py-0 w-full md:w-auto md:max-w-[26rem]">
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Blog
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full md:absolute md:inset-y-0 md:right-0 md:w-[68%] h-56 md:h-full">
|
||||
<Image alt="" src="/hero.jpg" fill sizes="(min-width: 768px) 68vw, 100vw" className="object-cover" />
|
||||
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-bg-base to-transparent" />
|
||||
</div>
|
||||
{/* Header — plain text, same treatment as /shop's ShopHeader.tsx
|
||||
and every legal page (the bespoke photo-bleed hero this used to
|
||||
be predated that convention being established elsewhere). */}
|
||||
<Reveal className="flex flex-col gap-4 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full border-b border-border">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Blog</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Blog
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
|
||||
</p>
|
||||
{allCategories.length > 1 && (
|
||||
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
{posts.length === 0 && (
|
||||
<p className="text-body text-text-muted w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-10">
|
||||
Keine Beiträge in dieser Kategorie gefunden.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{featured && (
|
||||
// -mt-8, not pt-10 — pulls the card up to slightly overlap the
|
||||
// hero section's bottom edge instead of sitting flush below it.
|
||||
// relative z-10 keeps it stacked above the hero's own photo.
|
||||
<Reveal delay={0.1} className="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
|
||||
// key'd for the same reason as RevealGroup below — guards against
|
||||
// the same stuck-at-opacity-0 failure mode if a future filter
|
||||
// combination ever swaps in a *different* featured post without
|
||||
// an intervening moment where `featured` was falsy.
|
||||
<Reveal key={featured.id} delay={0.1} className="w-full px-[var(--layout-padding-x)] pt-8 pb-4">
|
||||
<Link
|
||||
href={`/blog/${featured.slug}`}
|
||||
className="group grid grid-cols-1 md:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
|
||||
className="group grid grid-cols-1 sm:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:h-full bg-bg-muted">
|
||||
<div className="relative w-full aspect-video sm:aspect-auto sm:h-full bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 768px) 40rem, 100vw" className="object-cover" />
|
||||
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 640px) 40rem, 100vw" className="object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-3 p-8 md:p-12 min-w-0">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{featured.category}</span>
|
||||
<div className="flex flex-col justify-center gap-3 p-8 sm:p-12 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span className="whitespace-nowrap">{featured.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
<span className="whitespace-nowrap">{featured.readTime} Min</span>
|
||||
<span>•</span>
|
||||
<span className="uppercase">{formatDate(featured.publishedAt)}</span>
|
||||
<span className="uppercase whitespace-nowrap">{formatDate(featured.publishedAt)}</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-h-section text-text-primary"
|
||||
@@ -95,7 +133,23 @@ export default async function BlogOverviewPage() {
|
||||
)}
|
||||
|
||||
{rest.length > 0 && (
|
||||
<RevealGroup className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border">
|
||||
// key'd on the rendered post set — RevealGroup's `whileInView`
|
||||
// only fires once per component instance (viewport.once=true),
|
||||
// and a category-filter change re-renders this same page
|
||||
// component in place (only `searchParams` differs, no full
|
||||
// remount). Since `rest.length > 0` stays true across most
|
||||
// filter transitions, RevealGroup itself never naturally
|
||||
// unmounts, so once it has already fired "show" for one
|
||||
// filter's list, freshly swapped-in RevealItems (new post ids)
|
||||
// mount into an already-settled parent that has no reason to
|
||||
// re-fire the reveal trigger — they were stuck at opacity 0
|
||||
// forever, reported as the list appearing blank after refiltering.
|
||||
// Forcing a remount on every distinct post set restarts
|
||||
// RevealGroup's viewport tracking from scratch each time.
|
||||
<RevealGroup
|
||||
key={rest.map((post) => post.id).join(",")}
|
||||
className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border"
|
||||
>
|
||||
{rest.map((post) => (
|
||||
<RevealItem key={post.id} className="py-8 first:pt-0 last:pb-0">
|
||||
<Link href={`/blog/${post.slug}`} className="group flex flex-col sm:flex-row gap-6 items-start">
|
||||
@@ -105,12 +159,12 @@ export default async function BlogOverviewPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span className="whitespace-nowrap">{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
<span className="whitespace-nowrap">{post.readTime} Min</span>
|
||||
<span>•</span>
|
||||
<span className="uppercase">{formatDate(post.publishedAt)}</span>
|
||||
<span className="uppercase whitespace-nowrap">{formatDate(post.publishedAt)}</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
|
||||
@@ -7,12 +7,14 @@ import Image from "next/image";
|
||||
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { ArrowLeftIcon } from "../../components/ArrowLeftIcon";
|
||||
import { ProductName } from "../../components/ProductName";
|
||||
import { FreeShippingBanner } from "./FreeShippingBanner";
|
||||
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
|
||||
@@ -77,7 +79,7 @@ export function CartContent({
|
||||
|
||||
const subtotal = computeSubtotal(items);
|
||||
const shipping =
|
||||
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
items.length === 0 || !cartHasShippableItem(items) || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
? 0
|
||||
: shippingCost;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
@@ -163,22 +165,28 @@ export function CartContent({
|
||||
href="/shop"
|
||||
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
<ArrowLeftIcon />
|
||||
<span className="font-bold text-body-sm">Weiter einkaufen</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
) : (
|
||||
<>
|
||||
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<FreeShippingBanner subtotal={subtotal} threshold={freeShippingThreshold} />
|
||||
{/* threshold forced to null (banner just doesn't render, see its
|
||||
own early return) whenever nothing in the cart actually
|
||||
triggers shipping — a "€X bis kostenlosem Versand" nudge
|
||||
makes no sense for a cart that was never going to be charged
|
||||
shipping in the first place. */}
|
||||
<FreeShippingBanner subtotal={subtotal} threshold={cartHasShippableItem(items) ? freeShippingThreshold : null} />
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-4 px-[var(--layout-padding-x)] w-full">
|
||||
{/* Cart card — lg:-only split from the sidebar (same "wide content
|
||||
next to sidebar" shape as the Hero's image/text split, see
|
||||
figma-to-nextjs skill Gotcha #5: Figma's 830px card alone
|
||||
already exceeds the 768px Tablet floor, so md: would never
|
||||
have had room for a real 2-column layout at Tablet widths
|
||||
anyway). */}
|
||||
already exceeds even the site's 640px structural floor, so
|
||||
sm: would never have had room for a real 2-column layout at
|
||||
Tablet widths anyway — a deliberate exception to the site-wide
|
||||
sm: consolidation, not a leftover of it). */}
|
||||
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
|
||||
{items.map(({ entry, product }, i) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
@@ -221,13 +229,25 @@ export function CartContent({
|
||||
sm: once the row layout kicks in and the image sits
|
||||
beside the text instead. */}
|
||||
<div className="relative w-full aspect-square sm:size-[9.375rem] sm:shrink-0 rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 640px) 150px, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
{product.href ? (
|
||||
<Link href={product.href} aria-label={product.name} className="block w-full h-full">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 640px) 150px, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 640px) 150px, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
{discount !== null && (
|
||||
<span className="absolute top-2 left-2 rounded-full bg-brand px-2 py-0.5 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
@@ -235,18 +255,29 @@ export function CartContent({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-[0.625rem] items-start flex-1 min-w-0 w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
{product.href ? (
|
||||
<Link
|
||||
href={product.href}
|
||||
className="font-semibold text-h-small text-text-primary hover:text-brand transition-colors"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
<ProductName name={product.name} />
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</Link>
|
||||
) : (
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
<ProductName name={product.name} />
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
)}
|
||||
{product.subline && <p className="text-body-sm text-text-muted">{product.subline}</p>}
|
||||
{/* Independent stacked rows, not grid siblings — no
|
||||
equal-height pressure from neighboring lines, so a
|
||||
plain conditional line is enough here. */}
|
||||
{lowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<p className="font-bold text-body-sm text-text-muted">{product.description}</p>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="text-label text-text-muted">Einzelpreis</p>
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
@@ -296,7 +327,7 @@ export function CartContent({
|
||||
href="/shop"
|
||||
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
<ArrowLeftIcon />
|
||||
<span className="font-bold text-body-sm">Weiter einkaufen</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
@@ -391,31 +422,40 @@ export function CartContent({
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in
|
||||
the cart actually triggers shipping at all — that's a
|
||||
different state from hitting the free-shipping
|
||||
threshold, which is still a real promotional message
|
||||
worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<>
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
@@ -456,10 +496,22 @@ export function CartContent({
|
||||
</div>
|
||||
|
||||
{/* Title only — /checkout renders the same CartTrustBadges
|
||||
docs with description too, see CheckoutContent.tsx. */}
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
docs with description too, see CheckoutContent.tsx.
|
||||
Side by side between sm (640px) and lg (1024px) — this
|
||||
sidebar is full page width through that whole range (it
|
||||
only becomes a narrow fixed-width column at lg:, see this
|
||||
file's own lg:w-[20.625rem] above), plenty of room for 3
|
||||
short titles in a row; stacked at true mobile and again
|
||||
once the sidebar narrows at lg:. sm:w-auto with no
|
||||
flex-grow (was sm:flex-1) so each badge only takes its own
|
||||
content width instead of stretching to fill the full card
|
||||
width; sm:justify-center centers that shrink-to-fit row so
|
||||
the leftover space splits evenly left/right instead of
|
||||
collecting on the right (the default with items packed
|
||||
from the start edge). */}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap lg:flex-col gap-4 sm:gap-x-8 sm:gap-y-4 lg:gap-4 items-start sm:justify-center lg:justify-start w-full">
|
||||
{trustBadges.map((b) => (
|
||||
<div key={b.id} className="flex gap-3 items-center w-full">
|
||||
<div key={b.id} className="flex gap-3 items-center w-full sm:w-auto lg:w-full">
|
||||
<Image alt="" src={b.icon} width={22} height={22} className="size-[1.375rem] shrink-0 object-contain" />
|
||||
<span className="flex-1 text-body-sm text-text-primary">{b.title}</span>
|
||||
</div>
|
||||
@@ -496,7 +548,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
|
||||
import { ProductCard } from "../../components/ProductCard";
|
||||
import { FEEDBACK_MS } from "../../components/AddToCartInlineButton";
|
||||
import { useCart } from "../../lib/cart";
|
||||
|
||||
const DISPLAY_COUNT = 3;
|
||||
@@ -30,7 +28,15 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
|
||||
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
|
||||
}
|
||||
|
||||
export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultTaxRate: number; kleinunternehmer: boolean }) {
|
||||
export function RelatedProducts({
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
wishlistEnabled,
|
||||
}: {
|
||||
defaultTaxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
wishlistEnabled: boolean;
|
||||
}) {
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
// Cart/checkout resolve any product regardless of `active` (see
|
||||
@@ -123,18 +129,23 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
(opacity: 0) — invisible. Not worth chasing a fix for a
|
||||
scroll-reveal nicety on a list that mutates; a static grid
|
||||
renders correctly with no animation risk. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{displayProducts.map((product, i) => {
|
||||
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 (
|
||||
<div
|
||||
{/* No items-start, and grid-auto-rows + subgrid on each card — see
|
||||
ProductGrid.tsx's own comment on why both are needed for
|
||||
ProductCard's internal rows (price, etc.) to actually line up
|
||||
across cards, not just overall card height. No RevealItem
|
||||
wrapper here (see comment above), so the subgrid classes go
|
||||
straight onto ProductCard's own className prop instead. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full max-w-[75rem]">
|
||||
{displayProducts.map((product, i) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
wishlistEnabled={wishlistEnabled}
|
||||
wishlistRevealOnHover
|
||||
className={
|
||||
"group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
|
||||
"sm:col-span-4 [grid-row:span_6] " +
|
||||
// Center the row when there are fewer than 3 cards to show
|
||||
// (e.g. only 1 active product left once the others are
|
||||
// already in the cart) — only the first card needs an
|
||||
@@ -142,67 +153,14 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
// it. 3-card case keeps the default left-to-right flow.
|
||||
(i === 0
|
||||
? displayProducts.length === 1
|
||||
? "md:col-start-5"
|
||||
? "sm:col-start-5"
|
||||
: displayProducts.length === 2
|
||||
? "md:col-start-3"
|
||||
? "sm:col-start-3"
|
||||
: ""
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<div className="relative w-full aspect-[320/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 320px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{/* Same top-left pill pattern as ProductGrid.tsx/
|
||||
ProductSpotlight.tsx — position: absolute, so it never
|
||||
affects this card's height. Only the discount/Ausverkauft
|
||||
pill lives here now; the low-stock hint moved to a
|
||||
reserved-height text line below (see the min-h paragraph
|
||||
under the price) — plain conditional text here is what
|
||||
broke equal card heights in this grid before. */}
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
|
||||
<p
|
||||
className="font-semibold text-h4 text-text-primary w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
{/* Always rendered, text conditional — min-h reserves this
|
||||
line's height in both states so cards in the same row
|
||||
stay equal height regardless of low-stock status; this
|
||||
component has no h-full/flex-1 spacer trick like
|
||||
ProductGrid.tsx to absorb a variable-height line instead. */}
|
||||
<p className="min-h-[1.05rem] text-label font-bold text-warning">
|
||||
{anyLowStock ? "Nur noch wenige verfügbar" : null}
|
||||
</p>
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@ import { CartContent } from "./components/CartContent";
|
||||
import { RelatedProducts } from "./components/RelatedProducts";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
|
||||
import { hasActiveDiscountCode } from "../lib/discountServer";
|
||||
|
||||
// robots: noindex — transactional page (mirrors a specific shopper's cart
|
||||
@@ -20,13 +20,14 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function CartPage() {
|
||||
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField] = await Promise.all([
|
||||
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField, wishlistEnabled] = await Promise.all([
|
||||
getCartTrustBadges(),
|
||||
getShippingMethods(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
hasActiveDiscountCode(),
|
||||
getWishlistEnabled(),
|
||||
]);
|
||||
|
||||
// The cart doesn't ask which shipping method the shopper wants yet
|
||||
@@ -60,7 +61,7 @@ export default async function CartPage() {
|
||||
showDiscountField={showDiscountField}
|
||||
/>
|
||||
</Suspense>
|
||||
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
|
||||
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} wishlistEnabled={wishlistEnabled} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { autocompleteDhlAddress, type DhlAddressSuggestion } from "../../lib/shippingDhl";
|
||||
|
||||
// Wraps a plain street-address input with a DHL DataFactory suggestion
|
||||
// dropdown. Completely invisible/inert when the tenant doesn't have
|
||||
// autocomplete activated (dhl-settings.autocompleteEnabled off) — the proxy
|
||||
// route just returns an empty suggestions array in that case (see
|
||||
// app/api/checkout/autocomplete-address/route.ts), so this degrades to a
|
||||
// plain text input with no dropdown ever appearing, same "no half-built UI
|
||||
// when a feature is off" convention as WishlistButton/searchEnabled.
|
||||
//
|
||||
// Styling duplicates FormField's classes (defined locally in
|
||||
// CheckoutContent.tsx, not exported) rather than importing it, to keep this
|
||||
// component usable on its own.
|
||||
export function AddressAutocomplete({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onSelectSuggestion,
|
||||
error,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
required,
|
||||
wrapperClassName = "flex-1 min-w-0",
|
||||
}: {
|
||||
label: string;
|
||||
name?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onSelectSuggestion: (suggestion: DhlAddressSuggestion) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
wrapperClassName?: string;
|
||||
}) {
|
||||
const [suggestions, setSuggestions] = useState<DhlAddressSuggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (value.trim().length < 3) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/checkout/autocomplete-address?query=${encodeURIComponent(value)}`);
|
||||
const data = await res.json().catch(() => ({ ok: false, suggestions: [] }));
|
||||
setSuggestions(data.ok ? data.suggestions : []);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<label className={`relative flex flex-col gap-2 items-start ${wrapperClassName}`}>
|
||||
<span className="text-label text-text-muted">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
// Delayed so a click on a suggestion below (which itself fires a
|
||||
// blur first) still registers before the dropdown unmounts.
|
||||
onBlur={(e) => {
|
||||
setTimeout(() => setOpen(false), 150);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
|
||||
error ? "border-red-600" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{error && <span className="text-label text-red-600">{error}</span>}
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul className="absolute top-full left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-sm border border-border bg-background shadow-lg">
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(`${s.street}${s.houseNumber ? ` ${s.houseNumber}` : ""}`);
|
||||
onSelectSuggestion(s);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-body-sm text-text-primary hover:bg-brand/10 transition-colors"
|
||||
>
|
||||
{s.street}
|
||||
{s.houseNumber ? ` ${s.houseNumber}` : ""}, {s.zip} {s.city}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -7,18 +7,20 @@ import { useRouter } from "next/navigation";
|
||||
import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CustomSelect } from "../../components/CustomSelect";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { AddressAutocomplete } from "./AddressAutocomplete";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
import { PaymentStep } from "./PaymentStep";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { normalizeVatId, isValidVatId, isValidPlz, plzInputPattern } from "@einfach-produktiv/invoicing";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { validateEmailFormat } from "../../lib/email";
|
||||
import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
@@ -35,8 +37,7 @@ import type { CustomerProfile } from "../../lib/customerAuth";
|
||||
// here. `?? 4` only matters if a country somehow isn't in the map at all
|
||||
// (shouldn't happen — the <select> options are built from the same list).
|
||||
function plzPattern(country: string, plzDigitsMap: Record<string, number>): string {
|
||||
const digits = plzDigitsMap[country] ?? 4;
|
||||
return `\\d{${digits}}`;
|
||||
return plzInputPattern(plzDigitsMap[country] ?? 4);
|
||||
}
|
||||
|
||||
// Same rules as the pattern/required attributes each field already
|
||||
@@ -50,7 +51,7 @@ function validateRequired(label: string, value: string): string {
|
||||
function validateZip(value: string, country: string, plzDigitsMap: Record<string, number>): string {
|
||||
if (!value.trim()) return "PLZ ist erforderlich.";
|
||||
const digits = plzDigitsMap[country] ?? 4;
|
||||
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
|
||||
return isValidPlz(value, digits) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
|
||||
}
|
||||
|
||||
function validatePackstationNumber(value: string): string {
|
||||
@@ -243,19 +244,32 @@ export function CheckoutContent({
|
||||
const [zip, setZip] = useState(savedProfile?.zip ?? "");
|
||||
const [city, setCity] = useState(savedProfile?.city ?? "");
|
||||
const [country, setCountry] = useState(savedProfile?.country ?? "Deutschland");
|
||||
// Optional package destination distinct from the billing address above —
|
||||
// no savedProfile fallback (a customer's saved profile has only ever had
|
||||
// one address), just an empty draft-only section.
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(false);
|
||||
const [shippingFirstName, setShippingFirstName] = useState("");
|
||||
const [shippingLastName, setShippingLastName] = useState("");
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">("address");
|
||||
const [shippingStreet, setShippingStreet] = useState("");
|
||||
const [shippingPackstationNumber, setShippingPackstationNumber] = useState("");
|
||||
const [shippingPostNumber, setShippingPostNumber] = useState("");
|
||||
const [shippingZip, setShippingZip] = useState("");
|
||||
const [shippingCity, setShippingCity] = useState("");
|
||||
const [shippingCountry, setShippingCountry] = useState("Deutschland");
|
||||
// Optional package destination distinct from the billing address above.
|
||||
// Seeded from savedProfile's own "Lieferadresse" tab (Customers.ts),
|
||||
// same precedence as Card 1's billing fields above: draft (if any)
|
||||
// overwrites this in the hydration effect below, savedProfile is only
|
||||
// the pre-hydration/SSR-safe fallback. Unlike Card 1, savedProfile *can*
|
||||
// supply these now (see Customers.ts's "Lieferadresse" tab) — this used
|
||||
// to always start empty since the saved profile had only one address.
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(savedProfile?.hasDifferentShippingAddress ?? false);
|
||||
const [shippingFirstName, setShippingFirstName] = useState(savedProfile?.shippingFirstName ?? "");
|
||||
const [shippingLastName, setShippingLastName] = useState(savedProfile?.shippingLastName ?? "");
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">(
|
||||
savedProfile?.shippingDeliveryMethod ?? "address",
|
||||
);
|
||||
const [shippingStreet, setShippingStreet] = useState(savedProfile?.shippingStreet ?? "");
|
||||
const [shippingPackstationNumber, setShippingPackstationNumber] = useState(savedProfile?.shippingPackstationNumber ?? "");
|
||||
const [shippingPostNumber, setShippingPostNumber] = useState(savedProfile?.shippingPostNumber ?? "");
|
||||
const [shippingZip, setShippingZip] = useState(savedProfile?.shippingZip ?? "");
|
||||
const [shippingCity, setShippingCity] = useState(savedProfile?.shippingCity ?? "");
|
||||
const [shippingCountry, setShippingCountry] = useState(savedProfile?.shippingCountry ?? "Deutschland");
|
||||
// Optional, mirrors companyName above — no shipping-side vatId though,
|
||||
// a VAT ID is a billing/invoice concept, not a shipping one. Contact
|
||||
// email/phone have no billing-side equivalent at all: they're handed to
|
||||
// the shipping carrier, not used for any customer communication.
|
||||
const [shippingCompanyName, setShippingCompanyName] = useState(savedProfile?.shippingCompanyName ?? "");
|
||||
const [shippingContactEmail, setShippingContactEmail] = useState(savedProfile?.shippingContactEmail ?? "");
|
||||
const [shippingContactPhone, setShippingContactPhone] = useState(savedProfile?.shippingContactPhone ?? "");
|
||||
const [newsletterOptIn, setNewsletterOptIn] = useState(false);
|
||||
// Live VIES status for the USt-IdNr. field — only meaningful once the
|
||||
// goods' destination (shipping override country when set, billing
|
||||
@@ -265,6 +279,7 @@ export function CheckoutContent({
|
||||
// this exact same VIES check server-side at submit time regardless —
|
||||
// this state is a preview, never the source of truth.
|
||||
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
const [dhlPostNumberStatus, setDhlPostNumberStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
// Flips true only after the hydration effect's setState calls have
|
||||
// actually landed in a render — gates the write-back effect below so it
|
||||
// never fires with the pre-hydration defaults first and briefly
|
||||
@@ -301,6 +316,9 @@ export function CheckoutContent({
|
||||
if (draft.shippingZip) setShippingZip(draft.shippingZip);
|
||||
if (draft.shippingCity) setShippingCity(draft.shippingCity);
|
||||
if (draft.shippingCountry) setShippingCountry(draft.shippingCountry);
|
||||
if (draft.shippingCompanyName) setShippingCompanyName(draft.shippingCompanyName);
|
||||
if (draft.shippingContactEmail) setShippingContactEmail(draft.shippingContactEmail);
|
||||
if (draft.shippingContactPhone) setShippingContactPhone(draft.shippingContactPhone);
|
||||
if (typeof draft.newsletterOptIn === "boolean") setNewsletterOptIn(draft.newsletterOptIn);
|
||||
if (draft.shippingMethodId != null) setShippingMethodId(draft.shippingMethodId);
|
||||
if (draft.paymentMethodId != null) setPaymentMethodId(draft.paymentMethodId);
|
||||
@@ -331,6 +349,9 @@ export function CheckoutContent({
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingCompanyName,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
newsletterOptIn,
|
||||
shippingMethodId,
|
||||
paymentMethodId,
|
||||
@@ -356,6 +377,9 @@ export function CheckoutContent({
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingCompanyName,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
newsletterOptIn,
|
||||
shippingMethodId,
|
||||
paymentMethodId,
|
||||
@@ -372,7 +396,12 @@ export function CheckoutContent({
|
||||
selectedShipping?.freeShippingThreshold !== null &&
|
||||
selectedShipping?.freeShippingThreshold !== undefined &&
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
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 }) => ({
|
||||
@@ -449,6 +478,42 @@ export function CheckoutContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Live-checks the Packstation Postnummer against DHL, mirroring
|
||||
// handleVatIdBlur's shape. `ok: false` from the proxy route covers both
|
||||
// "DHL unreachable" and "this tenant doesn't have Postnummer-validation
|
||||
// activated" (dhl-settings.postnummerEnabled off) — the latter is the
|
||||
// common case for shops without a DHL integration, so it silently falls
|
||||
// back to idle (format-only, already checked above) rather than showing
|
||||
// an alarming "currently unavailable" message for a feature that was
|
||||
// simply never turned on.
|
||||
async function handlePostNumberBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const input = e.target;
|
||||
const value = input.value;
|
||||
const formatError = validatePostNumber(value);
|
||||
setFieldError("shippingPostNumber", formatError, input);
|
||||
if (!value.trim() || formatError || !shippingFirstName.trim() || !shippingLastName.trim()) {
|
||||
setDhlPostNumberStatus("idle");
|
||||
return;
|
||||
}
|
||||
setDhlPostNumberStatus("checking");
|
||||
try {
|
||||
const res = await fetch("/api/checkout/validate-dhl-postnumber", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ postNumber: value, firstName: shippingFirstName, lastName: shippingLastName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setDhlPostNumberStatus("idle");
|
||||
return;
|
||||
}
|
||||
setDhlPostNumberStatus(data.valid ? "valid" : "invalid");
|
||||
if (!data.valid) input.focus();
|
||||
} catch {
|
||||
setDhlPostNumberStatus("idle");
|
||||
}
|
||||
}
|
||||
|
||||
// Logs into an existing account inline, without leaving /checkout —
|
||||
// router.refresh() re-runs the page's Server Component, which re-reads
|
||||
// the now-set session cookie and passes the resolved customerEmail back
|
||||
@@ -558,6 +623,9 @@ export function CheckoutContent({
|
||||
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress ? shippingCompanyName || undefined : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress ? shippingContactEmail || undefined : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress ? shippingContactPhone || undefined : undefined,
|
||||
newsletterOptIn,
|
||||
};
|
||||
|
||||
@@ -911,7 +979,7 @@ export function CheckoutContent({
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
|
||||
<FormField
|
||||
label="Passwort (für dein neues Konto)"
|
||||
label="Passwort (für dein Konto)"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Mind. 8 Zeichen"
|
||||
@@ -951,13 +1019,16 @@ export function CheckoutContent({
|
||||
Rechnungsadresse (an invoice needs a real postal address).
|
||||
Packstation is only ever offered below, in the optional
|
||||
"Abweichende Lieferadresse" section's own Lieferart toggle. */}
|
||||
<FormField
|
||||
<AddressAutocomplete
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
value={street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
onChange={setStreet}
|
||||
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
onSelectSuggestion={(s) => {
|
||||
setZip(s.zip);
|
||||
setCity(s.city);
|
||||
}}
|
||||
error={fieldErrors.street}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="street-address"
|
||||
@@ -996,17 +1067,19 @@ export function CheckoutContent({
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
name="country"
|
||||
{/* CustomSelect, not a native <select> — consistent with the
|
||||
filter dropdowns elsewhere (native options popups can't be
|
||||
styled at all). includeAllOption={false}: every country is
|
||||
a real, selectable choice, there's no "clear" concept
|
||||
here — "country" is always genuinely set to something. */}
|
||||
<CustomSelect
|
||||
label="Land wählen"
|
||||
includeAllOption={false}
|
||||
fullWidth
|
||||
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setCountry}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
@@ -1048,6 +1121,15 @@ export function CheckoutContent({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="Firma (optional)"
|
||||
type="text"
|
||||
value={shippingCompanyName}
|
||||
onChange={(e) => setShippingCompanyName(e.target.value)}
|
||||
placeholder="Muster GmbH"
|
||||
autoComplete="off"
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
<div className="w-full sm:w-[calc(50%-0.5rem)] flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full rounded-sm border border-border overflow-hidden">
|
||||
@@ -1078,12 +1160,15 @@ export function CheckoutContent({
|
||||
</div>
|
||||
</div>
|
||||
{shippingDeliveryMethod === "address" ? (
|
||||
<FormField
|
||||
<AddressAutocomplete
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
value={shippingStreet}
|
||||
onChange={(e) => setShippingStreet(e.target.value)}
|
||||
onChange={setShippingStreet}
|
||||
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
onSelectSuggestion={(s) => {
|
||||
setShippingZip(s.zip);
|
||||
setShippingCity(s.city);
|
||||
}}
|
||||
error={fieldErrors.shippingStreet}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="off"
|
||||
@@ -1107,21 +1192,34 @@ export function CheckoutContent({
|
||||
title="Packstationsnummer muss aus 1 bis 3 Ziffern bestehen (1–999)."
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value), e.target)}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
pattern="\d{6,10}"
|
||||
maxLength={10}
|
||||
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
|
||||
required
|
||||
/>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={handlePostNumberBlur}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
pattern="\d{6,10}"
|
||||
maxLength={10}
|
||||
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
{/* Silent when idle — covers both "not yet checked" and
|
||||
"this shop has no DHL Postnummer-validation active",
|
||||
same reasoning as handlePostNumberBlur's own comment. */}
|
||||
<p className="text-label text-text-muted min-h-[1.05rem]">
|
||||
{dhlPostNumberStatus === "checking" && "Postnummer wird bei DHL geprüft…"}
|
||||
{dhlPostNumberStatus === "valid" && <span className="text-success">✓ Postnummer bestätigt</span>}
|
||||
{dhlPostNumberStatus === "invalid" && (
|
||||
<span className="text-red-600">DHL konnte Postnummer/Name-Kombination nicht bestätigen.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
@@ -1154,17 +1252,39 @@ export function CheckoutContent({
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
<CustomSelect
|
||||
label="Land wählen"
|
||||
includeAllOption={false}
|
||||
fullWidth
|
||||
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
|
||||
value={shippingCountry}
|
||||
onChange={(e) => setShippingCountry(e.target.value)}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setShippingCountry}
|
||||
/>
|
||||
</label>
|
||||
{/* Both optional and independent — handed to the shipping
|
||||
carrier, not used for any customer communication (that
|
||||
stays the account email above). Useful when the
|
||||
recipient at this address isn't reachable via the
|
||||
account holder's own email/phone. */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="Kontakt-E-Mail (optional)"
|
||||
type="email"
|
||||
value={shippingContactEmail}
|
||||
onChange={(e) => setShippingContactEmail(e.target.value)}
|
||||
placeholder="empfang@beispiel.de"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FormField
|
||||
label="Telefonnummer (optional)"
|
||||
type="tel"
|
||||
value={shippingContactPhone}
|
||||
onChange={(e) => setShippingContactPhone(e.target.value)}
|
||||
placeholder="+49 30 123456"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1337,33 +1457,39 @@ export function CheckoutContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in the
|
||||
cart actually triggers shipping at all — a different state
|
||||
from hitting the free-shipping threshold, which is still a
|
||||
real promotional message worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
|
||||
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
|
||||
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
@@ -1430,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,25 +24,43 @@ type Props = {
|
||||
testMode: boolean;
|
||||
/** Only present in test mode — see api/checkout/route.ts's own comment. */
|
||||
providerReference?: string;
|
||||
/** Where /checkout/verarbeitung sends the customer once payment is
|
||||
* confirmed — "checkout" (default) clears the cart/draft and lands on
|
||||
* /bestellbestaetigung, exactly like today. "account" is used by the
|
||||
* account order detail page's "Zahlungsart ändern" flow (an existing,
|
||||
* already-confirmed order — nothing to clear, and /bestellbestaetigung
|
||||
* would be the wrong destination): lands back on that same order's
|
||||
* page instead. See VerarbeitungContent.tsx's own branching on this. */
|
||||
returnContext?: "checkout" | "account";
|
||||
};
|
||||
|
||||
// Rendered by CheckoutContent once /api/checkout returns
|
||||
// `requiresPayment: true` (Kreditkarte/PayPal) — see
|
||||
// spicy-leaping-pizza.md §3/§7. The order already exists in Payload at
|
||||
// this point (status 'pending_payment'); this step only collects/confirms
|
||||
// the actual payment, it doesn't create anything.
|
||||
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference }: Props) {
|
||||
// the actual payment, it doesn't create anything. Also reused as-is by
|
||||
// the account order detail page's payment-method-switch flow (see
|
||||
// returnContext above) — the Stripe collection UI itself is identical
|
||||
// either way, only the post-payment destination differs.
|
||||
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference, returnContext = "checkout" }: Props) {
|
||||
if (testMode) {
|
||||
return <TestPaymentButtons orderNumber={orderNumber} orderId={orderId} providerReference={providerReference ?? ""} />;
|
||||
return (
|
||||
<TestPaymentButtons
|
||||
orderNumber={orderNumber}
|
||||
orderId={orderId}
|
||||
providerReference={providerReference ?? ""}
|
||||
returnContext={returnContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Elements stripe={getStripe()} options={{ clientSecret }}>
|
||||
<StripePaymentForm orderNumber={orderNumber} />
|
||||
<StripePaymentForm orderNumber={orderNumber} returnContext={returnContext} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
function StripePaymentForm({ orderNumber, returnContext }: { orderNumber: string; returnContext: "checkout" | "account" }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -62,7 +80,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`,
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`,
|
||||
},
|
||||
});
|
||||
// Only reached for immediate client-side failures (e.g. invalid card
|
||||
@@ -80,7 +98,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || submitting}
|
||||
className="rounded-full bg-brand-primary px-6 py-3 text-white font-semibold disabled:opacity-50"
|
||||
className="rounded-full bg-brand px-6 py-3 text-text-primary font-semibold hover:bg-brand-hover active:scale-[0.97] transition-all disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
|
||||
</button>
|
||||
@@ -88,7 +106,17 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TestPaymentButtons({ orderNumber, orderId, providerReference }: { orderNumber: string; orderId: number; providerReference: string }) {
|
||||
function TestPaymentButtons({
|
||||
orderNumber,
|
||||
orderId,
|
||||
providerReference,
|
||||
returnContext,
|
||||
}: {
|
||||
orderNumber: string;
|
||||
orderId: number;
|
||||
providerReference: string;
|
||||
returnContext: "checkout" | "account";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -108,7 +136,7 @@ function TestPaymentButtons({ orderNumber, orderId, providerReference }: { order
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`);
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`);
|
||||
} catch {
|
||||
setError("Testzahlung konnte nicht ausgeführt werden.");
|
||||
setSubmitting(null);
|
||||
|
||||
@@ -24,6 +24,11 @@ export function VerarbeitungContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const orderNumber = searchParams.get("orderNumber");
|
||||
// "account" — the payment-method-switch flow on an existing order's
|
||||
// account page (see PaymentStep.tsx's own returnContext comment).
|
||||
// Defaults to "checkout" for a bare/missing param, same as before this
|
||||
// branch existed.
|
||||
const isAccountContext = searchParams.get("context") === "account";
|
||||
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
|
||||
const startedAt = useRef<number | null>(null);
|
||||
|
||||
@@ -42,6 +47,15 @@ export function VerarbeitungContent() {
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "paid") {
|
||||
if (isAccountContext) {
|
||||
// Nothing to clear — this order was already placed (as
|
||||
// Überweisung) and confirmed long before this switch, there's
|
||||
// no cart/discount/draft snapshot involved. Land back on the
|
||||
// same order instead of /bestellbestaetigung, which would
|
||||
// read as a brand-new purchase.
|
||||
router.push(`/konto/bestellungen/${encodeURIComponent(orderNumber!)}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (pending) {
|
||||
@@ -114,13 +128,15 @@ export function VerarbeitungContent() {
|
||||
Zahlung fehlgeschlagen
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen.
|
||||
{isAccountContext
|
||||
? "Die Zahlung konnte nicht abgeschlossen werden. Deine Bestellung bleibt unverändert — du kannst es jederzeit erneut versuchen."
|
||||
: "Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen."}
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
@@ -133,10 +149,10 @@ export function VerarbeitungContent() {
|
||||
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,11 +21,14 @@ const FALLBACK: CompanySettings = {
|
||||
sellerCity: "",
|
||||
sellerCountry: "",
|
||||
sellerEmail: "",
|
||||
emailFromName: null,
|
||||
emailFromAddress: null,
|
||||
vatId: "",
|
||||
taxRatePercent: 19,
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
bankName: null,
|
||||
};
|
||||
|
||||
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
|
||||
|
||||
+33
-24
@@ -3,19 +3,20 @@ import { Reveal } from "./Reveal";
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col sm:flex-row sm:items-stretch w-full">
|
||||
|
||||
{/* Text content — relative + z-10 so it renders above the overlapping
|
||||
photo at lg+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake).
|
||||
md:flex-[1.4_0_0] lg:flex-[1_0_0] — at Tablet the text column got
|
||||
the narrower 1:1.4 share meant for Desktop's overlap layout,
|
||||
leaving it too cramped for the fixed-width statement + quote/bio
|
||||
row. Widened at Tablet (text gets the bigger share, image the
|
||||
smaller one, no overlap yet) and reverted to the original ratio
|
||||
from lg: up, where the overlap trick actually needs the image to
|
||||
have more room. */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1.4_0_0] lg:flex-[1_0_0] min-w-0 relative z-10">
|
||||
The 1:1.4 text:image ratio (text gets the narrower share) applies
|
||||
from sm: up unchanged — the photo needs the room, the text
|
||||
column doesn't need to be wide (the inner quote/bio row stays
|
||||
stacked at Tablet regardless, see below, so a wider text column
|
||||
doesn't even help it go inline; it just steals space the photo
|
||||
should have — reported 2026-07-29). Only the -ml-48 overlap
|
||||
itself (see the photo below) is gated to lg:, since that trick
|
||||
needs more absolute room than Tablet has to not look cramped. */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 sm:flex-[1_0_0] min-w-0 relative z-10">
|
||||
|
||||
{/* Large serif statement — width-constrained as per design */}
|
||||
<p
|
||||
@@ -26,11 +27,13 @@ export function About() {
|
||||
</p>
|
||||
|
||||
{/* Quote row: script quote / divider / author bio — side-by-side
|
||||
from lg: (was md:) — even with the text column's wider Tablet
|
||||
share above, quote + divider + the whitespace-nowrap bio ("Gründer
|
||||
von einfach-produktiv.") together still needed more room than
|
||||
Tablet's ~384px column has. Stacked with a horizontal divider
|
||||
through the whole Tablet range instead, side-by-side (vertical
|
||||
only from lg: (1024px), a deliberate exception to the site's
|
||||
sm: (640px) structural consolidation: quote + divider + the
|
||||
whitespace-nowrap bio ("Gründer von einfach produktiv.")
|
||||
together need more room than the sm:-anchored text column has
|
||||
through the whole 640-1023px range, independent of the outer
|
||||
text/image ratio above. Stacked with a horizontal divider
|
||||
through that whole range instead, side-by-side (vertical
|
||||
divider) only once there's real room at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 lg:gap-0 w-full">
|
||||
|
||||
@@ -74,19 +77,20 @@ export function About() {
|
||||
<p>Björn.</p>
|
||||
<p>Führungskraft.</p>
|
||||
<p>Familienmensch.</p>
|
||||
<p>Gründer von einfach-produktiv.</p>
|
||||
<p>Gründer von einfach produktiv.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Author photo — overlaps the text column via -ml-48 from lg+ only
|
||||
(that overlap trick has nothing to blend into once stacked, and
|
||||
at Tablet it would eat back into the extra width the text column
|
||||
above just gained); plain full-width photo below the text on
|
||||
Mobile, plain side-by-side (no overlap) at Tablet. */}
|
||||
{/* Author photo — gets the wider 1.4 share from sm: up already (see
|
||||
the text column's own comment above); plain, no overlap at
|
||||
Tablet. Overlaps the text column via -ml-48 only from lg+, where
|
||||
there's real absolute room for the photo to bleed under it
|
||||
without looking cramped. Plain full-width photo below the text
|
||||
on true Mobile. */}
|
||||
<Reveal
|
||||
className="relative overflow-hidden w-full md:flex-[1_0_0] lg:flex-[1.4_0_0] lg:-ml-48"
|
||||
className="relative overflow-hidden w-full sm:flex-[1.4_0_0] lg:-ml-48"
|
||||
style={{ minHeight: "14rem" }}
|
||||
delay={0.15}
|
||||
>
|
||||
@@ -94,11 +98,16 @@ export function About() {
|
||||
alt="Björn"
|
||||
src="/about-author.jpg"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 58vw, (min-width: 768px) 42vw, 100vw"
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover object-center pointer-events-none"
|
||||
/>
|
||||
{/* Left gradient: wide enough to cover the text-column overlap — lg+ only */}
|
||||
<div className="hidden lg:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
{/* Left gradient — from sm: up, not just lg:. Even without the
|
||||
-ml-48 overlap at Tablet, the photo sits directly against the
|
||||
dark text column with no transition, reading as a hard vertical
|
||||
seam (reported 2026-07-29). A narrower fade (w-16) softens just
|
||||
that seam at Tablet; widens to w-72 at lg: to also cover the
|
||||
deeper -ml-48 overlap once that kicks in. */}
|
||||
<div className="hidden sm:block absolute inset-y-0 left-0 w-16 lg:w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
</Reveal>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
import { NotifyMeForm } from "./NotifyMeForm";
|
||||
|
||||
const FEEDBACK_MS = 2000;
|
||||
|
||||
@@ -17,6 +18,7 @@ export function AddToCartButton({
|
||||
label,
|
||||
className,
|
||||
productId = "todo-karten",
|
||||
numericId,
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
@@ -27,6 +29,10 @@ export function AddToCartButton({
|
||||
* ProductSpotlight passes the actual CMS-selected spotlight product's id
|
||||
* explicitly, since that can now be a different product. */
|
||||
productId?: string;
|
||||
/** Payload's real numeric product id (`product.numericId`) — only used to
|
||||
* scope a NotifyMeForm signup once out of stock, never for the cart/
|
||||
* checkout path itself (that stays on the slug `productId` above). */
|
||||
numericId: number;
|
||||
/** Product-level — only meaningful when `variants` is empty, same split as
|
||||
* AddToCartInlineButton. */
|
||||
outOfStock?: boolean;
|
||||
@@ -82,7 +88,9 @@ export function AddToCartButton({
|
||||
: added
|
||||
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
|
||||
: "";
|
||||
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : label;
|
||||
// currentlyOutOfStock has no branch here — that state renders
|
||||
// NotifyMeForm instead of this button entirely (see below).
|
||||
const displayLabel = limitReached ? "Maximale Menge im Warenkorb" : label;
|
||||
|
||||
return (
|
||||
// Low stock is deliberately NOT surfaced here as its own text line
|
||||
@@ -111,46 +119,49 @@ export function AddToCartButton({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
directly — this button is inline-flex/content-sized (no w-full),
|
||||
so "Hinzugefügt ✓" being shorter than most labels made the whole
|
||||
button visibly shrink while showing the success state. Stacking
|
||||
both possible texts in the same grid cell (both invisible ones
|
||||
still contribute to sizing) reserves width for whichever is
|
||||
wider, so the button's box never changes size either way. Now
|
||||
also reserves space for "Ausverkauft"/"Maximale Menge im
|
||||
Warenkorb" — the widest of the four wins regardless of which is
|
||||
showing. */}
|
||||
{/* whitespace-nowrap — inherited by every stacked span below. On a
|
||||
w-full button (e.g. this page's mobile layout), "Maximale Menge
|
||||
im Warenkorb" is long enough to wrap to two lines without this,
|
||||
and since every stacked span shares the same grid cell, that
|
||||
inflated the row height for whichever text is actually showing
|
||||
too — "Ausverkauft" rendered with a tall empty gap underneath it
|
||||
(fixed 2026-07-24). */}
|
||||
<span className="relative grid whitespace-nowrap">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
{currentlyOutOfStock ? (
|
||||
// Replaces the button slot entirely rather than stacking below a
|
||||
// disabled "Ausverkauft" button — same reasoning as
|
||||
// AddToCartInlineButton's identical swap (see that file's own
|
||||
// comment on the `items-start` grid fix this relies on).
|
||||
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
directly — this button is inline-flex/content-sized (no w-full),
|
||||
so "Hinzugefügt ✓" being shorter than most labels made the whole
|
||||
button visibly shrink while showing the success state. Stacking
|
||||
both possible texts in the same grid cell (both invisible ones
|
||||
still contribute to sizing) reserves width for whichever is
|
||||
wider, so the button's box never changes size either way. Now
|
||||
also reserves space for "Maximale Menge im Warenkorb" — the
|
||||
widest of the three wins regardless of which is showing. */}
|
||||
{/* whitespace-nowrap — inherited by every stacked span below. On a
|
||||
w-full button (e.g. this page's mobile layout), "Maximale Menge
|
||||
im Warenkorb" is long enough to wrap to two lines without this,
|
||||
and since every stacked span shares the same grid cell, that
|
||||
inflated the row height for whichever text is actually showing
|
||||
too (fixed 2026-07-24). */}
|
||||
<span className="relative grid whitespace-nowrap">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Hinzugefügt ✓
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Maximale Menge im Warenkorb
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Hinzugefügt ✓
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Ausverkauft
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Maximale Menge im Warenkorb
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
import { NotifyMeForm } from "./NotifyMeForm";
|
||||
|
||||
// Exported so consumers like RelatedProducts.tsx can delay their own
|
||||
// follow-up UI changes (e.g. swapping out this exact card) until after
|
||||
@@ -18,6 +19,7 @@ export const FEEDBACK_MS = 2000;
|
||||
*/
|
||||
export function AddToCartInlineButton({
|
||||
id,
|
||||
numericId,
|
||||
label = "In den Warenkorb",
|
||||
className,
|
||||
outOfStock = false,
|
||||
@@ -25,6 +27,10 @@ export function AddToCartInlineButton({
|
||||
variants = [],
|
||||
}: {
|
||||
id: string;
|
||||
/** Payload's real numeric product id (`product.numericId`) — only used to
|
||||
* scope a NotifyMeForm signup once out of stock, never for the cart/
|
||||
* checkout path itself (that stays on the slug `id` above). */
|
||||
numericId: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
/** Product-level — only meaningful when `variants` is empty. A varianted
|
||||
@@ -107,23 +113,36 @@ export function AddToCartInlineButton({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
{currentlyOutOfStock ? (
|
||||
// Replaces the button slot entirely rather than stacking below a
|
||||
// disabled "Ausverkauft" button. An out-of-stock card is taller
|
||||
// than its in-stock siblings now — ProductGrid.tsx/MerklisteGrid.tsx/
|
||||
// RelatedProducts.tsx all use `items-start` on their grid (not the
|
||||
// CSS Grid default `stretch`) specifically so that doesn't cascade
|
||||
// into pushing every other card's button down to match; an earlier
|
||||
// attempt reserved the extra height invisibly on every card
|
||||
// instead, which looked worse in practice (visible dead space
|
||||
// under in-stock cards' buttons).
|
||||
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
|
||||
</button>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
>
|
||||
{limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Mirror of ArrowRightIcon.tsx — same reasoning applies here: the Unicode
|
||||
// "←" (U+2190) previously used inline in CartContent.tsx's "Weiter
|
||||
// einkaufen" links isn't covered by the site's custom web fonts, so it
|
||||
// fell back to a system font whose vertical metrics sit visibly low on
|
||||
// mobile relative to the label text next to it. An SVG has no
|
||||
// font-fallback path — renders identically everywhere.
|
||||
export function ArrowLeftIcon() {
|
||||
return (
|
||||
<svg aria-hidden width="16" height="12" viewBox="0 0 16 12" fill="none" className="shrink-0">
|
||||
<path d="M15 6H1M1 6L4 3M1 6L4 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Replaces the Unicode "→" (U+2192) previously used inline in CTA links
|
||||
// (Tools.tsx, Blog.tsx, ProductGrid.tsx) — that glyph isn't covered by the
|
||||
// site's custom web fonts, so browsers fall back to a system font just for
|
||||
// that one character. The fallback's vertical metrics differ enough by
|
||||
// platform (confirmed: sat visibly low relative to the label text on a
|
||||
// Samsung Galaxy S22/Android Chrome, not reproducible in desktop Chromium)
|
||||
// that centering it via flex `items-center` alone isn't reliable across
|
||||
// devices. An SVG has no font-fallback path — it renders identically
|
||||
// everywhere. `currentColor` stroke follows the parent Link's own
|
||||
// text/hover color, same as every other icon in this codebase.
|
||||
// Arrowhead wings are deliberately short relative to the shaft (4.2 units
|
||||
// vs. a 14-unit shaft) — the first version used full-length 45° wings
|
||||
// (7 units), which read as a generic, oversized chevron next to the small
|
||||
// bold label text it sits beside.
|
||||
export function ArrowRightIcon() {
|
||||
return (
|
||||
<svg aria-hidden width="16" height="12" viewBox="0 0 16 12" fill="none" className="shrink-0">
|
||||
<path d="M1 6H15M15 6L12 3M15 6L12 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+109
-86
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getBlogPosts } from "../lib/payload";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
import { ArrowRightIcon } from "./ArrowRightIcon";
|
||||
|
||||
export async function Blog() {
|
||||
const posts = await getBlogPosts(3);
|
||||
@@ -31,97 +32,119 @@ export async function Blog() {
|
||||
{/* Posts grid */}
|
||||
<RevealGroup className="flex flex-col gap-10 items-start px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Featured post — image on top on Mobile, side-by-side from md+ */}
|
||||
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-7 md:h-[220px] bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={featured.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 md:pr-4 md:pl-4 md:col-span-5 min-w-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{featured.category}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-text-primary">
|
||||
<p
|
||||
className="font-semibold text-h-section leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{featured.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 md:line-clamp-none">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
{/* Featured post — image on top through lg (1024px), side-by-side
|
||||
only from lg+ (was sm+, but that squeezed the text column too
|
||||
hard on tablet widths — same reasoning as the secondary posts'
|
||||
grid below, which already used this lg breakpoint).
|
||||
lg:min-h stretches the row taller on large screens (the image's
|
||||
lg:h-full and the text column's justify-between both follow) —
|
||||
without it the card's height was purely driven by the text
|
||||
column's content, which stays short regardless of viewport
|
||||
width. */}
|
||||
<RevealItem>
|
||||
{/* Whole card is the click target now (image + title + excerpt),
|
||||
not just the "Zum Beitrag" line — the hover-lift below already
|
||||
implied the whole card was clickable, so only the small arrow
|
||||
actually working was a mismatch. `group` drives the arrow
|
||||
row's hover color from anywhere on the card, not just when
|
||||
hovering that row itself. */}
|
||||
<Link
|
||||
href={featured.href}
|
||||
className="group w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-12 lg:min-h-[26rem] transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-7 lg:h-full bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={featured.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* flex + arrow as its own span — see Tools.tsx's comment on
|
||||
this same fix (→'s glyph baseline sits low next to text). */}
|
||||
<Link
|
||||
href={featured.href}
|
||||
className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>→</span>
|
||||
<span>Zum Beitrag</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 lg:pr-4 lg:pl-4 lg:col-span-5 min-w-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{featured.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-text-primary">
|
||||
<p
|
||||
className="font-semibold text-h-section leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{featured.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 lg:line-clamp-none">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* flex + arrow as its own span — see Tools.tsx's comment on
|
||||
this same fix (→'s glyph baseline sits low next to text).
|
||||
Plain span now, not its own nested Link (the whole card
|
||||
is one Link already) — group-hover keeps the color
|
||||
change working from anywhere on the card. */}
|
||||
<span className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap group-hover:text-brand transition-colors">
|
||||
<ArrowRightIcon />
|
||||
<span>Zum Beitrag</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</RevealItem>
|
||||
|
||||
{/* Secondary posts — image on top on Mobile, side-by-side from md+ */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
|
||||
{/* Secondary posts — image on top through lg (1024px), side-by-side
|
||||
only from lg+. These sit 2-up (sm:grid-cols-2 below) well
|
||||
before lg, so each card's own available width through the
|
||||
640-1023px tablet range is too narrow for image+text side by
|
||||
side — was sm:grid-cols-5, squeezing the text column. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 w-full">
|
||||
{secondary.map((post) => (
|
||||
<RevealItem
|
||||
key={post.id}
|
||||
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-2 md:h-[230px] bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={post.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 29vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-3 px-4 md:col-span-3 min-w-0 text-text-primary">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{post.category}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className="font-semibold text-h-small leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 md:line-clamp-none">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
<RevealItem key={post.id}>
|
||||
<Link
|
||||
href={post.href}
|
||||
className="group block border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-2 lg:h-[230px] bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={post.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 29vw, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* flex + arrow as its own span — see the featured post's
|
||||
own Link above / Tools.tsx's comment on this same fix. */}
|
||||
<Link
|
||||
href={post.href}
|
||||
className="flex items-center gap-1 font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>→</span>
|
||||
<span>Zum Beitrag</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-3 px-4 lg:col-span-3 min-w-0 text-text-primary">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className="font-semibold text-h-small leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 lg:line-clamp-none">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Plain span, not its own nested Link — see the featured
|
||||
post's own comment above. */}
|
||||
<span className="flex items-center gap-1 font-bold text-body whitespace-nowrap group-hover:text-brand transition-colors">
|
||||
<ArrowRightIcon />
|
||||
<span>Zum Beitrag</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</RevealItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function buildCategoryHref(active: string[], category: string): string {
|
||||
const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category];
|
||||
return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog";
|
||||
}
|
||||
|
||||
// Client-side navigation (not a plain <Link>) specifically so `isPending`
|
||||
// can gate the chips: rapid clicks used to fire multiple concurrent RSC
|
||||
// navigations with no guarantee they'd commit in the order they were
|
||||
// requested, so a slower, stale navigation could land after a newer one and
|
||||
// briefly show the wrong (sometimes empty) result. Disabling the chips
|
||||
// while a navigation is in flight makes that ordering race impossible —
|
||||
// only one navigation can ever be outstanding at a time.
|
||||
export function BlogCategoryFilter({ allCategories, activeCategories }: { allCategories: string[]; activeCategories: string[] }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{allCategories.map((category) => {
|
||||
const active = activeCategories.includes(category);
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => startTransition(() => router.push(buildCategoryHref(activeCategories, category)))}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors disabled:opacity-60 disabled:pointer-events-none ${
|
||||
active
|
||||
? "bg-brand border-brand text-text-primary"
|
||||
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCart } from "../lib/cart";
|
||||
import { useCart, pullServerCart } from "../lib/cart";
|
||||
|
||||
// Mirrors the local cart to the server whenever it changes, so a logged-in
|
||||
// customer's cart follows them across devices (see Customers.ts's `cart`
|
||||
@@ -10,6 +10,14 @@ import { useCart } from "../lib/cart";
|
||||
// no-op when logged out — POST /api/account/cart 401s in that case, which
|
||||
// this component doesn't need to distinguish from success; there's simply
|
||||
// nothing to keep in sync yet.
|
||||
//
|
||||
// Push (local→server) and pull (server→local) are both handled here, but
|
||||
// deliberately asymmetric: push reacts to every local cart change (that's
|
||||
// this device's news to share), pull only runs on mount and on window
|
||||
// focus (see pullServerCart()'s own comment on why it's safe to call
|
||||
// repeatedly) — no polling interval, since "another device changed my
|
||||
// cart while this tab has been open and unfocused the whole time" is a
|
||||
// rare enough case not to justify a persistent timer.
|
||||
export function CartSync() {
|
||||
const cart = useCart();
|
||||
const isFirstRender = useRef(true);
|
||||
@@ -34,5 +42,11 @@ export function CartSync() {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [cart]);
|
||||
|
||||
useEffect(() => {
|
||||
pullServerCart();
|
||||
window.addEventListener("focus", pullServerCart);
|
||||
return () => window.removeEventListener("focus", pullServerCart);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
// A fully custom-styled dropdown — a native <select>'s trigger box can be
|
||||
// styled, but its open options popup is rendered by the browser/OS itself
|
||||
// and can't be reached with CSS at all (wrong font size, wrong colors, no
|
||||
// brand styling whatsoever). This renders both the trigger and the
|
||||
// options panel as plain HTML we control end to end. Originally built for
|
||||
// /konto/bestellungen's filters, promoted to a shared component so
|
||||
// checkout's country selects can use the same look (moved here 2026-07-30).
|
||||
export function CustomSelect({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
includeAllOption = true,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
/** Screen-reader label — also the trigger's placeholder text when
|
||||
* `includeAllOption` is true and nothing is selected. */
|
||||
label: string;
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** true (default): prepends a `{value: "", label}` "clear/show all"
|
||||
* pseudo-option — the filter-dropdown use case (Order/blog/etc.
|
||||
* filters), where "nothing selected" is a real, meaningful state.
|
||||
* false: no pseudo-option, every real option is selectable and one is
|
||||
* always genuinely selected — the plain-select-replacement use case
|
||||
* (e.g. checkout's country picker), where there's no "clear" concept. */
|
||||
includeAllOption?: boolean;
|
||||
/** false (default): trigger shrinks to its content width from sm: up —
|
||||
* right for a row of compact filter dropdowns. true: trigger always
|
||||
* stays full width of its container — right for a form-field
|
||||
* replacement (e.g. checkout's country picker, alongside other w-full
|
||||
* inputs). */
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const allOptions: Option[] = includeAllOption ? [{ value: "", label }, ...options] : options;
|
||||
const selectedIndex = Math.max(
|
||||
0,
|
||||
allOptions.findIndex((o) => o.value === value),
|
||||
);
|
||||
const selectedLabel = allOptions[selectedIndex]?.label ?? label;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setHighlighted(selectedIndex);
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [open, highlighted]);
|
||||
|
||||
// Keyboard/focus-driven close: Tabbing (or programmatically moving
|
||||
// focus) away from the trigger+list entirely used to leave the panel
|
||||
// open forever — the mousedown-outside listener above only ever reacts
|
||||
// to a mouse click, not focus leaving via Tab. `relatedTarget` is where
|
||||
// focus is headed; null on some browsers when it lands outside the
|
||||
// document/on a non-focusable element, which should also close.
|
||||
function onBlur(e: React.FocusEvent) {
|
||||
if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
|
||||
}
|
||||
|
||||
function select(index: number) {
|
||||
onChange(allOptions[index].value);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open) {
|
||||
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlighted((i) => Math.min(i + 1, allOptions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlighted((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
select(highlighted);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex items-center justify-between gap-2 w-full ${fullWidth ? "" : "sm:w-auto min-w-[10rem]"} border rounded-sm ${
|
||||
fullWidth ? "px-4 py-3" : "px-3 py-2"
|
||||
} text-body-sm transition-colors outline-none ${
|
||||
value ? "border-brand text-text-primary" : "border-border text-text-muted"
|
||||
} hover:border-brand focus-visible:border-brand`}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}>
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
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) => (
|
||||
<li
|
||||
key={o.value || "__all__"}
|
||||
data-index={i}
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
onMouseEnter={() => setHighlighted(i)}
|
||||
onClick={() => select(i)}
|
||||
className={`px-3 py-2 text-body-sm cursor-pointer transition-colors ${
|
||||
i === selectedIndex ? "font-semibold text-brand" : "text-text-primary"
|
||||
} ${i === highlighted ? "bg-bg-muted" : ""}`}
|
||||
>
|
||||
{o.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function Divider() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Entlastung</Word>
|
||||
<Word>Leichtigkeit</Word>
|
||||
|
||||
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
|
||||
the text-h2 words next to it, previously hard rem values that
|
||||
|
||||
@@ -19,10 +19,15 @@ export function Footer() {
|
||||
<div className="flex flex-col items-center w-full max-w-[1280px] py-8 md:py-4">
|
||||
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below
|
||||
lg: (was md:). Logo + handle + 5 legal links all side by side
|
||||
with justify-between read too cramped on Tablet — stacked
|
||||
through that range instead, side by side again once there's
|
||||
real room at lg:. */}
|
||||
lg:. Reverted here from an earlier sm: rename (2026-07-29's
|
||||
breakpoint migration): this row isn't the "grid arrives before
|
||||
the fluid floor" bug the sm: consolidation targets — logo +
|
||||
handle + 5 nowrap legal links (all shrink-0) simply don't fit
|
||||
in one row below ~1024px regardless of fluid scaling (measured:
|
||||
~746px combined natural width at a 666px viewport, causing a
|
||||
real horizontal page overflow, not just visual cramping).
|
||||
Same fixed-content-doesn't-fit category as Cart/Checkout/
|
||||
SectionTOC, kept at lg: for the same reason. */}
|
||||
<div className="flex flex-col lg:flex-row items-center lg:justify-between gap-6 lg:gap-0 px-8 md:px-16 w-full">
|
||||
|
||||
{/* Logo: "einfach produktiv" white + "." gold */}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Server Component — no "use client" needed. The consent gating itself
|
||||
// happens entirely client-side inside Klaro (KlaroConsentManager.tsx),
|
||||
// not via any React state here: Klaro's own contextual-consent DOM scan
|
||||
// (renderContextualConsentNotices, runs as part of Klaro.render()) finds
|
||||
// this iframe by its `data-name="google-maps"` attribute after mount,
|
||||
// blanks its `src` and inserts its own "Karte laden?" placeholder in
|
||||
// front of it until that service's consent is granted, then restores the
|
||||
// real `src`. See klaroConfig.ts's own comment on the "google-maps"
|
||||
// service this depends on — a `tracking-codes` row with that provider
|
||||
// must exist and be `active`, or Klaro never gates (and never un-blanks)
|
||||
// this iframe at all.
|
||||
//
|
||||
// `width`/`height` are real HTML attributes, not just the Tailwind aspect-
|
||||
// ratio wrapper below — Klaro reads `element.width`/`element.height` (the
|
||||
// DOM attributes) to size its own placeholder box before the real iframe
|
||||
// has rendered, so both need to be present even though the wrapper's
|
||||
// `aspect-[...]` class is what actually controls layout.
|
||||
export function GoogleMapsEmbed({
|
||||
src,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
/** A full Google Maps embed URL (Google Maps → Teilen → Karte einbetten
|
||||
* → "HTML kopieren" → the `src` attribute of that iframe), e.g.
|
||||
* "https://www.google.com/maps/embed?pb=...". */
|
||||
src: string;
|
||||
title: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`relative w-full aspect-[16/9] overflow-hidden rounded-md border border-border ${className ?? ""}`}>
|
||||
<iframe
|
||||
data-name="google-maps"
|
||||
src={src}
|
||||
title={title}
|
||||
width={800}
|
||||
height={450}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="absolute inset-0 h-full w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+68
-48
@@ -11,7 +11,7 @@ function HeroImage() {
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
@@ -28,44 +28,56 @@ function HeroImage() {
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Structural breakpoint is md: (768px) for the GRID only — the text
|
||||
column stays ~283-320px wide through the whole 768-1023px Tablet
|
||||
{/* Structural breakpoint is sm: (640px, moved down from the old md:
|
||||
768px so the grid arrives exactly where the fluid token floor
|
||||
also sits — see fluid.ts/globals.css) for the GRID only — the
|
||||
text column stays narrow through the whole 640-1023px Tablet
|
||||
range regardless. Below, every piece of *content* inside the text
|
||||
column (heading/subtitle/CTA/social-proof) keeps its smaller,
|
||||
fixed-below-lg: sizing all the way through Tablet too, not just
|
||||
true Mobile — reusing the full fluid-token sizes at md: (as a
|
||||
first pass 2026-07-24 briefly did) put the original ~19-44px
|
||||
fluid floors right back in that narrow column, recreating the
|
||||
exact 3-line-wrap problem the old `lg:` structural exception
|
||||
existed to avoid. Splitting "grid at md:" from "full-size content
|
||||
at lg:" gets both: Tablet shows the real 5/7 grid, but with
|
||||
content sized for its column's actual width, not the column
|
||||
width `lg:` was designed for. */}
|
||||
<div className="flex flex-col md:grid md:grid-cols-12 md:items-center gap-8 md:gap-[var(--layout-grid-gap)] pt-10 md:pt-0">
|
||||
column (heading/subtitle/CTA/social-proof) deliberately keeps its
|
||||
smaller, fixed-below-lg: sizing all the way through Tablet too,
|
||||
not just true Mobile — reusing the full fluid-token sizes that
|
||||
early put the original ~19-44px fluid floors right back in that
|
||||
narrow column, recreating the exact 3-line-wrap problem the old
|
||||
`lg:` structural exception existed to avoid. This is a narrower,
|
||||
intentional exception to the site-wide sm: consolidation — the
|
||||
column's real width at sm: hasn't been visually verified yet
|
||||
(no browser in this environment), so full-size content stays
|
||||
gated behind lg: as a fast-follow rather than a guess. Splitting
|
||||
"grid at sm:" from "full-size content at lg:" gets both: Tablet
|
||||
shows the real 5/7 grid, but with content sized for its column's
|
||||
actual width, not the column width `lg:` was designed for. */}
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-12 sm:items-center gap-8 sm:gap-[var(--layout-grid-gap)] pt-10 sm:pt-0">
|
||||
|
||||
{/* Text content — first in DOM/visual order at every breakpoint so
|
||||
the CTA stays above the fold on Mobile (deliberate exception to
|
||||
the "keep DOM order" default, see Hero decision in the plan).
|
||||
Reveal fires ~immediately since Hero is already in the initial
|
||||
viewport — this doubles as the page's entrance animation. */}
|
||||
<Reveal className="order-1 md:order-none md:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 md:pr-0">
|
||||
viewport — this doubles as the page's entrance animation.
|
||||
Centered below sm: (true Mobile) — the CTA/social-proof below
|
||||
were already centered there, left-aligned heading/subheading
|
||||
above them read inconsistent (reported 2026-07-29). Left-
|
||||
aligned again from sm: up, matching the rest of the column. */}
|
||||
<Reveal className="order-1 sm:order-none sm:col-span-5 flex flex-col gap-7 items-center sm:items-start pl-[var(--layout-padding-x)] pr-10 sm:pr-0">
|
||||
|
||||
{/* Heading — smaller fixed-ish size below lg: (text-h1, still a
|
||||
real paired font-size+line-height token, not an arbitrary
|
||||
value) — text-display's own 44px floor wraps very heavily in
|
||||
a ~283-320px Tablet column (even a single word can approach
|
||||
that width). Forced break after "darf" only in the sm-md
|
||||
tablet range (natural wrap there landed awkwardly); removed
|
||||
at true mobile widths (below sm:) 2026-07-24 — narrower
|
||||
still, natural wrap reads fine there, and the forced break
|
||||
made "darf" the whole first line. Full text-display only
|
||||
from lg: up, where the column has real room again. */}
|
||||
that width). The old forced break after "darf" only existed
|
||||
for a "wide single-column, not yet grid" band (640-767px
|
||||
under the old md: 768px grid switch) that no longer exists
|
||||
now the grid itself starts at sm: (640px) — below sm: the
|
||||
stack is narrower than that old band ever was, where natural
|
||||
wrap already reads fine (confirmed 2026-07-24), so the break
|
||||
is removed rather than re-anchored to a new range. Full
|
||||
text-display only from lg: up, where the column has real
|
||||
room again. */}
|
||||
<p
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary"
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary text-center sm:text-left"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
<span className="text-h1 lg:text-display">
|
||||
Produktivität darf<br className="hidden sm:inline md:hidden" /> sich leicht anfühlen
|
||||
Verlier' dich nicht im mehr, verankere was zählt
|
||||
</span>
|
||||
{/* Brand's signature orange dot (also in the logo/footer) —
|
||||
bouncy pop-in once the heading scrolls into view, timed to
|
||||
@@ -83,14 +95,16 @@ export function Hero() {
|
||||
{/* Subheading — smaller fixed size below lg:, text-h-emphasis's
|
||||
own 20px floor read too large next to the now-smaller CTA
|
||||
text. leading shrinks to match, not just font-size. */}
|
||||
<p className="font-semibold leading-[1.75rem] lg:leading-[2.375rem] min-w-full shrink-0 text-text-primary text-[1rem] lg:text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
|
||||
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
|
||||
<p className="font-semibold leading-[1.75rem] lg:leading-[2.375rem] min-w-full shrink-0 text-text-primary text-[1rem] lg:text-h-emphasis w-[min-content] text-center sm:text-left [word-break:break-word] not-italic">
|
||||
Ich helfe dir, zwischen Job, Familie und eigenen Projekten nicht unterzugehen.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
{/* CTA — centered below sm: (reads better against the centered
|
||||
image/banner above it on true Mobile), left-aligned with the
|
||||
rest of the text column again from sm: up. */}
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
href="https://einfach-produktiv.mk360.de/7-tage-klarheits-check"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all self-center sm:self-auto"
|
||||
>
|
||||
{/* Letting this wrap to two lines below lg: (tried 2026-07-24)
|
||||
put the icon beside a two-line text block, which read as
|
||||
@@ -100,7 +114,7 @@ export function Hero() {
|
||||
too wide for that, both on a 375px phone AND in the
|
||||
~283-320px Tablet grid column. */}
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-[0.8125rem] lg:text-h3 whitespace-nowrap not-italic">
|
||||
Starte mit der 7-Tage-Challenge
|
||||
Jetzt starten
|
||||
</span>
|
||||
{/* Scaled down to match the smaller CTA text (same ~0.76
|
||||
aspect ratio as the lg: size), full size again from lg: up
|
||||
@@ -112,8 +126,14 @@ export function Hero() {
|
||||
|
||||
{/* Social proof — always avatars-then-text on two lines below
|
||||
lg: (not just when it happens to overflow), single row again
|
||||
from lg: up where the real column width fits it fine. */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 items-center justify-center overflow-clip shrink-0 w-full">
|
||||
from lg: up where the real column width fits it fine.
|
||||
Centered on true Mobile (<640px, matches the CTA above it,
|
||||
see self-center there); left-aligned in the 640-1023px
|
||||
Tablet band instead, matching the left-aligned heading/
|
||||
subheading — plain centered there read as floating/
|
||||
disconnected from that column (reported 2026-07-29); back to
|
||||
a centered row at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 items-center sm:items-start lg:items-center justify-center sm:justify-start lg:justify-center overflow-clip shrink-0 w-full">
|
||||
{/* Avatars — gap 2px, not overlapping */}
|
||||
<div className="flex gap-[0.125rem] items-center shrink-0">
|
||||
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
|
||||
@@ -126,36 +146,36 @@ export function Hero() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center [word-break:break-word]">
|
||||
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center sm:text-left [word-break:break-word]">
|
||||
1.120+ Menschen vertrauen <span className="whitespace-nowrap">einfach produktiv.</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Image — bleeds to the true edge at every breakpoint (never
|
||||
padded). Below md: (stacked layout) the full 887:583 aspect
|
||||
ratio at 100vw would make the image ~600-900px tall and
|
||||
dominate the page, so height is capped and object-cover crops
|
||||
it into a supporting banner instead; at md:+ (grid, image only
|
||||
58% width) the full aspect ratio looks right again, so the cap
|
||||
is lifted. No shadow: a plain box-shadow reads as a hard
|
||||
rectangular edge against the existing corner/right/bottom
|
||||
mask-gradient fade below, which looked worse than no shadow at
|
||||
all — tried and reverted. */}
|
||||
{/* No Reveal (fade-in-on-scroll) below md: — whileInView's -80px
|
||||
padded). Below sm: (stacked layout, moved down from the old
|
||||
md: 768px) the full 887:583 aspect ratio at 100vw would make
|
||||
the image ~600-900px tall and dominate the page, so height is
|
||||
capped and object-cover crops it into a supporting banner
|
||||
instead; at sm:+ (grid, image only 58% width) the full aspect
|
||||
ratio looks right again, so the cap is lifted. No shadow: a
|
||||
plain box-shadow reads as a hard rectangular edge against the
|
||||
existing corner/right/bottom mask-gradient fade below, which
|
||||
looked worse than no shadow at all — tried and reverted. */}
|
||||
{/* No Reveal (fade-in-on-scroll) below sm: — whileInView's -80px
|
||||
viewport margin means the image doesn't fade in until scrolled
|
||||
that much further into view; on a short mobile viewport this
|
||||
image sits right at the initial fold, so it stayed at
|
||||
opacity:0 (a white gap, matching the section's own bg-bg-base)
|
||||
above the fold until the user scrolled (reported 2026-07-24).
|
||||
Plain, always-visible image below md: instead; Reveal's fade
|
||||
kept from md: up, where the image is beside the text with
|
||||
Plain, always-visible image below sm: instead; Reveal's fade
|
||||
kept from sm: up, where the image is beside the text with
|
||||
plenty of room and this was never an issue. */}
|
||||
<div className="order-2 md:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<div className="order-2 sm:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<HeroImage />
|
||||
</div>
|
||||
<Reveal
|
||||
className="hidden md:block md:col-span-7 relative w-full aspect-[887/583]"
|
||||
className="hidden sm:block sm:col-span-7 relative w-full aspect-[887/583]"
|
||||
delay={0.15}
|
||||
>
|
||||
<HeroImage />
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import "klaro/dist/klaro.css";
|
||||
import type { TrackingCode } from "../lib/payload";
|
||||
import { buildKlaroConfig } from "../lib/klaroConfig";
|
||||
import { loadTrackingCode } from "../lib/loadTrackingCode";
|
||||
|
||||
// Replaced a hand-rolled CookieBanner.tsx + useConsent.ts + TrackingScripts.tsx
|
||||
// with kiprotect/klaro (open source, self-hosted, npm install klaro) —
|
||||
// the custom banner only ever covered the accept/reject UI itself; Klaro
|
||||
// additionally brings a real per-service consent list, bundled German UI
|
||||
// translations, and (via `cookies`, not used here yet) cookie-deletion on
|
||||
// withdrawal — all things a hand-rolled version would have had to build
|
||||
// from scratch. See project memory for the fuller reasoning.
|
||||
//
|
||||
// Dynamically imported inside an effect (client-only, after mount) rather
|
||||
// than a static top-level import — Klaro touches `window`/`document` at
|
||||
// module-eval time in places, which isn't SSR-safe. The CSS import above
|
||||
// stays static (Next.js requires CSS imports to be static, not inside a
|
||||
// dynamic import()), paired with the "-no-css" JS build so the stylesheet
|
||||
// isn't loaded twice.
|
||||
//
|
||||
// `buildKlaroConfig`'s `styling` CSS-var overrides (brand colors, corner
|
||||
// position) only get Klaro so far — its bundled klaro.css still draws its
|
||||
// own border/shadow/spacing/typography, which read as an obviously
|
||||
// bolted-on library widget next to this site's own hand-designed
|
||||
// components (NewsletterModal.tsx, NotifyMeForm.tsx, etc.). The <style>
|
||||
// block below is a real CSS override pass against Klaro's actual DOM
|
||||
// classnames (confirmed against kiprotect/klaro's own src/scss/*.scss,
|
||||
// not guessed) — same "plain unlayered <style> tag wins the cascade"
|
||||
// approach the Payload admin's own AdminUIStyles.tsx uses, `!important`
|
||||
// added only where needed to beat klaro.css's own rules of otherwise-equal
|
||||
// specificity. Kills Klaro's default border entirely (replaced with a
|
||||
// soft shadow, matching this site's own card language) and restyles
|
||||
// every button to the site's actual rounded/weighted look instead of
|
||||
// Klaro's generic flat rectangles.
|
||||
function KlaroTheme() {
|
||||
return (
|
||||
<style>{`
|
||||
/* Blanket reset first — the previous pass only targeted
|
||||
.cookie-notice/.cookie-modal directly and a hard black border
|
||||
still showed up in production (screenshot-confirmed), so every
|
||||
descendant gets border/outline stripped here regardless of which
|
||||
specific Klaro rule was actually drawing it; the two rules below
|
||||
re-add exactly the borders this theme actually wants. */
|
||||
.klaro, .klaro * { box-sizing: border-box; border: 0 !important; outline: 0 !important; }
|
||||
/* Matches NewsletterModal.tsx's own backdrop color
|
||||
(bg-[rgba(134,134,134,0.9)]) instead of Klaro's default plain
|
||||
black at 50% — same dimming purpose, but consistent with how
|
||||
every other modal on this site already looks. .cm-bg is a
|
||||
*sibling* of the actual dialog box (.cm-modal.cm-klaro), both
|
||||
direct children of the full-screen .cookie-modal wrapper — see
|
||||
that wrapper's own comment below on why it must never be resized/
|
||||
transformed itself. */
|
||||
.klaro .cm-bg { background: rgba(134, 134, 134, 0.9) !important; }
|
||||
.klaro .cookie-notice, .klaro .cm-modal.cm-klaro {
|
||||
box-shadow: 0 20px 44px -14px rgba(26,26,24,0.22), 0 4px 14px rgba(26,26,24,0.07) !important;
|
||||
border-radius: 18px !important;
|
||||
font-family: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
|
||||
}
|
||||
.klaro .cookie-notice .cn-body { padding: 22px 24px !important; }
|
||||
.klaro .cookie-notice p, .klaro .cm-modal.cm-klaro p {
|
||||
font-size: 14px !important;
|
||||
line-height: 1.6 !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
.klaro h1, .klaro h2 {
|
||||
font-family: var(--font-lora), Georgia, serif !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.01em !important;
|
||||
}
|
||||
.klaro .cm-btn {
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 13px !important;
|
||||
padding: 10px 18px !important;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.klaro .cm-btn:hover { opacity: 0.88; }
|
||||
.klaro .cm-btn:active { transform: scale(0.97); }
|
||||
.klaro .cm-btn:focus-visible { outline: 2px solid #f6a701 !important; outline-offset: 2px; }
|
||||
/* Side-by-side by default (accept/decline in the notice, the
|
||||
modal's own bottom action row), only wrapping to a stacked
|
||||
layout once they genuinely don't fit — flex-wrap does this
|
||||
naturally at any width, no fixed breakpoint needed. Klaro's own
|
||||
.cn-ok/.cn-buttons rely on inline-block flow for the same
|
||||
result, which is fragile; this is the explicit version. */
|
||||
.klaro .cn-ok, .klaro .cn-buttons, .klaro .cm-buttons {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 10px !important;
|
||||
width: auto !important;
|
||||
}
|
||||
.klaro .cn-buttons .cm-btn, .klaro .cm-buttons .cm-btn { width: auto !important; margin: 0 !important; }
|
||||
/* The "Karte laden?" placeholder ContextualConsentNotice renders in
|
||||
place of a gated embed (GoogleMapsEmbed.tsx) — fills that embed's
|
||||
own aspect-ratio box, so this needs to look like a real card
|
||||
slot, not a bare unstyled div floating inside it. */
|
||||
.klaro.cm-as-context-notice {
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.klaro .context-notice {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 4px !important;
|
||||
padding: 24px !important;
|
||||
background: #fffdf8 !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
.klaro .context-notice p { text-align: center !important; max-width: 32ch; }
|
||||
.klaro .context-notice .cm-buttons { display: flex !important; gap: 10px !important; margin-top: 6px !important; }
|
||||
.klaro a.cm-link, .klaro .cookie-notice a, .klaro .cookie-modal a {
|
||||
color: #a06b00 !important;
|
||||
text-decoration: underline !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.klaro select, .klaro .cm-list-input + label {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* ---- Settings modal service/purpose list — a real pass modeled on
|
||||
well-known CMPs like Cookiebot (roomier modal, clear row
|
||||
separators, bigger switches, muted-but-legible descriptions),
|
||||
not just inherited from the notice's own styling.
|
||||
|
||||
DOM structure (confirmed against kiprotect/klaro's own
|
||||
src/components/consent-modal.jsx — the FIRST version of this
|
||||
pass guessed wrong and broke the backdrop, see the incident note
|
||||
below):
|
||||
.cookie-modal full-viewport wrapper (position:
|
||||
fixed, 100%×100%) — MUST stay
|
||||
untouched; giving it its own
|
||||
transform/position (tried first)
|
||||
creates a new containing block
|
||||
for its position:fixed children,
|
||||
so .cm-bg below started
|
||||
positioning itself relative to
|
||||
THIS shrunken/centered box
|
||||
instead of the real viewport —
|
||||
the backdrop only covered a
|
||||
640px-wide column, screenshot-
|
||||
confirmed 2026-08-02.
|
||||
.cm-bg the dark backdrop (styled above)
|
||||
.cm-modal.cm-klaro the actual dialog box — every
|
||||
size/position override belongs
|
||||
HERE, not on .cookie-modal.
|
||||
.cm-header close (×) + h1.title + intro <p>
|
||||
.cm-body the service/purpose list
|
||||
.cm-footer > .cm-footer-buttons decline/accept/accept-all
|
||||
row (NOT .cm-buttons — that
|
||||
class belongs to the small
|
||||
notice/context-notice components
|
||||
only, an earlier pass wrongly
|
||||
reused it here too and the rule
|
||||
silently matched nothing). */
|
||||
.klaro .cm-modal.cm-klaro {
|
||||
position: fixed !important;
|
||||
left: 50% !important;
|
||||
top: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
width: calc(100% - 40px) !important;
|
||||
max-width: 640px !important;
|
||||
max-height: 88vh !important;
|
||||
overflow: auto !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.klaro .cm-header { padding: 32px 36px 0 !important; }
|
||||
.klaro .cm-body { padding: 8px 36px !important; }
|
||||
.klaro .cm-footer { padding: 0 36px 32px !important; }
|
||||
.klaro .cm-header h1.title { font-size: 26px !important; margin-bottom: 4px !important; }
|
||||
.klaro .cm-header > p { color: #6b6b69 !important; margin-bottom: 0 !important; }
|
||||
.klaro .cm-header .hide {
|
||||
position: absolute !important;
|
||||
top: 28px !important;
|
||||
right: 28px !important;
|
||||
color: #6b6b69 !important;
|
||||
font-size: 20px !important;
|
||||
}
|
||||
.klaro .cm-header .hide:hover { color: #1a1a18 !important; }
|
||||
|
||||
/* Re-adds a deliberate row separator the blanket border-reset above
|
||||
removes — each purpose/service row genuinely benefits from one,
|
||||
unlike the notice's own outer border which just looked heavy. */
|
||||
.klaro .cm-switch-container {
|
||||
border-bottom: 1px solid #e5e0d8 !important;
|
||||
padding: 16px 4px !important;
|
||||
padding-left: 70px !important;
|
||||
}
|
||||
.klaro .cm-switch-container:last-child { border-bottom: 0 !important; }
|
||||
.klaro .cm-list-title { font-size: 15px !important; font-weight: 700 !important; }
|
||||
.klaro .cm-list-description { font-size: 13.5px !important; line-height: 1.5 !important; padding-top: 5px !important; max-width: 46ch; }
|
||||
.klaro p.purposes { font-size: 12px !important; font-weight: 600 !important; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 6px !important; }
|
||||
|
||||
/* Bigger, clearer toggle (44×24 vs. Klaro's cramped 50×30-but-
|
||||
visually-thin default) — brand amber when on, plain border-tone
|
||||
off, matching this site's own bg-brand/bg-border pairing. */
|
||||
.klaro .cm-switch, .klaro .cm-list-input { width: 44px !important; height: 24px !important; }
|
||||
.klaro .slider { border: 1px solid #e5e0d8 !important; }
|
||||
.klaro .slider::before { width: 18px !important; height: 18px !important; left: 3px !important; bottom: 2px !important; box-shadow: 0 1px 2px rgba(0,0,0,0.15) !important; }
|
||||
.klaro .cm-list-input:checked + .cm-list-label .slider::before { transform: translateX(19px) !important; }
|
||||
|
||||
.klaro .cm-caret { color: #6b6b69 !important; }
|
||||
|
||||
/* Bottom action row (decline/accept/accept-all) — real classname
|
||||
is .cm-footer-buttons here, NOT .cm-buttons (see the DOM-
|
||||
structure note above); reads as a distinct footer, not just the
|
||||
last list item — separated + given the same side-by-side/wrap
|
||||
treatment as the notice's own buttons. */
|
||||
.klaro .cm-footer-buttons {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 10px !important;
|
||||
border-top: 1px solid #e5e0d8 !important;
|
||||
margin-top: 16px !important;
|
||||
padding-top: 20px !important;
|
||||
}
|
||||
.klaro .cm-footer-buttons .cm-btn { width: auto !important; margin: 0 !important; }
|
||||
.klaro .cm-powered-by { display: none !important; }
|
||||
|
||||
/* Hides the "alle umschalten" toggle's own boilerplate description
|
||||
("Mit diesem Schalter können Sie alle Dienste aktivieren oder
|
||||
deaktivieren.") — redundant next to a plainly-labeled switch.
|
||||
CSS hide, not an empty translation override — Klaro's own t()
|
||||
treats an empty string as "translation missing" and renders a
|
||||
literal "[missing translation: ...]" debug string instead
|
||||
(confirmed via screenshot 2026-08-02), which is worse than the
|
||||
original text. */
|
||||
.klaro .cm-toggle-all .cm-list-description { display: none !important; }
|
||||
`}</style>
|
||||
);
|
||||
}
|
||||
|
||||
export function KlaroConsentManager({ codes }: { codes: TrackingCode[] }) {
|
||||
// Kept in state (not just called once) so the persistent reopen button
|
||||
// below can call Klaro.show() itself, any time after the initial
|
||||
// accept/reject decision — a visitor must always be able to revisit
|
||||
// their choice, not just on first load.
|
||||
const [klaroModule, setKlaroModule] = useState<typeof import("klaro/dist/klaro-no-css") | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Nothing to ask consent for — don't even load/render Klaro. A cookie
|
||||
// banner with zero services to list would just be visual noise.
|
||||
if (codes.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
import("klaro/dist/klaro-no-css").then((Klaro) => {
|
||||
if (cancelled) return;
|
||||
const config = buildKlaroConfig(codes, loadTrackingCode);
|
||||
Klaro.setup(config);
|
||||
setKlaroModule(Klaro);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `codes` comes
|
||||
// from a server-fetched, 60s-ISR-cached layout prop; it's stable for
|
||||
// the lifetime of this component in practice, and Klaro.setup() isn't
|
||||
// meant to be called more than once per page load anyway.
|
||||
}, []);
|
||||
|
||||
if (codes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<KlaroTheme />
|
||||
{klaroModule && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => klaroModule.show()}
|
||||
aria-label="Cookie-Einstellungen öffnen"
|
||||
title="Cookie-Einstellungen"
|
||||
className="fixed bottom-5 left-5 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-bg-base shadow-md hover:border-brand transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="9" cy="9.5" r="1.1" fill="currentColor" />
|
||||
<circle cx="14" cy="8.5" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="13.5" r="1.1" fill="currentColor" />
|
||||
<circle cx="10.5" cy="14.5" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="11" r="0.9" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { renderPageBlockSync } from "./PageBlocks";
|
||||
import { mapPayloadPage, type PayloadPage, type Page } from "../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of a Pages document: the breadcrumb and the
|
||||
// layout blocks (the H1 now lives in the first block, usually a "hero" —
|
||||
// see HeroBlock.ts) — same "editable subset only" scope as LivePostContent.tsx
|
||||
// (see that file's own comment). `testimonialsRef` blocks are skipped here
|
||||
// (see renderPageBlockSync's own comment) since they need an async fetch a
|
||||
// client component can't perform inline; editing that block still shows up
|
||||
// once the page is saved and reloaded outside the preview iframe.
|
||||
export function LivePageContent({ initialPage }: { initialPage: Page }) {
|
||||
const { data } = useLivePreview<PayloadPage>({
|
||||
initialData: initialPage as unknown as PayloadPage,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 2,
|
||||
});
|
||||
const page = data?.slug ? mapPayloadPage(data) : initialPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">{page.title}</span>
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14 flex flex-col gap-5">
|
||||
{page.layout.map(renderPageBlockSync)}
|
||||
</Reveal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+170
-23
@@ -6,6 +6,8 @@ import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
import { SearchButton, SearchOverlay } from "./SearchOverlay";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
@@ -44,7 +46,7 @@ function getNavLinks(singleActiveProduct: boolean) {
|
||||
return [
|
||||
{ label: "Werkzeuge", href: "#werkzeuge" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Über Björn", href: "#ueber-bjoern" },
|
||||
{ label: "Über mich", href: "/ueber-mich" },
|
||||
{ label: "Shop", href: singleActiveProduct ? "#spotlight" : "/shop" },
|
||||
];
|
||||
}
|
||||
@@ -58,7 +60,10 @@ function getNavLinks(singleActiveProduct: boolean) {
|
||||
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
|
||||
// page-weekly-impulses's own breadcrumb ("Startseite › Werkzeuge ›
|
||||
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"];
|
||||
// /der-eine and /tasse-die-pause (both bespoke product detail pages, same
|
||||
// "own route, not nested" shape as /todo-cards) added here for the same
|
||||
// reason (2026-08-25).
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/7-tage-klarheits-check", "/newsletter", "/der-eine", "/tasse-die-pause", "/lebensuhr", "/3x3-system"];
|
||||
|
||||
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
|
||||
if (href === "#werkzeuge") {
|
||||
@@ -131,6 +136,71 @@ function AccountLink() {
|
||||
);
|
||||
}
|
||||
|
||||
// Wishlist icon + count badge — only rendered by the caller when
|
||||
// `wishlistEnabled` (CompanySettings). Visible at every width, alongside
|
||||
// Search/Account/Cart — all 4 stay full 44px touch targets even on the
|
||||
// smallest phones (shrinking them was tried and reverted: it made them
|
||||
// hard to hit accurately). The logo shrinks further below 375px instead
|
||||
// to make room — confirmed via an actual Playwright viewport sweep down
|
||||
// to 320px that this fits without wrapping/overflow (see git history).
|
||||
function WishlistLink() {
|
||||
const { count } = useWishlist();
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const prevCountRef = useRef(0);
|
||||
// Same guard as CartLink's own — useWishlist() starts from an empty
|
||||
// cached/SSR-safe list and only fills in the real count once its own
|
||||
// fetch resolves client-side, so without this the badge pulsed on every
|
||||
// page load/reload the instant that first real count arrived, not just
|
||||
// on an actual add/remove during the session.
|
||||
const hasMountedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true;
|
||||
prevCountRef.current = count;
|
||||
return;
|
||||
}
|
||||
if (count !== prevCountRef.current) {
|
||||
setPulse(true);
|
||||
const t = setTimeout(() => setPulse(false), 350);
|
||||
prevCountRef.current = count;
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [count]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/konto/merkliste"
|
||||
aria-label={count > 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
|
||||
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 18"
|
||||
className={"h-6 w-6 text-text-primary transition-transform duration-300 " + (pulse ? "scale-110" : "scale-100")}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={
|
||||
"absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white transition-transform duration-300 " +
|
||||
(pulse ? "scale-125" : "scale-100")
|
||||
}
|
||||
>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Cart icon + count badge — traced from the Figma Navbar/Default component's
|
||||
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
|
||||
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
|
||||
@@ -142,6 +212,12 @@ function CartLink() {
|
||||
const anchorRef = useRef<HTMLAnchorElement>(null);
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const prevVisibleRef = useRef(0);
|
||||
// useCartCount's getServerSnapshot is always 0 (see cart.ts) so hydration
|
||||
// always transitions 0 -> the real count on first render — without this
|
||||
// guard that transition alone satisfied "visibleCount > prevVisibleRef"
|
||||
// and pulsed the badge on every single page load/reload, not just an
|
||||
// actual in-session cart change.
|
||||
const hasMountedRef = useRef(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Held back by pendingCount while a ball is mid-flight, so the badge only
|
||||
@@ -156,6 +232,11 @@ function CartLink() {
|
||||
}, [registerCartIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true;
|
||||
prevVisibleRef.current = visibleCount;
|
||||
return;
|
||||
}
|
||||
if (visibleCount > prevVisibleRef.current) {
|
||||
setPulse(true);
|
||||
const t = setTimeout(() => setPulse(false), 350);
|
||||
@@ -210,12 +291,21 @@ function CartLink() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
|
||||
export function Navbar({
|
||||
singleActiveProduct,
|
||||
wishlistEnabled,
|
||||
searchEnabled,
|
||||
}: {
|
||||
singleActiveProduct: boolean;
|
||||
wishlistEnabled: boolean;
|
||||
searchEnabled: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [newsletterOpen, setNewsletterOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -383,13 +473,23 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
// 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.
|
||||
//
|
||||
// onClick here closes the drawer on any click that lands on the bar
|
||||
// itself (logo/cart/account/CTAs already call closeMobile() from
|
||||
// their own onClick, so this mainly covers clicking empty space in
|
||||
// the bar) — safe as a catch-all specifically because the panel is
|
||||
// a sibling, not a descendant, so a click inside the open drawer
|
||||
// never bubbles up to this handler. The hamburger's own onClick
|
||||
// stops propagation so toggling it open doesn't immediately get
|
||||
// undone by this same handler.
|
||||
onClick={() => mobileOpen && setMobileOpen(false)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-8">
|
||||
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-2 min-[375px]:px-4 sm:px-8">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
|
||||
{/* Logo — real navigation to "/" from anywhere else; only when
|
||||
@@ -401,7 +501,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
while leaving that page's content on screen. */}
|
||||
<Link
|
||||
href="/"
|
||||
className="shrink-0"
|
||||
className="shrink-0 w-[76px] min-[375px]:w-[130px] sm:w-[181px]"
|
||||
onClick={
|
||||
pathname === "/"
|
||||
? (e) => {
|
||||
@@ -413,11 +513,21 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
: closeMobile
|
||||
}
|
||||
>
|
||||
{/* Fixed width/height are the real file dimensions (Next Image
|
||||
needs them for optimization/layout); the wrapping Link's own
|
||||
w-[112px] min-[375px]:w-[130px] sm:w-[181px] + h-auto here is
|
||||
what actually shrinks the rendered logo below 640px — there
|
||||
wasn't enough header width for 4 full-44px icons + hamburger
|
||||
otherwise (confirmed via an actual Playwright viewport sweep
|
||||
down to 320px, not assumed) — the icons themselves are never
|
||||
shrunk (see WishlistLink's own comment on why), so the logo
|
||||
is what gives on the very smallest phones instead. */}
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="einfach produktiv"
|
||||
alt="einfach produktiv."
|
||||
width={181}
|
||||
height={61}
|
||||
className="w-full h-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
@@ -428,8 +538,27 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Figma's Navbar-Mobile component set exactly, see the plan.
|
||||
Matches the Figma NavLink component's hover: text never
|
||||
changes color, an underline (brand, 2px x 40px) fades in on
|
||||
hover and stays on for the active section. */}
|
||||
<nav className="hidden lg:flex items-center gap-12">
|
||||
hover and stays on for the active section.
|
||||
|
||||
Deliberate exception to the site-wide sm: (640px) structural
|
||||
consolidation (see the 640px-breakpoint plan): this md:/lg:
|
||||
3-tier scheme (hamburger-only <768, hamburger+inline-CTAs
|
||||
768-1023, full-inline ≥1024) is untouched by that migration.
|
||||
Horizontal nav-link overflow is a different failure mode than
|
||||
the vertical grid/flex reflows the rest of the site has —
|
||||
there's no fluid token that shrinks link text to make a full
|
||||
inline nav fit at 640px, and nothing here was ever tied to
|
||||
the fluid token scale the way Hero/About/etc. were.
|
||||
|
||||
The gap itself, though, does scale fluidly — just on its own
|
||||
1024-1300px range (this nav's own floor/ceiling, only ever
|
||||
relevant while it's actually visible), not the site-wide
|
||||
640-1440px scale: clamp(1.5rem, 8.696vw - 4.065rem, 3rem) is
|
||||
gap-6 (1.5rem) at exactly 1024px, gap-12 (3rem) from 1300px
|
||||
up, and eases linearly between — the fixed 48px gap read as
|
||||
too wide right where the nav labels themselves have the
|
||||
least room (just above 1024px). */}
|
||||
<nav className="hidden lg:flex items-center gap-[clamp(1.5rem,8.696vw_-_4.065rem,3rem)]">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const underline = (
|
||||
@@ -479,17 +608,24 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
{/* Trailing controls — CTA buttons (md+), cart (always), hamburger
|
||||
(below lg). Grouped so spacing stays consistent as individual
|
||||
children hide/show across the three breakpoint tiers. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* No gap between these two — each is already a 44px touch
|
||||
target with the icon centered inside, so even gap-0 here
|
||||
still leaves ~20px of visual space between the actual
|
||||
glyphs. The outer gap-2 is what separates this pair from
|
||||
the CTA-buttons/hamburger group that follows, and stays
|
||||
untouched. Fixed 2026-07-24: gap-2 here on top of that
|
||||
built-in padding read as too much space on mobile, where
|
||||
these two icons are the only always-visible controls. */}
|
||||
<div className="flex items-center gap-1 min-[375px]:gap-2">
|
||||
{/* No gap between these — each is a full 44px touch target
|
||||
(never shrunk, even on the smallest phones — a smaller
|
||||
target was tried and reverted for being hard to tap
|
||||
accurately) with the icon centered inside, so even gap-0
|
||||
here still leaves visual space between the actual glyphs.
|
||||
All 4
|
||||
icons (Search/Account/Wishlist/Cart) are visible at every
|
||||
width, including true mobile — the 375px-and-below size
|
||||
step exists specifically so all 4 plus the hamburger fit
|
||||
without wrapping/overflow on the narrowest real phone
|
||||
viewports (checked at 320px). The outer gap-2 is what
|
||||
separates this group from the CTA-buttons/hamburger group
|
||||
that follows. */}
|
||||
<div className="flex items-center">
|
||||
{searchEnabled && <SearchButton onOpen={() => setSearchOpen(true)} />}
|
||||
<AccountLink />
|
||||
{wishlistEnabled && <WishlistLink />}
|
||||
<CartLink />
|
||||
</div>
|
||||
|
||||
@@ -509,10 +645,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
href="/3x3-system"
|
||||
className="px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
Mein 3x3-System
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -525,7 +661,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav-panel"
|
||||
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMobileOpen((v) => !v);
|
||||
}}
|
||||
className="lg:hidden flex h-11 w-11 items-center justify-center shrink-0"
|
||||
>
|
||||
<span className="relative block h-4 w-6">
|
||||
@@ -571,7 +710,14 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
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%)" }}
|
||||
// Own (slower, gentler) transition for the exit — the shared
|
||||
// ease-out curve above is front-loaded (fast start, slow
|
||||
// finish), which reads great for the reveal but meant the
|
||||
// close shrank most of the way almost immediately, then
|
||||
// lingered on a barely-visible sliver — felt abrupt rather
|
||||
// than smooth. easeInOut plus a longer duration spreads the
|
||||
// shrink evenly instead.
|
||||
exit={{ clipPath: "circle(0vmax at 100% 0%)", transition: { duration: 0.7, ease: "easeInOut" } }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="flex flex-col min-h-full pt-[6.25rem]">
|
||||
@@ -658,11 +804,11 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
href="/3x3-system"
|
||||
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
|
||||
Mein 3x3-System
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -671,6 +817,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
</AnimatePresence>
|
||||
|
||||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||||
{searchEnabled && <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ type NewsletterProps = {
|
||||
* instead of a second near-duplicate component.
|
||||
*/
|
||||
export function Newsletter({
|
||||
title = <>Starte mit einer Woche voller Klarheit<span className="text-brand">.</span></>,
|
||||
description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.",
|
||||
title = "Kleine Impulse – große Wirkung",
|
||||
description = "Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.",
|
||||
}: NewsletterProps = {}) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-page");
|
||||
|
||||
return (
|
||||
@@ -43,17 +43,35 @@ export function Newsletter({
|
||||
{/* Outer section padding — same fluid horizontal padding as all other sections */}
|
||||
<div className="px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Rounded card: cream bg, stacks below md */}
|
||||
<Reveal className="bg-bg-muted flex flex-col md:flex-row gap-8 md:gap-12 items-center px-8 py-8 md:py-0 rounded-md w-full">
|
||||
{/* max-w-[1280px] mx-auto — the consistent default width for this
|
||||
card, matching /lebensuhr's own newsletter-style "Bottom CTA"
|
||||
section (same 1280px cap Footer.tsx already uses) and every
|
||||
other narrow-viewport-friendly section on the site. Previously
|
||||
unbounded here, so on wide desktop viewports this card stretched
|
||||
edge-to-edge while /lebensuhr's version stayed centered and
|
||||
noticeably narrower — reported 2026-07-29. */}
|
||||
{/* Rounded card: cream bg, stacks below sm (moved down from the old md:) */}
|
||||
{/* py-8 at every breakpoint, not just below sm (was sm:py-0,
|
||||
relying purely on items-center + the taller column's own
|
||||
height to create top/bottom breathing room) — the form column
|
||||
(input, checkbox+label, privacy note) can be as tall as or
|
||||
taller than the copy column depending on viewport width, so
|
||||
centering alone left no slack to distribute and the input/
|
||||
"Keine Werbung" note sat flush against the card's top/bottom
|
||||
edge. */}
|
||||
<Reveal className="bg-bg-muted flex flex-col sm:flex-row gap-8 sm:gap-12 items-center px-8 py-8 rounded-md w-full max-w-[1280px] mx-auto">
|
||||
|
||||
{/* Left: copy — fixed width from md+ so the form always gets the remaining space.
|
||||
Icon+text stacked (icon on top, centered) below md: — side by
|
||||
side they squeezed the text into a ~164px column on a 375px
|
||||
phone (icon width + gap eating most of the card's inner
|
||||
width), wrapping awkwardly. Row layout with the icon beside
|
||||
the text is fine again from md+, where the fixed copy-column
|
||||
width leaves real room. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full md:flex-row md:items-start md:gap-8 md:text-left md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
|
||||
{/* Left: copy — fixed width from sm+ so the form always gets the remaining space.
|
||||
Icon+text stacked (icon on top, centered) below sm: — side by
|
||||
side they squeezed the text into a narrow column on a phone
|
||||
(icon width + gap eating most of the card's inner width),
|
||||
wrapping awkwardly, and the same squeeze reappeared through
|
||||
the whole tablet range (640-1023px) once the outer card's own
|
||||
sm:flex-row already put this copy column next to the form —
|
||||
row layout for icon+text is only comfortable once there's
|
||||
real room, i.e. lg+. Left-aligned (not centered) from sm up —
|
||||
only true mobile keeps the centered treatment. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full sm:items-start sm:text-left lg:flex-row lg:gap-8 sm:w-[var(--newsletter-copy-width)] lg:py-4 lg:shrink-0">
|
||||
|
||||
{/* Decorative envelope icon, tilted -4° as per design.
|
||||
w-[4rem], not w-16 — this project's --spacing-16 is a
|
||||
@@ -89,22 +107,24 @@ export function Newsletter({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: form — takes remaining space, centered vertically from md+ */}
|
||||
<div className="flex w-full md:flex-1 items-center md:self-stretch min-w-0">
|
||||
{/* Right: form — takes remaining space, centered vertically from sm+ */}
|
||||
<div className="flex w-full sm:flex-1 items-center sm:self-stretch min-w-0">
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
{/* Input + submit button — stacked below lg: (was md:).
|
||||
The card above already goes side-by-side at md: with a
|
||||
fixed-width copy column (--newsletter-copy-width), which
|
||||
only leaves ~200px for this form column at 768px — not
|
||||
enough room for input+button side by side. Stacked
|
||||
through the whole Tablet range instead, side by side
|
||||
again once the form column has real room at lg:. */}
|
||||
{/* Input + submit button — stacked below lg:, a deliberate
|
||||
exception to the site's sm: (640px) structural
|
||||
consolidation, not a leftover of it. The card above
|
||||
already goes side-by-side at sm: with a fixed-width
|
||||
copy column (--newsletter-copy-width), which only
|
||||
leaves ~128px for this form column at 640px and
|
||||
~197px at 768px — not enough room for input+button
|
||||
side by side even at the new, lower floor. Stacked
|
||||
through the whole 640-1023px range instead, side by
|
||||
side again once the form column has real room (≈333px)
|
||||
at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
@@ -140,7 +160,7 @@ export function Newsletter({
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
|
||||
@@ -10,7 +10,7 @@ const features = [
|
||||
{
|
||||
icon: "/icon-sparkle-wrapper.svg",
|
||||
title: "7 Tage. Ein Fokus.",
|
||||
desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
desc: "Wöchentliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-checklist.png",
|
||||
@@ -35,7 +35,7 @@ const features = [
|
||||
export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-modal");
|
||||
|
||||
// Background scroll lock while open — intercepts and cancels the wheel/
|
||||
@@ -144,26 +144,50 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
// Capped narrower than the Desktop 75rem through the whole
|
||||
// 640-1023px Tablet band — this dialog is a modal, not a page
|
||||
// section, so at Tablet it should read as a compact centered
|
||||
// card, not stretch to near-full-viewport-width once stacked
|
||||
// single-column (see the flex-col/flex-row split below):
|
||||
// full-bleed-width + a single stacked photo/text column made
|
||||
// the photo huge and the text below it look lost/disconnected
|
||||
// (reported 2026-07-29, after first trying a lg:-gated
|
||||
// structural stack with no width cap). max-w-[75rem] only
|
||||
// takes over once the 2-column split itself starts at lg:.
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[36rem] lg:max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{/* sticky, not absolute — the dialog itself is the scrolling
|
||||
container (overflow-y-auto above), so an absolute-positioned
|
||||
child scrolls away with the rest of the content instead of
|
||||
staying pinned to the visible top-right corner (reported
|
||||
2026-07-29). sticky top-6 keeps it fixed to the scrolled
|
||||
viewport's top edge; ml-auto pushes it to the right within
|
||||
the dialog's normal block flow (sticky positioning doesn't
|
||||
use right-* the way absolute does); -mb-6 cancels its own
|
||||
height (size-6 = 1.5rem) so it doesn't push the modal-top
|
||||
content below it down — same visual overlap as the old
|
||||
absolute positioning, just still visible after scrolling. */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute top-6 right-6 z-10 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
className="sticky top-6 ml-auto mr-6 -mb-6 z-20 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
>
|
||||
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
|
||||
</button>
|
||||
|
||||
{/* modal-top: photo + copy/form, stacked below md */}
|
||||
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
|
||||
{/* modal-top: photo + copy/form, stacked below lg: — paired with
|
||||
the dialog's own narrower max-w-[36rem] cap through Tablet
|
||||
(see above), so the stacked photo stays a reasonably-sized
|
||||
4:3 banner instead of blowing up to near-full-viewport-width. */}
|
||||
<div className="flex flex-col lg:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full lg:flex-1 aspect-[4/3] lg:aspect-auto">
|
||||
<Image
|
||||
src="/newsletter-modal-photo.jpg"
|
||||
alt="Notizbuch mit Kaffee und Stift"
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
sizes="(min-width: 1024px) 50vw, 36rem"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -172,8 +196,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
|
||||
itself is authored upside-down (matches how Newsletter.tsx
|
||||
uses this exact same asset); without it the icon renders
|
||||
flipped. Hidden below md: — removed on mobile 2026-07-24. */}
|
||||
<div className="hidden md:block w-16 h-14 -rotate-4 -scale-y-100">
|
||||
flipped. Hidden below lg: — removed on mobile 2026-07-24. */}
|
||||
<div className="hidden lg:block w-16 h-14 -rotate-4 -scale-y-100">
|
||||
<Image alt="" src="/newsletter-icon.svg" width={64} height={56} className="w-full h-full" />
|
||||
</div>
|
||||
|
||||
@@ -182,20 +206,18 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Starte mit einer Woche voller Klarheit<span className="text-brand">.</span>
|
||||
Kleine Impulse – große Wirkung
|
||||
</p>
|
||||
|
||||
<p className="text-body text-text-primary">
|
||||
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
|
||||
Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.
|
||||
</p>
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
@@ -209,9 +231,13 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
|
||||
)}
|
||||
{/* Always rendered (min-h reserves one line's worth of
|
||||
space) rather than conditionally mounted — this sits
|
||||
inside the same row the photo on the left stretches
|
||||
to match (items-stretch, md:aspect-auto), so an error
|
||||
popping in and out used to grow/shrink the whole
|
||||
modal, visibly resizing the photo along with it. */}
|
||||
<p className="text-label text-red-600 font-normal -mt-2 min-h-[1.05rem]">{emailError}</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
@@ -225,7 +251,7 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
@@ -241,9 +267,12 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
{/* Same reserved-space fix as emailError above — this is
|
||||
the "already subscribed" message, the one that actually
|
||||
prompted it. */}
|
||||
<p className="text-label text-red-600 font-normal min-h-[1.05rem]">
|
||||
{status === "error" ? error : ""}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
@@ -258,7 +287,9 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
items-start + a fixed icon bounding box (icons have different
|
||||
native proportions, e.g. the sparkle glyph isn't square) fixes
|
||||
it without needing the flip trick. */}
|
||||
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md:gap-6">
|
||||
{/* Same lg: exception as modal-top above, for the same narrower-
|
||||
dialog-width-through-Tablet reason. */}
|
||||
<div className="flex flex-col lg:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 lg:gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
|
||||
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { isValidEmail } from "../lib/email";
|
||||
import { ArrowRightIcon } from "./ArrowRightIcon";
|
||||
|
||||
/**
|
||||
* Replaces the (disabled) Add-to-cart button's spot once a product/variant
|
||||
* is out of stock — lets a visitor leave their email to be notified once
|
||||
* lib/jobs/sendBackInStockEmails.ts (Payload backend) sends the "it's
|
||||
* back" mail. Always shows the email input + submit control directly (no
|
||||
* extra click to reveal them).
|
||||
*
|
||||
* One input with the submit control embedded inside it (absolutely
|
||||
* positioned, not a layout sibling) — not a separate stacked button below
|
||||
* the input. A first version stacked input + full-width "Benachrichtigen"
|
||||
* button, which made an out-of-stock ProductCard.tsx visibly taller than
|
||||
* its in-stock siblings (two form rows vs. one button); shrinking that
|
||||
* stacked version's own padding was tried and reverted — the actual ask
|
||||
* was to keep every control's height untouched and make the CARD match
|
||||
* instead. Embedding the submit icon inside the input keeps this whole
|
||||
* control to exactly one row, given an explicit h-14 (3.5rem/56px) to
|
||||
* match AddToCartInlineButton's own "In den Warenkorb" button's actual
|
||||
* rendered height — that button isn't 56px from its py-3 padding alone,
|
||||
* its 1.875rem cart-icon image is the tallest thing in it, so matching
|
||||
* padding here wouldn't have matched height; a sold-out card now ends up
|
||||
* exactly as tall as an in-stock one.
|
||||
*/
|
||||
export function NotifyMeForm({ productId, variantName = "" }: { productId: number; variantName?: string }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!isValidEmail(email)) {
|
||||
setError("Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setStatus("submitting");
|
||||
try {
|
||||
const res = await fetch("/api/stock-notifications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, productId, variantName }),
|
||||
});
|
||||
const data: { ok: boolean; reason?: string } = await res.json();
|
||||
if (!data.ok) {
|
||||
setStatus("error");
|
||||
setError(data.reason || "Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
|
||||
return;
|
||||
}
|
||||
setStatus("success");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setError("Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
return <p className="text-body-sm text-success">Danke! Wir melden uns, sobald es wieder verfügbar ist.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5 w-full">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Bei Verfügbarkeit benachrichtigen"
|
||||
aria-label="E-Mail-Adresse für Benachrichtigung, sobald das Produkt wieder verfügbar ist"
|
||||
// h-14 (3.5rem/56px), not py-3 alone — AddToCartInlineButton's
|
||||
// own button isn't 56px tall because of its py-3 padding alone,
|
||||
// it's that plus its 1.875rem/30px cart-icon image, which is
|
||||
// taller than this input's own text line-height would be at
|
||||
// that same padding. Setting the height explicitly (rather than
|
||||
// trying to reverse-engineer a padding value that happens to
|
||||
// produce 56px for text-body-sm) is what actually matches it.
|
||||
className="w-full h-14 rounded-sm border border-border pl-3 pr-12 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
aria-label="Benachrichtigen"
|
||||
title="Benachrichtigen"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 flex size-8 items-center justify-center rounded-sm text-text-primary hover:text-brand transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "submitting" ? "…" : <ArrowRightIcon />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { PageBlock } from "../lib/payload";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { RichText } from "./RichText";
|
||||
import { Quote } from "./Quote";
|
||||
import { StepArrow } from "./StepArrow";
|
||||
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
|
||||
// Renders a Payload Pages document's `layout` blocks field — structurally
|
||||
// parallel to RichText.tsx's own `blocks: {...}` converter map, but one
|
||||
// level up (full page sections, not inline Lexical nodes). Each block
|
||||
// component reuses the exact same visual treatment already hand-built on
|
||||
// /lebensuhr, /3x3-system, and /7-tage-klarheits-check, rather than
|
||||
// inventing new styling — this is what those pages' repeated patterns get
|
||||
// consolidated into for CMS-driven pages going forward.
|
||||
export async function PageBlocks({ blocks }: { blocks: PageBlock[] }) {
|
||||
const rendered = await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (block.blockType === "testimonialsRef") {
|
||||
const testimonials = await getTestimonials(block.page);
|
||||
if (testimonials.length === 0) return null;
|
||||
return <TestimonialsGrid key={block.id} testimonials={testimonials} />;
|
||||
}
|
||||
return renderPageBlockSync(block);
|
||||
})
|
||||
);
|
||||
return <>{rendered}</>;
|
||||
}
|
||||
|
||||
// Every block EXCEPT testimonialsRef (needs its own async data fetch,
|
||||
// which a client component can't await — see LivePageContent.tsx, which
|
||||
// uses this directly and simply skips that one block type in preview,
|
||||
// same "live-previewable subset" scope-cut LivePostContent.tsx already
|
||||
// makes for its own author-bio/"Weiterlesen" chrome).
|
||||
export function renderPageBlockSync(block: PageBlock): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
// Same "font-playfair clamp headline" treatment every hand-built page
|
||||
// (/lebensuhr, /3x3-system, /ueber-mich) used for its own H1 before
|
||||
// this block existed — see HeroBlock.ts's own comment on why the
|
||||
// headline moved out of Pages.title and into here.
|
||||
case "hero": {
|
||||
const headline = (
|
||||
<p
|
||||
className={
|
||||
block.image
|
||||
? "font-semibold text-[clamp(2rem,1.393rem+1.5vw,2.75rem)] text-text-primary leading-[1.15]"
|
||||
: "font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
}
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{block.headline}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-4 items-start">
|
||||
{/* Optional round portrait beside the headline — e.g. /ueber-mich's
|
||||
"Hallo, ich bin Björn." size-20 (80px) read as barely-there/
|
||||
easy to miss next to a full headline — bumped to size-32
|
||||
(128px) so it actually registers as a real photo, not a
|
||||
small decorative blob. */}
|
||||
{block.image ? (
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative size-32 shrink-0 rounded-full overflow-hidden ring-1 ring-border">
|
||||
<Image alt="" src={block.image} fill sizes="128px" className="object-cover" />
|
||||
</div>
|
||||
{headline}
|
||||
</div>
|
||||
) : (
|
||||
headline
|
||||
)}
|
||||
{/* text-primary + text-h-emphasis, not text-muted body — same
|
||||
weight class as the homepage Hero's own subheading
|
||||
("Ich helfe dir..."), which a plain muted text-body read too
|
||||
quiet next to. */}
|
||||
{block.subline && (
|
||||
<p className="font-semibold text-h-emphasis text-text-primary leading-[1.4]">{block.subline}</p>
|
||||
)}
|
||||
{block.ctaLabel && block.ctaHref && (
|
||||
<Link
|
||||
href={block.ctaHref}
|
||||
className="inline-flex items-center gap-2 bg-brand hover:brightness-95 active:scale-[0.97] transition-all rounded-sm px-6 py-3 font-semibold text-body text-text-primary"
|
||||
>
|
||||
{block.ctaLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "richTextSection":
|
||||
return <RichText key={block.id} content={block.content} />;
|
||||
|
||||
case "quote":
|
||||
return <Quote key={block.id} label={block.label}>{block.text}</Quote>;
|
||||
|
||||
case "icon": {
|
||||
const SymbolIcon = SYMBOLIC_ICONS[block.icon];
|
||||
return SymbolIcon ? <div key={block.id}>{SymbolIcon()}</div> : null;
|
||||
}
|
||||
|
||||
case "image":
|
||||
if (!block.image) return null;
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-[3/2] rounded-xl overflow-hidden bg-bg-muted">
|
||||
<Image alt={block.caption ?? ""} src={block.image} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-contain" />
|
||||
</div>
|
||||
{block.caption && <p className="text-body-sm text-text-muted text-center">{block.caption}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "pillList":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-wrap gap-2">
|
||||
{block.items.map((item) => (
|
||||
<span key={item.id} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">{item.label}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checklistImage":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
|
||||
{block.image && (
|
||||
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
|
||||
<Image alt="" src={block.image} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 w-full flex flex-col gap-4">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.id} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{item.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "table":
|
||||
return (
|
||||
<div key={block.id} className="bg-bg-muted rounded-xl overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-brand">
|
||||
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.labelHeader}</th>
|
||||
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.valueHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={row.id} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
|
||||
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
|
||||
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "stepRow": {
|
||||
const icons = block.items.map((item) => STEP_ICONS[item.icon]);
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{block.items.flatMap((item, i) => [
|
||||
<div key={item.id} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{icons[i]?.()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
|
||||
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
|
||||
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
</div>,
|
||||
i < block.items.length - 1 ? (
|
||||
<div key={`arrow-${item.id}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "testimonialsRef":
|
||||
// Needs an async fetch — handled by PageBlocks (server) above.
|
||||
// Skipped in the client-side live-preview renderer.
|
||||
return null;
|
||||
|
||||
case "ctaCard":
|
||||
return (
|
||||
<Link
|
||||
key={block.id}
|
||||
href={block.href}
|
||||
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">{block.eyebrow}</p>
|
||||
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{block.title}</p>
|
||||
{block.description && <p className="text-body-sm text-text-muted">{block.description}</p>}
|
||||
</div>
|
||||
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
|
||||
default:
|
||||
// Unknown/malformed block — same "skip rather than crash" convention
|
||||
// RichText.tsx's own blocks use.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Same checkmark used by /lebensuhr's and /7-tage-klarheits-check's own
|
||||
// checklists.
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
|
||||
import { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getTestimonials } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { ProductName } from "./ProductName";
|
||||
import { RichText } from "./RichText";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { renderPageBlockSync } from "./PageBlocks";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
|
||||
// Renders a Payload Products document's `layout` blocks field — same
|
||||
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
|
||||
// this delegates to directly for every block type the two fields share
|
||||
// (richTextSection/stepRow/quote/image/icon/pillList/checklistImage/table/
|
||||
// ctaCard are the exact same Block configs, just registered a second time
|
||||
// on Products.layout — see that field's own comment in Products.ts). Only
|
||||
// `productHero` and `productPricingPanel` are product-specific, since they
|
||||
// need the live product/shipping/tax context that a generic content page
|
||||
// doesn't have.
|
||||
export async function ProductBlocks({ product }: { product: Product }) {
|
||||
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const ctx: ProductBlockContext = { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
|
||||
|
||||
const rendered = await Promise.all(
|
||||
product.layout.map(async (block) => {
|
||||
// Needs its own async data fetch — same reason PageBlocks.tsx
|
||||
// handles this one block type in its own outer async map instead of
|
||||
// the sync switch below (renderProductBlock/renderPageBlockSync).
|
||||
if (block.blockType === "testimonialsRef") {
|
||||
const testimonials = await getTestimonials(block.page);
|
||||
if (testimonials.length === 0) return null;
|
||||
return (
|
||||
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-6">
|
||||
<TestimonialsGrid testimonials={testimonials} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// productHero/productPricingPanel render their own full section
|
||||
// (edge-to-edge image, own Reveal, own padding rhythm) — matching
|
||||
// the hand-coded PDPs (todo-cards etc.) precisely needed each of
|
||||
// those, unlike a generic content block, so they opt out of this
|
||||
// shared wrapper entirely rather than fighting it.
|
||||
if (block.blockType === "productHero" || block.blockType === "productPricingPanel") {
|
||||
return <div key={block.id}>{renderProductBlock(block, ctx)}</div>;
|
||||
}
|
||||
return (
|
||||
<Reveal key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12">
|
||||
{renderProductBlock(block, ctx)}
|
||||
</Reveal>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return <>{rendered}</>;
|
||||
}
|
||||
|
||||
type ProductBlockContext = {
|
||||
product: Product;
|
||||
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
|
||||
taxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
};
|
||||
|
||||
function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
case "productHero":
|
||||
return <ProductHero {...ctx} body={block.body} />;
|
||||
case "productPricingPanel":
|
||||
return <ProductPricingPanel {...ctx} />;
|
||||
// Every other block type is identical to Pages.layout's own — same
|
||||
// Block config, reused a second time (see this file's own comment).
|
||||
default:
|
||||
return renderPageBlockSync(block as PageBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// Matches der-eine/todo-cards' own hand-coded Hero.tsx exactly — same
|
||||
// section/grid/Reveal/padding structure, same edge-to-edge image with
|
||||
// hover-scale — so a block-driven PDP hero doesn't visually stand apart
|
||||
// from its hand-coded siblings. Only the body prose differs: it's this
|
||||
// block's own `body` richText instead of hardcoded JSX.
|
||||
function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: ProductBlockContext & { body: unknown }) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">
|
||||
<ProductName name={product.name} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<p className="font-semibold text-h-page text-text-primary" style={{ fontFamily: "var(--font-playfair)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.subline && (
|
||||
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.subline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{body ? <RichText content={body} /> : null}
|
||||
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<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">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
</div>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
</div>
|
||||
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
|
||||
delay={0.15}
|
||||
>
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Matches todo-cards' own hand-coded Pricing.tsx exactly — see that
|
||||
// file's own comment on why this closing panel looks the way it does.
|
||||
function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: ProductBlockContext) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
|
||||
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
|
||||
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 410px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
|
||||
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.subline && <p className="text-body-sm text-text-primary">{product.subline}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
import { AddToCartInlineButton } from "./AddToCartInlineButton";
|
||||
import { WishlistButton } from "./WishlistButton";
|
||||
import { ProductName } from "./ProductName";
|
||||
import type { Product } from "../lib/payload";
|
||||
|
||||
// Single shared card markup for every product grid (ProductGrid,
|
||||
// RelatedProducts, MerklisteGrid) — these three used to each duplicate
|
||||
// this JSX independently and had drifted (different image aspect ratios,
|
||||
// a "Mehr erfahren" link present on some but not others, purchased/
|
||||
// out-of-stock badges only on some). The title itself is now the card's
|
||||
// only link (product.href, when set) — no separate "Mehr erfahren" CTA
|
||||
// line, which is also what makes the card more compact than before.
|
||||
// No "use client" — plain enough (no hooks/browser APIs of its own) to
|
||||
// render from both ProductGrid's Server Component and RelatedProducts'/
|
||||
// MerklisteGrid's Client Components.
|
||||
export function ProductCard({
|
||||
product,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
wishlistEnabled,
|
||||
wishlistRevealOnHover = false,
|
||||
topLeftBadge,
|
||||
belowPrice,
|
||||
className = "",
|
||||
}: {
|
||||
product: Product;
|
||||
defaultTaxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
wishlistEnabled: boolean;
|
||||
/** See WishlistButton's own doc: true for any grid where an unprompted
|
||||
* heart on every card would read as noise (ProductGrid, RelatedProducts);
|
||||
* false (default) for /konto/merkliste, where every card is already
|
||||
* wishlisted. */
|
||||
wishlistRevealOnHover?: boolean;
|
||||
/** Overrides the default Ausverkauft/discount pill — used by
|
||||
* MerklisteGrid for its "Gekauft am ..." badge. Pass `null` to render
|
||||
* no badge at all. */
|
||||
topLeftBadge?: ReactNode;
|
||||
/** Rendered directly under the price — e.g. ProductGrid's delivery-time
|
||||
* line. Omitted entirely by grids that don't have anything to say there
|
||||
* (RelatedProducts, MerklisteGrid), rather than every card carrying a
|
||||
* fixed slot for content only one of the three actually has. */
|
||||
belowPrice?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
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 (
|
||||
// 6 direct grid rows (image / header / price / belowPrice / lowstock /
|
||||
// button) instead of the old flex column — a plain flex-1 spacer only
|
||||
// pins the button to the bottom of THIS card, it can't make the price
|
||||
// row start at the same Y as a row sibling whose header block is a
|
||||
// different height (e.g. a 2-line vs 1-line subline). grid-template-
|
||||
// rows: subgrid pulls in the row-tracks each caller's grid container
|
||||
// already declares (see ProductGrid.tsx's own comment) so every row is
|
||||
// genuinely shared/max'd across the row of cards — dynamic, no
|
||||
// line-clamp/truncation needed on the subline. gap-3 is used for every
|
||||
// row gap including after the image (was pt-4/1rem there before —
|
||||
// 0.25rem less, accepted for one consistent grid gap instead of mixed
|
||||
// margin/gap spacing).
|
||||
<div
|
||||
className={`group bg-bg-base border border-border rounded-md overflow-hidden grid [grid-template-rows:subgrid] [grid-row:span_6] gap-3 transition-transform duration-300 hover:-translate-y-1 ${className}`}
|
||||
>
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
{/* Link wraps only the image, not the whole header — WishlistButton
|
||||
below is its own interactive element and sits as a sibling, not
|
||||
nested inside this Link (nested interactive elements are both
|
||||
invalid HTML and would fire navigation on a wishlist click). */}
|
||||
{product.href ? (
|
||||
<Link href={product.href} aria-label={product.name} className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
{topLeftBadge !== undefined ? (
|
||||
topLeftBadge
|
||||
) : 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>
|
||||
)
|
||||
)}
|
||||
{wishlistEnabled && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 items-start w-full px-5">
|
||||
{product.categories.length > 0 && (
|
||||
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
|
||||
)}
|
||||
{product.href ? (
|
||||
<Link
|
||||
href={product.href}
|
||||
className="font-semibold text-h4 text-text-primary w-full hover:text-brand transition-colors"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
<ProductName name={product.name} />
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
)}
|
||||
{product.subline && <p className="text-body-sm text-text-muted w-full">{product.subline}</p>}
|
||||
</div>
|
||||
|
||||
<p className="flex items-baseline gap-1.5 px-5">
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
|
||||
<div className="px-5">{belowPrice}</div>
|
||||
|
||||
{/* Always rendered, text conditional — an empty row still occupies
|
||||
its shared track (0-height unless a row sibling needs it), same
|
||||
reasoning as the price/header rows above. */}
|
||||
<p className="text-label font-bold text-warning px-5">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
|
||||
|
||||
{/* self-end pins the button to the bottom of this row even though
|
||||
the row itself is minmax(0,1fr) (see grid-auto-rows) and can be
|
||||
taller than the button — replaces the old flex-1 spacer. */}
|
||||
<div className="self-end px-5 pb-5 w-full">
|
||||
<AddToCartInlineButton
|
||||
id={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.variants}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const SWIPE_THRESHOLD_PX = 40;
|
||||
|
||||
/**
|
||||
* Main image + thumbnail strip, swappable on click. `image` is always
|
||||
* slide zero, `gallery` (Products.gallery, optional/empty for most
|
||||
* products) fills in the rest. Renders as a single plain image with no
|
||||
* thumbnail row at all when `gallery` is empty — the common case, and
|
||||
* exactly today's pre-gallery appearance, no behavior change for any
|
||||
* product that hasn't opted in. Plain divs/buttons, no carousel package —
|
||||
* same "no charting/UI-library dependency for a simple case" reasoning as
|
||||
* OrderQueueWidget.tsx's own OrderSparkline.
|
||||
*/
|
||||
export function ProductGallery({ image, gallery, alt }: { image: string; gallery: string[]; alt: string }) {
|
||||
const slides = [image, ...gallery];
|
||||
const [current, setCurrent] = useState(0);
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
|
||||
if (slides.length <= 1) {
|
||||
return (
|
||||
<div className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base">
|
||||
<Image src={image} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function next() {
|
||||
setCurrent((c) => (c + 1) % slides.length);
|
||||
}
|
||||
function prev() {
|
||||
setCurrent((c) => (c - 1 + slides.length) % slides.length);
|
||||
}
|
||||
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
}
|
||||
function handleTouchEnd(e: React.TouchEvent) {
|
||||
if (touchStartX.current === null) return;
|
||||
const delta = e.changedTouches[0].clientX - touchStartX.current;
|
||||
touchStartX.current = null;
|
||||
if (delta <= -SWIPE_THRESHOLD_PX) next();
|
||||
else if (delta >= SWIPE_THRESHOLD_PX) prev();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3.5 w-full">
|
||||
<div
|
||||
className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base touch-pan-y"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<Image src={slides[current]} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
aria-label="Vorheriges Bild"
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path d="M15 19l-7-7 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
aria-label="Nächstes Bild"
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="absolute bottom-3.5 right-3.5 rounded-full bg-text-primary/60 px-2.5 py-1 text-label font-semibold text-white tabular-nums">
|
||||
{current + 1} / {slides.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
{slides.map((src, i) => (
|
||||
<button
|
||||
key={src + i}
|
||||
type="button"
|
||||
onClick={() => setCurrent(i)}
|
||||
aria-label={`Bild ${i + 1} anzeigen`}
|
||||
aria-current={i === current}
|
||||
className={`relative h-[4.75rem] w-[4.75rem] shrink-0 overflow-hidden rounded-sm border-2 transition-opacity ${
|
||||
i === current ? "border-brand opacity-100" : "border-transparent opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<Image src={src} alt="" fill sizes="76px" className="object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Every hand-written PDP headline styles a trailing "." in brand color
|
||||
// (e.g. der-eine/Pricing.tsx's "Der Eine<span className="text-brand">.</span>"),
|
||||
// matching how every product's own Payload `name` is actually written
|
||||
// ("Der Eine.", "Die Pause.", "Einfach anfangen."). Anywhere `product.name`
|
||||
// is rendered as plain text instead (cards, cart line items, the one
|
||||
// Payload-driven PDP hero) silently lost that styling — this centralizes
|
||||
// the same trailing-period split so it can't drift per call site again.
|
||||
export function ProductName({ name }: { name: string }) {
|
||||
if (!name.endsWith(".")) return <>{name}</>;
|
||||
return (
|
||||
<>
|
||||
{name.slice(0, -1)}
|
||||
<span className="text-brand">.</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,60 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { WishlistButton } from "./WishlistButton";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
|
||||
type LexicalTextNode = { type: "text"; text: string; format?: number };
|
||||
type LexicalNode = { type: string; text?: string; format?: number; children?: LexicalNode[] };
|
||||
|
||||
// Minimal Lexical JSON → JSX converter for spotlightText — deliberately NOT
|
||||
// the full block-oriented RichText.tsx (Posts/LegalPages' renderer, with
|
||||
// its own heading/paragraph spacing and image/quote blocks). This teaser
|
||||
// only ever needs paragraphs of plain text with optional bold spans
|
||||
// (Lexical's text `format` bitmask: 1 = bold), rendered inline to match
|
||||
// this section's own <p className="text-body ..."> treatment rather than
|
||||
// picking up article-level block styling.
|
||||
function renderInline(node: LexicalNode, key: number): ReactNode {
|
||||
if (node.type === "text") {
|
||||
const textNode = node as LexicalTextNode;
|
||||
const format = typeof textNode.format === "number" ? textNode.format : 0;
|
||||
const bold = (format & 1) === 1;
|
||||
// 2 = italic in Lexical's format bitmask — was silently dropped here,
|
||||
// so italic spans (e.g. Tasse "Die Pause"'s spotlightText) rendered as
|
||||
// plain text on the homepage despite showing correctly in the admin's
|
||||
// richText editor.
|
||||
const italic = (format & 2) === 2;
|
||||
let el: ReactNode = textNode.text;
|
||||
if (italic) el = <em>{el}</em>;
|
||||
if (bold) el = <strong>{el}</strong>;
|
||||
return <Fragment key={key}>{el}</Fragment>;
|
||||
}
|
||||
if (node.type === "linebreak") return <br key={key} />;
|
||||
// Any other inline node (link, etc.) — render its text children without
|
||||
// the wrapper itself; this teaser has no use for an actual <a> here.
|
||||
return (node.children ?? []).map((child, i) => renderInline(child, i));
|
||||
}
|
||||
|
||||
function SpotlightText({ content, fallback }: { content: unknown; fallback: string }) {
|
||||
const root = (content as { root?: { children?: LexicalNode[] } } | null)?.root;
|
||||
if (!root?.children?.length) {
|
||||
return <p className="text-body text-text-body whitespace-pre-line">{fallback}</p>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{root.children.map((paragraph, i) => (
|
||||
<p key={i} className="text-body text-text-body">
|
||||
{(paragraph.children ?? []).map((child, j) => renderInline(child, j))}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Product teaser for whichever product is marked `spotlight` in Payload
|
||||
* (defaults to none — the section just doesn't render until one is set),
|
||||
@@ -23,11 +72,12 @@ import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
* see Products.ts), not duplicated here as hardcoded literals.
|
||||
*/
|
||||
export async function ProductSpotlight() {
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
|
||||
getSpotlightProduct(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getWishlistEnabled(),
|
||||
]);
|
||||
if (!product) return null;
|
||||
|
||||
@@ -42,14 +92,22 @@ export async function ProductSpotlight() {
|
||||
// id="spotlight" — the Navbar's "Shop" link becomes an anchor to this
|
||||
// section instead of navigating to /shop whenever exactly 1 product is
|
||||
// active (see Navbar.tsx/layout.tsx).
|
||||
<section id="spotlight" className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
|
||||
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<section id="spotlight" className="w-full bg-bg-base py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
{/* Row layout only from lg (1024px) up — sm:flex-row used to kick in
|
||||
at 640px, but a 380px-wide image + text squeezed into the rest of
|
||||
a tablet-width viewport (768-1023px) read as cramped/too wide.
|
||||
Tablet now stays stacked like mobile, but max-w-[32rem] between sm
|
||||
and lg keeps that stacked card centered and narrower than the
|
||||
tablet viewport instead of stretching to fill it — true mobile
|
||||
(below sm) stays unconstrained since the viewport itself is
|
||||
already narrow there. */}
|
||||
<Reveal className="max-w-[75rem] sm:max-w-[32rem] lg:max-w-[75rem] mx-auto rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 sm:p-10">
|
||||
<div className="group relative w-full lg:w-[23.75rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 380px, 100vw"
|
||||
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
@@ -63,6 +121,9 @@ export async function ProductSpotlight() {
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{wishlistEnabled && product.spotlightShowWishlist && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
|
||||
@@ -73,9 +134,7 @@ export async function ProductSpotlight() {
|
||||
>
|
||||
{product.spotlightHeadline || product.name}
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
{product.spotlightText || product.description}
|
||||
</p>
|
||||
<SpotlightText content={product.spotlightText} fallback={product.descriptionText} />
|
||||
{/* MwSt./Versand disclosure on its own line, not crammed into
|
||||
the price row itself — same reasoning as todo-cards'
|
||||
Pricing.tsx (identical text, same narrow-column risk). */}
|
||||
@@ -86,10 +145,18 @@ export async function ProductSpotlight() {
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Single product, no grid siblings to stay equal-height with
|
||||
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
|
||||
@@ -106,7 +173,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} maxQty={product.maxQty} variants={product.variants} />
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { QuoteLabel } from "./QuoteLabel";
|
||||
|
||||
// Same visual language as RichText.tsx's Quote converter (Caveat script +
|
||||
// brand divider) — extracted here so PageBlocks.tsx (and any future hand-
|
||||
// written page) can reuse it instead of each page defining its own copy,
|
||||
// which is what /lebensuhr, /3x3-system, and /ueber-mich each did before
|
||||
// this existed. `label` was dropped in that extraction (this component had
|
||||
// no prop for it at all) — QuoteBlock.label was set in the admin but never
|
||||
// actually reached the page, unlike RichText.tsx's own local Quote, which
|
||||
// this now matches.
|
||||
export function Quote({ label, children }: { label?: string | null; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex items-start gap-6 w-full my-2">
|
||||
{label && <QuoteLabel label={label} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
<p className="text-text-primary text-[1.75rem] leading-[1.1] flex-1" style={{ fontFamily: "var(--font-caveat)" }}>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react";
|
||||
import type { TOCSection } from "./SectionTOC";
|
||||
import { QuoteLabel } from "./QuoteLabel";
|
||||
import { StepArrow } from "./StepArrow";
|
||||
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
|
||||
|
||||
// Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to
|
||||
// Payload's own official React renderer + custom JSXConverters — needed
|
||||
@@ -89,12 +92,29 @@ type ImageBlockFields = { image: MediaRef; caption?: string | null };
|
||||
type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] };
|
||||
type VideoEmbedBlockFields = { url: string; caption?: string | null };
|
||||
type QuoteBlockFields = { text: string; label?: string | null };
|
||||
type IconBlockFields = { icon: string };
|
||||
type PillListBlockFields = { items: { id?: string | null; label: string }[] };
|
||||
type ChecklistImageBlockFields = { image: MediaRef; items: { id?: string | null; text: string }[] };
|
||||
type TableBlockFields = { labelHeader: string; valueHeader: string; rows: { id?: string | null; label: string; value: string }[] };
|
||||
type StepRowBlockFields = {
|
||||
items: { id?: string | null; icon: string; title: string; subtitle?: string | null; description: string }[];
|
||||
};
|
||||
type CtaCardBlockFields = { eyebrow: string; title: string; description?: string | null; href: string };
|
||||
|
||||
function BlockCaption({ caption }: { caption?: string | null }) {
|
||||
if (!caption) return null;
|
||||
return <p className="text-body-sm text-text-muted text-center">{caption}</p>;
|
||||
}
|
||||
|
||||
// Same checkmark as PageBlocks.tsx's own — see that file's comment.
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Same visual treatment as the QuoteBlock converter below (and the native
|
||||
// blockquote case it replaces going forward) — see that converter's own
|
||||
// comment for why both still exist.
|
||||
@@ -160,6 +180,16 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</a>
|
||||
),
|
||||
// Lexical auto-detects a typed-out URL/email as its own "autolink" node,
|
||||
// distinct from an editor-inserted "link" node — falls through to
|
||||
// Payload's unstyled default converter without this, which is why the
|
||||
// Impressum/AGB's typed-in-place mailto addresses rendered as plain
|
||||
// black text instead of matching every editor-inserted link.
|
||||
autolink: ({ node, nodesToJSX }) => (
|
||||
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</a>
|
||||
),
|
||||
// Lexical's native blockquote feature — used by every post written
|
||||
// before Blocks existed. Kept working exactly as before (own comment on
|
||||
// Posts.ts's `content` field editor config on why this stays enabled
|
||||
@@ -240,6 +270,121 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
</Quote>
|
||||
);
|
||||
},
|
||||
// Same page-builder blocks as PageBlocks.tsx (Pages.layout) — see
|
||||
// that file's own comment on each block's visual treatment, mirrored
|
||||
// here field-for-field since it's the exact same Block config
|
||||
// registered a second time (Posts.content's BlocksFeature) so blog
|
||||
// posts can use them mid-article too. `testimonialsRef` is the one
|
||||
// page-builder block deliberately NOT included: it needs an async
|
||||
// data fetch, and this converter set is shared with LiveRichText's
|
||||
// client-side live-preview rendering (see blog's LivePostContent.tsx),
|
||||
// where an async component would break — PageBlocks.tsx only gets
|
||||
// away with it by awaiting outside the sync per-block switch, in its
|
||||
// own async server component.
|
||||
icon: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as IconBlockFields;
|
||||
const SymbolIcon = SYMBOLIC_ICONS[fields.icon];
|
||||
return SymbolIcon ? <div>{SymbolIcon()}</div> : null;
|
||||
},
|
||||
pillList: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as PillListBlockFields;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fields.items.map((item, i) => (
|
||||
<span key={item.id ?? i} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
checklistImage: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ChecklistImageBlockFields;
|
||||
const url = mediaUrl(fields.image);
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
|
||||
{url && (
|
||||
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
|
||||
<Image alt="" src={url} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 w-full flex flex-col gap-4">
|
||||
{fields.items.map((item, i) => (
|
||||
<li key={item.id ?? i} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{item.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
table: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as TableBlockFields;
|
||||
return (
|
||||
<div className="bg-bg-muted rounded-xl overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-brand">
|
||||
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.labelHeader}</th>
|
||||
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.valueHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.rows.map((row, i) => (
|
||||
<tr key={row.id ?? i} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
|
||||
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
|
||||
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
stepRow: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as StepRowBlockFields;
|
||||
const icons = fields.items.map((item) => STEP_ICONS[item.icon]);
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{fields.items.flatMap((item, i) => [
|
||||
<div key={item.id ?? i} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{icons[i]?.()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
|
||||
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
|
||||
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
</div>,
|
||||
i < fields.items.length - 1 ? (
|
||||
<div key={`arrow-${item.id ?? i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
ctaCard: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as CtaCardBlockFields;
|
||||
return (
|
||||
<Link
|
||||
href={fields.href}
|
||||
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">{fields.eyebrow}</p>
|
||||
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{fields.title}</p>
|
||||
{fields.description && <p className="text-body-sm text-text-muted">{fields.description}</p>}
|
||||
</div>
|
||||
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { SearchResult } from "../api/search/route";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
// Plain trigger button — no state of its own. `open`/`onOpen` are lifted to
|
||||
// Navbar (mirrors NewsletterModal's pattern) so SearchOverlay itself can be
|
||||
// rendered as a <header> *sibling* instead of a descendant. Rendering it
|
||||
// inside <header> put it under the header's conditional `backdrop-blur-md`
|
||||
// (applied once `scrolled` or `mobileOpen` is true), and per spec a
|
||||
// `backdrop-filter` makes its element a new containing block for
|
||||
// `position: fixed` descendants — the overlay's `fixed inset-0` then
|
||||
// resolved against the ~100px header instead of the viewport, clipping its
|
||||
// opaque background to that band while the input/results overflowed past
|
||||
// it, letting the page content underneath show through.
|
||||
export function SearchButton({ onOpen }: { onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-label="Suche öffnen"
|
||||
className="flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (query.trim().length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query.trim())}`, { cache: "no-store" });
|
||||
const data: { results?: SearchResult[] } = await res.json().catch(() => ({ results: [] }));
|
||||
setResults(data.results ?? []);
|
||||
setLoading(false);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const products = results.filter((r) => r.type === "product");
|
||||
const posts = results.filter((r) => r.type === "post");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center bg-bg-base/95 backdrop-blur-sm pt-[15vh] px-[var(--layout-padding-x)]" onClick={onClose}>
|
||||
<div className="w-full max-w-[36rem]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 border-b-2 border-border focus-within:border-brand transition-colors pb-3">
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-muted shrink-0" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Produkte, Blogbeiträge…"
|
||||
className="flex-1 min-w-0 bg-transparent outline-none text-h4 text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
{/* X icon, not the old "Esc" text — the keyboard shortcut still
|
||||
works (see the Escape keydown handler above), this button is
|
||||
just the mouse/touch affordance, and a close icon reads
|
||||
faster than a text label at a glance. */}
|
||||
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 h-8 w-8 flex items-center justify-center text-text-muted hover:text-brand transition-colors">
|
||||
<svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" aria-hidden="true">
|
||||
<path d="M2 2L14 14M14 2L2 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 max-h-[55vh] overflow-y-auto">
|
||||
{loading && <p className="text-body-sm text-text-muted">Suche…</p>}
|
||||
{!loading && query.trim().length >= 2 && results.length === 0 && (
|
||||
<p className="text-body-sm text-text-muted">Keine Treffer für „{query}“.</p>
|
||||
)}
|
||||
|
||||
{products.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Produkte</p>
|
||||
{products.map((r) => (
|
||||
<SearchResultRow key={r.id} result={r} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Blog</p>
|
||||
{posts.map((r) => (
|
||||
<SearchResultRow key={r.id} result={r} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultRow({ result, onClose }: { result: SearchResult; onClose: () => void }) {
|
||||
return (
|
||||
<Link
|
||||
href={result.href}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-3 p-2 rounded-sm hover:bg-bg-muted transition-colors"
|
||||
>
|
||||
<div className="relative h-12 w-12 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
|
||||
{result.thumbnail && <Image alt="" src={result.thumbnail} fill sizes="48px" className="object-cover" />}
|
||||
</div>
|
||||
<span className="text-body text-text-primary">{result.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -61,8 +61,9 @@ function useActiveSection(sections: TOCSection[]) {
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
// even the site's 640px structural floor, so sm: wouldn't leave room for
|
||||
// a real 2-column split at Tablet widths — a deliberate exception to the
|
||||
// site-wide sm: consolidation, not a leftover of it.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
|
||||
@@ -15,7 +15,7 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
|
||||
if (testimonials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
|
||||
<Reveal
|
||||
className="font-semibold text-h-emphasis text-text-primary text-center"
|
||||
@@ -24,11 +24,11 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
|
||||
{testimonials.map((t) => (
|
||||
<RevealItem
|
||||
key={t.id}
|
||||
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
className="group relative sm:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
|
||||
+30
-20
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
import { ArrowRightIcon } from "./ArrowRightIcon";
|
||||
import { getWerkzeugeCards } from "../lib/payload";
|
||||
|
||||
// Content now lives in Payload (WerkzeugeCards collection). Icons use a
|
||||
@@ -20,33 +21,45 @@ export async function Tools() {
|
||||
{/* Section header */}
|
||||
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
|
||||
<p className="font-bold text-brand text-h-small">
|
||||
Meine Werkzeuge
|
||||
Werkzeuge
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-text-primary text-h-section"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Werkzeuge für einen leichteren Alltag.
|
||||
Für mehr Orientierung im Alltag
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Tools grid — 3 cols md+, stacked below md; internal icon+text layout
|
||||
stays horizontal at every size, only the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{/* Tools grid — 3 cols sm+ (moved down from the old md: so the grid
|
||||
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 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}
|
||||
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
// Icon above text below lg (1024px) — a 3-up grid from sm
|
||||
// (640px) leaves each card too narrow for icon+text side by
|
||||
// side through the whole 640-1023px range. Centered on true
|
||||
// mobile (matches the rest of this stacked-card pattern
|
||||
// site-wide), left-aligned from sm up once there's a real
|
||||
// single-column card width to left-align within.
|
||||
className="sm:col-span-4 flex flex-col items-center text-center gap-4 sm:items-start sm:text-left lg:flex-row lg:gap-8 rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon — uniform box, pre-flipped/rotated source asset.
|
||||
size-14 (56px) is a fixed value at every width (14 isn't
|
||||
one of this project's fluid spacing-scale steps) — smaller
|
||||
below md: so it doesn't dwarf the title/description text,
|
||||
below lg: so it doesn't dwarf the title/description text,
|
||||
which does shrink toward its own fluid floor there. Full
|
||||
56px only from lg: up — at md: (Tablet, where this grid
|
||||
already switches to 3-up) the title/description are still
|
||||
fairly close to their own fluid floor, so the full-size
|
||||
icon read as too big next to them too. */}
|
||||
56px only from lg: up — a deliberate exception to the
|
||||
site's sm: (640px) structural consolidation: the grid now
|
||||
switches to 3-up at sm:, but title/description are still
|
||||
fairly close to their own fluid floor through the whole
|
||||
640-1023px range, so the full-size icon still reads too big
|
||||
next to them there. Not re-verified visually — narrower
|
||||
exception kept as-is, same reasoning as Hero's content
|
||||
sizing. */}
|
||||
<div className="relative flex items-center justify-center shrink-0 size-11 lg:size-14">
|
||||
<Image alt="" src={tool.icon} fill sizes="(min-width: 1024px) 56px, 44px" className="object-contain" />
|
||||
</div>
|
||||
@@ -63,8 +76,8 @@ export async function Tools() {
|
||||
minimum text→CTA gap even for the tallest card (the one
|
||||
that defines the row height, and so has ~zero leftover
|
||||
space for justify-between to distribute on its own). */}
|
||||
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-start gap-4 min-w-0 text-text-primary [word-break:break-word]">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-center text-center sm:items-start sm:text-left gap-4 min-w-0 text-text-primary [word-break:break-word]">
|
||||
<div className="flex flex-col gap-4 items-center sm:items-start w-full">
|
||||
<p
|
||||
className="font-semibold leading-normal text-h-section w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -75,17 +88,14 @@ export async function Tools() {
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
{/* flex items-center + arrow as its own span, not inline text
|
||||
— the → glyph sits low relative to the surrounding text's
|
||||
cap-height in the font used here, off-center against the
|
||||
label if it's just part of the same text node (fixed
|
||||
2026-07-24, same pattern ProductGrid.tsx's "Mehr
|
||||
erfahren" link already uses). */}
|
||||
{/* SVG arrow, not a Unicode "→" character — see
|
||||
ArrowRightIcon.tsx's own comment on why (font-fallback
|
||||
vertical-metrics mismatch, platform-dependent). */}
|
||||
<Link
|
||||
href={tool.ctaHref}
|
||||
className="flex items-center gap-1 font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>→</span>
|
||||
<ArrowRightIcon />
|
||||
<span>{tool.ctaLabel}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+49
-12
@@ -11,21 +11,58 @@ export async function TrustRow() {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-start md:items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-6 md:gap-12">
|
||||
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
<div className="w-full bg-bg-base py-8 px-[var(--layout-padding-x)]">
|
||||
{/* Below lg (1024px): a CSS Grid with `grid-template-rows: subgrid` —
|
||||
not a fixed min-height guess — so every badge's "icon+title" box
|
||||
shares the SAME row track height (auto-sized to whichever
|
||||
badge's title actually needs 2 lines), and every description
|
||||
starts exactly at that row's bottom edge regardless of how many
|
||||
lines its own title happens to wrap to. Icon beside text (the
|
||||
lg: layout below) is what made a full row of 3 badges too wide
|
||||
below ~1024px in the first place (see git history: originally
|
||||
lg:-gated for exactly this, then briefly tried flex-wrap, which
|
||||
put an odd 3rd item alone on its own wrapped line and read as
|
||||
disorganized) — icon above text instead shrinks each badge down
|
||||
to just its text column's width, letting a real row of 3 fit
|
||||
without wrapping at all. Two structurally different layouts
|
||||
(icon-above-title here vs. icon-beside-a-title/description-
|
||||
stack at lg:) don't share one flexible markup shape cleanly, so
|
||||
this renders as two separate blocks (lg:hidden / hidden lg:flex)
|
||||
rather than fighting one shape across both breakpoints. */}
|
||||
<div
|
||||
className="lg:hidden grid justify-center gap-x-6 gap-y-1"
|
||||
style={{ gridTemplateColumns: `repeat(${items.length}, auto)`, gridTemplateRows: "repeat(2, auto)" }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid row-span-2 justify-items-center" style={{ gridTemplateRows: "subgrid" }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<p className="font-semibold text-body text-text-primary text-center">{item.title}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
<p className="text-body-sm text-text-muted text-center">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* lg+: original icon-beside-text row with dividers, unchanged. */}
|
||||
<div className="hidden lg:flex justify-center gap-12 items-center">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-12">
|
||||
{i > 0 && <div className="h-10 w-px bg-border" />}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
|
||||
// Heart-toggle for a product card/detail page. Login-gated (unlike the
|
||||
// cart, which works for guests) — a logged-out click redirects to
|
||||
// /konto/login?redirect=<back-here> instead of silently failing, since
|
||||
// there's no local-storage fallback that would make sense for a wishlist
|
||||
// (see useWishlist.ts's own comment on why this can't reuse cart.ts's
|
||||
// guest-friendly pattern).
|
||||
export function WishlistButton({
|
||||
productId,
|
||||
variant = "",
|
||||
className = "",
|
||||
revealOnHover = false,
|
||||
}: {
|
||||
productId: number;
|
||||
variant?: string;
|
||||
className?: string;
|
||||
/** false (default): always visible — right for /konto/merkliste (every
|
||||
* card there is already-wishlisted, so hover-reveal would be pointless)
|
||||
* and the product detail page. true: invisible until the card is
|
||||
* hovered/focused, unless the product is already wishlisted (a filled
|
||||
* heart stays as a permanent status indicator) — right for any grid or
|
||||
* card-like feature (ProductGrid.tsx, ProductSpotlight.tsx) where a
|
||||
* heart sitting there unprompted reads as visual noise. Relies on the
|
||||
* parent card already carrying `group`/`focus-within` (see
|
||||
* ProductGrid.tsx/ProductSpotlight.tsx).
|
||||
*
|
||||
* On coarse-pointer (touch) devices, hover-reveal can't work at all —
|
||||
* touch has no persistent `:hover`, so a tap on the card never reliably
|
||||
* reveals a `group-hover`-gated element the way a mouse hover does.
|
||||
* Rather than falling back to "always visible at full size" (which
|
||||
* recreates the exact "a heart on every card" visual noise this mode
|
||||
* exists to avoid), touch devices get a smaller variant with a lighter
|
||||
* (not removed — fully transparent made it invisible against some
|
||||
* product photos) background instead — present and tappable everywhere,
|
||||
* but visually quieter than the full-size default. Applies to
|
||||
* already-wishlisted hearts too (not just the hover-revealed ones) —
|
||||
* consistent sizing across every heart on a touch device, rather than
|
||||
* only the not-yet-wishlisted ones shrinking. This smaller touch sizing
|
||||
* is applied regardless of `revealOnHover` (see the className below) —
|
||||
* a heart reading visually "quieter" on a touch screen is a general
|
||||
* touch-device trait, not specific to the multi-card-grid use case it
|
||||
* was first built for. */
|
||||
revealOnHover?: boolean;
|
||||
}) {
|
||||
const { isWishlisted, toggle } = useWishlist();
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
const wishlisted = isWishlisted(productId, variant);
|
||||
|
||||
async function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (pending) return;
|
||||
|
||||
const res = await fetch("/api/account/wishlist", { method: "GET", cache: "no-store" });
|
||||
if (res.status === 401) {
|
||||
router.push(`/konto/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
await toggle(productId, variant);
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"}
|
||||
aria-pressed={wishlisted}
|
||||
disabled={pending}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-all active:scale-90 disabled:opacity-60 pointer-coarse:h-7 pointer-coarse:w-7 pointer-coarse:bg-bg-base/60 ${
|
||||
revealOnHover && !wishlisted
|
||||
? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
|
||||
: ""
|
||||
} ${className}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="18"
|
||||
viewBox="0 0 20 18"
|
||||
fill={wishlisted ? "currentColor" : "none"}
|
||||
className={`${wishlisted ? "text-brand" : "text-text-primary"} pointer-coarse:scale-75`}
|
||||
>
|
||||
<path
|
||||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Shared registry of the site's hand-drawn brand-line icons, used by
|
||||
// StepArrow-connected icon rows (/lebensuhr's phase row, /3x3-system's
|
||||
// category row, /7-tage-klarheits-check's "So funktioniert"). Previously
|
||||
// each page defined its own copy of these locally — consolidated here so
|
||||
// the Payload page-builder's `stepRow` block can select one by a fixed
|
||||
// key (see PageBlocks.tsx), and so the icons aren't duplicated per page
|
||||
// anymore. The Payload StepRowBlock's `icon` select options MUST stay in
|
||||
// sync with STEP_ICON_KEYS below by hand — no automatic sync between the
|
||||
// two repos.
|
||||
|
||||
function IconSunrise() {
|
||||
return (
|
||||
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
|
||||
<line x1="4" y1="30" x2="40" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M13 30a9 9 0 0 1 18 0" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="22" y1="4" x2="22" y2="10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="10" x2="13" y2="14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="35" y1="10" x2="31" y2="14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconGrowthBars() {
|
||||
return (
|
||||
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
|
||||
<line x1="4" y1="36" x2="40" y2="36" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<rect x="7" y="25" width="7" height="11" rx="1" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="18.5" y="16" width="7" height="20" rx="1" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="30" y="6" width="7" height="30" rx="1" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSunHigh() {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="8" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="20" y1="2" x2="20" y2="7" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="20" y1="33" x2="20" y2="38" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="20" x2="7" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="33" y1="20" x2="38" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="7" y1="7" x2="10.5" y2="10.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="29.5" y1="29.5" x2="33" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="33" y1="7" x2="29.5" y2="10.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="10.5" y1="29.5" x2="7" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEnvelope() {
|
||||
return (
|
||||
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
|
||||
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M2 2l24 22L50 2" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMailLines() {
|
||||
return (
|
||||
<svg width="56" height="44" viewBox="0 0 56 44" fill="none">
|
||||
<line x1="2" y1="12" x2="12" y2="12" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="20" x2="9" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="28" x2="7" y2="28" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<rect x="13" y="2" width="41" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M13 2l20.5 19L54 2" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconNotepad() {
|
||||
return (
|
||||
<svg width="46" height="52" viewBox="0 0 46 52" fill="none">
|
||||
<rect x="2" y="4" width="32" height="42" rx="2" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="9" y1="16" x2="27" y2="16" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="23" x2="24" y2="23" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="30" x2="20" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="37" cy="40" r="7" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="37" y1="27" x2="37" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCheckCircle() {
|
||||
return (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M14 24l7.5 7.5L34 16" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTarget() {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="16" stroke="#f6a701" strokeWidth="2" />
|
||||
<circle cx="20" cy="20" r="9" stroke="#f6a701" strokeWidth="2" />
|
||||
<circle cx="20" cy="20" r="2" fill="#f6a701" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTrendingUp() {
|
||||
return (
|
||||
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
|
||||
<path d="M4 30l10-10 6 6 14-14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M24 10h10v10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconShield() {
|
||||
return (
|
||||
<svg width="36" height="40" viewBox="0 0 36 40" fill="none">
|
||||
<path d="M18 3l14 5v9c0 9-6 16-14 20-8-4-14-11-14-20v-9l14-5z" stroke="#f6a701" strokeWidth="2" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Keys MUST match the Payload StepRowBlock's `icon` select field options
|
||||
// (docker/payload/src/blocks/StepRowBlock.ts) exactly.
|
||||
export const STEP_ICONS: Record<string, () => React.ReactNode> = {
|
||||
sunrise: IconSunrise,
|
||||
growthBars: IconGrowthBars,
|
||||
sunHigh: IconSunHigh,
|
||||
envelope: IconEnvelope,
|
||||
mailLines: IconMailLines,
|
||||
notepad: IconNotepad,
|
||||
checkCircle: IconCheckCircle,
|
||||
target: IconTarget,
|
||||
trendingUp: IconTrendingUp,
|
||||
shield: IconShield,
|
||||
};
|
||||
|
||||
// Same symbolic clock icon /lebensuhr uses above its "24 Stunden" heading
|
||||
// — purely decorative (no numbers/data), unlike the STEP_ICONS above which
|
||||
// always sit inside an icon-above-text row. Keys MUST match the Payload
|
||||
// IconBlock's `icon` select field options (docker/payload/src/blocks/
|
||||
// IconBlock.ts) exactly.
|
||||
function IconClockSymbol() {
|
||||
return (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
|
||||
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const x1 = 24 + 16 * Math.sin(rad), y1 = 24 - 16 * Math.cos(rad);
|
||||
const x2 = 24 + 18.5 * Math.sin(rad), y2 = 24 - 18.5 * Math.cos(rad);
|
||||
return <line key={deg} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#f6a701" strokeWidth="1.5" strokeLinecap="round" />;
|
||||
})}
|
||||
<line x1="24" y1="24" x2="17" y2="15" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="24" y1="24" x2="32" y2="15" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="24" cy="24" r="1.8" fill="#f6a701" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const SYMBOLIC_ICONS: Record<string, () => React.ReactNode> = {
|
||||
clockSymbol: IconClockSymbol,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { headingId } from "../../components/RichText";
|
||||
import type { CompanySettings } from "../../lib/payload";
|
||||
import type { TOCSection } from "../../components/SectionTOC";
|
||||
|
||||
// Renders "1. Verantwortlicher" straight from company-settings, same
|
||||
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx — this
|
||||
// used to be hand-typed name/address/email baked into the Datenschutz
|
||||
// richText (seed-datenschutz.ts on the Payload side), silently out of
|
||||
// date the moment company-settings changed without a matching manual
|
||||
// edit here too. The heading itself stays numbered "1." (headingId's own
|
||||
// `^(\d+)\.` match turns that into "section-1", exactly the id this
|
||||
// section's TOC entry already had before the RichText stopped rendering
|
||||
// it — no anchor breakage).
|
||||
export function verantwortlicherHeadings(): TOCSection[] {
|
||||
return [{ id: headingId("1. Verantwortlicher"), title: "1. Verantwortlicher" }];
|
||||
}
|
||||
|
||||
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 VerantwortlicherBlock({ seller }: { seller: CompanySettings }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Heading>1. Verantwortlicher</Heading>
|
||||
<P>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</P>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>{seller.managingDirector || seller.sellerName}</P>
|
||||
<P>{seller.sellerStreet}</P>
|
||||
<P>
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<P>
|
||||
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
|
||||
</P>
|
||||
</div>
|
||||
<P>Ein gesetzlich vorgeschriebener Datenschutzbeauftragter ist für unser Unternehmen aufgrund seiner Größe derzeit nicht erforderlich.</P>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { Footer } from "../components/Footer";
|
||||
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 { VerantwortlicherBlock, verantwortlicherHeadings } from "./components/VerantwortlicherBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Datenschutzerklärung",
|
||||
@@ -17,8 +19,11 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function DatenschutzPage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const page = await getLegalPage("datenschutz", { draft: isPreview });
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
const [page, seller] = await Promise.all([getLegalPage("datenschutz", { draft: isPreview }), getCompanySettings()]);
|
||||
// "1. Verantwortlicher" headings first — that block renders above the
|
||||
// CMS content below (same reasoning as Impressum's own headings
|
||||
// composition, see AnbieterAngaben.tsx).
|
||||
const headings = [...verantwortlicherHeadings(), ...(page ? extractHeadings(page.content) : [])];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -35,7 +40,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
|
||||
@@ -68,7 +73,11 @@ export default async function DatenschutzPage() {
|
||||
</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">
|
||||
{/* Name/Adresse/E-Mail kommen direkt aus company-settings, nicht
|
||||
aus der CMS-Richtext unten — single-sourced, gleiche
|
||||
Begründung wie Impressum's AnbieterAngaben.tsx. */}
|
||||
{seller && <VerantwortlicherBlock seller={seller} />}
|
||||
{page ? (
|
||||
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
|
||||
// No circle badge, thicker uniform-weight black stroke, no fill — matching
|
||||
// the site's established illustrative icon language (public/icon-trust-*.png,
|
||||
// public/icon-step-*.png): bold outline glyphs standing on their own, not
|
||||
// boxed into a badge shape. Replaces the earlier thin-stroke circled
|
||||
// version, which didn't match anything else on the site.
|
||||
function IconBadge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" stroke="#231f20" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="h-16 w-auto shrink-0">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMetal() {
|
||||
// Pen/nib glyph — stands in for the metal body itself.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M10 22l2.5-0.8L23 10.7l-2.7-2.7L9.8 19.5 9 22z" />
|
||||
<path d="M19 9l3 3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSparkle() {
|
||||
// Engraving/laser mark — four-point sparkle, same glyph as before, just
|
||||
// heavier stroke and unboxed to match the rest of the set.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M16 7v5M16 20v5M7 16h5M20 16h5M10.5 10.5l3 3M18.5 18.5l3 3M21.5 10.5l-3 3M13.5 18.5l-3 3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCompact() {
|
||||
// Four corner brackets pointing inward — standard "minimize/fits
|
||||
// anywhere" glyph, reads as compact more directly than a shield ever did.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M11 8H8v3M21 8h3v3M11 24H8v-3M21 24h3v-3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEveryday() {
|
||||
// Sun — everyday/daily use.
|
||||
return (
|
||||
<IconBadge>
|
||||
<circle cx="16" cy="16" r="6" />
|
||||
<path d="M16 4v3M16 25v3M4 16h3M25 16h3M7.5 7.5l2 2M22.5 22.5l2 2M24.5 7.5l-2 2M9.5 22.5l-2 2" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
// "Was kann er?" section's full four-item breakdown (build, engraving,
|
||||
// size, use case). Same card shape as todo-cards/components/HowItWorks.tsx
|
||||
// — icon on top, centered, RevealGroup/RevealItem stagger, identical
|
||||
// title/description typography — reused for visual consistency with the
|
||||
// rest of the site rather than a one-off layout. 4 cards instead of
|
||||
// HowItWorks' 3 numbered steps, no connecting arrows (independent facts,
|
||||
// not a sequence).
|
||||
const items = [
|
||||
{ icon: IconMetal, title: "Metallgehäuse", desc: "Robust, wertig und angenehm in der Hand." },
|
||||
{ icon: IconSparkle, title: "Lasergravur", desc: "„Der Eine.“ auf der einen Seite. „einfach produktiv.“ auf der anderen." },
|
||||
{ icon: IconCompact, title: "Kompakt", desc: "Passt ans Notizbuch, auf den Schreibtisch oder in die Tasche." },
|
||||
{ icon: IconEveryday, title: "Für jeden Tag", desc: "Für Notizen, Gedanken, Listen und alles, was du lieber aufschreibst, bevor du es wieder vergisst." },
|
||||
];
|
||||
|
||||
export function Focus() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center mb-10">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Was kann er?
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-[42rem] w-full text-left md:text-center">
|
||||
Schreiben. Und das ziemlich gut. Metallgehäuse, angenehmes Gewicht und eine klassische Kugelschreibermine — kein besonderes Produktivitäts-Gadget, sondern einfach ein guter Stift, den du gern zur Hand nimmst.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-start sm:items-start w-full max-w-[75rem] mx-auto">
|
||||
{items.map(({ icon: Icon, title, desc }) => (
|
||||
<RevealItem key={title} className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none mx-auto">
|
||||
<Icon />
|
||||
<p className="font-semibold text-body text-text-primary">{title}</p>
|
||||
<p className="text-body-sm text-text-primary w-full text-left md:text-center">{desc}</p>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { ProductGallery } from "../../components/ProductGallery";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { ProductName } from "../../components/ProductName";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
|
||||
// The one bullet list on this page — after feedback that three separate
|
||||
// lists across Hero/Focus/Pricing was too much, this is the single place
|
||||
// features get enumerated, everywhere else stays prose. A subset of the
|
||||
// "Was kann er?" section's four cards (Focus.tsx) — "Für jeden Tag" is a
|
||||
// use case, not a build fact, so it's covered by the body copy instead.
|
||||
const features = ["Massives Metallgehäuse", "Lasergravur: „Der Eine.“ & „einfach produktiv.“", "Kompakt genug für jede Tasche"];
|
||||
|
||||
// Same fix/reasoning as TodoKartenHero.tsx's IconCheck — icon-check.svg's
|
||||
// fill can't be recolored from outside the SVG when loaded via <img
|
||||
// src>/next/image, so this stays an inline stroke path per page.
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Same "compact early teaser + delivery-time repeated next to every buy
|
||||
// button" reasoning as TodoKartenHero.tsx.
|
||||
export async function Hero() {
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
|
||||
const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null;
|
||||
const anyLowStock = product
|
||||
? product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">{product ? <ProductName name={product.name} /> : "Der Eine."}</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<p
|
||||
className="font-semibold text-h-page text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
Der Eine<span className="text-brand">.</span>
|
||||
</p>
|
||||
{product?.subline && (
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.subline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-body text-text-body">
|
||||
Manchmal reicht ein Stift und ein Stück Papier. Der Eine. ist ein schlichter Kugelschreiber aus Metall — angenehm in der Hand, klein genug für die Tasche und gemacht für alles, was kurz raus aus dem Kopf und irgendwo hin muss.
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{features.map((item) => (
|
||||
<li key={item} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 text-body text-text-primary">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
{product && (
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<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">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!product?.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
</div>
|
||||
|
||||
{product && (
|
||||
<AddToCartButton
|
||||
label="Der Eine. bestellen"
|
||||
productId={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* max-w cap, not the full lg:col-span-7 width — the source photos
|
||||
are only 338×338px (checked directly against the Payload media
|
||||
API, no larger size exists), so letting ProductGallery stretch
|
||||
to the full column blows them up hard. Capping the display
|
||||
width keeps the upscale factor small enough that it doesn't
|
||||
look broken; it can't fix the underlying resolution, only hide
|
||||
it. Real fix is new source photos, not a layout tweak. */}
|
||||
{product && product.gallery.length > 0 && (
|
||||
<Reveal className="order-2 lg:order-none lg:col-span-7 flex lg:items-center px-[var(--layout-padding-x)] lg:px-0" delay={0.15}>
|
||||
<div className="w-full max-w-[28rem] mx-auto lg:mx-0">
|
||||
<ProductGallery image={product.image} gallery={product.gallery} alt={product.name} />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug } from "../../lib/payload";
|
||||
|
||||
// Replaces the page's final price/buy bar entirely (explicit choice —
|
||||
// Hero.tsx already has the primary buy CTA, this page doesn't need a
|
||||
// second one at the very bottom too). Same "Passend dazu" card markup as
|
||||
// blog/[slug]/page.tsx's own related-product card. Driven by
|
||||
// Products.relatedProduct (2026-08-25, same field/pattern as
|
||||
// Posts.relatedProduct, just product->product instead of post->product) —
|
||||
// reusable on any product page by passing that page's own product slug,
|
||||
// not hardcoded to "todo-karten" like the first version of this component.
|
||||
export async function PasstDazu({ productSlug }: { productSlug: string }) {
|
||||
const product = await getProductBySlug(productSlug);
|
||||
const related = product?.relatedProduct;
|
||||
if (!related?.href) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-12 sm:py-16">
|
||||
<Reveal className="max-w-[48rem] mx-auto">
|
||||
<Link
|
||||
href={related.href}
|
||||
className="group flex items-center gap-4 sm:gap-6 border border-border rounded-md px-5 py-5 sm:px-9 sm:py-7 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image alt="" src={related.image} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passt dazu:</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
|
||||
<div className="flex flex-col gap-2 items-start w-full sm:w-[19rem] sm:shrink-0">
|
||||
<p
|
||||
className="font-semibold text-[1.375rem] text-text-primary sm:whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{related.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{related.descriptionText}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap mt-1 sm:mt-0">
|
||||
Entdecken
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
|
||||
// Closing buy banner — same image+price+button card as todo-cards' and
|
||||
// tasse-die-pause's own Pricing.tsx, repeated here as a second, final
|
||||
// call-to-action after Testimonials so a visitor who scrolled past the
|
||||
// Hero's buy button without ordering gets another prompt right before the
|
||||
// footer.
|
||||
export async function Pricing() {
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
if (!product) return null;
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
|
||||
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
|
||||
<div className="relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="(min-width: 1024px) 410px, 100vw" className="object-cover" />
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Der Eine<span className="text-brand">.</span> jetzt sichern
|
||||
</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Ein guter Stift muss nicht kompliziert sein. Hol dir Der Eine. und hab immer etwas zur Hand, wenn dir gerade etwas durch den Kopf geht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<AddToCartButton
|
||||
label="Der Eine. bestellen"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
productId={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { draftMode } from "next/headers";
|
||||
import { TestimonialsGrid } from "../../components/TestimonialsGrid";
|
||||
import { LiveTestimonialsGrid } from "../../components/LiveTestimonialsGrid";
|
||||
import { getTestimonials } from "../../lib/payload";
|
||||
|
||||
// Payload-driven like todo-cards/newsletter/challenge's own testimonial
|
||||
// grids (see Testimonials collection's "page" field) — no longer a
|
||||
// hardcoded array now that "der-eine" is a valid page value there.
|
||||
export async function Testimonials() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const testimonials = await getTestimonials("der-eine", { draft: isPreview });
|
||||
|
||||
return isPreview ? (
|
||||
<LiveTestimonialsGrid testimonials={testimonials} />
|
||||
) : (
|
||||
<TestimonialsGrid testimonials={testimonials} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Hero } from "./components/Hero";
|
||||
import { Focus } from "./components/Focus";
|
||||
import { PasstDazu } from "./components/PasstDazu";
|
||||
import { Testimonials } from "./components/Testimonials";
|
||||
import { Pricing } from "./components/Pricing";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "Der Eine. – Für die eine Sache, die gerade zählt.";
|
||||
|
||||
// Description reads from the live product instead of a separately
|
||||
// hardcoded string, unlike this page's own `title` — matches
|
||||
// /tasse-die-pause's own pattern (see that page's comment). Two
|
||||
// hand-maintained copies of the same text used to drift apart: this one
|
||||
// used to say something different from Products.description (which feeds
|
||||
// the cart/checkout/JSON-LD elsewhere) — same field, one source now.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const product = await getProductBySlug("stift-kugelschreiber");
|
||||
const description = product?.descriptionText || undefined;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: "/der-eine" },
|
||||
openGraph: { title, description, url: "/der-eine" },
|
||||
twitter: { title, description },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DerEinePage() {
|
||||
const [product, seller] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
const productSchema = product
|
||||
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-eine", seller)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{productSchema && (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
)}
|
||||
<main className="flex flex-col flex-1">
|
||||
<Hero />
|
||||
<Focus />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
<PasstDazu productSlug="stift-kugelschreiber" />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
// No product photo exists for this page (unlike /todo-cards, /lebensuhr) —
|
||||
// this is a free monthly ritual, not something with packaging to shoot.
|
||||
// Text + a Caveat-font quote carries the hero instead, same treatment
|
||||
// RichText.tsx's Quote component already uses for a callout line.
|
||||
export function DieSiebenHero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full">
|
||||
<Reveal className="flex flex-col gap-6 items-start px-[var(--layout-padding-x)] pt-10 pb-12 sm:pt-12 sm:pb-16 max-w-[46rem] mx-auto text-center sm:items-center">
|
||||
{/* Breadcrumb — left-aligned regardless of the centered content
|
||||
below, same "not part of the centered block" split as
|
||||
/todo-cards's and /lebensuhr's own hero. */}
|
||||
<p className="self-start flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Die Sieben</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 items-center">
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Die Sieben<span className="text-brand">.</span>
|
||||
</p>
|
||||
<p className="font-semibold text-h4 text-text-muted" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Das Monatsritual von einfach produktiv.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-body text-text-body max-w-[32rem]">
|
||||
Sieben kleine Dinge, die den Monat ein bisschen reicher machen.
|
||||
</p>
|
||||
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.2] mt-2"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
„Nicht alles, was zählt, steht auf einer ToDo-Liste.“
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
export function Philosophie() {
|
||||
return (
|
||||
<section className="w-full bg-bg-muted">
|
||||
<Reveal className="flex flex-col gap-6 items-start px-[var(--layout-padding-x)] py-14 sm:py-16 max-w-[42rem] mx-auto">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Worum es geht
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Am Ende eines Monats weiß ich meistens ziemlich genau, was ich erledigt habe. Rechnungen
|
||||
raus, Elternabend überstanden, drei Umzugskartons endlich ausgepackt, die seit Ostern im
|
||||
Flur standen. Was ich in dieser Zeit eigentlich erlebt habe, kann ich dagegen kaum noch
|
||||
erzählen. Irgendwann saß ich abends auf dem Sofa und mir fiel nichts ein, worüber ich
|
||||
mich in den letzten vier Wochen wirklich gefreut hatte. Nicht, weil nichts Schönes
|
||||
passiert wäre. Sondern weil ich es im Vorbeigehen nicht bemerkt hatte.
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Daraus ist Die Sieben entstanden. Jeden Monat gibt es sieben kleine Einladungen – nichts
|
||||
Kompliziertes, kein Kurs, keine App, die man täglich öffnen muss. Mal ist es die Idee,
|
||||
jemandem eine Sprachnachricht zu schicken statt einer Textnachricht. Mal geht es darum,
|
||||
einmal in der Woche ohne Handy zu frühstücken, oder einen Ort in der eigenen Stadt zu
|
||||
besuchen, an dem man noch nie war. Klein genug, dass man es wirklich macht. Konkret genug,
|
||||
dass es nicht bei der guten Absicht steckenbleibt.
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Man nimmt sich, was gerade passt. Eine Einladung, drei oder alle sieben – es gibt kein
|
||||
Richtig und kein Falsch dabei, keine Haken, die gesetzt werden müssen. Was nicht passt,
|
||||
lässt man liegen, ohne schlechtes Gewissen. Die Sieben will nicht, dass du produktiver
|
||||
wirst. Sie will, dass du am Ende des Monats mehr zu erzählen hast als nur, was fertig
|
||||
geworden ist.
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-body text-text-primary border-l-2 border-brand pl-4"
|
||||
>
|
||||
Es geht nicht darum, möglichst viel zu erleben, sondern die Dinge bewusster
|
||||
wahrzunehmen, die ohnehin schon da sind.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Fragment } from "react";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { StepArrow } from "../../components/StepArrow";
|
||||
|
||||
// Hand-drawn inline SVGs, brand-orange stroke — same convention as
|
||||
// /lebensuhr's own icon set (IconEnvelope etc.), used here instead of a
|
||||
// photo since there's no product to shoot for a free monthly ritual.
|
||||
function IconHand() {
|
||||
return (
|
||||
<svg width="55" height="48" viewBox="0 0 60 52" fill="none">
|
||||
<rect x="38" y="12" width="6" height="18" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="30" y="6" width="6" height="24" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="22" y="10" width="6" height="20" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="14" y="16" width="6" height="14" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="12" y="28" width="34" height="18" rx="9" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="2" y="30" width="14" height="8" rx="4" stroke="#f6a701" strokeWidth="2" transform="rotate(-25 9 34)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEye() {
|
||||
return (
|
||||
<svg width="52" height="34" viewBox="0 0 52 34" fill="none">
|
||||
<path
|
||||
d="M2 17S12 2 26 2s24 15 24 15-10 15-24 15S2 17 2 17Z"
|
||||
stroke="#f6a701"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="26" cy="17" r="7" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconShare() {
|
||||
return (
|
||||
<svg width="44" height="48" viewBox="0 0 44 48" fill="none">
|
||||
<circle cx="8" cy="24" r="6" stroke="#f6a701" strokeWidth="2" />
|
||||
<circle cx="36" cy="8" r="6" stroke="#f6a701" strokeWidth="2" />
|
||||
<circle cx="36" cy="40" r="6" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M13.5 21 30.5 11M13.5 27l17 10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const punkte = [
|
||||
{
|
||||
icon: <IconHand />,
|
||||
title: "Nimm dir, was passt",
|
||||
desc: "Eine Einladung, drei oder alle sieben – es gibt kein Richtig oder Falsch dabei.",
|
||||
},
|
||||
{
|
||||
icon: <IconEye />,
|
||||
title: "Nichts zum Abhaken",
|
||||
desc: "Die Sieben ist kein Programm. Es reicht, die Dinge bewusst wahrzunehmen.",
|
||||
},
|
||||
{
|
||||
icon: <IconShare />,
|
||||
title: "Teilen, wenn du magst",
|
||||
desc: "Manche erzählen anderen von ihren Sieben – nicht um sich zu messen, sondern um sich zu erinnern.",
|
||||
},
|
||||
];
|
||||
|
||||
export function SoFunktionierts() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-14 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
So läuft es ab
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Jeden Monat neu. Ohne Verpflichtung.</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[10rem]">
|
||||
{punkte.map((punkt, i) => (
|
||||
<Fragment key={punkt.title}>
|
||||
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
|
||||
<div className="flex items-center justify-center h-14 transition-transform duration-300 group-hover:scale-110">
|
||||
{punkt.icon}
|
||||
</div>
|
||||
<p className="font-semibold text-body text-text-primary">{punkt.title}</p>
|
||||
<p className="text-body-sm text-text-primary text-center">{punkt.desc}</p>
|
||||
</RevealItem>
|
||||
{i < punkte.length - 1 && (
|
||||
<div className="flex items-center justify-center shrink-0 sm:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DieSiebenHero } from "./components/DieSiebenHero";
|
||||
import { Philosophie } from "./components/Philosophie";
|
||||
import { SoFunktionierts } from "./components/SoFunktionierts";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
|
||||
const title = "Die Sieben – Das Monatsritual von einfach produktiv.";
|
||||
const description =
|
||||
"Sieben kleine Dinge, die den Monat ein bisschen reicher machen. Kein Programm, keine Pflicht – nur eine Einladung, bewusster wahrzunehmen, was ohnehin schon da ist.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/die-sieben",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/die-sieben",
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
// Rebuilt from the old WordPress page of the same name — a free monthly
|
||||
// ritual, not a product, so unlike /todo-cards or /lebensuhr there's no
|
||||
// photo asset and no pricing/testimonials section here. The WordPress
|
||||
// original also linked out to a monthly-changing "aktuelle Ausgabe" card,
|
||||
// a printable template, and an archive of past months — none of that has
|
||||
// a home in this codebase yet (no CMS collection backs it), so this build
|
||||
// is the evergreen concept page only. Newsletter signup below stands in as
|
||||
// the "stay in the loop" mechanism until/unless a real "monthly edition"
|
||||
// content model gets built.
|
||||
export default function DieSiebenPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<DieSiebenHero />
|
||||
<Philosophie />
|
||||
<SoFunktionierts />
|
||||
<Newsletter
|
||||
title="Nicht verpassen, wenn eine neue Sieben erscheint"
|
||||
description="Ich schreibe dir, sobald es die Sieben für den nächsten Monat gibt – ohne Spam, jederzeit abbestellbar."
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductBlocks } from "../components/ProductBlocks";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "Einfach anfangen. – Die Karte für die Dinge, die heute anstehen.";
|
||||
|
||||
// Description reads from the live product, same pattern as der-eine/
|
||||
// tasse-die-pause — see der-eine/page.tsx's own comment on why this and
|
||||
// the hand-written Hero prose are allowed to differ.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const product = await getProductBySlug("todo-starter");
|
||||
const description = product?.descriptionText || undefined;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: "/einfach-anfangen" },
|
||||
openGraph: { title, description, url: "/einfach-anfangen" },
|
||||
twitter: { title, description },
|
||||
};
|
||||
}
|
||||
|
||||
// First PDP driven entirely by Products.layout (see that field's own
|
||||
// comment in Products.ts) instead of its own bespoke Hero/HowItWorks/
|
||||
// Focus/Pricing components — those were deleted once this product's
|
||||
// content was authored as blocks in the admin (see the PDP page-builder
|
||||
// rollout). der-eine/todo-cards/tasse-die-pause are untouched and keep
|
||||
// their own hand-coded components; nothing about them depends on this.
|
||||
export default async function EinfachAnfangenPage() {
|
||||
const [product, seller] = await Promise.all([
|
||||
getProductBySlug("todo-starter"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
if (!product) notFound();
|
||||
const productSchema = buildProductSchema(product, "https://einfach-produktiv.mk360.de/einfach-anfangen", seller);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
<main className="flex flex-col flex-1">
|
||||
<ProductBlocks product={product} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import {
|
||||
renderOrderConfirmationHtml,
|
||||
renderPasswordResetHtml,
|
||||
renderOrderStatusHtml,
|
||||
renderBackInStockHtml,
|
||||
ORDER_STATUS_EMAIL_ICON,
|
||||
SAMPLE_ORDER,
|
||||
SAMPLE_ORDER_MANUAL,
|
||||
type EmailTemplateContent,
|
||||
} from "../../../lib/emailTemplates";
|
||||
import type { EmailTemplateType } from "../../../lib/payload";
|
||||
@@ -34,26 +37,87 @@ export function LiveEmailPreviewClient({
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
// Only order-confirmation has two meaningfully different rendered
|
||||
// states (isManualPayment true/false change which blocks show at all,
|
||||
// not just text) — every other type has one sample and no toggle.
|
||||
const [sampleVariant, setSampleVariant] = useState<"paid" | "manual">("paid");
|
||||
const orderSample = sampleVariant === "paid" ? SAMPLE_ORDER : SAMPLE_ORDER_MANUAL;
|
||||
|
||||
// No real company-settings fetch in this preview context — passing null
|
||||
// falls back to DEFAULT_LEGAL_FOOTER_LINES (placeholder Anbieterkennzeichnung)
|
||||
// inside buildLegalFooterLines(), same shape as the real send just with
|
||||
// placeholder business data.
|
||||
const html =
|
||||
type === "order-confirmation"
|
||||
? renderOrderConfirmationHtml(data, SAMPLE_ORDER, null)
|
||||
? renderOrderConfirmationHtml(data, orderSample, null)
|
||||
: type === "password-reset"
|
||||
? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", null)
|
||||
: renderOrderStatusHtml(
|
||||
data,
|
||||
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
|
||||
SAMPLE_ORDER.orderNumber,
|
||||
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
|
||||
null,
|
||||
);
|
||||
: type === "back-in-stock"
|
||||
? renderBackInStockHtml(
|
||||
data,
|
||||
"ToDo-Karten – Set",
|
||||
"https://einfach-produktiv.mk360.de/todo-cards",
|
||||
null,
|
||||
"https://payload.mk360.de/api/media/file/product-todo-karten.png",
|
||||
)
|
||||
: renderOrderStatusHtml(
|
||||
data,
|
||||
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
|
||||
SAMPLE_ORDER.orderNumber,
|
||||
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{type === "order-confirmation" && (
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginBottom: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSampleVariant("paid")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #d1cec4",
|
||||
background: sampleVariant === "paid" ? "#f6a701" : "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Online bezahlt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSampleVariant("manual")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #d1cec4",
|
||||
background: sampleVariant === "manual" ? "#f6a701" : "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Vorkasse (Überweisung)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* An iframe, not dangerouslySetInnerHTML into a plain div — `html`
|
||||
here is a full `<body>...</body>` fragment (see emailTemplates.ts's
|
||||
emailShell()), meant to become an actual email document. Dropped
|
||||
directly into this page's own already-existing <body> via
|
||||
dangerouslySetInnerHTML, that's a nested <body> tag — invalid
|
||||
HTML the browser "fixes" unpredictably, which is why this preview
|
||||
used to render broken (wrong background/padding/font, inline
|
||||
styles not applying). An iframe gives the email HTML its own
|
||||
real document, exactly like an actual email client would. */}
|
||||
<iframe
|
||||
srcDoc={`<!DOCTYPE html><html>${html}</html>`}
|
||||
title="E-Mail-Vorschau"
|
||||
style={{ width: "100%", height: "100vh", border: "none", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ const VALID_TYPES: EmailTemplateType[] = [
|
||||
"order-cancelled",
|
||||
"order-return-requested",
|
||||
"order-returned",
|
||||
"order-tracking-added",
|
||||
"order-tracking-corrected",
|
||||
"order-delivered",
|
||||
"payment-method-switched",
|
||||
"back-in-stock",
|
||||
];
|
||||
|
||||
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
@@ -23,6 +28,11 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
"order-cancelled": "Deine Bestellung wurde storniert",
|
||||
"order-return-requested": "Deine Rücksendung wurde angefragt",
|
||||
"order-returned": "Deine Retoure wurde bearbeitet",
|
||||
"order-tracking-added": "Hier ist deine Sendungsnummer",
|
||||
"order-tracking-corrected": "Korrigierte Sendungsnummer",
|
||||
"order-delivered": "Dein Paket ist angekommen",
|
||||
"payment-method-switched": "Erledigt!",
|
||||
"back-in-stock": "Wieder da!",
|
||||
};
|
||||
|
||||
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
|
||||
@@ -31,6 +41,11 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
// draft:true so an unsaved edit in the admin shows up here immediately;
|
||||
// the actual sent email (orderEmail.ts) always reads the published version
|
||||
// instead.
|
||||
//
|
||||
// "back-in-stock" has its own renderer (renderBackInStockHtml) rather than
|
||||
// sharing the generic order-status one below — it has no order at all, so
|
||||
// no order-number line, and its CTA points at the product page ("Zum
|
||||
// Produkt"), not /konto/bestellungen.
|
||||
export default async function EmailPreviewPage({ params }: { params: Promise<{ type: string }> }) {
|
||||
const { type } = await params;
|
||||
if (!VALID_TYPES.includes(type as EmailTemplateType)) notFound();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 1.8 KiB |
+57
-53
@@ -2,10 +2,10 @@
|
||||
|
||||
/*
|
||||
* Fluid design tokens — every value below scales continuously via clamp()
|
||||
* between the Tablet breakpoint (768px) and Desktop (1440px). Below 768px
|
||||
* the clamp() floor freezes the value automatically (no separate mobile
|
||||
* media query needed for sizing — only for structural layout changes,
|
||||
* see the `md:` breakpoint usage in components).
|
||||
* between the structural breakpoint (640px) and Desktop (1440px). Below
|
||||
* 640px the clamp() floor freezes the value automatically (no separate
|
||||
* mobile media query needed for sizing — only for structural layout
|
||||
* changes, see the `sm:` breakpoint usage in components).
|
||||
*
|
||||
* Spacing overrides Tailwind's own numeric scale (--spacing-4 etc.) at the
|
||||
* exact numbers the styleguide already used (n times 0.25rem equals the
|
||||
@@ -53,10 +53,10 @@
|
||||
--shadow-modal: 0 8px 40px rgba(26, 26, 24, 0.16);
|
||||
--shadow-sidebar: 0 2px 8px rgba(26, 26, 24, 0.08);
|
||||
|
||||
/* Type scale (fluid 768->1440) */
|
||||
--text-display: clamp(2.75rem, 1.3214rem + 2.9762vw, 4rem);
|
||||
/* Type scale (fluid 640->1440) */
|
||||
--text-display: clamp(2.75rem, 1.75rem + 2.5vw, 4rem);
|
||||
--text-display--line-height: 1.1;
|
||||
--text-h-feature: clamp(1.875rem, 1.1607rem + 1.4881vw, 2.5rem);
|
||||
--text-h-feature: clamp(1.875rem, 1.375rem + 1.25vw, 2.5rem);
|
||||
--text-h-feature--line-height: 1.2;
|
||||
/* New role, added for page-todo-karten's hero heading (48px Desktop) —
|
||||
doesn't match any existing scale step (h-feature=40, h1=36), Figma's
|
||||
@@ -67,21 +67,21 @@
|
||||
etc.)" per styleguide.md §2.2 — a different, smaller role). Also used
|
||||
by page-weekly-impulses's hero heading — same 48px role, reused
|
||||
rather than minting a near-duplicate token. */
|
||||
--text-h-page: clamp(2.5rem, 1.9286rem + 1.1905vw, 3rem);
|
||||
--text-h-page: clamp(2.5rem, 2.1rem + 1vw, 3rem);
|
||||
--text-h-page--line-height: 1.15;
|
||||
--text-h1: clamp(1.75rem, 1.1786rem + 1.1905vw, 2.25rem);
|
||||
--text-h1: clamp(1.75rem, 1.35rem + 1vw, 2.25rem);
|
||||
--text-h1--line-height: 1.15;
|
||||
--text-h2: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
|
||||
--text-h2: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
|
||||
--text-h2--line-height: 1.25;
|
||||
--text-h-section: clamp(1.4375rem, 1.0804rem + 0.744vw, 1.75rem);
|
||||
--text-h-section: clamp(1.4375rem, 1.1875rem + 0.625vw, 1.75rem);
|
||||
--text-h-section--line-height: 1.3;
|
||||
--text-h-emphasis: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
|
||||
--text-h-emphasis: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
|
||||
--text-h-emphasis--line-height: 1.4;
|
||||
--text-h3: clamp(1.1875rem, 0.9732rem + 0.4464vw, 1.375rem);
|
||||
--text-h3: clamp(1.1875rem, 1.0375rem + 0.375vw, 1.375rem);
|
||||
--text-h3--line-height: 1.35;
|
||||
--text-h-small: clamp(1.125rem, 0.9821rem + 0.2976vw, 1.25rem);
|
||||
--text-h-small: clamp(1.125rem, 1.025rem + 0.25vw, 1.25rem);
|
||||
--text-h-small--line-height: 1.4;
|
||||
--text-h4: clamp(1rem, 0.8571rem + 0.2976vw, 1.125rem);
|
||||
--text-h4: clamp(1rem, 0.9rem + 0.25vw, 1.125rem);
|
||||
--text-h4--line-height: 1.4;
|
||||
--text-body: 1rem;
|
||||
--text-body--line-height: 1.6;
|
||||
@@ -90,21 +90,21 @@
|
||||
--text-label: 0.75rem;
|
||||
--text-label--line-height: 1.4;
|
||||
|
||||
/* Spacing scale (fluid 768->1440) — overrides Tailwind's default n*0.25rem
|
||||
/* Spacing scale (fluid 640->1440) — overrides Tailwind's default n*0.25rem
|
||||
at these exact steps, so p-4/gap-6/px-20/etc. become fluid automatically */
|
||||
--spacing-1: clamp(0.25rem, 0.25rem + 0vw, 0.25rem);
|
||||
--spacing-2: clamp(0.5rem, 0.5rem + 0vw, 0.5rem);
|
||||
--spacing-3: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
|
||||
--spacing-4: clamp(0.875rem, 0.7321rem + 0.2976vw, 1rem);
|
||||
--spacing-5: clamp(1rem, 0.7143rem + 0.5952vw, 1.25rem);
|
||||
--spacing-6: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
|
||||
--spacing-8: clamp(1.5rem, 0.9286rem + 1.1905vw, 2rem);
|
||||
--spacing-10: clamp(1.75rem, 0.8929rem + 1.7857vw, 2.5rem);
|
||||
--spacing-12: clamp(2rem, 0.8571rem + 2.381vw, 3rem);
|
||||
--spacing-16: clamp(2.5rem, 0.7857rem + 3.5714vw, 4rem);
|
||||
--spacing-20: clamp(3rem, 0.7143rem + 4.7619vw, 5rem);
|
||||
--spacing-24: clamp(3.5rem, 0.6429rem + 5.9524vw, 6rem);
|
||||
--spacing-30: clamp(4rem, 0rem + 8.3333vw, 7.5rem);
|
||||
--spacing-3: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
|
||||
--spacing-4: clamp(0.875rem, 0.775rem + 0.25vw, 1rem);
|
||||
--spacing-5: clamp(1rem, 0.8rem + 0.5vw, 1.25rem);
|
||||
--spacing-6: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
|
||||
--spacing-8: clamp(1.5rem, 1.1rem + 1vw, 2rem);
|
||||
--spacing-10: clamp(1.75rem, 1.15rem + 1.5vw, 2.5rem);
|
||||
--spacing-12: clamp(2rem, 1.2rem + 2vw, 3rem);
|
||||
--spacing-16: clamp(2.5rem, 1.3rem + 3vw, 4rem);
|
||||
--spacing-20: clamp(3rem, 1.4rem + 4vw, 5rem);
|
||||
--spacing-24: clamp(3.5rem, 1.5rem + 5vw, 6rem);
|
||||
--spacing-30: clamp(4rem, 1.2rem + 7vw, 7.5rem);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
@@ -116,18 +116,22 @@
|
||||
/* Layout — not part of Tailwind's numeric spacing scale, referenced via
|
||||
arbitrary-value syntax (e.g. px-[var(--layout-padding-x)]) */
|
||||
--layout-max-width: 75rem; /* 1200px */
|
||||
--layout-padding-x: clamp(2rem, -1.4286rem + 7.1429vw, 5rem);
|
||||
--layout-grid-gap: clamp(1rem, 0.4286rem + 1.1905vw, 1.5rem);
|
||||
--layout-grid-gap-lg: clamp(1.25rem, 0.3929rem + 1.7857vw, 2rem);
|
||||
--layout-padding-x: clamp(2rem, -0.4rem + 6vw, 5rem);
|
||||
--layout-grid-gap: clamp(1rem, 0.6rem + 1vw, 1.5rem);
|
||||
--layout-grid-gap-lg: clamp(1.25rem, 0.65rem + 1.5vw, 2rem);
|
||||
|
||||
/* One-off fluid values that aren't part of a broader scale */
|
||||
--hero-avatar-size: clamp(2.25rem, 1.6071rem + 1.3393vw, 2.8125rem);
|
||||
--hero-avatar-size: clamp(2.25rem, 1.8rem + 1.125vw, 2.8125rem);
|
||||
/* Newsletter copy column — was a hard 38.25rem, which didn't leave enough
|
||||
room for the form column at the 768px tablet floor (only 656px of
|
||||
content width available for 612px column plus 56px gap), crushing the
|
||||
input/button/privacy text to near-zero width and overflowing the page.
|
||||
Scales down to 23rem at 768px instead. */
|
||||
--newsletter-copy-width: clamp(23rem, 5.5714rem + 36.3095vw, 38.25rem);
|
||||
room for the form column at the 640px structural floor (only ~576px of
|
||||
content width available for a 612px column plus gap at that width),
|
||||
crushing the input/button/privacy text to near-zero width and
|
||||
overflowing the page. Scales down to 23rem at 640px instead. Note: the
|
||||
inner input+button row still switches to a row layout at `lg:` (1024px),
|
||||
not at the site's `sm:` (640px) structural line — even at this reduced
|
||||
floor there isn't enough room for input+button side by side below
|
||||
~900px, see Newsletter.tsx's own comment. */
|
||||
--newsletter-copy-width: clamp(23rem, 10.8rem + 30.5vw, 38.25rem);
|
||||
|
||||
/* Divider section icons (arrow separators, sparkle) — previously hard
|
||||
rem values while the "Klarheit/Fokus/Entlastung" text next to them
|
||||
@@ -135,30 +139,30 @@
|
||||
while the words scaled. Same fluid(minPx, maxPx) formula as the rest
|
||||
of this file, floor ~82.5% of the Desktop value per the styleguide's
|
||||
80-85% Mobile-ratio guidance. */
|
||||
--divider-arrow-w: clamp(2.125rem, 1.5536rem + 1.1905vw, 2.625rem);
|
||||
--divider-arrow-h: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
|
||||
--divider-sparkle-w: clamp(1.5625rem, 1.1554rem + 0.8482vw, 1.91875rem);
|
||||
--divider-sparkle-h: clamp(2.0625rem, 1.5554rem + 1.0565vw, 2.50625rem);
|
||||
--divider-sparkle-inner-w: clamp(1.4375rem, 1.09875rem + 0.706vw, 1.734rem);
|
||||
--divider-sparkle-inner-h: clamp(1.9375rem, 1.4375rem + 1.0417vw, 2.375rem);
|
||||
--divider-arrow-w: clamp(2.125rem, 1.725rem + 1vw, 2.625rem);
|
||||
--divider-arrow-h: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
|
||||
--divider-sparkle-w: clamp(1.5625rem, 1.2775rem + 0.7125vw, 1.9188rem);
|
||||
--divider-sparkle-h: clamp(2.0625rem, 1.7075rem + 0.8875vw, 2.5063rem);
|
||||
--divider-sparkle-inner-w: clamp(1.4375rem, 1.2003rem + 0.593vw, 1.734rem);
|
||||
--divider-sparkle-inner-h: clamp(1.9375rem, 1.5875rem + 0.875vw, 2.375rem);
|
||||
/* Own token, mirrors --text-h2's clamp() exactly rather than the Word
|
||||
component reading var(--text-h2) directly — that's what lets the
|
||||
mobile override below shrink just this component's words without
|
||||
touching every other text-h2 heading site-wide. */
|
||||
--divider-word-size: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
|
||||
--divider-word-size: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
|
||||
}
|
||||
|
||||
/* This project's fluid() scale (see fluid.ts) is calibrated for the
|
||||
768-1440px Tablet-Desktop range and floors out at the 768px value for
|
||||
any narrower viewport (clamp()'s MIN bound) — by design, see the other
|
||||
fluid tokens above. Divider is the one spot that floor doesn't work:
|
||||
the "Klarheit → Fokus → Entlastung" phrase plus its connector icons
|
||||
needs ~550px of width to lay out on one row even at the 768px floor
|
||||
size, far more than a phone's ~310px content width. Below Tailwind's
|
||||
sm: breakpoint, shrink these tokens further so the phrase gets much
|
||||
closer to fitting on one row instead of stacking into three separate
|
||||
centered lines (see Divider.tsx's gap-x-3/gap-3 mobile overrides,
|
||||
same breakpoint). Scoped to these component-only tokens, not
|
||||
640-1440px structural-to-Desktop range and floors out at the 640px value
|
||||
for any narrower viewport (clamp()'s MIN bound) — by design, see the
|
||||
other fluid tokens above. Divider is the one spot that floor doesn't
|
||||
work: the "Klarheit → Fokus → Entlastung" phrase plus its connector
|
||||
icons needs ~550px of width to lay out on one row even at the 640px
|
||||
floor size, far more than a phone's ~310px content width. Below
|
||||
Tailwind's sm: breakpoint, shrink these tokens further so the phrase
|
||||
gets much closer to fitting on one row instead of stacking into three
|
||||
separate centered lines (see Divider.tsx's gap-x-3/gap-3 mobile
|
||||
overrides, same breakpoint). Scoped to these component-only tokens, not
|
||||
--text-h2 itself. */
|
||||
@media (max-width: 639px) {
|
||||
:root {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect x="0" y="0" width="64" height="64" rx="14" fill="#f6a701" />
|
||||
<text
|
||||
x="32"
|
||||
y="46"
|
||||
text-anchor="middle"
|
||||
font-family="Georgia, 'Playfair Display', 'Times New Roman', serif"
|
||||
font-weight="700"
|
||||
font-style="italic"
|
||||
font-size="42"
|
||||
fill="#1a1a1a"
|
||||
>e</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -35,7 +35,8 @@ import type { TOCSection } from "../../components/SectionTOC";
|
||||
// basis first, and don't assume the old attempt's reasoning was correct.
|
||||
export function anbieterAngabenHeadings(seller: CompanySettings | null): TOCSection[] {
|
||||
if (!seller) return [];
|
||||
const sections = ["Angaben zum Anbieter", "Umsatzsteuer"];
|
||||
const sections = ["Angaben zum Anbieter"];
|
||||
if (seller.vatId) sections.push("Umsatzsteuer");
|
||||
if (seller.registerCourt && seller.registerNumber) sections.push("Handelsregister");
|
||||
if (seller.managingDirector) sections.push("Geschäftsführung");
|
||||
sections.push("Verantwortlich für den Inhalt");
|
||||
@@ -75,14 +76,20 @@ export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<P>E-Mail: {seller.sellerEmail}</P>
|
||||
<P>
|
||||
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
|
||||
</P>
|
||||
</div>
|
||||
|
||||
<Heading>Umsatzsteuer</Heading>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
|
||||
<P>{seller.vatId}</P>
|
||||
</div>
|
||||
{seller.vatId && (
|
||||
<>
|
||||
<Heading>Umsatzsteuer</Heading>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
|
||||
<P>{seller.vatId}</P>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{seller.registerCourt && seller.registerNumber && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PaymentStep } from "../../../../checkout/components/PaymentStep";
|
||||
|
||||
// Shown only for a still-unpaid Überweisung order (see page.tsx's own
|
||||
// eligibility check, mirroring api/account/orders/[orderNumber]/
|
||||
// switch-to-stripe/route.ts's authoritative one) — lets a customer switch
|
||||
// to Kreditkarte/PayPal instead of waiting on their own bank transfer.
|
||||
// Reuses PaymentStep (the exact same Stripe collection UI checkout uses)
|
||||
// once this endpoint hands back a clientSecret — the order already
|
||||
// exists, this only changes how it gets paid.
|
||||
export function SwitchPaymentButton({ orderNumber }: { orderNumber: string }) {
|
||||
const [state, setState] = useState<
|
||||
| { step: "idle" }
|
||||
| { step: "loading" }
|
||||
| { step: "error"; reason: string }
|
||||
| { step: "paying"; clientSecret: string; orderId: number; testMode: boolean; providerReference?: string }
|
||||
>({ step: "idle" });
|
||||
|
||||
async function start() {
|
||||
setState({ step: "loading" });
|
||||
try {
|
||||
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}/switch-to-stripe`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setState({ step: "error", reason: data.reason || "Umstellung fehlgeschlagen." });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
step: "paying",
|
||||
clientSecret: data.clientSecret,
|
||||
orderId: data.orderId,
|
||||
testMode: Boolean(data.testMode),
|
||||
providerReference: data.providerReference,
|
||||
});
|
||||
} catch {
|
||||
setState({ step: "error", reason: "Umstellung gerade nicht möglich." });
|
||||
}
|
||||
}
|
||||
|
||||
if (state.step === "paying") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full border border-border rounded-md p-5">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Mit Kreditkarte/PayPal bezahlen</p>
|
||||
<PaymentStep
|
||||
clientSecret={state.clientSecret}
|
||||
orderNumber={orderNumber}
|
||||
orderId={state.orderId}
|
||||
testMode={state.testMode}
|
||||
providerReference={state.providerReference}
|
||||
returnContext="account"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={state.step === "loading"}
|
||||
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${state.step === "loading" ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{state.step === "loading" ? "…" : "Zahlungsart ändern"}
|
||||
</button>
|
||||
{state.step === "error" && <p className="text-label text-red-600">{state.reason}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import { Footer } from "../../../components/Footer";
|
||||
import { VatBreakdown } from "../../../components/VatBreakdown";
|
||||
import { formatPrice, formatDate } from "../../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
|
||||
import { getProductImagesByIds } from "../../../lib/payload";
|
||||
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout, getMediaUrlById } from "../../../lib/payload";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
|
||||
import { buildTrackingUrl, CARRIER_LABELS, type Carrier } from "@einfach-produktiv/invoicing";
|
||||
import { OrderActionButton } from "./components/OrderActionButton";
|
||||
import { SwitchPaymentButton } from "./components/SwitchPaymentButton";
|
||||
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
|
||||
import { PaymentStatusBadge } from "../../components/PaymentStatusBadge";
|
||||
|
||||
// Dynamic (was a static "Bestelldetails" title despite this being a
|
||||
// per-order route) — just formats the already-known order number into
|
||||
@@ -33,7 +35,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber), true);
|
||||
if (!order) notFound();
|
||||
|
||||
const address =
|
||||
@@ -46,7 +48,16 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
: order.shippingStreet;
|
||||
const action = customerOrderAction(order.status);
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const dhlReturnLabel = order.dhlReturnLabelMedia ? await getMediaUrlById(order.dhlReturnLabelMedia) : null;
|
||||
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
|
||||
// Same "Online-Zahlung" grouping/eligibility the switch-to-stripe route
|
||||
// itself re-checks authoritatively — only offer the button when it
|
||||
// would actually succeed.
|
||||
const canSwitchPayment =
|
||||
order.paymentProvider === "manual" &&
|
||||
order.status === "received" &&
|
||||
(order.paymentStatus === "pending" || order.paymentStatus === "not_applicable") &&
|
||||
groupPaymentMethodsForCheckout(await getPaymentMethods()).some((m) => m.provider === "stripe");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,6 +80,10 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
<p className="text-label text-text-muted">Status</p>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Zahlungsstatus</p>
|
||||
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Zahlungsart</p>
|
||||
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
|
||||
@@ -77,7 +92,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
|
||||
{order.trackingNumber && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}</p>
|
||||
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier as Carrier] ?? order.carrier})` : ""}</p>
|
||||
{(() => {
|
||||
const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
|
||||
return trackingUrl ? (
|
||||
@@ -91,6 +106,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dhlReturnLabel && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">DHL-Retourenschein{order.dhlReturnTrackingNumber ? ` (${order.dhlReturnTrackingNumber})` : ""}</p>
|
||||
<a href={dhlReturnLabel.url} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
|
||||
Retourenschein herunterladen
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{/* Labeled "Rechnungsadresse" only once there's an actual
|
||||
second (shipping) address to distinguish it from — the
|
||||
@@ -120,6 +144,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
{order.hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Lieferadresse</p>
|
||||
{order.shippingCompanyName && <p className="text-body-sm text-text-primary">{order.shippingCompanyName}</p>}
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingFirstName} {order.shippingLastName}
|
||||
</p>
|
||||
@@ -127,6 +152,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
|
||||
</p>
|
||||
{(order.shippingContactEmail || order.shippingContactPhone) && (
|
||||
<p className="text-body-sm text-text-muted">
|
||||
{[order.shippingContactEmail, order.shippingContactPhone].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -145,6 +175,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</p>
|
||||
{!order.kleinunternehmer && <p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>}
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
{item.sku && <p className="text-label text-text-muted">Art.-Nr. {item.sku}</p>}
|
||||
{item.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
)}
|
||||
@@ -220,12 +251,17 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
{(action || canSwitchPayment) && (
|
||||
<div className="flex flex-wrap gap-3 items-start w-full">
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
)}
|
||||
{canSwitchPayment && <SwitchPaymentButton orderNumber={order.orderNumber} />}
|
||||
</div>
|
||||
)}
|
||||
</Reveal>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { CustomSelect } from "../../../components/CustomSelect";
|
||||
|
||||
// Three custom-styled dropdowns in a row instead of a wall of filter
|
||||
// chips (tried first, reverted 2026-07-30 — with 7 status options + 5
|
||||
// payment-status options + N years, a chip per option read as cluttered
|
||||
// and ate a lot of vertical space) or plain native <select>s (tried next,
|
||||
// also reverted the same day — a native <select>'s open options popup is
|
||||
// rendered by the browser/OS and can't be styled at all, so it looked
|
||||
// completely off-brand next to everything else on the page; see
|
||||
// CustomSelect.tsx for the fully custom-styled replacement). Client
|
||||
// Component only for the onChange→navigate wiring; the actual filtering
|
||||
// still happens server-side in page.tsx via the same URL search params,
|
||||
// so this stays a plain GET-style filter (shareable/bookmarkable/
|
||||
// back-button-safe), not client-side state.
|
||||
export function OrderFilters({
|
||||
statusOptions,
|
||||
paymentStatusOptions,
|
||||
years,
|
||||
}: {
|
||||
statusOptions: { value: string; label: string }[];
|
||||
paymentStatusOptions: { value: string; label: string }[];
|
||||
years: string[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const status = searchParams.get("status") ?? "";
|
||||
const paymentStatus = searchParams.get("paymentStatus") ?? "";
|
||||
const year = searchParams.get("year") ?? "";
|
||||
const hasAnyFilter = Boolean(status || paymentStatus || year);
|
||||
const activeCount = [status, paymentStatus, year].filter(Boolean).length;
|
||||
|
||||
// Collapsed by default on mobile — with the account tab bar now also
|
||||
// stacked above this (see KontoShell/AccountNav), 3 full-width dropdowns
|
||||
// always visible left little room for the actual order list. Desktop
|
||||
// (sm+) ignores this entirely and always shows the row inline, same as
|
||||
// before. Starts expanded whenever a filter is already active (arriving
|
||||
// via a shared/bookmarked filtered URL shouldn't hide what's applied) —
|
||||
// a lazy initializer since it only needs to run once, on mount.
|
||||
const [expanded, setExpanded] = useState(() => hasAnyFilter);
|
||||
|
||||
function setParam(key: string, value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen");
|
||||
}
|
||||
|
||||
const yearOptions = years.map((y) => ({ value: y, label: y }));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="sm:hidden flex items-center justify-between w-full px-4 py-3 border border-border rounded-sm text-body-sm font-bold text-text-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Filter
|
||||
{activeCount > 0 && (
|
||||
<span className="flex items-center justify-center min-w-[1.1rem] h-[1.1rem] px-1 rounded-full bg-brand text-[0.6875rem] font-bold leading-none text-text-primary">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span aria-hidden>{expanded ? "▴" : "▾"}</span>
|
||||
</button>
|
||||
<div
|
||||
className={`${expanded ? "flex" : "hidden"} sm:flex flex-col sm:flex-row sm:items-center gap-3 w-full mt-3 sm:mt-0`}
|
||||
>
|
||||
<CustomSelect label="Alle Status" options={statusOptions} value={status} onChange={(v) => setParam("status", v)} />
|
||||
<CustomSelect label="Alle Zahlungsstatus" options={paymentStatusOptions} value={paymentStatus} onChange={(v) => setParam("paymentStatus", v)} />
|
||||
<CustomSelect label="Alle Jahre" options={yearOptions} value={year} onChange={(v) => setParam("year", v)} />
|
||||
{hasAnyFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/konto/bestellungen")}
|
||||
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors self-start sm:self-auto"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth";
|
||||
import {
|
||||
getSessionCustomer,
|
||||
getCustomerOrders,
|
||||
getCustomerOrderYears,
|
||||
ORDER_STATUS_LABEL,
|
||||
} from "../../lib/customerAuth";
|
||||
import { getOrderFilterEnabled } from "../../lib/payload";
|
||||
import { OrderStatusBadge } from "../components/OrderStatusBadge";
|
||||
import { LogoutButton } from "../components/LogoutButton";
|
||||
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.
|
||||
export const metadata: Metadata = {
|
||||
@@ -18,50 +27,105 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function KontoBestellungenPage() {
|
||||
// Mirrors PaymentStatusBadge's own grouping ("Offen" covers both
|
||||
// not_applicable and pending) — see getCustomerOrders's OPEN_PAYMENT_STATUSES.
|
||||
const PAYMENT_STATUS_FILTER_LABEL: Record<string, string> = {
|
||||
open: "Offen",
|
||||
paid: "Bezahlt",
|
||||
failed: "Fehlgeschlagen",
|
||||
refunded: "Erstattet",
|
||||
partially_refunded: "Teilweise erstattet",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(ORDER_STATUS_LABEL).map(([value, label]) => ({ value, label }));
|
||||
const PAYMENT_STATUS_OPTIONS = Object.entries(PAYMENT_STATUS_FILTER_LABEL).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export default async function KontoBestellungenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string; paymentStatus?: string; year?: string }>;
|
||||
}) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const orders = await getCustomerOrders(session.token, session.customer.id);
|
||||
const orderFilterEnabled = await getOrderFilterEnabled();
|
||||
const rawSearchParams = await searchParams;
|
||||
const status = orderFilterEnabled ? rawSearchParams.status : undefined;
|
||||
const paymentStatus = orderFilterEnabled ? rawSearchParams.paymentStatus : undefined;
|
||||
const year = orderFilterEnabled ? rawSearchParams.year : undefined;
|
||||
|
||||
const [orders, availableYears] = await Promise.all([
|
||||
getCustomerOrders(session.token, session.customer.id, true, { status, paymentStatus, year }),
|
||||
getCustomerOrderYears(session.token, session.customer.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[56rem] mx-auto">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Meine Bestellungen
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
|
||||
</p>
|
||||
<KontoShell>
|
||||
<Reveal className="flex flex-col gap-6 items-start w-full">
|
||||
<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>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
{orderFilterEnabled && availableYears.length > 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<OrderFilters statusOptions={STATUS_OPTIONS} paymentStatusOptions={PAYMENT_STATUS_OPTIONS} years={availableYears} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{availableYears.length === 0 ? (
|
||||
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-body text-text-muted">Keine Bestellungen gefunden, die zu den gewählten Filtern passen.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{orders.map((order) => (
|
||||
<Link
|
||||
key={order.orderNumber}
|
||||
href={`/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`}
|
||||
className="flex flex-wrap items-center gap-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
|
||||
className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 sm:gap-x-6 gap-y-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Below sm (640px): 2 columns, plain DOM order —
|
||||
Bestellnummer/Datum, Status/Zahlungsstatus,
|
||||
Artikel/Gesamtbetrag tile into exactly those 3 pairs
|
||||
on their own, no explicit placement needed. From sm
|
||||
up: 4 columns, every cell explicitly pinned to a
|
||||
row+column (sm:row-start-N sm:col-start-N) rather
|
||||
than relying on `order` + Grid's auto-placement —
|
||||
mixing `order` with only SOME items explicitly
|
||||
positioned is a real footgun: the auto-placement
|
||||
cursor for the un-pinned items starts scanning from
|
||||
column 1 again regardless of what's already
|
||||
explicitly placed elsewhere. Pinning every cell's row
|
||||
AND column explicitly removes that ambiguity
|
||||
entirely. Row 1: Bestellnummer/Datum in columns 1-2,
|
||||
left-aligned (centering them in columns 2-3 was
|
||||
tried and reverted — not wanted). Row 2: Artikel/
|
||||
Status/Zahlungsstatus/Gesamtbetrag fill all 4
|
||||
columns. */}
|
||||
<div className="flex flex-col gap-1 sm:row-start-1 sm:col-start-1">
|
||||
<p className="text-label text-text-muted">Bestellnummer</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{order.orderNumber}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 sm:row-start-1 sm:col-start-2">
|
||||
<p className="text-label text-text-muted">Datum</p>
|
||||
<p className="text-body-sm text-text-primary">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Artikel</p>
|
||||
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-2">
|
||||
<p className="text-label text-text-muted">Status</p>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 ml-auto">
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-3">
|
||||
<p className="text-label text-text-muted">Zahlungsstatus</p>
|
||||
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-1">
|
||||
<p className="text-label text-text-muted">Artikel</p>
|
||||
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-4">
|
||||
<p className="text-label text-text-muted">Gesamtbetrag</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
|
||||
</div>
|
||||
@@ -70,18 +134,7 @@ export default async function KontoBestellungenPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6">
|
||||
<Link href="/konto/profil" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Profil & Adresse
|
||||
</Link>
|
||||
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Weiter einkaufen
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</Reveal>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
</Reveal>
|
||||
</KontoShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
// Persistent account navigation — a fixed sidebar (sm+) / horizontal tab
|
||||
// bar (below sm) shown on every logged-in /konto/* page via KontoShell.
|
||||
// Replaces the "Profil & Adresse / Weiter einkaufen / Abmelden" links that
|
||||
// used to live at the bottom of the orders page's content — those became
|
||||
// unreachable without scrolling past an arbitrarily long order list. Being
|
||||
// part of the shell now (not page content), reachability no longer depends
|
||||
// on how much is above it.
|
||||
//
|
||||
// No Merkliste entry — the Navbar's own wishlist heart icon already covers
|
||||
// that (visible at every width, on every page, not just inside /konto/*),
|
||||
// so a second entry here would just be a redundant path to the same page.
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/konto/bestellungen", label: "Bestellungen" },
|
||||
{ href: "/konto/profil", label: "Profil" },
|
||||
] as const;
|
||||
|
||||
export function AccountNav() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// Same sequence as the old LogoutButton (now folded in here, its one
|
||||
// call site): clear the local cart (already mirrored server-side by
|
||||
// CartSync, so safe to drop — the next login's mergeServerCartIntoLocal()
|
||||
// restores it), tell already-mounted Client Components (Navbar's
|
||||
// AccountLink) the auth state changed, leave the account area, then
|
||||
// force a fresh Server Component render.
|
||||
async function handleLogout() {
|
||||
await fetch("/api/account/logout", { method: "POST" });
|
||||
clearCart();
|
||||
dispatchAuthChanged();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop/tablet — sidebar, sm+ (640px). Sits to the left of the
|
||||
page content inside KontoShell's flex row. */}
|
||||
<nav className="hidden sm:flex sm:flex-col sm:w-48 shrink-0 gap-1">
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide px-3 pb-2">Mein Konto</p>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 rounded-sm text-body-sm font-semibold transition-colors ${
|
||||
pathname.startsWith(item.href) ? "bg-bg-muted text-text-primary" : "text-text-muted hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="mt-4 px-3 py-2 text-left rounded-sm text-body-sm font-semibold text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile — horizontal tab bar, below sm. overflow-x-auto rather
|
||||
than wrapping: 3 items at once already fits most phones, and a
|
||||
scrollable single row reads clearly as "more tabs this way"
|
||||
rather than a wrapped second line competing for attention with
|
||||
the page content right below it. */}
|
||||
<nav className="sm:hidden flex items-center gap-2 overflow-x-auto pb-1 -mx-[var(--layout-padding-x)] px-[var(--layout-padding-x)]">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`shrink-0 px-4 py-2 rounded-full text-body-sm font-bold whitespace-nowrap transition-colors ${
|
||||
pathname.startsWith(item.href) ? "bg-brand text-text-primary" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="shrink-0 px-4 py-2 rounded-full text-body-sm font-bold whitespace-nowrap text-red-600"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { AccountNav } from "./AccountNav";
|
||||
|
||||
// Shared shell for every logged-in /konto/* page (bestellungen, merkliste,
|
||||
// profil) — the <main>/<Footer> wrapping plus the persistent AccountNav
|
||||
// were previously duplicated per page, with the equivalent of the nav
|
||||
// buried as plain links at the bottom of the orders page's own content
|
||||
// (unreachable once the order list got long enough to push it below the
|
||||
// fold). Each page keeps its own auth-check/redirect and data-fetching
|
||||
// exactly as before — this only replaces the outer chrome, not the
|
||||
// page-specific logic each page still needs (e.g. merkliste's return-URL
|
||||
// login redirect, profil's second profile-fetch redirect).
|
||||
//
|
||||
// "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">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/account/logout", { method: "POST" });
|
||||
dispatchAuthChanged();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleLogout} className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Abmelden
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const STYLES: Record<string, string> = {
|
||||
export function OrderStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
|
||||
className={`self-start inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
|
||||
>
|
||||
{ORDER_STATUS_LABEL[status] ?? status}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Same visual pattern as OrderStatusBadge — but answers a different
|
||||
// question ("hat Stripe/die Buchhaltung eine Zahlung bestätigt?", not
|
||||
// "wo im Fulfillment steht die Bestellung"). 'not_applicable' (an
|
||||
// Überweisung order before payment is manually reconciled) reads as
|
||||
// "offen", same as a still-'pending' Stripe order — the customer doesn't
|
||||
// need to know the internal distinction between the two.
|
||||
const LABEL: Record<string, string> = {
|
||||
not_applicable: "Offen",
|
||||
pending: "Offen",
|
||||
paid: "Bezahlt",
|
||||
failed: "Fehlgeschlagen",
|
||||
refunded: "Erstattet",
|
||||
partially_refunded: "Teilweise erstattet",
|
||||
};
|
||||
|
||||
const STYLES: Record<string, string> = {
|
||||
not_applicable: "bg-red-50 text-red-600",
|
||||
pending: "bg-red-50 text-red-600",
|
||||
paid: "bg-success-subtle text-success",
|
||||
failed: "bg-red-50 text-red-600",
|
||||
refunded: "bg-bg-muted text-text-light",
|
||||
partially_refunded: "bg-orange-50 text-orange-600",
|
||||
};
|
||||
|
||||
export function PaymentStatusBadge({ paymentStatus }: { paymentStatus: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`self-start inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[paymentStatus] ?? "bg-bg-muted text-text-muted"}`}
|
||||
>
|
||||
{LABEL[paymentStatus] ?? paymentStatus}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { mergeServerCartIntoLocal } from "../../../lib/cart";
|
||||
@@ -9,6 +9,12 @@ import { dispatchAuthChanged } from "../../../lib/auth";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// WishlistButton (and anything else login-gated) sends the shopper back
|
||||
// here with ?redirect=<where they were>, e.g. a product page they
|
||||
// wanted to heart — previously ignored, always landing on
|
||||
// /konto/bestellungen regardless of where the login was triggered from.
|
||||
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,7 +38,7 @@ export function LoginForm() {
|
||||
}
|
||||
await mergeServerCartIntoLocal();
|
||||
dispatchAuthChanged();
|
||||
router.push("/konto/bestellungen");
|
||||
router.push(redirectTo);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Login ist gerade nicht möglich.");
|
||||
@@ -81,11 +87,14 @@ export function LoginForm() {
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Noch kein Konto? Einfach beim{" "}
|
||||
<Link href="/checkout" className="underline hover:text-brand transition-colors">
|
||||
nächsten Einkauf
|
||||
</Link>{" "}
|
||||
anlegen.
|
||||
Noch kein Konto?{" "}
|
||||
<Link
|
||||
href={`/konto/registrieren${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
|
||||
className="underline hover:text-brand transition-colors"
|
||||
>
|
||||
Jetzt anlegen
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { LoginForm } from "./components/LoginForm";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
@@ -16,7 +17,12 @@ export default function KontoLoginPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<LoginForm />
|
||||
{/* Suspense required — LoginForm uses useSearchParams() (?redirect=,
|
||||
e.g. from WishlistButton's login-gate) which opts any consumer
|
||||
into client-side rendering unless wrapped. */}
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { RevealGroup, RevealItem } from "../../../components/Reveal";
|
||||
import { formatDate } from "../../../lib/format";
|
||||
import { ProductCard } from "../../../components/ProductCard";
|
||||
import { useWishlist } from "../../../lib/useWishlist";
|
||||
import type { Product } from "../../../lib/payload";
|
||||
|
||||
// Client Component so removing an item (WishlistButton toggling it off)
|
||||
// disappears from this grid immediately — the server-rendered initial
|
||||
// list alone doesn't react to that client-side toggle at all (the parent
|
||||
// page.tsx is a Server Component, its render is fixed at request time).
|
||||
// useWishlist()'s live `items` is the actual source of truth for which
|
||||
// products are still wishlisted; `initialProducts` only supplies the
|
||||
// display data (name/image/price) for whatever numericIds are currently
|
||||
// wishlisted, since the wishlist itself only stores product ids.
|
||||
export function MerklisteGrid({
|
||||
initialProducts,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
}: {
|
||||
initialProducts: Product[];
|
||||
defaultTaxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
}) {
|
||||
const { items } = useWishlist();
|
||||
// Preserve the wishlist's own order (most-recently-added-first, via
|
||||
// `items`) rather than initialProducts' own order. Zipped with the
|
||||
// originating wishlist item (not just the product) so purchasedAt stays
|
||||
// attached — the earlier `.map().filter()` chain that only kept the
|
||||
// product lost that association.
|
||||
const productsByNumericId = new Map(initialProducts.map((p) => [p.numericId, p]));
|
||||
const visibleEntries = items
|
||||
.map((item) => ({ item, product: productsByNumericId.get(item.productId) }))
|
||||
.filter((entry): entry is { item: (typeof items)[number]; product: Product } => Boolean(entry.product));
|
||||
|
||||
if (visibleEntries.length === 0) {
|
||||
return <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
// No items-start, and grid-auto-rows + subgrid on each RevealItem —
|
||||
// see ProductGrid.tsx's own comment on why both are needed for
|
||||
// ProductCard's internal rows (price, etc.) to actually line up
|
||||
// across cards, not just overall card height.
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full">
|
||||
{visibleEntries.map(({ item, product }) => (
|
||||
<RevealItem key={`${product.id}-${item.variant}`} className="grid [grid-template-rows:subgrid] [grid-row:span_6]">
|
||||
<ProductCard
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
wishlistEnabled
|
||||
// Already-purchased takes precedence over the default
|
||||
// Ausverkauft/discount badge — a customer who already bought
|
||||
// this doesn't need a restock notice, they need to know they
|
||||
// already own it (and can still remove it manually via
|
||||
// WishlistButton — this is a status note, not an
|
||||
// auto-removal, see feedback discussion this implements).
|
||||
// undefined (not purchased) falls through to ProductCard's
|
||||
// own default Ausverkauft/discount badge.
|
||||
topLeftBadge={
|
||||
item.purchasedAt ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-success-subtle px-2.5 py-1 text-label font-bold text-success">
|
||||
Gekauft am {formatDate(item.purchasedAt)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
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.
|
||||
export const metadata: Metadata = {
|
||||
title: "Meine Merkliste",
|
||||
description: "Deine gemerkten Produkte bei einfach produktiv.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function KontoMerklistePage() {
|
||||
const session = await getSessionCustomer();
|
||||
// ?redirect= back to this page — landing on the generic account/orders
|
||||
// overview after logging in from the wishlist icon (the common entry
|
||||
// point, unlike most other /konto/* pages that are only ever reached
|
||||
// once already logged in) would silently lose the wishlist context the
|
||||
// customer actually came here for.
|
||||
if (!session) redirect("/konto/login?redirect=%2Fkonto%2Fmerkliste");
|
||||
|
||||
// The feature can be switched off after a customer already had rows in
|
||||
// their wishlist — 404 rather than showing a stale page for a feature
|
||||
// that's no longer offered, same reasoning as any other feature-flagged
|
||||
// route in this codebase.
|
||||
const wishlistEnabled = await getWishlistEnabled();
|
||||
if (!wishlistEnabled) notFound();
|
||||
|
||||
const [wishlistItems, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getWishlist(session.token, session.customer.id),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const initialProducts = await getProductsByIds(wishlistItems.map((i) => i.productId));
|
||||
|
||||
return (
|
||||
<KontoShell>
|
||||
<Reveal className="flex flex-col gap-6 items-start w-full">
|
||||
<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>
|
||||
</KontoShell>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,10 @@ export function ProfileForm({
|
||||
shippingCountries: ShippingCountry[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(profile.hasDifferentShippingAddress);
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">(
|
||||
profile.shippingDeliveryMethod ?? "address",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -49,15 +52,32 @@ export function ProfileForm({
|
||||
const body = {
|
||||
firstName: String(form.get("firstName") ?? ""),
|
||||
lastName: String(form.get("lastName") ?? ""),
|
||||
deliveryMethod,
|
||||
// Always a plain street address — same "Rechnungsadresse is never a
|
||||
// Packstation" rule as /checkout's own billing card (an invoice
|
||||
// needs a real postal address). Packstation is only ever offered
|
||||
// below, in the optional "Lieferadresse" section.
|
||||
deliveryMethod: "address" as const,
|
||||
street: String(form.get("street") ?? "") || undefined,
|
||||
packstationNumber: String(form.get("packstationNumber") ?? "") || undefined,
|
||||
postNumber: String(form.get("postNumber") ?? "") || undefined,
|
||||
zip: String(form.get("zip") ?? ""),
|
||||
city: String(form.get("city") ?? ""),
|
||||
country: String(form.get("country") ?? ""),
|
||||
companyName: String(form.get("companyName") ?? "") || undefined,
|
||||
vatId: String(form.get("vatId") ?? "") || undefined,
|
||||
hasDifferentShippingAddress,
|
||||
shippingFirstName: hasDifferentShippingAddress ? String(form.get("shippingFirstName") ?? "") : undefined,
|
||||
shippingLastName: hasDifferentShippingAddress ? String(form.get("shippingLastName") ?? "") : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress ? String(form.get("shippingCompanyName") ?? "") || undefined : undefined,
|
||||
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
|
||||
shippingStreet: hasDifferentShippingAddress ? String(form.get("shippingStreet") ?? "") || undefined : undefined,
|
||||
shippingPackstationNumber: hasDifferentShippingAddress
|
||||
? String(form.get("shippingPackstationNumber") ?? "") || undefined
|
||||
: undefined,
|
||||
shippingPostNumber: hasDifferentShippingAddress ? String(form.get("shippingPostNumber") ?? "") || undefined : undefined,
|
||||
shippingZip: hasDifferentShippingAddress ? String(form.get("shippingZip") ?? "") : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? String(form.get("shippingCity") ?? "") : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? String(form.get("shippingCountry") ?? "") : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress ? String(form.get("shippingContactEmail") ?? "") || undefined : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress ? String(form.get("shippingContactPhone") ?? "") || undefined : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -83,14 +103,12 @@ 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"
|
||||
one further down read as one undifferentiated form instead of
|
||||
two distinct addresses. */}
|
||||
<p className="font-semibold text-body-sm text-text-primary">Rechnungsadresse</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Vorname" name="firstName" type="text" defaultValue={profile.firstName} required />
|
||||
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
|
||||
@@ -112,43 +130,18 @@ export function ProfileForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryMethod("address")}
|
||||
aria-pressed={deliveryMethod === "address"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${deliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Lieferadresse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryMethod("packstation")}
|
||||
aria-pressed={deliveryMethod === "packstation"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${deliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Packstation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deliveryMethod === "address" ? (
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
defaultValue={profile.street ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Packstationnummer" name="packstationNumber" type="text" defaultValue={profile.packstationNumber ?? ""} required />
|
||||
<Field label="Postnummer" name="postNumber" type="text" defaultValue={profile.postNumber ?? ""} required />
|
||||
</div>
|
||||
)}
|
||||
{/* Always a plain street address, no Lieferart/Packstation toggle —
|
||||
matches /checkout's own Rechnungsadresse card exactly (an
|
||||
invoice needs a real postal address). Packstation is only ever
|
||||
offered below, in the optional "Lieferadresse" section. */}
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
defaultValue={profile.street ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="PLZ" name="zip" type="text" defaultValue={profile.zip ?? ""} required />
|
||||
@@ -164,6 +157,111 @@ export function ProfileForm({
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<label className="flex gap-3 items-start w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasDifferentShippingAddress}
|
||||
onChange={(e) => setHasDifferentShippingAddress(e.target.checked)}
|
||||
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-body-sm text-text-primary">
|
||||
Abweichende Lieferadresse hinterlegen — wird beim Checkout vorgeschlagen, sobald dort "Abweichende Lieferadresse" aktiviert wird
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Lieferadresse</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Vorname" name="shippingFirstName" type="text" defaultValue={profile.shippingFirstName ?? ""} required />
|
||||
<Field label="Nachname" name="shippingLastName" type="text" defaultValue={profile.shippingLastName ?? ""} required />
|
||||
</div>
|
||||
<Field
|
||||
label="Firma (optional)"
|
||||
name="shippingCompanyName"
|
||||
type="text"
|
||||
defaultValue={profile.shippingCompanyName ?? ""}
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShippingDeliveryMethod("address")}
|
||||
aria-pressed={shippingDeliveryMethod === "address"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${shippingDeliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Lieferadresse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShippingDeliveryMethod("packstation")}
|
||||
aria-pressed={shippingDeliveryMethod === "packstation"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${shippingDeliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Packstation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shippingDeliveryMethod === "address" ? (
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="shippingStreet"
|
||||
type="text"
|
||||
defaultValue={profile.shippingStreet ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field
|
||||
label="Packstationnummer"
|
||||
name="shippingPackstationNumber"
|
||||
type="text"
|
||||
defaultValue={profile.shippingPackstationNumber ?? ""}
|
||||
required
|
||||
/>
|
||||
<Field label="Postnummer" name="shippingPostNumber" type="text" defaultValue={profile.shippingPostNumber ?? ""} required />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="PLZ" name="shippingZip" type="text" defaultValue={profile.shippingZip ?? ""} required />
|
||||
<Field label="Ort" name="shippingCity" type="text" defaultValue={profile.shippingCity ?? ""} required />
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select name="shippingCountry" defaultValue={profile.shippingCountry ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field
|
||||
label="Kontakt-E-Mail (optional)"
|
||||
name="shippingContactEmail"
|
||||
type="email"
|
||||
defaultValue={profile.shippingContactEmail ?? ""}
|
||||
/>
|
||||
<Field
|
||||
label="Telefonnummer (optional)"
|
||||
name="shippingContactPhone"
|
||||
type="tel"
|
||||
defaultValue={profile.shippingContactPhone ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
{success && <p className="text-label text-success">Gespeichert.</p>}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user