Compare commits
70 Commits
eb160cac61
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1903,6 +1903,36 @@ one; a second `google-maps` row would collide on that name. To actually
|
||||
use it: `<GoogleMapsEmbed src="<Google Maps embed URL>" title="..." />`
|
||||
anywhere.
|
||||
|
||||
## Short links & QR redirects
|
||||
|
||||
`app/r/[code]/route.ts` and `app/sticker/[code]/route.ts` are static
|
||||
short-link routes (e.g. printed on a flyer or a physical sticker's QR
|
||||
code) that resolve a `code` against Payload's `redirects` collection and
|
||||
307-redirect (`redirect()`, deliberately not `permanentRedirect()` — the
|
||||
target can change at any time and this must never be client-cached) to
|
||||
that doc's `targetPath`. Both routes call the same
|
||||
`resolveAndTrackRedirect(code, urlPrefix)` in `app/lib/payload.ts`, which
|
||||
also fire-and-forget PATCHes `clickCount`/`lastClickedAt` on every hit
|
||||
(tracking failure only logs — never worth stranding a visitor over).
|
||||
|
||||
- **`/r/[code]`** is the default scheme for all new short links —
|
||||
`einfach-produktiv.com/r/aktion-sommer`.
|
||||
- **`/sticker/[code]`** exists only because a batch of physical stickers
|
||||
was already printed as `einfach-produktiv.com/sticker/<code>` (`echt`,
|
||||
`geheimnis`, `fokus`) before `/r/[code]` existed and can't be
|
||||
reprinted — same collection, same lookup, just a second fixed prefix.
|
||||
- The Redirects doc's `urlPrefix` field (`/r` or `/sticker`) isn't just an
|
||||
admin label — `resolveAndTrackRedirect` filters on it too, so a code is
|
||||
only reachable under whichever prefix its doc is actually set to.
|
||||
Changing `urlPrefix` in Payload immediately changes which route serves
|
||||
that code.
|
||||
- Defense in depth: `targetPath` must start with `/` and not `//` (open-
|
||||
redirect guard, same check `app/api/preview/route.ts` does), enforced
|
||||
both in Payload's own field validation and again in each route.ts before
|
||||
calling `redirect()`.
|
||||
- A deactivated (`active: false`) doc — or no matching doc at all —
|
||||
renders a plain 404 (`notFound()`), not a redirect to some fallback.
|
||||
|
||||
## Product image gallery
|
||||
|
||||
`ProductGallery.tsx` — main image + thumbnail strip, swappable on click OR
|
||||
|
||||
+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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -162,7 +162,7 @@ export default async function BlogDetailPage({
|
||||
>
|
||||
{post.relatedProduct.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.description}</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.descriptionText}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap mt-1 sm:mt-0">
|
||||
Entdecken
|
||||
|
||||
+22
-44
@@ -57,46 +57,27 @@ export default async function BlogOverviewPage({
|
||||
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]">
|
||||
{/* Breadcrumb + text-h-feature heading — same treatment as
|
||||
/shop's ShopHeader.tsx and every legal page, this hero was
|
||||
the one page missing both (its own bespoke photo-bleed
|
||||
layout predates that convention being established
|
||||
elsewhere). */}
|
||||
<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>
|
||||
{/* Moved into the hero's own text column (was a separate full-
|
||||
width bar below the entire hero, including the photo) — on
|
||||
mobile especially (text stacks above the photo here), that
|
||||
pushed the filters below a full extra screen's worth of
|
||||
hero image before they were even visible. Living right
|
||||
under the description keeps them in view immediately, on
|
||||
every width, no scrolling past the photo needed. */}
|
||||
{allCategories.length > 1 && (
|
||||
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
|
||||
)}
|
||||
</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>
|
||||
|
||||
{posts.length === 0 && (
|
||||
@@ -106,14 +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.
|
||||
// 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="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
|
||||
<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"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { ArrowLeftIcon } from "../../components/ArrowLeftIcon";
|
||||
import { ProductName } from "../../components/ProductName";
|
||||
import { FreeShippingBanner } from "./FreeShippingBanner";
|
||||
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
|
||||
@@ -260,7 +261,7 @@ export function CartContent({
|
||||
className="font-semibold text-h-small text-text-primary hover:text-brand transition-colors"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
<ProductName name={product.name} />
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -268,15 +269,15 @@ export function CartContent({
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
<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">
|
||||
|
||||
@@ -129,7 +129,13 @@ export function RelatedProducts({
|
||||
(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 items-start grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{/* 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}
|
||||
@@ -139,7 +145,7 @@ export function RelatedProducts({
|
||||
wishlistEnabled={wishlistEnabled}
|
||||
wishlistRevealOnHover
|
||||
className={
|
||||
"sm:col-span-4 " +
|
||||
"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
|
||||
|
||||
+102
-84
@@ -32,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"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
<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
|
||||
@@ -83,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"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
<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>
|
||||
|
||||
@@ -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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
}
|
||||
@@ -63,7 +63,7 @@ function getNavLinks(singleActiveProduct: boolean) {
|
||||
// /der-eine and /tasse-die-pause (both bespoke product detail pages, same
|
||||
// "own route, not nested" shape as /todo-cards) added here for the same
|
||||
// reason (2026-08-25).
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/lebensuhr", "/newsletter", "/der-eine", "/tasse-die-pause"];
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/7-tage-klarheits-check", "/newsletter", "/der-eine", "/tasse-die-pause", "/lebensuhr", "/3x3-system"];
|
||||
|
||||
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
|
||||
if (href === "#werkzeuge") {
|
||||
@@ -524,7 +524,7 @@ export function Navbar({
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { PageBlock } from "../lib/payload";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { RichText } from "./RichText";
|
||||
import { Quote } from "./Quote";
|
||||
import { StepArrow } from "./StepArrow";
|
||||
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
|
||||
// Renders a Payload Pages document's `layout` blocks field — structurally
|
||||
// parallel to RichText.tsx's own `blocks: {...}` converter map, but one
|
||||
// level up (full page sections, not inline Lexical nodes). Each block
|
||||
// component reuses the exact same visual treatment already hand-built on
|
||||
// /lebensuhr, /3x3-system, and /7-tage-klarheits-check, rather than
|
||||
// inventing new styling — this is what those pages' repeated patterns get
|
||||
// consolidated into for CMS-driven pages going forward.
|
||||
export async function PageBlocks({ blocks }: { blocks: PageBlock[] }) {
|
||||
const rendered = await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (block.blockType === "testimonialsRef") {
|
||||
const testimonials = await getTestimonials(block.page);
|
||||
if (testimonials.length === 0) return null;
|
||||
return <TestimonialsGrid key={block.id} testimonials={testimonials} />;
|
||||
}
|
||||
return renderPageBlockSync(block);
|
||||
})
|
||||
);
|
||||
return <>{rendered}</>;
|
||||
}
|
||||
|
||||
// Every block EXCEPT testimonialsRef (needs its own async data fetch,
|
||||
// which a client component can't await — see LivePageContent.tsx, which
|
||||
// uses this directly and simply skips that one block type in preview,
|
||||
// same "live-previewable subset" scope-cut LivePostContent.tsx already
|
||||
// makes for its own author-bio/"Weiterlesen" chrome).
|
||||
export function renderPageBlockSync(block: PageBlock): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
// Same "font-playfair clamp headline" treatment every hand-built page
|
||||
// (/lebensuhr, /3x3-system, /ueber-mich) used for its own H1 before
|
||||
// this block existed — see HeroBlock.ts's own comment on why the
|
||||
// headline moved out of Pages.title and into here.
|
||||
case "hero": {
|
||||
const headline = (
|
||||
<p
|
||||
className={
|
||||
block.image
|
||||
? "font-semibold text-[clamp(2rem,1.393rem+1.5vw,2.75rem)] text-text-primary leading-[1.15]"
|
||||
: "font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
}
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{block.headline}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-4 items-start">
|
||||
{/* Optional round portrait beside the headline — e.g. /ueber-mich's
|
||||
"Hallo, ich bin Björn." size-20 (80px) read as barely-there/
|
||||
easy to miss next to a full headline — bumped to size-32
|
||||
(128px) so it actually registers as a real photo, not a
|
||||
small decorative blob. */}
|
||||
{block.image ? (
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative size-32 shrink-0 rounded-full overflow-hidden ring-1 ring-border">
|
||||
<Image alt="" src={block.image} fill sizes="128px" className="object-cover" />
|
||||
</div>
|
||||
{headline}
|
||||
</div>
|
||||
) : (
|
||||
headline
|
||||
)}
|
||||
{/* text-primary + text-h-emphasis, not text-muted body — same
|
||||
weight class as the homepage Hero's own subheading
|
||||
("Ich helfe dir..."), which a plain muted text-body read too
|
||||
quiet next to. */}
|
||||
{block.subline && (
|
||||
<p className="font-semibold text-h-emphasis text-text-primary leading-[1.4]">{block.subline}</p>
|
||||
)}
|
||||
{block.ctaLabel && block.ctaHref && (
|
||||
<Link
|
||||
href={block.ctaHref}
|
||||
className="inline-flex items-center gap-2 bg-brand hover:brightness-95 active:scale-[0.97] transition-all rounded-sm px-6 py-3 font-semibold text-body text-text-primary"
|
||||
>
|
||||
{block.ctaLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "richTextSection":
|
||||
return <RichText key={block.id} content={block.content} />;
|
||||
|
||||
case "quote":
|
||||
return <Quote key={block.id} label={block.label}>{block.text}</Quote>;
|
||||
|
||||
case "icon": {
|
||||
const SymbolIcon = SYMBOLIC_ICONS[block.icon];
|
||||
return SymbolIcon ? <div key={block.id}>{SymbolIcon()}</div> : null;
|
||||
}
|
||||
|
||||
case "image":
|
||||
if (!block.image) return null;
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-[3/2] rounded-xl overflow-hidden bg-bg-muted">
|
||||
<Image alt={block.caption ?? ""} src={block.image} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-contain" />
|
||||
</div>
|
||||
{block.caption && <p className="text-body-sm text-text-muted text-center">{block.caption}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "pillList":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-wrap gap-2">
|
||||
{block.items.map((item) => (
|
||||
<span key={item.id} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">{item.label}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checklistImage":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
|
||||
{block.image && (
|
||||
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
|
||||
<Image alt="" src={block.image} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 w-full flex flex-col gap-4">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.id} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{item.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "table":
|
||||
return (
|
||||
<div key={block.id} className="bg-bg-muted rounded-xl overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-brand">
|
||||
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.labelHeader}</th>
|
||||
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.valueHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={row.id} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
|
||||
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
|
||||
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "stepRow": {
|
||||
const icons = block.items.map((item) => STEP_ICONS[item.icon]);
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{block.items.flatMap((item, i) => [
|
||||
<div key={item.id} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{icons[i]?.()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
|
||||
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
|
||||
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
</div>,
|
||||
i < block.items.length - 1 ? (
|
||||
<div key={`arrow-${item.id}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "testimonialsRef":
|
||||
// Needs an async fetch — handled by PageBlocks (server) above.
|
||||
// Skipped in the client-side live-preview renderer.
|
||||
return null;
|
||||
|
||||
case "ctaCard":
|
||||
return (
|
||||
<Link
|
||||
key={block.id}
|
||||
href={block.href}
|
||||
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">{block.eyebrow}</p>
|
||||
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{block.title}</p>
|
||||
{block.description && <p className="text-body-sm text-text-muted">{block.description}</p>}
|
||||
</div>
|
||||
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
|
||||
default:
|
||||
// Unknown/malformed block — same "skip rather than crash" convention
|
||||
// RichText.tsx's own blocks use.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Same checkmark used by /lebensuhr's and /7-tage-klarheits-check's own
|
||||
// checklists.
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
|
||||
import { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getTestimonials } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { ProductName } from "./ProductName";
|
||||
import { RichText } from "./RichText";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { renderPageBlockSync } from "./PageBlocks";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
|
||||
// Renders a Payload Products document's `layout` blocks field — same
|
||||
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
|
||||
// this delegates to directly for every block type the two fields share
|
||||
// (richTextSection/stepRow/quote/image/icon/pillList/checklistImage/table/
|
||||
// ctaCard are the exact same Block configs, just registered a second time
|
||||
// on Products.layout — see that field's own comment in Products.ts). Only
|
||||
// `productHero` and `productPricingPanel` are product-specific, since they
|
||||
// need the live product/shipping/tax context that a generic content page
|
||||
// doesn't have.
|
||||
export async function ProductBlocks({ product }: { product: Product }) {
|
||||
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const ctx: ProductBlockContext = { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
|
||||
|
||||
const rendered = await Promise.all(
|
||||
product.layout.map(async (block) => {
|
||||
// Needs its own async data fetch — same reason PageBlocks.tsx
|
||||
// handles this one block type in its own outer async map instead of
|
||||
// the sync switch below (renderProductBlock/renderPageBlockSync).
|
||||
if (block.blockType === "testimonialsRef") {
|
||||
const testimonials = await getTestimonials(block.page);
|
||||
if (testimonials.length === 0) return null;
|
||||
return (
|
||||
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-6">
|
||||
<TestimonialsGrid testimonials={testimonials} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// productHero/productPricingPanel render their own full section
|
||||
// (edge-to-edge image, own Reveal, own padding rhythm) — matching
|
||||
// the hand-coded PDPs (todo-cards etc.) precisely needed each of
|
||||
// those, unlike a generic content block, so they opt out of this
|
||||
// shared wrapper entirely rather than fighting it.
|
||||
if (block.blockType === "productHero" || block.blockType === "productPricingPanel") {
|
||||
return <div key={block.id}>{renderProductBlock(block, ctx)}</div>;
|
||||
}
|
||||
return (
|
||||
<Reveal key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12">
|
||||
{renderProductBlock(block, ctx)}
|
||||
</Reveal>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return <>{rendered}</>;
|
||||
}
|
||||
|
||||
type ProductBlockContext = {
|
||||
product: Product;
|
||||
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
|
||||
taxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
};
|
||||
|
||||
function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
case "productHero":
|
||||
return <ProductHero {...ctx} body={block.body} />;
|
||||
case "productPricingPanel":
|
||||
return <ProductPricingPanel {...ctx} />;
|
||||
// Every other block type is identical to Pages.layout's own — same
|
||||
// Block config, reused a second time (see this file's own comment).
|
||||
default:
|
||||
return renderPageBlockSync(block as PageBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// Matches der-eine/todo-cards' own hand-coded Hero.tsx exactly — same
|
||||
// section/grid/Reveal/padding structure, same edge-to-edge image with
|
||||
// hover-scale — so a block-driven PDP hero doesn't visually stand apart
|
||||
// from its hand-coded siblings. Only the body prose differs: it's this
|
||||
// block's own `body` richText instead of hardcoded JSX.
|
||||
function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: ProductBlockContext & { body: unknown }) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">
|
||||
<ProductName name={product.name} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<p className="font-semibold text-h-page text-text-primary" style={{ fontFamily: "var(--font-playfair)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.subline && (
|
||||
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.subline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{body ? <RichText content={body} /> : null}
|
||||
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
</div>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
</div>
|
||||
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
|
||||
delay={0.15}
|
||||
>
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Matches todo-cards' own hand-coded Pricing.tsx exactly — see that
|
||||
// file's own comment on why this closing panel looks the way it does.
|
||||
function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: ProductBlockContext) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
|
||||
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
|
||||
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 410px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
) : (
|
||||
discount !== null && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
|
||||
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.subline && <p className="text-body-sm text-text-primary">{product.subline}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
import { AddToCartInlineButton } from "./AddToCartInlineButton";
|
||||
import { WishlistButton } from "./WishlistButton";
|
||||
import { ProductName } from "./ProductName";
|
||||
import type { Product } from "../lib/payload";
|
||||
|
||||
// Single shared card markup for every product grid (ProductGrid,
|
||||
@@ -53,8 +54,20 @@ export function ProductCard({
|
||||
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 flex flex-col h-full transition-transform duration-300 hover:-translate-y-1 ${className}`}
|
||||
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
|
||||
@@ -97,49 +110,46 @@ export function ProductCard({
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<div className="flex flex-col gap-1 items-start w-full">
|
||||
{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)" }}
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{belowPrice}
|
||||
{/* Always rendered, text conditional — reserved height keeps every
|
||||
card in a row equal height regardless of low-stock status. An
|
||||
earlier version omitted this for fully-out-of-stock products
|
||||
(this line can never apply there), on the theory that it was
|
||||
dead space — but NotifyMeForm's single-row redesign (see its
|
||||
own comment) now matches AddToCartInlineButton's button height
|
||||
exactly, which made THIS line the only remaining source of a
|
||||
mismatch: omitting it made the sold-out card shorter than its
|
||||
siblings instead of taller. Keeping it unconditional is what
|
||||
actually gets an exact match now. */}
|
||||
<p className="min-h-[1.05rem] text-label font-bold text-warning">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
|
||||
|
||||
{/* flex-1 spacer — pins every card's button to the same Y
|
||||
regardless of whether the title/category line wraps. */}
|
||||
<div className="flex-1" />
|
||||
<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}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
@@ -7,6 +8,53 @@ import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, get
|
||||
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),
|
||||
@@ -86,14 +134,7 @@ export async function ProductSpotlight() {
|
||||
>
|
||||
{product.spotlightHeadline || product.name}
|
||||
</p>
|
||||
{/* whitespace-pre-line — spotlightText is a plain textarea field,
|
||||
and admin-entered paragraph breaks (e.g. the "Die Sieben"
|
||||
spotlight copy) should render as actual line breaks instead of
|
||||
collapsing into one run-on line like default HTML whitespace
|
||||
handling would. */}
|
||||
<p className="text-body text-text-body whitespace-pre-line">
|
||||
{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). */}
|
||||
|
||||
@@ -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.
|
||||
@@ -250,6 +270,121 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
</Quote>
|
||||
);
|
||||
},
|
||||
// Same page-builder blocks as PageBlocks.tsx (Pages.layout) — see
|
||||
// that file's own comment on each block's visual treatment, mirrored
|
||||
// here field-for-field since it's the exact same Block config
|
||||
// registered a second time (Posts.content's BlocksFeature) so blog
|
||||
// posts can use them mid-article too. `testimonialsRef` is the one
|
||||
// page-builder block deliberately NOT included: it needs an async
|
||||
// data fetch, and this converter set is shared with LiveRichText's
|
||||
// client-side live-preview rendering (see blog's LivePostContent.tsx),
|
||||
// where an async component would break — PageBlocks.tsx only gets
|
||||
// away with it by awaiting outside the sync per-block switch, in its
|
||||
// own async server component.
|
||||
icon: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as IconBlockFields;
|
||||
const SymbolIcon = SYMBOLIC_ICONS[fields.icon];
|
||||
return SymbolIcon ? <div>{SymbolIcon()}</div> : null;
|
||||
},
|
||||
pillList: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as PillListBlockFields;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fields.items.map((item, i) => (
|
||||
<span key={item.id ?? i} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
checklistImage: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ChecklistImageBlockFields;
|
||||
const url = mediaUrl(fields.image);
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
|
||||
{url && (
|
||||
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
|
||||
<Image alt="" src={url} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 w-full flex flex-col gap-4">
|
||||
{fields.items.map((item, i) => (
|
||||
<li key={item.id ?? i} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{item.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
table: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as TableBlockFields;
|
||||
return (
|
||||
<div className="bg-bg-muted rounded-xl overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-brand">
|
||||
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.labelHeader}</th>
|
||||
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.valueHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.rows.map((row, i) => (
|
||||
<tr key={row.id ?? i} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
|
||||
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
|
||||
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
stepRow: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as StepRowBlockFields;
|
||||
const icons = fields.items.map((item) => STEP_ICONS[item.icon]);
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{fields.items.flatMap((item, i) => [
|
||||
<div key={item.id ?? i} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{icons[i]?.()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
|
||||
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
|
||||
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
</div>,
|
||||
i < fields.items.length - 1 ? (
|
||||
<div key={`arrow-${item.id ?? i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
ctaCard: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as CtaCardBlockFields;
|
||||
return (
|
||||
<Link
|
||||
href={fields.href}
|
||||
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">{fields.eyebrow}</p>
|
||||
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{fields.title}</p>
|
||||
{fields.description && <p className="text-body-sm text-text-muted">{fields.description}</p>}
|
||||
</div>
|
||||
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Shared registry of the site's hand-drawn brand-line icons, used by
|
||||
// StepArrow-connected icon rows (/lebensuhr's phase row, /3x3-system's
|
||||
// category row, /7-tage-klarheits-check's "So funktioniert"). Previously
|
||||
// each page defined its own copy of these locally — consolidated here so
|
||||
// the Payload page-builder's `stepRow` block can select one by a fixed
|
||||
// key (see PageBlocks.tsx), and so the icons aren't duplicated per page
|
||||
// anymore. The Payload StepRowBlock's `icon` select options MUST stay in
|
||||
// sync with STEP_ICON_KEYS below by hand — no automatic sync between the
|
||||
// two repos.
|
||||
|
||||
function IconSunrise() {
|
||||
return (
|
||||
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
|
||||
<line x1="4" y1="30" x2="40" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M13 30a9 9 0 0 1 18 0" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="22" y1="4" x2="22" y2="10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="10" x2="13" y2="14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="35" y1="10" x2="31" y2="14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconGrowthBars() {
|
||||
return (
|
||||
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
|
||||
<line x1="4" y1="36" x2="40" y2="36" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<rect x="7" y="25" width="7" height="11" rx="1" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="18.5" y="16" width="7" height="20" rx="1" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="30" y="6" width="7" height="30" rx="1" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSunHigh() {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="8" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="20" y1="2" x2="20" y2="7" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="20" y1="33" x2="20" y2="38" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="20" x2="7" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="33" y1="20" x2="38" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="7" y1="7" x2="10.5" y2="10.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="29.5" y1="29.5" x2="33" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="33" y1="7" x2="29.5" y2="10.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="10.5" y1="29.5" x2="7" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEnvelope() {
|
||||
return (
|
||||
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
|
||||
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M2 2l24 22L50 2" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMailLines() {
|
||||
return (
|
||||
<svg width="56" height="44" viewBox="0 0 56 44" fill="none">
|
||||
<line x1="2" y1="12" x2="12" y2="12" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="20" x2="9" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="28" x2="7" y2="28" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<rect x="13" y="2" width="41" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M13 2l20.5 19L54 2" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconNotepad() {
|
||||
return (
|
||||
<svg width="46" height="52" viewBox="0 0 46 52" fill="none">
|
||||
<rect x="2" y="4" width="32" height="42" rx="2" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="9" y1="16" x2="27" y2="16" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="23" x2="24" y2="23" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="30" x2="20" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="37" cy="40" r="7" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="37" y1="27" x2="37" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCheckCircle() {
|
||||
return (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M14 24l7.5 7.5L34 16" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTarget() {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="16" stroke="#f6a701" strokeWidth="2" />
|
||||
<circle cx="20" cy="20" r="9" stroke="#f6a701" strokeWidth="2" />
|
||||
<circle cx="20" cy="20" r="2" fill="#f6a701" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTrendingUp() {
|
||||
return (
|
||||
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
|
||||
<path d="M4 30l10-10 6 6 14-14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M24 10h10v10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconShield() {
|
||||
return (
|
||||
<svg width="36" height="40" viewBox="0 0 36 40" fill="none">
|
||||
<path d="M18 3l14 5v9c0 9-6 16-14 20-8-4-14-11-14-20v-9l14-5z" stroke="#f6a701" strokeWidth="2" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Keys MUST match the Payload StepRowBlock's `icon` select field options
|
||||
// (docker/payload/src/blocks/StepRowBlock.ts) exactly.
|
||||
export const STEP_ICONS: Record<string, () => React.ReactNode> = {
|
||||
sunrise: IconSunrise,
|
||||
growthBars: IconGrowthBars,
|
||||
sunHigh: IconSunHigh,
|
||||
envelope: IconEnvelope,
|
||||
mailLines: IconMailLines,
|
||||
notepad: IconNotepad,
|
||||
checkCircle: IconCheckCircle,
|
||||
target: IconTarget,
|
||||
trendingUp: IconTrendingUp,
|
||||
shield: IconShield,
|
||||
};
|
||||
|
||||
// Same symbolic clock icon /lebensuhr uses above its "24 Stunden" heading
|
||||
// — purely decorative (no numbers/data), unlike the STEP_ICONS above which
|
||||
// always sit inside an icon-above-text row. Keys MUST match the Payload
|
||||
// IconBlock's `icon` select field options (docker/payload/src/blocks/
|
||||
// IconBlock.ts) exactly.
|
||||
function IconClockSymbol() {
|
||||
return (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
|
||||
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const x1 = 24 + 16 * Math.sin(rad), y1 = 24 - 16 * Math.cos(rad);
|
||||
const x2 = 24 + 18.5 * Math.sin(rad), y2 = 24 - 18.5 * Math.cos(rad);
|
||||
return <line key={deg} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#f6a701" strokeWidth="1.5" strokeLinecap="round" />;
|
||||
})}
|
||||
<line x1="24" y1="24" x2="17" y2="15" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="24" y1="24" x2="32" y2="15" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="24" cy="24" r="1.8" fill="#f6a701" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const SYMBOLIC_ICONS: Record<string, () => React.ReactNode> = {
|
||||
clockSymbol: IconClockSymbol,
|
||||
};
|
||||
@@ -1,62 +1,97 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug } from "../../lib/payload";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Distinct from Hero.tsx's own (shorter) feature bullets — this is the
|
||||
// "Was kann er?" section's full four-item breakdown (build, engraving,
|
||||
// size, use case), each with its own one-line explanation rather than a
|
||||
// bare phrase.
|
||||
// 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 = [
|
||||
{ title: "Metallgehäuse", desc: "Robust, wertig und angenehm in der Hand." },
|
||||
{ title: "Lasergravur", desc: "„Der Eine.“ auf der einen Seite. „einfach produktiv.“ auf der anderen." },
|
||||
{ title: "Kompakt", desc: "Passt ans Notizbuch, auf den Schreibtisch oder in die Tasche." },
|
||||
{ title: "Für jeden Tag", desc: "Für Notizen, Gedanken, Listen und alles, was du lieber aufschreibst, bevor du es wieder vergisst." },
|
||||
{ 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." },
|
||||
];
|
||||
|
||||
// Unlike todo-cards/components/Focus.tsx, this product has no dedicated
|
||||
// lifestyle photo — only 3 supplier catalog macro shots exist, all
|
||||
// 338×338px (no larger source, confirmed against the Payload media API).
|
||||
// Uses gallery[1] (the chrome-rings close-up) — deliberately a different
|
||||
// frame than Hero's gallery (which opens on `image`, id 85) and Pricing's
|
||||
// own photo (product.image again, but cropped/treated differently there),
|
||||
// so the same handful of source photos don't read as one repeated image
|
||||
// across the page. Contained square tile, not object-cover, for the same
|
||||
// "don't blow up a 338px source" reasoning as Hero's gallery cap.
|
||||
export async function Focus() {
|
||||
const product = await getProductBySlug("stift-kugelschreiber");
|
||||
if (!product) return null;
|
||||
const photo = product.gallery[1] ?? product.image;
|
||||
|
||||
export function Focus() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="group relative w-full max-w-[20rem] lg:shrink-0 aspect-square rounded-md overflow-hidden bg-bg-muted p-10">
|
||||
<Image
|
||||
src={photo}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="320px"
|
||||
className="object-contain transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal className="flex flex-col gap-6 items-start flex-1 min-w-0 w-full" delay={0.1}>
|
||||
<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-body">
|
||||
<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>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full">
|
||||
{items.map((item) => (
|
||||
<li key={item.title} className="flex flex-col gap-1 items-start">
|
||||
<span className="font-semibold text-body text-text-primary">{item.title}</span>
|
||||
<span className="text-body-sm text-text-muted">{item.desc}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -52,7 +53,7 @@ export async function Hero() {
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Der Eine</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">
|
||||
@@ -63,12 +64,14 @@ export async function Hero() {
|
||||
>
|
||||
Der Eine<span className="text-brand">.</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Für die eine Sache, die gerade zählt.
|
||||
</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">
|
||||
@@ -129,7 +132,7 @@ export async function Hero() {
|
||||
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" delay={0.15}>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKle
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
|
||||
// Price/photo come from Payload (same "stift-kugelschreiber" product the
|
||||
// shop/cart use), not a hardcoded literal — same reasoning as
|
||||
// todo-cards/components/Pricing.tsx. Uses product.image (id 85, the
|
||||
// confident macro tip shot) — a third, distinct frame from Hero's gallery
|
||||
// opener and Focus's gallery[1] chrome-rings shot.
|
||||
// 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"),
|
||||
@@ -27,14 +27,8 @@ export async function Pricing() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
|
||||
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
|
||||
<div className="group relative w-full lg:w-[15.625rem] lg:shrink-0 aspect-square rounded-sm overflow-hidden bg-bg-base">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt="Der Eine."
|
||||
fill
|
||||
sizes="(min-width: 1024px) 250px, 100vw"
|
||||
className="object-contain p-4 transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<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
|
||||
@@ -53,9 +47,11 @@ export async function Pricing() {
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Der Eine.
|
||||
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>
|
||||
<p className="text-body-sm text-text-primary">Metall. Kugelschreiber. Fertig.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
|
||||
@@ -81,7 +77,7 @@ export async function Pricing() {
|
||||
</div>
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
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}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
// Plays the same structural role as todo-cards/components/HowItWorks.tsx (a
|
||||
// content section between Hero and the material/Pricing sections, matching
|
||||
// the site's established product-page rhythm) — but this is the brand-story
|
||||
// section explaining the product's name, so flowing prose fits better than
|
||||
// this file's former 2×2 scenario grid (replaced 2026-08-25 alongside the
|
||||
// Der Alltagsstift → Der Eine. rename).
|
||||
export function Why() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-6 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-5 items-start text-left max-w-2xl w-full">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Warum „Der Eine.“?
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Weil du nicht alles gleichzeitig machen kannst.
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Aufschreiben hilft dabei, aus all den Dingen im Kopf das herauszuholen, was gerade wichtig ist. Und manchmal ist das eben nur: die eine Sache.
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Daher steht auf der einen Seite des Stifts <strong className="font-semibold text-text-primary">Der Eine.</strong> und auf der anderen <strong className="font-semibold text-text-primary">einfach produktiv.</strong>
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Mehr steckt eigentlich nicht dahinter. Und vielleicht ist genau das der Punkt.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+19
-18
@@ -1,32 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Hero } from "./components/Hero";
|
||||
import { Why } from "./components/Why";
|
||||
import { Focus } from "./components/Focus";
|
||||
import { PasstDazu } from "./components/PasstDazu";
|
||||
import { Testimonials } from "./components/Testimonials";
|
||||
import { Pricing } from "./components/Pricing";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "Der Eine. – Für die eine Sache, die gerade zählt.";
|
||||
const description =
|
||||
"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.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/der-eine",
|
||||
},
|
||||
openGraph: {
|
||||
// Description reads from the live product instead of a separately
|
||||
// hardcoded string, unlike this page's own `title` — matches
|
||||
// /tasse-die-pause's own pattern (see that page's comment). Two
|
||||
// hand-maintained copies of the same text used to drift apart: this one
|
||||
// used to say something different from Products.description (which feeds
|
||||
// the cart/checkout/JSON-LD elsewhere) — same field, one source now.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const product = await getProductBySlug("stift-kugelschreiber");
|
||||
const description = product?.descriptionText || undefined;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
url: "/der-eine",
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
alternates: { canonical: "/der-eine" },
|
||||
openGraph: { title, description, url: "/der-eine" },
|
||||
twitter: { title, description },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DerEinePage() {
|
||||
const [product, seller] = await Promise.all([
|
||||
@@ -45,8 +45,9 @@ export default async function DerEinePage() {
|
||||
<main className="flex flex-col flex-1">
|
||||
<Hero />
|
||||
<Focus />
|
||||
<Why />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
<PasstDazu productSlug="stift-kugelschreiber" />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function DieSiebenHero() {
|
||||
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
|
||||
Das Monatsritual von einfach produktiv.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 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.";
|
||||
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -39,15 +39,18 @@ export function MerklisteGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<RevealGroup className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
|
||||
// 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}`}>
|
||||
<RevealItem key={`${product.id}-${item.variant}`} className="grid [grid-template-rows:subgrid] [grid-row:span_6]">
|
||||
<ProductCard
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
wishlistEnabled
|
||||
className="h-full"
|
||||
// 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
|
||||
|
||||
@@ -6,7 +6,11 @@ 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: "",
|
||||
@@ -27,6 +31,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
categories: [],
|
||||
relatedProduct: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
+271
-19
@@ -181,7 +181,19 @@ 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;
|
||||
@@ -203,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,
|
||||
@@ -245,13 +263,28 @@ export type Product = {
|
||||
// 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[];
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -262,7 +295,7 @@ type PayloadProduct = {
|
||||
spotlight: boolean;
|
||||
spotlightEyebrow: string | null;
|
||||
spotlightHeadline: string | null;
|
||||
spotlightText: string | null;
|
||||
spotlightText: unknown | null;
|
||||
spotlightImage: { url: string } | number | null;
|
||||
spotlightShowWishlist: boolean;
|
||||
trackInventory: boolean;
|
||||
@@ -282,6 +315,7 @@ type PayloadProduct = {
|
||||
}[]
|
||||
| null;
|
||||
categories: ({ name: string } | number)[] | null;
|
||||
relatedProduct: PayloadProduct | number | null;
|
||||
};
|
||||
|
||||
// A product/variant is only actually unbuyable when it opted into
|
||||
@@ -307,16 +341,36 @@ 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 : "",
|
||||
@@ -348,6 +402,11 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -800,7 +859,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 };
|
||||
|
||||
@@ -825,10 +884,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,
|
||||
@@ -1192,20 +1251,27 @@ export async function getSeoSettings(): Promise<SeoSettings> {
|
||||
};
|
||||
}
|
||||
|
||||
// Powers app/r/[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.
|
||||
// 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 };
|
||||
|
||||
// PATCH failure only logs — click tracking is informational, never worth
|
||||
// stranding a visitor on a broken link over.
|
||||
export async function resolveAndTrackRedirect(code: string): Promise<string | null> {
|
||||
// `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",
|
||||
});
|
||||
@@ -1266,3 +1332,189 @@ export async function getTrackingCodes(): Promise<TrackingCode[]> {
|
||||
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 };
|
||||
|
||||
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 };
|
||||
|
||||
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 })),
|
||||
};
|
||||
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 };
|
||||
|
||||
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 };
|
||||
|
||||
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,
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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." },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { resolveAndTrackRedirect } from "../../lib/payload";
|
||||
// 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);
|
||||
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.
|
||||
|
||||
@@ -114,12 +114,23 @@ export async function ProductGrid({
|
||||
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 items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full"
|
||||
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"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<RevealItem key={product.id} className="lg:col-span-4">
|
||||
<RevealItem key={product.id} className="lg:col-span-4 grid [grid-template-rows:subgrid] [grid-row:span_6]">
|
||||
<ProductCard
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
@@ -131,7 +142,6 @@ export async function ProductGrid({
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
}
|
||||
className="h-full"
|
||||
/>
|
||||
</RevealItem>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { NextRequest } from "next/server";
|
||||
import { resolveAndTrackRedirect } from "../../lib/payload";
|
||||
|
||||
// Legacy sticker QR codes already printed as https://einfach-produktiv.com/sticker/<code>
|
||||
// before the /r/[code] short-link scheme existed — can't be reprinted, so
|
||||
// this mirrors app/r/[code]/route.ts's exact lookup (same Redirects
|
||||
// collection, matched by `code` + urlPrefix "/sticker") under the fixed
|
||||
// /sticker prefix instead. Keeps a single admin-editable/toggleable source
|
||||
// of truth for both.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ code: string }> }) {
|
||||
const { code } = await params;
|
||||
const targetPath = await resolveAndTrackRedirect(code, "/sticker");
|
||||
|
||||
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
redirect(targetPath);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
|
||||
// Placeholder landing page for the /sticker/[code] QR redirects (see that
|
||||
// route) — targetPath for those Redirects docs currently points here until
|
||||
// a dedicated campaign page exists; swap targetPath in Payload once it does.
|
||||
export const metadata: Metadata = {
|
||||
title: "Schön, dass du da bist",
|
||||
description: "Du hast einen einfach produktiv Sticker gescannt.",
|
||||
};
|
||||
|
||||
export default function StickerPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-4 items-center text-center pt-24 pb-16 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="flex items-center justify-center size-14 rounded-full bg-brand/10 text-brand shrink-0">
|
||||
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
|
||||
<path d="M9 3.5h6a1.5 1.5 0 0 1 1.5 1.5v14.5L12 16l-4.5 3.5V5A1.5 1.5 0 0 1 9 3.5Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
Schön, dass du da bist!
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Du hast einen unserer Sticker entdeckt.
|
||||
</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">
|
||||
<p>Hallo,</p>
|
||||
<p>
|
||||
schön, dass dich der Sticker hierher geführt hat. Schau dich gerne um – hier
|
||||
findest du alles, was einfach produktiv ausmacht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/shop"
|
||||
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>
|
||||
</Reveal>
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { ProductName } from "../../components/ProductName";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
@@ -39,18 +41,25 @@ export async function Hero() {
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">{product?.name ?? "Tasse"}</span>
|
||||
<span className="text-text-primary">{product ? <ProductName name={product.name} /> : "Tasse"}</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
|
||||
<p
|
||||
className="font-semibold text-h-page text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{product?.name ?? "Tasse"}
|
||||
</p>
|
||||
<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)" }}
|
||||
>
|
||||
{product ? <ProductName name={product.name} /> : "Tasse"}
|
||||
</p>
|
||||
{product?.subline && (
|
||||
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.subline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product?.description && <p className="text-body text-text-body">{product.description}</p>}
|
||||
{product?.description ? <RichText content={product.description} /> : null}
|
||||
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
{product && (
|
||||
@@ -79,6 +88,7 @@ export async function Hero() {
|
||||
{product && (
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
productId={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { ProductName } from "../../components/ProductName";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
@@ -46,9 +48,10 @@ export async function Pricing() {
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.description && <p className="text-body-sm text-text-primary">{product.description}</p>}
|
||||
{product.subline && <p className="text-body-sm font-semibold text-text-primary">{product.subline}</p>}
|
||||
{product.description ? <RichText content={product.description} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
|
||||
@@ -76,6 +79,7 @@ export async function Pricing() {
|
||||
<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={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
|
||||
@@ -10,8 +10,8 @@ import { buildProductSchema } from "../lib/structuredData";
|
||||
// tagline, unlike every other bespoke page here — see Hero.tsx's comment.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const product = await getProductBySlug("tasse-die-pause");
|
||||
const title = product ? `${product.name} – einfach produktiv` : "Tasse – einfach produktiv";
|
||||
const description = product?.description ?? undefined;
|
||||
const title = product ? `${product.name} – einfach produktiv.` : "Tasse – einfach produktiv.";
|
||||
const description = product?.descriptionText || undefined;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -3,6 +3,7 @@ import Image from "next/image";
|
||||
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";
|
||||
@@ -77,7 +78,7 @@ export async function TodoKartenHero() {
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">ToDo-Karten</span>
|
||||
<span className="text-text-primary">{product ? <ProductName name={product.name} /> : "ToDo-Karten"}</span>
|
||||
</p>
|
||||
|
||||
{/* Everything else — centered in the remaining vertical space at
|
||||
@@ -97,12 +98,14 @@ export async function TodoKartenHero() {
|
||||
>
|
||||
ToDo-Karten<span className="text-brand">.</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Kleine Karten. Große Wirkung.
|
||||
</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">
|
||||
|
||||
@@ -111,7 +111,7 @@ export function VersandSections({
|
||||
<Section id="transportrisiko" title="Transportrisiko" withAnchor={withAnchors}>
|
||||
<p>
|
||||
Bestellst du als Verbraucher:in, trägt das Transportrisiko bei Beschädigung oder Verlust
|
||||
während des Versands grundsätzlich einfach produktiv — nicht du.
|
||||
während des Versands grundsätzlich einfach produktiv. — nicht du.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1010 KiB |
Reference in New Issue
Block a user