Compare commits
166 Commits
8a4170a1e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ebbe15ef73 | |||
| 1e3ab794f3 | |||
| d77d40a1bf | |||
| 601c2f5694 | |||
| 917795d810 | |||
| 807fda3d2a | |||
| 2b3868b605 | |||
| cefe2531d4 | |||
| a4606d26ad | |||
| 3e5c7b1a12 | |||
| 9de2693961 | |||
| 14d824491f | |||
| 9c6b717920 | |||
| 7babd0401f | |||
| 31b8e89ea4 | |||
| 48e73796b1 | |||
| c98294a6a4 | |||
| f98536ff88 | |||
| 207f33724b | |||
| 3e0e6c220d | |||
| 2686fd3106 | |||
| 52d0b8558e | |||
| 74bec632ca | |||
| 43bffaa5c1 | |||
| 6f6a6119f5 | |||
| 55d613d409 | |||
| 193b85a3ca | |||
| 698a2d736e | |||
| 7c842808cf | |||
| 06fee1739d | |||
| 33f3adb92c | |||
| 31e4f907f2 | |||
| d02707a212 | |||
| e9b7a2f582 | |||
| 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 |
+1
-1
@@ -12,7 +12,7 @@ function LockIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
export function EmailCapture({ buttonLabel = "Klarheits-Check starten" }: { buttonLabel?: string }) {
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("challenge");
|
||||
|
||||
@@ -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: "/lebensuhr",
|
||||
canonical: "/7-tage-klarheits-check",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/lebensuhr",
|
||||
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">Lebensuhr</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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await toggleWishlistItem(session.token, productId, variant);
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
+63
-16
@@ -57,11 +57,17 @@ export default async function BlogDetailPage({
|
||||
const post = await getPostBySlug(slug, { draft: isPreview });
|
||||
if (!post) notFound();
|
||||
|
||||
// "Weiterlesen" — any other post, most recent first. Not the current
|
||||
// one; falls back to nothing (section just doesn't render) rather than
|
||||
// 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;
|
||||
// "Weiterlesen" — the chronologically next-older post, wrapping around
|
||||
// to the newest post from the oldest one. Not getBlogPosts()'s own
|
||||
// -featured,-publishedAt order: picking "the first of the top 4 that
|
||||
// isn't this one" made nearly every post's Weiterlesen converge on the
|
||||
// same one or two most-recent posts (reported 2026-08-29). Sorting
|
||||
// purely by publishedAt here instead makes every post point somewhere
|
||||
// different, cycling through the whole archive one post at a time.
|
||||
const allPosts = await getBlogPosts(100);
|
||||
const byDate = [...allPosts].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
||||
const currentIndex = byDate.findIndex((p) => p.slug === post.slug);
|
||||
const nextPost = currentIndex === -1 || byDate.length < 2 ? null : byDate[(currentIndex + 1) % byDate.length];
|
||||
const seller = await getCompanySettings();
|
||||
const articleSchema = buildArticleSchema(post, seller);
|
||||
|
||||
@@ -125,16 +131,21 @@ export default async function BlogDetailPage({
|
||||
|
||||
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
{/* "Passend dazu" — per-post CMS content now (Posts.relatedProduct),
|
||||
not a hardcoded flagship-product link. Hidden entirely if the
|
||||
post has no related product, or that product has no detail
|
||||
page to send "Entdecken" to. Matches the actual built Figma
|
||||
frame exactly (node 4674:349, fetched via get_design_context) —
|
||||
1px border-border, rounded-md, px-9/py-7 padding; an earlier
|
||||
version guessed a plain borderless row instead, which
|
||||
get_metadata's structural dump didn't reveal (frame-level
|
||||
stroke/padding/radius aren't visible there, only
|
||||
get_design_context shows those). */}
|
||||
{post.relatedProduct?.href && (
|
||||
not a hardcoded flagship-product link. If the post has no
|
||||
related product (or that product has no detail page to send
|
||||
"Entdecken" to), falls back to a "Nicht verpassen" newsletter
|
||||
card instead of leaving this slot empty — /newsletter had no
|
||||
incoming link anywhere in the site despite being a fully
|
||||
built page (WERKZEUGE_ROUTES and TestimonialsPage already
|
||||
treated it as a first-class destination), and blog readers
|
||||
are exactly its target audience. Matches the actual built
|
||||
Figma frame exactly (node 4674:349, fetched via
|
||||
get_design_context) — 1px border-border, rounded-md,
|
||||
px-9/py-7 padding; an earlier version guessed a plain
|
||||
borderless row instead, which get_metadata's structural dump
|
||||
didn't reveal (frame-level stroke/padding/radius aren't
|
||||
visible there, only get_design_context shows those). */}
|
||||
{post.relatedProduct?.href ? (
|
||||
<Link
|
||||
href={post.relatedProduct.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"
|
||||
@@ -162,7 +173,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
|
||||
@@ -178,6 +189,42 @@ export default async function BlogDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/newsletter"
|
||||
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="/weekly-photo.png" 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">Nicht verpassen:</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)" }}
|
||||
>
|
||||
Wöchentliche Impulse
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">
|
||||
Jeden Mittwoch neue Denkanstöße direkt ins Postfach – kostenlos, jederzeit abbestellbar.
|
||||
</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">
|
||||
Abonnieren
|
||||
<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>
|
||||
|
||||
|
||||
+62
-91
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { BlogCategoryFilter } from "../components/BlogCategoryFilter";
|
||||
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
|
||||
import { formatDate } from "../lib/format";
|
||||
|
||||
@@ -31,11 +32,6 @@ function distinctCategories(posts: { categories: string[] }[]): string[] {
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
export default async function BlogOverviewPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -47,85 +43,43 @@ export default async function BlogOverviewPage({
|
||||
const allCategories = blogFilterEnabled ? distinctCategories(allPosts) : [];
|
||||
const posts =
|
||||
activeCategories.length === 0 ? allPosts : allPosts.filter((post) => post.categories.some((c) => activeCategories.includes(c)));
|
||||
const [featured, ...rest] = posts;
|
||||
// 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 sm:flex-row items-center w-full min-h-[20rem] sm: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 sm:py-0 w-full sm:w-auto sm: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 sm:absolute sm:inset-y-0 sm:right-0 sm:w-[68%] h-56 sm:h-full">
|
||||
<Image alt="" src="/hero.png" fill sizes="(min-width: 640px) 68vw, 100vw" className="object-cover object-top" />
|
||||
<div className="hidden sm: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>
|
||||
|
||||
{/* Plain div, not <Reveal> — this bar sits right at/just past the
|
||||
hero's bottom edge, exactly the "already near the initial
|
||||
viewport top" position Reveal.tsx's own comment documents as a
|
||||
whileInView(margin:"-80px") trap: the shrunk viewport can
|
||||
permanently miss triggering "entered view" for an element
|
||||
that's already visible without any further scroll, since
|
||||
`once: true` never gets a second chance. The Hero brand dot
|
||||
hit the identical bug and switched to a plain animate — this
|
||||
filter bar doesn't need a scroll-reveal animation at all, so
|
||||
it's simplest to just not wrap it in Reveal in the first place. */}
|
||||
{/* relative z-20 — the featured-post card right below pulls itself
|
||||
up by -mt-8 with its own z-10 to overlap the *hero's* bottom
|
||||
edge (its original, intended design). Inserting this filter
|
||||
bar between the hero and that card meant the same -mt-8 pull
|
||||
now overlapped THIS bar instead, and the card's higher/equal
|
||||
stacking rendered on top of it, visually hiding the chips
|
||||
behind the card. z-20 keeps this bar above that overlap
|
||||
regardless. */}
|
||||
{allCategories.length > 1 && (
|
||||
<div className="relative z-20 flex flex-wrap gap-2 w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-6">
|
||||
{allCategories.map((category) => {
|
||||
const active = activeCategories.includes(category);
|
||||
return (
|
||||
<Link
|
||||
key={category}
|
||||
href={buildCategoryHref(activeCategories, category)}
|
||||
// bg-bg-muted, not bg-bg-base — border-border (#e5e0d8)
|
||||
// on bg-base (#f8f5f1) is a ~2% lightness difference,
|
||||
// nearly invisible as a pill outline; a filled muted
|
||||
// background makes the chip read as a discrete control
|
||||
// regardless of the border's own low contrast.
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
|
||||
active
|
||||
? "bg-brand border-brand text-text-primary"
|
||||
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{activeCategories.length > 0 && (
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center px-3 py-1.5 text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
|
||||
>
|
||||
Zurücksetzen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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.
|
||||
@@ -133,10 +87,11 @@ export default async function BlogOverviewPage({
|
||||
)}
|
||||
|
||||
{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 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"
|
||||
@@ -147,12 +102,12 @@ export default async function BlogOverviewPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-3 p-8 sm: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.categories.join(", ")}</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">{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"
|
||||
@@ -178,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">
|
||||
@@ -188,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.categories.join(", ")}</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"
|
||||
|
||||
@@ -13,6 +13,9 @@ 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 { ProductBadge } from "../../components/ProductBadge";
|
||||
import { FreeShippingBanner } from "./FreeShippingBanner";
|
||||
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
|
||||
@@ -163,7 +166,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>
|
||||
@@ -227,32 +230,53 @@ 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"
|
||||
/>
|
||||
{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}%
|
||||
</span>
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-2 left-2">
|
||||
<ProductBadge product={product} />
|
||||
</div>
|
||||
</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">
|
||||
@@ -302,7 +326,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>
|
||||
@@ -523,7 +547,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 sm:grid-cols-12 gap-6 sm: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 sm: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
|
||||
@@ -148,61 +159,8 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
: ""
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<div className="relative w-full aspect-[320/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 640px) 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 />
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/orde
|
||||
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";
|
||||
@@ -37,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
|
||||
@@ -52,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 {
|
||||
@@ -398,6 +397,11 @@ export function CheckoutContent({
|
||||
selectedShipping?.freeShippingThreshold !== undefined &&
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || !cartHasShippableItem(items) || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
// For VersandModal — same "lowest freeShippingThreshold among active
|
||||
// methods" rule as CartContent.tsx/getTrustBadges(), independent of
|
||||
// whichever method is currently selected in the radio list above.
|
||||
const shippingMethodThresholds = shippingMethods.map((m) => m.freeShippingThreshold).filter((t): t is number => t !== null);
|
||||
const lowestFreeShippingThreshold = shippingMethodThresholds.length > 0 ? Math.min(...shippingMethodThresholds) : null;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
@@ -1552,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,9 +96,16 @@ export function About() {
|
||||
>
|
||||
<Image
|
||||
alt="Björn"
|
||||
src="/about-author.jpg"
|
||||
src="/about-banner.jpg"
|
||||
fill
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
// Reverted to the original wide banner photo (2448x896) per
|
||||
// 2026-08-29 request — the real bjoern.png headshot is a square
|
||||
// close-up that doesn't suit this wide/short strip (see git
|
||||
// history on this line for that whole detour). Blog posts keep
|
||||
// using the new photo via about-author.jpg; this section alone
|
||||
// goes back to its own dedicated file, about-banner.jpg, whose
|
||||
// wide aspect was always the right shape for object-cover here.
|
||||
className="object-cover object-center pointer-events-none"
|
||||
/>
|
||||
{/* Left gradient — from sm: up, not just lg:. Even without the
|
||||
|
||||
@@ -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,9 +18,11 @@ export function AddToCartButton({
|
||||
label,
|
||||
className,
|
||||
productId = "todo-karten",
|
||||
numericId,
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
showQuantityStepper = false,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
@@ -27,6 +30,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;
|
||||
@@ -37,8 +44,14 @@ export function AddToCartButton({
|
||||
* `variants` prop; all three callers already fetch the full product
|
||||
* server-side, so this is just threaded straight through. */
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
/** Opt-in +/- quantity control before the button, adding `qty` at once
|
||||
* instead of one click per unit. Off by default — every existing caller
|
||||
* (grid cards, spotlight) keeps today's exact one-click-adds-one
|
||||
* behavior; only the PDP hero/pricing panel enable this. */
|
||||
showQuantityStepper?: boolean;
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const [qty, setQty] = useState(1);
|
||||
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -50,14 +63,17 @@ export function AddToCartButton({
|
||||
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
|
||||
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
|
||||
const qtyInCart = cart.find((i) => i.id === productId && i.variant === selectedVariant)?.qty ?? 0;
|
||||
const remainingQty = currentMaxQty != null ? Math.max(0, currentMaxQty - qtyInCart) : null;
|
||||
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
|
||||
const disabled = currentlyOutOfStock || limitReached;
|
||||
const clampedQty = remainingQty != null ? Math.min(qty, Math.max(1, remainingQty)) : qty;
|
||||
|
||||
function handleClick() {
|
||||
if (disabled) return;
|
||||
addToCart(productId, 1, selectedVariant);
|
||||
addToCart(productId, showQuantityStepper ? clampedQty : 1, selectedVariant);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
setQty(1);
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setAdded(false), FEEDBACK_MS);
|
||||
}
|
||||
@@ -82,7 +98,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 +129,72 @@ 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}
|
||||
{showQuantityStepper && !currentlyOutOfStock && !limitReached && (
|
||||
<div className="flex items-center gap-3 w-fit rounded-sm border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty((q) => Math.max(1, q - 1))}
|
||||
disabled={qty <= 1}
|
||||
aria-label="Menge verringern"
|
||||
className="flex items-center justify-center size-9 shrink-0 text-body font-bold text-text-primary disabled:opacity-40 hover:bg-bg-muted transition-colors"
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<span className="min-w-4 text-center text-body-sm text-text-primary tabular-nums">{clampedQty}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty((q) => (remainingQty != null ? Math.min(remainingQty, q + 1) : q + 1))}
|
||||
disabled={remainingQty != null && clampedQty >= remainingQty}
|
||||
aria-label="Menge erhöhen"
|
||||
className="flex items-center justify-center size-9 shrink-0 text-body font-bold text-text-primary disabled:opacity-40 hover:bg-bg-muted transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
+103
-84
@@ -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,48 +32,67 @@ 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 sm+ */}
|
||||
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 sm:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
|
||||
<div className="relative w-full aspect-video sm:aspect-auto sm:col-span-7 sm:h-[220px] bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={featured.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 sm:pr-4 sm:pl-4 sm: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 sm: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 through lg (1024px), side-by-side
|
||||
@@ -82,50 +102,49 @@ export async function Blog() {
|
||||
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 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 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,11 @@ export function CustomSelect({
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
// Without this, a mousedown on an <li> blurs the trigger button
|
||||
// first (li isn't focusable) — the resulting onBlur closes and
|
||||
// unmounts this list before the click event ever fires, so
|
||||
// nothing is ever selectable by mouse/touch.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="absolute z-20 mt-1 w-full sm:min-w-[12rem] max-h-64 overflow-y-auto bg-bg-base border border-border rounded-sm shadow-lg py-1"
|
||||
>
|
||||
{allOptions.map((o, i) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export function Hero() {
|
||||
image/banner above it on true Mobile), left-aligned with the
|
||||
rest of the text column again from sm: up. */}
|
||||
<Link
|
||||
href="/lebensuhr"
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { renderProductBlock, type ProductBlockContext } from "./ProductBlocks";
|
||||
import { mapPayloadProduct, type PayloadProduct, type Product } from "../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of a Products document's `layout` — same
|
||||
// "editable subset only" scope as LivePageContent.tsx/LivePostContent.tsx:
|
||||
// `testimonialsRef` and `crossSell` are skipped here (both need an async
|
||||
// fetch a client component can't perform inline — testimonialsRef isn't
|
||||
// even registered on Products.layout anymore, kept out of the switch for
|
||||
// the same reason; crossSell's related-product card just doesn't update
|
||||
// live, shows correctly once saved and reloaded outside the iframe).
|
||||
// shipping/tax/Kleinunternehmer are passed in once from the server page
|
||||
// component instead of re-fetched on every keystroke — see
|
||||
// getProductBlockContext's own comment.
|
||||
export function LiveProductContent({
|
||||
initialProduct,
|
||||
shipping,
|
||||
taxRate,
|
||||
kleinunternehmer,
|
||||
}: {
|
||||
initialProduct: Product;
|
||||
} & Omit<ProductBlockContext, "product">) {
|
||||
const { data } = useLivePreview<PayloadProduct>({
|
||||
initialData: initialProduct as unknown as PayloadProduct,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 2,
|
||||
});
|
||||
const product = data?.slug ? mapPayloadProduct(data) : initialProduct;
|
||||
const ctx: ProductBlockContext = { product, shipping, taxRate, kleinunternehmer };
|
||||
|
||||
return <>{product.layout.map((block) => <div key={block.id}>{renderProductBlock(block, ctx)}</div>)}</>;
|
||||
}
|
||||
+99
-28
@@ -7,7 +7,7 @@ import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
import { SearchButton } from "./SearchOverlay";
|
||||
import { SearchButton, SearchOverlay } from "./SearchOverlay";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
@@ -46,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" },
|
||||
];
|
||||
}
|
||||
@@ -60,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", "/lebensuhr", "/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 = ["/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") {
|
||||
@@ -134,23 +137,49 @@ function AccountLink() {
|
||||
}
|
||||
|
||||
// Wishlist icon + count badge — only rendered by the caller when
|
||||
// `wishlistEnabled` (CompanySettings), and even then hidden below `sm:`.
|
||||
// Account+Cart are the only always-visible icons on true mobile (see the
|
||||
// trailing-controls group's own comment: gap-2 there was already tuned
|
||||
// specifically for exactly 2 icons) — a 3rd icon squeezed in at the
|
||||
// smallest phone widths risks the exact nav-overflow class of bug
|
||||
// documented in the figma-to-nextjs skill (computed hamburger/icon-row
|
||||
// thresholds, not assumed ones). sm+ has real room to spare.
|
||||
// `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 hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
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" fill="none" aria-hidden="true">
|
||||
<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"
|
||||
@@ -159,7 +188,12 @@ function WishlistLink() {
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
)}
|
||||
@@ -178,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
|
||||
@@ -192,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);
|
||||
@@ -260,6 +305,7 @@ export function Navbar({
|
||||
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);
|
||||
|
||||
@@ -443,7 +489,7 @@ export function Navbar({
|
||||
: "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
|
||||
@@ -455,7 +501,7 @@ export function Navbar({
|
||||
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) => {
|
||||
@@ -467,11 +513,21 @@ export function Navbar({
|
||||
: 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>
|
||||
@@ -492,8 +548,17 @@ export function Navbar({
|
||||
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. */}
|
||||
<nav className="hidden lg:flex items-center gap-12">
|
||||
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 = (
|
||||
@@ -543,17 +608,22 @@ export function Navbar({
|
||||
{/* 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 />}
|
||||
{searchEnabled && <SearchButton onOpen={() => setSearchOpen(true)} />}
|
||||
<AccountLink />
|
||||
{wishlistEnabled && <WishlistLink />}
|
||||
<CartLink />
|
||||
@@ -747,6 +817,7 @@ export function Navbar({
|
||||
</AnimatePresence>
|
||||
|
||||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||||
{searchEnabled && <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,6 +213,20 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
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>
|
||||
|
||||
{/* Quick-access signup here, full pitch (Testimonials, "So
|
||||
funktioniert's") one click away for anyone not ready to
|
||||
type an email yet — same "lightweight modal vs. full page"
|
||||
split this file's own top comment describes, just also
|
||||
offering the other side of it from within the modal
|
||||
itself instead of only via the Navbar. */}
|
||||
<Link
|
||||
href="/newsletter"
|
||||
onClick={onClose}
|
||||
className="text-label text-text-muted underline hover:text-brand transition-colors -mt-3"
|
||||
>
|
||||
Mehr über die wöchentlichen Impulse erfahren
|
||||
</Link>
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
|
||||
@@ -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,283 @@
|
||||
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>
|
||||
);
|
||||
|
||||
case "storySplit":
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className={`flex flex-col gap-8 lg:gap-10 items-center w-full ${block.imagePosition === "right" ? "lg:flex-row-reverse" : "lg:flex-row"}`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<div className="flex-1 w-full flex flex-col gap-4">
|
||||
{block.heading && (
|
||||
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{block.heading}
|
||||
</p>
|
||||
)}
|
||||
{block.content ? <RichText content={block.content} /> : null}
|
||||
{block.bullets.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{block.bullets.map((bullet) => (
|
||||
<li key={bullet.id} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{bullet.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "faq":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-3 w-full">
|
||||
{block.items.map((item) => (
|
||||
<details key={item.id} className="group border border-border rounded-md px-5 py-4 open:border-brand transition-colors">
|
||||
<summary className="flex items-center justify-between gap-4 cursor-pointer list-none font-semibold text-body text-text-primary">
|
||||
{item.question}
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="size-4 shrink-0 text-text-muted transition-transform duration-200 group-open:rotate-45"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 4v12M4 10h12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="pt-3 text-body-sm text-text-muted">
|
||||
<RichText content={item.answer} />
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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.
|
||||
export 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,29 @@
|
||||
import type { Product } from "../lib/payload";
|
||||
import { discountPercent } from "../lib/format";
|
||||
|
||||
// Single shared badge — used everywhere a product's image shows one:
|
||||
// PDP hero/pricing panel (ProductBlocks.tsx), shop grid, cart's
|
||||
// RelatedProducts, wishlist grid (all via ProductCard.tsx). Previously
|
||||
// each of those re-implemented the same Ausverkauft/discount logic
|
||||
// separately, so Products.badge ("Neu") only showed up on the PDP —
|
||||
// consolidated here so every surface stays in sync automatically
|
||||
// (reported 2026-08-29). Priority: out-of-stock, then discount, then
|
||||
// the one manual state (badge === "new") — never two pills at once.
|
||||
// Discount/Ausverkauft are derived, never editor-set — see Products.ts's
|
||||
// own field comment on why "new" is the only manual option.
|
||||
export function ProductBadge({ product }: { product: Product }) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
|
||||
if (fullyOutOfStock) {
|
||||
return <span className="rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">Ausverkauft</span>;
|
||||
}
|
||||
if (discount !== null) {
|
||||
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">-{discount}%</span>;
|
||||
}
|
||||
if (product.badge === "new") {
|
||||
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">Neu</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
|
||||
import {
|
||||
getShippingSettings,
|
||||
getDefaultTaxRatePercent,
|
||||
getKleinunternehmer,
|
||||
getTestimonials,
|
||||
getProductsByIds,
|
||||
} 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 { ProductGallery } from "./ProductGallery";
|
||||
import { ProductBadge } from "./ProductBadge";
|
||||
import { STEP_ICONS } from "./icons/StepIcons";
|
||||
import { renderPageBlockSync, CheckIcon } 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 ctx = await getProductBlockContext(product);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
// Needs its own async fetch — same reasoning as testimonialsRef just
|
||||
// above. A single hand-picked product (Payload dropdown, excludes
|
||||
// itself) — same shape as Products.relatedProduct/the blog's
|
||||
// relatedProduct, just editable per PDP block instead of a fixed
|
||||
// shell field.
|
||||
if (block.blockType === "crossSell") {
|
||||
const [related] = await getProductsByIds([block.productId]);
|
||||
if (!related) return null;
|
||||
// Same bordered "Passt dazu" card as der-eine's own hand-coded
|
||||
// PasstDazu.tsx (and the blog's "Passend dazu" card) — thumbnail +
|
||||
// name + description + "Entdecken" arrow, no price/wishlist/buy
|
||||
// button. Reads as editorial, not a commerce grid; ProductCard
|
||||
// (price, wishlist heart, add-to-cart) was too busy here
|
||||
// (reported 2026-08-29).
|
||||
return (
|
||||
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12 max-w-[48rem] mx-auto">
|
||||
<Link
|
||||
href={related.href ?? `/${related.id}`}
|
||||
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 src={related.image} alt="" 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>
|
||||
</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>;
|
||||
}
|
||||
// richTextSection→stepRow is always the "Was kann er?" heading
|
||||
// directly above its icon row on a PDP — both getting their own full
|
||||
// py-12/16 stacked (pb of one + pt of the other) put ~100-128px
|
||||
// between a heading and the row it's introducing, reading as two
|
||||
// unrelated sections instead of one (reported 2026-08-30). Trim the
|
||||
// heading's own bottom gap so the two feel like one group, while
|
||||
// keeping the outer edges (after Hero, before Pricing) generous.
|
||||
const isPdpHeadingBeforeIcons = block.blockType === "richTextSection";
|
||||
const isPdpIconsAfterHeading = block.blockType === "stepRow";
|
||||
return (
|
||||
<Reveal
|
||||
key={block.id}
|
||||
className={
|
||||
isPdpHeadingBeforeIcons
|
||||
? "w-full px-[var(--layout-padding-x)] pt-8 md:pt-10 pb-4 md:pb-6"
|
||||
: isPdpIconsAfterHeading
|
||||
? "w-full px-[var(--layout-padding-x)] pt-2 md:pt-4 pb-8 md:pb-10"
|
||||
: "w-full px-[var(--layout-padding-x)] py-12 md:py-16"
|
||||
}
|
||||
>
|
||||
{renderProductBlock(block, ctx)}
|
||||
</Reveal>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return <>{rendered}</>;
|
||||
}
|
||||
|
||||
export type ProductBlockContext = {
|
||||
product: Product;
|
||||
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
|
||||
taxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
};
|
||||
|
||||
// Split out so LiveProductContent.tsx (the "use client" live-preview
|
||||
// counterpart, same pattern as LivePageContent.tsx/renderPageBlockSync)
|
||||
// can fetch this once itself instead of duplicating the three calls —
|
||||
// shipping/tax/Kleinunternehmer status essentially never change between
|
||||
// keystrokes in a preview session, unlike the product doc itself.
|
||||
export async function getProductBlockContext(product: Product): Promise<ProductBlockContext> {
|
||||
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
return { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
|
||||
}
|
||||
|
||||
export function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
case "productHero":
|
||||
return <ProductHero {...ctx} body={block.body} />;
|
||||
case "productPricingPanel":
|
||||
return <ProductPricingPanel {...ctx} />;
|
||||
// Needs an async fetch — handled by ProductBlocks' own outer
|
||||
// Promise.all above, same as testimonialsRef. Never actually reached
|
||||
// from there since both return their own JSX before calling this.
|
||||
case "crossSell":
|
||||
return null;
|
||||
// Same max-w-[48rem] mx-auto as the stepRow fact-cards/crossSell card
|
||||
// below — without this, a heading/prose block sat edge-to-edge while
|
||||
// the cards under it were capped, reading as "still not width-limited"
|
||||
// even after the cards themselves were (reported 2026-08-30).
|
||||
// [&_h2>span]:hidden — RichText's heading converter always appends a
|
||||
// small brand-underline span under every h2 (shared with blog/legal
|
||||
// pages, where it's wanted); der-eine's own original heading had no
|
||||
// such decoration, plain text only (reported 2026-08-30). Hidden via
|
||||
// CSS rather than a RichText prop, same "override scoped to
|
||||
// ProductBlocks.tsx, don't touch the shared renderer" approach as the
|
||||
// quote/stepRow cases.
|
||||
case "richTextSection":
|
||||
return (
|
||||
<div key={block.id} className="max-w-[48rem] mx-auto [&_h2>span]:hidden">
|
||||
<RichText content={block.content} />
|
||||
</div>
|
||||
);
|
||||
// Black, not brand-yellow — matching der-eine's own hand-coded Focus.tsx
|
||||
// icons (#231f20 stroke), which this whole PDP system is meant to look
|
||||
// like. STEP_ICONS' SVGs hardcode stroke="#f6a701" per-element (shared
|
||||
// with Pages.layout's /lebensuhr, /3x3-system, /7-tage-klarheits-check,
|
||||
// which DO want brand-yellow) — overriding via a CSS descendant
|
||||
// selector here (SVG presentation attributes lose to any stylesheet
|
||||
// rule) scopes the recolor to PDPs only, without touching the shared
|
||||
// icon set or PageBlocks.tsx's own rendering.
|
||||
// Parallel fact cards, not a connected sequence — icon on top,
|
||||
// centered, no StepArrow connectors between them (those imply an
|
||||
// order these facts don't have). Reuses stepRow's existing icon/
|
||||
// title/description shape rather than a new block type; only the
|
||||
// rendering diverges from Pages.layout's own connected-arrow stepRow
|
||||
// (renderPageBlockSync), scoped to ProductBlocks.tsx. Icons black
|
||||
// (#231f20), same override reasoning as the quote block below.
|
||||
// max-w-[48rem] (blog-body width) read too squeezed for a 3-4-card
|
||||
// row (reported 2026-08-30, same day it was narrowed) — back to
|
||||
// der-eine's own original Focus.tsx width.
|
||||
case "stepRow":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col sm:flex-row gap-8 items-start w-full max-w-[75rem] mx-auto">
|
||||
{block.items.map((item) => {
|
||||
const Icon = STEP_ICONS[item.icon];
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none mx-auto">
|
||||
{Icon && (
|
||||
<span className="flex items-center justify-center h-16 [&_svg_*]:!stroke-[#231f20] [&_[fill='#f6a701']]:!fill-[#231f20]">
|
||||
{Icon()}
|
||||
</span>
|
||||
)}
|
||||
<p className="font-semibold text-body text-text-primary">{item.title}</p>
|
||||
<p className="text-body-sm text-text-primary w-full text-left md:text-center">{item.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
// A PDP-specific full-bleed treatment for this one block type only —
|
||||
// not editing the shared Quote.tsx/renderPageBlockSync, which would
|
||||
// also restyle every Pages.layout quote (e.g. /ueber-mich's).
|
||||
case "quote":
|
||||
return (
|
||||
<div key={block.id} className="bg-brand rounded-xl px-6 py-10 sm:px-12 sm:py-14 text-center">
|
||||
<p className="font-semibold text-h-small sm:text-h3 text-text-primary leading-snug" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{block.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
// 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">
|
||||
{/* pb-6 md:pb-8 — previously had no bottom padding at all, then
|
||||
overcorrected to pb-10/12 (reported 2026-08-30 as "too much" once
|
||||
seen outside the stale draft-mode render — see richTextSection's
|
||||
own comment). Compact but still a real gap, more than the
|
||||
heading→icons seam. */}
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12 pb-6 md:pb-8">
|
||||
<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}
|
||||
|
||||
{/* Plain checkmark, not usp.icon's own STEP_ICONS lookup — a
|
||||
circled checkCircle glyph didn't match the plain checkmark
|
||||
every other feature-bullet list on the site uses (der-eine's
|
||||
original Hero.tsx, RichText.tsx's/PageBlocks.tsx's own
|
||||
checklists) — reported 2026-08-30. usp.icon is still stored
|
||||
(may be used elsewhere later), just not used for this list. */}
|
||||
{product.usps.length > 0 && (
|
||||
<ul className="flex flex-col gap-2 w-full">
|
||||
{product.usps.map((usp, i) => (
|
||||
<li key={i} className="flex items-start gap-[0.625rem]">
|
||||
<CheckIcon />
|
||||
<p className="flex-1 text-body-sm text-text-body">{usp.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<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"
|
||||
productId={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
showQuantityStepper
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* max-w-[28rem] + centered, not a full-bleed col-span-7 image —
|
||||
matches der-eine's own Hero.tsx exactly (that file's own
|
||||
comment: capping display width keeps a modest source photo
|
||||
from blowing up). This system is meant to look like der-eine,
|
||||
not the other way round. */}
|
||||
<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} badge={<ProductBadge product={product} />} />
|
||||
</div>
|
||||
</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);
|
||||
|
||||
// pt-6 md:pt-8 — compact gap after the icons (stepRow's own pb-8/10
|
||||
// already provides most of the seam); pb-12/16 kept generous below,
|
||||
// same rhythm as the rest of the layout, before crossSell/footer.
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] pt-6 md:pt-8 pb-12 md:pb-16">
|
||||
<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"
|
||||
/>
|
||||
<div className="absolute top-3 left-3">
|
||||
<ProductBadge product={product} />
|
||||
</div>
|
||||
</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"
|
||||
productId={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
showQuantityStepper
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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 { ProductBadge } from "./ProductBadge";
|
||||
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" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-3 left-3">
|
||||
{topLeftBadge !== undefined ? topLeftBadge : <ProductBadge product={product} />}
|
||||
</div>
|
||||
{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,115 @@
|
||||
"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,
|
||||
badge,
|
||||
}: {
|
||||
image: string;
|
||||
gallery: string[];
|
||||
alt: string;
|
||||
/** Optional overlay (e.g. a discount/Ausverkauft/Neu pill) absolutely
|
||||
* positioned over the main image — top-3.5/left-3.5, same corner every
|
||||
* other badge on the site anchors to. Not part of this component's own
|
||||
* data; the caller decides what, if anything, to show. */
|
||||
badge?: React.ReactNode;
|
||||
}) {
|
||||
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" />
|
||||
{badge && <div className="absolute top-3.5 left-3.5">{badge}</div>}
|
||||
</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" />
|
||||
{badge && <div className="absolute top-3.5 left-3.5">{badge}</div>}
|
||||
<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 cursor-pointer"
|
||||
>
|
||||
<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 cursor-pointer"
|
||||
>
|
||||
<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 cursor-pointer ${
|
||||
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,12 +1,61 @@
|
||||
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 { WishlistButton } from "./WishlistButton";
|
||||
import { ProductBadge } from "./ProductBadge";
|
||||
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),
|
||||
@@ -55,26 +104,35 @@ export async function ProductSpotlight() {
|
||||
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: 1024px) 380px, (min-width: 640px) 512px, 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>
|
||||
{/* Link wraps only the image, not the whole card — WishlistButton
|
||||
below is its own interactive element and stays a sibling, same
|
||||
"no nested interactive elements" reasoning as ProductCard.tsx's
|
||||
own image link (reported 2026-08-30: spotlight's image wasn't
|
||||
clickable at all before this, unlike every product-grid card). */}
|
||||
{product.href ? (
|
||||
<Link href={product.href} aria-label={product.name} className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
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>
|
||||
)
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-3 left-3">
|
||||
<ProductBadge product={product} />
|
||||
</div>
|
||||
{wishlistEnabled && product.spotlightShowWishlist && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -86,9 +144,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). */}
|
||||
@@ -127,7 +183,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.
|
||||
@@ -156,6 +176,20 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
},
|
||||
listitem: ({ node, nodesToJSX }) => <li>{nodesToJSX({ nodes: node.children })}</li>,
|
||||
link: ({ node, nodesToJSX }) => (
|
||||
<a
|
||||
href={node.fields?.url ?? "#"}
|
||||
className="text-brand hover:underline"
|
||||
{...(node.fields?.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
>
|
||||
{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>
|
||||
@@ -240,6 +274,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>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,45 +7,33 @@ import type { SearchResult } from "../api/search/route";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
export function SearchButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
{/* hidden sm: — same reasoning as Navbar's WishlistLink: Account+Cart
|
||||
are the only always-visible icons on true mobile, a 3rd icon
|
||||
there risks the same computed nav-overflow class of bug the
|
||||
figma-to-nextjs skill documents. sm+ has real room to spare. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Suche öffnen"
|
||||
className="hidden sm: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>
|
||||
{open && <SearchOverlay onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -53,8 +41,21 @@ function SearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
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);
|
||||
@@ -78,6 +79,8 @@ function SearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
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()}>
|
||||
@@ -94,8 +97,14 @@ function SearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
placeholder="Produkte, Blogbeiträge…"
|
||||
className="flex-1 min-w-0 bg-transparent outline-none text-h4 text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
Esc
|
||||
{/* 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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -34,7 +35,7 @@ export async function Tools() {
|
||||
arrives where the fluid floor also sits), stacked below sm;
|
||||
internal icon+text layout stays horizontal at every size, only
|
||||
the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-10 sm:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
<RevealGroup className="grid grid-cols-1 gap-y-14 sm:grid-cols-12 sm:gap-y-10 gap-x-10 sm:gap-x-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{tools.map((tool) => (
|
||||
<RevealItem
|
||||
key={tool.id}
|
||||
@@ -87,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,18 +19,32 @@ export function WishlistButton({
|
||||
productId: number;
|
||||
variant?: string;
|
||||
className?: string;
|
||||
/** false (default): always visible — right for single-product contexts
|
||||
* (ProductSpotlight, /konto/merkliste, the product detail page) where
|
||||
* there's no "wall of hearts" to thin out. 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 a
|
||||
* multi-card grid (ProductGrid.tsx), where a heart on every single card
|
||||
* reads as visual noise (Marco: "sieht man überall Herzen", 2026-07-31).
|
||||
* Relies on the parent card already carrying `group`/`focus-within`
|
||||
* (see ProductGrid.tsx) — Tailwind's plain `:hover`, so tapping a card
|
||||
* on touch devices reveals it the same way the existing
|
||||
* `group-hover:-translate-y-1` card-lift already does, no separate
|
||||
* touch handling needed. */
|
||||
/** 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();
|
||||
@@ -61,13 +75,19 @@ export function WishlistButton({
|
||||
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 ${
|
||||
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"
|
||||
? "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"}>
|
||||
<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"
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCup() {
|
||||
// Mug + handle + steam — for "pour yourself a drink" steps (Tasse "Die
|
||||
// Pause." was the first thing that needed this, none of the existing 10
|
||||
// fit a physical cup product at all).
|
||||
return (
|
||||
<svg width="40" height="44" viewBox="0 0 40 44" fill="none">
|
||||
<path d="M6 16h20v14a7 7 0 0 1-7 7h-6a7 7 0 0 1-7-7V16z" stroke="#f6a701" strokeWidth="2" strokeLinejoin="round" />
|
||||
<path d="M26 20h3a5 5 0 0 1 0 10h-3" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M12 4c-2 2-2 3.5 0 5.5M20 4c-2 2-2 3.5 0 5.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPen() {
|
||||
// Pen/nib — same glyph as der-eine's own hand-coded Focus.tsx IconMetal
|
||||
// (32x32 coords carried over unchanged), now available through the
|
||||
// shared set for other PDPs — der-eine's PDP is the block-builder's
|
||||
// reference template, its own icons belong in the shared library too.
|
||||
return (
|
||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M10 22l2.5-0.8L23 10.7l-2.7-2.7L9.8 19.5 9 22z" stroke="#f6a701" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19 9l3 3" stroke="#f6a701" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSparkle() {
|
||||
// Engraving/laser mark — same glyph as der-eine's own IconSparkle.
|
||||
return (
|
||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none">
|
||||
<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"
|
||||
stroke="#f6a701"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCompact() {
|
||||
// Four corner brackets pointing inward — same glyph as der-eine's own
|
||||
// IconCompact ("fits anywhere").
|
||||
return (
|
||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M11 8H8v3M21 8h3v3M11 24H8v-3M21 24h3v-3" stroke="#f6a701" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPause() {
|
||||
// Two rounded bars — "sit still for a moment" steps.
|
||||
return (
|
||||
<svg width="32" height="40" viewBox="0 0 32 40" fill="none">
|
||||
<rect x="5" y="4" width="8" height="32" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="19" y="4" width="8" height="32" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconVase() {
|
||||
// Classic ornate pottery/vase silhouette (rim, neck, flared belly, foot)
|
||||
// — a literal ceramic shape reads as "Vintage-Porzellan" far more
|
||||
// directly than an abstract medallion did (reported 2026-08-30, the
|
||||
// seal glyph still didn't land).
|
||||
return (
|
||||
<svg width="30" height="40" viewBox="0 0 30 40" fill="none">
|
||||
<path
|
||||
d="M10 3h10M11 3v4.5c0 1.6-1.5 2.6-3.4 4.6C4.5 15.5 3 19 3 23.5 3 32 8.5 37 15 37s12-5 12-13.5c0-4.5-1.5-8-4.6-11.4C20.5 10.1 19 9.1 19 7.5V3"
|
||||
stroke="#f6a701"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M6 22c2 1.5 4.5 2.3 9 2.3s7-.8 9-2.3" stroke="#f6a701" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconStar() {
|
||||
// A single starred moment — "Für den einen Moment" flags one thing as
|
||||
// special, closer to what a star marks than an hourglass's literal
|
||||
// "time passing" (reported 2026-08-30, hourglass still didn't land).
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<path
|
||||
d="M20 3l4.5 10.3L35 14.6l-8 7.6 2.2 10.8L20 27.6l-9.2 5.4L13 22.2 5 14.6l10.5-1.3L20 3z"
|
||||
stroke="#f6a701"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconDroplets() {
|
||||
// Two water drops — dishwasher-safe facts.
|
||||
return (
|
||||
<svg width="36" height="40" viewBox="0 0 36 40" fill="none">
|
||||
<path
|
||||
d="M13 4c4 5.5 6 9.2 6 12.5a6 6 0 1 1-12 0C7 13.2 9 9.5 13 4z"
|
||||
stroke="#f6a701"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25 17c3 4 4.3 6.6 4.3 8.9a4.3 4.3 0 1 1-8.6 0c0-2.3 1.3-4.9 4.3-8.9z"
|
||||
stroke="#f6a701"
|
||||
strokeWidth="1.6"
|
||||
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,
|
||||
cup: IconCup,
|
||||
pause: IconPause,
|
||||
pen: IconPen,
|
||||
sparkle: IconSparkle,
|
||||
compact: IconCompact,
|
||||
seal: IconVase,
|
||||
droplets: IconDroplets,
|
||||
hourglass: IconStar,
|
||||
};
|
||||
|
||||
// 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,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { draftMode } from "next/headers";
|
||||
import { ProductBlocks, getProductBlockContext } from "../components/ProductBlocks";
|
||||
import { LiveProductContent } from "../components/LiveProductContent";
|
||||
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.";
|
||||
|
||||
// der-eine was the reference template the whole PDP block-builder system
|
||||
// was designed to match (gallery sizing, black stepRow-facts icons — see
|
||||
// ProductBlocks.tsx's own comments) — now migrated
|
||||
// onto it itself, same as /tasse-die-pause before it. Testimonials.tsx
|
||||
// dropped entirely rather than migrated: the PDP builder no longer offers
|
||||
// testimonialsRef (removed 2026-08-29, unused everywhere; the planned
|
||||
// replacement is a real reviews feature). Hero.tsx/Focus.tsx/
|
||||
// Testimonials.tsx/Pricing.tsx/PasstDazu.tsx in ./components are now dead
|
||||
// code, left for a follow-up cleanup pass rather than deleted here — same
|
||||
// as /tasse-die-pause's own old components.
|
||||
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 { isEnabled: isPreview } = await draftMode();
|
||||
const [product, seller] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
if (!product) notFound();
|
||||
const productSchema = buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-eine", seller);
|
||||
const ctx = isPreview ? await getProductBlockContext(product) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
<main className="flex flex-col flex-1">
|
||||
{ctx ? (
|
||||
<LiveProductContent initialProduct={product} shipping={ctx.shipping} taxRate={ctx.taxRate} kleinunternehmer={ctx.kleinunternehmer} />
|
||||
) : (
|
||||
<ProductBlocks product={product} />
|
||||
)}
|
||||
</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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
renderOrderConfirmationHtml,
|
||||
renderPasswordResetHtml,
|
||||
renderOrderStatusHtml,
|
||||
renderBackInStockHtml,
|
||||
ORDER_STATUS_EMAIL_ICON,
|
||||
SAMPLE_ORDER,
|
||||
SAMPLE_ORDER_MANUAL,
|
||||
@@ -51,13 +52,21 @@ export function LiveEmailPreviewClient({
|
||||
? 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" }}>
|
||||
|
||||
@@ -20,6 +20,7 @@ const VALID_TYPES: EmailTemplateType[] = [
|
||||
"order-tracking-corrected",
|
||||
"order-delivered",
|
||||
"payment-method-switched",
|
||||
"back-in-stock",
|
||||
];
|
||||
|
||||
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
@@ -31,6 +32,7 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
"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
|
||||
@@ -39,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();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { formatPrice, formatDate } from "../../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
|
||||
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";
|
||||
@@ -80,19 +80,19 @@ 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">Zahlungsart</p>
|
||||
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { CustomSelect } from "../../../components/CustomSelect";
|
||||
|
||||
@@ -31,6 +32,16 @@ export function OrderFilters({
|
||||
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());
|
||||
@@ -43,19 +54,38 @@ export function OrderFilters({
|
||||
const yearOptions = years.map((y) => ({ value: y, label: y }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 w-full">
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -14,7 +13,8 @@ import {
|
||||
import { getOrderFilterEnabled } from "../../lib/payload";
|
||||
import { OrderStatusBadge } from "../components/OrderStatusBadge";
|
||||
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
|
||||
import { LogoutButton } from "../components/LogoutButton";
|
||||
import { KontoShell } from "../components/KontoShell";
|
||||
import { AccountIdentity } from "../components/AccountIdentity";
|
||||
import { OrderFilters } from "./components/OrderFilters";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /checkout.
|
||||
@@ -60,15 +60,14 @@ export default async function KontoBestellungenPage({
|
||||
]);
|
||||
|
||||
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>
|
||||
|
||||
{orderFilterEnabled && availableYears.length > 0 && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -135,25 +134,7 @@ export default async function KontoBestellungenPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* self-center below sm — this row otherwise inherits the parent
|
||||
Reveal's items-start (left-aligned); centered here per its
|
||||
own request, but only the row itself (self-center keeps its
|
||||
shrink-to-fit content width, doesn't stretch it to the full
|
||||
parent width the way w-full/justify-center on the parent
|
||||
would). Back to left-aligned (matching the rest of the page)
|
||||
from sm up. */}
|
||||
<div className="flex gap-6 self-center sm:self-start">
|
||||
<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,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/account/logout", { method: "POST" });
|
||||
// The local cart is already mirrored server-side by CartSync, so it's
|
||||
// safe to clear it here — the next login's mergeServerCartIntoLocal()
|
||||
// restores it from the server. Without this, the local cart survived
|
||||
// logout untouched, and mergeServerCartIntoLocal()'s additive merge
|
||||
// (existing.qty += qty) would add the already-synced server quantities
|
||||
// on top of it on every login, doubling every logout/login cycle.
|
||||
clearCart();
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { RevealGroup, RevealItem } from "../../../components/Reveal";
|
||||
import { formatPrice, discountPercent } from "../../../lib/format";
|
||||
import { AddToCartInlineButton } from "../../../components/AddToCartInlineButton";
|
||||
import { WishlistButton } from "../../../components/WishlistButton";
|
||||
import { formatDate } from "../../../lib/format";
|
||||
import { ProductCard } from "../../../components/ProductCard";
|
||||
import { useWishlist } from "../../../lib/useWishlist";
|
||||
import { effectiveTaxRate } from "../../../lib/cartTotals";
|
||||
import type { Product } from "../../../lib/payload";
|
||||
|
||||
// Client Component so removing an item (WishlistButton toggling it off)
|
||||
@@ -27,62 +24,51 @@ export function MerklisteGrid({
|
||||
kleinunternehmer: boolean;
|
||||
}) {
|
||||
const { items } = useWishlist();
|
||||
const wishlistedIds = new Set(items.map((i) => i.productId));
|
||||
// Preserve the wishlist's own order (most-recently-added-first, via
|
||||
// `items`) rather than initialProducts' own order.
|
||||
// `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 visibleProducts = items.map((i) => productsByNumericId.get(i.productId)).filter((p): p is Product => Boolean(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 (visibleProducts.length === 0) {
|
||||
if (visibleEntries.length === 0) {
|
||||
return <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
|
||||
{visibleProducts.map((product) => {
|
||||
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;
|
||||
return (
|
||||
<RevealItem key={product.id} className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full">
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
{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
|
||||
// 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>
|
||||
)}
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.name}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<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>
|
||||
</div>
|
||||
{/* No className override — AddToCartInlineButton's `className`
|
||||
prop REPLACES its whole default styling (`?? defaultClass`,
|
||||
not a merge), so passing just "w-full" here previously threw
|
||||
away all the button's actual styling. Its default is
|
||||
already `w-full`. */}
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
})}
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
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.
|
||||
@@ -19,7 +19,12 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function KontoMerklistePage() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
// ?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
|
||||
@@ -36,26 +41,17 @@ export default async function KontoMerklistePage() {
|
||||
const initialProducts = await getProductsByIds(wishlistItems.map((i) => i.productId));
|
||||
|
||||
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-[75rem] mx-auto">
|
||||
<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} />
|
||||
|
||||
<div className="flex gap-6 self-center sm:self-start">
|
||||
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Meine Bestellungen
|
||||
</Link>
|
||||
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Weiter einkaufen
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
|
||||
</Reveal>
|
||||
</KontoShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,13 +103,6 @@ export function ProfileForm({
|
||||
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-6 items-start w-full">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Mein Profil
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
{profile.email} · Kundennummer {profile.customerNumber}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
{/* Explicit heading, matching /checkout's own "1. Rechnungsadresse"
|
||||
card title — without it, this section and the "Lieferadresse"
|
||||
|
||||
+15
-15
@@ -1,9 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
|
||||
import { getShippingCountries } from "../../lib/payload";
|
||||
import { KontoShell } from "../components/KontoShell";
|
||||
import { AccountIdentity } from "../components/AccountIdentity";
|
||||
import { ProfileForm } from "./components/ProfileForm";
|
||||
import { PasswordForm } from "./components/PasswordForm";
|
||||
import { VerificationBanner } from "./components/VerificationBanner";
|
||||
@@ -29,19 +29,19 @@ export default async function KontoProfilPage({
|
||||
const { verified } = await searchParams;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
|
||||
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
← Meine Bestellungen
|
||||
</Link>
|
||||
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
|
||||
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
|
||||
<PasswordForm email={profile.email} />
|
||||
<AccountDataSection />
|
||||
<KontoShell>
|
||||
<div className="flex flex-col gap-10 items-start w-full max-w-[40rem]">
|
||||
<div className="flex flex-col gap-1 items-start w-full">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Mein Profil
|
||||
</p>
|
||||
<AccountIdentity email={profile.email} customerNumber={session.customer.customerNumber} />
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
|
||||
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
|
||||
<PasswordForm email={profile.email} />
|
||||
<AccountDataSection />
|
||||
</div>
|
||||
</KontoShell>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-2
@@ -4,7 +4,8 @@ import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
import { CartSync } from "./components/CartSync";
|
||||
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled } from "./lib/payload";
|
||||
import { KlaroConsentManager } from "./components/KlaroConsentManager";
|
||||
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled, getTrackingCodes } from "./lib/payload";
|
||||
import { buildOrganizationSchema } from "./lib/structuredData";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -55,6 +56,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
card: "summary_large_image",
|
||||
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
|
||||
},
|
||||
verification: seo.googleSearchConsoleVerification
|
||||
? { google: seo.googleSearchConsoleVerification }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,11 +71,12 @@ export default async function RootLayout({
|
||||
// just to know whether Navbar's "Shop" link should behave as an anchor
|
||||
// to the homepage spotlight instead of a real /shop navigation (see
|
||||
// Navbar.tsx/ProductSpotlight.tsx).
|
||||
const [products, seller, wishlistEnabled, searchEnabled] = await Promise.all([
|
||||
const [products, seller, wishlistEnabled, searchEnabled, trackingCodes] = await Promise.all([
|
||||
getProducts(),
|
||||
getCompanySettings(),
|
||||
getWishlistEnabled(),
|
||||
getSearchEnabled(),
|
||||
getTrackingCodes(),
|
||||
]);
|
||||
const singleActiveProduct = products.filter((p) => p.active).length === 1;
|
||||
// Organization JSON-LD on every page — one canonical node (@id) that
|
||||
@@ -90,6 +95,7 @@ export default async function RootLayout({
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
|
||||
<KlaroConsentManager codes={trackingCodes} />
|
||||
<CartFlyProvider>
|
||||
<CartSync />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} searchEnabled={searchEnabled} />
|
||||
|
||||
@@ -6,10 +6,15 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
id: "todo-karten",
|
||||
numericId: 1,
|
||||
name: "ToDo-Karten",
|
||||
description: "",
|
||||
subline: null,
|
||||
layout: [],
|
||||
description: null,
|
||||
descriptionText: "",
|
||||
sku: null,
|
||||
price: 12.9,
|
||||
compareAtPrice: null,
|
||||
image: "",
|
||||
gallery: [],
|
||||
href: null,
|
||||
active: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -25,6 +30,10 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
maxQty: null,
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
categories: [],
|
||||
relatedProduct: null,
|
||||
usps: [],
|
||||
badge: "none",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
+60
-6
@@ -132,17 +132,71 @@ export function useCart(): CartItem[] {
|
||||
// logout — the local cart is always either empty (no guest additions since
|
||||
// the last logout) or holds only genuinely new guest-session items, never a
|
||||
// stale copy of what's already in the server cart, so this add never
|
||||
// double-counts. CartSync then picks up the resulting change and pushes the
|
||||
// merged cart back to the server on its own, closing the loop without a
|
||||
// separate save call here.
|
||||
// double-counts.
|
||||
//
|
||||
// Always pushes the resulting cart to the server itself at the end,
|
||||
// explicitly — does NOT rely on CartSync's own change-triggered push.
|
||||
// That was the bug (confirmed live 2026-08-01): logging in with an empty
|
||||
// server cart but a non-empty guest cart means the merge loop below never
|
||||
// actually runs (nothing in `data.cart` to add), so `writeCart` is never
|
||||
// called, no change event fires, and CartSync's effect — which only
|
||||
// re-fires when the cart *reference* actually changes — never pushes the
|
||||
// guest cart to the server at all. The cart looked fine on the device
|
||||
// that was already logged in, but never reached any other device. An
|
||||
// explicit push here doesn't depend on anything having changed.
|
||||
export async function mergeServerCartIntoLocal(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/account/cart");
|
||||
if (res.ok) {
|
||||
const data: { cart?: CartItem[] } = await res.json();
|
||||
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — a failed merge just means the server-side cart stays
|
||||
// as it was; nothing local is lost either way. Still fall through to
|
||||
// push below — the local (guest) cart is genuine either way.
|
||||
}
|
||||
try {
|
||||
await fetch("/api/account/cart", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cart: readCart() }),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — CartSync's own push will retry on the next cart
|
||||
// change, or the next login-time merge tries again.
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-device sync for an *already* logged-in session — CartSync.tsx
|
||||
// only ever pushes local→server, and mergeServerCartIntoLocal() above only
|
||||
// ever runs at the moment of a fresh login/register, so a customer already
|
||||
// signed in on device B never picked up a change made on device A. This
|
||||
// covers that gap: called on mount and on window focus (CartSync.tsx), not
|
||||
// just at login.
|
||||
//
|
||||
// Replaces the local cart outright with the server's — NOT an additive
|
||||
// merge like mergeServerCartIntoLocal() above (that one is only safe
|
||||
// right after login, when the local cart can't yet overlap the server
|
||||
// one, see its own comment). An earlier version of this function only
|
||||
// added/updated matching lines and never removed a local line the server
|
||||
// no longer had, which meant a *removal* on device A never reached device
|
||||
// B (confirmed live 2026-08-01 — adding synced, removing didn't). Server
|
||||
// wins outright, including "server cart is now empty."
|
||||
//
|
||||
// Trade-off: a local addition made in the last few hundred ms — after
|
||||
// this fetch went out but before CartSync's own 800ms debounce pushed it
|
||||
// — could theoretically get overwritten by a pull that lands in between.
|
||||
// Narrow enough (and self-healing on the next change/focus) to accept,
|
||||
// consistent with every other "best-effort" sync path in this file.
|
||||
export async function pullServerCart(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/account/cart");
|
||||
if (!res.ok) return;
|
||||
const data: { cart?: CartItem[] } = await res.json();
|
||||
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
|
||||
writeCart(data.cart ?? []);
|
||||
} catch {
|
||||
// Best-effort — a failed merge just means the server-side cart stays
|
||||
// as it was; nothing local is lost either way.
|
||||
// Best-effort — a failed pull just means this device doesn't see
|
||||
// another device's changes yet; nothing local is lost either way.
|
||||
}
|
||||
}
|
||||
|
||||
+54
-6
@@ -361,8 +361,48 @@ export type WishlistItem = {
|
||||
id: number;
|
||||
productId: number;
|
||||
variant: string;
|
||||
/** ISO date of the most recent non-cancelled/non-returned order
|
||||
* containing this exact (product, variant), or null if never bought.
|
||||
* Deliberately never persisted on wishlist-items itself (that
|
||||
* collection's own `update` access is hard-disabled — see its own
|
||||
* comment) — always derived fresh from Orders on every wishlist read,
|
||||
* same "read-only annotation" approach used elsewhere in this file. */
|
||||
purchasedAt: string | null;
|
||||
};
|
||||
|
||||
// Keyed `${productId}:${variant}` (empty-string variant included, matching
|
||||
// WishlistItems' own key shape) → the most recent qualifying order's
|
||||
// createdAt. "Qualifying" excludes `cancelled` (aborted/failed order, never
|
||||
// actually fulfilled) and `returned` (customer no longer has the item) —
|
||||
// everything else (received/processing/shipped/delivered/return_requested)
|
||||
// still counts as "they did buy this," which is the plain-language meaning
|
||||
// of the wishlist badge this feeds.
|
||||
async function getPurchasedVariantMap(token: string, customerId: number): Promise<Map<string, string>> {
|
||||
const params = new URLSearchParams({
|
||||
"where[customer][equals]": String(customerId),
|
||||
"where[status][not_in]": "cancelled,returned",
|
||||
depth: "0",
|
||||
limit: "200",
|
||||
sort: "-createdAt",
|
||||
});
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return new Map();
|
||||
const data: { docs?: { createdAt: string; items: { product: number; variantName?: string | null }[] }[] } = await res.json();
|
||||
const map = new Map<string, string>();
|
||||
for (const order of data.docs ?? []) {
|
||||
for (const item of order.items) {
|
||||
const key = `${item.product}:${item.variantName ?? ""}`;
|
||||
// `sort: "-createdAt"` above means the first order seen per key is
|
||||
// already the most recent — never overwrite with an older one.
|
||||
if (!map.has(key)) map.set(key, order.createdAt);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// `variant` empty string, not undefined — matches WishlistItems.ts's own
|
||||
// defaultValue: '' so the (customer, product, variant) unique index
|
||||
// actually catches a duplicate add for a variant-less product too.
|
||||
@@ -373,13 +413,19 @@ export async function getWishlist(token: string, customerId: number): Promise<Wi
|
||||
limit: "200",
|
||||
sort: "-createdAt",
|
||||
});
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const [res, purchasedMap] = await Promise.all([
|
||||
fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
cache: "no-store",
|
||||
}),
|
||||
getPurchasedVariantMap(token, customerId),
|
||||
]);
|
||||
if (!res.ok) return [];
|
||||
const data: { docs?: { id: number; product: number; variant?: string }[] } = await res.json();
|
||||
return (data.docs ?? []).map((doc) => ({ id: doc.id, productId: doc.product, variant: doc.variant ?? "" }));
|
||||
return (data.docs ?? []).map((doc) => {
|
||||
const variant = doc.variant ?? "";
|
||||
return { id: doc.id, productId: doc.product, variant, purchasedAt: purchasedMap.get(`${doc.product}:${variant}`) ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
// Toggles a single (product, variant) — tries to create first; a 400 here
|
||||
@@ -389,17 +435,19 @@ export async function getWishlist(token: string, customerId: number): Promise<Wi
|
||||
// toggle (the common case, adding something new, only needs one request).
|
||||
export async function toggleWishlistItem(
|
||||
token: string,
|
||||
customerId: number,
|
||||
productId: number,
|
||||
variant: string,
|
||||
): Promise<{ ok: true; wishlisted: boolean } | { ok: false }> {
|
||||
const createRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ product: productId, variant }),
|
||||
body: JSON.stringify({ customer: customerId, product: productId, variant }),
|
||||
});
|
||||
if (createRes.ok) return { ok: true, wishlisted: true };
|
||||
|
||||
const findParams = new URLSearchParams({
|
||||
"where[customer][equals]": String(customerId),
|
||||
"where[product][equals]": String(productId),
|
||||
"where[variant][equals]": variant,
|
||||
depth: "0",
|
||||
|
||||
@@ -336,6 +336,7 @@ export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
|
||||
"order-tracking-corrected": "📦",
|
||||
"order-delivered": "🎉",
|
||||
"payment-method-switched": "💳",
|
||||
"back-in-stock": "🔔",
|
||||
};
|
||||
|
||||
export function renderOrderStatusHtml(
|
||||
@@ -360,6 +361,36 @@ export function renderOrderStatusHtml(
|
||||
return emailShell(icon, escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
|
||||
}
|
||||
|
||||
// Separate from renderOrderStatusHtml — a back-in-stock mail has no order
|
||||
// at all (it fires from a "notify me" signup, not a purchase), so no
|
||||
// "Bestellnummer" line, and the CTA points at the product page, not
|
||||
// /konto/bestellungen. Mirrors lib/jobs/sendBackInStockEmails.ts on the
|
||||
// Payload backend, which is where the real send actually happens (see
|
||||
// this file's own top-of-file comment on why the two aren't literally
|
||||
// shared code).
|
||||
export function renderBackInStockHtml(
|
||||
template: EmailTemplateContent,
|
||||
productLabel: string,
|
||||
productUrl: string,
|
||||
seller: CompanySettings | null,
|
||||
productImageUrl: string | null = null,
|
||||
): string {
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
${productImageUrl ? `<p style="text-align:center;margin:0 0 12px;"><img src="${productImageUrl}" width="64" height="64" alt="" style="display:inline-block;width:64px;height:64px;border-radius:8px;object-fit:cover;border:1px solid ${BORDER};" /></p>` : ""}
|
||||
<p style="text-align:center;font-size:13px;color:${TEXT_MUTED};margin:0 0 12px;">${escapeHtml(productLabel)}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||||
<tr>
|
||||
<td style="background:${BRAND};border-radius:6px;">
|
||||
<a href="${productUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Zum Produkt</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
|
||||
return emailShell("🔔", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
|
||||
}
|
||||
|
||||
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, seller: CompanySettings | null): string {
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
|
||||
@@ -31,3 +31,12 @@ export function formatDate(iso: string): string {
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${day}. ${MONTHS_DE[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
// "Juli 2026" — for legal pages' "Stand: …" line, derived from the
|
||||
// LegalPages doc's own updatedAt so it always reflects when the template
|
||||
// was actually last edited, instead of a hand-typed string an admin has
|
||||
// to remember to bump on every content change.
|
||||
export function formatMonthYear(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${MONTHS_DE[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { TrackingCode } from "./payload";
|
||||
|
||||
// Klaro's own config shape (kiprotect/klaro, see node_modules/klaro's
|
||||
// dist/config.js for the fully-annotated reference) — only the fields we
|
||||
// actually set are typed here, not Klaro's full surface.
|
||||
export type KlaroConfig = {
|
||||
version: number;
|
||||
elementID: string;
|
||||
styling: Record<string, string | string[]>;
|
||||
storageMethod: "cookie";
|
||||
cookieName: string;
|
||||
cookieExpiresAfterDays: number;
|
||||
default: boolean;
|
||||
mustConsent: boolean;
|
||||
acceptAll: boolean;
|
||||
hideDeclineAll: boolean;
|
||||
noticeAsModal: boolean;
|
||||
disablePoweredBy: boolean;
|
||||
translations: Record<string, Record<string, unknown>>;
|
||||
services: {
|
||||
name: string;
|
||||
title: string;
|
||||
purposes: string[];
|
||||
required: boolean;
|
||||
default: boolean;
|
||||
onlyOnce: boolean;
|
||||
callback: (consent: boolean) => void;
|
||||
}[];
|
||||
};
|
||||
|
||||
// "google-maps" is a fixed name, not id-based like every other provider —
|
||||
// GoogleMapsEmbed.tsx has to hardcode a `data-name` matching this exact
|
||||
// string for Klaro's own contextual-consent DOM scan
|
||||
// (renderContextualConsentNotices in node_modules/klaro) to find it, and
|
||||
// there's no per-row distinguishing ID the way GA/FB/GTM each have one
|
||||
// (a Maps embed URL is per-page, not stored on this row at all — see
|
||||
// TrackingCodes.ts's own field comment). Only ever create ONE
|
||||
// google-maps row in the admin; a second one would collide on this name.
|
||||
export function serviceName(code: TrackingCode): string {
|
||||
if (code.provider === "google-maps") return "google-maps";
|
||||
return `tracking-code-${code.id}`;
|
||||
}
|
||||
|
||||
function providerTitle(code: TrackingCode): string {
|
||||
switch (code.provider) {
|
||||
case "google-analytics":
|
||||
return "Google Analytics";
|
||||
case "facebook-pixel":
|
||||
return "Facebook Pixel";
|
||||
case "google-tag-manager":
|
||||
return "Google Tag Manager";
|
||||
case "google-maps":
|
||||
return "Google Maps";
|
||||
default:
|
||||
return "Sonstiges Tracking-Skript";
|
||||
}
|
||||
}
|
||||
|
||||
// Shown per service in the modal's expanded list — without this, Klaro
|
||||
// only ever shows the bare provider title (see providerTitle above),
|
||||
// which is thin for real TTDSG transparency ("what does this actually
|
||||
// do") and reads as less trustworthy than a CMP that explains itself.
|
||||
// Generic per-provider text, not per-row — a second Google Analytics row
|
||||
// would get the identical description, which is fine since the "what
|
||||
// GA does" fact doesn't change between rows, only which property it's
|
||||
// wired to (already visible via the row's own `name` if an admin sets a
|
||||
// distinguishing one).
|
||||
function providerDescription(code: TrackingCode): string {
|
||||
switch (code.provider) {
|
||||
case "google-analytics":
|
||||
return "Erfasst anonymisierte Nutzungsstatistiken (z. B. welche Seiten wie oft besucht werden), damit wir verstehen, was bei euch ankommt. Setzt die Cookies _ga (Laufzeit 2 Jahre, unterscheidet Besucher:innen) und _ga_<Container-ID> (2 Jahre, Sitzungsstatus).";
|
||||
case "facebook-pixel":
|
||||
return "Misst, ob ein Besuch von hier zu einer Anzeige bei Facebook/Instagram passt, und hilft uns, Werbung zielgerichteter zu schalten. Setzt den Cookie _fbp (3 Monate, eigene Domain); ist ein Facebook-Konto im selben Browser aktiv, setzt facebook.com zusätzlich den Cookie fr (3 Monate).";
|
||||
case "google-tag-manager":
|
||||
return "Lädt weitere Mess-/Marketing-Skripte je nach Konfiguration nach, ohne dass wir dafür jedes Mal den Code der Seite ändern müssen. Setzt selbst keine eigenen Cookies — welche Cookies letztlich gesetzt werden, hängt von den darüber geladenen Diensten ab (siehe deren eigene Einträge hier).";
|
||||
case "google-maps":
|
||||
return "Bindet eine interaktive Karte von Google ein. Beim Laden überträgt dein Browser deine IP-Adresse an Google und Google setzt eigene Cookies (u. a. NID), deren genauer Umfang außerhalb unserer Kontrolle liegt.";
|
||||
default:
|
||||
return "Ein zusätzliches Skript, das diese Seite einbindet.";
|
||||
}
|
||||
}
|
||||
|
||||
// `onAccept` is threaded in rather than importing loadTrackingCode.ts
|
||||
// directly here — this module only builds a plain config object (no DOM
|
||||
// access), kept separate from the actual script-injection side effect so
|
||||
// it stays trivially testable/reusable if the loading mechanism ever
|
||||
// changes.
|
||||
export function buildKlaroConfig(codes: TrackingCode[], onAccept: (code: TrackingCode) => void): KlaroConfig {
|
||||
const serviceTranslations: Record<string, { title: string; description: string }> = {};
|
||||
for (const code of codes) {
|
||||
serviceTranslations[serviceName(code)] = { title: providerTitle(code), description: providerDescription(code) };
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
elementID: "klaro",
|
||||
// 'light' + 'bottom' + 'left' — a compact corner notice (Klaro's own
|
||||
// default max-width, ~400px), not a full-width bar. 'wide' (tried
|
||||
// first) stretched it across nearly the whole viewport, which read as
|
||||
// too dominant compared to a typical bottom-left corner CMP.
|
||||
// Extra keys beyond `theme` override individual CSS custom properties
|
||||
// (Klaro's injectStyles() applies any non-'theme' `styling` key as a
|
||||
// `--<key>` var, see node_modules/klaro's own utils/styling.js) — this
|
||||
// is the color pass; the border/shadow/typography pass lives in
|
||||
// KlaroConsentManager.tsx's <KlaroTheme> instead (a real CSS override,
|
||||
// needed because Klaro's own default border/spacing/font still read as
|
||||
// an obviously-bolted-on library widget even with the right colors).
|
||||
// dark1/light1 are (confusingly named) the notice's background/text
|
||||
// colors, not a dark-mode toggle. green1 colors the accept button.
|
||||
// blue1/blue2 color the "Einstellungen" (.cm-btn-info) button and
|
||||
// in-modal links — Klaro's default blue clashed with the brand just as
|
||||
// much as its default green did, easy to miss since it only shows
|
||||
// once the modal (not just the notice) is open.
|
||||
styling: {
|
||||
theme: ["light", "bottom", "left"],
|
||||
dark1: "#fffdf8", // --color-bg-paper — notice/modal background
|
||||
dark2: "#e5e0d8", // --color-border
|
||||
// NOT a light background tint despite the name — Klaro's own
|
||||
// .cm-list-description rule (each service's description text in
|
||||
// the modal) uses dark3 as a TEXT color, not a background. A near-
|
||||
// white value here (tried first) made every service description
|
||||
// almost invisible against the modal's own light background —
|
||||
// confirmed via screenshot 2026-08-02. --color-text-muted instead.
|
||||
dark3: "#6b6b69",
|
||||
light1: "#1a1a18", // --color-text-primary — notice/modal text
|
||||
light2: "#6b6b69", // --color-text-muted
|
||||
light3: "#1a1a18",
|
||||
green1: "#f6a701", // --color-brand — accept button
|
||||
green2: "#d99000",
|
||||
blue1: "#e5e0d8",
|
||||
blue2: "#6b6b69",
|
||||
"button-text-color": "#1a1a18",
|
||||
"notice-max-width": "380px",
|
||||
},
|
||||
storageMethod: "cookie",
|
||||
cookieName: "klaro-consent",
|
||||
cookieExpiresAfterDays: 180,
|
||||
default: false,
|
||||
mustConsent: false,
|
||||
acceptAll: true,
|
||||
hideDeclineAll: false,
|
||||
noticeAsModal: false,
|
||||
// Removes the "Realisiert mit Klaro!" attribution link in the modal —
|
||||
// BSD-3-Clause has no on-page-attribution requirement (only source/
|
||||
// binary copyright-notice retention), this is just the maintainers'
|
||||
// own polite ask via a dedicated config flag, not a license term.
|
||||
disablePoweredBy: true,
|
||||
translations: {
|
||||
de: {
|
||||
consentModal: {
|
||||
title: "Deine Cookie-Einstellungen",
|
||||
description: "Hier siehst du genau, was wir einsetzen — und entscheidest frei, was du erlaubst. Nichts davon läuft, bevor du zustimmst.",
|
||||
},
|
||||
consentNotice: {
|
||||
description: "Wir verwenden ein paar Cookies, damit diese Seite reibungslos läuft und wir sehen, was bei euch gut ankommt.",
|
||||
learnMore: "Einstellungen",
|
||||
},
|
||||
purposes: {
|
||||
necessary: "Notwendig",
|
||||
analytics: "Analyse",
|
||||
marketing: "Marketing",
|
||||
content: "Inhalte",
|
||||
},
|
||||
...serviceTranslations,
|
||||
},
|
||||
},
|
||||
services: codes.map((code) => ({
|
||||
name: serviceName(code),
|
||||
title: providerTitle(code),
|
||||
purposes: [code.consentCategory],
|
||||
required: code.consentCategory === "necessary",
|
||||
default: code.consentCategory === "necessary",
|
||||
// For "google-maps", `onAccept` below is a no-op (see
|
||||
// loadTrackingCode.ts) — an embed's src isn't a script this app
|
||||
// injects, Klaro's OWN contextual-consent DOM scan
|
||||
// (renderContextualConsentNotices, runs as part of Klaro.render())
|
||||
// finds any `data-name="google-maps"` iframe on the page and
|
||||
// blanks/restores its `src` itself, purely by service name +
|
||||
// consent state. This service entry's only job is telling Klaro
|
||||
// that name exists and which purpose gates it.
|
||||
//
|
||||
// A restock/page-reload shouldn't re-fire the loader for a service
|
||||
// the visitor already accepted in an earlier session within this
|
||||
// same page load — Klaro still calls `callback` once per accepted
|
||||
// service per page load either way, `onlyOnce` just governs repeat
|
||||
// toggling within one session.
|
||||
onlyOnce: true,
|
||||
callback: (consent: boolean) => {
|
||||
if (consent) onAccept(code);
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { TrackingCode } from "./payload";
|
||||
|
||||
// Imperative <script> injection, not next/script — this fires from
|
||||
// KlaroConsentManager.tsx's per-service `callback(consent)`, i.e. at an
|
||||
// arbitrary point after mount (whenever a visitor actually accepts), not
|
||||
// declaratively on every render. Idempotent via the DOM id check, since
|
||||
// Klaro's `onlyOnce: true` (see klaroConfig.ts) already tries to guarantee
|
||||
// a single callback-with-consent-true per page load, but this is a cheap
|
||||
// second guard against ever injecting the same script twice.
|
||||
function injectScript(id: string, src?: string, inlineJs?: string) {
|
||||
if (document.getElementById(id)) return;
|
||||
const script = document.createElement("script");
|
||||
script.id = id;
|
||||
script.async = true;
|
||||
if (src) script.src = src;
|
||||
else script.textContent = inlineJs ?? "";
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// One loader per TrackingCodes.provider (google-analytics/facebook-pixel/
|
||||
// google-tag-manager/other) — same fixed loader snippets per provider as
|
||||
// the Payload backend's own admin.description promises, 'other' is the
|
||||
// only one whose content is admin-supplied rather than a fixed snippet
|
||||
// (trusted deliberately, see TrackingCodes.ts on the backend).
|
||||
export function loadTrackingCode(code: TrackingCode): void {
|
||||
// No-op — Google Maps isn't a script this app injects. Klaro's own
|
||||
// contextual-consent DOM scan handles any `data-name="google-maps"`
|
||||
// iframe (GoogleMapsEmbed.tsx) independently, purely off the service's
|
||||
// consent state — see klaroConfig.ts's own comment on this service.
|
||||
if (code.provider === "google-maps") return;
|
||||
|
||||
const baseId = `tracking-code-${code.id}`;
|
||||
|
||||
if (code.provider === "google-analytics" && code.measurementId) {
|
||||
injectScript(`${baseId}-loader`, `https://www.googletagmanager.com/gtag/js?id=${code.measurementId}`);
|
||||
injectScript(
|
||||
`${baseId}-init`,
|
||||
undefined,
|
||||
`window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${code.measurementId}');`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.provider === "facebook-pixel" && code.pixelId) {
|
||||
injectScript(
|
||||
baseId,
|
||||
undefined,
|
||||
`!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
fbq('init', '${code.pixelId}');
|
||||
fbq('track', 'PageView');`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.provider === "google-tag-manager" && code.containerId) {
|
||||
injectScript(
|
||||
baseId,
|
||||
undefined,
|
||||
`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${code.containerId}');`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.provider === "other" && code.customScript) {
|
||||
injectScript(baseId, undefined, code.customScript);
|
||||
}
|
||||
}
|
||||
+508
-12
@@ -181,10 +181,27 @@ export type Product = {
|
||||
// slug-based call site.
|
||||
numericId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
// Short tagline shown under the name on cards/cart/PDP hero — see
|
||||
// Products.ts's own field comment. null/"" means show nothing extra.
|
||||
subline: string | null;
|
||||
// Raw Lexical richText JSON — rendered formatted (bold/italic/multiple
|
||||
// paragraphs) on the PDP via the shared <RichText> component. Plain-text
|
||||
// consumers (Passend-dazu cards, JSON-LD) use `descriptionText` below
|
||||
// instead of re-deriving text from this themselves.
|
||||
description: unknown | null;
|
||||
descriptionText: string;
|
||||
// Not shown anywhere in the UI today — only consumed by
|
||||
// buildProductSchema (JSON-LD's Product.sku), a real Schema.org property
|
||||
// Google's rich-result validator looks for.
|
||||
sku: string | null;
|
||||
price: number;
|
||||
compareAtPrice: number | null;
|
||||
image: string;
|
||||
// Extra photos beyond `image` above, for ProductGallery.tsx's
|
||||
// thumbnail-strip UI — empty for the vast majority of products (opt-in
|
||||
// per product, see Products.ts's own field comment). Never includes
|
||||
// `image` itself; ProductGallery treats `image` as always-slide-zero.
|
||||
gallery: string[];
|
||||
href: string | null;
|
||||
// `active` is opt-in for callers to filter by, not applied inside
|
||||
// getProducts()/getProductBySlug() themselves — cart, checkout, order
|
||||
@@ -198,7 +215,13 @@ export type Product = {
|
||||
spotlight: boolean;
|
||||
spotlightEyebrow: string | null;
|
||||
spotlightHeadline: string | null;
|
||||
spotlightText: string | null;
|
||||
// Lexical richText JSON (switched from plain textarea 2026-08-25 so
|
||||
// admins can bold parts of the teaser) — rendered by
|
||||
// ProductSpotlight.tsx's own minimal inline converter, not the full
|
||||
// article-oriented RichText.tsx (that one adds block-level heading/
|
||||
// paragraph spacing meant for blog posts/legal pages, wrong fit for a
|
||||
// single teaser paragraph).
|
||||
spotlightText: unknown | null;
|
||||
spotlightImage: string | null;
|
||||
// Per-product opt-in for a wishlist heart on the homepage spotlight —
|
||||
// independent of (in addition to) the global wishlistEnabled toggle,
|
||||
@@ -235,23 +258,53 @@ export type Product = {
|
||||
// exempts the individual product, not the whole cart.
|
||||
noShippingCost: boolean;
|
||||
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
// Empty for a product that predates this field or was never tagged —
|
||||
// treated as "matches every category filter" by the shop grid rather
|
||||
// than "matches none", since an uncategorized product shouldn't just
|
||||
// disappear the moment a category filter is applied.
|
||||
categories: string[];
|
||||
// Optional cross-sell pick (Products.relatedProduct) — powers a "Passt
|
||||
// dazu" card on the product's own detail page, same field/pattern as
|
||||
// Posts.relatedProduct. null means no card. Only one level deep (this
|
||||
// related product's own relatedProduct is never resolved/shown) — see
|
||||
// mapPayloadProduct's own comment.
|
||||
relatedProduct: Product | null;
|
||||
// Optional PDP page-builder content — see Products.ts's own `layout`
|
||||
// field comment. Empty for every existing hand-coded PDP (der-eine,
|
||||
// todo-cards, tasse-die-pause); a product's own page.tsx decides whether
|
||||
// to render this or its bespoke components, this type doesn't enforce
|
||||
// either.
|
||||
layout: ProductBlock[];
|
||||
// Fixed PDP-shell fields, not part of `layout` — always render the same
|
||||
// way regardless of block order, per the page-builder spec's "shell vs.
|
||||
// blocks" split. Max 3 enforced server-side (Products.usps maxRows).
|
||||
usps: { icon: string; text: string }[];
|
||||
// "new" is the only manual state — discount/out-of-stock/low-stock
|
||||
// badges are derived automatically elsewhere (discountPercent()/
|
||||
// outOfStock/lowStock above); see Products.ts's own field comment for
|
||||
// why those two aren't also manual options here.
|
||||
badge: "none" | "new";
|
||||
};
|
||||
|
||||
type PayloadProduct = {
|
||||
export type PayloadProduct = {
|
||||
id: number;
|
||||
name: string;
|
||||
subline: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
layout: PayloadProductBlock[] | null;
|
||||
description: unknown | null;
|
||||
sku: string | null;
|
||||
price: number;
|
||||
compareAtPrice: number | null;
|
||||
image: { url: string } | number | null;
|
||||
gallery: ({ url: string } | number)[] | null;
|
||||
detailHref: string | null;
|
||||
active: boolean;
|
||||
updatedAt: string;
|
||||
spotlight: boolean;
|
||||
spotlightEyebrow: string | null;
|
||||
spotlightHeadline: string | null;
|
||||
spotlightText: string | null;
|
||||
spotlightText: unknown | null;
|
||||
spotlightImage: { url: string } | number | null;
|
||||
spotlightShowWishlist: boolean;
|
||||
trackInventory: boolean;
|
||||
@@ -270,6 +323,10 @@ type PayloadProduct = {
|
||||
lowStockThreshold: number | null;
|
||||
}[]
|
||||
| null;
|
||||
categories: ({ name: string } | number)[] | null;
|
||||
relatedProduct: PayloadProduct | number | null;
|
||||
usps: { icon: string; text: string }[] | null;
|
||||
badge: "none" | "new" | null;
|
||||
};
|
||||
|
||||
// A product/variant is only actually unbuyable when it opted into
|
||||
@@ -295,19 +352,42 @@ function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowB
|
||||
return trackInventory && !allowBackorder ? (stock ?? 0) : null;
|
||||
}
|
||||
|
||||
type LexicalTextNode = {
|
||||
text?: string;
|
||||
children?: LexicalTextNode[];
|
||||
};
|
||||
|
||||
// Mirrors the backend's own Posts.ts extractPlainText (same shape, same
|
||||
// space-joined fallback for multiple paragraphs/children) — kept as a
|
||||
// separate copy rather than a shared package since it's a few lines and
|
||||
// the two run in different runtimes (Payload server vs. this frontend).
|
||||
function extractPlainText(node: LexicalTextNode | null | undefined): string {
|
||||
if (!node) return "";
|
||||
if (typeof node.text === "string") return node.text;
|
||||
if (Array.isArray(node.children)) return node.children.map(extractPlainText).join(" ");
|
||||
return "";
|
||||
}
|
||||
|
||||
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
|
||||
// one place instead of duplicating the same field mapping, which is
|
||||
// exactly the kind of drift this session's Shipping Settings work was
|
||||
// about eliminating elsewhere.
|
||||
export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
const descriptionRoot = (product.description as { root?: LexicalTextNode } | null)?.root;
|
||||
return {
|
||||
id: product.slug,
|
||||
numericId: product.id,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
subline: product.subline || null,
|
||||
description: product.description ?? null,
|
||||
descriptionText: extractPlainText(descriptionRoot),
|
||||
sku: product.sku || null,
|
||||
price: product.price,
|
||||
compareAtPrice: product.compareAtPrice ?? null,
|
||||
image: typeof product.image === "object" && product.image ? product.image.url : "",
|
||||
gallery: (product.gallery ?? [])
|
||||
.map((g) => (typeof g === "object" && g ? g.url : null))
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
href: product.detailHref || null,
|
||||
active: product.active,
|
||||
updatedAt: product.updatedAt,
|
||||
@@ -330,6 +410,16 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold),
|
||||
maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder),
|
||||
})),
|
||||
categories: (product.categories ?? [])
|
||||
.map((c) => (typeof c === "object" && c ? c.name : null))
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
// Recursive call is safe here — depth=2 on the originating fetch means
|
||||
// this nested object's OWN relatedProduct is never populated (stays a
|
||||
// raw id or absent), so the recursion bottoms out after exactly one level.
|
||||
relatedProduct: typeof product.relatedProduct === "object" && product.relatedProduct ? mapPayloadProduct(product.relatedProduct) : null,
|
||||
layout: (product.layout ?? []).map(mapPayloadProductBlock),
|
||||
usps: product.usps ?? [],
|
||||
badge: product.badge ?? "none",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -782,7 +872,7 @@ export async function getWerkzeugeCards(): Promise<WerkzeugeCard[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
export type TestimonialsPage = "todo-cards" | "newsletter" | "challenge";
|
||||
export type TestimonialsPage = "todo-cards" | "newsletter" | "klarheits-check" | "der-eine" | "einfach-anfangen";
|
||||
|
||||
export type Testimonial = { id: number; quote: string; name: string; role: string; avatar: string };
|
||||
|
||||
@@ -807,10 +897,10 @@ export function mapPayloadTestimonial(doc: PayloadTestimonial): Testimonial {
|
||||
}
|
||||
|
||||
// Powers the identically-styled customer testimonial grids on /todo-cards,
|
||||
// /newsletter, and /challenge (see TestimonialsGrid.tsx) — previously 3
|
||||
// hardcoded arrays kept manually in sync. The single-quote "photo band"
|
||||
// testimonials on /not-found and /bestellbestaetigung are a different
|
||||
// shape (no avatar/role) and are not part of this collection.
|
||||
// /newsletter, /7-tage-klarheits-check, and /der-eine (see TestimonialsGrid.tsx) —
|
||||
// previously hardcoded arrays kept manually in sync. The single-quote
|
||||
// "photo band" testimonials on /not-found and /bestellbestaetigung are a
|
||||
// different shape (no avatar/role) and are not part of this collection.
|
||||
export async function getTestimonials(page: TestimonialsPage, options?: { draft?: boolean }): Promise<Testimonial[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
@@ -837,14 +927,23 @@ export type LegalPage = {
|
||||
type: LegalPageType;
|
||||
title: string;
|
||||
content: unknown;
|
||||
// Only populated for pages that need a dynamically-rendered section
|
||||
// MID-document (currently just AGB's "Vertragspartner", sourced from
|
||||
// company-settings — see VertragspartnerBlock.tsx). null everywhere
|
||||
// else, including pages like Datenschutz whose one dynamic section sits
|
||||
// at the very start and so can just be prepended instead of split.
|
||||
contentPart2: unknown | null;
|
||||
attachment: { url: string; title: string } | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type PayloadLegalPage = {
|
||||
type: LegalPageType;
|
||||
title: string;
|
||||
content: unknown;
|
||||
contentPart2?: unknown;
|
||||
attachment: { url: string; title: string } | number | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function getLegalPage(type: LegalPageType, options?: { draft?: boolean }): Promise<LegalPage | null> {
|
||||
@@ -868,10 +967,12 @@ export async function getLegalPage(type: LegalPageType, options?: { draft?: bool
|
||||
type: doc.type,
|
||||
title: doc.title,
|
||||
content: doc.content,
|
||||
contentPart2: doc.contentPart2 ?? null,
|
||||
attachment:
|
||||
typeof doc.attachment === "object" && doc.attachment
|
||||
? { url: doc.attachment.url, title: doc.attachment.title }
|
||||
: null,
|
||||
updatedAt: doc.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -885,7 +986,8 @@ export type EmailTemplateType =
|
||||
| "order-tracking-added"
|
||||
| "order-tracking-corrected"
|
||||
| "order-delivered"
|
||||
| "payment-method-switched";
|
||||
| "payment-method-switched"
|
||||
| "back-in-stock";
|
||||
|
||||
type PayloadEmailTemplate = {
|
||||
type: EmailTemplateType;
|
||||
@@ -1111,6 +1213,7 @@ export type SeoSettings = {
|
||||
titleTemplate: string | null;
|
||||
defaultDescription: string | null;
|
||||
defaultOgImage: string | null;
|
||||
googleSearchConsoleVerification: string | null;
|
||||
};
|
||||
|
||||
// Fallback matches the values hardcoded in app/layout.tsx before this field
|
||||
@@ -1123,6 +1226,7 @@ const SEO_SETTINGS_FALLBACK: SeoSettings = {
|
||||
defaultDescription:
|
||||
"Kleine Impulse, praktische Werkzeuge und ehrliche Gedanken für mehr Klarheit im Alltag – weil du auch noch ein Leben hast.",
|
||||
defaultOgImage: "/og-image.png",
|
||||
googleSearchConsoleVerification: null,
|
||||
};
|
||||
|
||||
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
|
||||
@@ -1144,6 +1248,7 @@ export async function getSeoSettings(): Promise<SeoSettings> {
|
||||
seoTitleTemplate?: string | null;
|
||||
seoDefaultDescription?: string | null;
|
||||
seoDefaultOgImage?: { url?: string } | number | null;
|
||||
googleSearchConsoleVerification?: string | null;
|
||||
}[];
|
||||
} = await res.json();
|
||||
const doc = data.docs?.[0];
|
||||
@@ -1154,5 +1259,396 @@ export async function getSeoSettings(): Promise<SeoSettings> {
|
||||
defaultDescription: doc.seoDefaultDescription || SEO_SETTINGS_FALLBACK.defaultDescription,
|
||||
defaultOgImage:
|
||||
(typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage,
|
||||
googleSearchConsoleVerification:
|
||||
doc.googleSearchConsoleVerification || SEO_SETTINGS_FALLBACK.googleSearchConsoleVerification,
|
||||
};
|
||||
}
|
||||
|
||||
// Powers app/r/[code]/route.ts and app/sticker/[code]/route.ts — a static
|
||||
// short link (e.g. printed on a QR code) that redirects to a `targetPath`
|
||||
// editable in Payload at any time, so the QR code itself never needs
|
||||
// reprinting. `cache: "no-store"` (unlike this file's other public-catalog
|
||||
// fetches) since a stale hit here would send a visitor to a since-changed
|
||||
// target, and the PATCH below needs the just-fetched id/clickCount, not a
|
||||
// 60s-old ISR snapshot.
|
||||
type PayloadRedirect = { id: number; targetPath: string; clickCount: number };
|
||||
|
||||
// `urlPrefix` filters the match to whichever fixed frontend route is
|
||||
// actually calling this — Redirects.urlPrefix is otherwise just an admin
|
||||
// label, not something either route enforced, so a code edited to
|
||||
// urlPrefix "/sticker" in Payload would silently keep resolving under
|
||||
// /r/<code> too without this. Each call site passes its own literal
|
||||
// prefix (see route.ts files), so this actually makes the field mean
|
||||
// something rather than only decorate the admin list.
|
||||
export async function resolveAndTrackRedirect(code: string, urlPrefix: "/r" | "/sticker"): Promise<string | null> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[code][equals]": code,
|
||||
"where[urlPrefix][equals]": urlPrefix,
|
||||
"where[active][equals]": "true",
|
||||
limit: "1",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/redirects?${params}`, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
console.error(`resolveAndTrackRedirect: Payload returned ${res.status} ${res.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadRedirect[] } = await res.json();
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return null;
|
||||
|
||||
fetch(`${PAYLOAD_URL}/api/redirects/${doc.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "",
|
||||
},
|
||||
body: JSON.stringify({ clickCount: doc.clickCount + 1, lastClickedAt: new Date().toISOString() }),
|
||||
}).catch((err) => console.error("resolveAndTrackRedirect: click-tracking PATCH failed", err));
|
||||
|
||||
return doc.targetPath;
|
||||
}
|
||||
|
||||
export type TrackingCode = {
|
||||
id: number;
|
||||
provider: "google-analytics" | "facebook-pixel" | "google-tag-manager" | "google-maps" | "other";
|
||||
consentCategory: "necessary" | "analytics" | "marketing" | "content";
|
||||
measurementId: string | null;
|
||||
pixelId: string | null;
|
||||
containerId: string | null;
|
||||
customScript: string | null;
|
||||
};
|
||||
|
||||
// Public read (TrackingCodes.ts's own access.read: () => true), unlike
|
||||
// most of this file's other Company-Settings-adjacent fetchers — no
|
||||
// x-order-service-secret header needed. Only `active: true` rows, since
|
||||
// TrackingScripts.tsx has no reason to even know an inactive one exists.
|
||||
// Consent-gating itself happens in TrackingScripts.tsx, not here — this
|
||||
// fetcher runs server-side (no cookie access), the gating decision is a
|
||||
// client-only concern (useConsent()).
|
||||
export async function getTrackingCodes(): Promise<TrackingCode[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
depth: "0",
|
||||
limit: "50",
|
||||
});
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/tracking-codes?${params}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getTrackingCodes: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
const data: { docs?: TrackingCode[] } = await res.json();
|
||||
return Array.isArray(data.docs) ? data.docs : [];
|
||||
}
|
||||
|
||||
// ── Products.layout (PDP page builder) ─────────────────────────────────────
|
||||
// Shares 9 of PageBlock's own block types verbatim (richTextSection/
|
||||
// stepRow/quote/image/icon/pillList/checklistImage/table/ctaCard — same
|
||||
// Payload Block configs, reused a second time on Products.layout, see that
|
||||
// field's own comment) plus two product-specific ones that don't exist on
|
||||
// Pages: productHero (the one PDP hero variant for now — just a body
|
||||
// richText, everything else about the hero comes from the product itself)
|
||||
// and productPricingPanel (the closing buy panel, no fields of its own).
|
||||
|
||||
export type ProductBlock =
|
||||
| { blockType: "productHero"; id: string; body: unknown | null }
|
||||
| { blockType: "productPricingPanel"; id: string }
|
||||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
|
||||
| { blockType: "quote"; id: string; text: string; label: string | null }
|
||||
| { blockType: "image"; id: string; image: string | null; caption: string | null }
|
||||
| { blockType: "icon"; id: string; icon: string }
|
||||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||||
| { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
|
||||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string }
|
||||
| {
|
||||
blockType: "storySplit";
|
||||
id: string;
|
||||
image: string | null;
|
||||
imagePosition: "left" | "right";
|
||||
heading: string | null;
|
||||
content: unknown;
|
||||
bullets: { id: string; text: string }[];
|
||||
}
|
||||
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
|
||||
// Not resolved to full Product objects here — same reasoning as
|
||||
// testimonialsRef not resolving its testimonials in this mapper: needs
|
||||
// its own async fetch (automatic mode queries by category; manual mode
|
||||
// needs getProductsByIds), done in ProductBlocks.tsx's own outer
|
||||
// Promise.all, mirroring how that file already special-cases
|
||||
// testimonialsRef for the exact same reason.
|
||||
| { blockType: "crossSell"; id: string; productId: number };
|
||||
|
||||
type PayloadProductBlock =
|
||||
| { blockType: "productHero"; id: string; body?: unknown | null }
|
||||
| { blockType: "productPricingPanel"; id: string }
|
||||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
|
||||
| { blockType: "quote"; id: string; text: string; label?: string | null }
|
||||
| { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
|
||||
| { blockType: "icon"; id: string; icon: string }
|
||||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||||
| { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
|
||||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string }
|
||||
| {
|
||||
blockType: "storySplit";
|
||||
id: string;
|
||||
image: PayloadPageImageField;
|
||||
imagePosition?: "left" | "right" | null;
|
||||
heading?: string | null;
|
||||
content?: unknown;
|
||||
bullets?: { id: string; text: string }[];
|
||||
}
|
||||
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
|
||||
| { blockType: "crossSell"; id: string; product: number | { id: number } };
|
||||
|
||||
function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
|
||||
switch (block.blockType) {
|
||||
case "productHero":
|
||||
return { blockType: "productHero", id: block.id, body: block.body ?? null };
|
||||
case "image":
|
||||
return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
|
||||
case "checklistImage":
|
||||
return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
|
||||
case "quote":
|
||||
return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
|
||||
case "ctaCard":
|
||||
return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
|
||||
case "stepRow":
|
||||
return {
|
||||
blockType: "stepRow",
|
||||
id: block.id,
|
||||
items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
|
||||
};
|
||||
case "storySplit":
|
||||
return {
|
||||
blockType: "storySplit",
|
||||
id: block.id,
|
||||
image: mapPayloadPageImage(block.image),
|
||||
imagePosition: block.imagePosition ?? "left",
|
||||
heading: block.heading ?? null,
|
||||
content: block.content ?? null,
|
||||
bullets: block.bullets ?? [],
|
||||
};
|
||||
case "faq":
|
||||
return { blockType: "faq", id: block.id, items: block.items };
|
||||
case "crossSell":
|
||||
return {
|
||||
blockType: "crossSell",
|
||||
id: block.id,
|
||||
productId: typeof block.product === "object" ? block.product.id : block.product,
|
||||
};
|
||||
default:
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pages (generic slug-routed content pages / page builder) ──────────────
|
||||
// Powers app/[slug]/page.tsx — the first generic catch-all route in this
|
||||
// repo (every other content page today is its own static directory).
|
||||
// `layout` is a Payload native `type: 'blocks'` field (docker/payload/src/
|
||||
// collections/Pages.ts), a different shape from Posts.content's Lexical-
|
||||
// embedded Blocks (see RichText.tsx's own comment) — each block here is a
|
||||
// full page section, not an inline prose element.
|
||||
|
||||
export type PageBlock =
|
||||
| { blockType: "hero"; id: string; headline: string; subline: string | null; image: string | null; ctaLabel: string | null; ctaHref: string | null }
|
||||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
|
||||
| { blockType: "quote"; id: string; text: string; label: string | null }
|
||||
| { blockType: "image"; id: string; image: string | null; caption: string | null }
|
||||
| { blockType: "icon"; id: string; icon: string }
|
||||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||||
| { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
|
||||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string }
|
||||
| {
|
||||
blockType: "storySplit";
|
||||
id: string;
|
||||
image: string | null;
|
||||
imagePosition: "left" | "right";
|
||||
heading: string | null;
|
||||
content: unknown;
|
||||
bullets: { id: string; text: string }[];
|
||||
}
|
||||
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] };
|
||||
|
||||
export type Page = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
layout: PageBlock[];
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
seoImage: string | null;
|
||||
};
|
||||
|
||||
type PayloadPageImageField = { url?: string | null } | number | null;
|
||||
|
||||
type PayloadPageBlock =
|
||||
| { blockType: "hero"; id: string; headline: string; subline?: string | null; image?: PayloadPageImageField; ctaLabel?: string | null; ctaHref?: string | null }
|
||||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
|
||||
| { blockType: "quote"; id: string; text: string; label?: string | null }
|
||||
| { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
|
||||
| { blockType: "icon"; id: string; icon: string }
|
||||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||||
| { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
|
||||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string }
|
||||
| {
|
||||
blockType: "storySplit";
|
||||
id: string;
|
||||
image: PayloadPageImageField;
|
||||
imagePosition?: "left" | "right" | null;
|
||||
heading?: string | null;
|
||||
content?: unknown;
|
||||
bullets?: { id: string; text: string }[];
|
||||
}
|
||||
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] };
|
||||
|
||||
export type PayloadPage = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
layout: PayloadPageBlock[];
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
seoImage?: { url: string } | number | null;
|
||||
};
|
||||
|
||||
function mapPayloadPageImage(ref: PayloadPageImageField): string | null {
|
||||
return typeof ref === "object" && ref && typeof ref.url === "string" ? ref.url : null;
|
||||
}
|
||||
|
||||
function mapPayloadPageBlock(block: PayloadPageBlock): PageBlock {
|
||||
switch (block.blockType) {
|
||||
case "image":
|
||||
return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
|
||||
case "checklistImage":
|
||||
return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
|
||||
case "quote":
|
||||
return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
|
||||
case "ctaCard":
|
||||
return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
|
||||
case "stepRow":
|
||||
return {
|
||||
blockType: "stepRow",
|
||||
id: block.id,
|
||||
items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
|
||||
};
|
||||
case "hero":
|
||||
return {
|
||||
blockType: "hero",
|
||||
id: block.id,
|
||||
headline: block.headline,
|
||||
subline: block.subline ?? null,
|
||||
image: mapPayloadPageImage(block.image ?? null),
|
||||
ctaLabel: block.ctaLabel ?? null,
|
||||
ctaHref: block.ctaHref ?? null,
|
||||
};
|
||||
case "storySplit":
|
||||
return {
|
||||
blockType: "storySplit",
|
||||
id: block.id,
|
||||
image: mapPayloadPageImage(block.image),
|
||||
imagePosition: block.imagePosition ?? "left",
|
||||
heading: block.heading ?? null,
|
||||
content: block.content ?? null,
|
||||
bullets: block.bullets ?? [],
|
||||
};
|
||||
case "faq":
|
||||
return { blockType: "faq", id: block.id, items: block.items };
|
||||
default:
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by getPageBySlug() and LivePageContent.tsx (re-maps the raw
|
||||
// document useLivePreview receives via postMessage using this same logic)
|
||||
// — same reuse pattern as mapPayloadPost.
|
||||
export function mapPayloadPage(doc: PayloadPage): Page {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
layout: (doc.layout ?? []).map(mapPayloadPageBlock),
|
||||
seoTitle: doc.seoTitle || null,
|
||||
seoDescription: doc.seoDescription || null,
|
||||
seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPageBySlug(slug: string, options?: { draft?: boolean }): Promise<Page | null> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[slug][equals]": slug,
|
||||
depth: "2",
|
||||
limit: "1",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/pages?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||||
if (!res.ok) {
|
||||
console.error(`getPageBySlug: Payload returned ${res.status} ${res.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadPage[] } = await res.json();
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return null;
|
||||
return mapPayloadPage(doc);
|
||||
}
|
||||
|
||||
export type SitemapEntry = { slug: string; updatedAt: string };
|
||||
|
||||
// Backs app/sitemap.ts. Pages has no status/draft field (see Pages.ts) —
|
||||
// every doc that exists is live — so unlike getBlogPosts there's no
|
||||
// `where[status]` filter needed here.
|
||||
export async function getAllPageSlugs(): Promise<SitemapEntry[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
depth: "0",
|
||||
limit: "100",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/pages?${params}`, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) {
|
||||
console.error(`getAllPageSlugs: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: { docs?: { slug: string; updatedAt: string }[] } = await res.json();
|
||||
return (data.docs ?? []).map((doc) => ({ slug: doc.slug, updatedAt: doc.updatedAt }));
|
||||
}
|
||||
|
||||
// Same shape as getAllPageSlugs but for Posts, which — unlike Pages — does
|
||||
// have a draft/scheduled state, so this filters to `published` the same
|
||||
// way getBlogPosts does (the sitemap must never list a post that 404s).
|
||||
export async function getAllPostSlugs(): Promise<SitemapEntry[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[status][equals]": "published",
|
||||
depth: "0",
|
||||
limit: "100",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) {
|
||||
console.error(`getAllPostSlugs: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: { docs?: { slug: string; updatedAt: string }[] } = await res.json();
|
||||
return (data.docs ?? []).map((doc) => ({ slug: doc.slug, updatedAt: doc.updatedAt }));
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Single source of truth for shipping numbers — consumed by both the
|
||||
// cart's order summary (CartContent.tsx) and the /versand policy page, so
|
||||
// the two can never drift apart.
|
||||
//
|
||||
// The delivery-timeframe numbers (handling/transit days, Art. 246a § 1
|
||||
// Abs. 1 Nr. 8 EGBGB) used to live here too, but now come from Payload's
|
||||
// Shipping Settings collection instead (see lib/payload.ts's
|
||||
// getShippingSettings()) — that duplication is exactly why the
|
||||
// ShippingMethods CMS title once carried a different, drifted day-range
|
||||
// than this file did. Cost/threshold below have the same drift risk
|
||||
// against Payload's ShippingMethods price/freeShippingThreshold fields,
|
||||
// not yet addressed here.
|
||||
export const SHIPPING_COST = 2.9;
|
||||
export const FREE_SHIPPING_THRESHOLD = 39;
|
||||
@@ -1,6 +1,8 @@
|
||||
// Thin client for the backend's DHL custom endpoints (src/lib/endpoints/
|
||||
// dhlValidatePostNumber.ts, dhlAutocompleteAddress.ts) — own copy per repo,
|
||||
// same "no shared package yet" convention as app/lib/tracking.ts.
|
||||
// unlike vies.ts/vatId.ts/tracking.ts (now shared via @einfach-produktiv/invoicing),
|
||||
// since this proxies to Payload endpoints specific to this repo's own DHL wiring,
|
||||
// not portable logic.
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Server-only — imported exclusively by app/api/stock-notifications/route.ts.
|
||||
// Reuses ORDER_SERVICE_SECRET rather than a new dedicated secret, matching
|
||||
// StockNotifications.ts's own choice on the backend (see that collection's
|
||||
// comment) — it's already the general-purpose "frontend server calling
|
||||
// into Payload" credential used by getWishlistEnabled/getSearchEnabled/etc.
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || "";
|
||||
|
||||
export async function createStockNotification(email: string, productId: number, variantName: string): Promise<{ ok: boolean; reason?: string }> {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/stock-notifications`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-order-service-secret": SERVICE_SECRET },
|
||||
body: JSON.stringify({ email, product: productId, variantName }),
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
|
||||
// The (tenant, email, product, variantName) unique index rejects a
|
||||
// second signup for the same thing with a 400 — read as "already on the
|
||||
// list", not a real error, so the UI can still show success rather than
|
||||
// a confusing failure for someone who's already signed up.
|
||||
if (res.status === 400) return { ok: true };
|
||||
|
||||
console.error(`createStockNotification: Payload returned ${res.status} ${res.statusText}`);
|
||||
return { ok: false, reason: "Eintragen hat nicht geklappt. Bitte versuch es später erneut." };
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function buildOrganizationSchema(seller: CompanySettings | null): Record<
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}/#organization`,
|
||||
name: "einfach produktiv",
|
||||
name: "einfach produktiv.",
|
||||
url: SITE_URL,
|
||||
};
|
||||
}
|
||||
@@ -53,9 +53,10 @@ export function buildProductSchema(product: Product, url: string, seller: Compan
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
image: product.image,
|
||||
description: product.descriptionText,
|
||||
image: product.gallery.length > 0 ? [product.image, ...product.gallery] : product.image,
|
||||
url,
|
||||
...(product.sku ? { sku: product.sku } : {}),
|
||||
// No reviews/ratings system exists yet — `aggregateRating` is
|
||||
// optional in the spec and deliberately omitted rather than faked;
|
||||
// add it here once real reviews exist, not before.
|
||||
@@ -91,7 +92,7 @@ export function buildArticleSchema(
|
||||
// "Björn" is already hardcoded in the page's own author-bio block,
|
||||
// matched here rather than left out entirely.
|
||||
author: { "@type": "Person", name: "Björn" },
|
||||
publisher: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv" },
|
||||
publisher: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv." },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,3 +101,23 @@ export function buildArticleSchema(
|
||||
// directly (no need for a shared component around one line of JSX, and
|
||||
// keeps this file free of "use client"/React concerns so server
|
||||
// components can import it without issue).
|
||||
|
||||
// Generic fallback for content pages with no bespoke schema type of their
|
||||
// own (Article for blog posts, Product for PDPs) — the Payload Pages
|
||||
// collection (page builder) serves arbitrary content types, so a generic
|
||||
// WebPage node is the right shape rather than guessing at a more specific
|
||||
// one from the blocks it happens to contain.
|
||||
export function buildWebPageSchema(
|
||||
page: { title: string; seoDescription: string | null; slug: string },
|
||||
seller: CompanySettings | null,
|
||||
): Record<string, unknown> {
|
||||
const url = `${SITE_URL}/${page.slug}`;
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: page.title,
|
||||
url,
|
||||
...(page.seoDescription ? { description: page.seoDescription } : {}),
|
||||
isPartOf: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv." },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// Mirrors the Payload backend's own src/lib/tracking.ts — same carrier
|
||||
// set/labels/URL patterns, kept in sync by hand (two separate
|
||||
// deployments, no shared package). Used to render a clickable tracking
|
||||
// link on /konto/bestellungen/[orderNumber]; the backend's copy builds
|
||||
// the same link for the order-shipped email.
|
||||
export const CARRIER_LABELS: Record<string, string> = {
|
||||
dhl: "DHL",
|
||||
dpd: "DPD",
|
||||
hermes: "Hermes",
|
||||
ups: "UPS",
|
||||
gls: "GLS",
|
||||
other: "Sonstiger Versanddienstleister",
|
||||
};
|
||||
|
||||
const CARRIER_TRACKING_URL: Record<string, (trackingNumber: string) => string> = {
|
||||
dhl: (n) => `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(n)}`,
|
||||
dpd: (n) => `https://tracking.dpd.de/status/de_DE/parcel/${encodeURIComponent(n)}`,
|
||||
hermes: (n) => `https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation/#${encodeURIComponent(n)}`,
|
||||
ups: (n) => `https://www.ups.com/track?loc=de_DE&tracknum=${encodeURIComponent(n)}`,
|
||||
gls: (n) => `https://www.gls-pakete.de/sendungsverfolgung?trackingNumber=${encodeURIComponent(n)}`,
|
||||
};
|
||||
|
||||
export function buildTrackingUrl(carrier: string | null | undefined, trackingNumber: string | null | undefined): string | null {
|
||||
if (!carrier || !trackingNumber) return null;
|
||||
return CARRIER_TRACKING_URL[carrier]?.(trackingNumber) ?? null;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
// an item, without a shared cache library.
|
||||
const WISHLIST_EVENT = "ep-wishlist-updated";
|
||||
|
||||
type WishlistItem = { id: number; productId: number; variant: string };
|
||||
type WishlistItem = { id: number; productId: number; variant: string; purchasedAt: string | null };
|
||||
|
||||
let cachedItems: WishlistItem[] | null = null;
|
||||
|
||||
@@ -63,7 +63,7 @@ export function useWishlist() {
|
||||
const wasWishlisted = cachedItems?.some((i) => i.productId === productId && i.variant === variant) ?? false;
|
||||
const optimistic = wasWishlisted
|
||||
? (cachedItems ?? []).filter((i) => !(i.productId === productId && i.variant === variant))
|
||||
: [...(cachedItems ?? []), { id: -1, productId, variant }];
|
||||
: [...(cachedItems ?? []), { id: -1, productId, variant, purchasedAt: null }];
|
||||
cachedItems = optimistic;
|
||||
setItems(optimistic);
|
||||
|
||||
@@ -74,7 +74,7 @@ export function useWishlist() {
|
||||
body: JSON.stringify({ productId, variant }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
|
||||
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant, purchasedAt: null }] : optimistic.filter((i) => i.productId !== productId);
|
||||
setItems(cachedItems);
|
||||
return { ok: false as const, unauthorized: true as const };
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function useWishlist() {
|
||||
broadcast();
|
||||
return { ok: true as const, wishlisted: data.wishlisted ?? !wasWishlisted };
|
||||
} catch {
|
||||
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
|
||||
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant, purchasedAt: null }] : optimistic.filter((i) => i.productId !== productId);
|
||||
setItems(cachedItems);
|
||||
return { ok: false as const };
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Mirrors the backend's own USt-IdNr. validation exactly (Orders.ts/
|
||||
// Customers.ts/CompanySettings.ts in the Payload repo) — kept as a plain
|
||||
// client+server-safe helper here since this repo's frontend needs the same
|
||||
// check twice (checkout's instant client-side pattern + api/checkout's own
|
||||
// server-side re-validation, same "never trust the client" reasoning as
|
||||
// every other checkout field).
|
||||
const VAT_ID_PATTERN = /^[A-Z]{2}[A-Z0-9]{2,12}$/;
|
||||
|
||||
export function normalizeVatId(value: string): string {
|
||||
return value.toUpperCase().trim();
|
||||
}
|
||||
|
||||
export function isValidVatId(value: string): boolean {
|
||||
return VAT_ID_PATTERN.test(value);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Server-only — calls the European Commission's public VIES REST API to
|
||||
// confirm an EU VAT ID is actually registered, not just correctly
|
||||
// formatted (see lib/vatId.ts's own comment: format alone is never
|
||||
// enough to zero-rate an invoice). Confirmed live and working against
|
||||
// the real endpoint 2026-07-23 (POST {countryCode, vatNumber} →
|
||||
// {valid: boolean, ...}) — this is the Commission's own documented REST
|
||||
// API, not a guess.
|
||||
const VIES_URL = "https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number";
|
||||
|
||||
export type ViesCheckResult =
|
||||
| { ok: true; valid: boolean; name: string | null; address: string | null }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
// `vatNumber` must NOT include the country prefix (VIES wants it split
|
||||
// out) — callers pass the full "DE123456789"-shaped id and this function
|
||||
// does the splitting, since every call site already has the normalized
|
||||
// full id (see lib/vatId.ts's normalizeVatId()) rather than the two parts
|
||||
// separately.
|
||||
export async function checkVatIdViaVies(vatId: string): Promise<ViesCheckResult> {
|
||||
const countryCode = vatId.slice(0, 2);
|
||||
const vatNumber = vatId.slice(2);
|
||||
if (!countryCode || !vatNumber) return { ok: false, reason: "Ungültiges USt-IdNr.-Format." };
|
||||
|
||||
try {
|
||||
// 8s timeout — VIES is a shared EU-wide government service with no
|
||||
// uptime SLA to this shop; a slow/unreachable response must not hang
|
||||
// checkout indefinitely. Callers treat `ok: false` as "couldn't
|
||||
// confirm" and fail closed (no exemption), never as "confirmed invalid".
|
||||
const res = await fetch(VIES_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ countryCode, vatNumber }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return { ok: false, reason: `VIES antwortete mit ${res.status}` };
|
||||
const data: { actionSucceed?: boolean; valid?: boolean; name?: string; address?: string; errorWrappers?: { error?: string }[] } = await res.json();
|
||||
// VIES answers 200 even when it couldn't actually perform the check —
|
||||
// `actionSucceed: false` (e.g. `MS_UNAVAILABLE`, the member state's own
|
||||
// national gateway being temporarily down — Germany's in particular is
|
||||
// known to do this) means "couldn't confirm", not "confirmed invalid".
|
||||
// Without this check a `MS_UNAVAILABLE` response fell through to
|
||||
// `Boolean(data.valid)` on a body that has no `valid` field at all,
|
||||
// silently reading as `valid: false` — a real, currently-registered VAT
|
||||
// ID would then look rejected instead of "VIES unavailable, try again".
|
||||
if (data.actionSucceed === false) {
|
||||
const reason = data.errorWrappers?.[0]?.error ?? "VIES konnte die Anfrage nicht bearbeiten.";
|
||||
return { ok: false, reason: `VIES: ${reason}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
valid: Boolean(data.valid),
|
||||
name: data.name && data.name !== "---" ? data.name : null,
|
||||
address: data.address && data.address !== "---" ? data.address : null,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "VIES ist gerade nicht erreichbar." };
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ export default function NewsletterConfirmedPage() {
|
||||
>
|
||||
Bestätigt!
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Deine Newsletter-Anmeldung ist eingegangen.
|
||||
</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
|
||||
<div className="flex flex-col gap-4 text-left text-body text-text-muted max-w-[28rem] pt-2">
|
||||
@@ -54,10 +60,19 @@ export default function NewsletterConfirmedPage() {
|
||||
<div className="flex flex-col gap-3 bg-bg-muted rounded-md px-6 py-5">
|
||||
<p className="font-semibold text-text-primary">🧭 Dein Klarheitskompass</p>
|
||||
<p>Du erhältst ihn in einer separaten E-Mail in den nächsten Minuten – er hilft dir, kurz innezuhalten und dich zu fragen:</p>
|
||||
<ul className="flex flex-col gap-1 list-disc pl-5">
|
||||
<li>Was beschäftigt mich gerade?</li>
|
||||
<li>Was ist wirklich wichtig?</li>
|
||||
<li>Worauf möchte ich meinen Fokus legen?</li>
|
||||
<ul className="flex flex-col gap-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon />
|
||||
<span>Was beschäftigt mich gerade?</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon />
|
||||
<span>Was ist wirklich wichtig?</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon />
|
||||
<span>Worauf möchte ich meinen Fokus legen?</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -66,13 +81,16 @@ export default function NewsletterConfirmedPage() {
|
||||
Beobachte in den nächsten Tagen einfach einmal, womit du deine Aufmerksamkeit
|
||||
verbringst. Nicht bewerten. Nur wahrnehmen.
|
||||
</p>
|
||||
<p>Bis Sonntag</p>
|
||||
<p className="text-text-primary font-semibold">Björn</p>
|
||||
<p>
|
||||
Bis Sonntag
|
||||
<br />
|
||||
<span className="text-text-primary font-semibold">Björn</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/shop"
|
||||
className="inline-flex items-center justify-center py-4 px-8 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
className="inline-flex items-center justify-center px-7 py-4 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-h4 text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt stöbern
|
||||
</Link>
|
||||
@@ -83,3 +101,13 @@ export default function NewsletterConfirmedPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Same checkmark used by PageBlocks.tsx's checklistImage block (and
|
||||
// /lebensuhr's, /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,21 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { NextRequest } from "next/server";
|
||||
import { resolveAndTrackRedirect } from "../../lib/payload";
|
||||
|
||||
// A static short link (e.g. printed on a QR code) that always resolves to
|
||||
// whatever `targetPath` is currently set on the matching Redirects document
|
||||
// in Payload — the QR code itself never needs reprinting when the target
|
||||
// changes. `redirect()` (not permanentRedirect()) issues a 307, deliberately
|
||||
// non-cacheable client-side since the target can change at any time.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ code: string }> }) {
|
||||
const { code } = await params;
|
||||
const targetPath = await resolveAndTrackRedirect(code, "/r");
|
||||
|
||||
// Defense in depth — Redirects.targetPath is already validated in Payload
|
||||
// to start with "/", same open-redirect guard as api/preview/route.ts.
|
||||
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
redirect(targetPath);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const SITE_URL = "https://einfach-produktiv.mk360.de";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/checkout", "/cart", "/konto", "/bestellbestaetigung", "/newsletter-confirmed", "/email-preview", "/company-settings-preview", "/api"],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
// Category checkboxes (union — any checked category matches) plus a
|
||||
// separate "Nur verfügbare Produkte" availability toggle, sharing one
|
||||
// component since both are simple instant-apply checkboxes writing to the
|
||||
// same /shop searchParams (no submit button, same as the price slider —
|
||||
// see PriceRangeFilter.tsx). Categories are derived from the active
|
||||
// product set by the caller (ProductGrid.tsx), same "distinct values from
|
||||
// what's actually in use" pattern as the blog's category chips — an
|
||||
// uncategorized product still matches every category filter (see
|
||||
// Product.categories' own comment in lib/payload.ts), so this list never
|
||||
// needs an "Alle" fallback entry.
|
||||
export function CategoryFilter({ categories }: { categories: string[] }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const activeCategories = (searchParams.get("categories") ?? "").split(",").filter(Boolean);
|
||||
const inStockOnly = searchParams.get("inStock") === "1";
|
||||
|
||||
function push(params: URLSearchParams) {
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/shop?${qs}` : "/shop");
|
||||
}
|
||||
|
||||
function toggleCategory(category: string) {
|
||||
const next = activeCategories.includes(category)
|
||||
? activeCategories.filter((c) => c !== category)
|
||||
: [...activeCategories, category];
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (next.length > 0) params.set("categories", next.join(","));
|
||||
else params.delete("categories");
|
||||
push(params);
|
||||
}
|
||||
|
||||
function toggleInStock() {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (inStockOnly) params.delete("inStock");
|
||||
else params.set("inStock", "1");
|
||||
push(params);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{categories.length > 1 && (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<p className="font-bold text-body-sm text-text-primary">Kategorie</p>
|
||||
{categories.map((category) => (
|
||||
<label key={category} className="flex items-center gap-2 text-body-sm text-text-primary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeCategories.includes(category)}
|
||||
onChange={() => toggleCategory(category)}
|
||||
className="h-4 w-4 rounded-sm border-border accent-brand cursor-pointer"
|
||||
/>
|
||||
{category}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-body-sm text-text-primary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inStockOnly}
|
||||
onChange={toggleInStock}
|
||||
className="h-4 w-4 rounded-sm border-border accent-brand cursor-pointer"
|
||||
/>
|
||||
Nur verfügbare Produkte
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
|
||||
// Real min/max range (not preset toggle buckets, tried first and
|
||||
// reverted 2026-07-30 — "keine toggle badges") — two plain number inputs,
|
||||
// submitted via a small Client Component's router.push. Still a plain
|
||||
// URL search param underneath (?minPrice=&maxPrice=), so the result stays
|
||||
// shareable/bookmarkable like every other filter on the site.
|
||||
// Two overlapping native <input type="range"> thumbs (a plain CSS trick —
|
||||
// track transparent/pointer-events-none, only the thumb itself clickable
|
||||
// via the ::-webkit-slider-thumb/::-moz-range-thumb pseudo-elements) —
|
||||
// replaced the earlier two-number-input version (2026-08-01) for a more
|
||||
// direct "drag to filter" feel. `onInput` updates the visual position on
|
||||
// every drag frame; the URL/navigation only commits on `onChange` (fires
|
||||
// once, on mouse-up/key-up), so dragging doesn't spam router.push.
|
||||
export function PriceRangeFilter({
|
||||
catalogMin,
|
||||
catalogMax,
|
||||
@@ -15,87 +18,102 @@ export function PriceRangeFilter({
|
||||
}: {
|
||||
catalogMin: number;
|
||||
catalogMax: number;
|
||||
/** "bar" (default): horizontal row, wraps — used above the grid at
|
||||
* <lg (see ProductGrid.tsx's mobile/tablet filter bar). "sidebar":
|
||||
* stacked vertically to fit the lg:+ left sidebar column instead. */
|
||||
/** "bar" (default): horizontal row — used above the grid at <lg (see
|
||||
* ProductGrid.tsx's mobile/tablet filter bar). "sidebar": stacked to
|
||||
* fit the lg:+ left sidebar column instead. */
|
||||
layout?: "bar" | "sidebar";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") ?? "");
|
||||
const [maxPrice, setMaxPrice] = useState(searchParams.get("maxPrice") ?? "");
|
||||
const paramMin = searchParams.get("minPrice");
|
||||
const paramMax = searchParams.get("maxPrice");
|
||||
const [minPrice, setMinPrice] = useState(paramMin ? Number(paramMin) : catalogMin);
|
||||
const [maxPrice, setMaxPrice] = useState(paramMax ? Number(paramMax) : catalogMax);
|
||||
|
||||
const hasFilter = Boolean(searchParams.get("minPrice") || searchParams.get("maxPrice"));
|
||||
const hasFilter = Boolean(paramMin || paramMax);
|
||||
const sidebar = layout === "sidebar";
|
||||
|
||||
function apply(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
if (minPrice) params.set("minPrice", minPrice);
|
||||
if (maxPrice) params.set("maxPrice", maxPrice);
|
||||
function commit(nextMin: number, nextMax: number) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (nextMin > catalogMin) params.set("minPrice", String(nextMin));
|
||||
else params.delete("minPrice");
|
||||
if (nextMax < catalogMax) params.set("maxPrice", String(nextMax));
|
||||
else params.delete("maxPrice");
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/shop?${qs}` : "/shop");
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setMinPrice("");
|
||||
setMaxPrice("");
|
||||
router.push("/shop");
|
||||
setMinPrice(catalogMin);
|
||||
setMaxPrice(catalogMax);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("minPrice");
|
||||
params.delete("maxPrice");
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/shop?${qs}` : "/shop");
|
||||
}
|
||||
|
||||
const sidebar = layout === "sidebar";
|
||||
// Clamped against each other while dragging — a thumb can't be pushed
|
||||
// past its sibling, so the range never visually inverts.
|
||||
function onMinInput(value: number) {
|
||||
setMinPrice(Math.min(value, maxPrice));
|
||||
}
|
||||
function onMaxInput(value: number) {
|
||||
setMaxPrice(Math.max(value, minPrice));
|
||||
}
|
||||
|
||||
const range = catalogMax - catalogMin || 1;
|
||||
const minPct = ((minPrice - catalogMin) / range) * 100;
|
||||
const maxPct = ((maxPrice - catalogMin) / range) * 100;
|
||||
|
||||
const thumbClasses =
|
||||
"absolute w-full m-0 appearance-none bg-transparent pointer-events-none " +
|
||||
"[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-sm " +
|
||||
"[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-brand [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer " +
|
||||
"[&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:bg-transparent";
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={apply}
|
||||
className={sidebar ? "flex flex-col gap-3 items-stretch w-full" : "flex flex-wrap items-end gap-3 w-full pb-6"}
|
||||
>
|
||||
<div className={sidebar ? "flex flex-col gap-3 items-stretch w-full" : "flex flex-col gap-2 w-full pb-6 sm:max-w-xs"}>
|
||||
{sidebar && <p className="font-bold text-body-sm text-text-primary">Preis</p>}
|
||||
<div className={sidebar ? "flex items-end gap-3 w-full" : "flex items-end gap-3"}>
|
||||
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
|
||||
<span className="text-label text-text-muted">Von</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder={`${catalogMin}`}
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
className={`${sidebar ? "w-full" : "w-24"} border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors`}
|
||||
/>
|
||||
</label>
|
||||
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
|
||||
<span className="text-label text-text-muted">Bis</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder={`${catalogMax}`}
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
className={`${sidebar ? "w-full" : "w-24"} border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors`}
|
||||
/>
|
||||
</label>
|
||||
{!sidebar && <span className="text-body-sm text-text-muted">€</span>}
|
||||
<div className="flex items-center justify-between text-label text-text-muted">
|
||||
<span>{formatPrice(minPrice)}</span>
|
||||
<span>{formatPrice(maxPrice)}</span>
|
||||
</div>
|
||||
<div className={sidebar ? "flex flex-col gap-2 items-stretch w-full" : "flex items-center gap-3"}>
|
||||
<div className="relative h-4 flex items-center">
|
||||
<div className="absolute inset-x-0 h-1 rounded-full bg-bg-muted" />
|
||||
<div className="absolute h-1 rounded-full bg-brand" style={{ left: `${minPct}%`, right: `${100 - maxPct}%` }} />
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Mindestpreis"
|
||||
min={catalogMin}
|
||||
max={catalogMax}
|
||||
step="0.1"
|
||||
value={minPrice}
|
||||
onInput={(e) => onMinInput(Number(e.currentTarget.value))}
|
||||
onChange={() => commit(minPrice, maxPrice)}
|
||||
className={thumbClasses}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Höchstpreis"
|
||||
min={catalogMin}
|
||||
max={catalogMax}
|
||||
step="0.1"
|
||||
value={maxPrice}
|
||||
onInput={(e) => onMaxInput(Number(e.currentTarget.value))}
|
||||
onChange={() => commit(minPrice, maxPrice)}
|
||||
className={thumbClasses}
|
||||
/>
|
||||
</div>
|
||||
{hasFilter && (
|
||||
<button
|
||||
type="submit"
|
||||
className={`${sidebar ? "w-full" : ""} px-4 py-2 rounded-sm bg-brand text-body-sm font-bold text-text-primary hover:brightness-95 active:scale-[0.97] transition-all`}
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="self-start text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
|
||||
>
|
||||
Anwenden
|
||||
Zurücksetzen
|
||||
</button>
|
||||
{hasFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className={`${sidebar ? "text-center" : ""} text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors`}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled, getShopFilterEnabled } from "../../lib/payload";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
|
||||
import { WishlistButton } from "../../components/WishlistButton";
|
||||
import { ProductCard } from "../../components/ProductCard";
|
||||
import { PriceRangeFilter } from "./PriceRangeFilter";
|
||||
import { CategoryFilter } from "./CategoryFilter";
|
||||
|
||||
// Server Component — fetches straight from Payload (getProducts(), ISR
|
||||
// cached 60s) rather than going through the client-side useProducts()
|
||||
@@ -14,13 +10,20 @@ import { PriceRangeFilter } from "./PriceRangeFilter";
|
||||
// there's no reason to pay for a client fetch when a server one already
|
||||
// gives faster first paint and no loading flash.
|
||||
|
||||
// Products have no category taxonomy today (only Posts do) — a "N
|
||||
// checkboxes" category sidebar isn't buildable against real data yet, so
|
||||
// this only covers price, the one dimension that already exists on every
|
||||
// product. A real min/max range (PriceRangeFilter.tsx), not preset toggle
|
||||
// buckets — tried buckets-as-toggle-chips first, reverted 2026-07-30
|
||||
// ("keine toggle badges").
|
||||
export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?: string; maxPrice?: string } }) {
|
||||
// Three filter dimensions: price (PriceRangeFilter.tsx, a real min/max
|
||||
// slider — preset toggle buckets were tried first and reverted 2026-07-30,
|
||||
// "keine toggle badges"), category (CategoryFilter.tsx, since
|
||||
// `products.categories` now exists — same distinct-values-in-use pattern
|
||||
// as the blog's category chips), and availability (in-stock only).
|
||||
function isProductFullyOutOfStock(product: { outOfStock: boolean; variants: { outOfStock: boolean }[] }): boolean {
|
||||
return product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
|
||||
}
|
||||
|
||||
export async function ProductGrid({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: { minPrice?: string; maxPrice?: string; categories?: string; inStock?: string };
|
||||
}) {
|
||||
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled, shopFilterEnabled] = await Promise.all([
|
||||
getProducts(),
|
||||
getShippingSettings(),
|
||||
@@ -44,18 +47,36 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
|
||||
const catalogMax = Math.max(...catalogPrices);
|
||||
const minPrice = shopFilterEnabled && searchParams?.minPrice ? Number(searchParams.minPrice) : null;
|
||||
const maxPrice = shopFilterEnabled && searchParams?.maxPrice ? Number(searchParams.maxPrice) : null;
|
||||
const products = allActiveProducts.filter((p) => (minPrice === null || p.price >= minPrice) && (maxPrice === null || p.price <= maxPrice));
|
||||
|
||||
const hasSidebarFilter = shopFilterEnabled && catalogMin !== catalogMax;
|
||||
const allCategories = Array.from(new Set(allActiveProducts.flatMap((p) => p.categories))).sort((a, b) => a.localeCompare(b, "de"));
|
||||
const activeCategories =
|
||||
shopFilterEnabled && searchParams?.categories ? searchParams.categories.split(",").filter(Boolean) : [];
|
||||
const inStockOnly = shopFilterEnabled && searchParams?.inStock === "1";
|
||||
const anyOutOfStock = allActiveProducts.some(isProductFullyOutOfStock);
|
||||
|
||||
const products = allActiveProducts.filter((p) => {
|
||||
if (minPrice !== null && p.price < minPrice) return false;
|
||||
if (maxPrice !== null && p.price > maxPrice) return false;
|
||||
// Uncategorized products (categories.length === 0) always match — see
|
||||
// Product.categories' own comment in lib/payload.ts.
|
||||
if (activeCategories.length > 0 && p.categories.length > 0 && !p.categories.some((c) => activeCategories.includes(c))) {
|
||||
return false;
|
||||
}
|
||||
if (inStockOnly && isProductFullyOutOfStock(p)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const hasSidebarFilter = shopFilterEnabled && (catalogMin !== catalogMax || allCategories.length > 1 || anyOutOfStock);
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
|
||||
{/* <lg: filter (if any) sits as its own bar above the grid — same as
|
||||
before. lg+: it moves into a left sidebar instead (see aside
|
||||
below), so it's hidden here to avoid rendering twice. */}
|
||||
{/* <lg: filters (if any) sit as their own bar above the grid — same
|
||||
as before. lg+: they move into a left sidebar instead (see aside
|
||||
below), so this is hidden there to avoid rendering twice. */}
|
||||
{hasSidebarFilter && (
|
||||
<div className="lg:hidden">
|
||||
<div className="lg:hidden flex flex-col gap-4 pb-10">
|
||||
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />
|
||||
<CategoryFilter categories={allCategories} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -64,13 +85,17 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
|
||||
fills the row exactly as before, no empty reserved column. */}
|
||||
<div className={hasSidebarFilter ? "lg:flex lg:gap-10 w-full" : "w-full"}>
|
||||
{hasSidebarFilter && (
|
||||
<aside className="hidden lg:block lg:w-56 shrink-0">
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-56 shrink-0 gap-6">
|
||||
{/* Same label treatment as AccountNav.tsx's "Mein Konto"
|
||||
heading — this sidebar is visually modeled on that one. */}
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide px-3 pb-2">Filter</p>
|
||||
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} layout="sidebar" />
|
||||
<CategoryFilter categories={allCategories} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
|
||||
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte für diese Filter gefunden.</p>}
|
||||
|
||||
{/* 2-up from the mobile breakpoint (sm, 640px) through 1023px — was
|
||||
sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a
|
||||
@@ -80,98 +105,46 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
|
||||
filter active the grid still renders at this same 3-up density,
|
||||
simplest to keep one fixed lg: density rather than branching
|
||||
the whole grid on hasSidebarFilter too. */}
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
|
||||
{products.map((product) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
// A varianted product only reads as "ausverkauft" overall once
|
||||
// every one of its variants is — a single sold-out variant just
|
||||
// shows as such in the picker itself (AddToCartInlineButton),
|
||||
// not as a blanket badge that would misleadingly suggest the
|
||||
// whole product is unavailable while other variants still are.
|
||||
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
|
||||
// Mirrors fullyOutOfStock's "any vs. every" split — a varianted
|
||||
// product reads as low-stock as soon as one variant is, since a
|
||||
// shopper landing on the grid hasn't picked a variant yet.
|
||||
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
|
||||
return (
|
||||
<RevealItem
|
||||
key={product.id}
|
||||
className="group lg:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
|
||||
{/* key'd on the rendered product set — same fix, same reason as
|
||||
blog/page.tsx's RevealGroup: `whileInView` only fires once per
|
||||
component instance, and a filter change (price/category/
|
||||
inStock) re-renders this same page in place rather than
|
||||
remounting it, so RevealGroup itself never naturally unmounts
|
||||
between filter states. Without the key, freshly swapped-in
|
||||
RevealItems mounted into an already-settled parent and stayed
|
||||
stuck at opacity 0 — the grid going blank/white after
|
||||
applying a filter. */}
|
||||
{/* No items-start here (was set before, silently defeating
|
||||
ProductCard's own h-full, built exactly for equal-height
|
||||
cards) — default items-stretch is what makes every card in a
|
||||
row match the tallest one overall. That alone doesn't align
|
||||
each card's internal rows (e.g. the price sitting right
|
||||
after a subline that wraps to 2 lines on one card but 1 on
|
||||
its neighbor) — see ProductCard.tsx's own comment on the
|
||||
subgrid rows (grid-auto-rows below) that solves that part:
|
||||
each RevealItem here spans the same 6 row-tracks and passes
|
||||
them down via grid-template-rows: subgrid, so row heights are
|
||||
genuinely shared, no fixed line-clamp/truncation needed. */}
|
||||
<RevealGroup
|
||||
key={products.map((p) => p.id).join(",")}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full"
|
||||
>
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
<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" : ""}`}
|
||||
/>
|
||||
{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 />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<p
|
||||
className="font-semibold text-h4 text-text-primary w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<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>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
</div>
|
||||
{/* Always rendered, text conditional — not a conditional
|
||||
block — so this line's height (min-h as a cross-browser
|
||||
safety net for the empty case) is identical whether or
|
||||
not the product is low-stock. See AddToCartButton.tsx's
|
||||
own comment: an earlier text-based low-stock hint here
|
||||
broke equal card heights across the grid, which is why
|
||||
it moved to the image-overlay pill in the first place. */}
|
||||
<p className="min-h-[1.05rem] text-label font-bold text-warning">
|
||||
{anyLowStock ? "Nur noch wenige verfügbar" : null}
|
||||
</p>
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
className="flex items-center gap-1 font-bold text-label text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span>Mehr erfahren</span>
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* flex-1 spacer — pins every card's button to the same Y
|
||||
regardless of the "Mehr erfahren" line only products with
|
||||
a detail page have (see figma-to-nextjs skill's Tools.tsx
|
||||
equal-height lesson). */}
|
||||
<div className="flex-1" />
|
||||
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
})}
|
||||
{products.map((product) => (
|
||||
<RevealItem key={product.id} className="lg:col-span-4 grid [grid-template-rows:subgrid] [grid-row:span_6]">
|
||||
<ProductCard
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
wishlistEnabled={wishlistEnabled}
|
||||
wishlistRevealOnHover
|
||||
belowPrice={
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ShopHeader() {
|
||||
<span className="text-text-primary">Shop</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Shop
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
||||
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
|
||||
alternates: { canonical: "/shop" },
|
||||
openGraph: {
|
||||
title: "Shop | einfach produktiv",
|
||||
title: "Shop | einfach produktiv.",
|
||||
description:
|
||||
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
|
||||
url: "/shop",
|
||||
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
||||
export default async function ShopPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ minPrice?: string; maxPrice?: string }>;
|
||||
searchParams: Promise<{ minPrice?: string; maxPrice?: string; categories?: string; inStock?: string }>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getAllPageSlugs, getAllPostSlugs } from "./lib/payload";
|
||||
|
||||
const SITE_URL = "https://einfach-produktiv.mk360.de";
|
||||
|
||||
// Every indexable static route in this repo. Deliberately excludes:
|
||||
// - transactional/session pages already marked `robots: noindex` in their
|
||||
// own page.tsx (checkout, checkout/verarbeitung, cart, bestellbestaetigung,
|
||||
// newsletter-confirmed, company-settings-preview, /konto/*) — Google
|
||||
// explicitly advises against listing noindex URLs in a sitemap.
|
||||
// - /sticker/[code] and /r/[code]: redirect-only routes with no content of
|
||||
// their own (see resolveAndTrackRedirect), not pages to index.
|
||||
// - /email-preview/[type]: internal template-preview tool, not customer-facing.
|
||||
// - /blog/[slug] and /[slug] (Payload Pages): appended dynamically below.
|
||||
const STATIC_ROUTES = [
|
||||
"",
|
||||
"/blog",
|
||||
"/shop",
|
||||
"/newsletter",
|
||||
"/der-eine",
|
||||
"/tasse-die-pause",
|
||||
"/die-sieben",
|
||||
"/einfach-anfangen",
|
||||
"/7-tage-klarheits-check",
|
||||
"/sticker",
|
||||
"/agb",
|
||||
"/datenschutz",
|
||||
"/impressum",
|
||||
"/versand",
|
||||
"/widerruf",
|
||||
];
|
||||
|
||||
// New routes only ever need adding in one of two places from now on: a
|
||||
// plain static page.tsx goes in STATIC_ROUTES above; anything authored
|
||||
// through Payload (a Pages doc or a blog Post) shows up here automatically
|
||||
// the next time this regenerates — no code change needed for those.
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const [pages, posts] = await Promise.all([getAllPageSlugs(), getAllPostSlugs()]);
|
||||
|
||||
const staticEntries: MetadataRoute.Sitemap = STATIC_ROUTES.map((path) => ({
|
||||
url: `${SITE_URL}${path}`,
|
||||
changeFrequency: path === "" ? "daily" : "weekly",
|
||||
priority: path === "" ? 1 : 0.7,
|
||||
}));
|
||||
|
||||
const pageEntries: MetadataRoute.Sitemap = pages.map(({ slug, updatedAt }) => ({
|
||||
url: `${SITE_URL}/${slug}`,
|
||||
lastModified: updatedAt,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
const postEntries: MetadataRoute.Sitemap = posts.map(({ slug, updatedAt }) => ({
|
||||
url: `${SITE_URL}/blog/${slug}`,
|
||||
lastModified: updatedAt,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
}));
|
||||
|
||||
return [...staticEntries, ...pageEntries, ...postEntries];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user