Add ToDo-Karten product page, fluid design tokens, and Navbar/Hero fixes
New /todo-cards page (Hero, How-it-works, Focus, Testimonials, Pricing) built with the fluid clamp() token system (app/lib/fluid.ts) and scroll-reveal animations (app/components/Reveal.tsx), matching styling consistency with /challenge's testimonial section. Navbar: page-aware active state and anchor-link navigation from any route (not just "/"), fixed logo click to actually navigate instead of silently rewriting the URL, fluid nav text size, custom hash-scroll handling for cross-page anchor links. Hero: responsive breakpoint fix for the text/image split at Tablet widths, entrance animation for the brand's orange dot, removed a red dot artifact from hero.png. Also includes prior uncommitted work on About/Blog/Newsletter/Footer and the cart lib (app/lib/cart.ts). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+77
-41
@@ -1,5 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
|
||||
const title = "7-Tage-Challenge – Mehr Klarheit in 7 Tagen";
|
||||
const description =
|
||||
"Die 7-Tage-Challenge für deinen klaren Kopf und mehr Struktur im Alltag: 7 kurze, praktische Impulse direkt ins Postfach – für mehr Fokus, Ruhe und Klarheit.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/challenge",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/challenge",
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
function IconEnvelope() {
|
||||
return (
|
||||
@@ -132,7 +157,7 @@ function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: str
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 transition-all"
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
@@ -154,7 +179,7 @@ export default function ChallengePage() {
|
||||
<section className="w-full flex items-stretch overflow-hidden" style={{ minHeight: "32rem" }}>
|
||||
|
||||
{/* Left: text + form */}
|
||||
<div className="flex flex-col justify-center gap-6 px-8 lg:px-[5rem] py-10 lg:py-14 w-full lg:w-[52%] lg:shrink-0">
|
||||
<Reveal className="flex flex-col justify-center gap-6 px-8 lg:px-[5rem] py-10 lg:py-14 w-full lg:w-[52%] lg:shrink-0">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<p className="text-[0.875rem] text-[#888] flex items-center gap-1.5">
|
||||
@@ -194,14 +219,17 @@ export default function ChallengePage() {
|
||||
|
||||
{/* Email form */}
|
||||
<EmailCapture />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Right: hero image with badge */}
|
||||
<div className="hidden lg:block flex-1 relative overflow-hidden">
|
||||
{/* Right: hero image with badge — group + scale-105 on hover,
|
||||
same restrained "photo feels alive" treatment as the Home/
|
||||
todo-cards hero images; delay={0.15} mirrors their text→image
|
||||
stagger. */}
|
||||
<Reveal className="hidden lg:block group flex-1 relative overflow-hidden" delay={0.15}>
|
||||
<img
|
||||
alt="7-Tage-Challenge"
|
||||
src="/blog-featured.jpg"
|
||||
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none"
|
||||
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{/* Badge */}
|
||||
<div className="absolute top-8 right-8 w-[7.5rem] h-[7.5rem] rounded-full bg-white shadow-md flex flex-col items-center justify-center text-center p-3 gap-1">
|
||||
@@ -213,7 +241,7 @@ export default function ChallengePage() {
|
||||
<path d="M1 1l9 8 9-8" stroke="#f6a701" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -221,7 +249,7 @@ export default function ChallengePage() {
|
||||
<section className="bg-white w-full py-14 lg:py-20">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto flex flex-col items-center gap-12">
|
||||
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Reveal className="flex flex-col items-center gap-2 text-center">
|
||||
<h2
|
||||
className="font-semibold text-[#222221]"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
|
||||
@@ -229,26 +257,26 @@ export default function ChallengePage() {
|
||||
So funktioniert die Challenge
|
||||
</h2>
|
||||
<p className="text-[1rem] text-[#666]">Jeden Tag ein Impuls. In nur wenigen Minuten.</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-start gap-8 lg:gap-2 w-full">
|
||||
<RevealGroup className="flex flex-col lg:flex-row items-start lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{steps.flatMap((step, i) => [
|
||||
<div key={step.title} className="flex lg:flex-col items-start lg: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">
|
||||
<RevealItem key={step.title} className="group flex lg:flex-col items-start lg: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">
|
||||
{step.icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 lg:text-center">
|
||||
<p className="font-semibold text-[#222221] text-[1rem]">{step.title}</p>
|
||||
<p className="text-[0.875rem] text-[#666] leading-[1.5]">{step.desc}</p>
|
||||
</div>
|
||||
</div>,
|
||||
</RevealItem>,
|
||||
i < steps.length - 1 ? (
|
||||
<div key={`arrow-${i}`} className="hidden lg:flex items-center shrink-0 mt-5">
|
||||
<ArrowRight />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
</div>
|
||||
</RevealGroup>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
@@ -257,7 +285,7 @@ export default function ChallengePage() {
|
||||
<section className="w-full py-14 lg:py-20">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto flex flex-col gap-10">
|
||||
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Reveal className="flex flex-col items-center gap-2 text-center">
|
||||
<h2
|
||||
className="font-semibold text-[#222221]"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
|
||||
@@ -265,32 +293,37 @@ export default function ChallengePage() {
|
||||
Das erwartet dich
|
||||
</h2>
|
||||
<p className="text-[1rem] text-[#666]">7 Tage. 7 Impulse. Für mehr Klarheit in deinem Alltag.</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-10 lg:gap-16 items-center">
|
||||
|
||||
{/* Image */}
|
||||
<div className="w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden" style={{ minHeight: "18rem" }}>
|
||||
<Reveal
|
||||
className="group w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden"
|
||||
style={{ minHeight: "18rem" }}
|
||||
>
|
||||
<img
|
||||
alt="Notizbuch"
|
||||
src="/challenge-content.jpg"
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{ minHeight: "18rem" }}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Checklist */}
|
||||
<ul className="flex flex-col gap-5 flex-1">
|
||||
{benefits.map((b) => (
|
||||
<li key={b.title} className="flex items-start gap-3">
|
||||
<Check />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-semibold text-[#222221] text-[1rem]">{b.title}</p>
|
||||
<p className="text-[0.9rem] text-[#555] leading-[1.5]">{b.desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Reveal delay={0.1} className="flex-1 w-full">
|
||||
<ul className="flex flex-col gap-5">
|
||||
{benefits.map((b) => (
|
||||
<li key={b.title} className="flex items-start gap-3">
|
||||
<Check />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-semibold text-[#222221] text-[1rem]">{b.title}</p>
|
||||
<p className="text-[0.9rem] text-[#555] leading-[1.5]">{b.desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,34 +333,37 @@ export default function ChallengePage() {
|
||||
<section className="bg-white w-full py-14 lg:py-20">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto flex flex-col gap-10">
|
||||
|
||||
<h2
|
||||
<Reveal
|
||||
className="font-semibold text-[#222221] text-center"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
|
||||
>
|
||||
Was andere sagen
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Same micro-interactions as /todo-cards's identically-styled
|
||||
testimonial cards (kept in sync deliberately): hover-lift on
|
||||
the card, avatar scale on the same hover via `group`. */}
|
||||
<RevealGroup className="flex flex-col lg:flex-row gap-6">
|
||||
{testimonials.map((t) => (
|
||||
<div
|
||||
<RevealItem
|
||||
key={t.name}
|
||||
className="flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4"
|
||||
className="group flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<p className="text-[#222221] text-[0.95rem] leading-[1.6] flex-1">{t.quote}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
alt={t.name}
|
||||
src={t.avatar}
|
||||
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||
className="w-10 h-10 rounded-full object-cover shrink-0 transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-[#222221] text-[0.9rem]">{t.name}</p>
|
||||
<p className="text-[#777] text-[0.8rem]">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</div>
|
||||
</RevealGroup>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
@@ -335,7 +371,7 @@ export default function ChallengePage() {
|
||||
{/* ── Bottom CTA ── */}
|
||||
<section className="w-full py-14 lg:py-16">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto">
|
||||
<div className="bg-[#f8f3ec] rounded-xl flex flex-col lg:flex-row gap-8 lg:gap-[3.5rem] items-start lg:items-center px-6 lg:px-10 py-8">
|
||||
<Reveal className="bg-[#f8f3ec] rounded-xl flex flex-col lg:flex-row gap-8 lg:gap-[3.5rem] items-start lg:items-center px-6 lg:px-10 py-8">
|
||||
|
||||
{/* Left: icon + copy */}
|
||||
<div className="flex gap-5 items-start flex-1 min-w-0">
|
||||
@@ -359,11 +395,11 @@ export default function ChallengePage() {
|
||||
</div>
|
||||
|
||||
{/* Right: form */}
|
||||
<div className="w-full lg:w-[24rem] lg:shrink-0">
|
||||
<div className="w-full lg:w-[30rem] lg:shrink-0">
|
||||
<EmailCapture />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+49
-33
@@ -1,72 +1,88 @@
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
<section id="ueber-bjoern" className="bg-[#111] flex items-stretch w-full">
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
|
||||
|
||||
{/* Left: text content — relative + z-10 so it renders above the overlapping photo */}
|
||||
<div className="flex-[1_0_0] flex flex-col gap-4 justify-center px-[5rem] py-8 min-w-0 relative z-10">
|
||||
{/* Text content — relative + z-10 so it renders above the overlapping
|
||||
photo at md+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake). */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1_0_0] min-w-0 relative z-10">
|
||||
|
||||
{/* Large serif statement — width-constrained as per design */}
|
||||
<p
|
||||
className="font-semibold text-white text-[1.75rem] leading-normal w-[17.625rem]"
|
||||
className="font-semibold text-bg-white text-h-section leading-normal w-[17.625rem]"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Ich glaube nicht, dass Menschen mehr Disziplin brauchen.
|
||||
</p>
|
||||
|
||||
{/* Quote row: script quote + golden divider + author bio */}
|
||||
<div className="flex items-start justify-between w-full">
|
||||
{/* Quote row: script quote / divider / author bio — side-by-side
|
||||
from md+, stacked with a horizontal divider below md */}
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6 md:gap-0 w-full">
|
||||
|
||||
{/* Caveat script text with signature positioned below */}
|
||||
<div className="relative flex-1" style={{ minHeight: "8rem" }}>
|
||||
<div
|
||||
className="font-normal text-[#f6a701] text-[2.25rem]"
|
||||
className="font-normal text-brand text-[2.25rem]"
|
||||
style={{ fontFamily: "var(--font-caveat)", lineHeight: "2rem" }}
|
||||
>
|
||||
<p>Ich glaube,</p>
|
||||
<p>sie brauchen </p>
|
||||
<p>weniger Reibung.</p>
|
||||
</div>
|
||||
{/* Signature — absolute, sits just below the three Caveat lines (3 × 32px = 96px) */}
|
||||
{/* Signature underline — hand-drawn organic stroke, sits just below the three Caveat lines (3 × 32px = 96px) */}
|
||||
<div
|
||||
className="absolute overflow-hidden pointer-events-none"
|
||||
className="absolute pointer-events-none"
|
||||
style={{ top: "6.44rem", left: "1.5rem", width: "9.375rem", height: "1.375rem" }}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src="/about-signature.png"
|
||||
className="absolute max-w-none"
|
||||
style={{ width: "101.25%", height: "118.18%", left: "-0.62%", top: "-17.72%" }}
|
||||
/>
|
||||
<svg viewBox="0 0 150 22" fill="none" className="w-full h-full text-brand" aria-hidden="true">
|
||||
{/* Thin, near-constant-width ease-out sweep, traced from the actual
|
||||
Figma signature-image raster (node 4175:150, page-home) — a single
|
||||
smooth concave rise, no waviness/taper, ending ~88% across. */}
|
||||
<path
|
||||
d="M3,10 C20,8.3 35,6.7 50,5.4 C70,3.7 85,2.2 100,1.1 C112,0.5 122,0.3 131,0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Golden vertical divider + author lines */}
|
||||
<div className="flex gap-6 items-start shrink-0">
|
||||
<div className="bg-[#f6a701] w-[2px] h-24 shrink-0" />
|
||||
<div
|
||||
className="text-white font-normal whitespace-nowrap"
|
||||
style={{ fontSize: "1rem", lineHeight: "1.5rem" }}
|
||||
>
|
||||
<p>Björn.</p>
|
||||
<p>Führungskraft.</p>
|
||||
<p>Familienmensch.</p>
|
||||
<p>Gründer von einfach-produktiv.</p>
|
||||
</div>
|
||||
{/* Divider — horizontal full-width line below md, vertical
|
||||
gold line beside the author bio from md+ */}
|
||||
<div className="bg-brand w-full h-px md:w-[2px] md:h-24 md:mx-6 shrink-0" />
|
||||
|
||||
<div
|
||||
className="text-bg-white font-normal whitespace-nowrap md:shrink-0"
|
||||
style={{ fontSize: "1rem", lineHeight: "1.5rem" }}
|
||||
>
|
||||
<p>Björn.</p>
|
||||
<p>Führungskraft.</p>
|
||||
<p>Familienmensch.</p>
|
||||
<p>Gründer von einfach-produktiv.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Right: author photo — overlaps text column via -ml-32, gradient covers the overlap */}
|
||||
<div className="flex-[1.4_0_0] relative overflow-hidden -ml-48" style={{ minHeight: "14rem" }}>
|
||||
{/* Author photo — overlaps the text column via -ml-48 from md+ only
|
||||
(that overlap trick has nothing to blend into once stacked);
|
||||
plain full-width photo below the text on Mobile. */}
|
||||
<Reveal
|
||||
className="relative overflow-hidden w-full md:flex-[1.4_0_0] md:-ml-48"
|
||||
style={{ minHeight: "14rem" }}
|
||||
delay={0.15}
|
||||
>
|
||||
<img
|
||||
alt="Björn"
|
||||
src="/about-author.jpg"
|
||||
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none"
|
||||
/>
|
||||
{/* Left gradient: wide enough to cover the text-column overlap */}
|
||||
<div className="absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-[#111] to-transparent pointer-events-none" />
|
||||
</div>
|
||||
{/* Left gradient: wide enough to cover the text-column overlap — md+ only */}
|
||||
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
</Reveal>
|
||||
|
||||
</section>
|
||||
);
|
||||
|
||||
+48
-39
@@ -1,5 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getBlogPosts } from "../lib/payload";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
|
||||
export async function Blog() {
|
||||
const posts = await getBlogPosts(3);
|
||||
@@ -11,107 +13,114 @@ export async function Blog() {
|
||||
.map((post) => ({ ...post, href: `/blog/${post.slug}` }));
|
||||
|
||||
return (
|
||||
<div id="blog" className="flex flex-col gap-[3rem] items-start pb-[4rem] w-full bg-[#f5f0e8]">
|
||||
<div id="blog" className="flex flex-col gap-12 items-start pb-16 w-full bg-bg-base">
|
||||
|
||||
{/* Section header */}
|
||||
<div className="flex flex-col gap-[0.5rem] items-start px-[5rem] w-full">
|
||||
<p className="font-bold text-[#f6a701] text-[1.25rem]">
|
||||
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
|
||||
<p className="font-bold text-brand text-h-small">
|
||||
Neue Impulse
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-[#222221] text-[1.75rem] leading-normal"
|
||||
className="font-semibold text-text-primary text-h-section leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Gedanken, Methoden & Impulse
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Posts grid */}
|
||||
<div className="flex flex-col gap-[2.5rem] items-start px-[5rem] w-full">
|
||||
<RevealGroup className="flex flex-col gap-10 items-start px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Featured post — thumbnail flush left, card clips with overflow-hidden */}
|
||||
<div className="w-full border border-[#d9d9d9] rounded-xl overflow-hidden flex gap-3">
|
||||
<div className="relative h-[220px] shrink-0 w-[60%]">
|
||||
<img
|
||||
alt=""
|
||||
src={featured.thumbnail ?? undefined}
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
|
||||
/>
|
||||
{/* Featured post — image on top on Mobile, side-by-side from md+ */}
|
||||
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-7 md:h-[220px] bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={featured.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between py-4 pr-4 min-w-0">
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 md:pr-4 md:pl-4 md:col-span-5 min-w-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-[#777] text-[1rem] uppercase whitespace-nowrap">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{featured.category}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-[#222221]">
|
||||
<div className="flex flex-col gap-4 text-text-primary">
|
||||
<p
|
||||
className="font-semibold text-[1.75rem] leading-normal"
|
||||
className="font-semibold text-h-section leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{featured.title}
|
||||
</p>
|
||||
<p className="font-normal text-[1rem] leading-6">
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 md:line-clamp-none">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={featured.href}
|
||||
className="font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:text-[#f6a701] transition-colors"
|
||||
className="font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ Zum Beitrag
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
{/* Secondary posts — thumbnail with own rounded-xl, card py-2 only */}
|
||||
<div className="flex gap-3 w-full">
|
||||
{/* Secondary posts — image on top on Mobile, side-by-side from md+ */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
|
||||
{secondary.map((post) => (
|
||||
<div
|
||||
<RevealItem
|
||||
key={post.id}
|
||||
className="flex-1 border border-[#d9d9d9] rounded-xl flex gap-3 h-[230px] items-center py-2"
|
||||
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{/* Thumbnail: own border-radius so it sits neatly inside the padded card */}
|
||||
<div className="rounded-xl overflow-hidden shrink-0 h-full w-[14.7rem]">
|
||||
<img
|
||||
alt=""
|
||||
src={post.thumbnail ?? undefined}
|
||||
className="w-full h-full object-cover pointer-events-none"
|
||||
/>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-2 md:h-[230px] bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={post.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 29vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between h-full min-w-0 py-2 pr-2 text-[#222221]">
|
||||
<div className="flex flex-col justify-between gap-4 py-3 px-4 md:col-span-3 min-w-0 text-text-primary">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-[#777] text-[1rem] uppercase whitespace-nowrap">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{post.category}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className="font-semibold text-[1.25rem] leading-normal"
|
||||
className="font-semibold text-h-small leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="font-normal text-[1rem] leading-6">
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 md:line-clamp-none">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={post.href}
|
||||
className="font-bold text-[1rem] whitespace-nowrap hover:text-[#f6a701] transition-colors"
|
||||
className="font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ Zum Beitrag
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</RevealGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+61
-50
@@ -1,57 +1,68 @@
|
||||
export function Divider() {
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
function Word({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center pb-[1.25rem] pt-[3rem] w-full bg-[#f5f0e8]">
|
||||
<div className="flex gap-[2rem] items-center overflow-clip shrink-0">
|
||||
<p
|
||||
className="font-bold leading-normal text-text-primary text-h2 whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
<p
|
||||
className="font-bold leading-normal text-[#222221] text-[2rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
Klarheit
|
||||
</p>
|
||||
|
||||
{/* Arrow separator (flipped) */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="-scale-y-100 rotate-180">
|
||||
<div className="h-[0.75rem] relative w-[2.625rem]">
|
||||
<img alt="" src="/icon-separator.svg" className="absolute inset-0 w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
function Arrow() {
|
||||
return (
|
||||
<div className="flex items-center justify-center shrink-0">
|
||||
<div className="-scale-y-100 rotate-180">
|
||||
<div className="relative" style={{ height: "var(--divider-arrow-h)", width: "var(--divider-arrow-w)" }}>
|
||||
<img alt="" src="/icon-separator.svg" className="absolute inset-0 w-full h-full" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="font-bold leading-normal text-[#222221] text-[2rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
Fokus
|
||||
</p>
|
||||
|
||||
{/* Arrow separator (flipped) */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="-scale-y-100 rotate-180">
|
||||
<div className="h-[0.75rem] relative w-[2.625rem]">
|
||||
<img alt="" src="/icon-separator.svg" className="absolute inset-0 w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="font-bold leading-normal text-[#222221] text-[2rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
Entlastung
|
||||
</p>
|
||||
|
||||
{/* Sparkle icon */}
|
||||
<div className="flex items-center justify-center h-[2.506rem] w-[1.919rem]">
|
||||
<div style={{ rotate: "4.6deg" }}>
|
||||
<div className="h-[2.375rem] relative w-[1.734rem]">
|
||||
<img alt="" src="/icon-separator-right.svg" className="absolute inset-0 w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
return (
|
||||
<Reveal className="flex items-center justify-center flex-wrap gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center">
|
||||
|
||||
{/* Word + its trailing icon are grouped into one shrink-0 flex unit
|
||||
so flex-wrap only ever breaks BETWEEN pairs, never leaving an
|
||||
arrow stranded alone on its own line — the arrows are always
|
||||
visible now (previously hidden below md: entirely to sidestep
|
||||
that exact problem), this fixes the root cause instead. */}
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<Word>Klarheit</Word>
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<Word>Fokus</Word>
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<Word>Entlastung</Word>
|
||||
|
||||
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
|
||||
the text-h2 words next to it, previously hard rem values that
|
||||
stayed visually static while the words scaled. */}
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ height: "var(--divider-sparkle-h)", width: "var(--divider-sparkle-w)" }}
|
||||
>
|
||||
<div style={{ rotate: "4.6deg" }}>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: "var(--divider-sparkle-inner-h)", width: "var(--divider-sparkle-inner-w)" }}
|
||||
>
|
||||
<img alt="" src="/icon-separator-right.svg" className="absolute inset-0 w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-12
@@ -9,43 +9,43 @@ const links = [
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-[#111] flex flex-col items-center justify-end w-full mt-auto">
|
||||
<footer className="bg-bg-dark flex flex-col items-center justify-end w-full mt-auto">
|
||||
|
||||
{/* 3px top divider — #30302c, not a border */}
|
||||
{/* 3px top divider — not a border */}
|
||||
<div className="bg-[#30302c] h-[3px] w-full shrink-0" />
|
||||
|
||||
{/* Footer inner — max-width 1280px, centered */}
|
||||
<div className="flex flex-col items-start w-full max-w-[1280px] py-4">
|
||||
<div className="flex flex-col items-center w-full max-w-[1280px] py-8 md:py-4">
|
||||
|
||||
{/* Three columns: logo | @handle | links */}
|
||||
<div className="flex items-center justify-between px-16 w-full">
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below md */}
|
||||
<div className="flex flex-col md:flex-row items-center md:justify-between gap-6 md:gap-0 px-8 md:px-16 w-full">
|
||||
|
||||
{/* Logo: "einfach produktiv" white + "." gold */}
|
||||
<div className="flex items-center p-2 shrink-0">
|
||||
<span
|
||||
className="font-semibold text-white text-[1.25rem] leading-normal whitespace-nowrap"
|
||||
className="font-semibold text-bg-white text-h-small leading-normal whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
einfach produktiv
|
||||
<span style={{ color: "#fdb705" }}>.</span>
|
||||
<span className="text-brand">.</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Social handle — Lora Regular (weight 400), 24px */}
|
||||
{/* Social handle — Lora Regular (weight 400) */}
|
||||
<p
|
||||
className="font-normal text-white text-[1.5rem] text-center leading-[1.2] whitespace-nowrap shrink-0"
|
||||
className="font-normal text-bg-white text-h-small text-center leading-[1.2] whitespace-nowrap shrink-0"
|
||||
style={{ fontFamily: "var(--font-lora)", fontWeight: 400 }}
|
||||
>
|
||||
@einfach.produktiv
|
||||
</p>
|
||||
|
||||
{/* Legal links — Inter Regular 14px, gap 24px */}
|
||||
<div className="flex items-start gap-6 shrink-0">
|
||||
{/* Legal links — wrap + center below md instead of a rigid row */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 shrink-0">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="font-normal text-white text-[0.875rem] leading-[1.5] whitespace-nowrap hover:text-[#f6a701] transition-colors"
|
||||
className="font-normal text-bg-white text-body-sm leading-[1.5] whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
|
||||
+119
-69
@@ -1,81 +1,131 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { PopIn, Reveal } from "./Reveal";
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="bg-[#f5f0e8] flex items-center justify-between relative w-full overflow-hidden">
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Structural breakpoint is lg: (1024px) here, not the site-wide md:
|
||||
(768px) — a documented exception (see Gotcha in the figma-to-nextjs
|
||||
skill). At md:col-span-5 the text column was only ~320px at
|
||||
768-1023px viewports, too narrow for the heading/CTA/social-proof
|
||||
row (which wrapped to 3 cramped lines). Staying stacked full-width
|
||||
through the whole Tablet range and only splitting into the 5/7
|
||||
grid once there's real room (≥1024px) fixes that without touching
|
||||
the 5/7 ratio itself, which is fine once it has space. */}
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 lg:items-center gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12 lg:pt-0">
|
||||
|
||||
{/* Left: text content */}
|
||||
<div className="flex flex-[1_0_0] flex-col gap-[1.75rem] items-start min-w-px overflow-clip pl-[5rem] pr-[2.5rem] relative">
|
||||
{/* Text content — first in DOM/visual order at every breakpoint so
|
||||
the CTA stays above the fold on Mobile (deliberate exception to
|
||||
the "keep DOM order" default, see Hero decision in the plan).
|
||||
Reveal fires ~immediately since Hero is already in the initial
|
||||
viewport — this doubles as the page's entrance animation. */}
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
|
||||
{/* Heading — w-[min-content] forces wrap at widest word ("Produktivität") */}
|
||||
<p
|
||||
className="font-semibold leading-[0] min-w-full shrink-0 text-[0px] text-black w-[min-content] [word-break:break-word]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
<span className="leading-[4.5rem] text-[4rem]">Produktivität darf sich leicht anfühlen</span>
|
||||
<span className="leading-[4.5rem] text-[4rem] text-[#fdb705]">.</span>
|
||||
</p>
|
||||
|
||||
{/* Subheading */}
|
||||
<p className="font-semibold leading-[2.375rem] min-w-full shrink-0 text-[#222221] text-[1.5rem] w-[min-content] [word-break:break-word] not-italic">
|
||||
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="flex gap-[1rem] items-center justify-center overflow-clip px-[1.5rem] py-[0.75rem] rounded-[0.5rem] shrink-0 bg-[#f6a701] hover:brightness-95 transition-all"
|
||||
>
|
||||
<span className="font-semibold leading-[2.375rem] text-[#222221] text-[1.375rem] whitespace-nowrap not-italic">
|
||||
Starte mit der 7-Tage-Challenge
|
||||
</span>
|
||||
<div className="relative h-[1.1875rem] w-[1.5625rem] shrink-0">
|
||||
<img
|
||||
alt=""
|
||||
src="/icon-check.svg"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Social proof */}
|
||||
<div className="flex gap-[0.75rem] items-start overflow-clip shrink-0 w-full">
|
||||
{/* Avatars — gap 2px, not overlapping */}
|
||||
<div className="flex gap-[0.125rem] items-center shrink-0">
|
||||
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative w-[2.8125rem] h-[2.8125rem] rounded-full overflow-hidden shrink-0"
|
||||
>
|
||||
<Image src={src} alt="" fill className="object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-[#222221] text-[1rem] [word-break:break-word]">
|
||||
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
|
||||
{/* Heading — forced break after "darf" below lg: (1024px),
|
||||
natural wrap from lg: up. Below lg: the Hero is stacked
|
||||
full-width and narrower per-viewport, where natural wrap
|
||||
produced an awkward break — force it after "darf" there via
|
||||
a responsive <br/> (visible by default, turned off at lg:+).
|
||||
From lg: up the 5/12 grid's text column wraps fine on its
|
||||
own, no forced break needed. */}
|
||||
<p
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
<span className="text-display">
|
||||
Produktivität darf<br className="lg:hidden" /> sich leicht anfühlen
|
||||
</span>
|
||||
{/* Brand's signature orange dot (also in the logo/footer) —
|
||||
bouncy pop-in once the heading scrolls into view, timed to
|
||||
land just after the Reveal's own 0.6s fade-up so it reads
|
||||
as a deliberate flourish, not simultaneous with the text.
|
||||
One-shot, not a looping pulse — continuous motion next to
|
||||
the primary CTA would be distracting rather than "cool". */}
|
||||
<PopIn className="text-display text-brand inline-block" delay={0.5}>
|
||||
.
|
||||
</PopIn>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Subheading */}
|
||||
<p className="font-semibold leading-[2.375rem] min-w-full shrink-0 text-text-primary text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
|
||||
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
>
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-h3 whitespace-nowrap not-italic">
|
||||
Starte mit der 7-Tage-Challenge
|
||||
</span>
|
||||
<div className="relative h-[1.1875rem] w-[1.5625rem] shrink-0">
|
||||
<img
|
||||
alt=""
|
||||
src="/icon-check.svg"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Social proof */}
|
||||
<div className="flex gap-3 items-start overflow-clip shrink-0 w-full">
|
||||
{/* Avatars — gap 2px, not overlapping */}
|
||||
<div className="flex gap-[0.125rem] items-center shrink-0">
|
||||
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative rounded-full overflow-hidden shrink-0"
|
||||
style={{ width: "var(--hero-avatar-size)", height: "var(--hero-avatar-size)" }}
|
||||
>
|
||||
<Image src={src} alt="" fill sizes="46px" className="object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body [word-break:break-word]">
|
||||
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Image — bleeds to the true edge at every breakpoint (never
|
||||
padded). Below lg: (stacked layout) the full 887:583 aspect
|
||||
ratio at 100vw would make the image ~600-900px tall and
|
||||
dominate the page, so height is capped and object-cover crops
|
||||
it into a supporting banner instead; at lg:+ (grid, image only
|
||||
58% width) the full aspect ratio looks right again, so the cap
|
||||
is lifted. A small negative top margin below lg: pulls it up to
|
||||
slightly tuck under the text block (deliberately less than the
|
||||
social-proof row's height, so it never covers the avatars/text).
|
||||
Scoped to md:-only (mt-0 at base and again at lg:) — it's a
|
||||
Tablet-specific touch, not a permanent effect. No shadow: a
|
||||
plain box-shadow reads as a hard rectangular edge against the
|
||||
existing corner/right/bottom mask-gradient fade below, which
|
||||
looked worse than no shadow at all — tried and reverted. */}
|
||||
<Reveal
|
||||
className="order-2 lg:order-none lg:col-span-7 relative w-full aspect-[887/583] max-h-[16rem] md:max-h-[22rem] lg:max-h-none mt-0 md:-mt-6 lg:mt-0"
|
||||
delay={0.15}
|
||||
>
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
WebkitMaskComposite: "destination-in",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right: hero image — gradient mask fades left + top edges into the cream background */}
|
||||
<div
|
||||
className="h-[36.4375rem] shrink-0 w-[55.4375rem]"
|
||||
style={{
|
||||
backgroundImage: "url('/hero.png')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
WebkitMaskComposite: "destination-in",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+325
-54
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
|
||||
const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem]
|
||||
const SCROLL_DURATION = 800; // ms
|
||||
@@ -37,9 +39,67 @@ const anchorIds = navLinks
|
||||
.filter((l) => l.href.startsWith("#"))
|
||||
.map((l) => l.href.slice(1));
|
||||
|
||||
// "Werkzeuge" also covers standalone tool/product pages that live under
|
||||
// the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten)
|
||||
// and /challenge (7-Tage-Challenge) are both tools listed there, even
|
||||
// though neither route is nested under /werkzeuge/. Their own Figma
|
||||
// sources (page-todo-karten, and the 7-Tage-Challenge card in
|
||||
// section-tools) mark "Werkzeuge" active in the nav for the same reason.
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge"];
|
||||
|
||||
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
|
||||
if (href === "#werkzeuge") {
|
||||
return (
|
||||
WERKZEUGE_ROUTES.some((route) => pathname.startsWith(route)) ||
|
||||
(pathname === "/" && activeSection === "werkzeuge")
|
||||
);
|
||||
}
|
||||
if (href.startsWith("#")) {
|
||||
return pathname === "/" && activeSection === href.slice(1);
|
||||
}
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
// Cart icon + count badge — traced from the Figma Navbar/Default component's
|
||||
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
|
||||
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
|
||||
// the hamburger drawer below lg) since the cart is a primary nav affordance.
|
||||
// Badge only renders once something is actually in the cart.
|
||||
function CartLink() {
|
||||
const count = useCartCount();
|
||||
return (
|
||||
<Link
|
||||
href="/cart"
|
||||
aria-label={count > 0 ? `Warenkorb, ${count} Artikel` : "Warenkorb"}
|
||||
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 32 30" className="h-7 w-7 text-text-primary" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2,2 H6 L9.2,17.7 a2.4,2.4 0 0 0 2.4,2 h11.6 a2.4,2.4 0 0 0 2.4,-1.9 L28,7.5 H8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="25" r="1.6" fill="currentColor" />
|
||||
<circle cx="24" cy="25" r="1.6" fill="currentColor" />
|
||||
</svg>
|
||||
{count > 0 && (
|
||||
<span className="absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white">
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
@@ -47,6 +107,30 @@ export function Navbar() {
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Handles landing on "/#werkzeuge" etc. after a cross-page nav click
|
||||
// (e.g. clicking "Werkzeuge" from /todo-cards) or a direct URL load with
|
||||
// a hash. Next.js's own built-in hash-scroll-after-navigation doesn't
|
||||
// reliably apply the sticky navbar's height offset and can fire before
|
||||
// the just-mounted page's layout has settled — this reimplements it with
|
||||
// the exact same smoothScrollTo + NAVBAR_HEIGHT offset as the same-page
|
||||
// click handlers below, so behavior is identical either way. Runs
|
||||
// whenever pathname becomes "/" (covers both the initial load and a
|
||||
// client-side transition landing here), a short delay lets layout
|
||||
// settle first.
|
||||
useEffect(() => {
|
||||
if (pathname !== "/") return;
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (!anchorIds.includes(hash)) return;
|
||||
const timer = setTimeout(() => {
|
||||
const el = document.getElementById(hash);
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
// trigger line = 1/3 from top of viewport (= 2/3 from bottom)
|
||||
@@ -66,87 +150,274 @@ export function Navbar() {
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Close on viewport resize past the structural breakpoint, so the drawer
|
||||
// never lingers open behind the (now visible) desktop nav. The hamburger
|
||||
// exists through both sub-768 ("Collapsed") and 768-1023 ("Collapsed-CTA")
|
||||
// tiers, and only disappears at lg (1024) — see Navbar section of the plan.
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (window.innerWidth >= 1024) setMobileOpen(false);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
// Focus management: move focus into the panel on open, trap Tab within
|
||||
// it, close + return focus to the hamburger button on Escape.
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
|
||||
const panel = panelRef.current;
|
||||
const focusables = panel?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled])'
|
||||
);
|
||||
focusables?.[0]?.focus();
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMobileOpen(false);
|
||||
hamburgerRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !focusables || focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [mobileOpen]);
|
||||
|
||||
const closeMobile = () => setMobileOpen(false);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-50 w-full h-[6.25rem] flex items-center px-[2rem] transition-[background-color,backdrop-filter] duration-300 ${
|
||||
scrolled
|
||||
? "bg-[#f5f0e8]/80 backdrop-blur-md"
|
||||
: "bg-[#f5f0e8]"
|
||||
className={`sticky top-0 z-50 w-full h-[6.25rem] flex flex-col transition-[background-color,backdrop-filter] duration-300 ${
|
||||
scrolled || mobileOpen
|
||||
? "bg-bg-base/80 backdrop-blur-md"
|
||||
: "bg-bg-base"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-8">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
|
||||
{/* Logo — smooth scroll to top */}
|
||||
<a
|
||||
href="/"
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
smoothScrollTo(0);
|
||||
history.replaceState(null, "", "/");
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="einfach produktiv"
|
||||
width={181}
|
||||
height={61}
|
||||
priority
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex items-center gap-[3rem]">
|
||||
{navLinks.map((link) =>
|
||||
link.href.startsWith("#") ? (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
{/* Logo — real navigation to "/" from anywhere else; only when
|
||||
already on "/" does it become a same-page smooth-scroll-to-top
|
||||
instead (a full Link navigation there would be pointless).
|
||||
Previously always preventDefault()'d and silently
|
||||
history.replaceState()'d the URL to "/" without an actual
|
||||
navigation — from another page that just rewrote the URL bar
|
||||
while leaving that page's content on screen. */}
|
||||
<Link
|
||||
href="/"
|
||||
className="shrink-0"
|
||||
onClick={
|
||||
pathname === "/"
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
closeMobile();
|
||||
smoothScrollTo(0);
|
||||
history.replaceState(null, "", "/");
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}}
|
||||
className={`text-[1.125rem] font-semibold tracking-[0.01125rem] transition-colors cursor-pointer ${
|
||||
activeSection === link.href.slice(1)
|
||||
? "text-[#f6a701]"
|
||||
: "text-[#222221] hover:text-[#f6a701]"
|
||||
: closeMobile
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="einfach produktiv"
|
||||
width={181}
|
||||
height={61}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Nav links — inline only at lg+ (1024px, full "Navbar/Default").
|
||||
Between md and lg ("Collapsed-CTA") they move into the
|
||||
hamburger drawer while the CTA buttons stay inline — matches
|
||||
Figma's Navbar-Mobile component set exactly, see the plan.
|
||||
Matches the Figma NavLink component's hover: text never
|
||||
changes color, an underline (brand, 2px x 40px) fades in on
|
||||
hover and stays on for the active section. */}
|
||||
<nav className="hidden lg:flex items-center gap-12">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const underline = (
|
||||
<span
|
||||
className={`h-[2px] bg-brand transition-all duration-200 ${
|
||||
isActive ? "w-10 opacity-100" : "w-full opacity-0 group-hover:w-10 group-hover:opacity-100"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
// Anchor links only get the custom in-page smooth-scroll
|
||||
// behavior while actually on "/" — its target section
|
||||
// doesn't exist on any other route. From elsewhere (e.g.
|
||||
// /todo-cards), a plain navigation to "/#werkzeuge" is correct
|
||||
// instead: the global `scroll-padding-top` in globals.css
|
||||
// already offsets for the sticky navbar on a native hash
|
||||
// landing, so no extra JS is needed for that case.
|
||||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={resolvedHref}
|
||||
onClick={
|
||||
isHomeAnchor
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className="group flex flex-col items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<span className="text-h4 font-semibold text-text-primary tracking-[0.01125rem] whitespace-nowrap">
|
||||
{link.label}
|
||||
</span>
|
||||
{underline}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Trailing controls — CTA buttons (md+), cart (always), hamburger
|
||||
(below lg). Grouped so spacing stays consistent as individual
|
||||
children hide/show across the three breakpoint tiers. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CartLink />
|
||||
|
||||
{/* CTA buttons — inline from md (768px) up, i.e. through both
|
||||
"Collapsed-CTA" and full Desktop tiers */}
|
||||
<div className="hidden md:flex items-center gap-3 p-2">
|
||||
<Link
|
||||
href="/newsletter"
|
||||
className="px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
Newsletter
|
||||
</Link>
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Hamburger — visible below lg (1024px): covers both the fully
|
||||
collapsed (<768) and "Collapsed-CTA" (768-1023) tiers, since
|
||||
the nav links live in the drawer through both. */}
|
||||
<button
|
||||
ref={hamburgerRef}
|
||||
type="button"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav-panel"
|
||||
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="lg:hidden flex h-11 w-11 items-center justify-center shrink-0"
|
||||
>
|
||||
<span className="relative block h-4 w-6">
|
||||
<span
|
||||
className={`absolute left-0 top-0 block h-0.5 w-6 bg-text-primary transition-transform duration-200 ${
|
||||
mobileOpen ? "translate-y-[7px] rotate-45" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-[7px] block h-0.5 w-6 bg-text-primary transition-opacity duration-200 ${
|
||||
mobileOpen ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-[14px] block h-0.5 w-6 bg-text-primary transition-transform duration-200 ${
|
||||
mobileOpen ? "-translate-y-[7px] -rotate-45" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer panel — toggleable below lg (see hamburger above) */}
|
||||
<div
|
||||
id="mobile-nav-panel"
|
||||
ref={panelRef}
|
||||
className={`lg:hidden w-full overflow-hidden transition-[max-height] duration-300 ease-in-out ${
|
||||
mobileOpen ? "max-h-[30rem]" : "max-h-0"
|
||||
}`}
|
||||
>
|
||||
<nav className="flex flex-col gap-6 px-8 pt-2 pb-6">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||||
return link.href.startsWith("#") ? (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={resolvedHref}
|
||||
onClick={
|
||||
isHomeAnchor
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
closeMobile();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}
|
||||
: closeMobile
|
||||
}
|
||||
className="min-h-11 flex flex-col justify-center gap-1 text-h4 font-semibold text-text-primary w-fit"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
<span
|
||||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-[1.125rem] font-semibold text-[#222221] tracking-[0.01125rem] hover:text-[#f6a701] transition-colors"
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center text-h4 font-semibold text-text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<div className="flex items-center gap-[0.75rem] p-[0.5rem]">
|
||||
<div className="flex flex-col gap-3 px-8 pb-8">
|
||||
<Link
|
||||
href="/newsletter"
|
||||
className="px-[1.5rem] py-[1rem] rounded-[0.5rem] border border-[#868686] text-[1.125rem] font-bold text-[#222221] hover:bg-[#222221] hover:text-white transition-colors"
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
Newsletter
|
||||
</Link>
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="px-[1.5rem] py-[1rem] rounded-[0.5rem] bg-[#f6a701] text-[1.125rem] font-bold text-[#222221] hover:brightness-95 transition-all"
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
export function Newsletter() {
|
||||
return (
|
||||
<section className="py-16 w-full">
|
||||
|
||||
{/* Outer section padding — same 80px horizontal as all other sections */}
|
||||
<div className="px-[5rem] w-full">
|
||||
{/* Outer section padding — same fluid horizontal padding as all other sections */}
|
||||
<div className="px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Rounded card: cream bg, 32px inner padding, 56px gap between copy and form */}
|
||||
<div className="bg-[#f8f3ec] flex gap-[3.5rem] items-center px-8 rounded-xl w-full">
|
||||
{/* Rounded card: cream bg, stacks below md */}
|
||||
<Reveal className="bg-bg-muted flex flex-col md:flex-row gap-8 md:gap-12 items-center px-8 py-8 md:py-0 rounded-md w-full">
|
||||
|
||||
{/* Left: copy — fixed width 612px so form always gets the remaining flex-1 */}
|
||||
<div className="flex gap-8 items-start py-4 shrink-0 w-[38.25rem]">
|
||||
{/* Left: copy — fixed width from md+ so the form always gets the remaining space */}
|
||||
<div className="flex gap-8 items-start w-full md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
|
||||
|
||||
{/* Decorative envelope icon, tilted -4° as per design */}
|
||||
<div className="flex items-center justify-center shrink-0 w-[4.23rem] h-[3.71rem]">
|
||||
@@ -23,48 +25,48 @@ export function Newsletter() {
|
||||
</div>
|
||||
|
||||
{/* Heading + body */}
|
||||
<div className="flex flex-col gap-4 flex-1 min-w-0 text-[#222221]">
|
||||
<div className="flex flex-col gap-4 flex-1 min-w-0 text-text-primary">
|
||||
<p
|
||||
className="font-semibold text-[1.75rem] leading-normal"
|
||||
className="font-semibold text-h-section leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Starte mit einer Woche voller Klarheit
|
||||
</p>
|
||||
<p className="font-normal text-[1rem] leading-6">
|
||||
<p className="font-normal text-body leading-6">
|
||||
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge,
|
||||
mit der du durch mehr Struktur weniger Stress spürst.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: form — takes remaining space, centered vertically */}
|
||||
<div className="flex flex-1 items-center self-stretch min-w-0">
|
||||
<div className="flex flex-1 flex-col gap-4 min-w-0">
|
||||
{/* Right: form — takes remaining space, centered vertically from md+ */}
|
||||
<div className="flex w-full md:flex-1 items-center md:self-stretch min-w-0">
|
||||
<div className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
{/* Input + submit button side by side */}
|
||||
<div className="flex gap-4 items-stretch w-full">
|
||||
{/* Input + submit button — stacked below md, side by side from md+ */}
|
||||
<div className="flex flex-col md:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="flex-1 min-w-0 bg-white border border-[#d9d9d9] rounded-lg px-6 py-3 text-[1rem] text-[#868686] font-normal outline-none focus:border-[#f6a701] transition-colors"
|
||||
className="flex-1 min-w-0 bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1.125rem] text-[#222221] tracking-[0.18px] whitespace-nowrap hover:brightness-95 transition-all"
|
||||
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Privacy note */}
|
||||
<p className="text-[0.75rem] text-[#222221] font-normal leading-normal">
|
||||
<p className="text-label text-text-primary font-normal leading-normal">
|
||||
Ich achte auf deine Daten. Kein Spam, jederzeit abbestellbar.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
const fadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
|
||||
};
|
||||
|
||||
type RevealProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Delay in seconds, e.g. for staggering siblings that aren't inside a RevealGroup. */
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
/** Fades a section up into place once, the first time it scrolls into view. */
|
||||
export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
style={style}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
variants={fadeUp}
|
||||
transition={{ delay }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const staggerContainer: Variants = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
/** Wraps a grid/row of cards — reveals children one-by-one as it scrolls into view. */
|
||||
export function RevealGroup({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Child item for use inside a RevealGroup — same fade-up motion, driven by the parent's stagger. */
|
||||
export function RevealItem({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div className={className} variants={fadeUp}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const popIn: Variants = {
|
||||
hidden: { x: 140, y: -70, scale: 0.5, opacity: 0 },
|
||||
show: {
|
||||
// x is a single continuous glide (140 → 0), deliberately NOT
|
||||
// keyframed in step with y's bounce schedule. It used to share y's
|
||||
// per-segment alternating ease so it would decelerate to a dead stop
|
||||
// at every bounce peak alongside y — physically correct for y (a
|
||||
// ball's vertical speed really is zero at the top of each arc) but
|
||||
// wrong for x, which has no reason to pause horizontally just because
|
||||
// it's momentarily at peak height. That shared stop-start was read as
|
||||
// "stops unnaturally in between". Giving x its own transition (below)
|
||||
// decouples it completely, so it glides smoothly the whole time while
|
||||
// y still bounces underneath it.
|
||||
x: 0,
|
||||
y: [-70, 0, -28, 0, -12, 0, -3, 0],
|
||||
scale: [0.5, 1.05, 0.95, 1.03, 0.97, 1.02, 0.99, 1],
|
||||
// Array, not a bare 1 — a scalar target here would fade opacity
|
||||
// linearly across the *entire* 1s duration against the shared `times`
|
||||
// grid below, so the dot would still be half-transparent through its
|
||||
// first, fastest bounces. Snap to fully opaque at the first keyframe
|
||||
// instead, matching how a real solid ball would look immediately.
|
||||
opacity: [0, 1, 1, 1, 1, 1, 1, 1],
|
||||
transition: {
|
||||
// Applies to y/scale/opacity (anything without its own override
|
||||
// below) — the actual bounce schedule.
|
||||
default: {
|
||||
duration: 1,
|
||||
times: [0, 0.22, 0.4, 0.55, 0.68, 0.8, 0.9, 1],
|
||||
// Per-segment, not a single ease for the whole animation — a real
|
||||
// bounce accelerates while falling (gravity) and decelerates
|
||||
// while rising toward each peak. Alternating fall (easeIn) / rise
|
||||
// (easeOut), one entry per keyframe-to-keyframe transition.
|
||||
ease: ["easeIn", "easeOut", "easeIn", "easeOut", "easeIn", "easeOut", "easeIn"],
|
||||
},
|
||||
// x's own transition — one smooth decelerating tween across the
|
||||
// full duration, independent of the y bounce's stop-start rhythm.
|
||||
x: { duration: 1, ease: "easeOut" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* One-shot "bouncing ball" drop-in for a small accent glyph (e.g. the
|
||||
* brand's orange dot) — arrives from the top right and bounces to rest,
|
||||
* not a looping pulse. `motion.span` needs a Client Component, same reason
|
||||
* Reveal itself exists: keep "use client" isolated here so callers like
|
||||
* Hero.tsx can stay Server Components.
|
||||
*/
|
||||
export function PopIn({ children, className, delay = 0 }: RevealProps) {
|
||||
return (
|
||||
<motion.span
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
variants={popIn}
|
||||
transition={{ delay }}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
+31
-24
@@ -1,23 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
|
||||
const tools = [
|
||||
{
|
||||
icon: { src: "/icon-rocket.svg", w: "3.375rem", h: "4.122rem", transform: "-scale-y-100" },
|
||||
pr: "3.375rem",
|
||||
title: "Mini-Challenge",
|
||||
description: "In 7 Tagen zu mehr Klarheit. Kleine Gewohnheiten, die Großes bewirken.",
|
||||
cta: { label: "Starten", href: "/challenge" },
|
||||
},
|
||||
{
|
||||
icon: { src: "/icon-todo.svg", w: "3rem", h: "3.879rem", transform: "-scale-y-100" },
|
||||
pr: "2rem",
|
||||
title: "ToDo-Karten",
|
||||
description: "Das Werkzeug für Fokus im Alltag. bringe Struktur in deine Aufgaben und gewinne Zeit zurück.",
|
||||
cta: { label: "Entdecken", href: "/werkzeuge" },
|
||||
cta: { label: "Entdecken", href: "/todo-cards" },
|
||||
},
|
||||
{
|
||||
icon: { src: "/icon-newsletter.svg", w: "3rem", h: "2.579rem", transform: "-rotate-4 -scale-y-100" },
|
||||
pr: "2rem",
|
||||
title: "Impulse & Tipps",
|
||||
description: "Wöchentliche Impulse mit konkreten Ideen und erprobten Tipps für weniger Reibung und mehr Leichtigkeit.",
|
||||
cta: { label: "Anmelden", href: "/newsletter" },
|
||||
@@ -26,28 +24,28 @@ const tools = [
|
||||
|
||||
export function Tools() {
|
||||
return (
|
||||
<div id="werkzeuge" className="flex flex-col gap-[3rem] items-start pb-[4rem] pt-[2rem] w-full bg-[#f5f0e8]">
|
||||
<div id="werkzeuge" className="flex flex-col gap-12 items-start pb-16 pt-8 w-full bg-bg-base">
|
||||
|
||||
{/* Section header */}
|
||||
<div className="flex flex-col gap-[0.5rem] items-start px-[5rem] w-full whitespace-nowrap overflow-clip">
|
||||
<p className="font-bold text-[#f6a701] text-[1.25rem]">
|
||||
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
|
||||
<p className="font-bold text-brand text-h-small">
|
||||
Meine Werkzeuge
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-[#222221] text-[1.75rem]"
|
||||
className="font-semibold text-text-primary text-h-section"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Werkzeuge für einen leichteren Alltag.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Tools grid */}
|
||||
<div className="flex gap-[2.5rem] h-[12.5rem] items-center overflow-clip px-[5rem] w-full">
|
||||
{/* Tools grid — 3 cols md+, stacked below md; internal icon+text layout
|
||||
stays horizontal at every size, only the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
<RevealItem
|
||||
key={tool.title}
|
||||
className="flex flex-[1_0_0] gap-[2rem] h-full items-start min-w-px overflow-clip relative"
|
||||
style={{ paddingRight: tool.pr }}
|
||||
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex items-center justify-center shrink-0">
|
||||
@@ -62,31 +60,40 @@ export function Tools() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card content */}
|
||||
<div
|
||||
className="flex flex-[1_0_0] flex-col h-full items-start justify-between min-w-px overflow-clip p-[0.5rem] text-[#222221] [word-break:break-word]"
|
||||
>
|
||||
<div className="flex flex-col gap-[1rem] items-start w-full">
|
||||
{/* Card content — self-stretch + h-full + justify-between so
|
||||
the CTA always sits at the same bottom edge across all 3
|
||||
cards, regardless of how many lines the description wraps
|
||||
to. The parent RevealItem's items-start (needed to keep the
|
||||
icon top-aligned, not stretched) otherwise stops this one
|
||||
child from filling the grid-equalized row height — matches
|
||||
the Figma card-content spec (h-full items-start
|
||||
justify-between), which the earlier implementation had
|
||||
dropped. gap-4 alongside justify-between guarantees a
|
||||
minimum text→CTA gap even for the tallest card (the one
|
||||
that defines the row height, and so has ~zero leftover
|
||||
space for justify-between to distribute on its own). */}
|
||||
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-start gap-4 min-w-0 text-text-primary [word-break:break-word]">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<p
|
||||
className="font-semibold leading-normal text-[1.75rem] w-full"
|
||||
className="font-semibold leading-normal text-h-section w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{tool.title}
|
||||
</p>
|
||||
<p className="font-normal leading-[1.5rem] text-[1rem] w-full">
|
||||
<p className="font-normal leading-[1.5rem] text-body w-full">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={tool.cta.href}
|
||||
className="font-bold leading-normal text-[1rem] whitespace-nowrap hover:text-[#f6a701] transition-colors"
|
||||
className="font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ {tool.cta.label}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</div>
|
||||
</RevealGroup>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
+134
-11
@@ -1,15 +1,138 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #f5f0e8;
|
||||
--foreground: #222221;
|
||||
/*
|
||||
* Fluid design tokens — every value below scales continuously via clamp()
|
||||
* between the Tablet breakpoint (768px) and Desktop (1440px). Below 768px
|
||||
* the clamp() floor freezes the value automatically (no separate mobile
|
||||
* media query needed for sizing — only for structural layout changes,
|
||||
* see the `md:` breakpoint usage in components).
|
||||
*
|
||||
* Spacing overrides Tailwind's own numeric scale (--spacing-4 etc.) at the
|
||||
* exact numbers the styleguide already used (n times 0.25rem equals the
|
||||
* desktop value), so every existing p-4, gap-6, px-20 (etc.) utility
|
||||
* becomes fluid for free.
|
||||
*/
|
||||
@theme {
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-inter), system-ui, sans-serif;
|
||||
--font-display: var(--font-playfair), Georgia, serif;
|
||||
--font-serif: var(--font-lora), Georgia, serif;
|
||||
--font-script: var(--font-caveat), cursive;
|
||||
|
||||
/* Colors */
|
||||
--color-brand: #f6a701;
|
||||
--color-brand-hover: #cf8e01; /* verified against the live Figma NavButtonFilled/Hover variant */
|
||||
--color-bg-base: #f8f5f1;
|
||||
--color-bg-white: #ffffff;
|
||||
--color-bg-muted: #f3efe9;
|
||||
--color-bg-dark: #1a1a18;
|
||||
--color-text-primary: #1a1a18;
|
||||
--color-text-body: #2c2c2a;
|
||||
--color-text-muted: #6b6b69;
|
||||
--color-text-light: #9b9896;
|
||||
--color-text-on-dark: #ffffff;
|
||||
--color-border: #e5e0d8;
|
||||
--color-overlay: rgba(134, 134, 134, 0.55);
|
||||
--color-checkout-active: #f6a701;
|
||||
--color-checkout-done: #1a1a18;
|
||||
--color-toc-active-border: #f6a701;
|
||||
|
||||
/* Radius */
|
||||
--radius-xs: 0.25rem;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 2px 12px rgba(26, 26, 24, 0.06);
|
||||
--shadow-modal: 0 8px 40px rgba(26, 26, 24, 0.16);
|
||||
--shadow-sidebar: 0 2px 8px rgba(26, 26, 24, 0.08);
|
||||
|
||||
/* Type scale (fluid 768->1440) */
|
||||
--text-display: clamp(2.75rem, 1.3214rem + 2.9762vw, 4rem);
|
||||
--text-display--line-height: 1.1;
|
||||
--text-h-feature: clamp(1.875rem, 1.1607rem + 1.4881vw, 2.5rem);
|
||||
--text-h-feature--line-height: 1.2;
|
||||
/* New role, added for page-todo-karten's hero heading (48px Desktop) —
|
||||
doesn't match any existing scale step (h-feature=40, h1=36), Figma's
|
||||
own measured value takes priority over eyeballing it into a near
|
||||
token (Why-point 4). Tablet floor via the styleguide's 80-85% ratio,
|
||||
rounded to 40px. "Product/tool page hero H1" role, distinct from
|
||||
--text-h1 (already spoken for: "Seiten-H1 (Blog-Detail, Warenkorb,
|
||||
etc.)" per styleguide.md §2.2 — a different, smaller role). */
|
||||
--text-h-page: clamp(2.5rem, 1.9286rem + 1.1905vw, 3rem);
|
||||
--text-h-page--line-height: 1.15;
|
||||
--text-h1: clamp(1.75rem, 1.1786rem + 1.1905vw, 2.25rem);
|
||||
--text-h1--line-height: 1.15;
|
||||
--text-h2: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
|
||||
--text-h2--line-height: 1.25;
|
||||
--text-h-section: clamp(1.4375rem, 1.0804rem + 0.744vw, 1.75rem);
|
||||
--text-h-section--line-height: 1.3;
|
||||
--text-h-emphasis: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
|
||||
--text-h-emphasis--line-height: 1.4;
|
||||
--text-h3: clamp(1.1875rem, 0.9732rem + 0.4464vw, 1.375rem);
|
||||
--text-h3--line-height: 1.35;
|
||||
--text-h-small: clamp(1.125rem, 0.9821rem + 0.2976vw, 1.25rem);
|
||||
--text-h-small--line-height: 1.4;
|
||||
--text-h4: clamp(1rem, 0.8571rem + 0.2976vw, 1.125rem);
|
||||
--text-h4--line-height: 1.4;
|
||||
--text-body: 1rem;
|
||||
--text-body--line-height: 1.6;
|
||||
--text-body-sm: 0.875rem;
|
||||
--text-body-sm--line-height: 1.55;
|
||||
--text-label: 0.75rem;
|
||||
--text-label--line-height: 1.4;
|
||||
|
||||
/* Spacing scale (fluid 768->1440) — overrides Tailwind's default n*0.25rem
|
||||
at these exact steps, so p-4/gap-6/px-20/etc. become fluid automatically */
|
||||
--spacing-1: clamp(0.25rem, 0.25rem + 0vw, 0.25rem);
|
||||
--spacing-2: clamp(0.5rem, 0.5rem + 0vw, 0.5rem);
|
||||
--spacing-3: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
|
||||
--spacing-4: clamp(0.875rem, 0.7321rem + 0.2976vw, 1rem);
|
||||
--spacing-5: clamp(1rem, 0.7143rem + 0.5952vw, 1.25rem);
|
||||
--spacing-6: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
|
||||
--spacing-8: clamp(1.5rem, 0.9286rem + 1.1905vw, 2rem);
|
||||
--spacing-10: clamp(1.75rem, 0.8929rem + 1.7857vw, 2.5rem);
|
||||
--spacing-12: clamp(2rem, 0.8571rem + 2.381vw, 3rem);
|
||||
--spacing-16: clamp(2.5rem, 0.7857rem + 3.5714vw, 4rem);
|
||||
--spacing-20: clamp(3rem, 0.7143rem + 4.7619vw, 5rem);
|
||||
--spacing-24: clamp(3.5rem, 0.6429rem + 5.9524vw, 6rem);
|
||||
--spacing-30: clamp(4rem, 0rem + 8.3333vw, 7.5rem);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 250ms ease;
|
||||
--transition-slow: 400ms ease;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
:root {
|
||||
/* Layout — not part of Tailwind's numeric spacing scale, referenced via
|
||||
arbitrary-value syntax (e.g. px-[var(--layout-padding-x)]) */
|
||||
--layout-max-width: 75rem; /* 1200px */
|
||||
--layout-padding-x: clamp(2rem, -1.4286rem + 7.1429vw, 5rem);
|
||||
--layout-grid-gap: clamp(1rem, 0.4286rem + 1.1905vw, 1.5rem);
|
||||
--layout-grid-gap-lg: clamp(1.25rem, 0.3929rem + 1.7857vw, 2rem);
|
||||
|
||||
/* One-off fluid values that aren't part of a broader scale */
|
||||
--hero-avatar-size: clamp(2.25rem, 1.6071rem + 1.3393vw, 2.8125rem);
|
||||
/* Newsletter copy column — was a hard 38.25rem, which didn't leave enough
|
||||
room for the form column at the 768px tablet floor (only 656px of
|
||||
content width available for 612px column plus 56px gap), crushing the
|
||||
input/button/privacy text to near-zero width and overflowing the page.
|
||||
Scales down to 23rem at 768px instead. */
|
||||
--newsletter-copy-width: clamp(23rem, 5.5714rem + 36.3095vw, 38.25rem);
|
||||
|
||||
/* Divider section icons (arrow separators, sparkle) — previously hard
|
||||
rem values while the "Klarheit/Fokus/Entlastung" text next to them
|
||||
used the fluid text-h2 token, so the icons visually stayed still
|
||||
while the words scaled. Same fluid(minPx, maxPx) formula as the rest
|
||||
of this file, floor ~82.5% of the Desktop value per the styleguide's
|
||||
80-85% Mobile-ratio guidance. */
|
||||
--divider-arrow-w: clamp(2.125rem, 1.5536rem + 1.1905vw, 2.625rem);
|
||||
--divider-arrow-h: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
|
||||
--divider-sparkle-w: clamp(1.5625rem, 1.1554rem + 0.8482vw, 1.91875rem);
|
||||
--divider-sparkle-h: clamp(2.0625rem, 1.5554rem + 1.0565vw, 2.50625rem);
|
||||
--divider-sparkle-inner-w: clamp(1.4375rem, 1.09875rem + 0.706vw, 1.734rem);
|
||||
--divider-sparkle-inner-h: clamp(1.9375rem, 1.4375rem + 1.0417vw, 2.375rem);
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -17,7 +140,7 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-body);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
+20
-12
@@ -1,16 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Playfair_Display, Caveat, Lora } from "next/font/google";
|
||||
import { Inter, Playfair_Display, Caveat, Lora } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
@@ -32,8 +28,20 @@ const lora = Lora({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
metadataBase: new URL("https://einfach-produktiv.mk360.de"),
|
||||
title: {
|
||||
default: "einfach produktiv. – Werkzeuge und Impulse für einen leichteren Alltag",
|
||||
template: "%s | einfach produktiv.",
|
||||
},
|
||||
description: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
|
||||
openGraph: {
|
||||
siteName: "einfach produktiv.",
|
||||
locale: "de_DE",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -43,8 +51,8 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased`}
|
||||
lang="de"
|
||||
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Navbar />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const CART_KEY = "ep_cart";
|
||||
const CART_EVENT = "ep-cart-updated";
|
||||
|
||||
type CartItem = { id: string; qty: number };
|
||||
|
||||
function readCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CART_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeCart(items: CartItem[]) {
|
||||
window.localStorage.setItem(CART_KEY, JSON.stringify(items));
|
||||
window.dispatchEvent(new Event(CART_EVENT));
|
||||
}
|
||||
|
||||
export function getCartCount(): number {
|
||||
return readCart().reduce((sum, item) => sum + item.qty, 0);
|
||||
}
|
||||
|
||||
export function addToCart(id: string, qty = 1) {
|
||||
const items = readCart();
|
||||
const existing = items.find((i) => i.id === id);
|
||||
if (existing) existing.qty += qty;
|
||||
else items.push({ id, qty });
|
||||
writeCart(items);
|
||||
}
|
||||
|
||||
// Reactive cart item count — 0 until the client hydrates and reads
|
||||
// localStorage, then stays in sync across tabs ("storage") and same-tab
|
||||
// updates (the CART_EVENT dispatched by addToCart, which "storage" alone
|
||||
// doesn't fire for the tab that made the change).
|
||||
export function useCartCount(): number {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCount(getCartCount());
|
||||
const onUpdate = () => setCount(getCartCount());
|
||||
window.addEventListener(CART_EVENT, onUpdate);
|
||||
window.addEventListener("storage", onUpdate);
|
||||
return () => {
|
||||
window.removeEventListener(CART_EVENT, onUpdate);
|
||||
window.removeEventListener("storage", onUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const MIN_VW = 768;
|
||||
const MAX_VW = 1440;
|
||||
|
||||
/**
|
||||
* Fluid clamp() between two px values, scaling with viewport width from
|
||||
* `minVw` to `maxVw`. Below `minVw` the clamp() floor freezes the value —
|
||||
* that's the "Tablet-Floor" behavior baked into the formula itself, no
|
||||
* separate mobile media query needed for sizing.
|
||||
*/
|
||||
export function fluid(minPx: number, maxPx: number, minVw = MIN_VW, maxVw = MAX_VW): string {
|
||||
const slope = (maxPx - minPx) / (maxVw - minVw);
|
||||
const yIntercept = minPx - slope * minVw;
|
||||
const minRem = minPx / 16;
|
||||
const maxRem = maxPx / 16;
|
||||
const yRem = yIntercept / 16;
|
||||
const slopeVw = slope * 100;
|
||||
return `clamp(${minRem}rem, ${yRem}rem + ${slopeVw}vw, ${maxRem}rem)`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Hero } from "./components/Hero";
|
||||
import { Divider } from "./components/Divider";
|
||||
import { Tools } from "./components/Tools";
|
||||
@@ -6,6 +7,29 @@ import { About } from "./components/About";
|
||||
import { Newsletter } from "./components/Newsletter";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
const title = "einfach produktiv. – Produktivität darf sich leicht anfühlen";
|
||||
const description =
|
||||
"Werkzeuge, Impulse und die 7-Tage-Challenge für Menschen mit Familie, Verantwortung und wenig Zeit. Mehr Klarheit und Fokus im Alltag – einfach produktiv.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/",
|
||||
images: ["/hero.png"],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images: ["/hero.png"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { addToCart } from "../../lib/cart";
|
||||
|
||||
/**
|
||||
* Shared by the Hero's "ToDo-Karten bestellen" and the pricing panel's "In
|
||||
* den Warenkorb" — both are real add-to-cart actions per app/lib/cart.ts,
|
||||
* not just decorative links, and both go to /cart per the click-through
|
||||
* convention already established for page-todo-karten's two CTAs.
|
||||
*/
|
||||
export function AddToCartButton({
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href="/cart"
|
||||
onClick={() => addToCart("todo-karten")}
|
||||
className={
|
||||
className ??
|
||||
"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 whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
const bullets = [
|
||||
"Set mit 50 hochwertigen ToDo-Karten",
|
||||
"Viel Platz für Gedanken, Aufgaben und Notizen",
|
||||
"Passt in jede Tasche – überall einsetzbar",
|
||||
"Nachhaltig produziert in Deutschland",
|
||||
"Inklusive Mini-Anleitung mit Tipps für den Start",
|
||||
];
|
||||
|
||||
export function Focus() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="group relative w-full lg:w-[42%] lg:shrink-0 aspect-[580/328] rounded-md overflow-hidden">
|
||||
<Image
|
||||
src="/focus-todo-karten.png"
|
||||
alt="Notizbuch mit ToDo-Karten in natürlichem Licht"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 42vw, 100vw"
|
||||
className="object-cover 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)" }}
|
||||
>
|
||||
Für mehr Fokus im Alltag
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
ToDo-Karten helfen dir, den Überblick zu behalten, Entscheidungen zu vereinfachen und deinen Tag bewusst zu gestalten.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{bullets.map((b) => (
|
||||
<li key={b} className="flex gap-[0.625rem] items-center w-full">
|
||||
<img alt="" src="/icon-check.svg" className="size-5 shrink-0" />
|
||||
<span className="flex-1 font-semibold text-body text-text-primary">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Fragment } from "react";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: "/icon-step-1.png",
|
||||
title: "1. Aufschreiben",
|
||||
desc: "Notiere deine Aufgaben und alles, was dir im Kopf herumgeht.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-step-2.png",
|
||||
title: "2. Priorisieren",
|
||||
desc: "Wähle deine 3 wichtigsten Aufgaben für heute aus und schreibe sie auf.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-step-3.png",
|
||||
title: "3. Umsetzen",
|
||||
desc: "Konzentriere dich auf das Wesentliche und hake ab, was wirklich zählt.",
|
||||
},
|
||||
];
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 md: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 funktioniert es
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Ein paar Minuten am Morgen – mehr Klarheit den ganzen Tag.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Arrows always visible (see Divider lesson — hiding them below a
|
||||
breakpoint isn't the fix), just rotated 90° to point down while
|
||||
stacked below md:. lg:px-[16.25rem] mirrors Figma's Desktop-only
|
||||
px-[260px] inset — that value doesn't get its own fluid token
|
||||
since it's a pure aesthetic narrowing, not something that breaks
|
||||
if it's just absent below lg:. RevealGroup/RevealItem stagger the
|
||||
3 steps in on scroll, same pattern as Tools.tsx's card grid —
|
||||
arrows aren't part of the stagger (purely decorative connectors,
|
||||
not content), they just sit in the DOM between items. */}
|
||||
<RevealGroup className="flex flex-col md:flex-row gap-8 items-center md: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 md:max-w-none">
|
||||
{/* Plain <img>, not next/image — one of the three source PNGs
|
||||
isn't square (icon-step-2 is 68.6x64), and next/image's
|
||||
required width/height props fought with Tailwind's
|
||||
`img { height: auto }` preflight reset (console warning:
|
||||
"width or height modified, but not the other"). A fixed
|
||||
h-16 + w-auto lets each icon keep its own real aspect
|
||||
ratio without that conflict — matches how every other
|
||||
small decorative icon in this codebase (Tools.tsx etc.)
|
||||
is already a plain <img>, not next/image. group-hover
|
||||
scale is a small "step acknowledges you" touch — the
|
||||
whole text column is the hover target, not just the icon,
|
||||
so it's easy to trigger without precise pointing. */}
|
||||
<img
|
||||
src={step.icon}
|
||||
alt=""
|
||||
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">
|
||||
<img
|
||||
alt=""
|
||||
src="/icon-arrow-connector.svg"
|
||||
className="w-6 h-6 rotate-90 md:w-10 md:h-3 md:rotate-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
const bullets = [
|
||||
"50 ToDo-Karten",
|
||||
"Hochwertiges Papier (120 g/m²)",
|
||||
"Format: 105 x 148 mm (A6)",
|
||||
"Inklusive Mini-Anleitung",
|
||||
"Nachhaltig produziert in Deutschland",
|
||||
];
|
||||
|
||||
export function Pricing() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
|
||||
{/* Three flex children (photo, info, price/CTA) competing for space
|
||||
— same "big image next to text" shape as the Hero/Focus, so the
|
||||
same lg:-only structural exception applies here too. */}
|
||||
<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-todo-karten.png"
|
||||
alt="ToDo-Karten Set"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 410px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</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)" }}
|
||||
>
|
||||
ToDo-Karten – Set
|
||||
</p>
|
||||
<ul className="flex flex-col gap-[0.375rem] items-start">
|
||||
{bullets.map((b) => (
|
||||
<li key={b} className="flex gap-2 items-center">
|
||||
<img alt="" src="/icon-bullet-dot.svg" 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">
|
||||
<div className="flex gap-2 items-center">
|
||||
<p className="font-bold text-h3 text-text-primary">12,90 €</p>
|
||||
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
<div className="flex gap-[0.375rem] items-center">
|
||||
<img alt="" src="/icon-lock.svg" className="size-3.5" />
|
||||
<span className="text-body-sm text-text-primary">Sichere Zahlung</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
"„Die ToDo-Karten sind mein täglicher Begleiter. Endlich mehr Fokus auf das, was wirklich wichtig ist.“",
|
||||
name: "Sarah M.",
|
||||
role: "Marketing Managerin",
|
||||
avatar: "/avatar-todo-1.png",
|
||||
},
|
||||
{
|
||||
quote: "„Ein simples Konzept, das mein Arbeiten komplett verändert hat. Klare Empfehlung!“",
|
||||
name: "Thomas K.",
|
||||
role: "Selbstständiger Berater",
|
||||
avatar: "/avatar-todo-2.png",
|
||||
},
|
||||
{
|
||||
quote: "„Weniger Chaos im Kopf, mehr Struktur im Alltag. Die Karten helfen mir jeden Tag.“",
|
||||
name: "Miriam L.",
|
||||
role: "Projektleiterin",
|
||||
avatar: "/avatar-todo-3.png",
|
||||
},
|
||||
];
|
||||
|
||||
// Card layout/style matches app/challenge/page.tsx's testimonial section
|
||||
// exactly (bg-muted filled card, no border, no decorative quote-mark,
|
||||
// quote on top with flex-1 pushing the avatar/name row to the bottom) —
|
||||
// including its hover-lift + avatar-scale micro-interactions, kept in
|
||||
// sync deliberately since both sections are meant to look and behave
|
||||
// identically. Only the photos differ (this page's own Figma-sourced
|
||||
// avatars, not Challenge's shared avatar-1/2/3.jpg).
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal
|
||||
className="font-semibold text-h-emphasis text-text-primary text-center"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
|
||||
{testimonials.map((t) => (
|
||||
<RevealItem
|
||||
key={t.name}
|
||||
className="group md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<p className="flex-1 text-body-sm text-text-primary leading-[1.6]">{t.quote}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden transition-transform duration-300 group-hover:scale-110">
|
||||
<Image src={t.avatar} alt={t.name} fill sizes="40px" className="object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-body-sm text-text-primary">{t.name}</p>
|
||||
<p className="text-body-sm text-text-muted">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
const checklist = [
|
||||
"Klarer Fokus auf das, was wirklich zählt",
|
||||
"Flexibel einsetzbar – jeden Tag",
|
||||
"Minimalistisch, analog, effektiv",
|
||||
];
|
||||
|
||||
export function TodoKartenHero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Same lg:-only structural exception as the Home Hero (see
|
||||
figma-to-nextjs skill Gotcha #5) — a wide fixed-ratio photo next
|
||||
to a text column is exactly the shape that gets too cramped at
|
||||
Tablet widths under the site-wide md: convention. */}
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 lg:items-center gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12 lg:pt-0">
|
||||
<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">
|
||||
{/* Breadcrumb */}
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">ToDo-Karten</span>
|
||||
</p>
|
||||
|
||||
{/* Heading — 48px Desktop doesn't match any existing type-scale
|
||||
step, uses the new --text-h-page token (see globals.css). */}
|
||||
<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)" }}
|
||||
>
|
||||
ToDo-Karten
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Kleine Karten. Große Wirkung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-body text-text-body">
|
||||
Ein einfaches Werkzeug, das dir hilft, deinen Kopf frei zu bekommen und das Wesentliche zu sehen.
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{checklist.map((item) => (
|
||||
<li key={item} className="flex gap-[0.625rem] items-center w-full">
|
||||
<img alt="" src="/icon-check.svg" className="size-5 shrink-0" />
|
||||
<span className="flex-1 text-body text-text-primary">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<AddToCartButton label="ToDo-Karten bestellen" />
|
||||
</Reveal>
|
||||
|
||||
{/* group + subtle scale-on-hover — a common, restrained "photo
|
||||
feels alive" micro-interaction; overflow-hidden on the wrapper
|
||||
(not the image) keeps the zoom from spilling past the rounded
|
||||
corners. delay={0.15} matches the Home Hero's text→image
|
||||
stagger — both fire ~immediately since Hero is already in the
|
||||
initial viewport, so this doubles as the page's entrance. */}
|
||||
<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="/hero-todo-karten.png"
|
||||
alt="ToDo-Karten in Anwendung, mit Notizbuch und Kaffee"
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TodoKartenHero } from "./components/TodoKartenHero";
|
||||
import { HowItWorks } from "./components/HowItWorks";
|
||||
import { Focus } from "./components/Focus";
|
||||
import { Testimonials } from "./components/Testimonials";
|
||||
import { Pricing } from "./components/Pricing";
|
||||
import { Footer } from "../components/Footer";
|
||||
|
||||
const title = "ToDo-Karten – Kleine Karten. Große Wirkung.";
|
||||
const description =
|
||||
"Ein einfaches Werkzeug, das dir hilft, deinen Kopf frei zu bekommen und das Wesentliche zu sehen. 50 hochwertige ToDo-Karten für mehr Fokus im Alltag.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/todo-cards",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/todo-cards",
|
||||
images: ["/hero-todo-karten.png"],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images: ["/hero-todo-karten.png"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function TodoCardsPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1">
|
||||
<TodoKartenHero />
|
||||
<HowItWorks />
|
||||
<Focus />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user