Compare commits
72 Commits
988f259371
..
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 | |||
| eb160cac61 | |||
| 13fb37eb5e |
@@ -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" },
|
||||
];
|
||||
}
|
||||
@@ -60,7 +60,10 @@ function getNavLinks(singleActiveProduct: boolean) {
|
||||
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
|
||||
// page-weekly-impulses's own breadcrumb ("Startseite › Werkzeuge ›
|
||||
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/lebensuhr", "/newsletter"];
|
||||
// /der-eine and /tasse-die-pause (both bespoke product detail pages, same
|
||||
// "own route, not nested" shape as /todo-cards) added here for the same
|
||||
// reason (2026-08-25).
|
||||
const WERKZEUGE_ROUTES = ["/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") {
|
||||
@@ -521,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,9 +134,7 @@ export async function ProductSpotlight() {
|
||||
>
|
||||
{product.spotlightHeadline || product.name}
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
{product.spotlightText || product.description}
|
||||
</p>
|
||||
<SpotlightText content={product.spotlightText} fallback={product.descriptionText} />
|
||||
{/* MwSt./Versand disclosure on its own line, not crammed into
|
||||
the price row itself — same reasoning as todo-cards'
|
||||
Pricing.tsx (identical text, same narrow-column risk). */}
|
||||
|
||||
@@ -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,75 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug } from "../../lib/payload";
|
||||
|
||||
// Only visually-verifiable claims (Metallkorpus/Chromringe from the
|
||||
// product photos) — no engraving/color claim, both still unconfirmed
|
||||
// (see plan notes: Farbvariante/Gravur-Merkmal open as of 2026-08-24).
|
||||
const bullets = [
|
||||
"Korpus aus Metall, kein Plastik",
|
||||
"Liegt gut in der Hand",
|
||||
"Passt in jede Tasche",
|
||||
];
|
||||
|
||||
// Same fix/reasoning as Hero.tsx's own IconCheck.
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Unlike todo-cards/components/Focus.tsx, this product has no dedicated
|
||||
// lifestyle photo yet — only 3 supplier catalog macro shots exist, all
|
||||
// 338×338px (no larger source, confirmed against the Payload media API).
|
||||
// Rather than object-cover-stretching a small square into a wide rectangle
|
||||
// (blurs badly, and is exactly what Pricing.tsx's photo slot already does —
|
||||
// see plan notes on the "einheitlich" feedback), this uses a contained,
|
||||
// padded square tile so the source resolution reads as a deliberate macro
|
||||
// crop instead of a blown-up thumbnail. Uses gallery[1] (the chrome-rings
|
||||
// close-up) — deliberately not gallery[0], which shows an assortment
|
||||
// including a black pen and would imply a color variant that isn't
|
||||
// confirmed yet (see Focus's bullets comment).
|
||||
export async function Focus() {
|
||||
const product = await getProductBySlug("stift-kugelschreiber");
|
||||
if (!product) return null;
|
||||
const photo = product.gallery[1] ?? product.image;
|
||||
|
||||
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)]">
|
||||
{/* Fixed max-width, not a viewport-percentage width — same reasoning
|
||||
as Hero.tsx's gallery cap: the source is only 338×338px, and a
|
||||
percentage width grows past that on wide screens, exaggerating
|
||||
the upscale blur. */}
|
||||
<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}>
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Ein Stift, der bleibt
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Die meisten Kugelschreiber landen irgendwann in einer Schublade und werden nicht wieder rausgeholt. Der Alltagsstift soll man behalten.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{bullets.map((b) => (
|
||||
<li key={b} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 font-semibold text-body text-text-primary">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Fragment } from "react";
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { StepArrow } from "../../components/StepArrow";
|
||||
|
||||
// Same 3 generic step icons as todo-cards/components/HowItWorks.tsx — their
|
||||
// artwork (pen tip, checklist, arrow) reads generically enough to carry a
|
||||
// different 3-step flow without looking mismatched.
|
||||
const steps = [
|
||||
{
|
||||
icon: "/icon-step-1.png",
|
||||
width: 165,
|
||||
height: 177,
|
||||
title: "1. Griffbereit halten",
|
||||
desc: "Der Alltagsstift liegt dort, wo du ihn brauchst – Tasche, Schreibtisch, Notizbuch.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-step-2.png",
|
||||
width: 180,
|
||||
height: 168,
|
||||
title: "2. Sofort notieren",
|
||||
desc: "Du musst nicht erst dein Handy entsperren oder eine App öffnen.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-step-3.png",
|
||||
width: 180,
|
||||
height: 177,
|
||||
title: "3. Weitermachen",
|
||||
desc: "Notiert ist notiert, du machst einfach da weiter, wo du warst.",
|
||||
},
|
||||
];
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
So einfach ist er im Alltag
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Du nimmst ihn, schreibst, steckst ihn wieder ein. Mehr braucht es nicht.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[16.25rem]">
|
||||
{steps.map((step, i) => (
|
||||
<Fragment key={step.title}>
|
||||
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
|
||||
<Image
|
||||
src={step.icon}
|
||||
alt=""
|
||||
width={step.width}
|
||||
height={step.height}
|
||||
className="h-16 w-auto object-contain transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<p className="font-semibold text-body text-text-primary">{step.title}</p>
|
||||
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
|
||||
</RevealItem>
|
||||
{i < steps.length - 1 && (
|
||||
<div className="flex items-center justify-center shrink-0 sm:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Hero } from "./components/Hero";
|
||||
import { HowItWorks } from "./components/HowItWorks";
|
||||
import { Focus } from "./components/Focus";
|
||||
import { Pricing } from "./components/Pricing";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "Der Alltagsstift – Ein Stift, der einfach da ist, wenn du ihn brauchst.";
|
||||
const description =
|
||||
"Der Kugelschreiber für die eine Sache, die heute zählt. Schlicht, hochwertig und immer griffbereit.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/der-alltagsstift",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/der-alltagsstift",
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function DerAlltagsstiftPage() {
|
||||
const [product, seller] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
const productSchema = product
|
||||
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-alltagsstift", seller)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{productSchema && (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
)}
|
||||
<main className="flex flex-col flex-1">
|
||||
<Hero />
|
||||
<HowItWorks />
|
||||
<Focus />
|
||||
<Pricing />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
|
||||
// No circle badge, thicker uniform-weight black stroke, no fill — matching
|
||||
// the site's established illustrative icon language (public/icon-trust-*.png,
|
||||
// public/icon-step-*.png): bold outline glyphs standing on their own, not
|
||||
// boxed into a badge shape. Replaces the earlier thin-stroke circled
|
||||
// version, which didn't match anything else on the site.
|
||||
function IconBadge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" stroke="#231f20" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="h-16 w-auto shrink-0">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMetal() {
|
||||
// Pen/nib glyph — stands in for the metal body itself.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M10 22l2.5-0.8L23 10.7l-2.7-2.7L9.8 19.5 9 22z" />
|
||||
<path d="M19 9l3 3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSparkle() {
|
||||
// Engraving/laser mark — four-point sparkle, same glyph as before, just
|
||||
// heavier stroke and unboxed to match the rest of the set.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M16 7v5M16 20v5M7 16h5M20 16h5M10.5 10.5l3 3M18.5 18.5l3 3M21.5 10.5l-3 3M13.5 18.5l-3 3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCompact() {
|
||||
// Four corner brackets pointing inward — standard "minimize/fits
|
||||
// anywhere" glyph, reads as compact more directly than a shield ever did.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M11 8H8v3M21 8h3v3M11 24H8v-3M21 24h3v-3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEveryday() {
|
||||
// Sun — everyday/daily use.
|
||||
return (
|
||||
<IconBadge>
|
||||
<circle cx="16" cy="16" r="6" />
|
||||
<path d="M16 4v3M16 25v3M4 16h3M25 16h3M7.5 7.5l2 2M22.5 22.5l2 2M24.5 7.5l-2 2M9.5 22.5l-2 2" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
// "Was kann er?" section's full four-item breakdown (build, engraving,
|
||||
// size, use case). Same card shape as todo-cards/components/HowItWorks.tsx
|
||||
// — icon on top, centered, RevealGroup/RevealItem stagger, identical
|
||||
// title/description typography — reused for visual consistency with the
|
||||
// rest of the site rather than a one-off layout. 4 cards instead of
|
||||
// HowItWorks' 3 numbered steps, no connecting arrows (independent facts,
|
||||
// not a sequence).
|
||||
const items = [
|
||||
{ icon: IconMetal, title: "Metallgehäuse", desc: "Robust, wertig und angenehm in der Hand." },
|
||||
{ icon: IconSparkle, title: "Lasergravur", desc: "„Der Eine.“ auf der einen Seite. „einfach produktiv.“ auf der anderen." },
|
||||
{ icon: IconCompact, title: "Kompakt", desc: "Passt ans Notizbuch, auf den Schreibtisch oder in die Tasche." },
|
||||
{ icon: IconEveryday, title: "Für jeden Tag", desc: "Für Notizen, Gedanken, Listen und alles, was du lieber aufschreibst, bevor du es wieder vergisst." },
|
||||
];
|
||||
|
||||
export function Focus() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center mb-10">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Was kann er?
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-[42rem] w-full text-left md:text-center">
|
||||
Schreiben. Und das ziemlich gut. Metallgehäuse, angenehmes Gewicht und eine klassische Kugelschreibermine — kein besonderes Produktivitäts-Gadget, sondern einfach ein guter Stift, den du gern zur Hand nimmst.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-start sm:items-start w-full max-w-[75rem] mx-auto">
|
||||
{items.map(({ icon: Icon, title, desc }) => (
|
||||
<RevealItem key={title} className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none mx-auto">
|
||||
<Icon />
|
||||
<p className="font-semibold text-body text-text-primary">{title}</p>
|
||||
<p className="text-body-sm text-text-primary w-full text-left md:text-center">{desc}</p>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,17 @@ 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";
|
||||
|
||||
const checklist = [
|
||||
"Funktioniert ohne Strom oder Internet",
|
||||
"Aus Metall, kein Einwegstift",
|
||||
"Passt in jede Tasche",
|
||||
];
|
||||
// The one bullet list on this page — after feedback that three separate
|
||||
// lists across Hero/Focus/Pricing was too much, this is the single place
|
||||
// features get enumerated, everywhere else stays prose. A subset of the
|
||||
// "Was kann er?" section's four cards (Focus.tsx) — "Für jeden Tag" is a
|
||||
// use case, not a build fact, so it's covered by the body copy instead.
|
||||
const features = ["Massives Metallgehäuse", "Lasergravur: „Der Eine.“ & „einfach produktiv.“", "Kompakt genug für jede Tasche"];
|
||||
|
||||
// Same fix/reasoning as TodoKartenHero.tsx's IconCheck — icon-check.svg's
|
||||
// fill can't be recolored from outside the SVG when loaded via <img
|
||||
@@ -51,7 +53,7 @@ export async function Hero() {
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Der Alltagsstift</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">
|
||||
@@ -60,22 +62,24 @@ export async function Hero() {
|
||||
className="font-semibold text-h-page text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
Der Alltagsstift<span className="text-brand">.</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Ein Stift, der einfach da ist, wenn du ihn brauchst.
|
||||
Der Eine<span className="text-brand">.</span>
|
||||
</p>
|
||||
{product?.subline && (
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.subline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-body text-text-body">
|
||||
Der Kugelschreiber für die eine Sache, die heute zählt. Schlicht, hochwertig und immer griffbereit.
|
||||
Manchmal reicht ein Stift und ein Stück Papier. Der Eine. ist ein schlichter Kugelschreiber aus Metall — angenehm in der Hand, klein genug für die Tasche und gemacht für alles, was kurz raus aus dem Kopf und irgendwo hin muss.
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{checklist.map((item) => (
|
||||
{features.map((item) => (
|
||||
<li key={item} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 text-body text-text-primary">{item}</span>
|
||||
@@ -109,7 +113,8 @@ export async function Hero() {
|
||||
|
||||
{product && (
|
||||
<AddToCartButton
|
||||
label="Alltagsstift bestellen"
|
||||
label="Der Eine. bestellen"
|
||||
productId={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
@@ -127,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,17 +5,11 @@ import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKle
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
|
||||
// Only visually-verifiable claims — same reasoning as Focus.tsx's bullets
|
||||
// (no engraving/color claim, both unconfirmed as of 2026-08-24).
|
||||
const bullets = [
|
||||
"Kugelschreiber aus Metall",
|
||||
"Chromfarbene Details",
|
||||
"Handlich, für jede Tasche",
|
||||
];
|
||||
|
||||
// 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.
|
||||
// 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"),
|
||||
@@ -33,22 +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">
|
||||
{/* object-contain on a bg-bg-base tile, not object-cover — the
|
||||
source is a 338×338px supplier macro shot (no larger original
|
||||
exists), so stretching it across a 410×227 landscape crop like
|
||||
before blows it up and blurs it. A contained square reads as a
|
||||
deliberate close-up instead. Uses product.image (id 85, the
|
||||
confident macro tip shot) — distinct from Focus.tsx's photo
|
||||
(gallery[1], the chrome-rings shot) so the two sections don't
|
||||
repeat the same frame. */}
|
||||
<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 Alltagsstift"
|
||||
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
|
||||
@@ -67,16 +47,11 @@ export async function Pricing() {
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Der Alltagsstift
|
||||
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>
|
||||
<ul className="flex flex-col gap-[0.375rem] items-start">
|
||||
{bullets.map((b) => (
|
||||
<li key={b} className="flex gap-2 items-center">
|
||||
<Image alt="" src="/icon-bullet-dot.svg" width={4} height={4} className="size-1 shrink-0" />
|
||||
<span className="text-body-sm text-text-primary">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
|
||||
@@ -102,8 +77,9 @@ 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}
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { draftMode } from "next/headers";
|
||||
import { TestimonialsGrid } from "../../components/TestimonialsGrid";
|
||||
import { LiveTestimonialsGrid } from "../../components/LiveTestimonialsGrid";
|
||||
import { getTestimonials } from "../../lib/payload";
|
||||
|
||||
// Payload-driven like todo-cards/newsletter/challenge's own testimonial
|
||||
// grids (see Testimonials collection's "page" field) — no longer a
|
||||
// hardcoded array now that "der-eine" is a valid page value there.
|
||||
export async function Testimonials() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const testimonials = await getTestimonials("der-eine", { draft: isPreview });
|
||||
|
||||
return isPreview ? (
|
||||
<LiveTestimonialsGrid testimonials={testimonials} />
|
||||
) : (
|
||||
<TestimonialsGrid testimonials={testimonials} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Hero } from "./components/Hero";
|
||||
import { Focus } from "./components/Focus";
|
||||
import { PasstDazu } from "./components/PasstDazu";
|
||||
import { Testimonials } from "./components/Testimonials";
|
||||
import { Pricing } from "./components/Pricing";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "Der Eine. – Für die eine Sache, die gerade zählt.";
|
||||
|
||||
// Description reads from the live product instead of a separately
|
||||
// hardcoded string, unlike this page's own `title` — matches
|
||||
// /tasse-die-pause's own pattern (see that page's comment). Two
|
||||
// hand-maintained copies of the same text used to drift apart: this one
|
||||
// used to say something different from Products.description (which feeds
|
||||
// the cart/checkout/JSON-LD elsewhere) — same field, one source now.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const product = await getProductBySlug("stift-kugelschreiber");
|
||||
const description = product?.descriptionText || undefined;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: "/der-eine" },
|
||||
openGraph: { title, description, url: "/der-eine" },
|
||||
twitter: { title, description },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DerEinePage() {
|
||||
const [product, seller] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
const productSchema = product
|
||||
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-eine", seller)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{productSchema && (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
)}
|
||||
<main className="flex flex-col flex-1">
|
||||
<Hero />
|
||||
<Focus />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
<PasstDazu productSlug="stift-kugelschreiber" />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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